diff --git a/.gitignore b/.gitignore index f536e90..6144da5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ google-services.json # Xuất tài liệu (tools/docs/build_design_html.py) /build/ +# Log cua tools/verify-phase7.sh +/.build-logs/ + # Kho ".github" của org (hồ sơ, file cộng đồng mặc định) — clone vào đây bằng tools/workspace.sh clone /.github-org/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb956a..b558ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,96 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.2.0] — 2026-10-30 + +### Added + +- **Offline connectivity (Phase 7):** when both devices are near each other without a network, they connect over Bluetooth + L2CAP. The control channel (clipboard text, SMS and call notifications) carries message frames over a link-security layer + (Noise-style handshake keyed by the shared pairing secret). No relay deployment in this release; scenario Y (phone on + mobile data, Mac on another Wi-Fi) also uses Bluetooth when the two are near. First pairing over Bluetooth: QR scan brings + up the credential, then the phone advertises a proof and waits for the Mac to establish the link, then the user can + confirm the pairing value or enter a PIN over Bluetooth through CPace (constant-time password-authenticated key exchange, + X25519, SHA-512). + +- Bluetooth advertisement with a 128-bit service UUID and rotating hint (0.4.5 `BT_SERVICE_UUID`, `BT_HINT_PERIOD`, + service data format frozen by test vectors in `shared/test-vectors/`); link-security handshake (Noise prologue, + `challenge`, `noise1`, `noise2`) with frame codec (channel IDs, `more` flag, short and long control lanes, record + length bounds); Bluetooth admission policy (link cap, pre-auth connection cap, authentication failure block) on both + platforms (A-SVC advertiser on Android, CoreBluetooth central and peripheral on macOS). + +- CPace password-authenticated key exchange for offline PIN pairing over Bluetooth (SP2, `CPACE-X25519-SHA512`): the shared + secret is derived from the PIN at both ends without a third party; online guessing is limited to one attempt per channel, + with a global per-PIN counter (three failures per PIN, then lockout); the spec and test vectors (cpace.json, Android + Kotlin and macOS Swift implementations with constant-time arithmetic in `core/crypto` or `HLCrypto`). + +- **State machine update (0.11):** the Mac's connection manager now starts connecting when Bluetooth is authorized and on, + even without a network (new condition in the state machine); network loss ends LAN or relay sessions only, not Bluetooth + (so scenario X stays connected). Routes are prioritized: LAN > Bluetooth (on the Mac), and all pre-auth errors (PAIR_UNKNOWN, + PAIR_REVOKED, UNSUPPORTED_VERSION, AUTH_FAILED) are checked for TLS certification (`phoneAuthenticatedByTLS`), so only + the pinned-TLS LAN route can unpair, remove pairs, or stop reconnecting (AC8). + +- **Pre-auth admission and Bluetooth bans (0.4.5, 0.5):** every pre-auth frame on Bluetooth is checked against a device-global + nonce challenge (issued once per connection attempt) and a link-key confirmation after the handshake. Failed confirmations + increment a counter; after `BT_AUTH_FAIL_BLOCK` consecutive failures, the pre-auth channel is blocked for `BT_AUTH_FAIL_TIMEOUT`, + preventing slot hogging and replay attacks. + +- **Bluetooth prompts (AC7):** Android 12+ — decision logic for the one-time "Nearby devices" prompt (SET-01) and a + re-ask interval for enabling Bluetooth when there is no LAN; macOS — the system Bluetooth permission dialog + (`NSBluetoothAlwaysUsageDescription`) before scanning starts (SET-03). iOS prompts inherited from the existing Mac + integration. + +- **Hub and shared assets for Phase 7:** detailed design spec updates (00-common-specs.md, 01-setup-settings.md, 02-pairing.md, + 03-connectivity.md, 04-clipboard.md, all en and vi); test vectors for link-security, frame codec, advertisement and CPace; + new UI strings for the "Connected via Bluetooth" label and the Mac Bluetooth permission prompt text. + +### Changed + +- Device label in the connection status: "Connected via Bluetooth" appears on both Android and Mac when the active route is + Bluetooth (`core/design`'s `HLConnectionStatus` + `ui/devices` for Android, `HLMacUI`/`HLiOSUI` for Apple). + +- Android foreground service `connectedDevice` (A-SVC): now advertises the Bluetooth service UUID and rotates the hint with the + shared secret, independent of the LAN server; the existing `ControlServer.serveRelayPeer` pattern is extended to `serveBluetoothPeer` + for `SessionTransport.BLUETOOTH`. + +- macOS and iOS connection manager: `ConnectionManager` now routes to `.bluetooth` alongside `.relay`, with upgrades from Bluetooth + to LAN when a network appears; `ControlSession` enforces the pre-auth rule `phoneAuthenticatedByTLS` (originally only checking + `route == .relay`). + +### Fixed + +- ~~Relay routes kept in the code but not deployed; the `relay` constant and existing relay code paths are left untouched~~ (no + changes here; relay is intact for future gates G4, G5, G6 or if scenario Y demands it in a later phase). + +### Security + +- Offline pairing (PAIR-01 flow B and C): no pre-shared public key (QR-encoded `pr`) is required; the proof is an HMAC of the + pairing secret, and every pairing frame on a secured Bluetooth link is MAC'd with the link key before a slot is granted (D7.1). + PIN guessing is limited by the per-PIN counter (3 attempts, then lockout), and the CPace exchange runs at each attempt with the + PIN as the shared secret, so no stable state is exposed to a passive sniffer (AC9, `K_bt_pin` never leaves the channel). + +- Bluetooth link-security (D7.1 0.4.5): an unbonded L2CAP CoC link is encrypted with a Noise-style handshake keyed by the shared + pairing secret (`K_bt_disc` and `K_bt_link`, derived once per link and rotated at an interval); link-layer encryption is + assumed to be absent (no bond), so frame-level encryption is mandatory. The prologue is bound to the peer's rotating hint and + the sender's discovery key, so a replayed or cloned advertiser cannot form a valid handshake (AC9). + +- Pre-auth errors (CONN-01 E4, CONN-03 E9): forged PAIR_UNKNOWN, PAIR_REVOKED, UNSUPPORTED_VERSION or AUTH_FAILED on Bluetooth + cannot unpair, remove a pair, or stop reconnecting — only the pinned-TLS LAN route is exempt from this rule (AC8). + +### Specs + +Designed in the Phase 7 detailed-design update (00-common-specs.md 0.4.6/0.4.7, 01-setup-settings.md) but not implemented +on either platform yet — tracked for a later release: + +- **Wi-Fi Direct** (A7.5 / M7.5): randomly-generated SSID and passphrase per group, the Mac socket bound to the P2P + interface only, and the Android 10–12 Location prompt it needs. No Android or Apple code exists for this today. +- **Per-connection bulk stream keys** (`K_bulk` and nonces, A7.4 / M7.4): a bulk transfer (images via CLIP-03) with its own + handshake and encryption keys per stream. Android's `core/transport` reserves channel `0x02` and the chunk/priority + scheduler for this lane, but nothing encrypts or sends bulk content through it yet; there is no Apple code at all. +- **Bluetooth status UI beyond the connected label:** the Mac "Bluetooth off" / "Bluetooth denied" status lines, their + settings button, the Bluetooth permission-primer screens and the Settings feature-card label all exist as strings in + `handlive-shared/strings/ui-strings.json`, but no Android or Apple view calls them yet. + ## [0.1.1] — 2026-10-22 ### Added diff --git a/CHANGELOG.vi.md b/CHANGELOG.vi.md index 25e5c08..a60ad29 100644 --- a/CHANGELOG.vi.md +++ b/CHANGELOG.vi.md @@ -6,6 +6,58 @@ Các thay đổi đáng chú ý của kho hub HandLive (tài liệu và kế ho ## [Unreleased] +## [0.2.0] — 30/10/2026 + +### Thêm mới + +- **Kết nối khi không mạng (Phase 7):** khi cả hai thiết bị ở gần nhau mà không có mạng, chúng tự kết nối qua BLE L2CAP. Kênh điều khiển (sao chép bảng nhớ tạm, SMS và thông báo cuộc gọi) chở frame thông điệp qua một lớp bảo mật link (bắt tay Noise-style dùng secret chia sẻ từ ghép đôi). Phiên bản này không deploy relay; kịch bản Y (điện thoại dùng dữ liệu di động, Mac ở Wi-Fi khác) cũng dùng Bluetooth khi hai máy gần nhau. Ghép đôi lần đầu qua Bluetooth: quét QR mang thông tin xác thực, rồi điện thoại quảng bá bằng chứng và chờ Mac nối liên kết, rồi người dùng có thể xác nhận giá trị ghép đôi hoặc nhập PIN qua Bluetooth bằng CPace (trao đổi khóa xác thực mật khẩu hằng số, X25519, SHA-512). + +- Quảng bá Bluetooth với UUID dịch vụ 128-bit và hint xoay vòng (0.4.5 `BT_SERVICE_UUID`, `BT_HINT_PERIOD`, định dạng dữ liệu dịch vụ chốt bằng vector kiểm tra trong `shared/test-vectors/`); bắt tay bảo mật link (Noise prologue, `challenge`, `noise1`, `noise2`) với codec frame (ID kênh, cờ `more`, kênh điều khiển ngắn và dài, giới hạn độ dài bản ghi); chính sách admission Bluetooth (giới hạn link, giới hạn kết nối pre-auth, khóa lỗi xác thực) trên cả hai nền tảng (advertiser A-SVC trên Android, central và peripheral CoreBluetooth trên macOS). + +- CPace trao đổi khóa xác thực mật khẩu cho ghép đôi PIN offline qua Bluetooth (SP2, `CPACE-X25519-SHA512`): secret chia sẻ suy dẫn từ PIN ở cả hai đầu mà không cần bên thứ ba; phỏng đoán online chỉ được một lần mỗi kênh, với bộ đếm toàn cầu mỗi PIN (ba lần thất bại mỗi PIN, rồi khóa); spec và vector kiểm tra (cpace.json, cài đặt Kotlin Android và Swift macOS với số học hằng số trong `core/crypto` hoặc `HLCrypto`). + +- **Cập nhật máy trạng thái (0.11):** trình quản lý kết nối của Mac giờ bắt đầu kết nối khi Bluetooth được phép và bật, thậm chí khi không có mạng (điều kiện mới trong máy trạng thái); mất mạng chỉ kết thúc phiên LAN hoặc relay, không phải Bluetooth (nên kịch bản X vẫn kết nối). Route được ưu tiên: LAN > Bluetooth (trên Mac), và mọi lỗi pre-auth (PAIR_UNKNOWN, PAIR_REVOKED, UNSUPPORTED_VERSION, AUTH_FAILED) được kiểm tra chứng chỉ TLS (`phoneAuthenticatedByTLS`), nên chỉ route LAN có TLS ghim mới có thể hủy ghép đôi, gỡ cặp, hay dừng nối lại (AC8). + +- **Admission pre-auth và cấm Bluetooth (0.4.5, 0.5):** mọi frame pre-auth trên Bluetooth được kiểm tra với nonce challenge toàn cầu thiết bị (cấp một lần mỗi lần thử kết nối) và xác nhận khóa link sau bắt tay. Xác nhận thất bại tăng bộ đếm; sau `BT_AUTH_FAIL_BLOCK` lần thất bại liên tiếp, kênh pre-auth bị khóa trong `BT_AUTH_FAIL_TIMEOUT`, ngăn slot hogging và tấn công replay. + +- **Prompt Bluetooth (AC7):** Android 12+ — logic quyết định cho prompt "Thiết bị gần đó" một lần (SET-01) và chu kỳ hỏi lại bật Bluetooth khi không có LAN; macOS — hộp thoại quyền Bluetooth của hệ thống (`NSBluetoothAlwaysUsageDescription`) trước khi quét (SET-03). Prompt iOS thừa kế từ tích hợp Mac hiện tại. + +- **Tài sản hub và shared cho Phase 7:** cập nhật spec thiết kế chi tiết (00-common-specs.md, 01-setup-settings.md, 02-pairing.md, 03-connectivity.md, 04-clipboard.md, cả en và vi); vector kiểm tra cho bảo mật link, codec frame, quảng bá và CPace; chuỗi UI mới cho nhãn "Đã kết nối qua Bluetooth" và chữ prompt quyền Bluetooth trên Mac. + +### Thay đổi + +- Nhãn thiết bị trong trạng thái kết nối: "Đã kết nối qua Bluetooth" hiện trên cả Android và Mac khi route hoạt động là Bluetooth (`HLConnectionStatus` trong `core/design` + `ui/devices` cho Android, `HLMacUI`/`HLiOSUI` cho Apple). + +- Dịch vụ foreground Android `connectedDevice` (A-SVC): giờ quảng bá UUID dịch vụ Bluetooth và xoay vòng hint với secret chia sẻ, độc lập với server LAN; mẫu `ControlServer.serveRelayPeer` hiện có được mở rộng sang `serveBluetoothPeer` cho `SessionTransport.BLUETOOTH`. + +- Trình quản lý kết nối macOS và iOS: `ConnectionManager` giờ định tuyến sang `.bluetooth` bên cạnh `.relay`, với nâng cấp từ Bluetooth lên LAN khi mạng xuất hiện; `ControlSession` thực thi quy tắc pre-auth `phoneAuthenticatedByTLS` (ban đầu chỉ kiểm tra `route == .relay`). + +### Fixed + +- ~~Route relay giữ trong code nhưng không deploy; constant `relay` và route code relay hiện tại giữ nguyên~~ (không có thay đổi ở đây; relay toàn vẹn cho gate G4, G5, G6 sau hoặc nếu kịch bản Y đòi hỏi trong phase sau). + +### Security + +- Ghép đôi offline (PAIR-01 luồng B và C): không cần khóa công khai được chia sẻ trước (mã hóa `pr` trong QR); bằng chứng là HMAC của secret ghép đôi, và mọi frame ghép đôi trên liên kết Bluetooth bảo mật được MAC với khóa link trước khi cấp slot (D7.1). Phỏng đoán PIN bị giới hạn bằng bộ đếm mỗi PIN (3 lần, rồi khóa), và trao đổi CPace chạy ở mỗi lần thử với PIN là secret chia sẻ, nên không có state ổn định nào lộ ra cho sniffer bị động (AC9, `K_bt_pin` không bao giờ rời kênh). + +- Bảo mật link Bluetooth (D7.1 0.4.5): liên kết L2CAP CoC chưa bond được mã hóa bằng bắt tay Noise-style khóa từ secret ghép đôi chia sẻ (`K_bt_disc` và `K_bt_link`, suy dẫn một lần mỗi link và xoay vòng ở khoảng thời gian); mã hóa tầng link giả định vắng mặt (không bond), nên mã hóa tầng frame là bắt buộc. Prologue được ràng buộc với hint xoay vòng của peer và khóa khám phá của người gửi, nên advertiser bị replay hoặc nhái không thể hình thành bắt tay hợp lệ (AC9). + +- Lỗi pre-auth (CONN-01 E4, CONN-03 E9): PAIR_UNKNOWN, PAIR_REVOKED, UNSUPPORTED_VERSION hoặc AUTH_FAILED giả trên Bluetooth không thể hủy ghép đôi, gỡ cặp, hay dừng nối lại — chỉ route LAN có TLS ghim được miễn trừ quy tắc này (AC8). + +### Specs + +Đã thiết kế trong bản cập nhật spec Phase 7 (00-common-specs.md 0.4.6/0.4.7, 01-setup-settings.md) nhưng chưa code trên +nền tảng nào — để lại cho bản phát hành sau: + +- **Wi-Fi Direct** (A7.5 / M7.5): SSID và passphrase sinh ngẫu nhiên mỗi nhóm, socket Mac ràng buộc chỉ với interface + P2P, và prompt Location Android 10–12 mà nó cần. Chưa có code Android hay Apple nào cho phần này. +- **Khóa stream bulk trên mỗi kết nối** (`K_bulk` và nonce, A7.4 / M7.4): truyền bulk (ảnh qua CLIP-03) với bắt tay và + khóa mã hóa riêng cho mỗi stream. `core/transport` của Android đã dành sẵn kênh `0x02` và bộ lập lịch chunk/ưu tiên + cho làn này, nhưng chưa có gì mã hóa hay gửi nội dung bulk qua đó; bên Apple chưa có code nào. +- **UI Bluetooth ngoài nhãn đã kết nối:** dòng trạng thái "Bluetooth tắt" / "Bluetooth bị từ chối" trên Mac, nút cài + đặt của nó, màn hình prompt quyền Bluetooth và nhãn feature-card trong Settings đều đã có chuỗi trong + `handlive-shared/strings/ui-strings.json`, nhưng chưa màn hình Android hay Apple nào gọi tới. + ## [0.1.1] — 22/10/2026 ### Thêm mới diff --git a/CLAUDE.md b/CLAUDE.md index ab2e34b..2cb5308 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project status: Phase 0–3 on `main`; Phase 5–6 spikes on `main`; latest release `v0.1.1` (2026-10-22); G1/G2/G5/G6 open; G4 closed (Phase 4 stopped, owner decision 2026-10-12) +## Project status: Phase 0–3 on `main`; Phase 5–6 spikes on `main`; Phase 7 (Bluetooth offline connectivity) started 2026-10-28, BLE L2CAP pairing/control channel on `main`, release `v0.2.0` pending the owner's tag (Wi-Fi Direct and relay bulk dropped from this phase's scope); latest tagged release `v0.1.1` (2026-10-22); G1/G2/G5/G6 open; G4 closed (Phase 4 stopped, owner decision 2026-10-12) Phase 0–3 product code is on `main` (clipboard, SMS, iOS shell, Rust relay, call metadata/control). Phase 4–6 **spike probes** are also on `main` (merged 2026-09-30): `HFPSpike`, `CameraSpike`, Android `tools/web-spike`, @@ -61,7 +61,7 @@ project owner in Vietnamese with diacritics. **Commit identity:** always `Hồ Xuân Dũng ` (GitHub `xuandung38`). Never `dunghx1@viettel.com.vn` or any Viettel email in author/committer/`Signed-off-by`. Use `env -u CURSOR_AGENT git commit` so Cursor does not inject Co-authored-by trailers. -**Workspace tips to treat as current `main`** (tag `v0.1.1`, 2026-10-22): shared `56b4c93`, relay `17f367b`, android `8e4022a`, apple `098996c`, hub at the `v0.1.1` tag. Confirm with `tools/workspace.sh status` before coding. **Local JDK:** Gradle needs `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home` in a non-login shell, else `gradlew` prints "Unable to locate a Java Runtime" and a piped command still exits 0. +**Workspace tips to treat as current `main`** (ahead of tag `v0.1.1`; release `v0.2.0` pending the owner's tag, Phase 7 Bluetooth offline connectivity): shared `5cbe4aa`, relay `17f367b` (unchanged since `v0.1.1`), android `ed8e41f`, apple `7d3ce0b`, hub `main` tip. Confirm with `tools/workspace.sh status` before coding. **Local JDK:** Gradle needs `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home` in a non-login shell, else `gradlew` prints "Unable to locate a Java Runtime" and a piped command still exits 0. ### In progress / blocked (owner or gate) diff --git a/README.md b/README.md index cec8211..4755f53 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Phases ship in order, each as a usable slice. One exception (owner, 2026-09-28): | **4** Call audio | Bluetooth HFP/SCO and Opus fallback | **Stopped** (owner decision, 2026-10-12) · G4 closed: only macOS 27 is available, so calls stay notification + answer/decline/end, no audio | | **5** Virtual camera / mic | CMIOExtension and AudioServerPlugin | **Spike on `main`** (`CameraSpike`) · G5 needs a paid Apple Developer team | | **6** Continue Browsing | Foreground URL handover across devices | **Spike on `main`** · Chrome and Samsung Internet measured; browser go/no-go pending | -| **7** Connect anywhere | Auto link without a shared Wi-Fi network | **Proposed** (2026-10-01) · starts after G5 and G6 (G4 closed 2026-10-12) with spike G7 | +| **7** Connect anywhere | Auto link without a shared Wi-Fi network | **Started** (owner decision, 2026-10-28), no wait on G4/G5/G6 · spike G7 done; Bluetooth L2CAP pairing and control channel on `main`, Wi-Fi Direct and relay bulk dropped from this phase's scope | | **G0** Vectors | Shared cross-platform crypto test vectors | **Done** | | **G1** Real devices | Real-device Phase 1 matrix validation | **Open** (before 1.0) · App Store Connect screenshots recaptured at native 1320×2868 (2026-10-12) | | **G2** Play Console | SMS and call log permissions declaration | **Open** (before 1.0; Plan B: F-Droid/APK) · Play Console screenshots recaptured at 1080×1920 (2026-10-12) | diff --git a/README.vi.md b/README.vi.md index 9106ddf..0b74981 100644 --- a/README.vi.md +++ b/README.vi.md @@ -67,7 +67,7 @@ HandLive xây lần lượt. Mỗi phase là một phần dùng được. Ngoạ | **4** Âm thanh cuộc gọi | Bluetooth HFP/SCO và dự phòng Opus | **Dừng** (quyết định của chủ dự án, 12/10/2026) · G4 đóng: chỉ có macOS 27, cuộc gọi chỉ còn thông báo và nút nghe/từ chối/kết thúc, không âm thanh | | **5** Camera / mic ảo | CMIOExtension và AudioServerPlugin | **Spike trên `main`** (`CameraSpike`) · G5 cần Apple Developer trả phí | | **6** Duyệt web tiếp | Chuyển tiếp URL đang mở giữa các thiết bị | **Spike trên `main`** · đã đo Chrome và Samsung Internet; còn go/no-go trình duyệt | -| **7** Kết nối mọi nơi | Tự kết nối khi không chung mạng Wi-Fi | **Đề xuất** (01/10/2026) · bắt đầu sau G5 và G6 (G4 đã đóng 12/10/2026) bằng spike G7 | +| **7** Kết nối mọi nơi | Tự kết nối khi không chung mạng Wi-Fi | **Đã bắt đầu** (quyết định chủ dự án, 28/10/2026), không chờ G4/G5/G6 · spike G7 xong; ghép đôi và kênh điều khiển qua Bluetooth L2CAP đã lên `main`, Wi-Fi Direct và bulk qua relay bị cắt khỏi phạm vi phase này | | **G0** Vector mã hóa | Vector kiểm thử mã hóa dùng chung giữa các nền tảng | **Xong** | | **G1** Máy thật | Kiểm thực tế ma trận máy thật Phase 1 | **Mở** (trước 1.0) · ảnh App Store Connect đã chụp lại ở kích thước gốc 1320×2868 (12/10/2026) | | **G2** Play Console | Tờ khai quyền SMS và nhật ký cuộc gọi | **Mở** (trước 1.0; phương án B: F-Droid/APK) · ảnh Play Console đã chụp lại ở 1080×1920 (12/10/2026) | diff --git a/docs/detailed-design/00-common-specs.md b/docs/detailed-design/00-common-specs.md index 1c390ad..aa3b8c7 100644 --- a/docs/detailed-design/00-common-specs.md +++ b/docs/detailed-design/00-common-specs.md @@ -121,6 +121,7 @@ through TXT. | `/v1/ctl` | Control channel: every app envelope | Session handshake (0.6.3) | P1 | | `/v1/stream/call-audio` | Opus/WS call audio — fallback path (AUDIO-04) | Key derived from the ctl session | P4 | | `/v1/stream/camera` | Camera/microphone stream (separate channel) | Key derived from the ctl session | P5 | +| `/v1/stream/bulk` | Bulk transfers on the Wi-Fi Direct group only (0.4.7) | Per-connection keys derived from the ctl session | P7 | - Each pair has only **one** `/v1/ctl` connection at a time. A new connection that authenticates successfully replaces the old one (closed with code 4409). @@ -166,7 +167,7 @@ through TXT. of the destination one and forwards the HL frame unchanged. - The only exception is the pairing rendezvous (`rv_msg`, PAIR-01 API 7): there the relay reads `env.type` and decodes the unencrypted `pair` payload to block a `pair/hello` with - `mode = "pin"`, because PIN pairing is LAN-only. + `mode = "pin"`, because PIN pairing never goes through the relay (LAN, or Bluetooth with CPace). ### 0.4.4 Push (P2) @@ -176,6 +177,317 @@ through TXT. - **iOS (APNs):** `alert` push, `mutable-content: 1`, `apns-priority: 10`. The default displayed content is generic and is sent as a `loc-key` (a catalog key of the `push` group, 0.12.4) so the iPhone translates it itself; the `hl` field carries the envelope encrypted with `K_push` for I-NSE to decrypt and replace the content. `hl` is standard base64 with padding of the UTF-8 envelope JSON, the same string as `env_b64` of `POST /v1/push` (CONN-04 API 2), ≤ 3,000 characters of base64. Total payload ≤ 4 KB. - The Mac does not register for push; when it wakes up, M-APP reconnects and syncs from its cursors. +### 0.4.5 Bluetooth link: BLE L2CAP CoC (P7) + +The control channel when the phone and the Mac have no shared LAN (Phase 7, near each other). The +bearer was chosen by gate G7 ([spike report](../../plans/20260925-implementation/reports/phase-07-spike-g7.md), +"Kết luận bearer"). Mac only; iOS unchanged. Flow and exceptions: CONN-05. Offline first pairing over BLE: "Pairing mode" below and PAIR-01 flows B (QR) and C (PIN). Bulk lane (images): 0.4.7. Wi-Fi Direct: 0.4.6. + +- **Roles.** A-SVC is the peripheral: it advertises (below) and listens with + `BluetoothAdapter.listenUsingInsecureL2capChannel()` (dynamic LE PSM, no bond, no GATT server). M-APP is the + central: `CBCentralManager` scans, `CBPeripheral.openL2CAPChannel(_:)` opens the channel; neither side ever starts + OS pairing. An unbonded L2CAP CoC has no link-layer encryption (G7, "Brief items", item 5), so the link-security + layer below is mandatory: nothing else crosses the air before it completes. +- **Keys** (per pair, computed when needed, same storage rules as `K_push`): + `K_bt_disc` = HKDF(`PRK`, info = `"handlive/v1/bt-discovery"`); `K_bt_link` = HKDF(`PRK`, info = + `"handlive/v1/bt-link"`). Neither reuses `K_disc` or `K_auth`. +- **Pairing keys** (per QR, only in memory for the life of the QR, `PAIRING_WINDOW`): `K_bt_pdisc` = + HKDF-SHA256(ikm = `pairing_secret`, salt = empty, info = `"handlive/v1/bt-pair-disc"`, L = 32); `K_bt_plink` = + HKDF-SHA256(ikm = `pairing_secret`, salt = empty, info = `"handlive/v1/bt-pair-link"`, L = 32). The PIN has its own + Bluetooth keys, derived from a CPace exchange ("Pairing mode", PIN). + +#### Advertisement + +Legacy connectable advertising (`BluetoothLeAdvertiser.startAdvertisingSet`, legacy mode, interval `INTERVAL_LOW`, +TX power `TX_POWER_HIGH` — G7 probe settings), scannable, no device name, no TX power field. `BT_SERVICE_UUID` is a +self-generated 128-bit UUID. Since 128-bit UUID and service data do not fit in 31 bytes (G7 found +`ADVERTISE_FAILED_DATA_TOO_LARGE`, "Yêu cầu phát hiện cho sản phẩm", item 2), the advertisement carries the UUID and +scan response carries service data, each ≤ 31 bytes. + +| AD structure | Bytes | Content | +|---|---|---| +| Flags (advertisement) | 3 | Added by the stack for a connectable set | +| Complete list of 128-bit service UUIDs (advertisement) | 18 | `BT_SERVICE_UUID` (the Mac scans with this filter); advertisement total 21 | +| Service data, 128-bit UUID (scan response) | 18 + 3 + 4 × n | `BT_SERVICE_UUID` ‖ the service data below | +| **Scan response total** | ≤ 29 | n ≤ 2 (`BT_ADV_MAX_HINTS`) | + +Service data (after the UUID): + +| Offset | Length | Field | Description | +|---|---|---|---| +| 0 | 1 | `ver` | `0x01` | +| 1 | 2 | `psm` | The current L2CAP PSM, uint16 little-endian | +| 3 | 4 × n | `hints` | One hint per pair, n = 0…`BT_ADV_MAX_HINTS`; hint = the first 4 bytes of HMAC-SHA256(`K_bt_disc`, `"HLBTDISC1"` ‖ int64 BE `floor(now_ms / BT_HINT_PERIOD)`) | + +- A-SVC advertises while `link.bluetooth` is on, Bluetooth is on, Nearby devices is granted (Android 12+) and at + least one unrevoked pair has no live session on any route; the hints are those pairs', most recent `last_seen_at` + first, at most `BT_ADV_MAX_HINTS`. No such pair and no open QR pairing window → no advertising ("Pairing mode"). +- **Rotation:** at every `BT_HINT_PERIOD` boundary A-SVC stops the advertising set, closes the server socket + (existing links stay open), opens a new one (new PSM) and starts a new advertising set. Hint, PSM and advertising + set change together; restarting the set is the app's only lever on the radio address (T7.1 BLE sniff checks for + address change, AC9). +- The client compares the hints for the epochs from `e − 4` to `e + 4` (e = the current epoch: clock skew up to an + hour, as on the LAN). An advertisement with `ver` ≠ `0x01` or with no matching hint is ignored. + +#### Link records + +Every byte on the L2CAP channel belongs to a record: `rlen` (uint16 BE) ‖ `rlen` bytes. The reader checks `rlen` +**before allocating**: a pre-auth record over `BT_PREAUTH_RECORD_MAX`, a transport record whose `rlen` is not 272, +1,040 or 4,112 (`BT_RECORD_MAX`), or a record kind that is not expected at this point → close the channel at once, no answer (the +G7 probes read a 4-byte length without a bound, finding of the pentester in review #165, "Yêu cầu phát hiện cho +sản phẩm", item 3). + +Pre-auth records, plaintext, first byte = kind: + +| Kind | Name | Direction | Body | Size | +|---|---|---|---|---| +| `0x01` | `challenge` | S→C | `n_s`: 32 random bytes, new for every channel | 33 | +| `0x02` | `noise1` | C→S | Noise message 1: `e` (32) ‖ encrypted empty payload (16) | 49 | +| `0x03` | `noise2` | S→C | Noise message 2: `e` (32) ‖ encrypted empty payload (16) | 49 | +| `0x04` | `cpace1` | C→S | `Ya` (32), PIN pairing only ("Pairing mode") | 33 | +| `0x05` | `cpace2` | S→C | `Yb` (32), PIN pairing only | 33 | +| `0x7F` | `reject` | S→C | Empty; means `LINK_REJECTED` (0.8.4) | 1 | + +#### Link-security handshake + +Pattern `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (Noise rev. 34), psk = `K_bt_link` of the pair, prologue = +`"HLBT1"` ‖ `n_s`. C = M-APP (initiator), S = A-SVC (responder). + +1. The channel opens; S sends `challenge` at once and starts `BT_PREAUTH_TIMEOUT`. +2. C sends `noise1` with the psk of the pair whose hint it matched. +3. S tries the psk of each unrevoked pair in turn (the advertisement carries no `pair_id`) until the payload tag of + message 1 verifies. The prologue binds `n_s`, so a recorded `noise1` never verifies on a new channel. No pair + verifies → key-confirmation failure. +4. S sends `noise2`; C verifies its payload tag (failure → C closes the channel, CONN-05 E3). Both sides `Split()` + into one CipherState per direction; the link is now bound to that pair. +5. S treats the link as confirmed only when C's first transport record decrypts; a failure there is also a + key-confirmation failure. +6. The E2E session handshake (0.6.3) then runs inside the link on the control channel, unchanged. Its + `session/hello` must name the same `pair_id` as the psk that verified; otherwise S answers `AUTH_FAILED` + (`session/error`) and closes the link. + +Any failure before step 5 completes → S sends `reject` when it can and closes the channel. `reject` is the only +pre-auth error on the air: it does not say whether the cause was the admission cap, a timeout, a block or a failed key +confirmation. + +#### Transport records and channels + +After step 4 every record is a Noise transport message: ciphertext of the padded plaintext + 16-byte tag, nonce = +the 64-bit counter of that direction, empty associated data. Each side calls Noise `Rekey()` on its sending +CipherState after every `BT_LINK_REKEY_RECORDS` records, and the receiver mirrors it on the same count (no +message). Plaintext: + +| Offset | Length | Field | Description | +|---|---|---|---| +| 0 | 1 | `chan` | `0x01` control, `0x02` bulk (content: 0.4.7); any other value → close the link | +| 1 | 1 | `flags` | bit0 `more`: further fragments of the same message follow; bit1 `long`: the fragment belongs to the long lane of the control channel (channel `0x01` only, below); other bits 0 | +| 2 | 2 | `dlen` | uint16 BE, length of `data`, ≤ `BT_FRAGMENT_MAX` | +| 4 | `dlen` | `data` | A fragment of one message | +| 4 + `dlen` | rest | padding | Zero bytes up to the smallest of 256, 1,024 or 4,096 bytes that holds the frame | + +- **Control channel** (`0x01`): one message = one JSON envelope (0.5.1) as UTF-8, exactly what a WS text frame + carries on `/v1/ctl`. Envelope rules (0.5.1) are unchanged. An envelope may reach 256 KiB (0.5.1 rule 4), about + 20 s of air time at 12.9 KB/s, so the channel has two lanes, told apart by `long`: + - *Short lane* (`long` = 0): an envelope of at most `BT_FRAGMENT_MAX` bytes, always one fragment. `more` = 1 with + `long` = 0 → close the link. + - *Long lane* (`long` = 1): any envelope up to `BT_MESSAGE_MAX`, in fragments of at most `BT_BULK_FRAGMENT_MAX` + bytes, one message at a time per direction. The sender puts there every envelope longer than `BT_FRAGMENT_MAX`, + every `session` envelope (`rekey`, `bye`…), and a short envelope of the same `type` as a long message still + queued or in flight. The receiver reassembles this lane only and closes the link when a long-lane fragment has + `dlen` over `BT_BULK_FRAGMENT_MAX` or a message passes `BT_MESSAGE_MAX` before its last fragment. + - *Order.* Each lane keeps the sender's order, and the receiver handles an envelope as soon as it is complete. A + short envelope may thus overtake a long one of another `type` (a clipboard text never waits 20 s behind an SMS + batch); envelopes of one `type` and all `session` envelopes keep the order in which they were sent. +- **Priority:** the sender keeps three queues and at each fragment boundary sends from the first non-empty one: + short control, then long control, then bulk. At most `BT_BULK_INFLIGHT` long-control and bulk records together + may be written to the L2CAP stream and not yet drained, so a short envelope waits at most behind that many + 1,040-byte records (about 160 ms) and the record on the air. Long control goes before bulk without fairness: long + envelopes are rare over Bluetooth, since images travel on the bulk lane (0.4.7). The G7 probe used a single FIFO + stream and missed AC1 during a bulk transfer (one-way p95 4,769–7,768 ms, spike report runs "Lần 1" and "Lần 2"), + hence this rule. +- **Padding** hides the envelope `type` behind three sizes (AC9); `id` and `ts` travel only inside the ciphertext. A + long envelope, like bulk, shows as a run of 1,040-byte records: its size is visible to within about 1 KiB, its `type` is + not. +- **Keepalive:** every `BT_PING_INTERVAL` without other traffic the client sends `ping/ping` (CONN-02 API 2); a side + that receives no record for `BT_IDLE_TIMEOUT` closes the link. +- **No close codes:** a link has no equivalent of the WS close codes (0.8.3). A side ends an established session + with an encrypted `session/bye`, as for a relayed session (CONN-02 API 4), then closes the channel; before the + session handshake succeeds it uses `session/error` (CONN-01 API 6) followed by the close. A channel closed + without `session/bye` counts as a connection loss. + +#### Admission (A-SVC) + +- At most `BT_LINK_LIMIT` L2CAP links at once, of which at most `BT_PREAUTH_LIMIT` before step 5. A channel over + either cap gets `reject` and is closed. +- A link that has not completed step 5 within `BT_PREAUTH_TIMEOUT` gets `reject`; a confirmed link without a valid + `session/hello` (on a pairing link: `pair/hello`, "Pairing mode") within `HANDSHAKE_TIMEOUT` is closed. +- BLE gives no client address to count by, so failures are counted for the whole Bluetooth listener: every + key-confirmation failure (steps 3 and 5) and every `session/hello` rejected with `AUTH_FAILED` counts as one + `AUTH_FAILED`; `BT_AUTH_FAIL_BLOCK` of them within a minute → the listener answers every new channel with + `reject` for a minute. The LAN counters (`CTL_PREAUTH_LIMIT`, `CTL_IP_BLOCK`) do not see Bluetooth links. +- One session per pair across all routes (0.4.1): a new session that confirms its keys replaces the old one, + whatever the route; over Bluetooth the replaced side receives `session/bye {reason: replaced}`. + +#### Pairing mode (PAIR-01 flows B and C) + +First pairing when the phone and the Mac share no LAN: QR (flow B; the QR format does not change) or PIN (flow C, +CPace, last bullet). + +- **Advertising.** While A-SVC holds an open QR pairing window (PAIR-01 step 6) and Bluetooth is usable (on, Nearby + devices granted on Android 12+, `link.bluetooth` on), it restarts the advertising set (new server socket, new PSM) + with `pr_bt` as the first hint, followed by at most `BT_ADV_MAX_HINTS` − 1 pair hints under the rule above. + `pr_bt` = the first 4 bytes of HMAC-SHA256(`K_bt_pdisc`, `"HLBTPAIR1"`). The format is unchanged, so an observer + cannot tell pairing mode from normal advertising; `pr_bt` changes with every QR and is never derived from `pk`, so + it links no two pairings (AC9). When the window closes, A-SVC restarts the set without `pr_bt`. +- **Client.** M-APP in PAIR-01 step 7 also scans with the `BT_SERVICE_UUID` filter and treats an advertisement whose + hints contain its own `pr_bt` as a candidate. A LAN candidate wins; a Bluetooth candidate is connected at once when + there is no LAN candidate. Several advertisers with the same `pr_bt` (a copied advertisement) are tried one after + the other until the 20 s of PAIR-01 step 7 run out. +- **Link-security handshake.** Steps 1–5 above, unchanged, with psk = `K_bt_plink`. While the window is open, S + tries `K_bt_plink` first, then the psk of each unrevoked pair. A link whose `noise1` verified with `K_bt_plink` is + a **pairing link**: the first frame of a pairing is thus authenticated with a key from `pairing_secret` before the + pre-auth slot turns into a confirmed link. +- **Pairing link.** Within `HANDSHAKE_TIMEOUT` after step 5, C sends `pair/hello` with `mode = "qr"` on control + channel `0x01`; PAIR-01 API 2–6 then run as envelopes on that channel (payload unencrypted as on `/v1/pair`, inside + the link encryption). Any other envelope `type` on a pairing link, or a `pair` envelope on a pair's link → close the + link without an answer. A pairing link that sent a valid `pair/hello` holds the window: S stops trying `K_bt_plink`, + so a second claimant fails key confirmation and gets `reject` (one client per window, PAIR-01 API 2 logic 4). +- **After `pair/done`.** The link is kept and becomes the control link of the new pair: the next envelope from C must + be `session/hello` with the `pair_id` created on this link (step 6 above), within `HANDSHAKE_TIMEOUT`; otherwise + `session/error` `AUTH_FAILED` and close. `pair/error`, or a link closed before `pair/done`, ends the pairing: + nothing is stored (PAIR-01 postconditions). +- **PIN (PAIR-01 flow C, owner decision 2026-10-28).** The 6-digit PIN pairs over Bluetooth through CPace, so it + never crosses the air in any form and one recorded exchange cannot be checked offline: + - *Advertising.* While A-SVC holds an open PIN window (PAIR-01 A4) and Bluetooth is usable, it restarts the + advertising set with `BT_PIN_HINT` as the first hint (then at most `BT_ADV_MAX_HINTS` − 1 pair hints). The marker + is the same for every phone in PIN mode and only says "a phone is in PIN pairing mode now", like TXT `pm = 1` on + the LAN; it carries nothing about the device, the PIN or a pair (AC9). When the window closes the set restarts + without it. M-APP in step A2 treats every advertisement carrying `BT_PIN_HINT` as a candidate. + - *CPace.* After `challenge`, C sends `cpace1` instead of `noise1`; S accepts it only while a PIN window is open + (otherwise the channel is closed without an answer). Suite `CPACE-X25519-SHA512` of draft-irtf-cfrg-cpace + (initiator-responder), revision pinned by the S7.1 vectors (`shared/test-vectors/cpace.json`, field `draft`): + PRS = the UTF-8 bytes of the six digits; CI = `"HLBTPIN1"`; sid = `n_s` (32 bytes, new for every channel); ADa = + ADb = empty. C: generator `g` = `calculate_generator(SHA-512, PRS, CI, sid)` (Elligator2 map of RFC 9380), + random scalar `ya`, `Ya` = X25519(`ya`, `g`) → `cpace1`. S: same `g`, `yb`, `Yb` → `cpace2`; `K` = + X25519(`yb`, `Ya`). C: `K` = X25519(`ya`, `Yb`). An all-zero `K` → close without answer. `ISK` (64 bytes) per the + draft from `sid`, `K`, `Ya`, `Yb`. + - *Keys from ISK* (memory only, wiped with the PIN): `K_bt_pinlink` = HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = + `"handlive/v1/bt-pin-link"`, L = 32), the Noise psk of steps 2–5 above (same prologue); `K_bt_pin` = + HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-pair"`, L = 32), which replaces `K_pin` in + PAIR-01 steps 9–11 (0.6.2). The link that passes `noise1` with `K_bt_pinlink` is a **PIN pairing link**: the + "Pairing link" and "After `pair/done`" rules above apply, with `pair/hello` `mode = "pin"`. `mode = "qr"` on a PIN + pairing link, or `mode = "pin"` on a QR pairing link → `pair/error` `AUTH_FAILED` and close. + - *Online guessing only.* A wrong PIN on either side makes the `noise1` tag fail at S (or `noise2` at C): one + attempt. A man in the middle gets exactly one PIN guess per channel and a passive listener none. The phone keeps + **one counter per PIN window, shared by both transports**: every `pair/offer` on the LAN that ends without + `pair/done` and every Bluetooth channel that fails after `cpace1` add one; at `PIN_MAX_ATTEMPTS` in total it + closes the window as "PIN expired" (PAIR-01 E2) and refuses any new PIN window, on the LAN or over Bluetooth, for + `BT_PIN_LOCKOUT` (PAIR-01 E10). The Mac keeps the same single counter per PIN (`PIN_INVALID` on the LAN plus + failed Bluetooth channels) and generates a new PIN at `PIN_MAX_ATTEMPTS` (E7). With 10^6 PINs and 3 attempts per + PIN, one PIN window is guessed with probability 3 × 10^-6. + - *Timing.* One round trip more than the QR link (`cpace1`/`cpace2`); the phone is in the user's hand while the PIN + is typed, so Doze does not apply and `BT_PREAUTH_TIMEOUT` is unchanged. +- **Admission.** Pairing links share `BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, `BT_PREAUTH_TIMEOUT` and + `BT_AUTH_FAIL_BLOCK` with pair links. A device without `pairing_secret` (flow B) or the PIN (flow C) can hold + the single pre-auth slot for at most `BT_PREAUTH_TIMEOUT` per channel and never claims the window; its failures + count toward `BT_AUTH_FAIL_BLOCK`, which can block a real pairing for a minute (accepted risk, plan risk 12: the + user retries or pairs on the LAN). +- **AC8.** A pairing link never touches an existing pair: the pair store changes only at PAIR-01 step 11, after the + `mac` checks keyed by `pairing_secret`; `reject` and a closed channel only fail this attempt. + +### 0.4.6 Wi-Fi Direct link for bulk (P7) + +A fast path for images when the phone and the Mac share no LAN (scenario X), opened only on demand and closed as soon +as it is not needed. It carries only bulk stream connections; the control channel stays on the Bluetooth link +(0.4.5). Mac only; iOS/iPadOS have no Bluetooth route and no Wi-Fi Direct. Flow and exceptions: CONN-06. Bulk content, +per-connection keys and scheduling: 0.4.7. + +- **Topology (decision).** A-SVC creates an autonomous Wi-Fi Direct group (`WifiP2pManager.createGroup` with a + `WifiP2pConfig`, API 29+) and is its group owner; M-APP joins it as a plain WPA2 client with CoreWLAN. Reasons + ([research 2026-10-01](../../plans/20260925-implementation/reports/research-direct-wifi-link-2026-10-01.md), + sections 3 and 5): macOS has no Wi-Fi Direct or Wi-Fi Aware API (F2), so the Mac can only join a network; a + third-party app cannot turn the phone's hotspot on; `LocalOnlyHotspot` imposes a random SSID and passphrase and fails + on some OEM builds; a Wi-Fi Direct group lets the app set its own SSID and passphrase (API 29+) and keeps the phone's + own Wi-Fi association (STA + P2P). +- **Credentials.** A-SVC generates new ones for every group and never reuses them: + `ssid` = `"DIRECT-"` ‖ 10 random characters from `[A-Za-z0-9]` (17 bytes; Android requires the `DIRECT-xy` prefix, + here the `xy` are random too, so the name says nothing about HandLive or the pair); `passphrase` = base64url without + padding of 18 random bytes (24 characters, 144 bits; WPA2 needs 8–63). They travel only in the `ack` of + `direct/start`, an E2E envelope inside the Bluetooth link: never advertised, never logged, kept only in memory and + wiped when the group stops. A new group means new credentials, so there is no rotation schedule: a group lives at + most `DIRECT_WIFI_MAX`. +- **Group settings.** `WifiP2pConfig.Builder().setNetworkName(ssid).setPassphrase(passphrase) + .setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_AUTO)`; on API 36+ also + `setPccModeConnectionType(WifiP2pConfig.PCC_MODE_CONNECTION_TYPE_LEGACY_OR_R2)` (the Mac is a legacy client, F4). + A-SVC never toggles Wi-Fi and never leaves its own network for the group. +- **Entry rules (Mac, AC4).** M-APP asks for a group (`direct/start`) only when all hold: the session runs over + Bluetooth (no LAN session); a bulk transfer is waiting in either direction (0.4.7; the phone signals its own with + `direct/want`); `link.direct_wifi` is on; Location access is granted; Wi-Fi is powered on and has not been associated + with any network for at least `DIRECT_WIFI_IDLE_MIN`; the last scan shows no network from the Mac's known networks; + no Wi-Fi Direct attempt failed within `DIRECT_WIFI_RETRY_AFTER`. A Mac associated with any network, even one without + Internet, never joins: the transfer goes over Bluetooth in the background (plan routing table). So joining the group + never takes the Mac off another network. +- **Exit rules.** The Mac leaves the group (`CWInterface.disassociate()`) and sends `direct/stop` when: no bulk stream + has carried data for `DIRECT_WIFI_LINGER`; the group has existed for `DIRECT_WIFI_MAX`; a scan every + `DIRECT_WIFI_SCAN_INTERVAL` shows a known network (so macOS can go back to it); a LAN session appears; the Bluetooth + session ends. macOS moving to another network by itself counts as leaving. A-SVC removes the group + (`WifiP2pManager.removeGroup`) on `direct/stop`, on the same `DIRECT_WIFI_LINGER` and `DIRECT_WIFI_MAX` limits counted on + its side, when no client has joined within `DIRECT_WIFI_JOIN_TIMEOUT`, and when the control session ends. +- **Authentication on the group (AC8).** Knowing the passphrase lets a device join the group, not talk to HandLive. + M-APP connects to `host`:`port` from the `ack` with TLS pinned to `peer_tls_sha256`, exactly as on the LAN (0.4.1), + with its socket bound to the Wi-Fi interface; a certificate that does not match → `TLS_PIN_MISMATCH`, leave the group. + On a connection whose local address is the group owner address, A-SVC accepts only the bulk stream path `/v1/stream/bulk` (0.4.7), + whose stream handshake is keyed by the live control session (0.6.3 step 7); `/v1/ctl`, `/v1/pair` and anything else + on that address → close 4400. A device on the group without the session keys cannot finish a stream handshake (4401). +- **Not a LAN.** The group is never a session route: an mDNS instance or address seen on the group's interface is not a + CONN-01 candidate and does not trigger the 0.11 upgrade; the session stays on Bluetooth while images use the group. +- **Saved network.** After leaving, M-APP removes the network from the Mac's preferred networks when CoreWLAN allows + it without an administrator prompt; otherwise the entry stays, harmless because that SSID is never used again + (G7 did not test it, CONN-06 special requirements). + +### 0.4.7 Bulk lane (P7) + +Large transfers (CLIP-03 images, text over `CLIP_INLINE_MAX`, CLIP-04) keep the CLIP-03 messages unchanged +(`clipboard/push` with `transfer`, 64 KiB `clipboard/chunk`, `clipboard/cancel` and their `ack`); only the lane that +carries them depends on the route. Every other envelope, including inline clipboard text, stays on the control channel. + +- **Lane choice** (plan routing table: LAN > Wi-Fi Direct > Bluetooth), decided when a transfer starts: + +| Situation | Bulk lane | Keys | +|---|---|---| +| LAN session | `/v1/ctl`, as today (CLIP-03: at most 4 chunks in the send buffer) | Session keys (0.6.3) | +| Bluetooth session, size ≥ `BULK_DIRECT_MIN`, Wi-Fi Direct entry rules hold (0.4.6) | `wss://{host}:{port}/v1/stream/bulk` on the group (CONN-06) | Per-connection keys (below) | +| Bluetooth session, otherwise, or Wi-Fi Direct failed | Link channel `0x02` (0.4.5), in the background | Session keys inside the link encryption | + + A transfer of at least `BULK_DIRECT_MIN` waits for Wi-Fi Direct up to `DIRECT_WIFI_JOIN_TIMEOUT`, then starts on + Bluetooth if the group is not up. Below that size Bluetooth is faster than joining (12.9 KB/s, G7 Lần 2: 128 KiB ≈ + 10 s against an 8 s join budget). There is no Bluetooth-specific size cap: up to `CLIP_MAX_IMAGE` runs in the + background (plan AC3). No route at all → nothing is sent; CLIP-03 replays the latest clip when a session starts + (QC7). +- **Per-connection keys on `/v1/stream/bulk`.** The stream handshake of 0.6.3 step 7 with channel `bulk` + (`K_stream` from the live control session, `bulk/stream_hello` and `bulk/stream_welcome`), then + `K_bulk` = HKDF-SHA256(ikm = `K_stream` (96 bytes), salt = `nonce_c` ‖ `nonce_s`, info = `"handlive/v1/bulk-conn"`, + L = 64) → `k_c2s` (32) ‖ `k_s2c` (32). Every envelope on that connection is a 0.5.1 envelope encrypted with these + keys instead of the session keys; the 0.5.1 rule 2 duplicate set is kept per connection. A new connection, even in + the same session, gets new nonces and so new keys; the long-term `PRK` is never used directly. A device on the + group without the live session cannot pass `stream_hello` (4401, AC8). The control session ending closes the bulk + connection. +- **Bluetooth scheduling.** Control fragments always go first (0.4.5 "Priority": short control, long control, then + bulk). A bulk fragment carries at most `BT_BULK_FRAGMENT_MAX` bytes, so it fits the 1,040-byte record and holds the + radio about 80 ms at 12.9 KB/s; the sender keeps at most `BT_BULK_INFLIGHT` long-control and bulk records written to + the L2CAP stream and not yet drained, so a short control envelope never waits behind more than that. A7.4 measures this against AC1 (≤ 200 ms during bulk). +- **Wi-Fi Direct and control.** The control channel never moves to the group (0.4.6 "Not a LAN"); bulk on the group + uses a different radio path, so it cannot delay control envelopes on Bluetooth. +- **Changing lane mid-transfer: restart, no resume.** When the lane of a running transfer goes away or a better one + appears (Wi-Fi Direct up while it runs on Bluetooth; the group lost, CONN-06 E7/E8; a LAN session replacing the + Bluetooth one), the sender sends `clipboard/cancel` `rerouted` on the old lane if it still can, and restarts the whole + transfer with a new `transfer_id` and the same `clip_id` on the new lane, even past the 120 s of QC7, as long as it + is still the latest clip. At most `BULK_MAX_RESTARTS` restarts per clip, then CLIP-03 E8 applies. A restart because + a better lane appeared happens only if more than half of the transfer is left. +- **Timeouts and progress.** `CLIP_TRANSFER_IDLE_TIMEOUT` is unchanged on every lane (a 1,040-byte record at a Doze + RTT of 3 s still arrives well within it). Over Bluetooth, progress is shown for transfers of at least + `BULK_DIRECT_MIN` (CLIP-03 field 2) with the line of CLIP-03 field 10. + ## 0.5 Message frames ### 0.5.1 JSON envelope (WS text frame) @@ -270,6 +582,8 @@ The receiver drops frames whose `seq` ≤ the largest `seq` received so far (rep | `ik_dh` | X25519 | Like `ik_sig` | Like `ik_sig` | | TLS key | ECDSA P-256 + self-signed certificate | Android: PKCS#12, password wrapped by the Keystore | At install time. A corrupted PKCS#12 store or a lost password → regenerate the key and certificate; every client sees `TLS_PIN_MISMATCH` on every instance → shows "Needs to be paired again" (CONN-01 E2) | | `pairing_secret` | 32 random bytes | Only in Mac/iOS memory and in the QR | 120 s | +| `K_bt_pdisc`, `K_bt_plink` | HKDF(`pairing_secret`), 0.4.5 | Memory only (Mac and Android) | Like `pairing_secret`; wiped with it | +| `ISK`, `K_bt_pinlink`, `K_bt_pin` | CPace, then HKDF (0.4.5, PIN) | Memory only (Mac and Android) | One channel; wiped with the PIN | | `PRK` | 32 bytes | Android: column `prk_enc` (Tink AEAD, key in the Keystore). Mac/iOS: Keychain, account = `pair_id` | Per pair | | `K_push` | HKDF(`PRK`, info = `"handlive/v1/push"`) | Computed when needed | Per pair | | `k_c2s`, `k_s2c` | 32 bytes | Memory | Per connection; rekey after 24 h or 10,000 envelopes in each direction | @@ -302,7 +616,10 @@ The receiver drops frames whose `seq` ≤ the largest `seq` received so far (rep `0x00000000` ‖ the last 8 bytes of the 24-byte nonce. - Relay: `ed25519-dalek`, `jsonwebtoken`, `ring`. - A cross-platform test vector suite (Kotlin ↔ Swift ↔ Rust) covers: `device_id`, `PRK`, the session - handshake, envelope encryption, the HL frame. It runs in the CI of all three sides. + handshake, envelope encryption, the HL frame. It runs in the CI of all three sides. CPace (`cpace.json`, Kotlin and + Swift): the draft's own `CPACE-X25519-SHA512` vectors, then HandLive cases (fixed `n_s`, PIN, `ya`, `yb` → `g`, `Ya`, + `Yb`, `K`, `ISK`, `K_bt_pinlink`, `K_bt_pin`), PINs with leading zeros, and negative cases (a low-order `Ya`, an + all-zero `K`, a wrong PIN on one side). ### 0.6.2 Pairing @@ -310,6 +627,10 @@ The receiver drops frames whose `seq` ≤ the largest `seq` received so far (rep SHA-256(smaller `device_id` ‖ larger `device_id`, as 16 bytes), info = `"handlive/v1/pair"`, L = 32). - With a PIN (fallback): replace `pairing_secret` with `K_pin` = Argon2id(PIN, salt = `nonce_c` ‖ `nonce_s`, t = 3, m = 64 MiB, p = 4, L = 32); the PIN is the UTF-8 bytes of exactly six digits (leading zeros kept), Argon2 version 0x13, no secret and no associated data. +- Over Bluetooth with a PIN (PAIR-01 flow C): replace `pairing_secret` with `K_bt_pin` (0.4.5, from CPace) instead of + the Argon2id `K_pin`; the rest is unchanged. +- Over Bluetooth (PAIR-01 flow B, QR): `K_pa`, `PRK` and `attestation` are computed exactly as above; there is + no TLS connection, so the client pins the `tls_sha256` protected by the offer's `mac`, as through the relay. - Strings fed into a MAC, a signature or a hash (device names…) are their UTF-8 bytes as they are, without Unicode normalization (NFC/NFD). - Pairing attestation (`attestation`): `"HLPAIR1"` ‖ `pair_id` (16) ‖ `device_id` Android(16) ‖ `device_id` client(16) ‖ `ik_sig_pub` Android(32) ‖ `ik_sig_pub` client(32) ‖ `created_at` (int64 @@ -349,7 +670,7 @@ C = Mac/iOS, S = Android. The first two envelopes have an unencrypted payload (0 sent it; the old keys are kept for another 30 s for envelopes in flight. 7. **Stream channel keys** (`/v1/stream/*`): `K_stream` = HKDF(`secret`, info = `"handlive/v1/stream/" ‖ ‖ "/" ‖ `, L = 96) → `k_auth` (32) ‖ `k_c2s` (32) ‖ - `k_s2c` (32). The first message on a stream channel is a `camera` /`call_audio` envelope, op + `k_s2c` (32). The first message on a stream channel is a `camera` /`call_audio` /`bulk` envelope, op `stream_hello` `{session_id, nonce, mac}` with `mac` = HMAC-SHA256(`k_auth`, `"HLSTREAM1|"` ‖ `session_id` ‖ `nonce_c`); S answers `stream_welcome` `{session_id, nonce, mac}` with `mac` = HMAC-SHA256(`k_auth`, `"HLSTREAM1|welcome|"` ‖ `session_id` ‖ `nonce_c` ‖ `nonce_s`) — it binds @@ -362,7 +683,7 @@ C = Mac/iOS, S = Android. The first two envelopes have an unencrypted payload (0 - An HKDF without a stated salt uses an empty salt; without a stated L, L = 32 (e.g. `K_auth`). - Salt of `PRK`: the two `device_id`s are compared as 16 bytes, in unsigned byte order. - `info` of `K_stream`: UTF-8 `"handlive/v1/stream//"`, - `` ∈ {`camera`, `call-audio` }. `K_stream` is always derived from the `secret` of the + `` ∈ {`camera`, `call-audio`, `bulk` } (`bulk` then derives per-connection keys, 0.4.7). `K_stream` is always derived from the `secret` of the first handshake (epoch 0) for the whole connection, even after a rekey. - Rekey: shared = X25519(initiator eph, responder eph); salt = SHA-256(initiator `nonce` ‖ responder `nonce`); the 64 resulting bytes are split into `k_c2s` ‖ `k_s2c` by the C/S role (not @@ -404,7 +725,7 @@ C = Mac/iOS, S = Android. The first two envelopes have an unencrypted payload (0 `type` keeps exactly the agreed set (`clipboard | sms | call_event | call_audio | pair | ack | ping | capability`) and adds `session`, `camera` for the session handshake and Phase 5, and `web` for -Continue Browsing (Phase 6, README §5 C21). +Continue Browsing (Phase 6, README §5 C21), and `direct` for the on-demand Wi-Fi Direct link and `bulk` for its stream handshake (Phase 7, 0.4.6, 0.4.7). | type | op | Direction | Ack | Channel | Function | |------|----|-------|-----|------|-----------| @@ -421,7 +742,7 @@ Continue Browsing (Phase 6, README §5 C21). | `session` | `bye` | Both ways | — | `/v1/ctl` | CONN-02, PAIR-03 | | `capability` | `hello` | Both ways | — | `/v1/ctl` | CONN-01 | | `capability` | `update` | Both ways | — | `/v1/ctl` | SET-02 | -| `ping` | `ping` | Both ways | Yes | `/v1/ctl` via relay | CONN-02 | +| `ping` | `ping` | Both ways | Yes | `/v1/ctl` via relay or Bluetooth | CONN-02 | | `clipboard` | `push` | Both ways | Yes | `/v1/ctl` | CLIP-01…04 | | `clipboard` | `chunk` | Both ways | — | `/v1/ctl` | CLIP-03, CLIP-04 | | `clipboard` | `cancel` | Both ways | — | `/v1/ctl` | CLIP-03 | @@ -449,8 +770,12 @@ Continue Browsing (Phase 6, README §5 C21). | `camera` | `stats` | C→S | — | `/v1/ctl` | CAM-05 | | `camera` | `state` | S→C | — | `/v1/ctl` | CAM-02, CAM-05 | | `camera` | `stream_hello` / `stream_welcome` | C→S / S→C | — | `/v1/stream/camera` | CAM-02, CAM-04 | +| `bulk` | `stream_hello` / `stream_welcome` | C→S / S→C | — | `/v1/stream/bulk` | 0.4.7, CLIP-03 | | `web` | `active` | Both ways (Android → Mac, iPhone/iPad; Mac → Android) | — | `/v1/ctl` | WEB-01…05: `{page_id, url, title?, browser, observed_at}`, the page open in the sender's foreground browser; latest wins, fire-and-forget like `call_event/state` | | `web` | `inactive` | Both ways (as `active`) | — | `/v1/ctl` | WEB-01…05: `{page_id}`, the page is no longer open in the foreground | +| `direct` | `start` | C→S | Yes (with data) | `/v1/ctl` over Bluetooth | CONN-06: ask the phone to create a Wi-Fi Direct group; the `ack` carries `{ssid, passphrase, host, port}` | +| `direct` | `stop` | Both ways | Yes | `/v1/ctl` over Bluetooth | CONN-06: `{reason}`, the group is no longer needed or is gone | +| `direct` | `want` | S→C | — | `/v1/ctl` over Bluetooth | CONN-06: `{bytes}`, the phone has bulk waiting for the Mac | | `ack` | — | Both ways | — | Every channel | Response | Payload rules of `call_event/app_call` (full specification in CALL-05 API 1): `{call_id, app: {package, @@ -593,8 +918,8 @@ string for logs, never shown to the user; the UI picks its wording by code throu | `PAIRING_CLOSED` | Pairing | The 120 s window has ended or the Mac has refreshed the QR | Scan the new QR (PIN flow: get a new PIN) | | `PIN_INVALID` | Pairing | Wrong PIN (at most 3 attempts) | Enter it again; after 3 attempts the Mac generates a new PIN | | `AUTH_FAILED` | Session | Wrong HMAC or signature | No automatic retry | -| `PAIR_UNKNOWN` | Session | No matching pair | On the LAN (pinned TLS to the phone): delete the local pair, ask to pair again. Over the relay, before authentication: `Backoff`, never unpair (CONN-03 E9) | -| `PAIR_REVOKED` | Session | The pair has been revoked | On the LAN: delete the local pair. Over the relay, before authentication: `Backoff`, never unpair (CONN-03 E9) | +| `PAIR_UNKNOWN` | Session | No matching pair | On a route with `phoneAuthenticatedByTLS` (LAN, USB: pinned TLS to the phone): delete the local pair, ask to pair again. On any other route (relay, Bluetooth), before authentication: `Backoff`, never unpair (0.11, CONN-03 E9, CONN-05 E5) | +| `PAIR_REVOKED` | Session | The pair has been revoked | On a route with `phoneAuthenticatedByTLS`: delete the local pair. On any other route, before authentication: `Backoff`, never unpair (0.11, CONN-03 E9, CONN-05 E5) | | `DECRYPT_FAILED` | Session | The envelope cannot be decrypted | Close the session, reconnect | | `TLS_PIN_MISMATCH` | Session | The certificate does not match the pin | Drop this instance, try another instance | | `CLIP_TOO_LARGE` | Clipboard | Exceeds `max_text_bytes`/`max_image_bytes` | Tell the user | @@ -624,11 +949,12 @@ string for logs, never shown to the user; the UI picks its wording by code throu | `CAM_DENIED_BY_USER` | Camera | The user declined on the phone | Stop | | `CAM_ENCODER_UNSUPPORTED` | Camera | No suitable H.264 encoder | Lower the resolution or stop | | `CAM_THERMAL_LIMIT` | Camera | The phone is too hot | Lower the quality or stop | -| `CAM_TRANSPORT_UNSUPPORTED` | Camera | The `/v1/ctl` session runs over the relay; the camera only works over LAN or USB | Show "Requires the same Wi-Fi network or a USB cable" | +| `CAM_TRANSPORT_UNSUPPORTED` | Camera | The `/v1/ctl` session runs over the relay or Bluetooth; the camera only works over LAN or USB | Show "Requires the same Wi-Fi network or a USB cable" | | `USB_ADB_UNAUTHORIZED` | USB | The phone has not allowed USB debugging | Guide the user to accept the RSA dialog | | `USB_ADB_UNAVAILABLE` | USB | USB debugging is off or adb is missing | Open the wizard (CAM-04) | | `MAC_EXTENSION_NOT_ACTIVE` | Camera | The Camera Extension has not been activated | Open CAM-01 | | `MAC_MIC_DRIVER_MISSING` | Camera | The virtual microphone driver is not installed | Open CAM-01 | +| `DIRECT_WIFI_UNAVAILABLE` | Link | The phone cannot create the Wi-Fi Direct group; `details.reason` ∈ {`wifi_off`, `location_off`, `p2p_unsupported`, `busy`, `error`} | Send over Bluetooth in the background; no new `direct/start` before `DIRECT_WIFI_RETRY_AFTER` (CONN-06 E3) | ### 0.8.2 Relay error codes (HTTP) @@ -664,6 +990,14 @@ string for logs, never shown to the user; the UI picks its wording by code throu | 4429 | `RATE_LIMITED` — on `/v1/ctl`: more than 16 connections without a handshake or more than 4 from one IP, or the IP is blocked for 5 minutes (wrong `mac`, or repeated pre-handshake failures, CONN-01 API 3, API 4); on `/v1/pair`: more than 4 connections or more than 2 from one IP (PAIR-01 API 2) | | 4500 | `INTERNAL` | +### 0.8.4 Bluetooth link code + +A Bluetooth link has no close codes (0.4.5); its only pre-authentication error is one record. + +| Code | Meaning | Client handling | +|----|---------|----------------| +| `LINK_REJECTED` | `reject` record on a Bluetooth link before the link-security handshake completed: admission cap, timeout, listener block or failed key confirmation, never saying which (0.4.5) | Normal `Backoff`; never unpair, never stop reconnecting (0.11, CONN-05 E4) | + ## 0.9 Data model > **About "queries taken from code":** as of 2026-09-24 the repo has no source code, so no query @@ -929,6 +1263,9 @@ SET-02 function manages these keys. | `call_audio.phone_bt_address` | string | empty | Mac | Bluetooth address of the phone the user chose for HFP | | `feature.camera` | bool | `false` | Android, Mac | Turned on after CAM-01 | | `relay.enabled` | bool | `true` | All | Allow using the relay outside the LAN | +| `link.bluetooth` | bool | `true` | Android, Mac | Bluetooth control link (0.4.5, CONN-05); off → no advertising, no scan, the Bluetooth session ends | +| `link.bt_enable_asked_at` | timestamp | empty | Android | Last automatic request to turn Bluetooth on (SET-01 API 10); the next one waits `BT_ENABLE_PROMPT_INTERVAL` | +| `link.direct_wifi` | bool | `true` | Android, Mac | On-demand Wi-Fi Direct link for bulk (0.4.6, CONN-06); off → images without a LAN go over Bluetooth only | | `clip.auto_send` | bool | `true` | Android | Send automatically on copy (requires Accessibility, D4) | | `clip.a11y_consent_at` | timestamp | empty | Android | When the user accepted the Accessibility disclosure | | `clip.send_images` | bool | `true` | All | Sync images | @@ -959,7 +1296,7 @@ SET-02 function manages these keys. |------|---------|---------| | `CTL_PORT` | 47800 (fallback 47801–47809) | | | `PAIRING_WINDOW` | 120 s | QR/PIN validity and the `/v1/pair` window | -| `PIN_MAX_ATTEMPTS` | 3 | The phone sends at most 3 `pair/offer` per PIN; after the third without `pair/done` the window closes as "PIN expired" (PAIR-01 A4) | +| `PIN_MAX_ATTEMPTS` | 3 | Failed attempts per PIN window, counted once across the LAN and Bluetooth: `pair/offer` without `pair/done` plus Bluetooth channels failed after `cpace1`; at the third the window closes as "PIN expired" (PAIR-01 A4, C3) | | `HANDSHAKE_TIMEOUT` | 5 s | | | `CTL_PREAUTH_LIMIT` | 16 connections without a handshake, at most 4 per IP | `/v1/ctl`; over the cap → 4429 (CONN-01 API 3) | | `CTL_IP_BLOCK` | 10 pre-handshake failures (4408, `PAIR_UNKNOWN`, `BAD_REQUEST`) within 5 minutes → IP blocked for 5 minutes; only failures before a `session/hello` passed its `mac` check count, and an established session clears the address's count | Next to the `AUTH_FAILED` rule (5/minute); blocked → 4429 (CONN-01 API 4) | @@ -1028,13 +1365,38 @@ SET-02 function manages these keys. | `WEB_PAGE_TTL` | 10 minutes | The receiver forgets a page after this long without a new `web/active` (WEB-02, WEB-04, WEB-05) | | `WEB_URL_MAX` | 8 KiB (UTF-8) | Longer URLs are not sent and are dropped by the receiver | | `WEB_TITLE_MAX` | 256 characters | The sender cuts longer titles; the receiver drops a longer one | +| `BT_SERVICE_UUID` | `3b3a01f9-ab27-44a8-bb68-b9b11a488bdb` (128-bit, self-generated) | The 16-bit `0xFEED` of the G7 probe is for the spike only and must never be used by a product build: the SIG assigned it to another company (0.4.5) | +| `BT_ADV_MAX_HINTS` | 2 | Keeps the scan response ≤ 31 bytes (0.4.5) | +| `BT_HINT_PERIOD` | 15 minutes (900,000 ms) | Hint, PSM and advertising set rotate together; the client checks the epochs e − 4 to e + 4 | +| `BT_PREAUTH_RECORD_MAX` | 64 bytes | Largest pre-auth record (`rlen`) | +| `BT_RECORD_MAX` | 4,112 bytes | Largest transport record (`rlen`): 4,096 bytes of padded plaintext + 16-byte tag; the only valid `rlen` values are 272, 1,040 and 4,112 | +| `BT_FRAGMENT_MAX` | 4,092 bytes | `dlen` of one fragment; also the largest envelope on the short control lane (0.4.5) | +| `BT_MESSAGE_MAX` | 256 KiB | Reassembled message of the long control lane, the envelope limit of 0.5.1 rule 4 | +| `BT_LINK_REKEY_RECORDS` | 65,536 records per direction | Noise `Rekey()` on the link | +| `BT_LINK_LIMIT` / `BT_PREAUTH_LIMIT` | 4 links / 1 link | A-SVC admission (0.4.5) | +| `BT_PREAUTH_TIMEOUT` | 7 s | From `challenge` to the first valid transport record; 2 round trips at Doze RTT p95 ≤ 3 s (owner decision 2026-10-28) = 6 s + 1 s margin | +| `BT_AUTH_FAIL_BLOCK` | 5 `AUTH_FAILED` within 1 minute → every new channel rejected for 1 minute | Counted for the whole Bluetooth listener (0.4.5) | +| `BT_PING_INTERVAL` / `BT_IDLE_TIMEOUT` | 30 s / 45 s | Same values as the relay E2E ping and close code 4411 | +| `BT_ENABLE_PROMPT_INTERVAL` | 24 h | At most one automatic request to turn Bluetooth on per interval (AC7, SET-01 API 10) | +| `BT_PIN_HINT` | `00 00 00 00` | Hint advertised while a Bluetooth PIN window is open (0.4.5 "Pairing mode"); the same for every phone | +| `BT_PIN_LOCKOUT` | 5 min | After a PIN window closed by `PIN_MAX_ATTEMPTS` failures, the phone opens no PIN window, on the LAN or over Bluetooth, for this long (PAIR-01 E10) | +| `DIRECT_WIFI_IDLE_MIN` | 30 s | The Mac's Wi-Fi must have been unassociated this long before it may join a group (AC4; skips short roams) | +| `DIRECT_WIFI_JOIN_TIMEOUT` | 8 s | From `direct/start` sent to the pinned TLS connection on the group; leaves room in the AC3 budget (first image in X ≤ 10 s) | +| `DIRECT_WIFI_LINGER` | 60 s | No bulk data for this long → leave and remove the group (keeps it open for the next images, AC3) | +| `DIRECT_WIFI_MAX` | 10 min | Longest life of one group; the next transfer opens a new group with new credentials (AC4) | +| `DIRECT_WIFI_SCAN_INTERVAL` | 30 s | Scan period on the Mac while on the group, to leave as soon as a known network is visible (AC4) | +| `DIRECT_WIFI_RETRY_AFTER` | 5 min | No new `direct/start` for this long after a failed attempt (error `ack`, join timeout, pin mismatch) | +| `BULK_DIRECT_MIN` | 128 KiB | Over Bluetooth, transfers this large or larger wait for Wi-Fi Direct first (0.4.7); about 10 s at the G7 rate of 12.9 KB/s | +| `BT_BULK_FRAGMENT_MAX` | 1,020 bytes | `dlen` of a fragment on the long control lane (channel `0x01`, `long` = 1) and of a bulk fragment on channel `0x02` (fits the 1,040-byte record, about 80 ms of air time) | +| `BT_BULK_INFLIGHT` | 2 records | Long-control and bulk records together written to the L2CAP stream and not yet drained; bounds how long a short control envelope waits (AC1) | +| `BULK_MAX_RESTARTS` | 2 | Lane-change restarts per clip (0.4.7); after that CLIP-03 E8 | ## 0.11 Client connection states (Mac/iOS) ```mermaid stateDiagram-v2 [*] --> Idle - Idle --> Discovering: ≥1 pair and a network + Idle --> Discovering: ≥1 pair and a network, or Bluetooth on and granted Discovering --> ConnectingLAN: found an instance with a matching hint ConnectingLAN --> Handshaking: TLS OK and the pin matches ConnectingLAN --> Discovering: TLS_PIN_MISMATCH (drop the instance) @@ -1042,9 +1404,13 @@ stateDiagram-v2 Handshaking --> Backoff: error or 4408 ConnectingLAN --> Backoff: network or TLS error (not a pin mismatch) ConnectingLAN --> Idle: every instance of the pair fails the pin (Needs to be paired again) + Discovering --> ConnectingBT: Bluetooth advertisement with a matching hint, no LAN candidate + ConnectingBT --> Handshaking: link-security handshake OK (0.4.5) + Idle --> Handshaking: Bluetooth pairing ends with pair/done, the link is kept (0.4.5 Pairing mode, PAIR-01 B4) + ConnectingBT --> Backoff: channel not opened, reject, BT_PREAUTH_TIMEOUT or link-security handshake fails (noise2, CONN-05 E3) ConnectingRelay --> Backoff: relay unreachable, 401 or 404 after re-registering WaitingPeer --> Backoff: relay connection lost - Discovering --> Backoff: past LAN_DISCOVERY_GRACE and relay.enabled = false + Discovering --> Backoff: past LAN_DISCOVERY_GRACE, relay.enabled = false and no Bluetooth scan Discovering --> Idle: last pair unpaired Backoff --> Idle: last pair unpaired Discovering --> ConnectingRelay: past LAN_DISCOVERY_GRACE and relay.enabled @@ -1053,21 +1419,46 @@ stateDiagram-v2 WaitingPeer --> Discovering: the phone appears on the LAN (hint-matching mDNS instance) ConnectingRelay --> Handshaking: presence online Connected --> Backoff: connection lost - Connected --> ConnectingLAN: on the relay and the LAN is found (upgrade) + Connected --> ConnectingLAN: on the relay or Bluetooth and the LAN is found (upgrade) Connected --> WaitingPeer: relayed session ends, relay link still up (the phone left, CONN-03 E8) Handshaking --> WaitingPeer: relayed handshake ends, relay link still up (the phone left) - Backoff --> Discovering: wait is over, the network changed or the phone reappeared on mDNS + Backoff --> Discovering: wait is over, the network changed or the phone reappeared on mDNS or Bluetooth Connected --> Idle: unpaired ``` Status shown to the user (PAIR-02): `Idle` /`Backoff` → "Disconnected"; `Discovering` /`Connecting*`/`Handshaking` → "Connecting…"; `WaitingPeer` → "Phone offline"; -`Connected` → "Connected via Wi-Fi" or "Connected over the internet" ("Connected via USB" while the +`Connected` → "Connected via Wi-Fi", "Connected via Bluetooth" or "Connected over the internet" ("Connected via USB" while the camera channel uses USB); every instance fails the pin → "Needs to be paired again". With the peer's name (status line, screen-reader text of the status indicator): "Connected via Wi-Fi to \", -"Connected over the internet to \", "Connected via USB to \". - -From any state: losing the network → `Idle` (CONN-02 E1); removing the last pair → `Idle`. Over the relay, a session or handshake that ends while the `/v1/relay` link stays up means the phone left (CONN-03 E8): back to `WaitingPeer`, not `Backoff`; while in `WaitingPeer`, a hint-matching mDNS instance leads back to `Discovering` for a LAN session. In `Backoff`, seeing a hint-matching instance on mDNS again ends the wait (except after `AUTH_FAILED`); `4429 RATE_LIMITED` backs off on the normal schedule. A `session/error` `PAIR_UNKNOWN` or `PAIR_REVOKED` received over the relay, before the handshake authenticated the phone, leads to `Backoff` and never to unpairing (CONN-03 E9); over the LAN (pinned TLS to the phone) it keeps its meaning (CONN-01 E4). +"Connected via Bluetooth to \", "Connected over the internet to \", "Connected via USB to \". The two +Bluetooth strings are new catalog keys (S7.1). + +**Routes and priority (P7).** A session runs over one route: `lan` (`usb` too, for the camera only), `relay` or +`bluetooth` (Mac only, 0.4.5). Priority: LAN > Bluetooth; the relay is not deployed in Phase 7 and its code stays +last. In `Discovering` the client browses mDNS while it has a network and scans for `BT_SERVICE_UUID` while Bluetooth +is on, Bluetooth access is granted and `link.bluetooth` is on. A LAN candidate is always tried first; a Bluetooth +candidate is connected at once when there is no LAN candidate (AC2 counts from the moment the phone is seen). A +session over Bluetooth is upgraded as soon as a hint-matching mDNS instance appears: the new LAN session replaces it +once its keys are confirmed, so the user never sees a gap. The Wi-Fi Direct group of 0.4.6 is never a route: it only carries +bulk streams while the session stays on Bluetooth. + +**Start and loss.** Leaving `Idle` needs ≥ 1 pair and either a network or Bluetooth on and granted. Losing the network +ends only LAN and relay sessions and attempts: a session over Bluetooth stays `Connected`; without a session the +client goes to `Discovering` when Bluetooth can still be used, otherwise to `Idle` (CONN-02 E1). Bluetooth turned off, +its access withdrawn or `link.bluetooth` turned off ends only the Bluetooth session and scan, with the same rule the +other way round. Removing the last pair → `Idle`. On iOS there is no Bluetooth route, so these rules read as before. + +**Pre-authentication rule by route (AC8).** Every route has the property `phoneAuthenticatedByTLS`: true for `lan` +and `usb` (TLS pinned to the phone's certificate), false for `relay` and `bluetooth`. Before the session handshake has +authenticated the phone (valid `mac` of `session/welcome`), on a route where it is false: `session/error` +`PAIR_UNKNOWN` or `PAIR_REVOKED` → `Backoff`, never unpair and never stop reconnecting; `UNSUPPORTED_VERSION` or +`AUTH_FAILED` still shows its message (CONN-01 E3, E5) but keeps the normal backoff schedule instead of stopping or +waiting 5 minutes; `reject` (`LINK_REJECTED`) or a channel closed during the link-security handshake → normal +`Backoff`. On a route where it is true, CONN-01 E3–E5 keep their meaning. A revocation is acted on only over a route +where it is true or through a signed statement (PAIR-03). + +Over the relay, a session or handshake that ends while the `/v1/relay` link stays up means the phone left (CONN-03 E8): back to `WaitingPeer`, not `Backoff`; while in `WaitingPeer`, a hint-matching mDNS instance leads back to `Discovering` for a LAN session. In `Backoff`, seeing a hint-matching instance on mDNS again ends the wait (except after `AUTH_FAILED`); A hint-matching Bluetooth advertisement ends the wait the same way. `4429 RATE_LIMITED` backs off on the normal schedule. ## 0.12 Localization and the UI string catalog diff --git a/docs/detailed-design/00-common-specs.vi.md b/docs/detailed-design/00-common-specs.vi.md index 3dda103..be5171a 100644 --- a/docs/detailed-design/00-common-specs.vi.md +++ b/docs/detailed-design/00-common-specs.vi.md @@ -119,6 +119,7 @@ nên người lạ trong LAN không theo dõi được thiết bị qua TXT. | `/v1/ctl` | Kênh điều khiển: mọi envelope ứng dụng | Bắt tay phiên (0.6.3) | P1 | | `/v1/stream/call-audio` | Âm thanh cuộc gọi Opus/WS — đường dự phòng (AUDIO-04) | Khóa dẫn xuất từ phiên ctl | P4 | | `/v1/stream/camera` | Luồng camera/micro (kênh riêng) | Khóa dẫn xuất từ phiên ctl | P5 | +| `/v1/stream/bulk` | Truyền bulk, chỉ trên group Wi-Fi Direct (0.4.7) | Khóa theo kết nối dẫn xuất từ phiên ctl | P7 | - Mỗi cặp chỉ có **một** kết nối `/v1/ctl` tại một thời điểm. Kết nối mới xác thực thành công thay thế kết nối cũ (đóng mã 4409). @@ -164,7 +165,7 @@ nên người lạ trong LAN không theo dõi được thiết bị qua TXT. `device_id` nguồn và chuyển khung HL đi nguyên vẹn. - Ngoại lệ duy nhất là điểm hẹn ghép nối (`rv_msg`, PAIR-01 API 7). Ở đó relay đọc `env.type` và đọc payload `pair` chưa mã hóa (giải b64) để chặn `pair/hello` có `mode = "pin"`, vì ghép bằng - PIN chỉ dùng trong LAN. + PIN không bao giờ đi qua relay (LAN, hoặc Bluetooth có CPace). ### 0.4.4 Push (P2) @@ -174,6 +175,304 @@ nên người lạ trong LAN không theo dõi được thiết bị qua TXT. - **iOS (APNs):** push `alert`, `mutable-content: 1`, `apns-priority: 10`. Nội dung hiển thị mặc định chung chung, gửi bằng `loc-key` (khóa catalog nhóm `push`, 0.12.4) để iPhone tự dịch; trường `hl` chứa envelope đã mã hóa bằng `K_push` để I-NSE giải mã và thay nội dung. `hl` là base64 chuẩn có padding của JSON envelope dạng UTF-8, cùng chuỗi với `env_b64` của `POST /v1/push` (CONN-04 API 2), dài ≤ 3 000 ký tự base64. Tổng payload ≤ 4 KB. - Mac không đăng ký push; khi thức dậy, M-APP kết nối lại và đồng bộ theo con trỏ. +### 0.4.5 Kênh Bluetooth: BLE L2CAP CoC (P7) + +Kênh điều khiển khi điện thoại và Mac không chung LAN (Phase 7, hai máy ở gần nhau). Lớp +mang do cổng G7 chọn ([báo cáo spike](../../plans/20260925-implementation/reports/phase-07-spike-g7.md), mục +"Kết luận bearer"). Chỉ Mac; iOS không đổi. Luồng và ngoại lệ: CONN-05. Ghép đôi lần đầu qua BLE: mục "Chế độ ghép đôi" bên dưới và luồng B (QR), luồng C (PIN) của PAIR-01. Làn bulk (ảnh): 0.4.7. Wi-Fi Direct: 0.4.6. + +- **Vai trò.** A-SVC là peripheral: phát quảng bá (dưới đây) và lắng nghe bằng + `BluetoothAdapter.listenUsingInsecureL2capChannel()` (PSM LE động, không bond, không GATT server). M-APP là + central: `CBCentralManager` quét, `CBPeripheral.openL2CAPChannel(_:)` mở kênh; không bên nào khởi động ghép đôi + của hệ điều hành. L2CAP CoC không bond thì không có mã hóa tầng liên kết (G7, mục "Brief items", ý 5), nên lớp bảo + mật link dưới đây là bắt buộc: không có gì khác đi qua sóng trước khi lớp này xong. +- **Khóa** (theo cặp, tính khi cần, lưu như `K_push`): + `K_bt_disc` = HKDF(`PRK`, info = `"handlive/v1/bt-discovery"`); `K_bt_link` = HKDF(`PRK`, info = + `"handlive/v1/bt-link"`). Không dùng lại `K_disc` hay `K_auth`. +- **Khóa ghép đôi** (theo từng QR, chỉ nằm trong bộ nhớ trong suốt thời gian sống của QR, `PAIRING_WINDOW`): + `K_bt_pdisc` = HKDF-SHA256(ikm = `pairing_secret`, salt = rỗng, info = `"handlive/v1/bt-pair-disc"`, L = 32); + `K_bt_plink` = HKDF-SHA256(ikm = `pairing_secret`, salt = rỗng, info = `"handlive/v1/bt-pair-link"`, L = 32). PIN + có khóa Bluetooth riêng, sinh từ một lần trao đổi CPace ("Chế độ ghép đôi", PIN). + +#### Quảng bá + +Quảng bá legacy, kết nối được (`BluetoothLeAdvertiser.startAdvertisingSet`, chế độ legacy, khoảng +`INTERVAL_LOW`, công suất `TX_POWER_HIGH` — cấu hình probe G7), có scan response, không tên thiết bị, không trường +TX power. `BT_SERVICE_UUID` là UUID 128-bit tự sinh. Vì UUID 128-bit và service data không vừa 31 byte (G7 gặp +`ADVERTISE_FAILED_DATA_TOO_LARGE`, mục "Yêu cầu phát hiện cho sản phẩm", ý 2), gói quảng bá mang UUID còn scan +response mang service data, mỗi gói ≤ 31 byte. + +| Cấu trúc AD | Byte | Nội dung | +|---|---|---| +| Flags (gói quảng bá) | 3 | Stack tự thêm cho advertising set kết nối được | +| Danh sách đầy đủ UUID dịch vụ 128-bit (gói quảng bá) | 18 | `BT_SERVICE_UUID` (Mac quét với bộ lọc này); gói quảng bá tổng 21 | +| Service data, UUID 128-bit (scan response) | 18 + 3 + 4 × n | `BT_SERVICE_UUID` ‖ service data bên dưới | +| **Tổng scan response** | ≤ 29 | n ≤ 2 (`BT_ADV_MAX_HINTS`) | + +Service data (sau UUID): + +| Offset | Độ dài | Trường | Mô tả | +|---|---|---|---| +| 0 | 1 | `ver` | `0x01` | +| 1 | 2 | `psm` | PSM L2CAP hiện tại, uint16 little-endian | +| 3 | 4 × n | `hints` | Mỗi cặp một hint, n = 0…`BT_ADV_MAX_HINTS`; hint = 4 byte đầu của HMAC-SHA256(`K_bt_disc`, `"HLBTDISC1"` ‖ int64 BE `floor(now_ms / BT_HINT_PERIOD)`) | + +- A-SVC phát quảng bá khi `link.bluetooth` bật, Bluetooth bật, đã cấp Nearby devices (Android 12+) và có ít nhất + một cặp chưa thu hồi đang không có phiên sống trên route nào; hint là của các cặp đó, `last_seen_at` gần nhất + trước, tối đa `BT_ADV_MAX_HINTS`. Không có cặp như vậy và không có cửa sổ ghép đôi QR đang mở → không phát ("Chế độ ghép đôi"). +- **Xoay vòng:** ở mỗi mốc `BT_HINT_PERIOD`, A-SVC dừng advertising set, đóng server socket (link hiện tại vẫn mở), + mở socket mới (PSM mới) và chạy advertising set mới. Hint, PSM và advertising set đổi cùng lúc; khởi động lại set là + đòn bẩy duy nhất app có lên địa chỉ vô tuyến (T7.1 bắt gói BLE kiểm địa chỉ có đổi, AC9). +- Client so hint của các epoch từ `e − 4` tới `e + 4` (e = epoch hiện tại: chịu lệch đồng hồ tới một giờ, như + trong LAN). Quảng bá có `ver` ≠ `0x01` hoặc không có hint khớp → bỏ qua. + +#### Bản ghi link + +Mọi byte trên kênh L2CAP thuộc một bản ghi: `rlen` (uint16 BE) ‖ `rlen` byte. Bên đọc kiểm `rlen` **trước khi cấp +phát**: bản ghi pre-auth vượt `BT_PREAUTH_RECORD_MAX`, bản ghi transport có `rlen` khác 272, 1 040 và +4 112 (`BT_RECORD_MAX`), hoặc loại bản ghi không đúng lúc → đóng kênh ngay, không trả lời (probe G7 đọc độ dài 4 byte không có cận trên, +finding của pentester ở review #165, mục "Yêu cầu phát hiện cho sản phẩm", ý 3). + +Bản ghi pre-auth, không mã hóa, byte đầu = loại: + +| Loại | Tên | Chiều | Nội dung | Kích thước | +|---|---|---|---|---| +| `0x01` | `challenge` | S→C | `n_s`: 32 byte ngẫu nhiên, mới cho mỗi kênh | 33 | +| `0x02` | `noise1` | C→S | Noise message 1: `e` (32) ‖ payload rỗng đã mã hóa (16) | 49 | +| `0x03` | `noise2` | S→C | Noise message 2: `e` (32) ‖ payload rỗng đã mã hóa (16) | 49 | +| `0x04` | `cpace1` | C→S | `Ya` (32), chỉ cho ghép bằng PIN ("Chế độ ghép đôi") | 33 | +| `0x05` | `cpace2` | S→C | `Yb` (32), chỉ cho ghép bằng PIN | 33 | +| `0x7F` | `reject` | S→C | Rỗng; nghĩa là `LINK_REJECTED` (0.8.4) | 1 | + +#### Bắt tay bảo mật link + +Mẫu `Noise_NNpsk0_25519_ChaChaPoly_SHA256` (Noise rev. 34), psk = `K_bt_link` của cặp, prologue = +`"HLBT1"` ‖ `n_s`. C = M-APP (bên khởi tạo), S = A-SVC (bên đáp). + +1. Kênh mở; S gửi `challenge` ngay và bắt đầu đếm `BT_PREAUTH_TIMEOUT`. +2. C gửi `noise1` với psk của cặp có hint vừa khớp. +3. S lần lượt thử psk của từng cặp chưa thu hồi (quảng bá không mang `pair_id`) tới khi tag payload của message 1 + hợp lệ. Prologue gắn `n_s`, nên một `noise1` bị ghi lại không bao giờ hợp lệ trên kênh mới. Không cặp nào hợp + lệ → xác nhận khóa thất bại. +4. S gửi `noise2`; C kiểm tag payload (sai → C đóng kênh, CONN-05 E3). Hai bên `Split()` ra một CipherState cho + mỗi chiều; từ đây link gắn với cặp đó. +5. S chỉ coi link đã xác nhận khi bản ghi transport đầu tiên của C giải mã được; lỗi ở đây cũng là xác nhận khóa + thất bại. +6. Sau đó bắt tay phiên E2E (0.6.3) chạy bên trong link trên kênh điều khiển, không đổi gì. `session/hello` phải + ghi đúng `pair_id` của psk đã hợp lệ; nếu không, S trả `AUTH_FAILED` (`session/error`) rồi đóng link. + +Mọi lỗi trước khi xong bước 5 → S gửi `reject` khi gửi được rồi đóng kênh. `reject` là lỗi pre-auth duy nhất trên +sóng: nó không cho biết nguyên nhân là vượt giới hạn tiếp nhận, hết giờ, đang bị chặn hay xác nhận khóa thất bại. + +#### Bản ghi transport và kênh con + +Sau bước 4, mọi bản ghi là một Noise transport message: ciphertext của plaintext đã đệm + tag 16 byte, nonce = bộ +đếm 64-bit của chiều đó, associated data rỗng. Mỗi bên gọi Noise `Rekey()` trên CipherState gửi sau mỗi +`BT_LINK_REKEY_RECORDS` bản ghi, bên nhận làm theo ở cùng số đếm (không có message). Plaintext: + +| Offset | Độ dài | Trường | Mô tả | +|---|---|---|---| +| 0 | 1 | `chan` | `0x01` điều khiển, `0x02` bulk (nội dung: 0.4.7); giá trị khác → đóng link | +| 1 | 1 | `flags` | bit0 `more`: còn mảnh tiếp theo của cùng message; bit1 `long`: mảnh thuộc làn dài của kênh điều khiển (chỉ kênh `0x01`, xem dưới); các bit khác bằng 0 | +| 2 | 2 | `dlen` | uint16 BE, độ dài `data`, ≤ `BT_FRAGMENT_MAX` | +| 4 | `dlen` | `data` | Một mảnh của một message | +| 4 + `dlen` | phần còn lại | đệm | Byte 0, đệm tới cỡ nhỏ nhất trong 256, 1 024 hoặc 4 096 byte đủ chứa frame | + +- **Kênh điều khiển** (`0x01`): một message = một JSON envelope (0.5.1) dạng UTF-8, đúng thứ một WS text frame + mang trên `/v1/ctl`. Luật envelope (0.5.1) giữ nguyên. Một envelope có thể tới 256 KiB (0.5.1 luật 4), tức khoảng + 20 s chiếm sóng ở 12.9 KB/s, nên kênh có hai làn, phân biệt bằng `long`: + - *Làn ngắn* (`long` = 0): envelope tối đa `BT_FRAGMENT_MAX` byte, luôn đúng một mảnh. `more` = 1 mà `long` = 0 + → đóng link. + - *Làn dài* (`long` = 1): mọi envelope tới `BT_MESSAGE_MAX`, chia mảnh tối đa `BT_BULK_FRAGMENT_MAX` byte, mỗi + chiều chỉ một message tại một thời điểm. Bên gửi đưa vào làn này mọi envelope dài hơn `BT_FRAGMENT_MAX`, mọi envelope `session` + (`rekey`, `bye`…), và envelope ngắn cùng `type` với một message dài còn trong hàng đợi hoặc đang gửi. Bên nhận + chỉ ráp lại làn này và đóng link khi một mảnh của làn dài có `dlen` vượt `BT_BULK_FRAGMENT_MAX` hoặc một message vượt + `BT_MESSAGE_MAX` trước mảnh cuối. + - *Thứ tự.* Mỗi làn giữ đúng thứ tự bên gửi, và bên nhận xử lý envelope ngay khi nó đủ mảnh. Vì vậy envelope ngắn + có thể vượt một envelope dài khác `type` (văn bản clipboard không bao giờ chờ 20 s sau một lô SMS); các envelope + cùng `type` và mọi envelope `session` giữ đúng thứ tự đã gửi. +- **Ưu tiên:** bên gửi giữ ba hàng đợi và ở mỗi ranh giới mảnh gửi từ hàng đầu tiên còn dữ liệu: điều khiển ngắn, + rồi điều khiển dài, rồi bulk. Tổng số bản ghi điều khiển dài và bulk đã ghi vào stream L2CAP mà chưa đi hết không + vượt quá `BT_BULK_INFLIGHT`, nên envelope ngắn chờ nhiều nhất sau chừng đó bản ghi 1 040 byte (khoảng 160 ms) và + bản ghi đang phát. Điều khiển dài đi trước bulk, không chia đều: envelope dài hiếm khi đi qua Bluetooth vì ảnh đi + làn bulk (0.4.7). Probe G7 dùng một luồng FIFO duy nhất và không đạt AC1 khi đang truyền bulk (p95 một chiều + 4 769–7 768 ms, mục "Lần 1" và "Lần 2" của báo cáo spike), vì vậy mới có luật này. +- **Đệm** giấu `type` của envelope sau ba cỡ (AC9); `id` và `ts` chỉ đi bên trong ciphertext. Envelope dài, giống + bulk, hiện ra thành một chuỗi bản ghi 1 040 byte: lộ kích thước với sai số khoảng 1 KiB, không lộ `type`. +- **Giữ kết nối:** cứ `BT_PING_INTERVAL` không có lưu lượng khác, client gửi `ping/ping` (CONN-02 API 2); bên nào + không nhận bản ghi nào trong `BT_IDLE_TIMEOUT` thì đóng link. +- **Không có close code:** link không có thứ tương đương close code WS (0.8.3). Một bên kết thúc phiên đã lập bằng + `session/bye` đã mã hóa, như phiên qua relay (CONN-02 API 4), rồi đóng kênh; trước khi bắt tay phiên thành công + thì dùng `session/error` (CONN-01 API 6) rồi đóng. Kênh đóng mà không có `session/bye` được tính là mất kết nối. + +#### Tiếp nhận (A-SVC) + +- Tối đa `BT_LINK_LIMIT` link L2CAP cùng lúc, trong đó tối đa `BT_PREAUTH_LIMIT` link chưa xong bước 5. Kênh vượt + một trong hai giới hạn nhận `reject` rồi bị đóng. +- Link chưa xong bước 5 trong `BT_PREAUTH_TIMEOUT` nhận `reject`; link đã xác nhận mà không có `session/hello` hợp + lệ (trên link ghép đôi: `pair/hello`, "Chế độ ghép đôi") trong `HANDSHAKE_TIMEOUT` thì bị đóng. +- BLE không cho địa chỉ client để đếm theo, nên lỗi được đếm cho cả bộ lắng nghe Bluetooth: mỗi lần xác nhận khóa + thất bại (bước 3 và 5) và mỗi `session/hello` bị từ chối với `AUTH_FAILED` tính là một `AUTH_FAILED`; đủ + `BT_AUTH_FAIL_BLOCK` lần trong một phút → bộ lắng nghe trả `reject` cho mọi kênh mới trong một phút. Bộ đếm của + LAN (`CTL_PREAUTH_LIMIT`, `CTL_IP_BLOCK`) không thấy link Bluetooth. +- Mỗi cặp một phiên trên mọi route (0.4.1): phiên mới xác nhận được khóa thì thay phiên cũ, bất kể route; qua + Bluetooth bên bị thay nhận `session/bye {reason: replaced}`. + +#### Chế độ ghép đôi (luồng B và C của PAIR-01) + +Ghép đôi lần đầu khi điện thoại và Mac không chung LAN: QR (luồng B; định dạng QR không đổi) hoặc PIN (luồng C, +CPace, ý cuối). + +- **Quảng bá.** Khi A-SVC đang giữ một cửa sổ ghép đôi QR (PAIR-01 bước 6) và dùng được Bluetooth (đang bật, đã cấp + Nearby devices trên Android 12+, `link.bluetooth` bật), nó khởi động lại advertising set (server socket mới, PSM + mới) với `pr_bt` là hint đầu tiên, sau đó tối đa `BT_ADV_MAX_HINTS` − 1 hint của cặp theo luật ở trên. `pr_bt` = + 4 byte đầu của HMAC-SHA256(`K_bt_pdisc`, `"HLBTPAIR1"`). Định dạng không đổi, nên người quan sát không phân biệt + được chế độ ghép đôi với quảng bá thường; `pr_bt` đổi theo từng QR và không bao giờ tính từ `pk`, nên không nối + được hai lần ghép đôi với nhau (AC9). Khi cửa sổ đóng, A-SVC khởi động lại set không có `pr_bt`. +- **Client.** Ở PAIR-01 bước 7, M-APP quét thêm với bộ lọc `BT_SERVICE_UUID` và coi gói quảng bá nào có `pr_bt` của + chính nó trong danh sách hint là một ứng viên. Ứng viên LAN được ưu tiên; ứng viên Bluetooth được nối ngay khi + không có ứng viên LAN. Nhiều máy phát cùng `pr_bt` (gói quảng bá bị chép lại) được thử lần lượt cho tới khi hết + 20 s của PAIR-01 bước 7. +- **Bắt tay bảo mật link.** Bước 1–5 ở trên, không đổi, với psk = `K_bt_plink`. Khi cửa sổ đang mở, S thử + `K_bt_plink` trước, rồi tới psk của từng cặp chưa thu hồi. Link có `noise1` hợp lệ với `K_bt_plink` là **link ghép + đôi**: như vậy frame đầu tiên của một lần ghép đôi đã được xác thực bằng khóa sinh từ `pairing_secret` trước khi + suất pre-auth trở thành link đã xác nhận. +- **Link ghép đôi.** Trong `HANDSHAKE_TIMEOUT` sau bước 5, C gửi `pair/hello` với `mode = "qr"` trên kênh điều khiển + `0x01`; sau đó PAIR-01 API 2–6 chạy dưới dạng envelope trên kênh đó (payload không mã hóa như trên `/v1/pair`, nằm + bên trong lớp mã hóa link). Envelope có `type` khác trên link ghép đôi, hoặc envelope `pair` trên link của một cặp + → đóng link, không trả lời. Link ghép đôi đã gửi `pair/hello` hợp lệ thì giữ cửa sổ: S thôi thử `K_bt_plink`, nên + máy thứ hai muốn giành cửa sổ sẽ xác nhận khóa thất bại và nhận `reject` (mỗi cửa sổ một client, PAIR-01 API 2 logic 4). +- **Sau `pair/done`.** Link được giữ lại và trở thành link điều khiển của cặp mới: envelope tiếp theo từ C phải là + `session/hello` mang `pair_id` vừa tạo trên link này (bước 6 ở trên), trong `HANDSHAKE_TIMEOUT`; nếu không, + `session/error` `AUTH_FAILED` rồi đóng. `pair/error`, hoặc link bị đóng trước `pair/done`, kết thúc lần ghép đôi: + không lưu gì (điều kiện sau của PAIR-01). +- **PIN (luồng C của PAIR-01, sếp quyết định ngày 2026-10-28).** PIN 6 số ghép qua Bluetooth bằng CPace, nên PIN + không bao giờ đi qua sóng dưới bất kỳ dạng nào và một lần trao đổi bị ghi lại không kiểm được ngoại tuyến: + - *Quảng bá.* Khi A-SVC đang giữ cửa sổ PIN (PAIR-01 A4) và dùng được Bluetooth, nó khởi động lại advertising set với + `BT_PIN_HINT` là hint đầu tiên (sau đó tối đa `BT_ADV_MAX_HINTS` − 1 hint của cặp). Dấu hiệu này giống nhau cho mọi + điện thoại ở chế độ PIN và chỉ cho biết "đang có một điện thoại ở chế độ ghép bằng PIN", như TXT `pm = 1` trong + LAN; nó không mang gì về thiết bị, PIN hay cặp (AC9). Khi cửa sổ đóng, set khởi động lại không có nó. Ở bước A2, + M-APP coi mọi gói quảng bá mang `BT_PIN_HINT` là ứng viên. + - *CPace.* Sau `challenge`, C gửi `cpace1` thay cho `noise1`; S chỉ nhận khi đang mở cửa sổ PIN (nếu không thì đóng + kênh, không trả lời). Bộ `CPACE-X25519-SHA512` của draft-irtf-cfrg-cpace (initiator-responder), bản draft cố định + theo vector S7.1 (`shared/test-vectors/cpace.json`, trường `draft`): PRS = byte UTF-8 của sáu chữ số; CI = + `"HLBTPIN1"`; sid = `n_s` (32 byte, mới cho mỗi kênh); ADa = ADb = rỗng. C: generator `g` = + `calculate_generator(SHA-512, PRS, CI, sid)` (ánh xạ Elligator2 của RFC 9380), scalar ngẫu nhiên `ya`, `Ya` = + X25519(`ya`, `g`) → `cpace1`. S: cùng `g`, `yb`, `Yb` → `cpace2`; `K` = X25519(`yb`, `Ya`). C: `K` = + X25519(`ya`, `Yb`). `K` toàn số 0 → đóng, không trả lời. `ISK` (64 byte) tính theo draft từ `sid`, `K`, `Ya`, `Yb`. + - *Khóa từ ISK* (chỉ trong bộ nhớ, xóa cùng PIN): `K_bt_pinlink` = HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = + `"handlive/v1/bt-pin-link"`, L = 32), là psk Noise của bước 2–5 ở trên (cùng prologue); `K_bt_pin` = + HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-pair"`, L = 32), thay cho `K_pin` ở PAIR-01 + bước 9–11 (0.6.2). Link qua được `noise1` với `K_bt_pinlink` là **link ghép đôi PIN**: áp dụng các luật "Link ghép + đôi" và "Sau `pair/done`" ở trên, với `pair/hello` `mode = "pin"`. `mode = "qr"` trên link ghép đôi PIN, hoặc + `mode = "pin"` trên link ghép đôi QR → `pair/error` `AUTH_FAILED` rồi đóng. + - *Chỉ dò được trực tuyến.* PIN sai ở bất kỳ bên nào làm tag `noise1` sai ở S (hoặc `noise2` ở C): một lần thử. Kẻ + đứng giữa chỉ được đúng một lần đoán PIN cho mỗi kênh, kẻ nghe lén thụ động thì không được lần nào. Điện thoại + giữ **một bộ đếm cho mỗi cửa sổ PIN, dùng chung cho cả hai đường truyền**: mỗi `pair/offer` trong LAN kết thúc mà + không có `pair/done` và mỗi kênh Bluetooth thất bại sau `cpace1` đều cộng một; tổng tới `PIN_MAX_ATTEMPTS` thì + đóng cửa sổ như "PIN hết hạn" (PAIR-01 E2) và từ chối mở mọi cửa sổ PIN mới, trong LAN hay qua Bluetooth, trong + `BT_PIN_LOCKOUT` (PAIR-01 E10). Mac giữ cùng một bộ đếm duy nhất cho mỗi PIN (`PIN_INVALID` trong LAN cộng các + kênh Bluetooth thất bại) và sinh PIN mới khi tới `PIN_MAX_ATTEMPTS` (E7). Với 10^6 PIN và 3 lần thử mỗi PIN, xác + suất đoán trúng trong một cửa sổ PIN là 3 × 10^-6. + - *Thời gian.* Thêm một lượt đi về so với link QR (`cpace1`/`cpace2`); điện thoại đang ở trong tay người dùng khi + nhập PIN nên không có Doze, `BT_PREAUTH_TIMEOUT` giữ nguyên. +- **Tiếp nhận.** Link ghép đôi dùng chung `BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, `BT_PREAUTH_TIMEOUT` và + `BT_AUTH_FAIL_BLOCK` với link của cặp. Thiết bị không có `pairing_secret` (luồng B) hoặc không biết PIN (luồng C) + chỉ giữ được suất pre-auth duy nhất tối đa `BT_PREAUTH_TIMEOUT` cho mỗi kênh và không bao giờ giành được cửa sổ; + lỗi của nó được tính vào `BT_AUTH_FAIL_BLOCK`, có thể chặn một lần ghép đôi thật trong một phút (rủi ro đã chấp + nhận, rủi ro 12 của plan: người dùng thử lại hoặc ghép qua LAN). +- **AC8.** Link ghép đôi không bao giờ đụng tới cặp đã có: kho cặp chỉ đổi ở PAIR-01 bước 11, sau các bước kiểm `mac` + khóa bằng `pairing_secret`; `reject` và kênh bị đóng chỉ làm hỏng lần thử này. + +### 0.4.6 Kênh Wi-Fi Direct cho bulk (P7) + +Đường nhanh cho ảnh khi điện thoại và Mac không chung LAN (kịch bản X), chỉ mở khi cần và đóng ngay khi không còn cần. +Kênh chỉ mang kết nối stream bulk; kênh điều khiển vẫn ở link Bluetooth (0.4.5). Chỉ Mac; iOS/iPadOS không có route +Bluetooth và không có Wi-Fi Direct. Luồng và ngoại lệ: CONN-06. Nội dung bulk, khóa theo kết nối và lập lịch: +0.4.7. + +- **Cấu trúc (quyết định).** A-SVC tạo một group Wi-Fi Direct tự chủ (`WifiP2pManager.createGroup` với một + `WifiP2pConfig`, API 29+) và làm group owner; M-APP vào group như một client WPA2 thường bằng CoreWLAN. Lý do + ([nghiên cứu 2026-10-01](../../plans/20260925-implementation/reports/research-direct-wifi-link-2026-10-01.vi.md), mục 3 + và 5): macOS không có API Wi-Fi Direct hay Wi-Fi Aware (F2), nên Mac chỉ có thể vào một mạng; app bên thứ ba không + bật được hotspot của điện thoại; `LocalOnlyHotspot` buộc SSID và passphrase ngẫu nhiên và lỗi trên một số bản ROM của + hãng; group Wi-Fi Direct cho app tự đặt SSID và passphrase (API 29+) và giữ nguyên mạng Wi-Fi riêng của điện thoại + (STA + P2P). +- **Thông tin vào mạng.** A-SVC sinh mới cho mỗi group và không bao giờ dùng lại: `ssid` = `"DIRECT-"` ‖ 10 ký tự ngẫu + nhiên trong `[A-Za-z0-9]` (17 byte; Android bắt buộc tiền tố `DIRECT-xy`, ở đây cả `xy` cũng ngẫu nhiên, nên tên + không nói gì về HandLive hay cặp); `passphrase` = base64url không đệm của 18 byte ngẫu nhiên (24 ký tự, 144 bit; WPA2 + cần 8–63). Chúng chỉ đi trong `ack` của `direct/start`, một envelope E2E bên trong link Bluetooth: không bao giờ được + quảng bá, không ghi log, chỉ nằm trong bộ nhớ và bị xóa khi group dừng. Group mới thì thông tin mới, nên không cần lịch + xoay vòng: một group sống tối đa `DIRECT_WIFI_MAX`. +- **Cấu hình group.** `WifiP2pConfig.Builder().setNetworkName(ssid).setPassphrase(passphrase) + .setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_AUTO)`; từ API 36 thêm + `setPccModeConnectionType(WifiP2pConfig.PCC_MODE_CONNECTION_TYPE_LEGACY_OR_R2)` (Mac là client legacy, F4). A-SVC + không bao giờ bật/tắt Wi-Fi và không bao giờ rời mạng của chính nó để dựng group. +- **Điều kiện vào (Mac, AC4).** M-APP chỉ xin group (`direct/start`) khi đủ mọi điều kiện: phiên chạy qua Bluetooth + (không có phiên LAN); có một lần truyền bulk đang chờ theo chiều nào cũng được (0.4.7; điện thoại báo phần của nó bằng + `direct/want`); `link.direct_wifi` bật; đã cấp quyền Vị trí; Wi-Fi đang bật và không nối mạng nào trong ít nhất + `DIRECT_WIFI_IDLE_MIN`; lần quét gần nhất không thấy mạng nào trong danh sách mạng đã biết của Mac; không có lần thử + Wi-Fi Direct nào thất bại trong `DIRECT_WIFI_RETRY_AFTER`. Mac đang nối bất kỳ mạng nào, kể cả mạng không có Internet, + không bao giờ vào group: việc truyền đi qua Bluetooth ở nền (bảng route của plan). Vì vậy việc vào group không bao giờ kéo Mac khỏi + một mạng khác. +- **Điều kiện rời.** Mac rời group (`CWInterface.disassociate()`) và gửi `direct/stop` khi: không stream bulk nào có dữ + liệu trong `DIRECT_WIFI_LINGER`; group đã tồn tại `DIRECT_WIFI_MAX`; lần quét định kỳ mỗi `DIRECT_WIFI_SCAN_INTERVAL` + thấy một mạng đã biết (để macOS quay về mạng đó); xuất hiện phiên LAN; phiên Bluetooth kết thúc. macOS tự chuyển sang + mạng khác cũng tính là rời. A-SVC xóa group (`WifiP2pManager.removeGroup`) khi nhận `direct/stop`, theo cùng giới hạn + `DIRECT_WIFI_LINGER` và `DIRECT_WIFI_MAX` tự đếm phía nó, khi không client nào vào trong `DIRECT_WIFI_JOIN_TIMEOUT`, và + khi phiên điều khiển kết thúc. +- **Xác thực trên group (AC8).** Biết passphrase thì một thiết bị vào được group nhưng không nói chuyện được với HandLive. + M-APP kết nối tới `host`:`port` trong `ack` bằng TLS ghim `peer_tls_sha256`, đúng như trong LAN (0.4.1), socket gắn + vào giao diện Wi-Fi; chứng chỉ không khớp → `TLS_PIN_MISMATCH`, rời group. Trên kết nối có địa chỉ cục bộ là địa chỉ + group owner, A-SVC chỉ nhận đường stream bulk `/v1/stream/bulk` (0.4.7), có bắt tay stream khóa bằng phiên điều khiển đang sống (0.6.3 + bước 7); `/v1/ctl`, `/v1/pair` và mọi thứ khác trên địa chỉ đó → đóng 4400. Thiết bị trong group mà không có khóa + phiên thì không hoàn tất được bắt tay stream (4401). +- **Không phải LAN.** Group không bao giờ là route của phiên: instance mDNS hay địa chỉ thấy trên giao diện của group + không phải ứng viên CONN-01 và không kích hoạt nâng cấp ở 0.11; phiên vẫn ở Bluetooth trong khi ảnh dùng group. +- **Mạng đã lưu.** Sau khi rời, M-APP xóa mạng khỏi danh sách mạng ưu tiên của Mac khi CoreWLAN cho phép mà không hỏi + quyền quản trị; nếu không, mục đó ở lại, vô hại vì SSID đó không bao giờ được dùng lại (G7 chưa thử, xem yêu cầu đặc + biệt của CONN-06). + +### 0.4.7 Làn bulk (P7) + +Các lần truyền lớn (ảnh của CLIP-03, văn bản quá `CLIP_INLINE_MAX`, CLIP-04) giữ nguyên các message của CLIP-03 +(`clipboard/push` có `transfer`, `clipboard/chunk` 64 KiB, `clipboard/cancel` và `ack` của chúng); chỉ làn chở chúng thay +đổi theo route. Mọi envelope khác, kể cả văn bản clipboard gửi thẳng, vẫn ở kênh điều khiển. + +- **Chọn làn** (bảng route của plan: LAN > Wi-Fi Direct > Bluetooth), quyết định lúc bắt đầu một lần truyền: + +| Tình huống | Làn bulk | Khóa | +|---|---|---| +| Phiên LAN | `/v1/ctl`, như hiện nay (CLIP-03: tối đa 4 chunk trong bộ đệm gửi) | Khóa phiên (0.6.3) | +| Phiên Bluetooth, kích thước ≥ `BULK_DIRECT_MIN`, đủ điều kiện vào Wi-Fi Direct (0.4.6) | `wss://{host}:{port}/v1/stream/bulk` trên group (CONN-06) | Khóa theo kết nối (bên dưới) | +| Phiên Bluetooth, các trường hợp còn lại, hoặc Wi-Fi Direct thất bại | Kênh `0x02` của link (0.4.5), chạy ở nền | Khóa phiên bên trong lớp mã hóa link | + + Lần truyền từ `BULK_DIRECT_MIN` trở lên chờ Wi-Fi Direct tối đa `DIRECT_WIFI_JOIN_TIMEOUT`, group chưa lên thì bắt đầu + qua Bluetooth. Dưới mức đó Bluetooth nhanh hơn việc vào group (12.9 KB/s, G7 Lần 2: 128 KiB ≈ 10 s so với ngân sách + vào mạng 8 s). Không có giới hạn kích thước riêng cho Bluetooth: tới `CLIP_MAX_IMAGE` vẫn chạy ở nền (AC3 của plan). + Không có route nào → không gửi gì; CLIP-03 gửi lại clip mới nhất khi phiên bắt đầu (QC7). +- **Khóa theo kết nối trên `/v1/stream/bulk`.** Bắt tay stream của 0.6.3 bước 7 với kênh `bulk` (`K_stream` từ phiên + điều khiển đang sống, `bulk/stream_hello` và `bulk/stream_welcome`), rồi `K_bulk` = HKDF-SHA256(ikm = `K_stream` + (96 byte), salt = `nonce_c` ‖ `nonce_s`, info = `"handlive/v1/bulk-conn"`, L = 64) → `k_c2s` (32) ‖ `k_s2c` (32). Mọi + envelope trên kết nối đó là envelope 0.5.1 mã hóa bằng các khóa này thay cho khóa phiên; tập chống trùng của 0.5.1 + luật 2 tính riêng cho mỗi kết nối. Kết nối mới, kể cả trong cùng phiên, có nonce mới nên có khóa mới; `PRK` dài hạn + không bao giờ được dùng trực tiếp. Thiết bị trong group mà không có phiên đang sống thì không qua được `stream_hello` + (4401, AC8). Phiên điều khiển kết thúc thì kết nối bulk đóng theo. +- **Lập lịch trên Bluetooth.** Mảnh điều khiển luôn đi trước (0.4.5 "Ưu tiên": điều khiển ngắn, điều khiển dài, rồi + bulk). Một mảnh bulk mang tối đa `BT_BULK_FRAGMENT_MAX` byte, nên vừa bản ghi 1 040 byte và chiếm sóng khoảng 80 ms + ở 12.9 KB/s; bên gửi giữ tối đa `BT_BULK_INFLIGHT` bản ghi điều khiển dài và bulk đã ghi vào stream L2CAP mà chưa đi + hết, nên một envelope điều khiển ngắn không bao giờ phải chờ sau nhiều hơn chừng đó. A7.4 đo điều này so với AC1 (≤ 200 ms khi đang truyền bulk). +- **Wi-Fi Direct và điều khiển.** Kênh điều khiển không bao giờ chuyển sang group (0.4.6 "Không phải LAN"); bulk trên + group dùng đường sóng khác, nên không làm trễ envelope điều khiển trên Bluetooth. +- **Đổi làn giữa chừng: làm lại từ đầu, không nối tiếp.** Khi làn của một lần truyền đang chạy mất đi hoặc có làn tốt + hơn (Wi-Fi Direct lên khi đang chạy trên Bluetooth; mất group, CONN-06 E7/E8; phiên LAN thay phiên Bluetooth), bên gửi + gửi `clipboard/cancel` `rerouted` trên làn cũ nếu còn gửi được, rồi truyền lại toàn bộ với `transfer_id` mới và cùng + `clip_id` trên làn mới, kể cả khi đã quá 120 s của QC7, miễn đó vẫn là clip mới nhất. Tối đa `BULK_MAX_RESTARTS` lần + làm lại cho mỗi clip, sau đó áp dụng CLIP-03 E8. Chỉ làm lại vì có làn tốt hơn khi còn hơn một nửa lần truyền. +- **Thời hạn và tiến độ.** `CLIP_TRANSFER_IDLE_TIMEOUT` giữ nguyên trên mọi làn (bản ghi 1 040 byte với RTT Doze 3 s vẫn + tới trong hạn). Qua Bluetooth, tiến độ hiện cho các lần truyền từ `BULK_DIRECT_MIN` trở lên (CLIP-03 trường 2) kèm + dòng chữ ở CLIP-03 trường 10. + ## 0.5 Khung tin ### 0.5.1 Envelope JSON (WS text frame) @@ -266,6 +565,8 @@ Bên nhận bỏ khung có `seq` ≤ `seq` lớn nhất đã nhận (chống ph | `ik_dh` | X25519 | Như `ik_sig` | Như `ik_sig` | | Khóa TLS | ECDSA P-256 + chứng chỉ tự ký | Android: PKCS#12, mật khẩu bọc bởi Keystore | Lúc cài. Kho PKCS#12 hỏng hoặc mất mật khẩu → sinh lại khóa và chứng chỉ; mọi client thấy `TLS_PIN_MISMATCH` ở mọi instance → hiển thị "Cần ghép nối lại" (CONN-01 E2) | | `pairing_secret` | 32 byte ngẫu nhiên | Chỉ trong bộ nhớ Mac/iOS và trong QR | 120 s | +| `K_bt_pdisc`, `K_bt_plink` | HKDF(`pairing_secret`), 0.4.5 | Chỉ trong bộ nhớ (Mac và Android) | Như `pairing_secret`; xóa cùng lúc | +| `ISK`, `K_bt_pinlink`, `K_bt_pin` | CPace, rồi HKDF (0.4.5, PIN) | Chỉ trong bộ nhớ (Mac và Android) | Một kênh; xóa cùng PIN | | `PRK` | 32 byte | Android: cột `prk_enc` (Tink AEAD, khóa trong Keystore). Mac/iOS: Keychain, account = `pair_id` | Theo cặp ghép nối | | `K_push` | HKDF(`PRK`, info = `"handlive/v1/push"`) | Tính khi cần | Theo cặp | | `k_c2s`, `k_s2c` | 32 byte | Bộ nhớ | Theo kết nối; rekey sau 24 h hoặc 10 000 envelope mỗi chiều | @@ -298,7 +599,10 @@ Bên nhận bỏ khung có `seq` ≤ `seq` lớn nhất đã nhận (chống ph 24 byte. - Relay: `ed25519-dalek`, `jsonwebtoken`, `ring`. - Bộ test vector liên nền tảng (Kotlin ↔ Swift ↔ Rust) cho: `device_id`, `PRK`, bắt tay phiên, mã - hóa envelope, khung HL. Chạy trong CI của cả ba phía. + hóa envelope, khung HL. Chạy trong CI của cả ba phía. CPace (`cpace.json`, Kotlin và Swift): vector + `CPACE-X25519-SHA512` của chính draft, rồi các trường hợp của HandLive (`n_s`, PIN, `ya`, `yb` cố định → `g`, `Ya`, + `Yb`, `K`, `ISK`, `K_bt_pinlink`, `K_bt_pin`), PIN có số 0 đầu, và các trường hợp âm (`Ya` bậc thấp, `K` toàn số 0, + PIN sai ở một bên). ### 0.6.2 Ghép nối @@ -306,6 +610,10 @@ Bên nhận bỏ khung có `seq` ≤ `seq` lớn nhất đã nhận (chống ph SHA-256(`device_id` nhỏ hơn ‖ `device_id` lớn hơn, dạng 16 byte), info = `"handlive/v1/pair"`, L = 32). - Với PIN (dự phòng): thay `pairing_secret` bằng `K_pin` = Argon2id(PIN, salt = `nonce_c` ‖ `nonce_s`, t = 3, m = 64 MiB, p = 4, L = 32); PIN là byte UTF-8 của đúng 6 chữ số (giữ số 0 đầu), Argon2 phiên bản 0x13, không có secret và dữ liệu phụ. +- Qua Bluetooth bằng PIN (luồng C của PAIR-01): thay `pairing_secret` bằng `K_bt_pin` (0.4.5, từ CPace) thay vì + `K_pin` Argon2id; phần còn lại không đổi. +- Qua Bluetooth (luồng B của PAIR-01, QR): `K_pa`, `PRK` và `attestation` tính đúng như trên; không có kết nối + TLS, nên client ghim `tls_sha256` được `mac` của offer bảo vệ, như khi đi qua relay. - Chuỗi đưa vào MAC, chữ ký hoặc hash (tên thiết bị…) là byte UTF-8 nguyên văn, không chuẩn hóa Unicode (NFC/NFD). - Bản chứng thực ghép nối (`attestation`): `"HLPAIR1"` ‖ `pair_id` (16) ‖ `device_id` Android(16) ‖ `device_id` client(16) ‖ `ik_sig_pub` Android(32) ‖ `ik_sig_pub` client(32) ‖ `created_at` (int64 @@ -344,7 +652,7 @@ C = Mac/iOS, S = Android. Hai envelope đầu có payload chưa mã hóa (0.5.1) chuyển sau khi gửi xong; khóa cũ giữ thêm 30 s cho envelope đang bay. 7. **Khóa kênh stream** (`/v1/stream/*`): `K_stream` = HKDF(`secret`, info = `"handlive/v1/stream/" ‖ ‖ "/" ‖ `, L = 96) → `k_auth` (32) ‖ `k_c2s` (32) ‖ - `k_s2c` (32). Tin đầu tiên trên kênh stream là envelope `camera` /`call_audio` op `stream_hello` + `k_s2c` (32). Tin đầu tiên trên kênh stream là envelope `camera` /`call_audio` /`bulk` op `stream_hello` `{session_id, nonce, mac}` với `mac` = HMAC-SHA256(`k_auth`, `"HLSTREAM1|"` ‖ `session_id` ‖ `nonce_c`); S trả `stream_welcome` `{session_id, nonce, mac}` với `mac` = HMAC-SHA256(`k_auth`, `"HLSTREAM1|welcome|"` ‖ `session_id` ‖ `nonce_c` ‖ `nonce_s`) — gắn cả hai nonce nên không phát @@ -357,7 +665,7 @@ C = Mac/iOS, S = Android. Hai envelope đầu có payload chưa mã hóa (0.5.1) - HKDF không ghi salt thì salt rỗng; không ghi L thì L = 32 (vd. `K_auth`). - Salt của `PRK`: hai `device_id` so theo 16 byte, thứ tự byte không dấu. - `info` của `K_stream`: UTF-8 `"handlive/v1/stream//"`, - `` ∈ {`camera`, `call-audio` }. `K_stream` luôn dẫn từ `secret` của bắt tay đầu (epoch 0) + `` ∈ {`camera`, `call-audio`, `bulk` } (`bulk` sau đó dẫn thêm khóa theo kết nối, 0.4.7). `K_stream` luôn dẫn từ `secret` của bắt tay đầu (epoch 0) trong suốt kết nối, kể cả sau rekey. - Rekey: shared = X25519(eph bên khởi tạo, eph bên nhận); salt = SHA-256(`nonce` bên khởi tạo ‖ `nonce` bên nhận); 64 byte kết quả chia `k_c2s` ‖ `k_s2c` theo vai C/S (không theo bên khởi @@ -397,7 +705,7 @@ C = Mac/iOS, S = Android. Hai envelope đầu có payload chưa mã hóa (0.5.1) `type` giữ đúng tập đã chốt (`clipboard | sms | call_event | call_audio | pair | ack | ping | capability`) và bổ sung `session`, `camera` cho bắt tay phiên và Phase 5, và `web` cho Duyệt web -tiếp (Phase 6, README §5 C21). +tiếp (Phase 6, README §5 C21), và `direct` cho kênh Wi-Fi Direct mở khi cần cùng `bulk` cho bắt tay stream của nó (Phase 7, 0.4.6, 0.4.7). | type | op | Chiều | Ack | Kênh | Chức năng | |------|----|-------|-----|------|-----------| @@ -414,7 +722,7 @@ tiếp (Phase 6, README §5 C21). | `session` | `bye` | Hai chiều | — | `/v1/ctl` | CONN-02, PAIR-03 | | `capability` | `hello` | Hai chiều | — | `/v1/ctl` | CONN-01 | | `capability` | `update` | Hai chiều | — | `/v1/ctl` | SET-02 | -| `ping` | `ping` | Hai chiều | Có | `/v1/ctl` qua relay | CONN-02 | +| `ping` | `ping` | Hai chiều | Có | `/v1/ctl` qua relay hoặc Bluetooth | CONN-02 | | `clipboard` | `push` | Hai chiều | Có | `/v1/ctl` | CLIP-01…04 | | `clipboard` | `chunk` | Hai chiều | — | `/v1/ctl` | CLIP-03, CLIP-04 | | `clipboard` | `cancel` | Hai chiều | — | `/v1/ctl` | CLIP-03 | @@ -442,8 +750,12 @@ tiếp (Phase 6, README §5 C21). | `camera` | `stats` | C→S | — | `/v1/ctl` | CAM-05 | | `camera` | `state` | S→C | — | `/v1/ctl` | CAM-02, CAM-05 | | `camera` | `stream_hello` / `stream_welcome` | C→S / S→C | — | `/v1/stream/camera` | CAM-02, CAM-04 | +| `bulk` | `stream_hello` / `stream_welcome` | C→S / S→C | — | `/v1/stream/bulk` | 0.4.7, CLIP-03 | | `web` | `active` | Hai chiều (Android → Mac, iPhone/iPad; Mac → Android) | — | `/v1/ctl` | WEB-01…05: `{page_id, url, title?, browser, observed_at}`, trang đang mở trong trình duyệt ở foreground của bên gửi; bản mới nhất thắng, gửi không chờ phản hồi như `call_event/state` | | `web` | `inactive` | Hai chiều (như `active`) | — | `/v1/ctl` | WEB-01…05: `{page_id}`, trang không còn mở ở foreground | +| `direct` | `start` | C→S | Có (kèm dữ liệu) | `/v1/ctl` qua Bluetooth | CONN-06: nhờ điện thoại tạo group Wi-Fi Direct; `ack` mang `{ssid, passphrase, host, port}` | +| `direct` | `stop` | Hai chiều | Có | `/v1/ctl` qua Bluetooth | CONN-06: `{reason}`, group không còn cần hoặc đã mất | +| `direct` | `want` | S→C | — | `/v1/ctl` qua Bluetooth | CONN-06: `{bytes}`, điện thoại có bulk đang chờ gửi sang Mac | | `ack` | — | Hai chiều | — | Mọi kênh | Phản hồi | Quy tắc payload của `call_event/app_call` (đặc tả đầy đủ ở CALL-05 API 1): `{call_id, app: {package, @@ -583,8 +895,8 @@ log, không hiển thị cho người dùng; giao diện chọn câu chữ theo | `PAIRING_CLOSED` | Ghép nối | Hết cửa sổ 120 s hoặc Mac đã làm mới QR | Quét QR mới (luồng PIN: lấy mã PIN mới) | | `PIN_INVALID` | Ghép nối | PIN sai (tối đa 3 lần) | Nhập lại; quá 3 lần Mac sinh PIN mới | | `AUTH_FAILED` | Phiên | HMAC hoặc chữ ký sai | Không thử lại tự động | -| `PAIR_UNKNOWN` | Phiên | Không có cặp tương ứng | Trong LAN (TLS ghim tới điện thoại): xóa cặp cục bộ, yêu cầu ghép nối lại. Qua relay, trước khi xác thực: `Backoff`, không bao giờ hủy cặp (CONN-03 E9) | -| `PAIR_REVOKED` | Phiên | Cặp đã bị thu hồi | Trong LAN: xóa cặp cục bộ. Qua relay, trước khi xác thực: `Backoff`, không bao giờ hủy cặp (CONN-03 E9) | +| `PAIR_UNKNOWN` | Phiên | Không có cặp tương ứng | Trên route có `phoneAuthenticatedByTLS` (LAN, USB: TLS ghim tới điện thoại): xóa cặp cục bộ, yêu cầu ghép nối lại. Trên route khác (relay, Bluetooth), trước khi xác thực: `Backoff`, không bao giờ hủy cặp (0.11, CONN-03 E9, CONN-05 E5) | +| `PAIR_REVOKED` | Phiên | Cặp đã bị thu hồi | Trên route có `phoneAuthenticatedByTLS`: xóa cặp cục bộ. Trên route khác, trước khi xác thực: `Backoff`, không bao giờ hủy cặp (0.11, CONN-03 E9, CONN-05 E5) | | `DECRYPT_FAILED` | Phiên | Không giải mã được envelope | Đóng phiên, kết nối lại | | `TLS_PIN_MISMATCH` | Phiên | Chứng chỉ không khớp ghim | Bỏ instance này, thử instance khác | | `CLIP_TOO_LARGE` | Clipboard | Vượt `max_text_bytes`/`max_image_bytes` | Báo người dùng | @@ -614,11 +926,12 @@ log, không hiển thị cho người dùng; giao diện chọn câu chữ theo | `CAM_DENIED_BY_USER` | Camera | Người dùng từ chối trên điện thoại | Dừng | | `CAM_ENCODER_UNSUPPORTED` | Camera | Không có bộ mã hóa H.264 phù hợp | Giảm độ phân giải hoặc dừng | | `CAM_THERMAL_LIMIT` | Camera | Máy quá nóng | Giảm chất lượng hoặc dừng | -| `CAM_TRANSPORT_UNSUPPORTED` | Camera | Phiên `/v1/ctl` đang đi qua relay; camera chỉ chạy qua LAN hoặc USB | Hiển thị "Cần cùng mạng Wi-Fi hoặc cắm cáp USB" | +| `CAM_TRANSPORT_UNSUPPORTED` | Camera | Phiên `/v1/ctl` đang đi qua relay hoặc Bluetooth; camera chỉ chạy qua LAN hoặc USB | Hiển thị "Cần cùng mạng Wi-Fi hoặc cắm cáp USB" | | `USB_ADB_UNAUTHORIZED` | USB | Điện thoại chưa cho phép gỡ lỗi USB | Hướng dẫn chấp nhận hộp thoại RSA | | `USB_ADB_UNAVAILABLE` | USB | Chưa bật gỡ lỗi USB hoặc không có adb | Mở wizard (CAM-04) | | `MAC_EXTENSION_NOT_ACTIVE` | Camera | Camera Extension chưa được kích hoạt | Mở CAM-01 | | `MAC_MIC_DRIVER_MISSING` | Camera | Chưa cài driver micro ảo | Mở CAM-01 | +| `DIRECT_WIFI_UNAVAILABLE` | Link | Điện thoại không tạo được group Wi-Fi Direct; `details.reason` ∈ {`wifi_off`, `location_off`, `p2p_unsupported`, `busy`, `error`} | Gửi qua Bluetooth ở nền; không gửi `direct/start` mới trước `DIRECT_WIFI_RETRY_AFTER` (CONN-06 E3) | ### 0.8.2 Mã lỗi relay (HTTP) @@ -654,6 +967,14 @@ log, không hiển thị cho người dùng; giao diện chọn câu chữ theo | 4429 | `RATE_LIMITED` — trên `/v1/ctl`: quá 16 kết nối chưa bắt tay hoặc quá 4 kết nối từ một IP, hoặc IP bị chặn 5 phút (sai `mac`, hoặc lỗi trước bắt tay lặp lại, CONN-01 API 3, API 4); trên `/v1/pair`: quá 4 kết nối hoặc quá 2 kết nối từ một IP (PAIR-01 API 2) | | 4500 | `INTERNAL` | +### 0.8.4 Mã lỗi kênh Bluetooth + +Kênh Bluetooth không có close code (0.4.5); lỗi trước xác thực duy nhất của nó là một bản ghi. + +| Mã | Ý nghĩa | Cách client xử lý | +|----|---------|----------------| +| `LINK_REJECTED` | Bản ghi `reject` trên kênh Bluetooth trước khi bắt tay bảo mật link xong: vượt giới hạn tiếp nhận, hết giờ, bộ lắng nghe đang chặn hoặc xác nhận khóa thất bại, không bao giờ nói là cái nào (0.4.5) | `Backoff` thường; không bao giờ hủy cặp, không bao giờ ngừng kết nối lại (0.11, CONN-05 E4) | + ## 0.9 Mô hình dữ liệu > **Về "query lấy từ mã":** tại ngày 2026-09-24 repo chưa có mã nguồn nên không trích được query từ @@ -919,6 +1240,9 @@ SET-02 quản lý các khóa này. | `call_audio.phone_bt_address` | string | rỗng | Mac | Địa chỉ Bluetooth của điện thoại người dùng chọn cho HFP | | `feature.camera` | bool | `false` | Android, Mac | Bật sau CAM-01 | | `relay.enabled` | bool | `true` | Tất cả | Cho phép dùng relay khi ngoài LAN | +| `link.bluetooth` | bool | `true` | Android, Mac | Kênh điều khiển Bluetooth (0.4.5, CONN-05); tắt → không quảng bá, không quét, phiên Bluetooth kết thúc | +| `link.bt_enable_asked_at` | timestamp | rỗng | Android | Lần gần nhất tự xin bật Bluetooth (SET-01 API 10); lần sau chờ `BT_ENABLE_PROMPT_INTERVAL` | +| `link.direct_wifi` | bool | `true` | Android, Mac | Kênh Wi-Fi Direct mở khi cần cho bulk (0.4.6, CONN-06); tắt → ảnh khi không có LAN chỉ đi Bluetooth | | `clip.auto_send` | bool | `true` | Android | Tự gửi khi sao chép (cần Accessibility, D4) | | `clip.a11y_consent_at` | timestamp | rỗng | Android | Thời điểm người dùng đồng ý công bố dùng Accessibility | | `clip.send_images` | bool | `true` | Tất cả | Đồng bộ ảnh | @@ -949,7 +1273,7 @@ SET-02 quản lý các khóa này. |------|---------|---------| | `CTL_PORT` | 47800 (dự phòng 47801–47809) | | | `PAIRING_WINDOW` | 120 s | Hạn QR/PIN và cửa sổ `/v1/pair` | -| `PIN_MAX_ATTEMPTS` | 3 | Điện thoại gửi tối đa 3 `pair/offer` cho mỗi PIN; sau lần thứ ba không có `pair/done` thì cửa sổ đóng như "PIN hết hạn" (PAIR-01 A4) | +| `PIN_MAX_ATTEMPTS` | 3 | Số lần thử thất bại cho mỗi cửa sổ PIN, đếm chung một lần cho LAN và Bluetooth: `pair/offer` không có `pair/done` cộng các kênh Bluetooth thất bại sau `cpace1`; tới lần thứ ba thì cửa sổ đóng như "PIN hết hạn" (PAIR-01 A4, C3) | | `HANDSHAKE_TIMEOUT` | 5 s | | | `CTL_PREAUTH_LIMIT` | 16 kết nối chưa bắt tay, tối đa 4 mỗi IP | `/v1/ctl`; vượt → 4429 (CONN-01 API 3) | | `CTL_IP_BLOCK` | 10 lỗi trước bắt tay (4408, `PAIR_UNKNOWN`, `BAD_REQUEST`) trong 5 phút → chặn IP 5 phút; chỉ tính lỗi xảy ra trước khi một `session/hello` qua được kiểm tra `mac`, và một phiên thiết lập thành công xóa bộ đếm của địa chỉ đó | Bên cạnh quy tắc `AUTH_FAILED` (5/phút); bị chặn → 4429 (CONN-01 API 4) | @@ -1018,13 +1342,38 @@ SET-02 quản lý các khóa này. | `WEB_PAGE_TTL` | 10 phút | Bên nhận quên trang sau chừng này không có `web/active` mới (WEB-02, WEB-04, WEB-05) | | `WEB_URL_MAX` | 8 KiB (UTF-8) | URL dài hơn không được gửi và bị bên nhận bỏ | | `WEB_TITLE_MAX` | 256 ký tự | Bên gửi cắt tiêu đề dài hơn; bên nhận bỏ tiêu đề dài hơn | +| `BT_SERVICE_UUID` | `3b3a01f9-ab27-44a8-bb68-b9b11a488bdb` (128-bit, tự sinh) | `0xFEED` 16-bit của probe G7 chỉ dùng cho spike, bản thật không bao giờ được dùng: SIG đã cấp nó cho công ty khác (0.4.5) | +| `BT_ADV_MAX_HINTS` | 2 | Giữ scan response ≤ 31 byte (0.4.5) | +| `BT_HINT_PERIOD` | 15 phút (900 000 ms) | Hint, PSM và advertising set xoay cùng lúc; client kiểm các epoch từ e − 4 tới e + 4 | +| `BT_PREAUTH_RECORD_MAX` | 64 byte | Bản ghi pre-auth lớn nhất (`rlen`) | +| `BT_RECORD_MAX` | 4 112 byte | Bản ghi transport lớn nhất (`rlen`): 4 096 byte plaintext đã đệm + tag 16 byte; `rlen` hợp lệ chỉ có 272, 1 040 và 4 112 | +| `BT_FRAGMENT_MAX` | 4 092 byte | `dlen` của một mảnh; cũng là cỡ envelope lớn nhất trên làn ngắn của kênh điều khiển (0.4.5) | +| `BT_MESSAGE_MAX` | 256 KiB | Message của làn dài kênh điều khiển sau khi ráp, đúng giới hạn envelope của 0.5.1 luật 4 | +| `BT_LINK_REKEY_RECORDS` | 65 536 bản ghi mỗi chiều | Noise `Rekey()` trên link | +| `BT_LINK_LIMIT` / `BT_PREAUTH_LIMIT` | 4 link / 1 link | Tiếp nhận ở A-SVC (0.4.5) | +| `BT_PREAUTH_TIMEOUT` | 7 s | Từ `challenge` tới bản ghi transport hợp lệ đầu tiên; hai lượt đi về ở RTT p95 Doze ≤ 3 s (sếp chốt 2026-10-28) = 6 s + 1 s biên độ | +| `BT_AUTH_FAIL_BLOCK` | 5 `AUTH_FAILED` trong 1 phút → từ chối mọi kênh mới trong 1 phút | Đếm cho cả bộ lắng nghe Bluetooth (0.4.5) | +| `BT_PING_INTERVAL` / `BT_IDLE_TIMEOUT` | 30 s / 45 s | Cùng giá trị với ping E2E qua relay và close code 4411 | +| `BT_ENABLE_PROMPT_INTERVAL` | 24 h | Mỗi khoảng này tự xin bật Bluetooth tối đa một lần (AC7, SET-01 API 10) | +| `BT_PIN_HINT` | `00 00 00 00` | Hint được quảng bá khi đang mở cửa sổ PIN qua Bluetooth (0.4.5 "Chế độ ghép đôi"); giống nhau cho mọi điện thoại | +| `BT_PIN_LOCKOUT` | 5 phút | Sau khi cửa sổ PIN đóng vì đủ `PIN_MAX_ATTEMPTS` lần thất bại, điện thoại không mở cửa sổ PIN nào, trong LAN hay qua Bluetooth, trong chừng này (PAIR-01 E10) | +| `DIRECT_WIFI_IDLE_MIN` | 30 s | Wi-Fi của Mac phải không nối mạng nào trong chừng này mới được vào group (AC4; bỏ qua lúc chuyển mạng ngắn) | +| `DIRECT_WIFI_JOIN_TIMEOUT` | 8 s | Từ lúc gửi `direct/start` tới khi có kết nối TLS đã ghim trên group; chừa chỗ trong ngân sách AC3 (ảnh đầu ở X ≤ 10 s) | +| `DIRECT_WIFI_LINGER` | 60 s | Chừng này không có dữ liệu bulk → rời và xóa group (giữ group cho các ảnh tiếp theo, AC3) | +| `DIRECT_WIFI_MAX` | 10 phút | Thời gian sống dài nhất của một group; lần truyền sau mở group mới với thông tin vào mạng mới (AC4) | +| `DIRECT_WIFI_SCAN_INTERVAL` | 30 s | Chu kỳ quét trên Mac khi đang ở group, để rời ngay khi thấy một mạng đã biết (AC4) | +| `DIRECT_WIFI_RETRY_AFTER` | 5 phút | Trong chừng này không gửi `direct/start` mới sau một lần thử thất bại (`ack` lỗi, hết giờ vào mạng, lệch ghim) | +| `BULK_DIRECT_MIN` | 128 KiB | Qua Bluetooth, lần truyền từ cỡ này trở lên chờ Wi-Fi Direct trước (0.4.7); khoảng 10 s ở tốc độ 12.9 KB/s của G7 | +| `BT_BULK_FRAGMENT_MAX` | 1 020 byte | `dlen` của một mảnh trên làn dài kênh điều khiển (kênh `0x01`, `long` = 1) và của một mảnh bulk trên kênh `0x02` (vừa bản ghi 1 040 byte, chiếm sóng khoảng 80 ms) | +| `BT_BULK_INFLIGHT` | 2 bản ghi | Tổng số bản ghi điều khiển dài và bulk đã ghi vào stream L2CAP mà chưa đi hết; giới hạn thời gian một envelope điều khiển ngắn phải chờ (AC1) | +| `BULK_MAX_RESTARTS` | 2 | Số lần làm lại vì đổi làn cho mỗi clip (0.4.7); sau đó áp dụng CLIP-03 E8 | ## 0.11 Trạng thái kết nối của client (Mac/iOS) ```mermaid stateDiagram-v2 [*] --> Idle - Idle --> Discovering: có ≥1 cặp và có mạng + Idle --> Discovering: có ≥1 cặp và có mạng, hoặc Bluetooth bật và đã cấp quyền Discovering --> ConnectingLAN: thấy instance có hint khớp ConnectingLAN --> Handshaking: TLS OK và ghim khớp ConnectingLAN --> Discovering: TLS_PIN_MISMATCH (bỏ instance) @@ -1032,9 +1381,13 @@ stateDiagram-v2 Handshaking --> Backoff: lỗi hoặc 4408 ConnectingLAN --> Backoff: lỗi mạng hoặc TLS (không phải lệch ghim) ConnectingLAN --> Idle: mọi instance của cặp lệch ghim (Cần ghép nối lại) + Discovering --> ConnectingBT: thấy quảng bá Bluetooth có hint khớp, không có ứng viên LAN + ConnectingBT --> Handshaking: bắt tay bảo mật link OK (0.4.5) + Idle --> Handshaking: ghép đôi qua Bluetooth xong bằng pair/done, link được giữ lại (0.4.5 Chế độ ghép đôi, PAIR-01 B4) + ConnectingBT --> Backoff: không mở được kênh, reject, BT_PREAUTH_TIMEOUT hoặc bắt tay bảo mật kênh thất bại (noise2, CONN-05 E3) ConnectingRelay --> Backoff: relay không tới được, 401 hoặc 404 sau khi đăng ký lại WaitingPeer --> Backoff: mất kết nối relay - Discovering --> Backoff: quá LAN_DISCOVERY_GRACE và relay.enabled = false + Discovering --> Backoff: quá LAN_DISCOVERY_GRACE, relay.enabled = false và không quét Bluetooth Discovering --> Idle: hủy cặp cuối cùng Backoff --> Idle: hủy cặp cuối cùng Discovering --> ConnectingRelay: quá LAN_DISCOVERY_GRACE và relay.enabled @@ -1043,21 +1396,45 @@ stateDiagram-v2 WaitingPeer --> Discovering: điện thoại xuất hiện trong LAN (instance mDNS có hint khớp) ConnectingRelay --> Handshaking: presence online Connected --> Backoff: mất kết nối - Connected --> ConnectingLAN: đang qua relay và thấy LAN (nâng cấp) + Connected --> ConnectingLAN: đang qua relay hoặc Bluetooth và thấy LAN (nâng cấp) Connected --> WaitingPeer: phiên qua relay kết thúc, liên kết relay vẫn còn (điện thoại đã rời, CONN-03 E8) Handshaking --> WaitingPeer: bắt tay qua relay kết thúc, liên kết relay vẫn còn (điện thoại đã rời) - Backoff --> Discovering: hết thời gian chờ, đổi mạng hoặc điện thoại xuất hiện lại trên mDNS + Backoff --> Discovering: hết thời gian chờ, đổi mạng hoặc điện thoại xuất hiện lại trên mDNS hoặc Bluetooth Connected --> Idle: hủy ghép nối ``` Trạng thái hiển thị cho người dùng (PAIR-02): `Idle` /`Backoff` → "Mất kết nối"; `Discovering` /`Connecting*`/`Handshaking` → "Đang kết nối…"; `WaitingPeer` → "Điện thoại ngoại tuyến"; -`Connected` → "Đã kết nối qua Wi-Fi" hoặc "Đã kết nối qua Internet" ("Đã kết nối qua USB" khi kênh +`Connected` → "Đã kết nối qua Wi-Fi", "Đã kết nối qua Bluetooth" hoặc "Đã kết nối qua Internet" ("Đã kết nối qua USB" khi kênh camera đang dùng USB); mọi instance lệch ghim → "Cần ghép nối lại". Kèm tên thiết bị bên kia (dòng trạng thái, câu đọc màn hình của chỉ báo trạng thái): "Đã kết nối qua Wi-Fi với ", -"Đã kết nối qua Internet với ", "Đã kết nối qua USB với ". - -Từ mọi trạng thái: mất mạng → `Idle` (CONN-02 E1); hủy cặp cuối cùng → `Idle`. Qua relay, phiên hoặc bước bắt tay kết thúc trong khi liên kết `/v1/relay` vẫn còn nghĩa là điện thoại đã rời (CONN-03 E8): quay về `WaitingPeer`, không về `Backoff`; khi đang ở `WaitingPeer`, thấy instance mDNS có hint khớp thì quay về `Discovering` để mở phiên LAN. Trong `Backoff`, thấy lại instance có hint khớp trên mDNS thì dừng chờ (trừ sau `AUTH_FAILED`); `4429 RATE_LIMITED` lùi theo lịch backoff thường. `session/error` `PAIR_UNKNOWN` hoặc `PAIR_REVOKED` nhận qua relay, trước khi bước bắt tay xác thực được điện thoại, dẫn đến `Backoff` và không bao giờ hủy cặp (CONN-03 E9); trong LAN (TLS ghim tới điện thoại) vẫn giữ ý nghĩa cũ (CONN-01 E4). +"Đã kết nối qua Bluetooth với ", "Đã kết nối qua Internet với ", "Đã kết nối qua USB với ". Hai +chuỗi Bluetooth là khóa catalog mới (S7.1). + +**Route và thứ tự ưu tiên (P7).** Một phiên chạy trên một route: `lan` (cả `usb`, chỉ cho camera), `relay` hoặc +`bluetooth` (chỉ Mac, 0.4.5). Ưu tiên: LAN > Bluetooth; Phase 7 không triển khai relay và code relay đứng cuối. Ở +`Discovering`, client duyệt mDNS khi có mạng và quét `BT_SERVICE_UUID` khi Bluetooth bật, đã cấp quyền Bluetooth và +`link.bluetooth` bật. Ứng viên LAN luôn được thử trước; ứng viên Bluetooth được nối ngay khi không có ứng viên LAN +(AC2 tính từ lúc thấy điện thoại). Phiên qua Bluetooth được nâng cấp ngay khi xuất hiện instance mDNS có hint khớp: +phiên LAN mới thay nó khi đã xác nhận khóa, nên người dùng không thấy khoảng đứt. Group Wi-Fi Direct ở 0.4.6 không bao giờ là +route: nó chỉ mang stream bulk trong khi phiên vẫn ở Bluetooth. + +**Bắt đầu và mất kết nối.** Rời `Idle` cần ≥ 1 cặp và có mạng hoặc Bluetooth bật và đã cấp quyền. Mất mạng chỉ kết +thúc phiên và lượt thử LAN, relay: phiên qua Bluetooth vẫn `Connected`; không có phiên thì client về `Discovering` +khi còn dùng được Bluetooth, ngược lại về `Idle` (CONN-02 E1). Tắt Bluetooth, thu hồi quyền Bluetooth hoặc tắt +`link.bluetooth` chỉ kết thúc phiên và lượt quét Bluetooth, theo cùng quy tắc theo chiều ngược lại. Hủy cặp cuối cùng +→ `Idle`. iOS không có route Bluetooth nên các quy tắc này đọc như cũ. + +**Quy tắc trước xác thực theo route (AC8).** Mỗi route có thuộc tính `phoneAuthenticatedByTLS`: đúng với `lan` và +`usb` (TLS ghim tới chứng chỉ của điện thoại), sai với `relay` và `bluetooth`. Trước khi bắt tay phiên xác thực được +điện thoại (`mac` của `session/welcome` hợp lệ), trên route có thuộc tính sai: `session/error` `PAIR_UNKNOWN` hoặc +`PAIR_REVOKED` → `Backoff`, không bao giờ hủy cặp và không bao giờ ngừng kết nối lại; `UNSUPPORTED_VERSION` hoặc +`AUTH_FAILED` vẫn hiện thông báo (CONN-01 E3, E5) nhưng giữ lịch backoff thường thay vì dừng hay chờ 5 phút; +`reject` (`LINK_REJECTED`) hoặc kênh bị đóng giữa bắt tay bảo mật link → `Backoff` thường. Trên route có thuộc tính +đúng, CONN-01 E3–E5 giữ nguyên ý nghĩa. Việc thu hồi chỉ được thực hiện qua route có thuộc tính đúng hoặc qua bản +tuyên bố có chữ ký (PAIR-03). + +Qua relay, phiên hoặc bước bắt tay kết thúc trong khi liên kết `/v1/relay` vẫn còn nghĩa là điện thoại đã rời (CONN-03 E8): quay về `WaitingPeer`, không về `Backoff`; khi đang ở `WaitingPeer`, thấy instance mDNS có hint khớp thì quay về `Discovering` để mở phiên LAN. Trong `Backoff`, thấy lại instance có hint khớp trên mDNS thì dừng chờ (trừ sau `AUTH_FAILED`); Thấy quảng bá Bluetooth có hint khớp cũng dừng chờ như vậy. `4429 RATE_LIMITED` lùi theo lịch backoff thường. ## 0.12 Bản địa hóa và catalog chuỗi giao diện diff --git a/docs/detailed-design/01-setup-settings.md b/docs/detailed-design/01-setup-settings.md index c0ec0c4..89b8195 100644 --- a/docs/detailed-design/01-setup-settings.md +++ b/docs/detailed-design/01-setup-settings.md @@ -17,12 +17,12 @@ English | [Tiếng Việt](01-setup-settings.vi.md) | Item | Content | |-----|----------| | Name | SET-01 — Initial setup and permissions on Android | -| Description | Two parts.
**Part A — first run:** a welcome screen with a short privacy explanation; create the identity keys, `device_id` and TLS certificate (0.6.1); request `POST_NOTIFICATIONS` (Android 13+); start `HandLiveService` (A-SVC) as a foreground service of type `connectedDevice` with a low-importance ongoing notification; request the battery optimization exemption (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) and show manufacturer-specific instructions for allowing autostart (Xiaomi, OPPO/realme, Samsung "Never sleeping apps"); then move on to PAIR-01.
**Part B — just-in-time permissions per feature:** a feature's permissions are requested only when the user turns on or uses that feature: QR scanning needs `CAMERA`; sending the clipboard automatically needs the disclosure, consent and turning on the Accessibility service (C15, CLIP-01); SMS needs `READ_SMS`, `SEND_SMS`, `READ_CONTACTS`, `READ_PHONE_STATE` (`RECEIVE_SMS` is not requested); calls need `READ_PHONE_STATE`, `READ_CALL_LOG`, `ANSWER_PHONE_CALLS`, `READ_CONTACTS` (C12, no `InCallService`); call audio needs `BLUETOOTH_CONNECT`, with Shizuku optional for the Opus/WS path (AUDIO-01, C13); calls from other apps (CALL-05) need Notification access, a special access the user turns on by hand in the system settings after a primer (field 19), optional: without it only calls from other apps are off; camera streaming needs `CAMERA`, `RECORD_AUDIO`; Continue Browsing (P6) needs its own disclosure and the separate Accessibility service "HandLive Browser Pages" (WEB-01 A1–A4, C21), the same way as automatic clipboard sending.
A denied permission leaves that feature inactive (or partially active) and is listed in `permissions_missing`; other features are not affected. | +| Description | Two parts.
**Part A — first run:** a welcome screen with a short privacy explanation; create the identity keys, `device_id` and TLS certificate (0.6.1); request `POST_NOTIFICATIONS` (Android 13+); start `HandLiveService` (A-SVC) as a foreground service of type `connectedDevice` with a low-importance ongoing notification; request the battery optimization exemption (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) and show manufacturer-specific instructions for allowing autostart (Xiaomi, OPPO/realme, Samsung "Never sleeping apps"); then move on to PAIR-01.
**Part B — just-in-time permissions per feature:** a feature's permissions are requested only when the user turns on or uses that feature: QR scanning needs `CAMERA`; sending the clipboard automatically needs the disclosure, consent and turning on the Accessibility service (C15, CLIP-01); SMS needs `READ_SMS`, `SEND_SMS`, `READ_CONTACTS`, `READ_PHONE_STATE` (`RECEIVE_SMS` is not requested); calls need `READ_PHONE_STATE`, `READ_CALL_LOG`, `ANSWER_PHONE_CALLS`, `READ_CONTACTS` (C12, no `InCallService`); call audio needs `BLUETOOTH_CONNECT`, with Shizuku optional for the Opus/WS path (AUDIO-01, C13); calls from other apps (CALL-05) need Notification access, a special access the user turns on by hand in the system settings after a primer (field 19), optional: without it only calls from other apps are off; camera streaming needs `CAMERA`, `RECORD_AUDIO`; Continue Browsing (P6) needs its own disclosure and the separate Accessibility service "HandLive Browser Pages" (WEB-01 A1–A4, C21), the same way as automatic clipboard sending; the Bluetooth link (`link.bluetooth`, P7: offline pairing PAIR-01 flow B and CONN-05) needs `BLUETOOTH_ADVERTISE` and `BLUETOOTH_CONNECT` (Nearby devices, Android 12+), asked once: when the user taps "Scan QR Code", or the first time A-UI opens with ≥ 1 pair after the update that adds it, or from its card; images without Wi-Fi (`link.direct_wifi`, P7, CONN-06) need `NEARBY_WIFI_DEVICES` (Nearby devices, Android 13+) or `ACCESS_FINE_LOCATION` with location services on (Android 10–12), asked only when the Mac first needs a Wi-Fi Direct group (the suggestion notification of field 17) or from its card.
A denied permission leaves that feature inactive (or partially active) and is listed in `permissions_missing`; other features are not affected. | | Actors | Primary: User (the phone's owner). System: A-UI, A-SVC, A-CLIP (`ClipboardAccessibilityService`), OS (Android's permission controller, `PowerManager`, system Settings and the manufacturer's own settings), R-API (device registration). | | Preconditions | **Part A:** HandLive has just been installed (Google Play, F-Droid or APK) on Android 10+ (API 29+); `setup.completed_at` is not set.
**Part B:** part A is done; the user taps "Scan QR Code" (PAIR-01), turns on a feature (SET-02), taps "Grant Permission" on a feature card, or taps a permission suggestion notification. | | Postconditions | **Part A:** the identity keys, `device_id` and TLS certificate exist; A-SVC runs in the foreground with its ongoing notification, listens on port 47800 (0.4.1) and advertises over mDNS; the battery optimization exemption has been asked for; `setup.completed_at` is written; the device is registered with the relay if `relay.enabled = true` and a network is available (or a background retry is pending); the UI moves on to PAIR-01.
**Part B:** each of the feature's permissions is granted or denied; `permissions_missing` and the capability sub-flags (`can_send`, `sims`, `caller_id`, `can_answer`, `can_end`, `auto_send`, `app_calls`) match reality; every connected client has received `capability/update` if anything changed. | -| Exceptions | E1 — `POST_NOTIFICATIONS` denied: A-SVC still runs (its service notification only appears in Android's Task Manager), but connection status, permission suggestions and camera confirmation requests (CAM-02) are not shown; the app shows the warning banner "Notifications are off, so connection status and requests from your Mac don't appear." with a button that opens the notification settings.
E2 — The foreground service cannot start (`ForegroundServiceStartNotAllowedException` when the app is not in the foreground and not exempt from battery optimization, or `SecurityException` because a type declaration is missing): retry when A-UI is in the foreground; still failing → show "Couldn't Start the Connection Service" with a "Try Again" button.
E3 — Battery optimization exemption denied or manufacturer instructions skipped: HandLive still works, but the connection may drop while the phone sleeps (CONN-02 E6); the warning stays in Settings › Permissions & Background.
E4 — A feature's permission is denied: the feature is inactive or partially active (for example `can_send = false`); the permission goes into `permissions_missing`.
E5 — Permission permanently denied (the system no longer shows the dialog): show an "Open Settings" button that goes to the App info page.
E6 — Accessibility disclosure declined: `clip.auto_send = false`; manual sending (a button in the notification, a Quick Settings tile, the Share menu) still works (CLIP-01 E1).
E7 — An install from outside Google Play on Android 13+ is blocked from turning on the Accessibility service ("Restricted setting"); HandLive cannot read this state and treats every such install as possibly blocked (`R = likely`, API 6 logic 2): guide the user to allow it in App info (field 14), then come back.
E8 — The user comes back from the Accessibility settings without the service turned on: keep the "Auto-send isn't on yet" status; if `R = likely`, show field 14 once with "Open Settings" (API 6 logic 6), otherwise allow another try. Coming back from App info never opens a page by itself.
E9 — Key generation fails (Keystore error): retry with a TEE-backed key when StrongBox is unavailable; still failing → show an error and do not continue to PAIR-01.
E10 — No network or relay error during device registration: skip, retry in the background (CONN-03), do not block setup.
E11 — Notification access not granted, or the user comes back without turning it on: `features.call.app_calls = false` and `NOTIFICATION_LISTENER` in `permissions_missing`; the "Calls from Other Apps" card shows "Needs permission" and allows another try; coming back from Notification access without it while `R` is not `none` shows field 14 once with "Open Settings", as in E8 (API 9 logic 4); CALL-01…04 and the other features are not affected. | -| Special requirements | **Google Play compliance:** submit the Permissions Declaration Form for `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG` under the "Cross-device synchronization or transfer of SMS or calls" exception; do not declare `RECEIVE_SMS` (new messages are detected with a `ContentObserver`); declare use of the Accessibility API (`isAccessibilityTool = false`) with a prominent disclosure and in-app consent — the project owner has accepted the rejection risk (C15); declare the `connectedDevice` foreground service type in Play Console; use `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` on the grounds that a companion app must stay connected to its paired devices.
**Privacy:** no feature permissions are requested in part A; every system dialog is preceded by an explanation; each request covers the permissions of one feature only.
**Usability:** part A ≤ 5 screens, completed in ≤ 60 s (not counting actions in the manufacturer's settings); readable with TalkBack; when field 14 opens by itself on a return (API 6 logic 6), TalkBack focus starts on its title, not on its button; every step after the welcome screen can be skipped; no ADB, USB or Shizuku needed.
**Compatibility:** minSdk 29, targetSdk 35 — `POST_NOTIFICATIONS` exists only from API 33; `BLUETOOTH_CONNECT` from API 31 (API 29–30 use `BLUETOOTH`, granted at install); `FOREGROUND_SERVICE_CONNECTED_DEVICE` is required from API 34.
**Feature independence:** a missing permission for one feature does not block the others (CONN-01 API 7). | +| Exceptions | E1 — `POST_NOTIFICATIONS` denied: A-SVC still runs (its service notification only appears in Android's Task Manager), but connection status, permission suggestions and camera confirmation requests (CAM-02) are not shown; the app shows the warning banner "Notifications are off, so connection status and requests from your Mac don't appear." with a button that opens the notification settings.
E2 — The foreground service cannot start (`ForegroundServiceStartNotAllowedException` when the app is not in the foreground and not exempt from battery optimization, or `SecurityException` because a type declaration is missing): retry when A-UI is in the foreground; still failing → show "Couldn't Start the Connection Service" with a "Try Again" button.
E3 — Battery optimization exemption denied or manufacturer instructions skipped: HandLive still works, but the connection may drop while the phone sleeps (CONN-02 E6); the warning stays in Settings › Permissions & Background.
E4 — A feature's permission is denied: the feature is inactive or partially active (for example `can_send = false`); the permission goes into `permissions_missing`.
E5 — Permission permanently denied (the system no longer shows the dialog): show an "Open Settings" button that goes to the App info page.
E6 — Accessibility disclosure declined: `clip.auto_send = false`; manual sending (a button in the notification, a Quick Settings tile, the Share menu) still works (CLIP-01 E1).
E7 — An install from outside Google Play on Android 13+ is blocked from turning on the Accessibility service ("Restricted setting"); HandLive cannot read this state and treats every such install as possibly blocked (`R = likely`, API 6 logic 2): guide the user to allow it in App info (field 14), then come back.
E8 — The user comes back from the Accessibility settings without the service turned on: keep the "Auto-send isn't on yet" status; if `R = likely`, show field 14 once with "Open Settings" (API 6 logic 6), otherwise allow another try. Coming back from App info never opens a page by itself.
E9 — Key generation fails (Keystore error): retry with a TEE-backed key when StrongBox is unavailable; still failing → show an error and do not continue to PAIR-01.
E10 — No network or relay error during device registration: skip, retry in the background (CONN-03), do not block setup.
E11 — Notification access not granted, or the user comes back without turning it on: `features.call.app_calls = false` and `NOTIFICATION_LISTENER` in `permissions_missing`; the "Calls from Other Apps" card shows "Needs permission" and allows another try; coming back from Notification access without it while `R` is not `none` shows field 14 once with "Open Settings", as in E8 (API 9 logic 4); CALL-01…04 and the other features are not affected.
E12 — Nearby devices denied (Android 12+): no Bluetooth advertising, so no offline pairing (PAIR-01 E12) and no CONN-05; LAN and relay are not affected. `BLUETOOTH_ADVERTISE` goes into `permissions_missing` while `link.bluetooth` is on; the "Connect Without Wi-Fi" card shows "Needs permission". HandLive never asks again by itself (AC7): only a tap on the card asks again, and a permanent denial shows field 16 (E5).
E13 — Bluetooth off while it is the only possible path (no Wi-Fi or Ethernet network connected, and a QR pairing window is open or a pair has no live session): A-UI in the foreground sends the system request to turn Bluetooth on, at most once per `BT_ENABLE_PROMPT_INTERVAL` (API 10); declined → nothing more until the next interval, field 11 of PAIR-01 stays available. HandLive never turns Bluetooth on or off by itself.
E14 — Wi-Fi Direct permission denied (Nearby devices on Android 13+, Location on Android 10–12), or location services off on Android 10–12: the phone answers `direct/start` with `PERMISSION_MISSING` or `DIRECT_WIFI_UNAVAILABLE` `location_off` (CONN-06 E2, E3), images without a LAN go over Bluetooth in the background, and the "Send Images Without Wi-Fi" card shows "Needs permission". The suggestion notification (field 17) is posted at most once per 24 h as for every feature; a permanent denial shows field 16 (E5); location services off shows field 21. HandLive never asks again by itself beyond that (AC7) and never turns location services on. | +| Special requirements | **Google Play compliance:** submit the Permissions Declaration Form for `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG` under the "Cross-device synchronization or transfer of SMS or calls" exception; do not declare `RECEIVE_SMS` (new messages are detected with a `ContentObserver`); declare use of the Accessibility API (`isAccessibilityTool = false`) with a prominent disclosure and in-app consent — the project owner has accepted the rejection risk (C15); declare the `connectedDevice` foreground service type in Play Console; use `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` on the grounds that a companion app must stay connected to its paired devices.
**Privacy:** no feature permissions are requested in part A; every system dialog is preceded by an explanation; each request covers the permissions of one feature only.
**Usability:** part A ≤ 5 screens, completed in ≤ 60 s (not counting actions in the manufacturer's settings); readable with TalkBack; when field 14 opens by itself on a return (API 6 logic 6), TalkBack focus starts on its title, not on its button; every step after the welcome screen can be skipped; no ADB, USB or Shizuku needed.
**Compatibility:** minSdk 29, targetSdk 35 — `POST_NOTIFICATIONS` exists only from API 33; `BLUETOOTH_CONNECT` and `BLUETOOTH_ADVERTISE` from API 31 (API 29–30 use `BLUETOOTH` and `BLUETOOTH_ADMIN`, granted at install, declared with `maxSdkVersion = 30`; the phone never scans, so neither `BLUETOOTH_SCAN` nor Location is requested for the Bluetooth link); `NEARBY_WIFI_DEVICES` from API 33, declared with `usesPermissionFlags="neverForLocation"`, and `ACCESS_FINE_LOCATION` declared with `maxSdkVersion = 32` for Wi-Fi Direct on API 29–32 (owner decision 2026-10-01, AC7); `ACCESS_WIFI_STATE` and `CHANGE_WIFI_STATE` are granted at install; `FOREGROUND_SERVICE_CONNECTED_DEVICE` is required from API 34.
**Feature independence:** a missing permission for one feature does not block the others (CONN-01 API 7). | ### 1.1.2 Screens @@ -41,16 +41,18 @@ N/A — no approved wireframe yet. | 7 | Pause app activity if unused | enum{enabled\| disabled\| not_available} | Input/Output | From `PackageManagerCompat.getUnusedAppRestrictionsStatus` | Android 11+: suggest turning it off so the system does not revoke permissions automatically when the user hasn't opened HandLive on the phone for a long time (API 4) | | 8 | Manufacturer autostart instructions | string | Output | From `Build.MANUFACTURER` | Under the title, on every manufacturer: "Some phones stop apps that run in the background. These settings keep HandLive connected to your Mac, iPhone, and iPad." Then specific steps for Xiaomi/Redmi/POCO, OPPO/realme/OnePlus, Samsung (API 5); the steps are hidden for other manufacturers | | 9 | "Open Manufacturer Settings", "Done", "Skip" buttons | action | Input | — | "Open Manufacturer Settings" opens the manufacturer's screen (API 5); "Done" and "Skip" go to step 7 | -| 10 | Feature list | array\ | Output | From the `feature.*` keys (0.9.5) and current permissions | Each card: feature name, status `ready` \| `needs_permission` \| `permanently_denied` \| `off` \| `unsupported`, missing permissions. Shown after the first PAIR-01 and in Settings › Permissions & Background
Card names are the SET-02 switch labels: "Auto-Send on Copy", "SMS Messages", "Calls", "Calls from Other Apps", "Take Calls on Mac", "Use Phone as Webcam". Status texts: `ready` "On", `needs_permission` "Needs permission" (with field 11), `permanently_denied` "Permission denied" (with field 16), `off` "Off", `unsupported` "Not supported on this phone". The "Auto-Send on Copy" card takes its status from field 15: `needs_accessibility` shows "Auto-send isn't on yet" (E8)
Tapping the "Auto-Send on Copy" card when `clip.a11y_consent_at` is already set never shows the disclosure again (step 12) | +| 10 | Feature list | array\ | Output | From the `feature.*` keys (0.9.5) and current permissions | Each card: feature name, status `ready` \| `needs_permission` \| `permanently_denied` \| `off` \| `unsupported`, missing permissions. Shown after the first PAIR-01 and in Settings › Permissions & Background
Card names are the SET-02 switch labels: "Auto-Send on Copy", "SMS Messages", "Calls", "Calls from Other Apps", "Take Calls on Mac", "Use Phone as Webcam", and (P7) "Connect Without Wi-Fi" for `link.bluetooth` (key `settings.bluetooth_link`) and "Send Images Without Wi-Fi" for `link.direct_wifi` (key `settings.direct_wifi`). Status texts: `ready` "On", `needs_permission` "Needs permission" (with field 11), `permanently_denied` "Permission denied" (with field 16), `off` "Off", `unsupported` "Not supported on this phone". The "Auto-Send on Copy" card takes its status from field 15: `needs_accessibility` shows "Auto-send isn't on yet" (E8)
Tapping the "Auto-Send on Copy" card when `clip.a11y_consent_at` is already set never shows the disclosure again (step 12) | | 11 | "Grant Permission" button on a feature card | action | Input | Shown when the card is `needs_permission` | Runs part B for that feature only | -| 12 | Explanation before the permission request | string | Output | Per feature (API 2) | SMS example: "To view and reply to SMS messages on your Mac or iPhone, HandLive needs to read and send SMS, read your contacts to show sender names, and read the phone state to choose a SIM."
Titles: SMS "Use SMS on Your Mac and iPhone", calls "See Calls on Your Mac and iPhone" (body "To announce incoming calls and let you answer or decline them on your Mac, HandLive needs to read the phone state, the call log, and your contacts."), notifications "Get Notified About Connections and Requests", background "Run in the Background", manufacturer autostart "Keep HandLive Running", camera "Scan the Pairing Code" (body "HandLive uses the camera to scan the QR code on your Mac, iPhone, or iPad.") | +| 12 | Explanation before the permission request | string | Output | Per feature (API 2) | SMS example: "To view and reply to SMS messages on your Mac or iPhone, HandLive needs to read and send SMS, read your contacts to show sender names, and read the phone state to choose a SIM."
Titles: SMS "Use SMS on Your Mac and iPhone", calls "See Calls on Your Mac and iPhone" (body "To announce incoming calls and let you answer or decline them on your Mac, HandLive needs to read the phone state, the call log, and your contacts."), notifications "Get Notified About Connections and Requests", background "Run in the Background", manufacturer autostart "Keep HandLive Running", camera "Scan the Pairing Code" (body "HandLive uses the camera to scan the QR code on your Mac, iPhone, or iPad."), Bluetooth link "Connect Without Wi-Fi" (key `permission.bluetooth_title`; body "When your phone and Mac aren't on the same Wi-Fi network, HandLive connects them over Bluetooth. Android calls this permission Nearby devices. HandLive doesn't use it to find your location.", key `permission.bluetooth_body`), images without Wi-Fi "Send Images Without Wi-Fi" (key `permission.direct_wifi_title`; Android 13+ body "When your phone and Mac have no Wi-Fi network in common, HandLive creates a direct Wi-Fi link between them to send images. Android calls this permission Nearby devices.", key `permission.direct_wifi_body`; Android 10–12 body "When your phone and Mac have no Wi-Fi network in common, HandLive creates a direct Wi-Fi link between them to send images. Android 10 to 12 need the Location permission and Location turned on for this. HandLive never reads or records your location.", key `permission.direct_wifi_body_location`) | | 13 | Accessibility disclosure | string | Output | Text of CLIP-01 field 2 | Shown full screen, with the choices "Send Manually" / "Agree" per CLIP-01 field 3 | | 14 | "Restricted setting" instructions | string | Output | Hidden | Shown on Android 13+ when HandLive may be blocked from a restricted setting (restriction state `R = likely`, API 6 logic 2), before opening Accessibility (step 12) or Notification access (step N1), and once more each time the user comes back from that page without turning HandLive on (E7, E8, E11; API 6 logic 6). Never shown when `R = none`. Guides the user to allow it in App info, then come back; the text stays conditional ("If Android shows…") because HandLive cannot read whether Android blocks it (API 6 logic 2), and an install from outside Google Play may not be blocked at all
A single button: before opening the system page "Continue" (`common.continue`), which opens that page; shown again on a return "Open Settings", which opens App info. "Open Settings" is the same button and string as field 16 (`common.open_settings`) | | 15 | Automatic clipboard sending status | enum{on\| off\| needs_accessibility} | Output | `needs_accessibility` | `on` when `clip.auto_send = true`, `clip.a11y_consent_at` is set and HandLive is in the list of enabled Accessibility services (`getEnabledAccessibilityServiceList`, as in API 6 Method and logic 6); `off` when `clip.auto_send = false`; `needs_accessibility` otherwise | | 16 | "Open Settings" button | action | Input | Shown when a permission is permanently denied, or as the button of field 14 | Opens HandLive's App info page (E5; field 14, E7, E8, E11). One button and one string (`common.open_settings`) for both uses | -| 17 | Permission suggestion notification | string | Output | — | Posted by A-SVC when it returns `PERMISSION_MISSING` to a client: "Lan's MacBook needs SMS permission on this phone — tap to allow" (the same text whichever SMS permission is missing); for a call permission "Lan's MacBook needs call permission on this phone — tap to allow" (the same text whichever call permission is missing); at most once per feature per 24 h; channel `permission` ("Permissions", description "Suggestions to grant a permission when a Mac or iPhone needs a feature of this phone.", `IMPORTANCE_LOW`) | -| 18 | Error message | string | Output | Empty | Text per E1–E11 | +| 17 | Permission suggestion notification | string | Output | — | Posted by A-SVC when it returns `PERMISSION_MISSING` to a client: "Lan's MacBook needs SMS permission on this phone — tap to allow" (the same text whichever SMS permission is missing); for a call permission "Lan's MacBook needs call permission on this phone — tap to allow" (the same text whichever call permission is missing); for Wi-Fi Direct (CONN-06 E2) "Lan's MacBook needs permission to send images without Wi-Fi — tap to allow" (key `permission.suggest_direct_wifi`); at most once per feature per 24 h; channel `permission` ("Permissions", description "Suggestions to grant a permission when a Mac or iPhone needs a feature of this phone.", `IMPORTANCE_LOW`) | +| 18 | Error message | string | Output | Empty | Text per E1–E14 | | 19 | Notification access primer | string | Output | Fixed text | Before the system Notification access page (steps N1–N2, CALL-05): title "Allow Notification Access", body "HandLive reads only call notifications from calling apps, to show them on your Mac. Other notifications are ignored and never leave your phone.", the single button "Continue" and the footer "You can change this in Settings at any time." | +| 20 | Bluetooth-off request | system dialog | Input | — | `BluetoothAdapter.ACTION_REQUEST_ENABLE` (API 10): the system asks "HandLive wants to turn on Bluetooth" with Allow / Deny; sent automatically under E13 or by a tap on PAIR-01 field 11 | +| 21 | Location services off (Android 10–12) | string + action | Output / Input | Hidden | Shown on the "Send Images Without Wi-Fi" card, and once right after the Location permission is granted, while `LocationManager.isLocationEnabled()` is false: "Turn on Location so HandLive can create a direct Wi-Fi link. HandLive doesn't record your location." (key `permission.location_services_off`) with "Open Location Settings" (key `common.open_location_settings`, opens `Settings.ACTION_LOCATION_SOURCE_SETTINGS`) | ### 1.1.4 Business flow @@ -75,6 +77,7 @@ flowchart TB S12["(12) Show disclosure, restricted setting instructions, open Accessibility"] S14["(14) Recompute permissions_missing and auto_send, send capability/update"] SN1["(N1) Show the Notification access primer, open the system page"] + S15["(15) Bluetooth off and no other path: request to turn it on"] X1(["End: show error, allow retry"]) end U1 --> S2 @@ -94,6 +97,7 @@ flowchart TB U13 -- "On, not on, or declined (E6, E7, E8)" --> S14 D9 -- "Calls from other apps" --> SN1 --> UN2 UN2 -- "On or not on (E11)" --> S14 + S14 -- "Bluetooth off, no other path (E13)" --> S15 ``` | Step | Actor | Component | Description | Exceptions / Notes | @@ -105,13 +109,14 @@ flowchart TB | 5 | System | A-UI → A-SVC, OS | (a) Start A-SVC: `startForegroundService`, then within 5 s call `startForeground` with type `connectedDevice` and a notification on channel `hl_service` (API 3); A-SVC opens WSS on ports 47800–47809 and advertises over mDNS (CONN-01 API 1). (b) Not yet exempt from battery optimization → explain "So your Mac and iPhone can always reach this phone, HandLive needs to run in the background without being stopped by the system", then open the `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog (API 4). (c) Android 11+ and field 7 = `enabled` → suggest turning off "Pause app activity if unused" (API 4). (d) Manufacturer listed in the configuration table → show the autostart instructions (field 8, API 5). | Cannot start → E2. | | 6 | User | OS, A-UI | Allows background running in the system dialog; follows the manufacturer instructions and taps "Done", or taps "Skip". | Denied or skipped → E3, still goes to step 7. | | 7 | System | A-UI, A-SVC → R-API | Write `setup.completed_at`. If `relay.enabled = true` and the internet is available: run `POST /v1/devices` in the background (CONN-03 API 1), then register the FCM push token (CONN-04 step 2). Go to PAIR-01 (the "Pair a Device" screen with "Scan QR Code" and "Enter PIN"). | Relay error → E10. | -| 8 | User | A-UI | Starts part B: taps "Scan QR Code" (PAIR-01 step 3), turns on a feature in SET-02, taps "Grant Permission" on a feature card (field 11) or taps a suggestion notification (field 17).
After the first PAIR-01, A-UI opens the feature list (field 10) so the user can grant permissions to the features that are on by default: clipboard (automatic sending), SMS, calls. "First" means the phone had no active pair when the pairing began: re-pairing the same client or adding another device does not open it, and after every device has been unpaired the next pairing does. The list opens over the Devices tab; Back returns there. | Device without `FEATURE_TELEPHONY` → the SMS and call cards are `unsupported` and the capability reports `enabled = false`; no camera (`FEATURE_CAMERA_ANY`) → the same for the camera. | -| 9 | System | A-UI | Looks up the per-feature permission table (API 2): QR scanning, SMS, calls, call audio (only `BLUETOOTH_CONNECT`; Shizuku belongs to AUDIO-01 steps 10–11), camera → step 10; automatic clipboard sending → step 12; calls from other apps (Notification access) → step N1. | | +| 8 | User | A-UI | Starts part B: taps "Scan QR Code" (PAIR-01 step 3), turns on a feature in SET-02, taps "Grant Permission" on a feature card (field 11) or taps a suggestion notification (field 17).
After the first PAIR-01, A-UI opens the feature list (field 10) so the user can grant permissions to the features that are on by default: clipboard (automatic sending), SMS, calls. "First" means the phone had no active pair when the pairing began: re-pairing the same client or adding another device does not open it, and after every device has been unpaired the next pairing does. The list opens over the Devices tab; Back returns there.
After the update that adds the Bluetooth link (P7), the first A-UI launch with ≥ 1 pair runs step 10 once for it when neither Bluetooth permission is in `perm.requested` (Android 12+, `link.bluetooth` on). | Device without `FEATURE_TELEPHONY` → the SMS and call cards are `unsupported` and the capability reports `enabled = false`; no camera (`FEATURE_CAMERA_ANY`) → the same for the camera. | +| 9 | System | A-UI | Looks up the per-feature permission table (API 2): QR scanning, SMS, calls, call audio (only `BLUETOOTH_CONNECT`; Shizuku belongs to AUDIO-01 steps 10–11), camera, Bluetooth link (P7), images without Wi-Fi (P7) → step 10; automatic clipboard sending → step 12; calls from other apps (Notification access) → step N1. | | | 10 | System | A-UI | Filters out the permissions not yet granted (`checkSelfPermission`).
A permission in `perm.requested`, not granted and with `shouldShowRequestPermissionRationale = false` → permanently denied: show field 16 instead of the dialog.
Otherwise: show the explanation (field 12), call `RequestMultiplePermissions` for the feature's missing permissions and add them to `perm.requested`. | E5 → step 14 when the user comes back from Settings. | | 11 | User | OS | Allows or denies each permission group; the system groups the dialogs by permission group (SMS, Contacts, Phone, Call logs, Camera, Microphone, Nearby devices). | Denied → E4. | | 12 | System | A-UI | `clip.a11y_consent_at` not set → runs CLIP-01 A2: show the disclosure (field 13); "Agree" → write `clip.a11y_consent_at`. Already set → skip the disclosure (consent is asked only once, CLIP-01 A1). Before opening `ACTION_ACCESSIBILITY_SETTINGS`: `R` is not `none` → show field 14 (API 6 logic 2–3); `none` → open it directly. | Declined → E6, go to step 14. | | 13 | User | OS | In Settings › Accessibility, selects HandLive, turns the service on, confirms the system's full-control permission dialog, then returns to HandLive. | The system reports "Restricted setting" → E7. Returns without turning it on → E8. | -| 14 | System | A-UI, A-SVC, A-CLIP | Recomputes `permissions_missing` and the sub-flags from the API 2 table; updates fields 10 and 15.
Something changed and a client is connected → send `capability/update` (SET-02 API 1); both sides recompute the active features (CONN-01 API 7).
For QR scanning: `CAMERA` granted → open the scanner (PAIR-01 step 3); denied → PAIR-01 E9 (use the PIN). | A-SVC also recomputes at startup, on A-UI `onResume` and when the Accessibility service or the notification listener connects or disconnects (CLIP-01 A3, CALL-05 API 3). | +| 14 | System | A-UI, A-SVC, A-CLIP | Recomputes `permissions_missing` and the sub-flags from the API 2 table; updates fields 10 and 15.
Something changed and a client is connected → send `capability/update` (SET-02 API 1); both sides recompute the active features (CONN-01 API 7).
For QR scanning: `CAMERA` granted → open the scanner (PAIR-01 step 3); denied → PAIR-01 E9 (use the PIN). Before the scanner opens, on Android 12+ with `link.bluetooth` on and neither Bluetooth permission in `perm.requested`, step 10 runs once for the Bluetooth link (its own primer, field 12); whatever the answer, the scanner then opens (E12). | A-SVC also recomputes at startup, on A-UI `onResume` and when the Accessibility service or the notification listener connects or disconnects (CLIP-01 A3, CALL-05 API 3). | +| 15 | System | A-UI | Bluetooth off (`BluetoothAdapter.isEnabled() = false`), `link.bluetooth` on, Nearby devices granted, no Wi-Fi or Ethernet network connected (`ConnectivityManager`), and a QR pairing window is open or a pair has no live session: when `link.bt_enable_asked_at` is empty or older than `BT_ENABLE_PROMPT_INTERVAL`, write it and send the request (field 20, API 10). Checked on A-UI `onResume`, when the pairing window opens and when the network changes while A-UI is in the foreground. | Declined → E13. A-UI in the background → nothing (the request needs an activity). | | N1 | System | A-UI | Calls from other apps turned on (`feature.call` and `call.app_calls` are `true`) without Notification access, or "Grant Permission" on its card: show the primer (field 19); "Continue" opens the system Notification access page for HandLive (API 9). Before opening: `R` is not `none` → show field 14 (the restricted-setting guidance, API 9 logic 4). | Optional: other features never wait for it. | | N2 | User | OS | In Notification access, turns on HandLive, confirms the system dialog, then returns to HandLive. | The system reports "Restricted setting" → E11 (same guidance as E7). Not turned on → E11. | @@ -130,6 +135,7 @@ flowchart TB | 7 | `WS capability/update` | `/v1/ctl` (LAN, USB or relay) | S→C | 14 | | 8 | `POST /v1/devices` | Relay REST | A-SVC → R-API | 7 | | 9 | `Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS`, `NotificationManager.isNotificationListenerAccessGranted` | Local | — | N1, N2, 14 | +| 10 | `BluetoothAdapter.ACTION_REQUEST_ENABLE` (request to turn Bluetooth on) | Local | — | 15 | #### API 1 — Create the identity keys and TLS certificate @@ -188,6 +194,8 @@ flowchart TB | `web` (sending pages, P6) | Accessibility service "HandLive Browser Pages" (WEB-01 API 3) | 29+ | `features.web.send = false`; receiving still works | Accessibility declaration | | `web` (receiving pages, P6) | `POST_NOTIFICATIONS` | 33+ | `features.web.receive = false` (WEB-04 E2) | — | | `call` (calls from other apps, CALL-05) | Notification access, a special access (API 9), listed as `NOTIFICATION_LISTENER` | 29+ | `features.call.app_calls = false`; CALL-01…04 unaffected | Declaration of the listener's use; the primer (field 19) | +| Bluetooth link (`link.bluetooth`, P7) | `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT` | 31+ (API 29–30: `BLUETOOTH`, `BLUETOOTH_ADMIN` granted at install) | No advertising: no offline pairing (PAIR-01 flow B), no CONN-05 (E12) | — | +| Images without Wi-Fi (`link.direct_wifi`, P7) | `NEARBY_WIFI_DEVICES` (33+); `ACCESS_FINE_LOCATION` + location services on (29–32) | 29+ | No Wi-Fi Direct group: images without a LAN go over Bluetooth in the background (E14, CONN-06 E2) | Location: prominent disclosure in the primer (field 12), declared as not used for location on 33+ | - **Response:** `Map` — the result for each permission. - **Example:** SMS: `launch(arrayOf(READ_SMS, SEND_SMS, READ_CONTACTS, READ_PHONE_STATE))` → @@ -428,6 +436,24 @@ available (E10). The FCM push token is registered afterwards per CONN-04 API 1. the known limit of API 6 logic 2 applies too; `feature.call` or `call.app_calls` off → no warning. +#### API 10 — Request to turn Bluetooth on + +- **URL:** N/A +- **Method:** `registerForActivityResult(ActivityResultContracts.StartActivityForResult())`, then + `launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))`; needs `BLUETOOTH_CONNECT` on API 31+. +- **Request:** no extras. +- **Response:** `Activity.RESULT_OK` (Bluetooth turning on) or `RESULT_CANCELED` (declined); A-SVC then sees + `STATE_ON` (CONN-05 API 6) and starts advertising. +- **Example:** QR pairing offline with Bluetooth off → step 15 → the user taps Allow → `RESULT_OK` → A-SVC + advertises `pr_bt` (PAIR-01 B1). +- **Business logic:** + 1. Automatic request (step 15) at most once per `BT_ENABLE_PROMPT_INTERVAL`, tracked by `link.bt_enable_asked_at`, + written before `launch`; a tap on PAIR-01 field 11 is the user's own action and does not check or write the key. + 2. Never call `BluetoothAdapter.enable()` or `disable()` (deprecated from API 33 and not allowed for HandLive): + HandLive never changes the radio state by itself. + 3. Not sent while a Wi-Fi or Ethernet network is connected: the LAN may still work, so Bluetooth is not the only + path. + #### Query No Room database queries; only DataStore reads and writes (the keys in 0.9.5 and the group's new @@ -445,6 +471,10 @@ dataStore.edit { it[longPreferencesKey("clip.a11y_consent_at")] = now } dataStore.edit { it[booleanPreferencesKey("clip.auto_send")] = false } # E6 prefs[booleanPreferencesKey("feature.sms")] ?: true # step 14: only permissions of enabled features count (likewise feature.call, feature.camera, feature.call_audio) prefs[booleanPreferencesKey("call.app_calls")] ?: true # steps 14, N1: NOTIFICATION_LISTENER counts only while feature.call and call.app_calls are true +prefs[booleanPreferencesKey("link.bluetooth")] ?: true # steps 10, 14, 15: Bluetooth permissions count only while it is true +prefs[booleanPreferencesKey("link.direct_wifi")] ?: true # steps 10, 14: Wi-Fi Direct permissions count only while it is true +prefs[longPreferencesKey("link.bt_enable_asked_at")] # step 15: empty or older than BT_ENABLE_PROMPT_INTERVAL → may ask +dataStore.edit { it[longPreferencesKey("link.bt_enable_asked_at")] = now } # step 15, before launch ``` --- @@ -874,12 +904,12 @@ SMEMBERS revoked_notice: | Item | Content | |-----|----------| | Name | SET-03 — Initial setup on Mac and iOS | -| Description | First run of HandLive for Mac (M-APP, a menu bar app) and HandLive for iOS/iPadOS (I-APP).
**Common:** a welcome screen with a privacy explanation; create the identity keys `ik_sig`, `ik_dh`, compute `device_id` and create the SQLCipher key `db_key` in the Keychain (0.6.1, 0.6.5); request notification permission (`UNUserNotificationCenter`, with the time-sensitive interruption level for incoming calls); trigger the local network permission dialog with the first Bonjour browse; finally register the device with the relay when `relay.enabled = true` (CONN-03 API 1) and move on to PAIR-01.
**Mac:** check that the app runs from `/Applications` (required for the virtual camera — C11) and offer to move it; ask about "Show HandLive in Menu Bar"; "Open at Login" with `SMAppService.mainApp.register()`; paste permission guidance per C10 (`NSPasteboard.accessBehavior`, macOS 15.4+).
Bluetooth and camera permissions are not requested here but in AUDIO-01 and CAM-01.
**iOS/iPadOS:** explain the platform limits — the clipboard only syncs while the app is in the foreground, there is no call audio, and SMS and incoming calls arrive through push when the app is closed (C7). | +| Description | First run of HandLive for Mac (M-APP, a menu bar app) and HandLive for iOS/iPadOS (I-APP).
**Common:** a welcome screen with a privacy explanation; create the identity keys `ik_sig`, `ik_dh`, compute `device_id` and create the SQLCipher key `db_key` in the Keychain (0.6.1, 0.6.5); request notification permission (`UNUserNotificationCenter`, with the time-sensitive interruption level for incoming calls); trigger the local network permission dialog with the first Bonjour browse; finally register the device with the relay when `relay.enabled = true` (CONN-03 API 1) and move on to PAIR-01.
**Mac:** check that the app runs from `/Applications` (required for the virtual camera — C11) and offer to move it; ask about "Show HandLive in Menu Bar"; "Open at Login" with `SMAppService.mainApp.register()`; paste permission guidance per C10 (`NSPasteboard.accessBehavior`, macOS 15.4+).
**Mac (P7):** Bluetooth access for the Bluetooth link (CONN-05, PAIR-01 flow B) is asked here, right after local network, then Location access for the Wi-Fi Direct link (CONN-06: macOS shows Wi-Fi network names only to apps with Location). The camera is asked in CAM-01. iOS/iPadOS never ask for Bluetooth: they have no Bluetooth route.
**iOS/iPadOS:** explain the platform limits — the clipboard only syncs while the app is in the foreground, there is no call audio, and SMS and incoming calls arrive through push when the app is closed (C7). | | Actors | Primary: User. System: M-APP or I-APP, OS (Keychain, privacy/TCC, Notification Center, Service Management, Bonjour), R-API (device registration), PUSH (APNs, iOS/iPadOS only). | | Preconditions | M-APP (Developer ID, notarized, macOS 13+) or I-APP (App Store, iOS/iPadOS 16+) has just been installed; `setup.completed_at` is not set. | -| Postconditions | **Success:** the Keychain holds `ik_sig`, `ik_dh`, `db_key`; `handlive.sqlite` has been created with the 0.9.3 schema; notification and local network permissions have been asked for; Mac: the menu bar icon and the login item follow the user's choices, and the app location and the paste permission state are known; iOS/iPadOS: the user has read the limits; `setup.completed_at` is written; the device is registered with the relay (if enabled) or a background retry is pending; the UI moves on to PAIR-01.
**Failure (E1):** `setup.completed_at` is not written and the app does not move on to PAIR-01. | -| Exceptions | E1 — Key generation or the Keychain write fails (`errSecInteractionNotAllowed`, `errSecMissingEntitlement`): show "Couldn't create the security keys on this device. Try again." with a "Try Again" button; do not continue to PAIR-01.
E2 — Mac: the app is not running from `/Applications` (opened straight from the disk image or the Downloads folder, under App Translocation, or no write permission to the Applications folder): offer to move it; the user postpones or the move fails → continue, with a note that the virtual camera can't be used yet (CAM-01 E1).
E3 — Notifications denied: HandLive still works; the Mac gets no SMS or missed call notifications (the call panel still appears while the app is running); iOS/iPadOS receive no SMS or calls while the app is closed (CONN-04 field 1 = `denied`); show instructions for turning them back on. Mac: "Notifications are off, so new SMS messages and missed calls don't appear. Turn them on in System Settings › Notifications › HandLive." iPhone, iPad: "Notifications are off, so new SMS messages and incoming calls don't appear while HandLive is closed. Turn them on in Settings › Notifications › HandLive."
Time-sensitive notifications turned off → Focus may silence call notifications.
E4 — Local network denied: the phone can't be found on the LAN (CONN-01 E8); pairing and connecting only go through the relay (PAIR-01 with a QR code that carries `rv`; the PIN can't be used); show instructions for turning it back on. Mac: "HandLive can't look for the phone on Wi-Fi. Turn on HandLive in System Settings › Privacy & Security › Local Network." iPhone, iPad: "HandLive can't look for the phone on Wi-Fi. Turn on HandLive in Settings › Privacy & Security › Local Network."
E5 — Mac: `SMAppService` returns `.requiresApproval` or an error: show "To open HandLive at login, allow it in System Settings › General › Login Items." with "Open System Settings"; do not block.
E6 — macOS 15.4+: `accessBehavior` is `.ask` or `.alwaysDeny`: point to System Settings › Privacy & Security › Paste from Other Apps (C10); Apple has no API to request "Always Allow"; do not block.
E7 — No network or relay error while registering the device or the push token: skip, retry in the background (CONN-03, CONN-04); do not block. | -| Special requirements | **Platform declarations:** Info.plist `NSLocalNetworkUsageDescription` ("HandLive looks for your Android phone on your Wi-Fi network to connect to it directly, not over the internet.") and `NSBonjourServices` = `["_handlive._tcp"]`; entitlement `com.apple.developer.usernotifications.time-sensitive`; Mac: `LSUIElement` = `YES` (launches without a Dock icon); the app switches `NSApplication.setActivationPolicy(_:)` between `.accessory` (menu bar icon only) and `.regular` (Dock icon, the menu bar HandLive · File · Edit · View · Window · Help, Dock menu) when the Messages or Camera Preview window opens or when "Show HandLive in Menu Bar" is turned off; `NSMicrophoneUsageDescription` ("HandLive uses the microphone so you can talk during calls transferred from your phone.") and `NSFocusStatusUsageDescription` ("HandLive reads your Focus status so it doesn't ring or show calls while a Focus is on.") are used in AUDIO-01 and CALL-01; iOS/iPadOS: `aps-environment`, the App Group `group.app.hxd.handlive` and a keychain access group shared with I-NSE.
**Privacy:** only ask for the permissions the core functions need; Bluetooth (AUDIO-01) and camera (CAM-01) are asked only when the user turns the feature on; every system dialog is preceded by an explanation screen with a single "Continue" button (HIG); the user declines right in the system dialog.
**Usability:** ≤ 6 screens, completed in ≤ 60 s; readable with VoiceOver; every step after key generation can move on without blocking; iOS/iPadOS set clear expectations and never promise features that don't exist.
**Security:** Keychain `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (0.6.1); private keys only live in the Keychain and in process memory; reinstalling the app generates a new `device_id` (0.2). | +| Postconditions | **Success:** the Keychain holds `ik_sig`, `ik_dh`, `db_key`; `handlive.sqlite` has been created with the 0.9.3 schema; notification and local network permissions have been asked for; Mac: the menu bar icon and the login item follow the user's choices, and the app location and the paste permission state are known, Bluetooth and Location access have been asked for; iOS/iPadOS: the user has read the limits; `setup.completed_at` is written; the device is registered with the relay (if enabled) or a background retry is pending; the UI moves on to PAIR-01.
**Failure (E1):** `setup.completed_at` is not written and the app does not move on to PAIR-01. | +| Exceptions | E1 — Key generation or the Keychain write fails (`errSecInteractionNotAllowed`, `errSecMissingEntitlement`): show "Couldn't create the security keys on this device. Try again." with a "Try Again" button; do not continue to PAIR-01.
E2 — Mac: the app is not running from `/Applications` (opened straight from the disk image or the Downloads folder, under App Translocation, or no write permission to the Applications folder): offer to move it; the user postpones or the move fails → continue, with a note that the virtual camera can't be used yet (CAM-01 E1).
E3 — Notifications denied: HandLive still works; the Mac gets no SMS or missed call notifications (the call panel still appears while the app is running); iOS/iPadOS receive no SMS or calls while the app is closed (CONN-04 field 1 = `denied`); show instructions for turning them back on. Mac: "Notifications are off, so new SMS messages and missed calls don't appear. Turn them on in System Settings › Notifications › HandLive." iPhone, iPad: "Notifications are off, so new SMS messages and incoming calls don't appear while HandLive is closed. Turn them on in Settings › Notifications › HandLive."
Time-sensitive notifications turned off → Focus may silence call notifications.
E4 — Local network denied: the phone can't be found on the LAN (CONN-01 E8); pairing and connecting only go through the relay (PAIR-01 with a QR code that carries `rv`; the PIN can't be used); show instructions for turning it back on. Mac: "HandLive can't look for the phone on Wi-Fi. Turn on HandLive in System Settings › Privacy & Security › Local Network." iPhone, iPad: "HandLive can't look for the phone on Wi-Fi. Turn on HandLive in Settings › Privacy & Security › Local Network."
E5 — Mac: `SMAppService` returns `.requiresApproval` or an error: show "To open HandLive at login, allow it in System Settings › General › Login Items." with "Open System Settings"; do not block.
E6 — macOS 15.4+: `accessBehavior` is `.ask` or `.alwaysDeny`: point to System Settings › Privacy & Security › Paste from Other Apps (C10); Apple has no API to request "Always Allow"; do not block.
E7 — No network or relay error while registering the device or the push token: skip, retry in the background (CONN-03, CONN-04); do not block.
E8 — Mac: Bluetooth access denied or restricted: no Bluetooth link (CONN-05 E1) and no offline pairing (PAIR-01 E12); LAN and relay are not affected. Field 12 shows "HandLive can't use Bluetooth, so it can't connect to the phone without Wi-Fi. Turn on HandLive in System Settings › Privacy & Security › Bluetooth." (key `status.bluetooth_denied`) with field 13; macOS never shows the dialog again, so HandLive never asks again (AC7).
E9 — Mac: Bluetooth turned off: HandLive never turns it on (macOS has no public API for it) and does not show the system power alert; field 18 shows the status while there is no LAN session.
E10 — Mac: Location access denied or restricted: no Wi-Fi Direct link (CONN-06 E1); images without a LAN go over Bluetooth in the background; the control lane is not affected. Field 12 shows "HandLive can't see Wi-Fi network names, so images sent without Wi-Fi go over Bluetooth and take longer. Turn on HandLive in System Settings › Privacy & Security › Location Services." (key `status.location_denied`) with field 13; macOS never shows the dialog again, so HandLive never asks again (AC7). | +| Special requirements | **Platform declarations:** Info.plist `NSLocalNetworkUsageDescription` ("HandLive looks for your Android phone on your Wi-Fi network to connect to it directly, not over the internet.") and `NSBonjourServices` = `["_handlive._tcp"]`; entitlement `com.apple.developer.usernotifications.time-sensitive`; Mac: `LSUIElement` = `YES` (launches without a Dock icon); the app switches `NSApplication.setActivationPolicy(_:)` between `.accessory` (menu bar icon only) and `.regular` (Dock icon, the menu bar HandLive · File · Edit · View · Window · Help, Dock menu) when the Messages or Camera Preview window opens or when "Show HandLive in Menu Bar" is turned off; `NSMicrophoneUsageDescription` ("HandLive uses the microphone so you can talk during calls transferred from your phone.") and `NSFocusStatusUsageDescription` ("HandLive reads your Focus status so it doesn't ring or show calls while a Focus is on.") are used in AUDIO-01 and CALL-01; Mac: `NSBluetoothAlwaysUsageDescription` ("HandLive connects to your Android phone over Bluetooth when they aren't on the same Wi-Fi network.", key `infoplist.bluetooth_always`), shared with AUDIO-01, whose wording must cover call audio again when Phase 4 reopens; Mac: `NSLocationUsageDescription` and `NSLocationWhenInUseUsageDescription` ("HandLive uses Location only to see Wi-Fi network names, so your Mac can join your phone's direct Wi-Fi link when there's no Wi-Fi. HandLive never records your location.", key `infoplist.location`) and the entitlement `com.apple.security.personal-information.location` (hardened runtime; without it the notarized build never gets Location); iOS/iPadOS: `aps-environment`, the App Group `group.app.hxd.handlive` and a keychain access group shared with I-NSE.
**Privacy:** only ask for the permissions the core functions need; the camera (CAM-01) is asked only when the user turns the feature on; Bluetooth and Location on the Mac are asked during setup because the Bluetooth link and the Wi-Fi Direct link are part of connecting (P7), never on iOS/iPadOS; Location is used only to read Wi-Fi network names, never to locate the user; every system dialog is preceded by an explanation screen with a single "Continue" button (HIG); the user declines right in the system dialog.
**Usability:** ≤ 6 screens, completed in ≤ 60 s; readable with VoiceOver; every step after key generation can move on without blocking; iOS/iPadOS set clear expectations and never promise features that don't exist.
**Security:** Keychain `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (0.6.1); private keys only live in the Keychain and in process memory; reinstalling the app generates a new `device_id` (0.2). | ### 1.3.2 Screens @@ -905,6 +935,9 @@ N/A — no approved wireframe yet. | 14 | "Continue" button | action | Input | — | The only button on each permission primer and instruction screen; there is no "Skip" — the user declines right in the system dialog (HIG, design system) | | 15 | Error message | string | Output | Empty | Per E1–E7 | | 16 | Show HandLive in Menu Bar | bool | Input/Output | `true` | Mac. Checkbox on the welcome screen → `mac.menu_bar_extra` (SET-02 field 31); `false` → the app keeps its Dock icon as the main entry point (step 6) | +| 17 | Bluetooth permission | enum{allowed\| denied\| restricted\| not_determined} | Output | From `CBManager.authorization` | Mac. Primer before the system dialog (step B1): title "Connect Without Wi-Fi" (key `permission.bluetooth_title`), body "When your Mac and phone aren't on the same Wi-Fi network, HandLive connects them over Bluetooth. macOS asks for permission next." (key `permission.bluetooth_body_mac`), the single button "Continue" (field 14); `denied`, `restricted` show instructions (E8) | +| 18 | Bluetooth unavailable status line | string + action | Output / Input | Hidden | Mac. Shown under the status line (CONN-05 field 4) while there is no LAN session: Bluetooth off → "Bluetooth is off. Turn it on to connect without Wi-Fi." (key `status.bluetooth_off`) with "Open Bluetooth Settings" (key `common.open_bluetooth_settings`, opens `x-apple.systempreferences:com.apple.BluetoothSettings`); access denied → the E8 text with "Open System Settings"; `link.bluetooth` off → nothing | +| 19 | Location permission | enum{allowed\| denied\| restricted\| not_determined} | Output | From `CLLocationManager.authorizationStatus` | Mac. Primer before the system dialog (step L1): title "Send Images Without Wi-Fi" (key `permission.direct_wifi_title`), body "When your Mac has no Wi-Fi network, HandLive can join a direct Wi-Fi link from your phone to send images faster. macOS asks for Location so HandLive can see Wi-Fi network names. HandLive never records your location." (key `permission.direct_wifi_body_mac`), the single button "Continue" (field 14); `denied`, `restricted` show instructions (E10) | ### 1.3.4 Business flow @@ -917,6 +950,8 @@ flowchart TB U8["(8) Allow or deny notifications"] U10["(10) Allow or deny local network"] U12["(12) Read the instructions, choose Continue"] + UB2["(B2) Allow or deny Bluetooth"] + UL2["(L2) Allow or deny Location"] end subgraph HT["System"] S2["(2) Create identity keys, device_id, SQLCipher key"] @@ -924,6 +959,8 @@ flowchart TB S6["(6) Mac: register Open at Login, set up the menu bar icon"] S7["(7) Explain, request notification permission"] S9["(9) Explain, browse Bonjour so the system asks for local network"] + SB1["(B1) Mac: explain, start CoreBluetooth so the system asks for Bluetooth"] + SL1["(L1) Mac: explain, request Location for Wi-Fi network names"] S11["(11) Mac: check paste permission. iOS: explain the limits"] S13["(13) Save setup.completed_at, register with relay in background, go to PAIR-01"] X1(["End: show error, allow retry"]) @@ -938,9 +975,16 @@ flowchart TB U5 -- "Moved, the app reopens" --> U1 S6 --> S7 --> U8 U8 -- "Allow or deny (E3)" --> S9 - S9 -- "macOS 13 to 14" --> S11 + S9 -- "macOS 13 to 14" --> SB1 S9 --> U10 - U10 -- "Allow or deny (E4)" --> S11 + U10 -- "Allow or deny (E4), Mac" --> SB1 + U10 -- "Allow or deny (E4), iOS" --> S11 + SB1 --> UB2 + SB1 -- "Already decided" --> SL1 + UB2 -- "Allow or deny (E8)" --> SL1 + SL1 --> UL2 + SL1 -- "Already decided" --> S11 + UL2 -- "Allow or deny (E10)" --> S11 S11 --> U12 --> S13 ``` @@ -956,6 +1000,10 @@ flowchart TB | 8 | User | OS | Chooses "Allow" or "Don't Allow". | Denied → E3. | | 9 | System | M-APP / I-APP | iOS/iPadOS and macOS 15+: show the local network explanation ("HandLive looks for your Android phone on your Wi-Fi network to connect to it directly."), then run `NWBrowser` for `_handlive._tcp` (API 5) — the first Bonjour browse makes the system show its dialog. macOS 13–14 have no such permission → field 9 = `not_required`, go to step 11. | | | 10 | User | OS | Chooses "Allow" or "Don't Allow" in the system dialog that asks to let HandLive find and connect to devices on the local network. | Denied → E4. | +| B1 | System | M-APP | Mac only. `CBManager.authorization` = `.notDetermined` and `link.bluetooth` on: show the primer (field 17), then create the `CBCentralManager` (API 9), which makes macOS show its dialog. Already decided → only read the state into field 17. A Mac whose setup was completed before the Bluetooth link existed runs this step once at the next launch. iOS/iPadOS skip it. | `.denied`, `.restricted` → E8. | +| B2 | User | OS | Chooses "Allow" or "Don't Allow" in the system dialog that asks to let HandLive use Bluetooth. | Denied → E8. | +| L1 | System | M-APP | Mac only. `CLLocationManager.authorizationStatus` = `.notDetermined` and `link.direct_wifi` on: show the primer (field 19), then `requestWhenInUseAuthorization()` (API 10). Already decided → only read the state into field 19. A Mac whose setup was completed before the Wi-Fi Direct link existed runs this step once at the next launch. iOS/iPadOS skip it. | `.denied`, `.restricted` → E10. | +| L2 | User | OS | Chooses "Allow" or "Don't Allow" in the system dialog that asks to let HandLive use your location. | Denied → E10. | | 11 | System | M-APP / I-APP | Mac, macOS 15.4+: read `NSPasteboard.general.accessBehavior` (API 6); `.ask` or `.alwaysDeny` → show instructions (field 12, E6). iOS/iPadOS: show the platform limits (field 11). | | | 12 | User | M-APP / I-APP | Reads the instructions, optionally chooses "Open Settings" (field 13), then chooses "Continue". | | | 13 | System | M-APP / I-APP → R-API | Writes `setup.completed_at`. If `relay.enabled = true` and the internet is available: run `POST /v1/devices` in the background (CONN-03 API 1); iOS/iPadOS: `registerForRemoteNotifications()`, then `PUT /v1/devices/me/push-token` (CONN-04 API 1). Go to PAIR-01 step 1 ("Add Phone…"). | Relay error → E7. | @@ -974,6 +1022,8 @@ flowchart TB | 6 | `NSPasteboard.accessBehavior` | Local | — | 11 | | 7 | `POST /v1/devices` | Relay REST | Device → R-API | 13 | | 8 | `UIApplication.registerForRemoteNotifications`, `PUT /v1/devices/me/push-token` | APNs, relay REST | I-APP → R-API | 13 | +| 9 | CoreBluetooth access: `CBManager.authorization`, `CBCentralManager` (Mac) | Local | — | B1, B2 | +| 10 | Location access: `CLLocationManager` (Mac) | Local | — | L1, L2 | #### API 1 — Create keys and store them in the Keychain @@ -1139,6 +1189,46 @@ Error → E7, retry in the background when a network is available. with `provider = apns` (development builds: `apns_sandbox`) and `topic` = the bundle id of I-APP. Specified in CONN-04 API 1 and step 2; runs after API 7. The Mac does not register for push (0.4.4). +#### API 9 — Bluetooth access (Mac) + +- **URL:** N/A +- **Method:** `CBManager.authorization` (class property, read before creating a manager); + `CBCentralManager(delegate: self, queue: btQueue, options: [CBCentralManagerOptionShowPowerAlertKey: false])`; + `centralManagerDidUpdateState(_:)` reads `central.state`. +- **Request:** Info.plist `NSBluetoothAlwaysUsageDescription` (missing → macOS terminates the app when the manager is + created). +- **Response:** `CBManagerAuthorization` ∈ {`.notDetermined`, `.restricted`, `.denied`, `.allowedAlways`}; + `CBManagerState` ∈ {`.unknown`, `.resetting`, `.unsupported`, `.unauthorized`, `.poweredOff`, `.poweredOn`}. +- **Example:** `.notDetermined` → field 17 → "Continue" → the manager is created → the user allows → + `.allowedAlways`, state `.poweredOn` → CONN-05 may scan. +- **Business logic:** + 1. Ask once: the dialog appears only while the authorization is `.notDetermined`; afterwards only read it (AC7). + 2. The same manager is kept for CONN-05 and PAIR-01 flow B; creating it before the primer would show the dialog + without an explanation, so no CoreBluetooth object exists before step B1. + 3. `CBCentralManagerOptionShowPowerAlertKey` = `false`: Bluetooth off is shown by field 18, not by the system + alert (E9). + 4. `.poweredOff` or `.unauthorized` while there is no LAN session → field 18; any other state hides it. + 5. `.unsupported` (a Mac without Bluetooth LE) → no Bluetooth link, no field 18. + +#### API 10 — Location access (Mac) + +- **URL:** N/A +- **Method:** `CLLocationManager()` with a delegate; `authorizationStatus` (instance property); + `requestWhenInUseAuthorization()`; `locationManagerDidChangeAuthorization(_:)`. +- **Request:** Info.plist `NSLocationUsageDescription` and `NSLocationWhenInUseUsageDescription`; entitlement + `com.apple.security.personal-information.location`. +- **Response:** `CLAuthorizationStatus` ∈ {`.notDetermined`, `.restricted`, `.denied`, `.authorizedAlways`, + `.authorizedWhenInUse`}; both authorized values count as `allowed`. +- **Example:** `.notDetermined` → field 19 → "Continue" → the user allows → `.authorizedAlways` → CoreWLAN returns + SSIDs, CONN-06 may run. +- **Business logic:** + 1. Ask once: the dialog appears only while the status is `.notDetermined`; afterwards only read it (AC7). + 2. HandLive never starts location updates (`startUpdatingLocation` is never called): the authorization only lets + CoreWLAN return SSIDs and the known-network check of CONN-06 API 7. + 3. `link.direct_wifi` off → step L1 is skipped and the status is not asked for; turning it on later asks once through + the same primer. + 4. Location Services off for the whole Mac → treated as `denied` (E10 text). + #### Query ```text diff --git a/docs/detailed-design/01-setup-settings.vi.md b/docs/detailed-design/01-setup-settings.vi.md index b8039b4..42514c9 100644 --- a/docs/detailed-design/01-setup-settings.vi.md +++ b/docs/detailed-design/01-setup-settings.vi.md @@ -16,12 +16,12 @@ | Mục | Nội dung | |-----|----------| | Tên | SET-01 — Thiết lập ban đầu và cấp quyền trên Android | -| Mô tả | Gồm hai phần.
**Phần A — lần chạy đầu:** màn hình chào mừng kèm giải thích ngắn về quyền riêng tư; tạo khóa định danh, `device_id` và chứng chỉ TLS (0.6.1); xin `POST_NOTIFICATIONS` (Android 13+); khởi động `HandLiveService` (A-SVC) dạng foreground service type `connectedDevice` với thông báo thường trực mức thấp; xin miễn tối ưu pin (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) và hướng dẫn cho phép tự khởi chạy theo hãng (Xiaomi, OPPO/realme, Samsung "Ứng dụng không bao giờ ngủ"); rồi chuyển sang PAIR-01.
**Phần B — cấp quyền đúng lúc theo tính năng:** quyền của một tính năng chỉ được xin khi người dùng bật hoặc dùng tính năng đó: quét QR cần `CAMERA`; tự gửi clipboard cần công bố, đồng ý và bật dịch vụ Hỗ trợ tiếp cận (C15, CLIP-01); SMS cần `READ_SMS`, `SEND_SMS`, `READ_CONTACTS`, `READ_PHONE_STATE` (không xin `RECEIVE_SMS`); cuộc gọi cần `READ_PHONE_STATE`, `READ_CALL_LOG`, `ANSWER_PHONE_CALLS`, `READ_CONTACTS` (C12, không dùng `InCallService`); âm thanh cuộc gọi cần `BLUETOOTH_CONNECT`, Shizuku tùy chọn cho đường Opus/WS (AUDIO-01, C13); cuộc gọi từ ứng dụng khác (CALL-05) cần quyền truy cập thông báo, một quyền truy cập đặc biệt người dùng tự bật trong cài đặt hệ thống sau màn giải thích (trường 19), không bắt buộc: thiếu nó thì chỉ cuộc gọi từ ứng dụng khác bị tắt; phát camera cần `CAMERA`, `RECORD_AUDIO`; Duyệt web tiếp (P6) cần công bố riêng và dịch vụ Hỗ trợ tiếp cận riêng "Trang trình duyệt HandLive" (WEB-01 A1–A4, C21), giống cách của tự gửi bảng nhớ tạm.
Quyền bị từ chối làm tính năng đó không hiệu lực (hoặc hiệu lực một phần) và được liệt kê trong `permissions_missing`; tính năng khác không bị ảnh hưởng. | +| Mô tả | Gồm hai phần.
**Phần A — lần chạy đầu:** màn hình chào mừng kèm giải thích ngắn về quyền riêng tư; tạo khóa định danh, `device_id` và chứng chỉ TLS (0.6.1); xin `POST_NOTIFICATIONS` (Android 13+); khởi động `HandLiveService` (A-SVC) dạng foreground service type `connectedDevice` với thông báo thường trực mức thấp; xin miễn tối ưu pin (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) và hướng dẫn cho phép tự khởi chạy theo hãng (Xiaomi, OPPO/realme, Samsung "Ứng dụng không bao giờ ngủ"); rồi chuyển sang PAIR-01.
**Phần B — cấp quyền đúng lúc theo tính năng:** quyền của một tính năng chỉ được xin khi người dùng bật hoặc dùng tính năng đó: quét QR cần `CAMERA`; tự gửi clipboard cần công bố, đồng ý và bật dịch vụ Hỗ trợ tiếp cận (C15, CLIP-01); SMS cần `READ_SMS`, `SEND_SMS`, `READ_CONTACTS`, `READ_PHONE_STATE` (không xin `RECEIVE_SMS`); cuộc gọi cần `READ_PHONE_STATE`, `READ_CALL_LOG`, `ANSWER_PHONE_CALLS`, `READ_CONTACTS` (C12, không dùng `InCallService`); âm thanh cuộc gọi cần `BLUETOOTH_CONNECT`, Shizuku tùy chọn cho đường Opus/WS (AUDIO-01, C13); cuộc gọi từ ứng dụng khác (CALL-05) cần quyền truy cập thông báo, một quyền truy cập đặc biệt người dùng tự bật trong cài đặt hệ thống sau màn giải thích (trường 19), không bắt buộc: thiếu nó thì chỉ cuộc gọi từ ứng dụng khác bị tắt; phát camera cần `CAMERA`, `RECORD_AUDIO`; Duyệt web tiếp (P6) cần công bố riêng và dịch vụ Hỗ trợ tiếp cận riêng "Trang trình duyệt HandLive" (WEB-01 A1–A4, C21), giống cách của tự gửi bảng nhớ tạm; kênh Bluetooth (`link.bluetooth`, P7: ghép đôi không cần mạng ở luồng B của PAIR-01 và CONN-05) cần `BLUETOOTH_ADVERTISE` và `BLUETOOTH_CONNECT` (Nearby devices, Android 12+), chỉ xin một lần: khi người dùng bấm "Quét mã QR", hoặc lần đầu A-UI mở khi đã có ≥ 1 cặp sau bản cập nhật thêm tính năng này, hoặc từ thẻ của nó; gửi ảnh không cần Wi-Fi (`link.direct_wifi`, P7, CONN-06) cần `NEARBY_WIFI_DEVICES` (Nearby devices, Android 13+) hoặc `ACCESS_FINE_LOCATION` kèm dịch vụ vị trí bật (Android 10–12), chỉ xin khi Mac lần đầu cần một group Wi-Fi Direct (thông báo gợi ý ở trường 17) hoặc từ thẻ của nó.
Quyền bị từ chối làm tính năng đó không hiệu lực (hoặc hiệu lực một phần) và được liệt kê trong `permissions_missing`; tính năng khác không bị ảnh hưởng. | | Tác nhân | Chính: Người dùng (chủ điện thoại). Hệ thống: A-UI, A-SVC, A-CLIP (`ClipboardAccessibilityService`), OS (trình cấp quyền của Android, `PowerManager`, Cài đặt hệ thống và cài đặt riêng của hãng), R-API (đăng ký thiết bị). | | Điều kiện trước | **Phần A:** HandLive vừa được cài (Google Play, F-Droid hoặc APK) trên Android 10+ (API 29+); chưa có `setup.completed_at`.
**Phần B:** phần A đã xong; người dùng bấm "Quét mã QR" (PAIR-01), bật một tính năng (SET-02), bấm "Cấp quyền" trên thẻ tính năng, hoặc chạm thông báo gợi ý cấp quyền. | | Điều kiện sau | **Phần A:** có khóa định danh, `device_id`, chứng chỉ TLS; A-SVC chạy foreground với thông báo thường trực, lắng nghe cổng 47800 (0.4.1) và quảng bá mDNS; miễn tối ưu pin đã được hỏi; `setup.completed_at` được ghi; thiết bị đã đăng ký relay nếu `relay.enabled = true` và có mạng (hoặc chờ thử lại nền); giao diện chuyển sang PAIR-01.
**Phần B:** mỗi quyền của tính năng ở trạng thái cấp hoặc từ chối; `permissions_missing` và các cờ con của capability (`can_send`, `sims`, `caller_id`, `can_answer`, `can_end`, `auto_send`, `app_calls`) khớp thực tế; mọi client đang kết nối đã nhận `capability/update` nếu có thay đổi. | -| Ngoại lệ | E1 — Từ chối `POST_NOTIFICATIONS`: A-SVC vẫn chạy (thông báo dịch vụ chỉ còn trong Trình quản lý tác vụ của Android) nhưng không hiện trạng thái kết nối, gợi ý cấp quyền và yêu cầu xác nhận camera (CAM-02); ứng dụng hiện dải cảnh báo "Thông báo đang tắt: không thấy trạng thái kết nối và yêu cầu từ Mac." kèm nút mở cài đặt thông báo.
E2 — Không khởi động được foreground service (`ForegroundServiceStartNotAllowedException` khi ứng dụng không ở foreground và chưa được miễn tối ưu pin, hoặc `SecurityException` do thiếu khai báo type): thử lại khi A-UI ở foreground; vẫn lỗi → báo "Không khởi động được dịch vụ kết nối", nút "Thử lại".
E3 — Từ chối miễn tối ưu pin hoặc bỏ qua hướng dẫn hãng: vẫn dùng được nhưng kết nối có thể bị ngắt khi máy ngủ (CONN-02 E6); cảnh báo giữ trong Cài đặt › Quyền và chạy nền.
E4 — Quyền của một tính năng bị từ chối: tính năng không hiệu lực hoặc hiệu lực một phần (ví dụ `can_send = false`), quyền vào `permissions_missing`.
E5 — Quyền bị từ chối vĩnh viễn (hệ thống không hiện hộp thoại nữa): hiện nút "Mở cài đặt" tới trang Thông tin ứng dụng.
E6 — Không đồng ý công bố Hỗ trợ tiếp cận: `clip.auto_send = false`; gửi thủ công (nút trên thông báo, ô Cài đặt nhanh, menu Chia sẻ) vẫn hoạt động (CLIP-01 E1).
E7 — Bản cài ngoài Google Play trên Android 13+ bị chặn bật dịch vụ Hỗ trợ tiếp cận ("Chế độ cài đặt bị hạn chế"); HandLive không đọc được trạng thái này nên coi mọi bản cài như vậy là có thể bị chặn (`R = likely`, API 6 logic 2): hướng dẫn cho phép trong Thông tin ứng dụng (trường 14) rồi quay lại.
E8 — Quay lại từ cài đặt Hỗ trợ tiếp cận mà dịch vụ chưa bật: giữ trạng thái "Chưa bật tự gửi"; nếu `R = likely` thì hiện trường 14 một lần với nút "Mở cài đặt" (API 6 logic 6), còn lại cho thử lại. Quay về từ Thông tin ứng dụng thì không tự mở trang nào.
E9 — Tạo khóa thất bại (Keystore lỗi): thử lại với khóa trong TEE khi StrongBox không khả dụng; vẫn lỗi → báo lỗi, không cho sang PAIR-01.
E10 — Không có mạng hoặc relay lỗi khi đăng ký thiết bị: bỏ qua, thử lại nền (CONN-03), không chặn thiết lập.
E11 — Chưa cấp quyền truy cập thông báo, hoặc người dùng quay lại mà chưa bật: `features.call.app_calls = false` và `NOTIFICATION_LISTENER` trong `permissions_missing`; thẻ "Cuộc gọi từ ứng dụng khác" hiện "Cần cấp quyền" và cho thử lại; quay về từ Truy cập thông báo mà chưa bật trong khi `R` khác `none` thì hiện trường 14 một lần với nút "Mở cài đặt", như E8 (API 9 logic 4); CALL-01…04 và các tính năng khác không bị ảnh hưởng. | -| Yêu cầu đặc biệt | **Tuân thủ Google Play:** nộp Permissions Declaration Form cho `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG` theo ngoại lệ "Cross-device synchronization or transfer of SMS or calls"; không khai báo `RECEIVE_SMS` (tin mới phát hiện bằng `ContentObserver`); khai báo dùng Accessibility API (`isAccessibilityTool = false`) kèm công bố nổi bật và đồng ý trong ứng dụng — rủi ro bị từ chối đã được chủ dự án chấp nhận (C15); khai báo foreground service type `connectedDevice` trong Play Console; dùng `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` với lý do ứng dụng đồng hành phải giữ kết nối với thiết bị đã ghép.
**Quyền riêng tư:** không xin quyền tính năng trong phần A; mỗi hộp thoại hệ thống đi sau một câu giải thích; mỗi lần chỉ xin quyền của một tính năng.
**Khả dụng:** phần A ≤ 5 màn hình, hoàn tất ≤ 60 s (không tính thao tác trong cài đặt của hãng); đọc được bằng TalkBack; khi trường 14 tự hiện lúc quay về (API 6 logic 6), TalkBack đặt tiêu điểm vào tiêu đề trước, không vào nút; mọi bước sau màn hình chào mừng đều bỏ qua được; không cần ADB, USB hay Shizuku.
**Tương thích:** minSdk 29, targetSdk 35 — `POST_NOTIFICATIONS` chỉ có từ API 33; `BLUETOOTH_CONNECT` từ API 31 (API 29–30 dùng `BLUETOOTH` cấp lúc cài); `FOREGROUND_SERVICE_CONNECTED_DEVICE` bắt buộc từ API 34.
**Độc lập tính năng:** thiếu quyền một tính năng không chặn tính năng khác (CONN-01 API 7). | +| Ngoại lệ | E1 — Từ chối `POST_NOTIFICATIONS`: A-SVC vẫn chạy (thông báo dịch vụ chỉ còn trong Trình quản lý tác vụ của Android) nhưng không hiện trạng thái kết nối, gợi ý cấp quyền và yêu cầu xác nhận camera (CAM-02); ứng dụng hiện dải cảnh báo "Thông báo đang tắt: không thấy trạng thái kết nối và yêu cầu từ Mac." kèm nút mở cài đặt thông báo.
E2 — Không khởi động được foreground service (`ForegroundServiceStartNotAllowedException` khi ứng dụng không ở foreground và chưa được miễn tối ưu pin, hoặc `SecurityException` do thiếu khai báo type): thử lại khi A-UI ở foreground; vẫn lỗi → báo "Không khởi động được dịch vụ kết nối", nút "Thử lại".
E3 — Từ chối miễn tối ưu pin hoặc bỏ qua hướng dẫn hãng: vẫn dùng được nhưng kết nối có thể bị ngắt khi máy ngủ (CONN-02 E6); cảnh báo giữ trong Cài đặt › Quyền và chạy nền.
E4 — Quyền của một tính năng bị từ chối: tính năng không hiệu lực hoặc hiệu lực một phần (ví dụ `can_send = false`), quyền vào `permissions_missing`.
E5 — Quyền bị từ chối vĩnh viễn (hệ thống không hiện hộp thoại nữa): hiện nút "Mở cài đặt" tới trang Thông tin ứng dụng.
E6 — Không đồng ý công bố Hỗ trợ tiếp cận: `clip.auto_send = false`; gửi thủ công (nút trên thông báo, ô Cài đặt nhanh, menu Chia sẻ) vẫn hoạt động (CLIP-01 E1).
E7 — Bản cài ngoài Google Play trên Android 13+ bị chặn bật dịch vụ Hỗ trợ tiếp cận ("Chế độ cài đặt bị hạn chế"); HandLive không đọc được trạng thái này nên coi mọi bản cài như vậy là có thể bị chặn (`R = likely`, API 6 logic 2): hướng dẫn cho phép trong Thông tin ứng dụng (trường 14) rồi quay lại.
E8 — Quay lại từ cài đặt Hỗ trợ tiếp cận mà dịch vụ chưa bật: giữ trạng thái "Chưa bật tự gửi"; nếu `R = likely` thì hiện trường 14 một lần với nút "Mở cài đặt" (API 6 logic 6), còn lại cho thử lại. Quay về từ Thông tin ứng dụng thì không tự mở trang nào.
E9 — Tạo khóa thất bại (Keystore lỗi): thử lại với khóa trong TEE khi StrongBox không khả dụng; vẫn lỗi → báo lỗi, không cho sang PAIR-01.
E10 — Không có mạng hoặc relay lỗi khi đăng ký thiết bị: bỏ qua, thử lại nền (CONN-03), không chặn thiết lập.
E11 — Chưa cấp quyền truy cập thông báo, hoặc người dùng quay lại mà chưa bật: `features.call.app_calls = false` và `NOTIFICATION_LISTENER` trong `permissions_missing`; thẻ "Cuộc gọi từ ứng dụng khác" hiện "Cần cấp quyền" và cho thử lại; quay về từ Truy cập thông báo mà chưa bật trong khi `R` khác `none` thì hiện trường 14 một lần với nút "Mở cài đặt", như E8 (API 9 logic 4); CALL-01…04 và các tính năng khác không bị ảnh hưởng.
E12 — Từ chối Nearby devices (Android 12+): không quảng bá Bluetooth, nên không ghép đôi được khi không có mạng (PAIR-01 E12) và không có CONN-05; LAN và relay không bị ảnh hưởng. `BLUETOOTH_ADVERTISE` nằm trong `permissions_missing` khi `link.bluetooth` bật; thẻ "Kết nối không cần Wi-Fi" hiện "Cần cấp quyền". HandLive không bao giờ tự xin lại (AC7): chỉ khi bấm vào thẻ mới xin lại, và khi bị từ chối vĩnh viễn thì hiện trường 16 (E5).
E13 — Bluetooth tắt trong khi đó là đường duy nhất có thể dùng (không nối mạng Wi-Fi hay Ethernet nào, và đang mở cửa sổ ghép đôi QR hoặc có cặp đang không có phiên sống): A-UI ở foreground gửi yêu cầu hệ thống bật Bluetooth, tối đa một lần mỗi `BT_ENABLE_PROMPT_INTERVAL` (API 10); bị từ chối → không làm gì thêm tới khoảng kế tiếp, trường 11 của PAIR-01 vẫn dùng được. HandLive không bao giờ tự bật hay tắt Bluetooth.
E14 — Quyền Wi-Fi Direct bị từ chối (Nearby devices trên Android 13+, Vị trí trên Android 10–12), hoặc dịch vụ vị trí tắt trên Android 10–12: điện thoại trả lời `direct/start` bằng `PERMISSION_MISSING` hoặc `DIRECT_WIFI_UNAVAILABLE` `location_off` (CONN-06 E2, E3), ảnh khi không có LAN đi Bluetooth ở nền, và thẻ "Gửi ảnh không cần Wi-Fi" hiện "Cần cấp quyền". Thông báo gợi ý (trường 17) đăng tối đa một lần mỗi 24 h như mọi tính năng; bị từ chối vĩnh viễn thì hiện trường 16 (E5); dịch vụ vị trí tắt thì hiện trường 21. Ngoài các lần đó HandLive không bao giờ tự xin lại (AC7) và không bao giờ tự bật dịch vụ vị trí. | +| Yêu cầu đặc biệt | **Tuân thủ Google Play:** nộp Permissions Declaration Form cho `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG` theo ngoại lệ "Cross-device synchronization or transfer of SMS or calls"; không khai báo `RECEIVE_SMS` (tin mới phát hiện bằng `ContentObserver`); khai báo dùng Accessibility API (`isAccessibilityTool = false`) kèm công bố nổi bật và đồng ý trong ứng dụng — rủi ro bị từ chối đã được chủ dự án chấp nhận (C15); khai báo foreground service type `connectedDevice` trong Play Console; dùng `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` với lý do ứng dụng đồng hành phải giữ kết nối với thiết bị đã ghép.
**Quyền riêng tư:** không xin quyền tính năng trong phần A; mỗi hộp thoại hệ thống đi sau một câu giải thích; mỗi lần chỉ xin quyền của một tính năng.
**Khả dụng:** phần A ≤ 5 màn hình, hoàn tất ≤ 60 s (không tính thao tác trong cài đặt của hãng); đọc được bằng TalkBack; khi trường 14 tự hiện lúc quay về (API 6 logic 6), TalkBack đặt tiêu điểm vào tiêu đề trước, không vào nút; mọi bước sau màn hình chào mừng đều bỏ qua được; không cần ADB, USB hay Shizuku.
**Tương thích:** minSdk 29, targetSdk 35 — `POST_NOTIFICATIONS` chỉ có từ API 33; `BLUETOOTH_CONNECT` và `BLUETOOTH_ADVERTISE` từ API 31 (API 29–30 dùng `BLUETOOTH` và `BLUETOOTH_ADMIN`, cấp lúc cài, khai báo với `maxSdkVersion = 30`; điện thoại không bao giờ quét, nên kênh Bluetooth không xin `BLUETOOTH_SCAN` hay Vị trí); `NEARBY_WIFI_DEVICES` từ API 33, khai báo với `usesPermissionFlags="neverForLocation"`, và `ACCESS_FINE_LOCATION` khai báo với `maxSdkVersion = 32` cho Wi-Fi Direct trên API 29–32 (sếp quyết định 2026-10-01, AC7); `ACCESS_WIFI_STATE` và `CHANGE_WIFI_STATE` cấp lúc cài; `FOREGROUND_SERVICE_CONNECTED_DEVICE` bắt buộc từ API 34.
**Độc lập tính năng:** thiếu quyền một tính năng không chặn tính năng khác (CONN-01 API 7). | ### 1.1.2 Màn hình @@ -40,16 +40,18 @@ N/A — chưa có wireframe được duyệt. | 7 | Tạm dừng hoạt động nếu không dùng | enum{enabled\| disabled\| not_available} | Input/Output | Theo `PackageManagerCompat.getUnusedAppRestrictionsStatus` | Android 11+: gợi ý tắt để hệ thống không tự thu hồi quyền khi người dùng lâu không mở HandLive trên điện thoại (API 4) | | 8 | Hướng dẫn tự khởi chạy theo hãng | string | Output | Theo `Build.MANUFACTURER` | Dưới tiêu đề, với mọi hãng: "Một số điện thoại dừng các ứng dụng chạy nền. Các cài đặt này giúp HandLive luôn kết nối với Mac, iPhone và iPad." Sau đó là các bước riêng cho Xiaomi/Redmi/POCO, OPPO/realme/OnePlus, Samsung (API 5); các bước ẩn với hãng khác | | 9 | Nút "Mở cài đặt của hãng", "Đã xong", "Bỏ qua" | action | Input | — | "Mở cài đặt của hãng" mở màn hình của hãng (API 5); "Đã xong" và "Bỏ qua" sang bước 7 | -| 10 | Danh sách tính năng | array\ | Output | Theo khóa `feature.*` (0.9.5) và quyền hiện có | Mỗi thẻ: tên tính năng, trạng thái `ready` \| `needs_permission` \| `permanently_denied` \| `off` \| `unsupported`, quyền còn thiếu. Hiện sau PAIR-01 lần đầu và ở Cài đặt › Quyền và chạy nền
Tên thẻ là nhãn công tắc của SET-02: "Tự gửi khi sao chép", "Tin nhắn SMS", "Cuộc gọi", "Cuộc gọi từ ứng dụng khác", "Nghe gọi trên Mac", "Dùng điện thoại làm webcam". Chữ trạng thái: `ready` "Bật", `needs_permission` "Cần cấp quyền" (kèm trường 11), `permanently_denied` "Quyền bị từ chối" (kèm trường 16), `off` "Tắt", `unsupported` "Điện thoại này không hỗ trợ". Thẻ "Tự gửi khi sao chép" lấy trạng thái từ trường 15: `needs_accessibility` hiện "Chưa bật tự gửi" (E8)
Bấm thẻ "Tự gửi khi sao chép" khi đã có `clip.a11y_consent_at` thì không bao giờ hiện lại công bố (bước 12) | +| 10 | Danh sách tính năng | array\ | Output | Theo khóa `feature.*` (0.9.5) và quyền hiện có | Mỗi thẻ: tên tính năng, trạng thái `ready` \| `needs_permission` \| `permanently_denied` \| `off` \| `unsupported`, quyền còn thiếu. Hiện sau PAIR-01 lần đầu và ở Cài đặt › Quyền và chạy nền
Tên thẻ là nhãn công tắc của SET-02: "Tự gửi khi sao chép", "Tin nhắn SMS", "Cuộc gọi", "Cuộc gọi từ ứng dụng khác", "Nghe gọi trên Mac", "Dùng điện thoại làm webcam", và (P7) "Kết nối không cần Wi-Fi" cho `link.bluetooth` (khóa `settings.bluetooth_link`) và "Gửi ảnh không cần Wi-Fi" cho `link.direct_wifi` (khóa `settings.direct_wifi`). Chữ trạng thái: `ready` "Bật", `needs_permission` "Cần cấp quyền" (kèm trường 11), `permanently_denied` "Quyền bị từ chối" (kèm trường 16), `off` "Tắt", `unsupported` "Điện thoại này không hỗ trợ". Thẻ "Tự gửi khi sao chép" lấy trạng thái từ trường 15: `needs_accessibility` hiện "Chưa bật tự gửi" (E8)
Bấm thẻ "Tự gửi khi sao chép" khi đã có `clip.a11y_consent_at` thì không bao giờ hiện lại công bố (bước 12) | | 11 | Nút "Cấp quyền" trên thẻ tính năng | action | Input | Hiện khi thẻ ở `needs_permission` | Chạy phần B cho đúng tính năng đó | -| 12 | Giải thích trước khi xin quyền | string | Output | Theo tính năng (API 2) | Ví dụ SMS: "Để xem và trả lời SMS trên Mac hoặc iPhone, HandLive cần đọc và gửi SMS, đọc danh bạ để hiện tên người gửi và đọc trạng thái điện thoại để chọn SIM."
Tiêu đề: SMS "Dùng SMS trên Mac và iPhone", cuộc gọi "Xem cuộc gọi trên Mac và iPhone" (nội dung "Để báo cuộc gọi đến và cho bạn trả lời, từ chối trên Mac, HandLive cần đọc trạng thái điện thoại, nhật ký cuộc gọi và danh bạ."), thông báo "Nhận thông báo về kết nối và yêu cầu", chạy nền "Chạy trong nền", tự khởi chạy của hãng "Giữ HandLive luôn chạy", camera "Quét mã ghép nối" (nội dung "HandLive dùng camera để quét mã QR trên Mac, iPhone hoặc iPad.") | +| 12 | Giải thích trước khi xin quyền | string | Output | Theo tính năng (API 2) | Ví dụ SMS: "Để xem và trả lời SMS trên Mac hoặc iPhone, HandLive cần đọc và gửi SMS, đọc danh bạ để hiện tên người gửi và đọc trạng thái điện thoại để chọn SIM."
Tiêu đề: SMS "Dùng SMS trên Mac và iPhone", cuộc gọi "Xem cuộc gọi trên Mac và iPhone" (nội dung "Để báo cuộc gọi đến và cho bạn trả lời, từ chối trên Mac, HandLive cần đọc trạng thái điện thoại, nhật ký cuộc gọi và danh bạ."), thông báo "Nhận thông báo về kết nối và yêu cầu", chạy nền "Chạy trong nền", tự khởi chạy của hãng "Giữ HandLive luôn chạy", camera "Quét mã ghép nối" (nội dung "HandLive dùng camera để quét mã QR trên Mac, iPhone hoặc iPad."), kênh Bluetooth "Kết nối không cần Wi-Fi" (khóa `permission.bluetooth_title`; nội dung "Khi điện thoại và Mac không cùng mạng Wi-Fi, HandLive nối hai máy qua Bluetooth. Android gọi quyền này là Thiết bị ở gần. HandLive không dùng nó để biết vị trí của bạn.", khóa `permission.bluetooth_body`), gửi ảnh không cần Wi-Fi "Gửi ảnh không cần Wi-Fi" (khóa `permission.direct_wifi_title`; nội dung trên Android 13+ "Khi điện thoại và Mac không chung mạng Wi-Fi nào, HandLive tạo một kết nối Wi-Fi trực tiếp giữa hai máy để gửi ảnh. Android gọi quyền này là Thiết bị ở gần.", khóa `permission.direct_wifi_body`; nội dung trên Android 10–12 "Khi điện thoại và Mac không chung mạng Wi-Fi nào, HandLive tạo một kết nối Wi-Fi trực tiếp giữa hai máy để gửi ảnh. Android 10 tới 12 cần quyền Vị trí và bật Vị trí cho việc này. HandLive không bao giờ đọc hay lưu vị trí của bạn.", khóa `permission.direct_wifi_body_location`) | | 13 | Công bố Hỗ trợ tiếp cận | string | Output | Văn bản CLIP-01 trường 2 | Hiện toàn màn hình, lựa chọn "Gửi thủ công" / "Đồng ý" theo CLIP-01 trường 3 | | 14 | Hướng dẫn "Chế độ cài đặt bị hạn chế" | string | Output | Ẩn | Hiện trên Android 13+ khi HandLive có thể bị chặn bật một cài đặt hạn chế (trạng thái hạn chế `R = likely`, API 6 logic 2), trước khi mở Hỗ trợ tiếp cận (bước 12) hoặc Truy cập thông báo (bước N1, SET-01 API 9), và hiện lại một lần mỗi khi người dùng quay về từ trang đó mà chưa bật HandLive (E7, E8, E11; API 6 logic 6). Không bao giờ hiện khi `R = none`. Hướng dẫn cho phép trong Thông tin ứng dụng rồi quay lại; câu chữ giữ dạng điều kiện ("Nếu Android báo…") vì HandLive không đọc được Android có chặn hay không (API 6 logic 2), và bản cài ngoài Google Play có thể hoàn toàn không bị chặn
Một nút duy nhất: trước khi mở trang hệ thống là "Tiếp tục" (`common.continue`), mở trang đó; khi hiện lại lúc quay về là "Mở cài đặt", mở Thông tin ứng dụng. "Mở cài đặt" là cùng nút và cùng chuỗi với trường 16 (`common.open_settings`) | | 15 | Trạng thái tự gửi clipboard | enum{on\| off\| needs_accessibility} | Output | `needs_accessibility` | `on` khi `clip.auto_send = true`, đã có `clip.a11y_consent_at` và HandLive có trong danh sách dịch vụ Hỗ trợ tiếp cận đã bật (`getEnabledAccessibilityServiceList`, như API 6 Method và logic 6); `off` khi `clip.auto_send = false`; còn lại là `needs_accessibility` | | 16 | Nút "Mở cài đặt" | action | Input | Hiện khi có quyền bị từ chối vĩnh viễn, hoặc làm nút của trường 14 | Mở trang Thông tin ứng dụng của HandLive (E5; trường 14, E7, E8, E11). Một nút và một chuỗi (`common.open_settings`) cho cả hai chỗ | -| 17 | Thông báo gợi ý cấp quyền | string | Output | — | A-SVC đăng khi trả `PERMISSION_MISSING` cho client: "MacBook của Lan cần quyền SMS trên điện thoại — chạm để cho phép" (cùng một câu dù thiếu quyền SMS nào); với quyền cuộc gọi: "MacBook của Lan cần quyền cuộc gọi trên điện thoại — chạm để cho phép" (cùng một câu dù thiếu quyền cuộc gọi nào); tối đa 1 lần mỗi tính năng mỗi 24 h; kênh `permission` ("Quyền", mô tả "Gợi ý cấp quyền khi Mac hoặc iPhone cần một tính năng trên điện thoại.", `IMPORTANCE_LOW`) | -| 18 | Thông báo lỗi | string | Output | Rỗng | Nội dung theo E1–E11 | +| 17 | Thông báo gợi ý cấp quyền | string | Output | — | A-SVC đăng khi trả `PERMISSION_MISSING` cho client: "MacBook của Lan cần quyền SMS trên điện thoại — chạm để cho phép" (cùng một câu dù thiếu quyền SMS nào); với quyền cuộc gọi: "MacBook của Lan cần quyền cuộc gọi trên điện thoại — chạm để cho phép" (cùng một câu dù thiếu quyền cuộc gọi nào); với Wi-Fi Direct (CONN-06 E2): "MacBook của Lan cần quyền để gửi ảnh khi không có Wi-Fi — chạm để cho phép" (khóa `permission.suggest_direct_wifi`); tối đa 1 lần mỗi tính năng mỗi 24 h; kênh `permission` ("Quyền", mô tả "Gợi ý cấp quyền khi Mac hoặc iPhone cần một tính năng trên điện thoại.", `IMPORTANCE_LOW`) | +| 18 | Thông báo lỗi | string | Output | Rỗng | Nội dung theo E1–E14 | | 19 | Màn giải thích quyền truy cập thông báo | string | Output | Văn bản cố định | Trước trang Truy cập thông báo của hệ thống (bước N1–N2, CALL-05): tiêu đề "Cho phép truy cập thông báo", nội dung "HandLive chỉ đọc thông báo cuộc gọi của các ứng dụng gọi điện để hiện trên Mac. Các thông báo khác bị bỏ qua và không bao giờ rời khỏi điện thoại.", một nút duy nhất "Tiếp tục" và chú thích "Bạn có thể đổi lại trong Cài đặt bất cứ lúc nào." | +| 20 | Yêu cầu bật Bluetooth | hộp thoại hệ thống | Input | — | `BluetoothAdapter.ACTION_REQUEST_ENABLE` (API 10): hệ thống hỏi "HandLive muốn bật Bluetooth" với Cho phép / Từ chối; được gửi tự động theo E13 hoặc khi người dùng bấm trường 11 của PAIR-01 | +| 21 | Dịch vụ vị trí tắt (Android 10–12) | string + action | Output / Input | Ẩn | Hiện trên thẻ "Gửi ảnh không cần Wi-Fi", và một lần ngay sau khi được cấp quyền Vị trí, khi `LocationManager.isLocationEnabled()` là false: "Bật Vị trí để HandLive tạo được kết nối Wi-Fi trực tiếp. HandLive không lưu vị trí của bạn." (khóa `permission.location_services_off`) kèm "Mở cài đặt Vị trí" (khóa `common.open_location_settings`, mở `Settings.ACTION_LOCATION_SOURCE_SETTINGS`) | ### 1.1.4 Luồng nghiệp vụ @@ -74,6 +76,7 @@ flowchart TB S12["(12) Hiện công bố, hướng dẫn cài đặt bị hạn chế, mở Hỗ trợ tiếp cận"] S14["(14) Tính lại permissions_missing và auto_send, gửi capability/update"] SN1["(N1) Hiện màn giải thích quyền truy cập thông báo, mở trang hệ thống"] + S15["(15) Bluetooth tắt và không còn đường nào khác: xin bật"] X1(["Kết thúc: báo lỗi, cho thử lại"]) end U1 --> S2 @@ -93,6 +96,7 @@ flowchart TB U13 -- "Bật, chưa bật hoặc không đồng ý (E6, E7, E8)" --> S14 D9 -- "Cuộc gọi từ ứng dụng khác" --> SN1 --> UN2 UN2 -- "Bật hoặc chưa bật (E11)" --> S14 + S14 -- "Bluetooth tắt, không còn đường khác (E13)" --> S15 ``` | Bước | Tác nhân | Thành phần | Mô tả | Ngoại lệ / Ghi chú | @@ -104,13 +108,14 @@ flowchart TB | 5 | Hệ thống | A-UI → A-SVC, OS | (a) Khởi động A-SVC: `startForegroundService`, trong 5 s gọi `startForeground` với type `connectedDevice` và thông báo kênh `hl_service` (API 3); A-SVC mở WSS trên cổng 47800–47809 và quảng bá mDNS (CONN-01 API 1). (b) Chưa được miễn tối ưu pin → giải thích "Để Mac và iPhone luôn tới được điện thoại, HandLive cần chạy nền mà không bị hệ thống ngắt", mở hộp thoại `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (API 4). (c) Android 11+ và trường 7 = `enabled` → gợi ý tắt "Tạm dừng hoạt động nếu không dùng" (API 4). (d) Hãng thuộc bảng cấu hình → hiện hướng dẫn tự khởi chạy (trường 8, API 5). | Không khởi động được → E2. | | 6 | Người dùng | OS, A-UI | Cho phép chạy nền trong hộp thoại hệ thống; làm theo hướng dẫn của hãng rồi bấm "Đã xong", hoặc bấm "Bỏ qua". | Từ chối hoặc bỏ qua → E3, vẫn sang bước 7. | | 7 | Hệ thống | A-UI, A-SVC → R-API | Ghi `setup.completed_at`. Nếu `relay.enabled = true` và có Internet: chạy nền `POST /v1/devices` (CONN-03 API 1), rồi đăng ký push token FCM (CONN-04 bước 2). Chuyển sang PAIR-01 (màn hình "Ghép nối thiết bị" với "Quét mã QR" và "Nhập mã PIN"). | Relay lỗi → E10. | -| 8 | Người dùng | A-UI | Bắt đầu phần B: bấm "Quét mã QR" (PAIR-01 bước 3), bật một tính năng ở SET-02, bấm "Cấp quyền" trên thẻ tính năng (trường 11) hoặc chạm thông báo gợi ý (trường 17).
Sau PAIR-01 lần đầu, A-UI mở danh sách tính năng (trường 10) để người dùng cấp quyền cho các tính năng mặc định bật: clipboard (tự gửi), SMS, cuộc gọi. "Lần đầu" nghĩa là điện thoại chưa có cặp nào đang hoạt động khi bắt đầu ghép nối: ghép lại cùng client hoặc thêm thiết bị khác thì không mở, còn sau khi đã hủy ghép mọi thiết bị thì lần ghép kế tiếp có mở. Danh sách mở đè lên tab Thiết bị; Quay lại trở về đó. | Máy không có `FEATURE_TELEPHONY` → thẻ SMS, cuộc gọi ở `unsupported` và capability báo `enabled = false`; không có camera (`FEATURE_CAMERA_ANY`) → tương tự với camera. | -| 9 | Hệ thống | A-UI | Tra bảng quyền theo tính năng (API 2): quét QR, SMS, cuộc gọi, âm thanh cuộc gọi (chỉ `BLUETOOTH_CONNECT`; Shizuku thuộc AUDIO-01 bước 10–11), camera → bước 10; tự gửi clipboard → bước 12; cuộc gọi từ ứng dụng khác (quyền truy cập thông báo) → bước N1. | | +| 8 | Người dùng | A-UI | Bắt đầu phần B: bấm "Quét mã QR" (PAIR-01 bước 3), bật một tính năng ở SET-02, bấm "Cấp quyền" trên thẻ tính năng (trường 11) hoặc chạm thông báo gợi ý (trường 17).
Sau PAIR-01 lần đầu, A-UI mở danh sách tính năng (trường 10) để người dùng cấp quyền cho các tính năng mặc định bật: clipboard (tự gửi), SMS, cuộc gọi. "Lần đầu" nghĩa là điện thoại chưa có cặp nào đang hoạt động khi bắt đầu ghép nối: ghép lại cùng client hoặc thêm thiết bị khác thì không mở, còn sau khi đã hủy ghép mọi thiết bị thì lần ghép kế tiếp có mở. Danh sách mở đè lên tab Thiết bị; Quay lại trở về đó.
Sau bản cập nhật thêm kênh Bluetooth (P7), lần đầu A-UI mở khi đã có ≥ 1 cặp sẽ chạy bước 10 một lần cho kênh này nếu chưa có quyền Bluetooth nào trong `perm.requested` (Android 12+, `link.bluetooth` bật). | Máy không có `FEATURE_TELEPHONY` → thẻ SMS, cuộc gọi ở `unsupported` và capability báo `enabled = false`; không có camera (`FEATURE_CAMERA_ANY`) → tương tự với camera. | +| 9 | Hệ thống | A-UI | Tra bảng quyền theo tính năng (API 2): quét QR, SMS, cuộc gọi, âm thanh cuộc gọi (chỉ `BLUETOOTH_CONNECT`; Shizuku thuộc AUDIO-01 bước 10–11), camera, kênh Bluetooth (P7), gửi ảnh không cần Wi-Fi (P7) → bước 10; tự gửi clipboard → bước 12; cuộc gọi từ ứng dụng khác (quyền truy cập thông báo) → bước N1. | | | 10 | Hệ thống | A-UI | Lọc quyền chưa cấp (`checkSelfPermission`).
Quyền có trong `perm.requested`, chưa cấp và `shouldShowRequestPermissionRationale = false` → từ chối vĩnh viễn: hiện trường 16 thay cho hộp thoại.
Còn lại: hiện giải thích (trường 12), gọi `RequestMultiplePermissions` cho các quyền còn thiếu của tính năng, thêm chúng vào `perm.requested`. | E5 → bước 14 khi người dùng quay lại từ cài đặt. | | 11 | Người dùng | OS | Cho phép hoặc từ chối từng nhóm quyền; hệ thống gộp hộp thoại theo nhóm (SMS, Danh bạ, Điện thoại, Nhật ký cuộc gọi, Camera, Micro, Thiết bị ở gần). | Từ chối → E4. | | 12 | Hệ thống | A-UI | Chưa có `clip.a11y_consent_at` → chạy CLIP-01 A2: hiện công bố (trường 13); "Đồng ý" → ghi `clip.a11y_consent_at`. Đã có → bỏ qua công bố (chỉ hỏi đồng ý một lần, CLIP-01 A1). Trước khi mở `ACTION_ACCESSIBILITY_SETTINGS`: `R` khác `none` → hiện trường 14 (API 6 logic 2–3); `none` → mở thẳng. | "Không" → E6, sang bước 14. | | 13 | Người dùng | OS | Trong Cài đặt › Hỗ trợ tiếp cận chọn HandLive, bật dịch vụ, xác nhận hộp thoại cấp quyền kiểm soát của hệ thống, rồi quay lại HandLive. | Hệ thống báo "Chế độ cài đặt bị hạn chế" → E7. Quay lại mà chưa bật → E8. | -| 14 | Hệ thống | A-UI, A-SVC, A-CLIP | Tính lại `permissions_missing` và các cờ con theo bảng API 2; cập nhật trường 10, 15.
Có thay đổi và có client đang kết nối → gửi `capability/update` (SET-02 API 1); hai bên tính lại tính năng hiệu lực (CONN-01 API 7).
Với quét QR: có `CAMERA` → mở khung quét (PAIR-01 bước 3); bị từ chối → PAIR-01 E9 (dùng PIN). | A-SVC cũng tính lại khi khởi động, khi A-UI `onResume` và khi dịch vụ Hỗ trợ tiếp cận hoặc listener thông báo nối/ngắt (CLIP-01 A3, CALL-05 API 3). | +| 14 | Hệ thống | A-UI, A-SVC, A-CLIP | Tính lại `permissions_missing` và các cờ con theo bảng API 2; cập nhật trường 10, 15.
Có thay đổi và có client đang kết nối → gửi `capability/update` (SET-02 API 1); hai bên tính lại tính năng hiệu lực (CONN-01 API 7).
Với quét QR: có `CAMERA` → mở khung quét (PAIR-01 bước 3); bị từ chối → PAIR-01 E9 (dùng PIN). Trước khi mở khung quét, trên Android 12+ khi `link.bluetooth` bật và chưa có quyền Bluetooth nào trong `perm.requested`, bước 10 chạy một lần cho kênh Bluetooth (màn giải thích riêng, trường 12); dù trả lời thế nào, khung quét vẫn mở sau đó (E12). | A-SVC cũng tính lại khi khởi động, khi A-UI `onResume` và khi dịch vụ Hỗ trợ tiếp cận hoặc listener thông báo nối/ngắt (CLIP-01 A3, CALL-05 API 3). | +| 15 | Hệ thống | A-UI | Bluetooth tắt (`BluetoothAdapter.isEnabled() = false`), `link.bluetooth` bật, đã cấp Nearby devices, không nối mạng Wi-Fi hay Ethernet nào (`ConnectivityManager`), và đang mở cửa sổ ghép đôi QR hoặc có cặp đang không có phiên sống: khi `link.bt_enable_asked_at` trống hoặc cũ hơn `BT_ENABLE_PROMPT_INTERVAL`, ghi khóa đó rồi gửi yêu cầu (trường 20, API 10). Kiểm khi A-UI `onResume`, khi cửa sổ ghép đôi mở và khi đổi mạng trong lúc A-UI ở foreground. | Bị từ chối → E13. A-UI ở nền → không làm gì (yêu cầu cần một activity). | | N1 | Hệ thống | A-UI | Bật cuộc gọi từ ứng dụng khác (`feature.call` và `call.app_calls` đều `true`) khi chưa có quyền truy cập thông báo, hoặc bấm "Cấp quyền" trên thẻ của nó: hiện màn giải thích (trường 19); "Tiếp tục" mở trang Truy cập thông báo của hệ thống cho HandLive (API 9). Trước khi mở: `R` khác `none` → hiện trường 14 (hướng dẫn cài đặt hạn chế, API 9 logic 4). | Không bắt buộc: tính năng khác không bao giờ phải chờ. | | N2 | Người dùng | OS | Trong Truy cập thông báo, bật HandLive, xác nhận hộp thoại của hệ thống, rồi quay lại HandLive. | Hệ thống báo "Chế độ cài đặt bị hạn chế" → E11 (cùng hướng dẫn như E7). Chưa bật → E11. | @@ -129,6 +134,7 @@ flowchart TB | 7 | `WS capability/update` | `/v1/ctl` (LAN, USB hoặc relay) | S→C | 14 | | 8 | `POST /v1/devices` | REST relay | A-SVC → R-API | 7 | | 9 | `Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS`, `NotificationManager.isNotificationListenerAccessGranted` | Cục bộ | — | N1, N2, 14 | +| 10 | `BluetoothAdapter.ACTION_REQUEST_ENABLE` (xin bật Bluetooth) | Cục bộ | — | 15 | #### API 1 — Tạo khóa định danh và chứng chỉ TLS @@ -184,6 +190,8 @@ flowchart TB | `web` (gửi trang, P6) | Dịch vụ Hỗ trợ tiếp cận "Trang trình duyệt HandLive" (WEB-01 API 3) | 29+ | `features.web.send = false`; vẫn nhận được | Khai báo Accessibility | | `web` (nhận trang, P6) | `POST_NOTIFICATIONS` | 33+ | `features.web.receive = false` (WEB-04 E2) | — | | `call` (cuộc gọi từ ứng dụng khác, CALL-05) | Quyền truy cập thông báo, một quyền truy cập đặc biệt (API 9), ghi là `NOTIFICATION_LISTENER` | 29+ | `features.call.app_calls = false`; CALL-01…04 không bị ảnh hưởng | Khai báo việc dùng listener; màn giải thích (trường 19) | +| Kênh Bluetooth (`link.bluetooth`, P7) | `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT` | 31+ (API 29–30: `BLUETOOTH`, `BLUETOOTH_ADMIN` cấp lúc cài) | Không quảng bá: không ghép đôi khi không có mạng (luồng B của PAIR-01), không có CONN-05 (E12) | — | +| Gửi ảnh không cần Wi-Fi (`link.direct_wifi`, P7) | `NEARBY_WIFI_DEVICES` (33+); `ACCESS_FINE_LOCATION` + dịch vụ vị trí bật (29–32) | 29+ | Không có group Wi-Fi Direct: ảnh khi không có LAN đi Bluetooth ở nền (E14, CONN-06 E2) | Vị trí: công bố rõ trong màn giải thích (trường 12), khai báo không dùng để định vị trên 33+ | - **Response:** `Map` — kết quả từng quyền. - **Ví dụ:** SMS: `launch(arrayOf(READ_SMS, SEND_SMS, READ_CONTACTS, READ_PHONE_STATE))` → @@ -413,6 +421,25 @@ mạng hoặc 5xx → thử lại nền theo `RECONNECT_BACKOFF` khi có mạng báo mà chưa bật và `R = likely` → trường 14 với nút "Mở cài đặt" một lần (E11); giới hạn đã biết ở API 6 logic 2 cũng áp dụng; `feature.call` hoặc `call.app_calls` tắt → không cảnh báo. +#### API 10 — Xin bật Bluetooth + +- **URL:** N/A +- **Method:** `registerForActivityResult(ActivityResultContracts.StartActivityForResult())`, rồi + `launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))`; cần `BLUETOOTH_CONNECT` trên API 31+. +- **Request:** không có extra. +- **Response:** `Activity.RESULT_OK` (Bluetooth đang bật) hoặc `RESULT_CANCELED` (bị từ chối); sau đó A-SVC thấy + `STATE_ON` (CONN-05 API 6) và bắt đầu quảng bá. +- **Ví dụ:** ghép đôi QR khi không có mạng mà Bluetooth tắt → bước 15 → người dùng bấm Cho phép → `RESULT_OK` → + A-SVC quảng bá `pr_bt` (PAIR-01 B1). +- **Logic nghiệp vụ:** + 1. Yêu cầu tự động (bước 15) tối đa một lần mỗi `BT_ENABLE_PROMPT_INTERVAL`, theo dõi bằng `link.bt_enable_asked_at`, + ghi trước khi `launch`; người dùng bấm trường 11 của PAIR-01 là hành động của chính họ, không kiểm và không ghi + khóa này. + 2. Không bao giờ gọi `BluetoothAdapter.enable()` hay `disable()` (bị bỏ từ API 33 và HandLive không được phép + dùng): HandLive không bao giờ tự đổi trạng thái sóng. + 3. Không gửi khi đang nối mạng Wi-Fi hay Ethernet: LAN có thể vẫn dùng được, nên Bluetooth không phải đường duy + nhất. + #### Query Không truy vấn cơ sở dữ liệu Room; chỉ đọc/ghi DataStore (khóa 0.9.5 và khóa mới của nhóm). @@ -429,6 +456,10 @@ dataStore.edit { it[longPreferencesKey("clip.a11y_consent_at")] = now } dataStore.edit { it[booleanPreferencesKey("clip.auto_send")] = false } # E6 prefs[booleanPreferencesKey("feature.sms")] ?: true # bước 14: chỉ tính quyền của tính năng đang bật (tương tự feature.call, feature.camera, feature.call_audio) prefs[booleanPreferencesKey("call.app_calls")] ?: true # bước 14, N1: chỉ tính NOTIFICATION_LISTENER khi feature.call và call.app_calls đều true +prefs[booleanPreferencesKey("link.bluetooth")] ?: true # bước 10, 14, 15: quyền Bluetooth chỉ tính khi khóa này true +prefs[booleanPreferencesKey("link.direct_wifi")] ?: true # bước 10, 14: quyền Wi-Fi Direct chỉ tính khi khóa này true +prefs[longPreferencesKey("link.bt_enable_asked_at")] # bước 15: trống hoặc cũ hơn BT_ENABLE_PROMPT_INTERVAL → được xin +dataStore.edit { it[longPreferencesKey("link.bt_enable_asked_at")] = now } # bước 15, trước khi launch ``` --- @@ -843,12 +874,12 @@ SMEMBERS revoked_notice: | Mục | Nội dung | |-----|----------| | Tên | SET-03 — Thiết lập ban đầu trên Mac và iOS | -| Mô tả | Lần chạy đầu của HandLive for Mac (M-APP, ứng dụng menu bar) và HandLive for iOS/iPadOS (I-APP).
**Chung:** màn hình chào mừng kèm giải thích quyền riêng tư; tạo khóa định danh `ik_sig`, `ik_dh`, tính `device_id` và tạo khóa SQLCipher `db_key` trong Keychain (0.6.1, 0.6.5); xin quyền thông báo (`UNUserNotificationCenter`, kèm mức nhạy cảm thời gian — time-sensitive — cho cuộc gọi đến); gợi hộp thoại quyền mạng cục bộ bằng lần duyệt Bonjour đầu tiên; cuối cùng đăng ký thiết bị với relay khi `relay.enabled = true` (CONN-03 API 1) và chuyển sang PAIR-01.
**Mac:** kiểm ứng dụng chạy từ `/Applications` (bắt buộc cho camera ảo — C11) và đề nghị chuyển; hỏi "Hiện HandLive trên thanh menu"; "Mở khi đăng nhập" bằng `SMAppService.mainApp.register()`; hướng dẫn quyền dán theo C10 (`NSPasteboard.accessBehavior`, macOS 15.4+).
Quyền Bluetooth và camera không hỏi ở đây mà ở AUDIO-01 và CAM-01.
**iOS/iPadOS:** giải thích giới hạn nền tảng — clipboard chỉ đồng bộ khi ứng dụng ở foreground, không có âm thanh cuộc gọi, SMS và cuộc gọi đến tới qua push khi ứng dụng đóng (C7). | +| Mô tả | Lần chạy đầu của HandLive for Mac (M-APP, ứng dụng menu bar) và HandLive for iOS/iPadOS (I-APP).
**Chung:** màn hình chào mừng kèm giải thích quyền riêng tư; tạo khóa định danh `ik_sig`, `ik_dh`, tính `device_id` và tạo khóa SQLCipher `db_key` trong Keychain (0.6.1, 0.6.5); xin quyền thông báo (`UNUserNotificationCenter`, kèm mức nhạy cảm thời gian — time-sensitive — cho cuộc gọi đến); gợi hộp thoại quyền mạng cục bộ bằng lần duyệt Bonjour đầu tiên; cuối cùng đăng ký thiết bị với relay khi `relay.enabled = true` (CONN-03 API 1) và chuyển sang PAIR-01.
**Mac:** kiểm ứng dụng chạy từ `/Applications` (bắt buộc cho camera ảo — C11) và đề nghị chuyển; hỏi "Hiện HandLive trên thanh menu"; "Mở khi đăng nhập" bằng `SMAppService.mainApp.register()`; hướng dẫn quyền dán theo C10 (`NSPasteboard.accessBehavior`, macOS 15.4+).
**Mac (P7):** quyền Bluetooth cho kênh Bluetooth (CONN-05, luồng B của PAIR-01) được hỏi ở đây, ngay sau quyền mạng cục bộ, rồi tới quyền Vị trí cho kênh Wi-Fi Direct (CONN-06: macOS chỉ cho app có quyền Vị trí thấy tên mạng Wi-Fi). Camera được hỏi ở CAM-01. iOS/iPadOS không bao giờ hỏi Bluetooth: hai nền tảng này không có route Bluetooth.
**iOS/iPadOS:** giải thích giới hạn nền tảng — clipboard chỉ đồng bộ khi ứng dụng ở foreground, không có âm thanh cuộc gọi, SMS và cuộc gọi đến tới qua push khi ứng dụng đóng (C7). | | Tác nhân | Chính: Người dùng. Hệ thống: M-APP hoặc I-APP, OS (Keychain, quyền riêng tư/TCC, Notification Center, Service Management, Bonjour), R-API (đăng ký thiết bị), PUSH (APNs, chỉ iOS/iPadOS). | | Điều kiện trước | M-APP (Developer ID, đã notarize, macOS 13+) hoặc I-APP (App Store, iOS/iPadOS 16+) vừa được cài; chưa có `setup.completed_at`. | -| Điều kiện sau | **Thành công:** Keychain có `ik_sig`, `ik_dh`, `db_key`; `handlive.sqlite` đã tạo với lược đồ 0.9.3; quyền thông báo và quyền mạng cục bộ đã được hỏi; Mac: biểu tượng thanh menu và mục đăng nhập theo lựa chọn của người dùng, đã biết vị trí ứng dụng và trạng thái quyền dán; iOS/iPadOS: người dùng đã đọc giới hạn; `setup.completed_at` được ghi; thiết bị đã đăng ký relay (nếu bật) hoặc chờ thử lại nền; giao diện chuyển sang PAIR-01.
**Thất bại (E1):** không ghi `setup.completed_at`, không sang PAIR-01. | -| Ngoại lệ | E1 — Tạo khóa hoặc ghi Keychain lỗi (`errSecInteractionNotAllowed`, `errSecMissingEntitlement`): báo "Không tạo được khóa bảo mật trên máy này. Hãy thử lại." kèm nút "Thử lại"; không sang PAIR-01.
E2 — Mac: ứng dụng không chạy từ `/Applications` (mở thẳng từ ảnh đĩa hoặc thư mục Tải về, bị App Translocation, hoặc không có quyền ghi thư mục Ứng dụng): đề nghị chuyển; người dùng để sau hoặc chuyển lỗi → tiếp tục, ghi chú camera ảo chưa dùng được (CAM-01 E1).
E3 — Từ chối thông báo: vẫn dùng được; Mac không có thông báo SMS và cuộc gọi nhỡ (panel cuộc gọi vẫn hiện khi ứng dụng chạy); iOS/iPadOS không nhận SMS, cuộc gọi khi ứng dụng đóng (CONN-04 trường 1 = `denied`); hiện hướng dẫn bật lại. Mac: "Thông báo đang tắt nên không thấy SMS mới và cuộc gọi nhỡ. Bật lại trong Cài đặt hệ thống › Thông báo › HandLive." iPhone, iPad: "Thông báo đang tắt nên không thấy SMS mới và cuộc gọi đến khi HandLive đóng. Bật lại trong Cài đặt › Thông báo › HandLive."
Mức nhạy cảm thời gian bị tắt → thông báo cuộc gọi có thể bị chế độ Tập trung chặn.
E4 — Từ chối mạng cục bộ: không tìm được điện thoại trong LAN (CONN-01 E8); ghép nối và kết nối chỉ qua relay (PAIR-01 bằng QR có `rv`, không dùng được PIN); hiện hướng dẫn bật lại. Mac: "HandLive không tìm được điện thoại trong mạng Wi-Fi. Bật HandLive trong Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Mạng cục bộ." iPhone, iPad: "HandLive không tìm được điện thoại trong mạng Wi-Fi. Bật HandLive trong Cài đặt › Quyền riêng tư & Bảo mật › Mạng cục bộ."
E5 — Mac: `SMAppService` trả `.requiresApproval` hoặc lỗi: hiện "Để mở HandLive khi đăng nhập, cho phép trong Cài đặt hệ thống › Cài đặt chung › Mục đăng nhập." kèm nút "Mở Cài đặt hệ thống"; không chặn.
E6 — macOS 15.4+: `accessBehavior` là `.ask` hoặc `.alwaysDeny`: hướng dẫn Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Dán từ ứng dụng khác (C10); Apple không có API xin "Luôn cho phép"; không chặn.
E7 — Không có mạng hoặc relay lỗi khi đăng ký thiết bị hoặc push token: bỏ qua, thử lại nền (CONN-03, CONN-04); không chặn. | -| Yêu cầu đặc biệt | **Khai báo nền tảng:** Info.plist `NSLocalNetworkUsageDescription` ("HandLive tìm điện thoại Android của bạn trong mạng Wi-Fi để kết nối trực tiếp, không qua Internet.") và `NSBonjourServices` = `["_handlive._tcp"]`; entitlement `com.apple.developer.usernotifications.time-sensitive`; Mac: `LSUIElement` = `YES` (khởi động không có biểu tượng Dock); app đổi `NSApplication.setActivationPolicy(_:)` giữa `.accessory` (chỉ biểu tượng thanh menu) và `.regular` (biểu tượng Dock, thanh menu HandLive · Tệp · Sửa · Xem · Cửa sổ · Trợ giúp, Dock menu) khi mở cửa sổ Tin nhắn, Xem trước camera hoặc khi tắt "Hiện HandLive trên thanh menu"; `NSMicrophoneUsageDescription` ("HandLive dùng micro để bạn nói trong cuộc gọi chuyển từ điện thoại.") và `NSFocusStatusUsageDescription` ("HandLive đọc trạng thái Tập trung để không đổ chuông và không hiện cuộc gọi khi bạn đang tập trung.") dùng ở AUDIO-01 và CALL-01; iOS/iPadOS: `aps-environment`, App Group `group.app.hxd.handlive` và keychain access group dùng chung với I-NSE.
**Quyền riêng tư:** chỉ hỏi quyền cần cho chức năng cốt lõi; Bluetooth (AUDIO-01) và camera (CAM-01) chỉ hỏi khi người dùng bật tính năng; mỗi hộp thoại hệ thống đi sau một màn hình giải thích chỉ có một nút "Tiếp tục" (HIG); người dùng từ chối ngay trong hộp thoại hệ thống.
**Khả dụng:** ≤ 6 màn hình, hoàn tất ≤ 60 s; đọc được bằng VoiceOver; mọi bước sau tạo khóa đều đi tiếp được mà không chặn; iOS/iPadOS đặt kỳ vọng rõ, không hứa tính năng không có.
**Bảo mật:** Keychain `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (0.6.1); khóa riêng chỉ nằm trong Keychain và bộ nhớ tiến trình; cài lại ứng dụng sinh `device_id` mới (0.2). | +| Điều kiện sau | **Thành công:** Keychain có `ik_sig`, `ik_dh`, `db_key`; `handlive.sqlite` đã tạo với lược đồ 0.9.3; quyền thông báo và quyền mạng cục bộ đã được hỏi; Mac: biểu tượng thanh menu và mục đăng nhập theo lựa chọn của người dùng, đã biết vị trí ứng dụng và trạng thái quyền dán, đã hỏi quyền Bluetooth và quyền Vị trí; iOS/iPadOS: người dùng đã đọc giới hạn; `setup.completed_at` được ghi; thiết bị đã đăng ký relay (nếu bật) hoặc chờ thử lại nền; giao diện chuyển sang PAIR-01.
**Thất bại (E1):** không ghi `setup.completed_at`, không sang PAIR-01. | +| Ngoại lệ | E1 — Tạo khóa hoặc ghi Keychain lỗi (`errSecInteractionNotAllowed`, `errSecMissingEntitlement`): báo "Không tạo được khóa bảo mật trên máy này. Hãy thử lại." kèm nút "Thử lại"; không sang PAIR-01.
E2 — Mac: ứng dụng không chạy từ `/Applications` (mở thẳng từ ảnh đĩa hoặc thư mục Tải về, bị App Translocation, hoặc không có quyền ghi thư mục Ứng dụng): đề nghị chuyển; người dùng để sau hoặc chuyển lỗi → tiếp tục, ghi chú camera ảo chưa dùng được (CAM-01 E1).
E3 — Từ chối thông báo: vẫn dùng được; Mac không có thông báo SMS và cuộc gọi nhỡ (panel cuộc gọi vẫn hiện khi ứng dụng chạy); iOS/iPadOS không nhận SMS, cuộc gọi khi ứng dụng đóng (CONN-04 trường 1 = `denied`); hiện hướng dẫn bật lại. Mac: "Thông báo đang tắt nên không thấy SMS mới và cuộc gọi nhỡ. Bật lại trong Cài đặt hệ thống › Thông báo › HandLive." iPhone, iPad: "Thông báo đang tắt nên không thấy SMS mới và cuộc gọi đến khi HandLive đóng. Bật lại trong Cài đặt › Thông báo › HandLive."
Mức nhạy cảm thời gian bị tắt → thông báo cuộc gọi có thể bị chế độ Tập trung chặn.
E4 — Từ chối mạng cục bộ: không tìm được điện thoại trong LAN (CONN-01 E8); ghép nối và kết nối chỉ qua relay (PAIR-01 bằng QR có `rv`, không dùng được PIN); hiện hướng dẫn bật lại. Mac: "HandLive không tìm được điện thoại trong mạng Wi-Fi. Bật HandLive trong Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Mạng cục bộ." iPhone, iPad: "HandLive không tìm được điện thoại trong mạng Wi-Fi. Bật HandLive trong Cài đặt › Quyền riêng tư & Bảo mật › Mạng cục bộ."
E5 — Mac: `SMAppService` trả `.requiresApproval` hoặc lỗi: hiện "Để mở HandLive khi đăng nhập, cho phép trong Cài đặt hệ thống › Cài đặt chung › Mục đăng nhập." kèm nút "Mở Cài đặt hệ thống"; không chặn.
E6 — macOS 15.4+: `accessBehavior` là `.ask` hoặc `.alwaysDeny`: hướng dẫn Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Dán từ ứng dụng khác (C10); Apple không có API xin "Luôn cho phép"; không chặn.
E7 — Không có mạng hoặc relay lỗi khi đăng ký thiết bị hoặc push token: bỏ qua, thử lại nền (CONN-03, CONN-04); không chặn.
E8 — Mac: quyền Bluetooth bị từ chối hoặc bị hạn chế: không có kênh Bluetooth (CONN-05 E1) và không ghép đôi được khi không có mạng (PAIR-01 E12); LAN và relay không bị ảnh hưởng. Trường 12 hiện "HandLive không dùng được Bluetooth nên không kết nối được với điện thoại khi không có Wi-Fi. Bật HandLive trong Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Bluetooth." (khóa `status.bluetooth_denied`) kèm trường 13; macOS không hiện lại hộp thoại, nên HandLive không bao giờ hỏi lại (AC7).
E9 — Mac: Bluetooth bị tắt: HandLive không bao giờ bật nó (macOS không có API công khai cho việc này) và không hiện cảnh báo nguồn của hệ thống; trường 18 hiện trạng thái khi không có phiên LAN.
E10 — Mac: quyền Vị trí bị từ chối hoặc bị hạn chế: không có kênh Wi-Fi Direct (CONN-06 E1); ảnh khi không có LAN đi Bluetooth ở nền; làn điều khiển không bị ảnh hưởng. Trường 12 hiện "HandLive không thấy được tên mạng Wi-Fi, nên ảnh gửi khi không có Wi-Fi đi qua Bluetooth và lâu hơn. Bật HandLive trong Cài đặt hệ thống › Quyền riêng tư & Bảo mật › Dịch vụ định vị." (khóa `status.location_denied`) kèm trường 13; macOS không hiện lại hộp thoại, nên HandLive không bao giờ hỏi lại (AC7). | +| Yêu cầu đặc biệt | **Khai báo nền tảng:** Info.plist `NSLocalNetworkUsageDescription` ("HandLive tìm điện thoại Android của bạn trong mạng Wi-Fi để kết nối trực tiếp, không qua Internet.") và `NSBonjourServices` = `["_handlive._tcp"]`; entitlement `com.apple.developer.usernotifications.time-sensitive`; Mac: `LSUIElement` = `YES` (khởi động không có biểu tượng Dock); app đổi `NSApplication.setActivationPolicy(_:)` giữa `.accessory` (chỉ biểu tượng thanh menu) và `.regular` (biểu tượng Dock, thanh menu HandLive · Tệp · Sửa · Xem · Cửa sổ · Trợ giúp, Dock menu) khi mở cửa sổ Tin nhắn, Xem trước camera hoặc khi tắt "Hiện HandLive trên thanh menu"; `NSMicrophoneUsageDescription` ("HandLive dùng micro để bạn nói trong cuộc gọi chuyển từ điện thoại.") và `NSFocusStatusUsageDescription` ("HandLive đọc trạng thái Tập trung để không đổ chuông và không hiện cuộc gọi khi bạn đang tập trung.") dùng ở AUDIO-01 và CALL-01; Mac: `NSBluetoothAlwaysUsageDescription` ("HandLive kết nối với điện thoại Android qua Bluetooth khi hai máy không cùng mạng Wi-Fi.", khóa `infoplist.bluetooth_always`), dùng chung với AUDIO-01, câu chữ phải bao cả âm thanh cuộc gọi khi Phase 4 mở lại; Mac: `NSLocationUsageDescription` và `NSLocationWhenInUseUsageDescription` ("HandLive chỉ dùng Vị trí để thấy tên mạng Wi-Fi, nhờ đó Mac vào được kết nối Wi-Fi trực tiếp của điện thoại khi không có Wi-Fi. HandLive không bao giờ lưu vị trí của bạn.", khóa `infoplist.location`) và entitlement `com.apple.security.personal-information.location` (hardened runtime; thiếu nó thì bản đã notarize không bao giờ có quyền Vị trí); iOS/iPadOS: `aps-environment`, App Group `group.app.hxd.handlive` và keychain access group dùng chung với I-NSE.
**Quyền riêng tư:** chỉ hỏi quyền cần cho chức năng cốt lõi; camera (CAM-01) chỉ hỏi khi người dùng bật tính năng; Bluetooth và Vị trí trên Mac được hỏi lúc thiết lập vì kênh Bluetooth và kênh Wi-Fi Direct là một phần của việc kết nối (P7), không bao giờ hỏi trên iOS/iPadOS; Vị trí chỉ dùng để đọc tên mạng Wi-Fi, không bao giờ để định vị người dùng; mỗi hộp thoại hệ thống đi sau một màn hình giải thích chỉ có một nút "Tiếp tục" (HIG); người dùng từ chối ngay trong hộp thoại hệ thống.
**Khả dụng:** ≤ 6 màn hình, hoàn tất ≤ 60 s; đọc được bằng VoiceOver; mọi bước sau tạo khóa đều đi tiếp được mà không chặn; iOS/iPadOS đặt kỳ vọng rõ, không hứa tính năng không có.
**Bảo mật:** Keychain `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (0.6.1); khóa riêng chỉ nằm trong Keychain và bộ nhớ tiến trình; cài lại ứng dụng sinh `device_id` mới (0.2). | ### 1.3.2 Màn hình @@ -874,6 +905,9 @@ N/A — chưa có wireframe được duyệt. | 14 | Nút "Tiếp tục" | action | Input | — | Nút duy nhất trên mỗi màn hình giải thích quyền và hướng dẫn; không có "Bỏ qua" — người dùng từ chối ngay trong hộp thoại hệ thống (HIG, design system) | | 15 | Thông báo lỗi | string | Output | Rỗng | Theo E1–E7 | | 16 | Hiện HandLive trên thanh menu | bool | Input/Output | `true` | Mac. Hộp chọn trên màn hình chào mừng → `mac.menu_bar_extra` (SET-02 trường 31); `false` → app giữ biểu tượng Dock làm lối vào chính (bước 6) | +| 17 | Quyền Bluetooth | enum{allowed\| denied\| restricted\| not_determined} | Output | Theo `CBManager.authorization` | Mac. Màn giải thích trước hộp thoại hệ thống (bước B1): tiêu đề "Kết nối không cần Wi-Fi" (khóa `permission.bluetooth_title`), nội dung "Khi Mac và điện thoại không cùng mạng Wi-Fi, HandLive nối hai máy qua Bluetooth. Tiếp theo macOS sẽ hỏi quyền." (khóa `permission.bluetooth_body_mac`), một nút duy nhất "Tiếp tục" (trường 14); `denied`, `restricted` hiện hướng dẫn (E8) | +| 18 | Dòng trạng thái không dùng được Bluetooth | string + action | Output / Input | Ẩn | Mac. Hiện dưới dòng trạng thái (CONN-05 trường 4) khi không có phiên LAN: Bluetooth tắt → "Bluetooth đang tắt. Bật Bluetooth để kết nối khi không có Wi-Fi." (khóa `status.bluetooth_off`) kèm "Mở cài đặt Bluetooth" (khóa `common.open_bluetooth_settings`, mở `x-apple.systempreferences:com.apple.BluetoothSettings`); bị từ chối quyền → câu của E8 kèm "Mở Cài đặt hệ thống"; `link.bluetooth` tắt → không hiện gì | +| 19 | Quyền Vị trí | enum{allowed\| denied\| restricted\| not_determined} | Output | Theo `CLLocationManager.authorizationStatus` | Mac. Màn giải thích trước hộp thoại hệ thống (bước L1): tiêu đề "Gửi ảnh không cần Wi-Fi" (khóa `permission.direct_wifi_title`), nội dung "Khi Mac không có mạng Wi-Fi, HandLive có thể vào kết nối Wi-Fi trực tiếp từ điện thoại để gửi ảnh nhanh hơn. macOS hỏi quyền Vị trí để HandLive thấy được tên mạng Wi-Fi. HandLive không bao giờ lưu vị trí của bạn." (khóa `permission.direct_wifi_body_mac`), một nút duy nhất "Tiếp tục" (trường 14); `denied`, `restricted` hiện hướng dẫn (E10) | ### 1.3.4 Luồng nghiệp vụ @@ -886,6 +920,8 @@ flowchart TB U8["(8) Cho phép hoặc từ chối thông báo"] U10["(10) Cho phép hoặc từ chối mạng cục bộ"] U12["(12) Đọc hướng dẫn, bấm Tiếp tục"] + UB2["(B2) Cho phép hoặc từ chối Bluetooth"] + UL2["(L2) Cho phép hoặc từ chối Vị trí"] end subgraph HT["Hệ thống"] S2["(2) Tạo khóa định danh, device_id, khóa SQLCipher"] @@ -893,6 +929,8 @@ flowchart TB S6["(6) Mac: đăng ký Mở khi đăng nhập, đặt biểu tượng thanh menu"] S7["(7) Giải thích, xin quyền thông báo"] S9["(9) Giải thích, duyệt Bonjour để hệ thống hỏi mạng cục bộ"] + SB1["(B1) Mac: giải thích, khởi động CoreBluetooth để hệ thống hỏi quyền Bluetooth"] + SL1["(L1) Mac: giải thích, xin quyền Vị trí để đọc tên mạng Wi-Fi"] S11["(11) Mac: kiểm quyền dán. iOS: giải thích giới hạn"] S13["(13) Lưu setup.completed_at, đăng ký relay nền, chuyển PAIR-01"] X1(["Kết thúc: báo lỗi, cho thử lại"]) @@ -907,9 +945,16 @@ flowchart TB U5 -- "Chuyển, ứng dụng mở lại" --> U1 S6 --> S7 --> U8 U8 -- "Cho phép hoặc từ chối (E3)" --> S9 - S9 -- "macOS 13 đến 14" --> S11 + S9 -- "macOS 13 đến 14" --> SB1 S9 --> U10 - U10 -- "Cho phép hoặc từ chối (E4)" --> S11 + U10 -- "Cho phép hoặc từ chối (E4), Mac" --> SB1 + U10 -- "Cho phép hoặc từ chối (E4), iOS" --> S11 + SB1 --> UB2 + SB1 -- "Đã quyết trước đó" --> SL1 + UB2 -- "Cho phép hoặc từ chối (E8)" --> SL1 + SL1 --> UL2 + SL1 -- "Đã quyết trước đó" --> S11 + UL2 -- "Cho phép hoặc từ chối (E10)" --> S11 S11 --> U12 --> S13 ``` @@ -925,6 +970,10 @@ flowchart TB | 8 | Người dùng | OS | Chọn "Cho phép" hoặc "Không cho phép". | Từ chối → E3. | | 9 | Hệ thống | M-APP / I-APP | iOS/iPadOS và macOS 15+: hiện giải thích quyền mạng cục bộ ("HandLive tìm điện thoại Android trong mạng Wi-Fi để kết nối trực tiếp."), rồi chạy `NWBrowser` cho `_handlive._tcp` (API 5) — lần duyệt Bonjour đầu tiên làm hệ thống hiện hộp thoại. macOS 13–14 không có quyền này → trường 9 = `not_required`, sang bước 11. | | | 10 | Người dùng | OS | Chọn "Cho phép" hoặc "Không cho phép" trong hộp thoại hệ thống hỏi cho HandLive tìm và kết nối với thiết bị trên mạng cục bộ. | Từ chối → E4. | +| B1 | Hệ thống | M-APP | Chỉ Mac. `CBManager.authorization` = `.notDetermined` và `link.bluetooth` bật: hiện màn giải thích (trường 17), rồi tạo `CBCentralManager` (API 9), việc này làm macOS hiện hộp thoại. Đã quyết trước đó → chỉ đọc trạng thái vào trường 17. Mac đã thiết lập xong từ trước khi có kênh Bluetooth sẽ chạy bước này một lần ở lần mở kế tiếp. iOS/iPadOS bỏ qua. | `.denied`, `.restricted` → E8. | +| B2 | Người dùng | OS | Chọn "Cho phép" hoặc "Không cho phép" trong hộp thoại hệ thống hỏi cho HandLive dùng Bluetooth. | Từ chối → E8. | +| L1 | Hệ thống | M-APP | Chỉ Mac. `CLLocationManager.authorizationStatus` = `.notDetermined` và `link.direct_wifi` bật: hiện màn giải thích (trường 19), rồi `requestWhenInUseAuthorization()` (API 10). Đã quyết trước đó → chỉ đọc trạng thái vào trường 19. Mac đã thiết lập xong từ trước khi có kênh Wi-Fi Direct sẽ chạy bước này một lần ở lần mở kế tiếp. iOS/iPadOS bỏ qua. | `.denied`, `.restricted` → E10. | +| L2 | Người dùng | OS | Chọn "Cho phép" hoặc "Không cho phép" trong hộp thoại hệ thống hỏi cho HandLive dùng vị trí của bạn. | Từ chối → E10. | | 11 | Hệ thống | M-APP / I-APP | Mac, macOS 15.4+: đọc `NSPasteboard.general.accessBehavior` (API 6); `.ask` hoặc `.alwaysDeny` → hiện hướng dẫn (trường 12, E6). iOS/iPadOS: hiện giới hạn nền tảng (trường 11). | | | 12 | Người dùng | M-APP / I-APP | Đọc hướng dẫn, có thể bấm "Mở Cài đặt" (trường 13), rồi bấm "Tiếp tục". | | | 13 | Hệ thống | M-APP / I-APP → R-API | Ghi `setup.completed_at`. Nếu `relay.enabled = true` và có Internet: chạy nền `POST /v1/devices` (CONN-03 API 1); iOS/iPadOS: `registerForRemoteNotifications()` rồi `PUT /v1/devices/me/push-token` (CONN-04 API 1). Chuyển sang PAIR-01 bước 1 ("Thêm điện thoại…"). | Relay lỗi → E7. | @@ -943,6 +992,8 @@ flowchart TB | 6 | `NSPasteboard.accessBehavior` | Cục bộ | — | 11 | | 7 | `POST /v1/devices` | REST relay | Thiết bị → R-API | 13 | | 8 | `UIApplication.registerForRemoteNotifications`, `PUT /v1/devices/me/push-token` | APNs, REST relay | I-APP → R-API | 13 | +| 9 | Quyền CoreBluetooth: `CBManager.authorization`, `CBCentralManager` (Mac) | Cục bộ | — | B1, B2 | +| 10 | Quyền Vị trí: `CLLocationManager` (Mac) | Cục bộ | — | L1, L2 | #### API 1 — Tạo khóa và lưu Keychain @@ -1100,6 +1151,45 @@ lại nền khi có mạng. với `provider = apns` (bản phát triển: `apns_sandbox`) và `topic` = bundle id của I-APP. Đặc tả ở CONN-04 API 1 và bước 2; chạy sau API 7. Mac không đăng ký push (0.4.4). +#### API 9 — Quyền Bluetooth (Mac) + +- **URL:** N/A +- **Method:** `CBManager.authorization` (thuộc tính lớp, đọc trước khi tạo manager); + `CBCentralManager(delegate: self, queue: btQueue, options: [CBCentralManagerOptionShowPowerAlertKey: false])`; + `centralManagerDidUpdateState(_:)` đọc `central.state`. +- **Request:** Info.plist có `NSBluetoothAlwaysUsageDescription` (thiếu → macOS dừng ứng dụng khi tạo manager). +- **Response:** `CBManagerAuthorization` ∈ {`.notDetermined`, `.restricted`, `.denied`, `.allowedAlways`}; + `CBManagerState` ∈ {`.unknown`, `.resetting`, `.unsupported`, `.unauthorized`, `.poweredOff`, `.poweredOn`}. +- **Ví dụ:** `.notDetermined` → trường 17 → "Tiếp tục" → tạo manager → người dùng cho phép → + `.allowedAlways`, trạng thái `.poweredOn` → CONN-05 được quét. +- **Logic nghiệp vụ:** + 1. Chỉ hỏi một lần: hộp thoại chỉ hiện khi quyền còn `.notDetermined`; sau đó chỉ đọc (AC7). + 2. Giữ cùng một manager cho CONN-05 và luồng B của PAIR-01; tạo manager trước màn giải thích sẽ làm hộp thoại + hiện mà không có lời giải thích, nên trước bước B1 không có đối tượng CoreBluetooth nào. + 3. `CBCentralManagerOptionShowPowerAlertKey` = `false`: Bluetooth tắt được báo bằng trường 18, không bằng cảnh báo + của hệ thống (E9). + 4. `.poweredOff` hoặc `.unauthorized` khi không có phiên LAN → trường 18; trạng thái khác thì ẩn. + 5. `.unsupported` (Mac không có Bluetooth LE) → không có kênh Bluetooth, không có trường 18. + +#### API 10 — Quyền Vị trí (Mac) + +- **URL:** N/A +- **Method:** `CLLocationManager()` có delegate; `authorizationStatus` (thuộc tính đối tượng); + `requestWhenInUseAuthorization()`; `locationManagerDidChangeAuthorization(_:)`. +- **Request:** Info.plist có `NSLocationUsageDescription` và `NSLocationWhenInUseUsageDescription`; entitlement + `com.apple.security.personal-information.location`. +- **Response:** `CLAuthorizationStatus` ∈ {`.notDetermined`, `.restricted`, `.denied`, `.authorizedAlways`, + `.authorizedWhenInUse`}; cả hai giá trị đã cấp đều tính là `allowed`. +- **Ví dụ:** `.notDetermined` → trường 19 → "Tiếp tục" → người dùng cho phép → `.authorizedAlways` → CoreWLAN trả + SSID, CONN-06 được chạy. +- **Logic nghiệp vụ:** + 1. Chỉ hỏi một lần: hộp thoại chỉ hiện khi trạng thái còn `.notDetermined`; sau đó chỉ đọc (AC7). + 2. HandLive không bao giờ bắt đầu cập nhật vị trí (không gọi `startUpdatingLocation`): quyền này chỉ để CoreWLAN trả + SSID và để kiểm mạng đã biết ở CONN-06 API 7. + 3. `link.direct_wifi` tắt → bỏ qua bước L1 và không hỏi quyền; bật lại sau đó thì hỏi một lần qua cùng màn giải + thích. + 4. Dịch vụ định vị tắt cho cả Mac → coi như `denied` (câu của E10). + #### Query ```text diff --git a/docs/detailed-design/02-pairing.md b/docs/detailed-design/02-pairing.md index 037490a..6cbc34f 100644 --- a/docs/detailed-design/02-pairing.md +++ b/docs/detailed-design/02-pairing.md @@ -12,12 +12,12 @@ English | [Tiếng Việt](02-pairing.vi.md) | Item | Content | |-----|----------| | Name | PAIR-01 — Pair devices with a QR code (fallback: PIN) | -| Description | Establishes a trust relationship between the Android phone and a Mac/iPhone/iPad: exchange the identity keys, derive the pair root key `PRK`, pin the phone's TLS certificate and store an attestation signed by both sides.
Mac/iOS shows the QR code, Android scans it.
When the QR code can't be scanned, a 6-digit PIN is used (LAN only).
From P2, if the two devices can't see each other on the LAN, the exchange goes through a rendezvous on the relay (QR only). | +| Description | Establishes a trust relationship between the Android phone and a Mac/iPhone/iPad: exchange the identity keys, derive the pair root key `PRK`, pin the phone's TLS certificate and store an attestation signed by both sides.
Mac/iOS shows the QR code, Android scans it.
When the QR code can't be scanned, a 6-digit PIN is used (on the LAN, or from P7 over Bluetooth with CPace, flow C).
From P2, if the two devices can't see each other on the LAN, the exchange goes through a rendezvous on the relay (QR only).
From P7, a Mac and a phone that share no LAN, even with no network at all, run the same exchange over a Bluetooth link (flow B with the QR, flow C with the PIN, 0.4.5 "Pairing mode"); the link is then kept for the first session. | | Actors | Primary: User (owns both devices). System: A-UI, A-SVC, M-APP or I-APP, R-API and R-KV (rendezvous, pair registration). | -| Preconditions | 1.
Android has completed SET-01 (with the camera permission for scanning the QR code) and A-SVC is running.
2.
Mac/iOS has completed SET-03 (local network permission).
3.
Both devices are on the same LAN; or (from P2) both have internet access and `relay.enabled = true`.
4.
Mac/iOS has no active pair yet.
5.
Android has fewer than 8 active pairs. | -| Postconditions | **Success:** both sides have a `paired_device` record with the same `pair_id`; `PRK` is in the key store; Mac/iOS pins `peer_tls_sha256`; the pair is registered with the relay if the relay is enabled (or marked as waiting for registration); CONN-01 starts automatically.
**Failure:** neither side stores anything; the `pairing_secret` or the PIN is wiped from memory. | -| Exceptions | E1 — The QR code is not a HandLive code or is malformed (`QR_INVALID`): Android shows "This QR code isn't a HandLive code."
E2 — The QR code has expired because Mac/iOS has refreshed it (`PAIRING_CLOSED`): Android shows "The QR code has changed. Scan the new code on your Mac or iPhone." In the PIN flow (A3–A4) the window closed after 120 s without a pair, or the third `pair/offer` for this PIN ended without `pair/done` (A4): Android shows "The PIN has expired. Get a new PIN on your Mac or iPhone and try again."
E3 — The devices don't find each other within 20 s and the relay is unavailable: Mac/iOS shows "Couldn't find the phone. Put both devices on the same Wi-Fi network and try again."
E4 — Wrong HMAC, signature or TLS binding; possibly a man-in-the-middle attack (`AUTH_FAILED`).
E5 — The user taps Cancel on Android.
E6 — Android already has 8 pairs: Android shows "This phone is already paired with 8 devices. Unpair one and try again."
E7 — Wrong PIN (`PIN_INVALID`); after 3 failed attempts Mac/iOS generates a new PIN and the phone closes the window (E2).
E8 — Registering the pair with the relay fails: the pair still works on the LAN, `relay_registered = 0`, retry in the background (API 8 logic 6).
E9 — Camera permission denied: Android shows "The camera isn't available. Use a PIN to pair." and switches to the PIN. | -| Special requirements | **Security:** `pairing_secret` is 256 bits, lives only 120 s, never leaves the device except through the QR code and is never logged; HMAC comparison is constant-time; the relay never sees `pairing_secret` and is not used for the PIN.
The PIN is the fallback path: an active attacker in the middle at the exact moment of pairing could guess the PIN offline — mitigated by Argon2id (t=3, m=64 MiB, p=4), at most 3 `pair/offer` per PIN counted by the phone (A4) and allowing it on the LAN only. Remaining risk: a single `pair/offer` carries an HMAC keyed by `K_pin`, so an attacker who obtains one offer can still try the 10^6 PINs offline (Argon2id makes each guess costly, not impossible). The long-term fix is a PAKE (CPace) for the PIN path — an open decision, not part of the current design.
**Performance:** from scanning to "Paired" ≤ 5 s on the LAN, ≤ 8 s through the relay.
**Usability:** the QR code has enough contrast in both light and dark appearance; the instructions are readable with VoiceOver/TalkBack; QR recognition runs entirely on the device (bundled ML Kit). | +| Preconditions | 1.
Android has completed SET-01 (with the camera permission for scanning the QR code) and A-SVC is running.
2.
Mac/iOS has completed SET-03 (local network permission).
3.
Both devices are on the same LAN; or (from P2) both have internet access and `relay.enabled = true`; or (from P7, Mac only) both have Bluetooth on, Android 12+ has Nearby devices granted, the Mac has Bluetooth access and `link.bluetooth` is on on both.
4.
Mac/iOS has no active pair yet.
5.
Android has fewer than 8 active pairs. | +| Postconditions | **Success:** both sides have a `paired_device` record with the same `pair_id`; `PRK` is in the key store; Mac/iOS pins `peer_tls_sha256`; the pair is registered with the relay if the relay is enabled (or marked as waiting for registration); CONN-01 starts automatically. Over Bluetooth: `last_host`, `last_port` stay empty, the relay registration waits for a network (E8), and the client goes straight to the session handshake on the kept link (B4, CONN-05 step 8) instead of CONN-01.
**Failure:** neither side stores anything; the `pairing_secret` or the PIN is wiped from memory. | +| Exceptions | E1 — The QR code is not a HandLive code or is malformed (`QR_INVALID`): Android shows "This QR code isn't a HandLive code."
E2 — The QR code has expired because Mac/iOS has refreshed it (`PAIRING_CLOSED`): Android shows "The QR code has changed. Scan the new code on your Mac or iPhone." In the PIN flow (A3–A4) the window closed after 120 s without a pair, or the third `pair/offer` for this PIN ended without `pair/done` (A4), or over Bluetooth the third channel that failed after `cpace1` (C3): Android shows "The PIN has expired. Get a new PIN on your Mac or iPhone and try again."
E3 — The devices don't find each other within 20 s and the relay is unavailable: Mac/iOS shows "Couldn't find the phone. Put both devices on the same Wi-Fi network and try again." A Mac that scanned Bluetooth during step 7 (B2) shows instead "Couldn't find the phone. Keep both devices close with Bluetooth on, or put them on the same Wi-Fi network, then try again." (key `pairing.error_not_found_offline`).
E4 — Wrong HMAC, signature or TLS binding; possibly a man-in-the-middle attack (`AUTH_FAILED`).
E5 — The user taps Cancel on Android.
E6 — Android already has 8 pairs: Android shows "This phone is already paired with 8 devices. Unpair one and try again."
E7 — Wrong PIN (`PIN_INVALID` on the LAN; over Bluetooth a channel that fails after `cpace1`, C3); after 3 failed attempts Mac/iOS generates a new PIN and the phone closes the window (E2).
E8 — Registering the pair with the relay fails: the pair still works on the LAN, `relay_registered = 0`, retry in the background (API 8 logic 6).
E9 — Camera permission denied: Android shows "The camera isn't available. Use a PIN to pair." and switches to the PIN.
E10 — PIN lockout: a PIN window closed by `PIN_MAX_ATTEMPTS` failures (E2) makes the phone refuse any new PIN window, on the LAN and over Bluetooth, for `BT_PIN_LOCKOUT`. Confirming a PIN during that time shows "Too many wrong PIN attempts. Wait 5 minutes, or scan the QR code instead." (key `pairing.error_pin_locked`); the QR flows still work. The earlier rule "the PIN works only on the same Wi-Fi" and its string `pairing.error_pin_needs_wifi` are withdrawn (owner decision 2026-10-28: the PIN pairs over Bluetooth through CPace).
E11 — Over Bluetooth, the channel does not open, gets `reject` (`LINK_REJECTED`), fails `noise2` (a copied advertisement) or closes before `pair/done`: the Mac tries the next advertiser with the same `pr_bt` while the 20 s of step 7 last; then E3. A link closed after `pair/hello` ends the pairing with "Pairing didn't finish. Try again." Nothing is stored, no existing pair is touched (AC8).
E12 — Bluetooth not usable for pairing: Nearby devices denied on Android (SET-01 E12) or Bluetooth access denied on the Mac (SET-03 E8) → flows B and C are skipped and pairing needs the LAN or the relay; Bluetooth off on the phone while the window is open and no Wi-Fi network is connected → the pairing screen shows field 11 and A-UI may request Bluetooth on (SET-01 API 10). | +| Special requirements | **Security:** `pairing_secret` is 256 bits, lives only 120 s, never leaves the device except through the QR code and is never logged; HMAC comparison is constant-time; the relay never sees `pairing_secret` and is not used for the PIN.
The PIN is the fallback path: an active attacker in the middle at the exact moment of pairing could guess the PIN offline — mitigated by Argon2id (t=3, m=64 MiB, p=4), at most `PIN_MAX_ATTEMPTS` failed attempts per PIN window counted by the phone (A4) and never through the relay. Remaining risk: a single `pair/offer` carries an HMAC keyed by `K_pin`, so an attacker who obtains one offer can still try the 10^6 PINs offline (Argon2id makes each guess costly, not impossible). Over Bluetooth the PIN goes through CPace instead (flow C) and cannot be checked offline; using CPace on the LAN too is not part of this design.
**Security over Bluetooth (flow B):** the advertisement carries only `pr_bt`, an HMAC keyed from `pairing_secret` that changes with every QR, never `pr`, `pk`, a name or a `device_id` (AC9); every `pair` envelope travels inside the link encryption, so names, keys and `device_id` never cross the air in the clear; the first frame is authenticated by `K_bt_plink` before the pre-auth slot becomes a link; no pre-authentication message changes pair state (AC8).
**Security of the PIN over Bluetooth (flow C):** the PIN goes only into CPace (`CPACE-X25519-SHA512`, 0.4.5 "Pairing mode"), so neither the PIN nor any value a listener could test it against crosses the air; a man in the middle gets one guess per channel, 3 per PIN (`PIN_MAX_ATTEMPTS`, counted on both sides), so one PIN window is guessed with probability 3 × 10^-6, and `BT_PIN_LOCKOUT` stops repeated windows; every PIN is single-use and lives at most `PAIRING_WINDOW`; the PIN-mode advertisement carries only `BT_PIN_HINT`, the same for every phone (AC9).
**Performance:** from scanning to "Paired" ≤ 5 s on the LAN, ≤ 8 s through the relay, ≤ 15 s over Bluetooth with both devices offline, for the QR from the scan and for the PIN from its confirmation on the phone (AC6).
**Not yet measured (A7.2/M7.2 check first on the S25 + Mac):** G7 never paired over BLE; AC6 itself; the advertising set restarted when the window opens (new PSM) being seen by the Mac's scan within a few seconds; the service data in the scan response (also CONN-05 API 3 logic 1); the Nearby devices and camera prompts back to back before scanning (SET-01 step 10); the Bluetooth prompt of a menu bar app (`LSUIElement`, SET-03 B1); flow C: neither Tink nor CryptoKit offers CPace or the Elligator2 map, so both are written in-house on Kotlin and Swift and checked only by the S7.1 vectors (`cpace.json`); the time of the CPace + Noise handshake (one round trip more than flow B) against AC6; the assumption that the phone is not in Doze while the PIN is typed.
**Usability:** the QR code has enough contrast in both light and dark appearance; the instructions are readable with VoiceOver/TalkBack; QR recognition runs entirely on the device (bundled ML Kit). | ### 2.1.2 Screens @@ -36,7 +36,8 @@ N/A — no approved wireframe yet. | 7 | PIN attempts left | int32 | Output | 3 | Shown on Android after a wrong entry: "{count} attempts left" ("1 attempt left") | | 8 | Pairing status | enum{waiting_scan\| connecting\| verifying\| done\| failed} | Output | `waiting_scan` | Shown on both devices | | 9 | Phone name | string(64) | Output | `Settings.Global.DEVICE_NAME` | Shown on Mac/iOS when pairing completes | -| 10 | Error message | string | Output | Empty | Text per E1–E9; failures without their own text (lost connection, internal error): "Pairing didn't finish. Try again." | +| 10 | Error message | string | Output | Empty | Text per E1–E12; failures without their own text (lost connection, internal error): "Pairing didn't finish. Try again." | +| 11 | Bluetooth-off hint (Android) | string + action | Output / Input | Hidden | Shown on the pairing screen while the window is open, Bluetooth is off, Nearby devices is granted and no Wi-Fi network is connected: "Bluetooth is off. Turn it on to pair without Wi-Fi." (key `pairing.bluetooth_off`) with the button "Turn On Bluetooth" (key `common.turn_on_bluetooth`), which sends `ACTION_REQUEST_ENABLE` (SET-01 API 10; a tap by the user does not count toward `BT_ENABLE_PROMPT_INTERVAL`) | ### 2.1.4 Business flow @@ -46,12 +47,20 @@ flowchart TB U1["(1) Choose Add Phone on Mac/iOS"] U3["(3) Open Pair a Device on Android, scan the QR code"] U5["(5) Confirm pairing"] + UA3["(A3) Type the PIN on Android and confirm"] U12["(12) See Paired on both devices"] end subgraph HT["System"] S2["(2) Generate pairing_secret, show the QR code for 120 s"] D4{"(4) QR code valid and a pair slot left?"} S6["(6) Android opens the pairing window, advertises TXT pr"] + B1["(B1) Android also advertises pr_bt over BLE when Bluetooth is usable"] + B2["(B2) Mac also scans BLE for pr_bt beside mDNS"] + B3["(B3) Open the L2CAP channel, link-security handshake with K_bt_plink"] + B4["(B4) Keep the link, session handshake on it"] + C1["(C1) Android advertises BT_PIN_HINT over BLE when Bluetooth is usable"] + C2["(C2) Mac scans BLE for BT_PIN_HINT beside mDNS pm = 1"] + C3["(C3) CPace with the PIN, then link-security handshake with K_bt_pinlink"] D7{"(7) Found each other within 20 s?"} S8["(8) Open /v1/pair (LAN) or the relay rendezvous"] S9["(9) pair/hello and pair/offer"] @@ -62,11 +71,16 @@ flowchart TB U1 --> S2 --> U3 --> D4 D4 -- "Yes" --> U5 D4 -- "No (E1, E2, E6)" --> X1 - U5 -- "Accept" --> S6 --> D7 + U5 -- "Accept" --> S6 --> B1 --> B2 --> D7 U5 -- "Cancel (E5)" --> X1 D7 -- "LAN or relay" --> S8 --> S9 --> D10 + D7 -- "Bluetooth only (E11)" --> B3 --> S9 + U1 -- "Use a PIN (A1)" --> UA3 --> C1 --> C2 --> D7 + D7 -- "PIN, Bluetooth only (E7, E11)" --> C3 --> S9 + UA3 -- "Locked out (E10)" --> X1 D7 -- "No (E3)" --> X1 D10 -- "Valid" --> S11 --> U12 + S11 -- "Over Bluetooth" --> B4 --> U12 D10 -- "Invalid (E4)" --> X1 ``` @@ -78,16 +92,24 @@ flowchart TB | 4 | System | A-UI | Checks the scheme `handlive`, the host `pair`, `v = 1`, that `pk` and `ps` decode to exactly 32 bytes, `d` ≤ 64 characters and `rv` (if present) is exactly 16 bytes. Checks that active pairs < 8. | Malformed → E1. 8 pairs already → E6. | | 5 | User | A-UI | Chooses "Pair" or "Cancel" in the "Pair with \?" dialog. | "Cancel" → E5. | | 6 | System | A-SVC | Opens a 120 s pairing window: accepts connections on `/v1/pair`; re-registers the mDNS service with TXT `pr` = the first 8 lowercase hex digits of SHA-256 over the 32 decoded bytes of `pk`. If `rv` is present: connect to the relay and send `rv_join`. | | -| 7 | System | M-APP / I-APP | Waits up to 20 s: an instance with a matching `pr` is found → go over the LAN; `rv_joined` arrives with `peer_present = true` → go through the relay. The LAN wins if both are available. | No path at all → E3. | +| 7 | System | M-APP / I-APP | Waits up to 20 s: an instance with a matching `pr` is found → go over the LAN; `rv_joined` arrives with `peer_present = true` → go through the relay; (Mac, P7) an advertisement carrying `pr_bt` and no LAN candidate → go over Bluetooth (B3). The LAN wins if both are available; the relay is not deployed in P7. | No path at all → E3. | | 8 | System | M-APP / I-APP | LAN: open `wss://:/v1/pair` and accept the self-signed certificate, but record the SHA-256 of the certificate seen. Relay: wrap the `pair` envelopes in `rv_msg`. | | | 9 | System | M-APP ↔ A-SVC | The client sends `pair/hello`. Android checks that `ik_dh_pub` equals the `pk` from the QR code, generates `nonce_s`, computes `K_pa` and returns a `pair/offer` with a `mac`. | `ik_dh_pub` differs from `pk` → `pair/error AUTH_FAILED` (E4). | | 10 | System | M-APP / I-APP | Checks the offer's `mac`; checks that the Android `device_id` = UUIDv8(SHA-256(`ik_sig_pub`)); on the LAN checks that `tls_sha256` = the certificate of the current connection. | Mismatch → send `pair/error AUTH_FAILED`, close, show "Pairing isn't secure — try again" (E4). | | 11 | System | M-APP ↔ A-SVC, R-API | The client computes `PRK`, generates `pair_id`, `created_at` and the attestation, signs `sig_c` and sends `pair/confirm`.
Android checks `mac`, `prk_check`, `device_id` and `sig_c`; signs `sig_s`; stores `paired_device`; returns `pair/done`.
The client checks `sig_s`, `prk_check`; stores `paired_device`, puts `PRK` in the Keychain and pins the certificate.
Both sides discard the secret; Android closes `/v1/pair`, drops TXT `pr` and adds the new pair's hint to TXT `h`.
If the relay is enabled, each side calls `POST /v1/pairs` (idempotent). | Relay error → E8, retry in the background when a network is available. | | 12 | User | A-UI, M-APP / I-APP | Sees "Paired with \" on both devices; the client moves on to CONN-01. | | -| A1 | User | M-APP / I-APP | PIN flow: chooses "Can't Scan? Use a PIN". | LAN only. | -| A2 | System | M-APP / I-APP | Generates a 6-digit PIN (CSPRNG, uniformly distributed) and shows it for 120 s; browses mDNS for an instance with `pm = 1`. | | +| B1 | System | A-SVC | Flow B (P7). With the window of step 6 open and Bluetooth usable, A-SVC derives `K_bt_pdisc`, `K_bt_plink` from the scanned `ps` and restarts the advertising set with `pr_bt` first (0.4.5 "Pairing mode", CONN-05 API 1). Bluetooth off with no Wi-Fi network connected → field 11 and SET-01 API 10. | Nearby devices denied → no advertising (E12). | +| B2 | System | M-APP | Mac only, Bluetooth usable: from step 2 on, derives the same two keys from its `pairing_secret` (again at every QR refresh) and scans with the `BT_SERVICE_UUID` filter (CONN-05 API 3); an advertisement whose hints contain `pr_bt` is a candidate. iOS/iPadOS skip flow B. | Bluetooth access denied → only the LAN and the relay (E12). | +| B3 | System | M-APP ↔ A-SVC | Connects and opens the L2CAP channel (CONN-05 API 4), then the link-security handshake of 0.4.5 with psk = `K_bt_plink` (API 10). Steps 9–11 follow as `pair` envelopes on control channel `0x01` (API 11); in step 10 there is no TLS connection to compare, so the client pins the `tls_sha256` of the offer, as through the relay. | `reject`, `noise2` failure or a closed channel → next candidate, then E3 (E11). | +| B4 | System | M-APP ↔ A-SVC | After `pair/done` both sides keep the link (no close 1000): A-SVC treats it as the control link of the new pair, stops pairing-mode advertising and wipes the pairing keys; M-APP stores the pair and goes to `Handshaking` (0.11) with `session/hello` for the new `pair_id` on the same link (CONN-05 step 8). The pairing has succeeded even if this session fails: the client then follows CONN-05 from `Backoff`. | Relay registration (API 8) waits for a network (E8). | +| A1 | User | M-APP / I-APP | PIN flow: chooses "Can't Scan? Use a PIN". | On the LAN, or over Bluetooth with CPace (flow C). | +| A2 | System | M-APP / I-APP | Generates a 6-digit PIN (CSPRNG, uniformly distributed) and shows it for 120 s; browses mDNS for an instance with `pm = 1`; a Mac with Bluetooth usable also scans BLE (C2). | | | A3 | User | A-UI | Chooses "Enter PIN", types the 6 digits and confirms the pairing. | | -| A4 | System | A-SVC | Once the PIN is confirmed, opens the pairing window with TXT `pm = 1` (never earlier). The client connects to `/v1/pair` and sends `pair/hello` with `mode = "pin"`. Both sides use `K_pin` = Argon2id(PIN, `nonce_c` ‖ `nonce_s`) instead of `pairing_secret` in steps 9–11.
The phone counts its own `pair/offer` messages: at most 3 per PIN (`PIN_MAX_ATTEMPTS`), whatever `attempts_left` the client reports. When the third offer ends without `pair/done` (`PIN_INVALID`, disconnect, timeout), the window closes as "PIN expired" (E2) and the user makes a new PIN. | After `PIN_INVALID` the window stays open and the PIN field comes back: a client that connects before the new PIN is confirmed waits for it (no `pair/offer` yet) and the PIN field stays editable; "Pairing…" (`verifying`) starts when the PIN is confirmed. | +| A4 | System | A-SVC | Once the PIN is confirmed, opens the pairing window with TXT `pm = 1` (never earlier), and over Bluetooth advertises `BT_PIN_HINT` (C1); during `BT_PIN_LOCKOUT` it opens no window (E10). The client connects to `/v1/pair` and sends `pair/hello` with `mode = "pin"`. Both sides use `K_pin` = Argon2id(PIN, `nonce_c` ‖ `nonce_s`) instead of `pairing_secret` in steps 9–11.
The phone keeps one failure counter per PIN window, shared with Bluetooth (C3): each of its own `pair/offer` messages that ends without `pair/done` (`PIN_INVALID`, disconnect, timeout) adds one, whatever `attempts_left` the client reports. When the counter reaches `PIN_MAX_ATTEMPTS` (3, LAN and Bluetooth together), the window closes as "PIN expired" (E2) and the user makes a new PIN. | After `PIN_INVALID` the window stays open and the PIN field comes back: a client that connects before the new PIN is confirmed waits for it (no `pair/offer` yet) and the PIN field stays editable; "Pairing…" (`verifying`) starts when the PIN is confirmed. | +| C1 | System | A-SVC | Flow C (P7, owner decision 2026-10-28). With the PIN window of A4 open and Bluetooth usable, A-SVC restarts the advertising set with `BT_PIN_HINT` first (0.4.5 "Pairing mode", PIN) and accepts `cpace1` on new channels. | Nearby devices denied → no advertising (E12). | +| C2 | System | M-APP | Mac only, Bluetooth usable: during A2 scans with the `BT_SERVICE_UUID` filter; an advertisement carrying `BT_PIN_HINT` with no `pm = 1` instance on the LAN is a candidate (the LAN wins when both exist). Several phones in PIN mode → try them one after the other within the 20 s of step 7. iOS/iPadOS skip flow C. | Bluetooth access denied → LAN only (E12). | +| C3 | System | M-APP ↔ A-SVC | Opens the L2CAP channel, receives `challenge`, runs CPace with the PIN (`cpace1`, `cpace2`) and the link-security handshake with `K_bt_pinlink` (API 12). Then steps 9–11 as `pair` envelopes on control channel `0x01` with `mode = "pin"` and `K_bt_pin` in place of `K_pin` (no Argon2id); `tls_sha256` is pinned as in B3. Both sides count a channel that fails after `cpace1` as one wrong PIN on the same counter as the LAN attempts of A4/A5: the total across both transports never exceeds `PIN_MAX_ATTEMPTS` per PIN, so one PIN window allows 3 guesses (3 × 10^-6), not 3 per transport. | Wrong PIN → E7, then E2 and E10; `reject`, another phone or a closed channel → next candidate, then E3 (E11). | +| C4 | System | M-APP ↔ A-SVC | As B4: the link is kept for the session handshake of the new pair; the PIN, `ISK`, `K_bt_pinlink` and `K_bt_pin` are wiped. | | | A5 | System | M-APP / I-APP | Checks the offer's `mac` with its own PIN. Wrong → `pair/error PIN_INVALID` with the number of attempts left; third failure → discard the PIN and generate a new one (E7). Correct → continue with steps 10–12. | | ### 2.1.5 API/service specification @@ -105,6 +127,9 @@ flowchart TB | 7 | Relay `rv_join` / `rv_joined` / `rv_msg` | `wss://{RELAY_HOST}/v1/relay` (text) | Device ↔ R-API | 2, 6, 7, 8 | | 8 | `POST /v1/pairs` | Relay REST | Device → R-API | 11 | | 9 | Operating system services: `NsdManager.registerService`, `NWBrowser`, ML Kit `BarcodeScanning`, `CIFilter.qrCodeGenerator`, Keychain `SecItemAdd` | Local | — | 2, 3, 6, 7, 11 | +| 10 | Bluetooth pairing link: pairing-mode advertising, scan, L2CAP channel and link-security handshake with `K_bt_plink` | BLE, L2CAP | Client ↔ Android | B1, B2, B3 | +| 11 | `pair` envelopes over the Bluetooth link | Link channel `0x01` | Both directions | B3, C3, 9–11, B4 | +| 12 | Bluetooth PIN pairing link: `BT_PIN_HINT` advertising, records `cpace1` / `cpace2`, link-security handshake with `K_bt_pinlink` | BLE, L2CAP | Client ↔ Android | C1, C2, C3 | Authentication strings shared by the APIs below: - `K_pa` = HKDF-SHA256(ikm = `pairing_secret` (or `K_pin`), salt = `nonce_c` ‖ `nonce_s`, info = @@ -170,8 +195,9 @@ Authentication strings shared by the APIs below: 1. Accepted only while the pairing window is open; outside the window → `pair/error PAIRING_CLOSED` and close. 2. `mode = qr`: `ik_dh_pub` must equal the scanned `pk`, otherwise → `AUTH_FAILED`. `mode = pin`: - accepted only when the window is open in PIN mode and the connection comes from the LAN (not - through the relay). + accepted only when the window is open in PIN mode and the connection comes from the LAN or from a + Bluetooth PIN pairing link (API 12), never through the relay; on a QR pairing link `mode = pin` → + `AUTH_FAILED` (API 11). 3. Check `device_id` = UUIDv8(SHA-256(`ik_sig_pub`)); mismatch → `AUTH_FAILED`. 4. A pairing window serves only one client: a second connection in the same window → `PAIRING_CLOSED`. Only that connection is refused: the window, its timer and the client @@ -212,8 +238,9 @@ Authentication strings shared by the APIs below: 2. `tls_sha256` is protected by `mac`, so even through the relay (where there is no direct TLS channel) the client pins the phone's real certificate. 3. The client checks in this order: `mac` → `device_id` matches `ik_sig_pub` → (LAN) `tls_sha256` - equals the certificate of the current connection. Any failed check → `AUTH_FAILED`, without - revealing which check failed. + equals the certificate of the current connection; through the relay and over Bluetooth there is no + TLS connection and the `mac`-protected value is pinned as it is. Any failed check → `AUTH_FAILED`, + without revealing which check failed. #### API 4 — `WS pair/confirm` @@ -258,7 +285,8 @@ Authentication strings shared by the APIs below: | `prk_check` | b64u (32 bytes) | Yes | HMAC-SHA256(`PRK`, `"HL1\|prk-check-s\|"` ‖ `pair_id`) | | `mac` | b64u (32 bytes) | Yes | HMAC-SHA256(`K_pa`, `"HL1\|done\|"` ‖ `pair_id` ‖ `sig`) — `pair_id` 16 bytes, `sig` 64 raw bytes | -- **Response:** N/A (end of the protocol; the client closes the connection with code 1000). +- **Response:** N/A (end of the protocol; the client closes the connection with code 1000; over Bluetooth the link + is kept for the session handshake, B4). - **Example:** ```json @@ -325,7 +353,7 @@ Authentication strings shared by the APIs below: 3. The relay forwards `env` unchanged to the other member without reading its content; it only accepts `env.type = "pair"`. 4. A `pair/hello` envelope with `mode = "pin"` through the rendezvous is blocked by the relay and - also rejected by Android (the PIN is LAN-only). To block it the relay decodes the unencrypted + also rejected by Android (the PIN never goes through the relay). To block it the relay decodes the unencrypted `pair` payload: the only place where it looks inside an envelope (0.4.3). 5. An `rv_msg` while no other member is in the rendezvous → `error NOT_CONNECTED`; an `rv_msg` for a rendezvous the sender is not in (never joined, or expired) → `error BAD_REQUEST`. An expired @@ -393,6 +421,85 @@ Content-Type: application/json API 1) and repeats the call; a second 404 means the peer is not registered yet → the next call for that pair waits 24 h. Any other 4xx answer also waits 24 h before the next call. +#### API 10 — Bluetooth pairing link + +- **URL:** N/A (BLE advertising channels, then an L2CAP channel to the advertised PSM) +- **Method:** Android: `BluetoothLeAdvertiser.startAdvertisingSet` and `listenUsingInsecureL2capChannel` (CONN-05 API 1, + 2); Mac: `scanForPeripherals`, `connect`, `openL2CAPChannel` (CONN-05 API 3, 4); then the records `challenge`, + `noise1`, `noise2`, `reject` (0.4.5). +- **Request:** + +| Item | Value | Description | +|------|-------|-------------| +| `K_bt_pdisc` | HKDF-SHA256(ikm = `pairing_secret`, salt = empty, info = `"handlive/v1/bt-pair-disc"`, L = 32) | Both sides; Android from the scanned `ps` | +| `K_bt_plink` | HKDF-SHA256(ikm = `pairing_secret`, salt = empty, info = `"handlive/v1/bt-pair-link"`, L = 32) | The Noise psk of the pairing link | +| `pr_bt` | first 4 bytes of HMAC-SHA256(`K_bt_pdisc`, `"HLBTPAIR1"`) | First hint of the service data while the window is open | +| `noise1` | `rlen` = `0x0031` ‖ `0x02` ‖ `e` (32) ‖ tag (16) | Pattern `Noise_NNpsk0_25519_ChaChaPoly_SHA256`, psk = `K_bt_plink`, prologue = `"HLBT1"` ‖ `n_s` | + +- **Response:** `noise2` (`rlen` = `0x0031` ‖ `0x03` ‖ `e` ‖ tag) or `reject` (`rlen` = `0x0001` ‖ `0x7F`). +- **Example:** no pair yet, PSM `0x0091`, `pr_bt` = `5e 0c 9a 21`: scan response `18 21 U×16 01 91 00 5e 0c 9a 21` + (25 bytes; `U×16` = `BT_SERVICE_UUID` little-endian); the S7.1 vectors give `K_bt_pdisc`, `K_bt_plink`, `pr_bt` and + a full transcript for the `ps` of the API 1 example. +- **Business logic:** + 1. A-SVC advertises `pr_bt` only while the window of step 6 is open; opening and closing the window each restart + the advertising set with a new PSM (CONN-05 API 1 logic 1). + 2. While the window is open A-SVC tries `K_bt_plink` before the pairs' psks; once a pairing link has sent a valid + `pair/hello` it stops trying it (one client per window). + 3. The Mac tries every advertiser with its `pr_bt` in turn (a copied advertisement fails `noise2`) until step 7 + times out. + 4. Admission, timeouts and failure counting are those of 0.4.5; `reject` says nothing about the cause. + +#### API 11 — `pair` envelopes over the Bluetooth link + +- **URL:** N/A (control channel `0x01` of a pairing link) +- **Method:** the JSON envelopes of API 2–6 (0.5.1, `type` = `pair`, unencrypted payload with its `mac`), one per + message, fragmented and padded per 0.4.5. +- **Request:** `pair/hello` (`mode` = `qr`), then `pair/confirm`, as in API 2 and 4. +- **Response:** `pair/offer`, `pair/done` or `pair/error`, as in API 3, 5 and 6. +- **Example:** the envelope of the API 2 example, carried unchanged as the `data` of control-channel fragments. +- **Business logic:** + 1. `pair/hello` must arrive within `HANDSHAKE_TIMEOUT` after the link is confirmed; its `mode` must match the link: + `qr` on a QR pairing link (API 10), `pin` on a PIN pairing link (API 12); otherwise `pair/error` `AUTH_FAILED` and + close. + 2. Any envelope other than `pair` before `pair/done` → close the link without an answer. + 3. After `pair/done` the next envelope from the client must be `session/hello` naming the new `pair_id` (0.4.5 + "Pairing mode"); A-SVC treats the link as that pair's link from then on. + 4. Android stores the pair at step 11 exactly as on the LAN (Query); a failure afterwards is handled by PAIR-03 + cleanup, as in API 5 logic 1. + +#### API 12 — Bluetooth PIN pairing link (CPace) + +- **URL:** N/A (BLE advertising channels, then an L2CAP channel to the advertised PSM) +- **Method:** advertising and channel as in API 10; then the records `challenge` (S→C), `cpace1` (C→S), `cpace2` + (S→C), `noise1`, `noise2`, `reject` (0.4.5). +- **Request:** + +| Item | Value | Description | +|------|-------|-------------| +| Hint | `BT_PIN_HINT` (`00 00 00 00`) | First hint of the service data while the PIN window is open | +| CPace suite | `CPACE-X25519-SHA512`, initiator-responder | draft-irtf-cfrg-cpace, revision pinned by `shared/test-vectors/cpace.json` | +| PRS / CI / sid / ADa / ADb | UTF-8 of the six digits / `"HLBTPIN1"` / `n_s` of `challenge` / empty / empty | `n_s` binds the exchange to this channel, as the Noise prologue does | +| `cpace1` | `rlen` = `0x0021` ‖ `0x04` ‖ `Ya` (32) | `Ya` = X25519(`ya`, `g`) | +| `K_bt_pinlink` | HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-link"`, L = 32) | Noise psk; prologue `"HLBT1"` ‖ `n_s` | +| `K_bt_pin` | HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-pair"`, L = 32) | Replaces `K_pin` in `K_pa` and `PRK` (0.6.2) | + +- **Response:** `cpace2` (`rlen` = `0x0021` ‖ `0x05` ‖ `Yb` (32)), then `noise2` as in API 10, or `reject`. +- **Example:** PIN `042517` (PRS = `30 34 32 35 31 37`); `challenge` `00 21 01` ‖ `n_s`; `cpace1` `00 21 04` ‖ `Ya`; + `cpace2` `00 21 05` ‖ `Yb`; the S7.1 vectors give `g`, `Ya`, `Yb`, `K`, `ISK` and both keys for fixed `n_s`, `ya`, + `yb`. +- **Business logic:** + 1. A-SVC accepts `cpace1` only while a PIN window is open; otherwise it closes the channel without an answer. It + never answers `noise1` with `K_bt_pinlink` before its own `cpace2` on the same channel. + 2. `Ya` or `Yb` that makes `K` all zero (a low-order point) → close without an answer; counted as a failed attempt. + 3. A failed `noise1` tag after `cpace1` → `reject`, one attempt on the phone's counter for this PIN window + (the counter shared with the LAN, A4); a failed `noise2` tag → the Mac closes, one attempt on the Mac's counter + for this PIN (shared with `PIN_INVALID` on the LAN, A5). At `PIN_MAX_ATTEMPTS` the phone closes the window + (E2) and starts `BT_PIN_LOCKOUT` (E10); the Mac generates a new PIN (E7). + 4. The PIN window serves one client: once a PIN pairing link has sent a valid `pair/hello`, A-SVC accepts no + further `cpace1` for that window. + 5. Admission, `BT_PREAUTH_TIMEOUT` and `BT_AUTH_FAIL_BLOCK` are those of 0.4.5; `reject` says nothing about the + cause. + #### Query ```sql @@ -424,6 +531,14 @@ VALUES (:pair_id, :peer_device_id, :peer_name, :peer_model, :peer_ik_sig_pub, :peer_ik_dh_pub, :peer_tls_sha256, :attestation, :sig_self, :sig_peer, :last_host, :last_port, :created_at); +-- [Design] Mac, step 11 over Bluetooth (B3): store the pair without a LAN address +INSERT INTO paired_device (pair_id, peer_device_id, peer_name, peer_model, peer_ik_sig_pub, + peer_ik_dh_pub, peer_tls_sha256, attestation, sig_self, sig_peer, + last_host, last_port, created_at) +VALUES (:pair_id, :peer_device_id, :peer_name, :peer_model, :peer_ik_sig_pub, + :peer_ik_dh_pub, :peer_tls_sha256, :attestation, :sig_self, :sig_peer, + NULL, NULL, :created_at); + -- [Design] Relay, API 8: get the public keys of the two devices SELECT device_id, ik_sig_pub FROM devices diff --git a/docs/detailed-design/02-pairing.vi.md b/docs/detailed-design/02-pairing.vi.md index 5dcb050..27e50e1 100644 --- a/docs/detailed-design/02-pairing.vi.md +++ b/docs/detailed-design/02-pairing.vi.md @@ -12,12 +12,12 @@ | Mục | Nội dung | |-----|----------| | Tên | PAIR-01 — Ghép nối thiết bị bằng mã QR (dự phòng: PIN) | -| Mô tả | Thiết lập quan hệ tin cậy giữa điện thoại Android và một Mac/iPhone/iPad: trao đổi khóa định danh, sinh khóa gốc của cặp `PRK`, ghim chứng chỉ TLS của điện thoại, lưu bản chứng thực có chữ ký của cả hai bên.
Mac/iOS hiển thị QR, Android quét.
Khi không quét được QR thì dùng PIN 6 số (chỉ trong LAN).
Từ P2, nếu hai thiết bị không thấy nhau trong LAN, bước trao đổi đi qua điểm hẹn trên relay (chỉ với QR). | +| Mô tả | Thiết lập quan hệ tin cậy giữa điện thoại Android và một Mac/iPhone/iPad: trao đổi khóa định danh, sinh khóa gốc của cặp `PRK`, ghim chứng chỉ TLS của điện thoại, lưu bản chứng thực có chữ ký của cả hai bên.
Mac/iOS hiển thị QR, Android quét.
Khi không quét được QR thì dùng PIN 6 số (trong LAN, hoặc từ P7 qua Bluetooth bằng CPace, luồng C).
Từ P2, nếu hai thiết bị không thấy nhau trong LAN, bước trao đổi đi qua điểm hẹn trên relay (chỉ với QR).
Từ P7, Mac và điện thoại không chung LAN, kể cả khi không có mạng nào, chạy cùng bước trao đổi đó qua một link Bluetooth (luồng B với QR, luồng C với PIN, 0.4.5 "Chế độ ghép đôi"); link sau đó được giữ cho phiên đầu tiên. | | Tác nhân | Chính: Người dùng (sở hữu cả hai thiết bị). Hệ thống: A-UI, A-SVC, M-APP hoặc I-APP, R-API và R-KV (điểm hẹn, đăng ký cặp). | -| Điều kiện trước | 1.
Android đã hoàn tất SET-01 (có quyền camera để quét QR) và A-SVC đang chạy.
2.
Mac/iOS đã hoàn tất SET-03 (quyền mạng cục bộ).
3.
Hai thiết bị cùng LAN; hoặc (từ P2) cả hai có Internet và `relay.enabled = true`.
4.
Mac/iOS chưa có cặp hiệu lực nào.
5.
Android có ít hơn 8 cặp hiệu lực. | -| Điều kiện sau | **Thành công:** hai bên có bản ghi `paired_device` cùng `pair_id`; `PRK` nằm trong kho khóa; Mac/iOS ghim `peer_tls_sha256`; cặp được đăng ký lên relay nếu relay bật (hoặc đánh dấu chờ đăng ký); CONN-01 tự chạy.
**Thất bại:** không bên nào lưu gì; `pairing_secret` hoặc PIN bị hủy khỏi bộ nhớ. | -| Ngoại lệ | E1 — QR không phải của HandLive hoặc sai định dạng (`QR_INVALID`): Android báo "Mã QR này không phải của HandLive."
E2 — QR đã hết hạn vì Mac/iOS đã làm mới (`PAIRING_CLOSED`): Android báo "Mã QR đã đổi. Quét mã mới trên Mac hoặc iPhone." Ở luồng PIN (A3–A4), cửa sổ đã đóng sau 120 s mà chưa ghép được, hoặc `pair/offer` thứ ba của PIN này kết thúc mà không có `pair/done` (A4): Android báo "Mã PIN đã hết hạn. Lấy mã PIN mới trên Mac hoặc iPhone rồi thử lại."
E3 — Không tìm thấy nhau trong 20 s và relay không khả dụng: Mac/iOS báo "Không tìm thấy điện thoại. Để hai máy cùng mạng Wi-Fi rồi thử lại."
E4 — HMAC, chữ ký hoặc ràng buộc TLS sai, có thể đang bị tấn công xen giữa (`AUTH_FAILED`).
E5 — Người dùng bấm Hủy trên Android.
E6 — Android đã đủ 8 cặp: Android báo "Điện thoại đã ghép đủ 8 thiết bị. Hủy ghép nối một thiết bị rồi thử lại."
E7 — PIN sai (`PIN_INVALID`); quá 3 lần thì Mac/iOS sinh PIN mới và điện thoại đóng cửa sổ (E2).
E8 — Đăng ký cặp lên relay lỗi: cặp vẫn dùng được trong LAN, `relay_registered = 0`, thử lại nền (API 8 logic 6).
E9 — Quyền camera bị từ chối: Android báo "Không dùng được camera. Dùng mã PIN để ghép nối." và chuyển sang PIN. | -| Yêu cầu đặc biệt | **Bảo mật:** `pairing_secret` 256 bit, chỉ sống 120 s, không bao giờ rời thiết bị ngoài QR, không ghi log; so sánh HMAC hằng thời gian; relay không nhìn thấy `pairing_secret` và không được dùng cho PIN.
PIN là đường dự phòng: kẻ tấn công chủ động nằm giữa đúng lúc ghép có thể dò PIN ngoại tuyến — giảm thiểu bằng Argon2id (t=3, m=64 MiB, p=4), tối đa 3 `pair/offer` cho mỗi PIN do điện thoại tự đếm (A4) và chỉ cho phép trong LAN. Rủi ro còn lại: một `pair/offer` mang HMAC khóa bằng `K_pin`, nên kẻ tấn công lấy được một offer vẫn có thể thử 10^6 PIN ngoại tuyến (Argon2id làm mỗi lần thử tốn kém, không phải bất khả). Cách khắc phục lâu dài là PAKE (CPace) cho đường PIN — quyết định còn mở, chưa thuộc thiết kế hiện tại.
**Hiệu năng:** từ lúc quét tới "Đã ghép nối" ≤ 5 s trong LAN, ≤ 8 s qua relay.
**Khả dụng:** QR đủ tương phản ở cả giao diện sáng và tối; hướng dẫn đọc được bằng VoiceOver/TalkBack; nhận diện QR chạy hoàn toàn trên máy (ML Kit bản đóng gói). | +| Điều kiện trước | 1.
Android đã hoàn tất SET-01 (có quyền camera để quét QR) và A-SVC đang chạy.
2.
Mac/iOS đã hoàn tất SET-03 (quyền mạng cục bộ).
3.
Hai thiết bị cùng LAN; hoặc (từ P2) cả hai có Internet và `relay.enabled = true`; hoặc (từ P7, chỉ Mac) cả hai bật Bluetooth, Android 12+ đã cấp Nearby devices, Mac đã có quyền Bluetooth và `link.bluetooth` bật ở cả hai.
4.
Mac/iOS chưa có cặp hiệu lực nào.
5.
Android có ít hơn 8 cặp hiệu lực. | +| Điều kiện sau | **Thành công:** hai bên có bản ghi `paired_device` cùng `pair_id`; `PRK` nằm trong kho khóa; Mac/iOS ghim `peer_tls_sha256`; cặp được đăng ký lên relay nếu relay bật (hoặc đánh dấu chờ đăng ký); CONN-01 tự chạy. Qua Bluetooth: `last_host`, `last_port` để trống, việc đăng ký relay chờ có mạng (E8), và client đi thẳng vào bắt tay phiên trên link được giữ lại (B4, CONN-05 bước 8) thay vì CONN-01.
**Thất bại:** không bên nào lưu gì; `pairing_secret` hoặc PIN bị hủy khỏi bộ nhớ. | +| Ngoại lệ | E1 — QR không phải của HandLive hoặc sai định dạng (`QR_INVALID`): Android báo "Mã QR này không phải của HandLive."
E2 — QR đã hết hạn vì Mac/iOS đã làm mới (`PAIRING_CLOSED`): Android báo "Mã QR đã đổi. Quét mã mới trên Mac hoặc iPhone." Ở luồng PIN (A3–A4), cửa sổ đã đóng sau 120 s mà chưa ghép được, hoặc `pair/offer` thứ ba của PIN này kết thúc mà không có `pair/done` (A4), hoặc qua Bluetooth kênh thứ ba thất bại sau `cpace1` (C3): Android báo "Mã PIN đã hết hạn. Lấy mã PIN mới trên Mac hoặc iPhone rồi thử lại."
E3 — Không tìm thấy nhau trong 20 s và relay không khả dụng: Mac/iOS báo "Không tìm thấy điện thoại. Để hai máy cùng mạng Wi-Fi rồi thử lại." Mac đã quét Bluetooth trong bước 7 (B2) thì báo câu khác: "Không tìm thấy điện thoại. Để hai máy ở gần nhau và bật Bluetooth, hoặc cho hai máy cùng mạng Wi-Fi, rồi thử lại." (khóa `pairing.error_not_found_offline`).
E4 — HMAC, chữ ký hoặc ràng buộc TLS sai, có thể đang bị tấn công xen giữa (`AUTH_FAILED`).
E5 — Người dùng bấm Hủy trên Android.
E6 — Android đã đủ 8 cặp: Android báo "Điện thoại đã ghép đủ 8 thiết bị. Hủy ghép nối một thiết bị rồi thử lại."
E7 — PIN sai (`PIN_INVALID` trong LAN; qua Bluetooth là một kênh thất bại sau `cpace1`, C3); quá 3 lần thì Mac/iOS sinh PIN mới và điện thoại đóng cửa sổ (E2).
E8 — Đăng ký cặp lên relay lỗi: cặp vẫn dùng được trong LAN, `relay_registered = 0`, thử lại nền (API 8 logic 6).
E9 — Quyền camera bị từ chối: Android báo "Không dùng được camera. Dùng mã PIN để ghép nối." và chuyển sang PIN.
E10 — Khóa tạm PIN: cửa sổ PIN đóng vì đủ `PIN_MAX_ATTEMPTS` lần thất bại (E2) làm điện thoại từ chối mọi cửa sổ PIN mới, trong LAN và qua Bluetooth, trong `BT_PIN_LOCKOUT`. Xác nhận PIN trong thời gian đó thì hiện "Nhập sai mã PIN quá nhiều lần. Chờ 5 phút, hoặc quét mã QR." (khóa `pairing.error_pin_locked`); luồng QR vẫn dùng được. Luật cũ "PIN chỉ dùng được khi cùng Wi-Fi" và chuỗi `pairing.error_pin_needs_wifi` bị bỏ (sếp quyết định ngày 2026-10-28: PIN ghép qua Bluetooth bằng CPace).
E11 — Qua Bluetooth, kênh không mở được, nhận `reject` (`LINK_REJECTED`), `noise2` sai (gói quảng bá bị chép) hoặc kênh đóng trước `pair/done`: Mac thử máy phát kế tiếp có cùng `pr_bt` trong khi còn 20 s của bước 7; hết giờ thì E3. Link bị đóng sau `pair/hello` kết thúc lần ghép đôi với câu "Không ghép nối được. Thử lại." Không lưu gì, không đụng tới cặp nào đã có (AC8).
E12 — Không dùng được Bluetooth để ghép: Android từ chối Nearby devices (SET-01 E12) hoặc Mac bị từ chối quyền Bluetooth (SET-03 E8) → bỏ qua luồng B và C, việc ghép cần LAN hoặc relay; Bluetooth trên điện thoại đang tắt khi cửa sổ mở và không nối mạng Wi-Fi nào → màn hình ghép nối hiện trường 11 và A-UI có thể xin bật Bluetooth (SET-01 API 10). | +| Yêu cầu đặc biệt | **Bảo mật:** `pairing_secret` 256 bit, chỉ sống 120 s, không bao giờ rời thiết bị ngoài QR, không ghi log; so sánh HMAC hằng thời gian; relay không nhìn thấy `pairing_secret` và không được dùng cho PIN.
PIN là đường dự phòng: kẻ tấn công chủ động nằm giữa đúng lúc ghép có thể dò PIN ngoại tuyến — giảm thiểu bằng Argon2id (t=3, m=64 MiB, p=4), tối đa `PIN_MAX_ATTEMPTS` lần thử thất bại cho mỗi cửa sổ PIN do điện thoại tự đếm (A4) và không bao giờ qua relay. Rủi ro còn lại: một `pair/offer` mang HMAC khóa bằng `K_pin`, nên kẻ tấn công lấy được một offer vẫn có thể thử 10^6 PIN ngoại tuyến (Argon2id làm mỗi lần thử tốn kém, không phải bất khả). Qua Bluetooth, PIN đi qua CPace (luồng C) nên không kiểm được ngoại tuyến; dùng CPace cho cả LAN chưa thuộc thiết kế này.
**Bảo mật qua Bluetooth (luồng B):** gói quảng bá chỉ mang `pr_bt`, một HMAC khóa từ `pairing_secret` đổi theo từng QR, không bao giờ mang `pr`, `pk`, tên hay `device_id` (AC9); mọi envelope `pair` đi bên trong lớp mã hóa link, nên tên, khóa và `device_id` không bao giờ lộ trên sóng; frame đầu tiên được xác thực bằng `K_bt_plink` trước khi suất pre-auth trở thành link; không message trước xác thực nào đổi trạng thái cặp (AC8).
**Bảo mật của PIN qua Bluetooth (luồng C):** PIN chỉ đi vào CPace (`CPACE-X25519-SHA512`, 0.4.5 "Chế độ ghép đôi"), nên cả PIN lẫn bất kỳ giá trị nào để kẻ nghe lén thử PIN đều không đi qua sóng; kẻ đứng giữa được một lần đoán mỗi kênh, 3 lần mỗi PIN (`PIN_MAX_ATTEMPTS`, cả hai bên cùng đếm), nên xác suất đoán trúng một cửa sổ PIN là 3 × 10^-6, và `BT_PIN_LOCKOUT` chặn việc mở cửa sổ liên tục; mỗi PIN chỉ dùng một lần và sống tối đa `PAIRING_WINDOW`; gói quảng bá ở chế độ PIN chỉ mang `BT_PIN_HINT`, giống nhau cho mọi điện thoại (AC9).
**Hiệu năng:** từ lúc quét tới "Đã ghép nối" ≤ 5 s trong LAN, ≤ 8 s qua relay, ≤ 15 s qua Bluetooth khi cả hai máy không có mạng, với QR tính từ lúc quét và với PIN tính từ lúc xác nhận trên điện thoại (AC6).
**Chưa đo (A7.2/M7.2 kiểm đầu tiên trên S25 + Mac):** G7 chưa từng ghép đôi qua BLE; bản thân AC6; advertising set khởi động lại khi cửa sổ mở (PSM mới) có được Mac quét thấy trong vài giây không; service data trong scan response (cũng là CONN-05 API 3 logic 1); hộp thoại Nearby devices và camera liền nhau trước khi quét (SET-01 bước 10); hộp thoại Bluetooth của một app thanh menu (`LSUIElement`, SET-03 B1); luồng C: cả Tink lẫn CryptoKit đều không có CPace hay ánh xạ Elligator2, nên hai thứ này tự viết trên Kotlin và Swift và chỉ được kiểm bằng vector S7.1 (`cpace.json`); thời gian bắt tay CPace + Noise (thêm một lượt đi về so với luồng B) so với AC6; giả định điện thoại không ở Doze khi đang nhập PIN.
**Khả dụng:** QR đủ tương phản ở cả giao diện sáng và tối; hướng dẫn đọc được bằng VoiceOver/TalkBack; nhận diện QR chạy hoàn toàn trên máy (ML Kit bản đóng gói). | ### 2.1.2 Màn hình @@ -36,7 +36,8 @@ N/A — chưa có wireframe được duyệt. | 7 | Số lần nhập PIN còn lại | int32 | Output | 3 | Hiển thị trên Android sau lần nhập sai: "Còn {count} lần thử" | | 8 | Trạng thái ghép nối | enum{waiting_scan\| connecting\| verifying\| done\| failed} | Output | `waiting_scan` | Hiển thị trên cả hai thiết bị | | 9 | Tên điện thoại | string(64) | Output | `Settings.Global.DEVICE_NAME` | Hiển thị trên Mac/iOS khi ghép xong | -| 10 | Thông báo lỗi | string | Output | Rỗng | Nội dung theo E1–E9; lỗi không có câu riêng (mất kết nối, lỗi nội bộ): "Không ghép nối được. Thử lại." | +| 10 | Thông báo lỗi | string | Output | Rỗng | Nội dung theo E1–E12; lỗi không có câu riêng (mất kết nối, lỗi nội bộ): "Không ghép nối được. Thử lại." | +| 11 | Gợi ý Bluetooth đang tắt (Android) | string + action | Output / Input | Ẩn | Hiện trên màn hình ghép nối khi cửa sổ đang mở, Bluetooth tắt, đã cấp Nearby devices và không nối mạng Wi-Fi nào: "Bluetooth đang tắt. Bật Bluetooth để ghép nối khi không có Wi-Fi." (khóa `pairing.bluetooth_off`) kèm nút "Bật Bluetooth" (khóa `common.turn_on_bluetooth`), nút này gửi `ACTION_REQUEST_ENABLE` (SET-01 API 10; người dùng tự bấm thì không tính vào `BT_ENABLE_PROMPT_INTERVAL`) | ### 2.1.4 Luồng nghiệp vụ @@ -46,12 +47,20 @@ flowchart TB U1["(1) Chọn Thêm điện thoại trên Mac/iOS"] U3["(3) Mở Ghép nối trên Android, quét QR"] U5["(5) Xác nhận ghép nối"] + UA3["(A3) Nhập PIN trên Android và xác nhận"] U12["(12) Thấy Đã ghép nối trên hai thiết bị"] end subgraph HT["Hệ thống"] S2["(2) Sinh pairing_secret, hiển thị QR 120 s"] D4{"(4) QR hợp lệ và còn chỗ?"} S6["(6) Android mở cửa sổ ghép nối, quảng bá TXT pr"] + B1["(B1) Android quảng bá thêm pr_bt qua BLE khi dùng được Bluetooth"] + B2["(B2) Mac quét thêm BLE tìm pr_bt bên cạnh mDNS"] + B3["(B3) Mở kênh L2CAP, bắt tay bảo mật link với K_bt_plink"] + B4["(B4) Giữ link, bắt tay phiên trên chính link đó"] + C1["(C1) Android quảng bá BT_PIN_HINT qua BLE khi dùng được Bluetooth"] + C2["(C2) Mac quét BLE tìm BT_PIN_HINT bên cạnh mDNS pm = 1"] + C3["(C3) CPace bằng PIN, rồi bắt tay bảo mật link với K_bt_pinlink"] D7{"(7) Tìm thấy nhau trong 20 s?"} S8["(8) Mở /v1/pair (LAN) hoặc điểm hẹn relay"] S9["(9) pair/hello và pair/offer"] @@ -62,11 +71,16 @@ flowchart TB U1 --> S2 --> U3 --> D4 D4 -- "Có" --> U5 D4 -- "Không (E1, E2, E6)" --> X1 - U5 -- "Đồng ý" --> S6 --> D7 + U5 -- "Đồng ý" --> S6 --> B1 --> B2 --> D7 U5 -- "Hủy (E5)" --> X1 D7 -- "LAN hoặc relay" --> S8 --> S9 --> D10 + D7 -- "Chỉ có Bluetooth (E11)" --> B3 --> S9 + U1 -- "Dùng PIN (A1)" --> UA3 --> C1 --> C2 --> D7 + D7 -- "PIN, chỉ có Bluetooth (E7, E11)" --> C3 --> S9 + UA3 -- "Đang bị khóa tạm (E10)" --> X1 D7 -- "Không (E3)" --> X1 D10 -- "Đúng" --> S11 --> U12 + S11 -- "Qua Bluetooth" --> B4 --> U12 D10 -- "Sai (E4)" --> X1 ``` @@ -78,16 +92,24 @@ flowchart TB | 4 | Hệ thống | A-UI | Kiểm scheme `handlive`, host `pair`, `v = 1`, `pk` và `ps` giải mã đủ 32 byte, `d` ≤ 64 ký tự, `rv` (nếu có) đủ 16 byte. Đếm cặp hiệu lực < 8. | Sai định dạng → E1. Đủ 8 cặp → E6. | | 5 | Người dùng | A-UI | Chọn "Ghép nối" hoặc "Hủy" ở hộp thoại "Ghép nối với \?". | "Hủy" → E5. | | 6 | Hệ thống | A-SVC | Mở cửa sổ ghép nối 120 s: nhận kết nối `/v1/pair`; đăng ký lại dịch vụ mDNS với TXT `pr` = 8 hex đầu (chữ thường) của SHA-256 trên 32 byte `pk` đã giải b64u. Nếu có `rv`: kết nối relay, gửi `rv_join`. | | -| 7 | Hệ thống | M-APP / I-APP | Chờ tối đa 20 s: thấy instance có `pr` khớp → đi LAN; nhận `rv_joined` với `peer_present = true` → đi relay. LAN được ưu tiên nếu cả hai cùng có. | Không có đường nào → E3. | +| 7 | Hệ thống | M-APP / I-APP | Chờ tối đa 20 s: thấy instance có `pr` khớp → đi LAN; nhận `rv_joined` với `peer_present = true` → đi relay; (Mac, P7) thấy gói quảng bá mang `pr_bt` mà không có ứng viên LAN → đi Bluetooth (B3). LAN được ưu tiên nếu cả hai cùng có; P7 không triển khai relay. | Không có đường nào → E3. | | 8 | Hệ thống | M-APP / I-APP | LAN: mở `wss://:/v1/pair`, chấp nhận chứng chỉ tự ký nhưng ghi lại SHA-256 chứng chỉ thấy được. Relay: gói envelope `pair` trong `rv_msg`. | | | 9 | Hệ thống | M-APP ↔ A-SVC | Client gửi `pair/hello`. Android kiểm `ik_dh_pub` trùng `pk` trong QR, sinh `nonce_s`, tính `K_pa`, trả `pair/offer` có `mac`. | `ik_dh_pub` khác `pk` → `pair/error AUTH_FAILED` (E4). | | 10 | Hệ thống | M-APP / I-APP | Kiểm `mac` của offer; kiểm `device_id` Android = UUIDv8(SHA-256(`ik_sig_pub`)); trong LAN kiểm `tls_sha256` = chứng chỉ của kết nối hiện tại. | Sai → gửi `pair/error AUTH_FAILED`, đóng, báo "Ghép nối không an toàn, thử lại" (E4). | | 11 | Hệ thống | M-APP ↔ A-SVC, R-API | Client tính `PRK`, sinh `pair_id`, `created_at`, bản chứng thực, ký `sig_c`, gửi `pair/confirm`.
Android kiểm `mac`, `prk_check`, `device_id` và `sig_c`; ký `sig_s`; lưu `paired_device`; trả `pair/done`.
Client kiểm `sig_s`, `prk_check`; lưu `paired_device`, `PRK` vào Keychain, ghim chứng chỉ.
Cả hai hủy secret; Android đóng `/v1/pair`, bỏ TXT `pr`, thêm hint của cặp mới vào TXT `h`.
Nếu relay bật, mỗi bên gọi `POST /v1/pairs` (idempotent). | Relay lỗi → E8, thử lại nền khi có mạng. | | 12 | Người dùng | A-UI, M-APP / I-APP | Thấy "Đã ghép nối với " trên cả hai thiết bị; client chuyển sang CONN-01. | | -| A1 | Người dùng | M-APP / I-APP | Luồng PIN: chọn "Không quét được? Dùng mã PIN". | Chỉ LAN. | -| A2 | Hệ thống | M-APP / I-APP | Sinh PIN 6 số (CSPRNG, phân bố đều), hiển thị 120 s; duyệt mDNS tìm instance có `pm = 1`. | | +| B1 | Hệ thống | A-SVC | Luồng B (P7). Khi cửa sổ ở bước 6 đang mở và dùng được Bluetooth, A-SVC tính `K_bt_pdisc`, `K_bt_plink` từ `ps` vừa quét rồi khởi động lại advertising set với `pr_bt` đứng đầu (0.4.5 "Chế độ ghép đôi", CONN-05 API 1). Bluetooth tắt và không nối mạng Wi-Fi nào → trường 11 và SET-01 API 10. | Từ chối Nearby devices → không quảng bá (E12). | +| B2 | Hệ thống | M-APP | Chỉ Mac, dùng được Bluetooth: từ bước 2, tính cùng hai khóa đó từ `pairing_secret` của mình (tính lại mỗi lần làm mới QR) và quét với bộ lọc `BT_SERVICE_UUID` (CONN-05 API 3); gói quảng bá có `pr_bt` trong danh sách hint là một ứng viên. iOS/iPadOS bỏ qua luồng B. | Bị từ chối quyền Bluetooth → chỉ còn LAN và relay (E12). | +| B3 | Hệ thống | M-APP ↔ A-SVC | Kết nối và mở kênh L2CAP (CONN-05 API 4), rồi bắt tay bảo mật link của 0.4.5 với psk = `K_bt_plink` (API 10). Bước 9–11 tiếp theo dưới dạng envelope `pair` trên kênh điều khiển `0x01` (API 11); ở bước 10 không có kết nối TLS để so, nên client ghim `tls_sha256` của offer, như khi qua relay. | `reject`, `noise2` sai hoặc kênh bị đóng → ứng viên kế tiếp, rồi E3 (E11). | +| B4 | Hệ thống | M-APP ↔ A-SVC | Sau `pair/done` hai bên giữ link (không đóng 1000): A-SVC coi đó là link điều khiển của cặp mới, ngừng quảng bá chế độ ghép đôi và xóa khóa ghép đôi; M-APP lưu cặp rồi chuyển `Handshaking` (0.11) với `session/hello` cho `pair_id` mới trên cùng link (CONN-05 bước 8). Ghép đôi vẫn thành công dù phiên này lỗi: khi đó client theo CONN-05 từ `Backoff`. | Đăng ký relay (API 8) chờ có mạng (E8). | +| A1 | Người dùng | M-APP / I-APP | Luồng PIN: chọn "Không quét được? Dùng mã PIN". | Trong LAN, hoặc qua Bluetooth bằng CPace (luồng C). | +| A2 | Hệ thống | M-APP / I-APP | Sinh PIN 6 số (CSPRNG, phân bố đều), hiển thị 120 s; duyệt mDNS tìm instance có `pm = 1`; Mac dùng được Bluetooth thì quét thêm BLE (C2). | | | A3 | Người dùng | A-UI | Chọn "Nhập mã PIN", nhập 6 số, xác nhận ghép nối. | | -| A4 | Hệ thống | A-SVC | Khi PIN đã được xác nhận mới mở cửa sổ ghép nối với TXT `pm = 1` (không mở sớm hơn). Client kết nối `/v1/pair`, gửi `pair/hello` với `mode = "pin"`. Hai bên dùng `K_pin` = Argon2id(PIN, `nonce_c` ‖ `nonce_s`) thay cho `pairing_secret` ở bước 9–11.
Điện thoại tự đếm các `pair/offer` của mình: tối đa 3 cho mỗi PIN (`PIN_MAX_ATTEMPTS`), bất kể client báo `attempts_left` bao nhiêu. Khi offer thứ ba kết thúc mà không có `pair/done` (`PIN_INVALID`, ngắt kết nối, hết giờ), cửa sổ đóng như "PIN hết hạn" (E2) và người dùng tạo PIN mới. | Sau `PIN_INVALID`, cửa sổ vẫn mở và ô PIN hiện lại: client kết nối trước khi PIN mới được xác nhận sẽ chờ PIN (chưa có `pair/offer`), ô PIN vẫn nhập được; "Đang ghép nối…" (`verifying`) bắt đầu khi PIN được xác nhận. | +| A4 | Hệ thống | A-SVC | Khi PIN đã được xác nhận mới mở cửa sổ ghép nối với TXT `pm = 1` (không mở sớm hơn), và qua Bluetooth quảng bá `BT_PIN_HINT` (C1); trong `BT_PIN_LOCKOUT` thì không mở cửa sổ nào (E10). Client kết nối `/v1/pair`, gửi `pair/hello` với `mode = "pin"`. Hai bên dùng `K_pin` = Argon2id(PIN, `nonce_c` ‖ `nonce_s`) thay cho `pairing_secret` ở bước 9–11.
Điện thoại giữ một bộ đếm thất bại cho mỗi cửa sổ PIN, dùng chung với Bluetooth (C3): mỗi `pair/offer` của nó kết thúc mà không có `pair/done` (`PIN_INVALID`, ngắt kết nối, hết giờ) cộng một, bất kể client báo `attempts_left` bao nhiêu. Khi bộ đếm tới `PIN_MAX_ATTEMPTS` (3, tính chung LAN và Bluetooth), cửa sổ đóng như "PIN hết hạn" (E2) và người dùng tạo PIN mới. | Sau `PIN_INVALID`, cửa sổ vẫn mở và ô PIN hiện lại: client kết nối trước khi PIN mới được xác nhận sẽ chờ PIN (chưa có `pair/offer`), ô PIN vẫn nhập được; "Đang ghép nối…" (`verifying`) bắt đầu khi PIN được xác nhận. | +| C1 | Hệ thống | A-SVC | Luồng C (P7, sếp quyết định ngày 2026-10-28). Khi cửa sổ PIN của A4 đang mở và dùng được Bluetooth, A-SVC khởi động lại advertising set với `BT_PIN_HINT` đứng đầu (0.4.5 "Chế độ ghép đôi", PIN) và nhận `cpace1` trên kênh mới. | Từ chối Nearby devices → không quảng bá (E12). | +| C2 | Hệ thống | M-APP | Chỉ Mac, dùng được Bluetooth: trong A2 quét với bộ lọc `BT_SERVICE_UUID`; khi LAN không có instance `pm = 1`, gói quảng bá mang `BT_PIN_HINT` là một ứng viên (có cả hai thì LAN được ưu tiên). Nhiều điện thoại ở chế độ PIN → thử lần lượt trong 20 s của bước 7. iOS/iPadOS bỏ qua luồng C. | Bị từ chối quyền Bluetooth → chỉ LAN (E12). | +| C3 | Hệ thống | M-APP ↔ A-SVC | Mở kênh L2CAP, nhận `challenge`, chạy CPace bằng PIN (`cpace1`, `cpace2`) và bắt tay bảo mật link với `K_bt_pinlink` (API 12). Sau đó bước 9–11 dưới dạng envelope `pair` trên kênh điều khiển `0x01` với `mode = "pin"` và `K_bt_pin` thay cho `K_pin` (không dùng Argon2id); `tls_sha256` được ghim như ở B3. Hai bên tính một kênh thất bại sau `cpace1` là một lần sai PIN trên cùng bộ đếm với các lần thử trong LAN ở A4/A5: tổng của cả hai đường truyền không bao giờ vượt `PIN_MAX_ATTEMPTS` cho mỗi PIN, nên một cửa sổ PIN chỉ cho 3 lần đoán (3 × 10^-6), không phải 3 lần cho mỗi đường. | PIN sai → E7, rồi E2 và E10; `reject`, điện thoại khác hoặc kênh bị đóng → ứng viên kế tiếp, rồi E3 (E11). | +| C4 | Hệ thống | M-APP ↔ A-SVC | Như B4: link được giữ cho bắt tay phiên của cặp mới; PIN, `ISK`, `K_bt_pinlink` và `K_bt_pin` bị xóa. | | | A5 | Hệ thống | M-APP / I-APP | Kiểm `mac` của offer bằng PIN của mình. Sai → `pair/error PIN_INVALID` kèm số lần còn lại; lần thứ 3 sai → hủy PIN, sinh PIN mới (E7). Đúng → tiếp bước 10–12. | | ### 2.1.5 Đặc tả API/service @@ -105,6 +127,9 @@ flowchart TB | 7 | Relay `rv_join` / `rv_joined` / `rv_msg` | `wss://{RELAY_HOST}/v1/relay` (text) | Thiết bị ↔ R-API | 2, 6, 7, 8 | | 8 | `POST /v1/pairs` | REST relay | Thiết bị → R-API | 11 | | 9 | Dịch vụ hệ điều hành: `NsdManager.registerService`, `NWBrowser`, ML Kit `BarcodeScanning`, `CIFilter.qrCodeGenerator`, Keychain `SecItemAdd` | Cục bộ | — | 2, 3, 6, 7, 11 | +| 10 | Link ghép đôi Bluetooth: quảng bá chế độ ghép đôi, quét, kênh L2CAP và bắt tay bảo mật link với `K_bt_plink` | BLE, L2CAP | Client ↔ Android | B1, B2, B3 | +| 11 | Envelope `pair` qua link Bluetooth | Kênh `0x01` của link | Hai chiều | B3, C3, 9–11, B4 | +| 12 | Link ghép đôi PIN qua Bluetooth: quảng bá `BT_PIN_HINT`, bản ghi `cpace1` / `cpace2`, bắt tay bảo mật link với `K_bt_pinlink` | BLE, L2CAP | Client ↔ Android | C1, C2, C3 | Chuỗi xác thực dùng chung trong các API dưới đây: - `K_pa` = HKDF-SHA256(ikm = `pairing_secret` (hoặc `K_pin`), salt = `nonce_c` ‖ `nonce_s`, info = @@ -166,7 +191,8 @@ Chuỗi xác thực dùng chung trong các API dưới đây: - **Logic nghiệp vụ:** 1. Chỉ nhận khi cửa sổ ghép nối đang mở; ngoài cửa sổ → `pair/error PAIRING_CLOSED` và đóng. 2. `mode = qr`: `ik_dh_pub` phải trùng `pk` đã quét, sai → `AUTH_FAILED`. `mode = pin`: chỉ nhận - khi cửa sổ mở ở chế độ PIN và kết nối đến từ LAN (không qua relay). + khi cửa sổ mở ở chế độ PIN và kết nối đến từ LAN hoặc từ một link ghép đôi PIN qua Bluetooth (API 12), + không bao giờ qua relay; trên link ghép đôi QR `mode = pin` → `AUTH_FAILED` (API 11). 3. Kiểm `device_id` = UUIDv8(SHA-256(`ik_sig_pub`)); sai → `AUTH_FAILED`. 4. Một cửa sổ ghép nối chỉ phục vụ một client: kết nối thứ hai trong cùng cửa sổ → `PAIRING_CLOSED`. Chỉ kết nối đó bị từ chối: cửa sổ, bộ đếm giờ và client đang giữ cửa @@ -206,7 +232,8 @@ Chuỗi xác thực dùng chung trong các API dưới đây: 2. `tls_sha256` được bảo vệ bởi `mac`, nên kể cả qua relay (không có kênh TLS trực tiếp) client vẫn ghim đúng chứng chỉ thật của điện thoại. 3. Client kiểm theo thứ tự: `mac` → `device_id` khớp `ik_sig_pub` → (LAN) `tls_sha256` trùng chứng - chỉ của kết nối hiện tại. Bất kỳ bước nào sai → `AUTH_FAILED`, không tiết lộ bước nào sai. + chỉ của kết nối hiện tại; qua relay và qua Bluetooth không có kết nối TLS, giá trị được `mac` bảo vệ + được ghim nguyên như vậy. Bất kỳ bước nào sai → `AUTH_FAILED`, không tiết lộ bước nào sai. #### API 4 — `WS pair/confirm` @@ -250,7 +277,8 @@ Chuỗi xác thực dùng chung trong các API dưới đây: | `prk_check` | b64u (32 byte) | Có | HMAC-SHA256(`PRK`, `"HL1\|prk-check-s\|"` ‖ `pair_id`) | | `mac` | b64u (32 byte) | Có | HMAC-SHA256(`K_pa`, `"HL1\|done\|"` ‖ `pair_id` ‖ `sig`) — `pair_id` 16 byte, `sig` 64 byte thô | -- **Response:** N/A (kết thúc giao thức; client đóng kết nối với mã 1000). +- **Response:** N/A (kết thúc giao thức; client đóng kết nối với mã 1000; qua Bluetooth link được giữ lại cho + bắt tay phiên, B4). - **Ví dụ:** ```json @@ -315,7 +343,7 @@ Chuỗi xác thực dùng chung trong các API dưới đây: 3. Relay chuyển nguyên `env` sang thành viên còn lại, không đọc nội dung; chỉ chấp nhận `env.type = "pair"`. 4. Envelope `pair/hello` có `mode = "pin"` qua điểm hẹn bị relay chặn và Android cũng từ chối (PIN - chỉ trong LAN). Để chặn, relay đọc payload `pair` chưa mã hóa (giải b64). Đây là chỗ duy nhất + không bao giờ đi qua relay). Để chặn, relay đọc payload `pair` chưa mã hóa (giải b64). Đây là chỗ duy nhất relay xem bên trong envelope (0.4.3). 5. `rv_msg` khi điểm hẹn không còn thành viên nào khác → `error NOT_CONNECTED`. `rv_msg` tới điểm hẹn mà người gửi không ở trong (chưa từng tham gia, hoặc đã hết hạn) → `error BAD_REQUEST`. Relay không phân @@ -382,6 +410,82 @@ Content-Type: application/json hai nghĩa là đối phương chưa đăng ký → lần gọi kế tiếp cho cặp đó chờ 24 h. Mọi mã 4xx khác cũng chờ 24 h trước lần gọi kế tiếp. +#### API 10 — Link ghép đôi Bluetooth + +- **URL:** N/A (kênh quảng bá BLE, rồi một kênh L2CAP tới PSM đã quảng bá) +- **Method:** Android: `BluetoothLeAdvertiser.startAdvertisingSet` và `listenUsingInsecureL2capChannel` (CONN-05 API 1, + 2); Mac: `scanForPeripherals`, `connect`, `openL2CAPChannel` (CONN-05 API 3, 4); sau đó các bản ghi `challenge`, + `noise1`, `noise2`, `reject` (0.4.5). +- **Request:** + +| Mục | Giá trị | Mô tả | +|------|-------|-------------| +| `K_bt_pdisc` | HKDF-SHA256(ikm = `pairing_secret`, salt = rỗng, info = `"handlive/v1/bt-pair-disc"`, L = 32) | Cả hai bên; Android tính từ `ps` đã quét | +| `K_bt_plink` | HKDF-SHA256(ikm = `pairing_secret`, salt = rỗng, info = `"handlive/v1/bt-pair-link"`, L = 32) | psk Noise của link ghép đôi | +| `pr_bt` | 4 byte đầu của HMAC-SHA256(`K_bt_pdisc`, `"HLBTPAIR1"`) | Hint đầu tiên của service data khi cửa sổ đang mở | +| `noise1` | `rlen` = `0x0031` ‖ `0x02` ‖ `e` (32) ‖ tag (16) | Mẫu `Noise_NNpsk0_25519_ChaChaPoly_SHA256`, psk = `K_bt_plink`, prologue = `"HLBT1"` ‖ `n_s` | + +- **Response:** `noise2` (`rlen` = `0x0031` ‖ `0x03` ‖ `e` ‖ tag) hoặc `reject` (`rlen` = `0x0001` ‖ `0x7F`). +- **Example:** chưa có cặp nào, PSM `0x0091`, `pr_bt` = `5e 0c 9a 21`: scan response `18 21 U×16 01 91 00 5e 0c 9a 21` + (25 byte; `U×16` = `BT_SERVICE_UUID` little-endian); vector của S7.1 cho `K_bt_pdisc`, `K_bt_plink`, `pr_bt` và một + bản ghi đầy đủ với `ps` của ví dụ API 1. +- **Business logic:** + 1. A-SVC chỉ quảng bá `pr_bt` khi cửa sổ ở bước 6 đang mở; mở và đóng cửa sổ đều khởi động lại advertising set + với PSM mới (CONN-05 API 1 logic 1). + 2. Khi cửa sổ đang mở, A-SVC thử `K_bt_plink` trước psk của các cặp; khi một link ghép đôi đã gửi `pair/hello` + hợp lệ thì thôi thử khóa này (mỗi cửa sổ một client). + 3. Mac lần lượt thử mọi máy phát có `pr_bt` của mình (gói quảng bá bị chép sẽ sai `noise2`) cho tới khi bước 7 + hết giờ. + 4. Tiếp nhận, thời hạn và đếm lỗi theo 0.4.5; `reject` không nói nguyên nhân. + +#### API 11 — Envelope `pair` qua link Bluetooth + +- **URL:** N/A (kênh điều khiển `0x01` của link ghép đôi) +- **Method:** các JSON envelope của API 2–6 (0.5.1, `type` = `pair`, payload không mã hóa kèm `mac`), mỗi message + một envelope, chia mảnh và đệm theo 0.4.5. +- **Request:** `pair/hello` (`mode` = `qr`), rồi `pair/confirm`, như API 2 và 4. +- **Response:** `pair/offer`, `pair/done` hoặc `pair/error`, như API 3, 5 và 6. +- **Example:** envelope trong ví dụ của API 2, giữ nguyên, làm `data` của các mảnh trên kênh điều khiển. +- **Business logic:** + 1. `pair/hello` phải tới trong `HANDSHAKE_TIMEOUT` sau khi link được xác nhận; `mode` phải khớp loại link: `qr` trên + link ghép đôi QR (API 10), `pin` trên link ghép đôi PIN (API 12); nếu không, `pair/error` `AUTH_FAILED` rồi đóng. + 2. Envelope nào khác `pair` trước `pair/done` → đóng link, không trả lời. + 3. Sau `pair/done`, envelope tiếp theo từ client phải là `session/hello` mang `pair_id` mới (0.4.5 "Chế độ ghép + đôi"); từ đó A-SVC coi link là link của cặp đó. + 4. Android lưu cặp ở bước 11 đúng như trong LAN (Query); lỗi sau đó được dọn bằng bước dọn của PAIR-03, như API 5 + logic 1. + +#### API 12 — Link ghép đôi PIN qua Bluetooth (CPace) + +- **URL:** N/A (kênh quảng bá BLE, rồi một kênh L2CAP tới PSM đã quảng bá) +- **Method:** quảng bá và kênh như API 10; sau đó các bản ghi `challenge` (S→C), `cpace1` (C→S), `cpace2` (S→C), + `noise1`, `noise2`, `reject` (0.4.5). +- **Request:** + +| Mục | Giá trị | Mô tả | +|------|-------|-------------| +| Hint | `BT_PIN_HINT` (`00 00 00 00`) | Hint đầu tiên của service data khi cửa sổ PIN đang mở | +| Bộ CPace | `CPACE-X25519-SHA512`, initiator-responder | draft-irtf-cfrg-cpace, bản draft cố định theo `shared/test-vectors/cpace.json` | +| PRS / CI / sid / ADa / ADb | UTF-8 của sáu chữ số / `"HLBTPIN1"` / `n_s` của `challenge` / rỗng / rỗng | `n_s` gắn lần trao đổi vào kênh này, như prologue Noise | +| `cpace1` | `rlen` = `0x0021` ‖ `0x04` ‖ `Ya` (32) | `Ya` = X25519(`ya`, `g`) | +| `K_bt_pinlink` | HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-link"`, L = 32) | psk Noise; prologue `"HLBT1"` ‖ `n_s` | +| `K_bt_pin` | HKDF-SHA256(ikm = `ISK`, salt = `n_s`, info = `"handlive/v1/bt-pin-pair"`, L = 32) | Thay `K_pin` trong `K_pa` và `PRK` (0.6.2) | + +- **Response:** `cpace2` (`rlen` = `0x0021` ‖ `0x05` ‖ `Yb` (32)), rồi `noise2` như API 10, hoặc `reject`. +- **Example:** PIN `042517` (PRS = `30 34 32 35 31 37`); `challenge` `00 21 01` ‖ `n_s`; `cpace1` `00 21 04` ‖ `Ya`; + `cpace2` `00 21 05` ‖ `Yb`; vector S7.1 cho `g`, `Ya`, `Yb`, `K`, `ISK` và cả hai khóa với `n_s`, `ya`, `yb` cố định. +- **Business logic:** + 1. A-SVC chỉ nhận `cpace1` khi đang mở cửa sổ PIN; nếu không thì đóng kênh, không trả lời. Nó không bao giờ trả lời + `noise1` khóa bằng `K_bt_pinlink` trước khi chính nó gửi `cpace2` trên cùng kênh. + 2. `Ya` hoặc `Yb` làm `K` toàn số 0 (điểm bậc thấp) → đóng, không trả lời; tính là một lần thử thất bại. + 3. Tag `noise1` sai sau `cpace1` → `reject`, một lần thử trên bộ đếm của điện thoại cho cửa sổ PIN này (bộ đếm dùng + chung với LAN, A4); tag `noise2` sai → Mac đóng kênh, một lần thử trên bộ đếm của Mac cho PIN này (dùng chung với + `PIN_INVALID` trong LAN, A5). Tới `PIN_MAX_ATTEMPTS` thì điện thoại đóng cửa sổ (E2) và bắt đầu + `BT_PIN_LOCKOUT` (E10); Mac sinh PIN mới (E7). + 4. Mỗi cửa sổ PIN phục vụ một client: khi một link ghép đôi PIN đã gửi `pair/hello` hợp lệ, A-SVC không nhận thêm + `cpace1` nào cho cửa sổ đó. + 5. Tiếp nhận, `BT_PREAUTH_TIMEOUT` và `BT_AUTH_FAIL_BLOCK` theo 0.4.5; `reject` không nói nguyên nhân. + #### Query ```sql @@ -413,6 +517,14 @@ VALUES (:pair_id, :peer_device_id, :peer_name, :peer_model, :peer_ik_sig_pub, :peer_ik_dh_pub, :peer_tls_sha256, :attestation, :sig_self, :sig_peer, :last_host, :last_port, :created_at); +-- [Thiết kế] Mac, bước 11 qua Bluetooth (B3): lưu cặp không có địa chỉ LAN +INSERT INTO paired_device (pair_id, peer_device_id, peer_name, peer_model, peer_ik_sig_pub, + peer_ik_dh_pub, peer_tls_sha256, attestation, sig_self, sig_peer, + last_host, last_port, created_at) +VALUES (:pair_id, :peer_device_id, :peer_name, :peer_model, :peer_ik_sig_pub, + :peer_ik_dh_pub, :peer_tls_sha256, :attestation, :sig_self, :sig_peer, + NULL, NULL, :created_at); + -- [Thiết kế] Relay, API 8: lấy khóa công khai của hai thiết bị SELECT device_id, ik_sig_pub FROM devices diff --git a/docs/detailed-design/03-connectivity.md b/docs/detailed-design/03-connectivity.md index ea850df..9bd49c5 100644 --- a/docs/detailed-design/03-connectivity.md +++ b/docs/detailed-design/03-connectivity.md @@ -17,7 +17,7 @@ English | [Tiếng Việt](03-connectivity.vi.md) | Actors | Primary: System (M-APP / I-APP, A-SVC). The user acts indirectly (opening the app, turning on Wi-Fi, opening the lid) or clicks "Reconnect Now". | | Preconditions | 1. There is a valid pair (PAIR-01).
2. A-SVC is running as a foreground service.
3. Both devices are on the same LAN and the network allows mDNS multicast.
4. The client has been granted the local network permission (iOS 14+, macOS 15+) per SET-03. | | Postconditions | **Success:** state `Connected` (LAN); session keys ready; the peer's capability stored in `features_json`; `last_seen_at`, `last_host`, `last_port` updated; the effective features turned on; the Android foreground service notification reads "Connected to \".
**Failure:** move to CONN-03 after `LAN_DISCOVERY_GRACE` (if the relay is on) or to `Backoff` (CONN-02). | -| Exceptions | E1 — No instance with a matching hint within 10 s → CONN-03 (relay on) or keep browsing.
E2 — `TLS_PIN_MISMATCH`: drop this instance (it may be another device or an impostor) and try the next instance; if every instance of the pair fails the pin (the phone has regenerated its TLS key, 0.6.1) → stop trying and show "Needs to be paired again" (PAIR-02).
E3 — `session/error AUTH_FAILED` → report "Couldn't verify the phone", long 5-minute backoff, no continuous retries.
E4 — `PAIR_UNKNOWN` or `PAIR_REVOKED` (4403) on the LAN (pinned TLS to the phone) → clean up the pair per PAIR-03 flow B, ask to pair again. Over the relay the same codes only lead to `Backoff` (CONN-03 E9).
E5 — 4426 `UNSUPPORTED_VERSION` → prompt to update the app on the older device.
E6 — Handshake longer than 5 s (4408) → CONN-02 backoff.
E7 — The network isolates clients (guest Wi-Fi, mDNS blocked) → same as E1.
E8 — Local network permission denied → report it and open the SET-03 guidance. | +| Exceptions | E1 — No instance with a matching hint within 10 s → CONN-03 (relay on) or keep browsing.
E2 — `TLS_PIN_MISMATCH`: drop this instance (it may be another device or an impostor) and try the next instance; if every instance of the pair fails the pin (the phone has regenerated its TLS key, 0.6.1) → stop trying and show "Needs to be paired again" (PAIR-02).
E3 — `session/error AUTH_FAILED` → report "Couldn't verify the phone", long 5-minute backoff, no continuous retries.
E4 — `PAIR_UNKNOWN` or `PAIR_REVOKED` (4403) before the phone is authenticated: on a route where `phoneAuthenticatedByTLS` is true (LAN, USB: pinned TLS to the phone) → clean up the pair per PAIR-03 flow B, ask to pair again. On a route where it is false (relay, Bluetooth) the same codes only lead to `Backoff` (0.11, CONN-03 E9, CONN-05 E5).
E5 — 4426 `UNSUPPORTED_VERSION` → prompt to update the app on the older device.
E6 — Handshake longer than 5 s (4408) → CONN-02 backoff.
E7 — The network isolates clients (guest Wi-Fi, mDNS blocked) → same as E1.
E8 — Local network permission denied → report it and open the SET-03 guidance. | | Special requirements | **Performance:** reconnect < 3 s when `last_host` is known (a project success metric); handshake ≤ 300 ms on the LAN.
**Security:** TLS 1.3 only; pin the certificate's SHA-256, no hostname check; the mDNS TXT contains no static identifier (0.4.1).
**Platform:** iOS/macOS declare `NSLocalNetworkUsageDescription` and `NSBonjourServices = ["_handlive._tcp"]`; Android runs A-SVC with type `connectedDevice` (permissions `FOREGROUND_SERVICE_CONNECTED_DEVICE` + `CHANGE_NETWORK_STATE`) with an ongoing notification.
**Feature independence:** capabilities decide each feature; a feature that lacks a permission does not block the other features. | ### 3.1.2 Screens @@ -288,7 +288,7 @@ WHERE revoked_at IS NULL; | Actors | Primary: System (M-APP / I-APP, A-SVC). The user can click "Reconnect Now". | | Preconditions | There has been a session (`Connected`) before, or the client is in `Backoff`/`Discovering`. | | Postconditions | The session is kept or re-established; the displayed status matches reality within ≤ 1 s of detection; `sms_outbox` entries still `pending` are resent; SMS-01 and CALL-04 catch up. | -| Exceptions | E1 — The network is completely lost: go to `Idle`, wait for `NWPathMonitor`/`NetworkCallback` to report a network, no useless backoff.
E2 — The Mac goes to sleep: send `session/bye {reason: shutdown}` if there is still time; on wake-up run CONN-01 immediately.
E3 — iOS goes to the background (`scenePhase` of the app becomes `.background`; `.inactive` is ignored): I-APP keeps the session for a short grace instead of closing at once. It starts a background task (`beginBackgroundTask`); iOS refuses it → `session/bye {reason: shutdown}` and close at once. Otherwise the session stays open (pings go on) until min(`IOS_BACKGROUND_GRACE`, the background time iOS still gives − 5 s), then `session/bye {reason: shutdown}`, close 1000 and end the task.
The grace is one holder of the session; a notification action (SMS-04 step B2, CALL-02 step B2) is another and keeps its own background task as today. I-APP sends `bye` and closes only when no holder is left and the app is not in the foreground. When iOS expires the grace task, the grace releases its hold and ends its task at once without waiting; an action still running keeps the session through its own task. An action after the session has closed connects as before (CONN-01 or CONN-03).
Closing and opening run one after the other on one queue (cancelling the timer, the timer firing, `bye`, close, CONN-01). Back in the foreground: the close has not started → cancel the timer, end the task, keep the session (no `bye`, no new CONN-01); `bye` already sent → wait until the close completes, then run CONN-01, so the client never holds two sessions of the same pair (no 4409 against itself).
The session drops during the grace → release the grace at once: cancel the timer, end the task, no `bye`, no reconnection in the background.
During the grace: an incoming SMS or call shows the notification the user would get through push (CONN-04), posted locally with no new text and with the same content rules as I-NSE (`sms.preview`, locked iPhone, SMS-02 field 2); a `clipboard/push` is written only if `UIPasteboard.changeCount` still equals its reference value, taken when the app went to the background and moved after each write or clear HandLive makes during the grace (CLIP-04 E2, CLIP-05); a successful write is remembered as HandLive's own, a failed write follows the existing error path (`ack` as today).
After the grace, while suspended, messages arrive through push (CONN-04).
E4 — Rekey without an `ack` within 10 s → close the session, reconnect (a new handshake generates new keys).
E5 — `DECRYPT_FAILED` → close 4400, reconnect.
E6 — The operating system stops A-SVC (OEM background killing) → the client sees the connection drop; A-SVC `START_STICKY` restarts by itself; SET-01 has already requested the battery optimization exemption.
E7 — Closed with 4409 (replaced by a new connection of the same client; over the relay: `session/bye {reason: replaced}`, API 4) → do not reconnect from the old session. | +| Exceptions | E1 — The network is completely lost: LAN and relay sessions end; go to `Idle`, wait for `NWPathMonitor`/`NetworkCallback` to report a network, no useless backoff. On the Mac with Bluetooth usable, a Bluetooth session stays and the client keeps looking over Bluetooth instead of going to `Idle` (0.11, CONN-05).
E2 — The Mac goes to sleep: send `session/bye {reason: shutdown}` if there is still time; on wake-up run CONN-01 immediately.
E3 — iOS goes to the background (`scenePhase` of the app becomes `.background`; `.inactive` is ignored): I-APP keeps the session for a short grace instead of closing at once. It starts a background task (`beginBackgroundTask`); iOS refuses it → `session/bye {reason: shutdown}` and close at once. Otherwise the session stays open (pings go on) until min(`IOS_BACKGROUND_GRACE`, the background time iOS still gives − 5 s), then `session/bye {reason: shutdown}`, close 1000 and end the task.
The grace is one holder of the session; a notification action (SMS-04 step B2, CALL-02 step B2) is another and keeps its own background task as today. I-APP sends `bye` and closes only when no holder is left and the app is not in the foreground. When iOS expires the grace task, the grace releases its hold and ends its task at once without waiting; an action still running keeps the session through its own task. An action after the session has closed connects as before (CONN-01 or CONN-03).
Closing and opening run one after the other on one queue (cancelling the timer, the timer firing, `bye`, close, CONN-01). Back in the foreground: the close has not started → cancel the timer, end the task, keep the session (no `bye`, no new CONN-01); `bye` already sent → wait until the close completes, then run CONN-01, so the client never holds two sessions of the same pair (no 4409 against itself).
The session drops during the grace → release the grace at once: cancel the timer, end the task, no `bye`, no reconnection in the background.
During the grace: an incoming SMS or call shows the notification the user would get through push (CONN-04), posted locally with no new text and with the same content rules as I-NSE (`sms.preview`, locked iPhone, SMS-02 field 2); a `clipboard/push` is written only if `UIPasteboard.changeCount` still equals its reference value, taken when the app went to the background and moved after each write or clear HandLive makes during the grace (CLIP-04 E2, CLIP-05); a successful write is remembered as HandLive's own, a failed write follows the existing error path (`ack` as today).
After the grace, while suspended, messages arrive through push (CONN-04).
E4 — Rekey without an `ack` within 10 s → close the session, reconnect (a new handshake generates new keys).
E5 — `DECRYPT_FAILED` → close 4400, reconnect.
E6 — The operating system stops A-SVC (OEM background killing) → the client sees the connection drop; A-SVC `START_STICKY` restarts by itself; SET-01 has already requested the battery optimization exemption.
E7 — Closed with 4409 (replaced by a new connection of the same client; over the relay: `session/bye {reason: replaced}`, API 4) → do not reconnect from the old session. | | Special requirements | **Performance:** reconnect < 3 s after the network returns; detect a lost connection in ≤ 25 s (15 s ping + 10 s waiting for the pong).
**Battery:** the client pings actively, Android only answers with pongs and closes connections that have been silent for more than 45 s; Android does not keep the relay connection when idle for more than 5 minutes.
**Backoff:** 0.5 → 1 → 2 → 4 → 8 → 16 → 30 s, jitter ±20 %, back to the start once a session has stayed connected for 30 s (`RECONNECT_BACKOFF`, 0.10). | ### 3.2.2 Screens @@ -454,7 +454,7 @@ SELECT stream, cursor FROM sync_cursor WHERE pair_id = :pair_id; | Actors | Primary: System (M-APP / I-APP, A-SVC, R-API, R-KV, R-DB). | | Preconditions | 1. There is a valid pair, already registered with the relay or registrable right away (PAIR-01 API 8). 2. `relay.enabled = true` on both devices. 3. Internet access. | | Postconditions | **Success:** an E2E session through the relay, status "Connected over the internet"; the data features work as on the LAN (except the camera and call audio — they need proximity).
**Phone offline:** state `WaitingPeer` ("Phone offline"), a wake push has been sent. | -| Exceptions | E1 — The relay does not respond or returns 5xx → backoff (CONN-02).
E2 — 401 `SIGNATURE_INVALID` or 404 `DEVICE_NOT_FOUND` → register the device again, then retry once.
E3 — 410 `DEVICE_REVOKED` → report "This device was removed from the internet service", turn the relay off until the user turns it back on (new registration).
E4 — `relay.error NOT_PAIRED` when sending → call `GET /v1/pairs`: revoked → PAIR-03 flow B; not registered → `POST /v1/pairs`, then retry.
E5 — The phone does not come online within 60 s of the push → stay in `WaitingPeer`; do not push again more than once per 5 minutes.
E6 — 429 `RATE_LIMITED` → wait for `Retry-After`; field 4: "Too many requests. Trying again in {duration}." (`{duration}` = the remaining `Retry-After` wait, formatted by the system).
E7 — The relay certificate does not match the pin, on any device (Android included) → do not connect; field 4: "The server's certificate isn't trusted, so HandLive didn't connect over the internet."
E8 — `relay.error NOT_CONNECTED` (the peer just left) → back to `WaitingPeer`.
E9 — `session/error` `PAIR_UNKNOWN` or `PAIR_REVOKED` over the relay, before the handshake authenticated the phone → `Backoff`, never unpair: anyone on the relay path could forge it. A revocation is acted on only through a signed `pair_revoked` (PAIR-03 API 4), `GET /v1/pairs` (PAIR-02) or the LAN (CONN-01 E4). A pre-authentication `UNSUPPORTED_VERSION` or `AUTH_FAILED` over the relay still shows its message (CONN-01 E3, E5) but keeps the normal backoff schedule instead of stopping or waiting 5 minutes, so a forged error cannot leave the client idle. | +| Exceptions | E1 — The relay does not respond or returns 5xx → backoff (CONN-02).
E2 — 401 `SIGNATURE_INVALID` or 404 `DEVICE_NOT_FOUND` → register the device again, then retry once.
E3 — 410 `DEVICE_REVOKED` → report "This device was removed from the internet service", turn the relay off until the user turns it back on (new registration).
E4 — `relay.error NOT_PAIRED` when sending → call `GET /v1/pairs`: revoked → PAIR-03 flow B; not registered → `POST /v1/pairs`, then retry.
E5 — The phone does not come online within 60 s of the push → stay in `WaitingPeer`; do not push again more than once per 5 minutes.
E6 — 429 `RATE_LIMITED` → wait for `Retry-After`; field 4: "Too many requests. Trying again in {duration}." (`{duration}` = the remaining `Retry-After` wait, formatted by the system).
E7 — The relay certificate does not match the pin, on any device (Android included) → do not connect; field 4: "The server's certificate isn't trusted, so HandLive didn't connect over the internet."
E8 — `relay.error NOT_CONNECTED` (the peer just left) → back to `WaitingPeer`.
E9 — `session/error` `PAIR_UNKNOWN` or `PAIR_REVOKED` over the relay, before the handshake authenticated the phone → `Backoff`, never unpair: the relay route has `phoneAuthenticatedByTLS` = false (0.11), since anyone on the relay path could forge it. A revocation is acted on only through a signed `pair_revoked` (PAIR-03 API 4), `GET /v1/pairs` (PAIR-02) or a route where `phoneAuthenticatedByTLS` is true (CONN-01 E4). A pre-authentication `UNSUPPORTED_VERSION` or `AUTH_FAILED` over the relay still shows its message (CONN-01 E3, E5) but keeps the normal backoff schedule instead of stopping or waiting 5 minutes, so a forged error cannot leave the client idle. The same route rule covers Bluetooth (CONN-05 E5). | | Special requirements | **Security:** the relay only sees the wrapper (`to`/`from`, `type`, size, time); it has no E2E key; 15-minute JWT; pinned SPKI of ISRG Root X1/X2 + a backup key.
**Resources:** at most 2 MiB/s per pair and direction; envelope ≤ 256 KiB.
**Reference performance:** SMS and call notifications through the relay ≤ 1 s when both sides are online.
**Operations:** the relay is stateless; presence and routing between instances go through Redis; statistics only per `device_hash`. | ### 3.3.2 Screens @@ -978,3 +978,472 @@ LIMIT 20; -- [Design] Android: clean up pushes that were sent or have expired DELETE FROM push_outbox WHERE id = :id OR expires_at <= :now; ``` + +--- + +## 3.5 CONN-05 — Connect over Bluetooth when there is no shared LAN + +### 3.5.1 General information + +| Item | Content | +|-----|----------| +| Name | CONN-05 — Connect over Bluetooth when there is no shared LAN | +| Description | When the Mac and paired phone are near but share no LAN (offline or different networks), M-APP finds the BLE advertisement, opens an L2CAP channel, runs the link-security handshake (0.4.5), then the session handshake (0.6.3) and capability exchange. The control lane (clipboard, SMS, call info, `ping`) works as on the LAN; the session upgrades to LAN when found (0.11).
Runs whenever in `Discovering` with Bluetooth usable (0.11): app launch, wake-up, network lost, Bluetooth toggle, each CONN-02 retry. Mac only; iOS unchanged. Offline first pairing: PAIR-01 flow B. Images (bulk lane): 0.4.7. Images over Wi-Fi Direct: CONN-06. Bluetooth prompts and off request: SET-01, SET-03. | +| Actors | Primary: System (M-APP, A-SVC). The user acts indirectly (turns Bluetooth on, opens the lid, brings the phone closer). | +| Preconditions | 1. There is a valid pair (PAIR-01).
2. A-SVC runs as a foreground service of type `connectedDevice`.
3. Bluetooth is on on both devices; Android 12+ has Nearby devices granted; the Mac has Bluetooth access.
4. `link.bluetooth` is on on both devices. | +| Postconditions | **Success:** state `Connected` on route `bluetooth`; status "Connected via Bluetooth to \"; the peer's capability stored and the effective features on; `last_seen_at` updated (`last_host`, `last_port` untouched); A-SVC no longer advertises this pair's hint while the session lives.
**Failure:** `Backoff` (CONN-02), the scan goes on. | +| Exceptions | E1 — Bluetooth off, Bluetooth access missing or `link.bluetooth` off on the Mac → no scan; without a network the client stays `Idle` and the status line says that Bluetooth is off (wording: SET-03 field 18; the Android request to turn Bluetooth on: SET-01 API 10).
E2 — The phone is not seen (out of range, Bluetooth off on the phone, A-SVC stopped) → keep scanning in `Discovering`; no timeout of its own.
E3 — `noise2` fails its tag check (a device that copies the advertisement but has no `PRK`) → close the channel, `Backoff`; when several advertisers carry the hint, the next attempt tries another one.
E4 — `reject` (`LINK_REJECTED`), or the channel closes during the link-security handshake → `Backoff` on the normal schedule, never unpair (0.11).
E5 — `session/error` `PAIR_UNKNOWN`, `PAIR_REVOKED`, `UNSUPPORTED_VERSION` or `AUTH_FAILED` before the phone is authenticated → route rule of 0.11 (`phoneAuthenticatedByTLS` = false): `Backoff`, never unpair, never stop reconnecting; the version and verification messages are still shown (CONN-01 E3, E5).
E6 — Bluetooth turned off on the phone, or the phone out of range, during a session → the Mac sees the disconnect (`didDisconnectPeripheral`) → `Backoff`, then `Discovering` once the advertisement is seen again (AC2).
E7 — Bluetooth turned back on on the phone → A-SVC rebuilds the L2CAP server and the advertiser on `STATE_ON` (API 6); without this the phone stays invisible until the app restarts.
E8 — Malformed record, `rlen` out of bounds, unknown `chan`, a message over `BT_MESSAGE_MAX` or a record that does not decrypt → close the link without an answer; the client counts it as a connection loss (`Backoff`).
E9 — No record received for `BT_IDLE_TIMEOUT` → close the link, reconnect.
E10 — The LAN appears during a Bluetooth session → upgrade per 0.11; the Bluetooth session receives `session/bye {reason: replaced}` once the LAN session has confirmed its keys.
E11 — The network is lost during a Bluetooth session → the session is not affected (0.11). | +| Special requirements | **Performance** (targets backed by gate G7, `plans/20260925-implementation/reports/phase-07-spike-g7.md`; one-way latency is estimated as RTT/2, the G7 method):
1. Text envelope one way, p95 ≤ 200 ms on an idle established link (AC1). G7 Lần 2: p50/p95 91.72/133.97 ms.
2. The same ≤ 200 ms while a bulk transfer runs on the link (AC1). G7 missed it with one FIFO stream (p95 4,769.3 ms in Lần 2, 7,768.4 ms in Lần 1), so the control-before-bulk scheduler of 0.4.5 is mandatory; measured in A7.4.
3. Phone in Doze, screen off: the link stays up and the accepted latency is `ping` RTT p95 ≤ 3 s (one way ≤ 1.5 s; owner decision of 2026-10-28). G7 Lần 5b: 29/29 pings, 0 `bt_lost` over 15 minutes of forced Doze, RTT p50/p95 232.4/1,328.7 ms. No connection-priority tuning: an L2CAP CoC without GATT has no public API for it (G7 "Yêu cầu phát hiện cho sản phẩm", item 4); a minimal GATT server only to request priority is not built unless T7.1 misses this target.
4. Reconnect ≤ 3 s from Bluetooth turned back on on the phone, or the phone seen again, to `Connected` (AC2). G7 Lần 4 (Bluetooth on → L2CAP channel open, without the two handshakes): p50 2,793 ms, max 10,583 ms, n = 3, so the margin is thin and T7.1 measures it again with more rounds.
5. Loss detection: the Mac reports `bt_lost` about 0.7 s after Bluetooth turns off on the phone (G7 Lần 3; 67 and 42 ms in Lần 4 rounds 1 and 2).
6. Throughput, for information: 12.9 KB/s (G7 Lần 2): enough for the control lane only; images use the bulk lane (0.4.7).
**Security:** AC8 — no pre-authentication message over Bluetooth changes pair state, removes a pair or stops reconnecting (0.11, E4, E5). AC9 — nothing static on the air: no name, `pair_id`, `device_id`, key or `pr` in the advertisement; hints rotate every `BT_HINT_PERIOD` together with the PSM and the advertising set; the link-security layer hides every envelope field and padding hides `type` (0.4.5). Every length is checked before allocation (0.4.5). Admission per 0.4.5.
**Platform:** Android: API 29+ (L2CAP CoC), `BLUETOOTH_ADVERTISE` and `BLUETOOTH_CONNECT` (Android 12+), A-SVC registers `BluetoothAdapter.ACTION_STATE_CHANGED`. Mac: CoreBluetooth, `NSBluetoothAlwaysUsageDescription`; never starts OS pairing. | + +### 3.5.2 Screens + +N/A — no approved wireframe yet. + +### 3.5.3 Component details + +| # | Field | Data type | Input/Output | Initial value | Description | +|---|--------|--------------|--------------|------------------|-------| +| 1 | Status icon (Mac menu bar) | enum{connected\| connecting\| peer_offline\| disconnected} | Output | `connecting` | From 0.11; a Bluetooth session shows `connected` | +| 2 | Status line | string | Output | "Connecting…" | "Connected via Bluetooth to \" (catalog key added in S7.1) | +| 3 | Route of the session | enum{lan\| relay\| bluetooth\| usb} | Output | Empty | Internal; decides the status wording and the pre-authentication rule (`phoneAuthenticatedByTLS`, 0.11) | +| 4 | Bluetooth unavailable message | string | Output | Empty | Per E1; wording and button: SET-03 field 18 | +| 5 | Foreground service notification (Android) | string | Output | "Waiting for a connection" | Unchanged: "Connected to \" whatever the route | + +### 3.5.4 Business flow + +```mermaid +flowchart TB + subgraph ND["User"] + U1["(1) Devices near each other, Bluetooth on, lid open"] + U10["(10) See Connected via Bluetooth"] + end + subgraph HT["System"] + S2["(2) A-SVC advertises rotating hints and the PSM"] + S3["(3) M-APP scans BT_SERVICE_UUID and matches a hint"] + D4{"(4) LAN candidate found?"} + S5["(5) Open the L2CAP channel to the PSM"] + S6["(6) challenge, noise1, noise2"] + D7{"(7) Link-security handshake OK?"} + S8["(8) session/hello and session/welcome inside the link"] + S9["(9) capability/hello, save the state, start the syncs"] + X1(["Backoff (CONN-02), scan goes on"]) + X2(["CONN-01 on the LAN"]) + end + U1 --> S2 --> S3 --> D4 + D4 -- "Yes" --> X2 + D4 -- "No" --> S5 --> S6 --> D7 + D7 -- "Yes" --> S8 --> S9 --> U10 + D7 -- "No (E3, E4)" --> X1 + S8 -- "session/error (E5)" --> X1 +``` + +| Step | Actor | Component | Description | Exceptions / Notes | +|------|----------|-----------|-------|--------------------| +| 1 | User | — | Brings the devices near each other, turns Bluetooth on, opens the Mac's lid. | The flow also runs by itself from 0.11 and CONN-02. | +| 2 | System | A-SVC | Advertises per 0.4.5: the hints of the pairs without a live session, the current PSM; hint, PSM and advertising set rotate every `BT_HINT_PERIOD`. | E7. | +| 3 | System | M-APP | Scans with the `BT_SERVICE_UUID` filter, reads the service data, computes the hints of the epochs e − 4 to e + 4 with `K_bt_disc`; an advertisement with `ver` = `0x01` and a matching hint is a candidate. | E1, E2. | +| 4 | System | M-APP | Priority LAN > Bluetooth: a LAN candidate (CONN-01 step 3) is tried first; otherwise the Bluetooth candidate is connected at once. | | +| 5 | System | M-APP → A-SVC | Connects to the peripheral and opens the L2CAP channel to the advertised PSM; A-SVC applies the admission rules (0.4.5). | Over a cap → `reject` (E4). | +| 6 | System | Both sides | Link-security handshake, 0.4.5 steps 1–5. | E3, E4. | +| 7 | System | Both sides | Handshake result: M-APP verified `noise2`, A-SVC decrypted the first transport record. | E3, E4. | +| 8 | System | Both sides | Session handshake (0.6.3) on control channel `0x01`, exactly as CONN-01 steps 6–8; A-SVC also checks that `pair_id` is the pair bound to the link. | E5. | +| 9 | System | Both sides | `capability/hello`, effective features, `last_seen_at`; state `Connected` on route `bluetooth`; the syncs start as in CONN-01 step 10. A-SVC drops this pair's hint from the advertisement (stops advertising when no pair is left). | E6, E8–E11. | +| 10 | User | M-APP | Sees "Connected via Bluetooth" on the Mac and the usual notification on Android. | | + +### 3.5.5 API/service specification + +**List of calls** + +| # | API/service | Channel | Direction | Used in steps | +|---|-------------|------|-------|-------------| +| 1 | BLE advertising `BluetoothLeAdvertiser.startAdvertisingSet` | BLE advertising | Android | 2 | +| 2 | L2CAP server `BluetoothAdapter.listenUsingInsecureL2capChannel` | L2CAP | Android | 2, 5 | +| 3 | BLE scan `CBCentralManager.scanForPeripherals` | BLE advertising | Mac | 3 | +| 4 | Open the channel `CBPeripheral.openL2CAPChannel` | L2CAP | C→S | 5 | +| 5 | Link-security handshake (records `challenge`, `noise1`, `noise2`, `reject`) | L2CAP | Both ways | 6, 7 | +| 6 | Bluetooth state `ACTION_STATE_CHANGED` / `centralManagerDidUpdateState` | System | Both sides | E1, E6, E7 | +| 7 | Control envelopes over the link | Link channel `0x01` | Both ways | 8, 9 | + +#### API 1 — BLE advertising (Android) + +- **URL:** N/A (BLE advertising channels) +- **Method:** `BluetoothLeAdvertiser.startAdvertisingSet(parameters, advertiseData, null, null, null, callback)` +- **Request:** + +| Property | Value | Description | +|-----------|---------|-------| +| `setLegacyMode` | `true` | 31-byte legacy advertising, seen by every central | +| `setConnectable` | `true` | | +| `setScannable` | `true` | The scan response carries the service data | +| `setInterval` | `INTERVAL_LOW` | The G7 probe setting behind the AC2 numbers | +| `setTxPowerLevel` | `TX_POWER_HIGH` | As in the G7 probe | +| Service UUID | `BT_SERVICE_UUID` | 128-bit, in the advertisement; service data in the scan response | +| Service data | `ver` ‖ `psm` ‖ `hints` | 0.4.5 | +| `setIncludeDeviceName`, `setIncludeTxPowerLevel` | `false` | No name, no TX power field | + +- **Response:** `AdvertisingSetCallback.onAdvertisingSetStarted(set, txPower, status)`; `status` ≠ + `ADVERTISE_SUCCESS` → logic 3. +- **Example** (two pairs, PSM `0x0085`, `U×16` = `BT_SERVICE_UUID` little-endian): advertisement `02 01 06` · + `11 07 U×16` (21 bytes); scan response `1c 21 U×16 01 85 00 1a 2b 3c 4d 77 e0 aa 19` (29 bytes). +- **Business logic:** + 1. Restart the set when the pair set, a live session, the `BT_HINT_PERIOD` epoch or the QR pairing window (open or + closed, PAIR-01 B1) changes, at most once per + second (changes batched). + 2. At each restart: stop the set, close the server socket (accepted links stay), open a new server socket (API 2), + start a new set with its PSM. + 3. Start failure → retry after 5 s, 30 s, then every 5 minutes, as mDNS (CONN-01 API 1). + `ADVERTISE_FAILED_DATA_TOO_LARGE` is a programming error: the S7.1 vectors include the largest advertisement. + 4. No pair needs Bluetooth and no QR pairing window is open, `link.bluetooth` off or Bluetooth off → stop the set. + +#### API 2 — L2CAP server (Android) + +- **URL:** N/A (LE dynamic PSM) +- **Method:** `BluetoothAdapter.listenUsingInsecureL2capChannel()` → `BluetoothServerSocket`; `getPsm()` gives the PSM + advertised in API 1; `accept()` on A-SVC's own thread. +- **Request:** none. +- **Response:** one `BluetoothSocket` per accepted channel. +- **Example:** PSM `0x0085` (133), in the advertisement as `85 00`. +- **Business logic:** + 1. Each accepted socket goes through the admission of 0.4.5 first, then the link-security handshake (API 5); a + confirmed link becomes a `BluetoothPeerSocket` served by the same `/v1/ctl` session logic as LAN and relay peers. + 2. Closing the server socket at a rotation never closes accepted sockets. + 3. `accept()` failing because Bluetooth went off ends the loop; API 6 restarts it. + +#### API 3 — BLE scan (Mac) + +- **URL:** N/A (BLE advertising channels) +- **Method:** `CBCentralManager.scanForPeripherals(withServices: [CBUUID(BT_SERVICE_UUID)], options: nil)`, as in the + G7 probe. +- **Request:** the service filter only. +- **Response:** `centralManager(_:didDiscover:advertisementData:rssi:)`; the service data is + `advertisementData[CBAdvertisementDataServiceDataKey][CBUUID(BT_SERVICE_UUID)]`. +- **Example:** service data `01 85 00 1a 2b 3c 4d 77 e0 aa 19` → `ver` 1, PSM 133, two hints. +- **Business logic:** + 1. The service data comes from the scan response, which CoreBluetooth merges into `advertisementData`; G7 used no + scan response, so M7.1 checks this first on the S25 + Mac. Ignore service data shorter than 3 bytes, with `ver` ≠ `0x01`, or whose length minus 3 is not a multiple of 4. + 2. Scan while the client is in `Discovering` or `Backoff` and Bluetooth is usable; stop while `Connected` on any + route (the LAN upgrade comes from mDNS). + 3. Several advertisers with the same matching hint (a cloned advertisement) → try them one after the other; only + the real phone passes `noise2` (E3). + +#### API 4 — Opening the L2CAP channel (Mac) + +- **URL:** N/A +- **Method:** `CBCentralManager.connect(peripheral, options: nil)`, then + `CBPeripheral.openL2CAPChannel(psm)` → `peripheral(_:didOpen:error:)` with a `CBL2CAPChannel`. +- **Request:** the PSM read in API 3. +- **Response:** `CBL2CAPChannel.inputStream` / `outputStream`, read and written as link records (0.4.5). +- **Example:** `openL2CAPChannel(0x0085)`. +- **Business logic:** + 1. Connect and open must finish within `BT_PREAUTH_TIMEOUT`; otherwise `cancelPeripheralConnection` and + `Backoff`. + 2. The channel never starts OS pairing: no encrypted GATT characteristic is read. + 3. `didDisconnectPeripheral` during a session → E6. + +#### API 5 — Link-security handshake + +- **URL:** N/A (records on the L2CAP channel) +- **Method:** records `challenge` (S→C), `noise1` (C→S), `noise2` (S→C), `reject` (S→C), then the first transport + record from C (0.4.5). +- **Request (`noise1`):** `rlen` = `0x0031` ‖ `0x02` ‖ `e` (32) ‖ tag (16). +- **Response:** `noise2` (`rlen` = `0x0031` ‖ `0x03` ‖ `e` ‖ tag) or `reject` (`rlen` = `0x0001` ‖ `0x7F`). +- **Example:** `00 21 01` ‖ 32 random bytes (`challenge`); the S7.1 vectors give full transcripts. +- **Business logic:** + 1. Steps 1–6 of the link-security handshake in 0.4.5; A-SVC tries the psk of every unrevoked pair (Query). + 2. Failures count toward `BT_AUTH_FAIL_BLOCK` as defined in 0.4.5. + 3. `reject` and a closed channel look the same to the client: E4. + +#### API 6 — Bluetooth state + +- **URL:** N/A +- **Method:** Android: a receiver for `BluetoothAdapter.ACTION_STATE_CHANGED` registered by A-SVC for its whole life; + Mac: `centralManagerDidUpdateState(_:)`. +- **Request:** `EXTRA_STATE` (Android); `CBManager.state` (Mac). +- **Response:** N/A. +- **Example:** `STATE_TURNING_OFF` → `STATE_OFF` → `STATE_ON`. +- **Business logic:** + 1. Android `STATE_TURNING_OFF` or `STATE_OFF`: close every link (the peers see a loss), stop the advertising set. + 2. Android `STATE_ON`: open a new server socket (new PSM) and start a new advertising set, exactly as when A-SVC + starts. G7 found the probe silent after a real off/on cycle until it did this (Lần 3). + 3. Mac `.poweredOn`: scan per API 3 when the client is in `Discovering` or `Backoff`. `.poweredOff`, + `.unauthorized`: end the Bluetooth session and the scan, then follow 0.11. + +#### API 7 — Control envelopes over the link + +- **URL:** N/A (control channel `0x01` of the link) +- **Method:** the JSON envelopes of 0.5.1, one per message, fragmented per 0.4.5. +- **Request:** as on `/v1/ctl`: `session/hello` (CONN-01 API 4), then every envelope of 0.7.1 that uses `/v1/ctl`. +- **Response:** as on `/v1/ctl`: `session/welcome` or `session/error` (CONN-01 API 5, 6), `ack`. +- **Example:** see CONN-01 API 4. +- **Business logic:** + 1. No WebSocket ping exists on the link: the client sends `ping/ping` every `BT_PING_INTERVAL` without other + traffic (CONN-02 API 2). + 2. The session ends with `session/bye` under the rules of a relayed session (CONN-02 API 4), then the channel + closes. + 3. `camera/start` over Bluetooth gets `CAM_TRANSPORT_UNSUPPORTED`, as over the relay. + 4. Bulk content and channel `0x02`: 0.4.7 (CLIP-03 messages, fragments of at most `BT_BULK_FRAGMENT_MAX`). + +#### Query + +```sql +-- [Design] Android, API 1: pairs whose Bluetooth hints may be advertised (live sessions are filtered in memory) +SELECT pair_id, prk_enc, last_seen_at +FROM paired_device +WHERE revoked_at IS NULL +ORDER BY last_seen_at DESC; + +-- [Design] Android, API 5: candidate psks for noise1 +SELECT pair_id, prk_enc +FROM paired_device +WHERE revoked_at IS NULL; + +-- [Design] Mac, API 3: pairs whose hints are matched +SELECT pair_id, peer_device_id, peer_name +FROM paired_device +WHERE revoked_at IS NULL; + +-- [Design] Both sides, step 9 +UPDATE paired_device +SET last_seen_at = :now, features_json = :features_json +WHERE pair_id = :pair_id; +``` + +--- + +## 3.6 CONN-06 — Open a Wi-Fi Direct link for images when there is no shared LAN + +### 3.6.1 General information + +| Item | Content | +|-----|----------| +| Name | CONN-06 — Open a Wi-Fi Direct link for images when there is no shared LAN | +| Description | While the session runs over Bluetooth (CONN-05) and an image or other bulk transfer is waiting, the Mac asks the phone over the encrypted control lane to create a Wi-Fi Direct group, joins it as a plain Wi-Fi client, connects with the pinned TLS of the LAN, and leaves as soon as the transfers are done (0.4.6). The control lane stays on Bluetooth. Mac only; iOS unchanged. Bulk lane, per-connection keys and restarts: 0.4.7. | +| Actors | Primary: System (M-APP, A-SVC). The user acts indirectly (copies an image on one device). | +| Preconditions | 1. `Connected` on route `bluetooth` (CONN-05); no LAN session.
2. A bulk transfer is waiting (0.4.7) in either direction.
3. Both devices have `link.direct_wifi` on.
4. Android: Wi-Fi on, Nearby devices granted (Android 13+) or Location granted with location services on (Android 10–12) (SET-01).
5. Mac: Location access granted (SET-03); Wi-Fi powered on and unassociated for ≥ `DIRECT_WIFI_IDLE_MIN`; the last scan shows no known network. | +| Postconditions | **Success:** the Mac is on the group with a pinned TLS connection to A-SVC; the bulk streams of 0.4.7 run on it; after `DIRECT_WIFI_LINGER` without bulk data, or at `DIRECT_WIFI_MAX`, the Mac leaves, the phone removes the group and both wipe the credentials; the Mac's Wi-Fi is back to unassociated.
**Failure:** no group remains; the transfer goes over Bluetooth in the background; no new attempt before `DIRECT_WIFI_RETRY_AFTER`. In every case neither device leaves or changes the network it was on (AC4). | +| Exceptions | E1 — An entry rule does not hold (the Mac is on a network, a known network is visible, Wi-Fi off, Location missing, `link.direct_wifi` off) → no `direct/start`; Bluetooth in the background.
E2 — `ack` with `PERMISSION_MISSING` (Nearby devices or Location missing on the phone) → Bluetooth in the background; A-SVC posts the permission suggestion notification (SET-01 field 17, E14); retry after `DIRECT_WIFI_RETRY_AFTER`.
E3 — `ack` with `DIRECT_WIFI_UNAVAILABLE` (`wifi_off`, `location_off`, `p2p_unsupported`, `busy`, `error`) → Bluetooth in the background, retry after `DIRECT_WIFI_RETRY_AFTER`; HandLive never turns Wi-Fi or location services on.
E4 — `ack` with `FEATURE_DISABLED` (`link.direct_wifi` off on the phone) or `UNSUPPORTED_TYPE` (an older phone) → no more `direct/start` in this session.
E5 — The SSID is not found, `associate` fails or the pinned TLS connection is not up within `DIRECT_WIFI_JOIN_TIMEOUT` → `direct/stop {reason: join_failed}`, disassociate, Bluetooth in the background, retry after `DIRECT_WIFI_RETRY_AFTER`.
E6 — The certificate on the group does not match `peer_tls_sha256` (`TLS_PIN_MISMATCH`: another device answers at that address) → close, leave, `direct/stop {reason: join_failed}`; the pair is not touched (AC8).
E7 — macOS moves to another network by itself, or a periodic scan shows a known network → leave at once (AC4), `direct/stop {reason: known_network}`; unfinished transfers continue over Bluetooth (0.4.7).
E8 — The phone's group disappears (Wi-Fi turned off on the phone, the OS removed it) → A-SVC sends `direct/stop {reason: group_lost}`; the Mac sees the association drop and goes back to Bluetooth.
E9 — A LAN session appears → leave the group, `direct/stop {reason: lan}`; bulk moves to the LAN.
E10 — The Bluetooth session ends → both sides end the group (the stream keys belong to that session).
E11 — A device without the session keys joins the group and connects to A-SVC → `/v1/ctl`, `/v1/pair` and unknown paths are closed with 4400, a failed stream handshake with 4401 (0.4.6). | +| Special requirements | **Performance:** AC3 — 5 MB image in X: the first ≤ 10 s, joining included (`DIRECT_WIFI_JOIN_TIMEOUT` 8 s leaves about 2 s for the transfer); the next ones ≤ 2 s while the group is open (`DIRECT_WIFI_LINGER`).
**Battery:** the group exists only while a transfer is waiting or running, at most `DIRECT_WIFI_MAX`; no group without a reason (AC4).
**Security:** credentials new for every group, 144-bit passphrase, sent only inside the E2E control lane, never advertised or logged; pinned TLS and session-keyed stream handshake on the group; the group never becomes a session route (AC8, 0.4.6).
**Network invariant (AC4):** the Mac joins only while unassociated and with no known network visible, so it never loses another network; the phone keeps its own Wi-Fi (STA + P2P).
**Not yet measured (A7.5/M7.5 check first on the S25 + Mac; G7 did not test Wi-Fi Direct, spike report "Brief items", item 7):** the Mac joining the S25 group from the notarized app with Location granted; join time against `DIRECT_WIFI_JOIN_TIMEOUT` and AC3; reading "associated or idle" and SSIDs on macOS 27 and macOS 13 or 14; whether macOS keeps the joined SSID in its preferred networks and whether the app can remove it without an administrator prompt; STA + P2P concurrency on the S25 while the phone is on its own Wi-Fi; the group owner address the S25 hands out (not always `192.168.49.1`, research section 2). | + +### 3.6.2 Screens + +N/A — no approved wireframe yet. + +### 3.6.3 Component details + +| # | Field | Data type | Input/Output | Initial value | Description | +|---|--------|--------------|--------------|------------------|-------| +| 1 | Bulk path | enum{lan\| direct_wifi\| bluetooth} | Output | Empty | Internal; chosen per the plan routing table (LAN > Wi-Fi Direct > Bluetooth); no text of its own | +| 2 | Transfer status (Mac) | string | Output | Empty | Unchanged CLIP-03 wording; no Wi-Fi Direct text, the user does not need to know the path | +| 3 | Wi-Fi Direct credentials | object{ssid, passphrase, host, port} | Input (Mac), Output (Android) | Empty | Memory only, wiped when the group ends; never logged | +| 4 | Status line (Mac) | string | Output | "Connected via Bluetooth to \" | Unchanged while on the group: the session route is still `bluetooth` (0.4.6) | + +### 3.6.4 Business flow + +```mermaid +flowchart TB + subgraph ND["User"] + U1["(1) Copy an image on one device"] + U11["(11) The image arrives on the other device"] + end + subgraph HT["System"] + S2["(2) Bulk transfer waiting on a Bluetooth session, the phone sends direct/want for its own"] + D3{"(3) Mac entry rules hold?"} + S4["(4) Mac sends direct/start over the control lane"] + D5{"(5) Phone can create the group?"} + S6["(6) Phone creates the group with new credentials, ack with ssid, passphrase, host, port"] + S7["(7) Mac scans for the SSID and joins"] + D8{"(8) Pinned TLS up within DIRECT_WIFI_JOIN_TIMEOUT?"} + S9["(9) Bulk streams on the group (0.4.7)"] + S10["(10) Exit rule hit: leave, direct/stop, remove the group, wipe credentials"] + X1(["Bluetooth in the background"]) + end + U1 --> S2 --> D3 + D3 -- "No (E1)" --> X1 + D3 -- "Yes" --> S4 --> D5 + D5 -- "No (E2, E3, E4)" --> X1 + D5 -- "Yes" --> S6 --> S7 --> D8 + D8 -- "No (E5, E6)" --> X1 + D8 -- "Yes" --> S9 --> U11 + S9 --> S10 + S10 -- "Transfers left (E7 to E10)" --> X1 +``` + +| Step | Actor | Component | Description | Exceptions / Notes | +|------|----------|-----------|-------|--------------------| +| 1 | User | — | Copies an image (CLIP-03) on the phone or on the Mac while the two share no LAN. | Any other bulk transfer of 0.4.7 starts the same way. | +| 2 | System | M-APP, A-SVC | The transfer is queued on the bulk lane (0.4.7). A transfer waiting on the phone → A-SVC sends `direct/want {bytes}` (API 3), at most once per waiting transfer. | Until a faster path exists, the transfer may start over Bluetooth in the background (0.4.7). | +| 3 | System | M-APP | Checks the entry rules of 0.4.6 with API 7: Bluetooth session, no LAN session, `link.direct_wifi`, Location granted, Wi-Fi on and unassociated for ≥ `DIRECT_WIFI_IDLE_MIN`, no known network in the last scan, no failure within `DIRECT_WIFI_RETRY_AFTER`. | Any rule fails → E1. | +| 4 | System | M-APP → A-SVC | Sends `direct/start` (API 1) and starts `DIRECT_WIFI_JOIN_TIMEOUT`. | | +| 5 | System | A-SVC | Checks `link.direct_wifi`, the permissions of SET-01 (API 2 table), Wi-Fi on, location services on (Android 10–12). | E2, E3, E4. | +| 6 | System | A-SVC | Generates `ssid` and `passphrase` (0.4.6), calls `createGroup` (API 4), reads the group owner address from `WifiP2pInfo.groupOwnerAddress` and answers the `ack` with `{ssid, passphrase, host, port}`. Starts its own `DIRECT_WIFI_JOIN_TIMEOUT`, `DIRECT_WIFI_LINGER` and `DIRECT_WIFI_MAX` counters. | `createGroup` fails → `DIRECT_WIFI_UNAVAILABLE` (E3). | +| 7 | System | M-APP | Scans for the received SSID and associates with the passphrase (API 5). | E5. | +| 8 | System | M-APP → A-SVC | Opens TLS to `host`:`port` on the Wi-Fi interface and checks the pin (API 6); success within `DIRECT_WIFI_JOIN_TIMEOUT` means joined. | E5, E6. | +| 9 | System | Both sides | The bulk streams of 0.4.7 run over the group; further images reuse it while it is open. A-SVC accepts only the bulk stream path on the group owner address. | E11. | +| 10 | System | Both sides | An exit rule of 0.4.6 holds (linger, max, known network, LAN, Bluetooth lost): the Mac disassociates and removes the saved network when it can (API 5), sends `direct/stop` (API 2); A-SVC calls `removeGroup`; both wipe the credentials. | E7–E10. | +| 11 | User | M-APP / A-UI | The image is available on the other device (CLIP-03). | | + +### 3.6.5 API/service specification + +**List of calls** + +| # | API/service | Channel | Direction | Used in steps | +|---|-------------|------|-------|-------------| +| 1 | `WS direct/start` | Link channel `0x01` (Bluetooth) | C→S | 4, 5, 6 | +| 2 | `WS direct/stop` | Link channel `0x01` (Bluetooth) | Both ways | 10, E5–E10 | +| 3 | `WS direct/want` | Link channel `0x01` (Bluetooth) | S→C | 2 | +| 4 | Wi-Fi Direct group `WifiP2pManager.createGroup` / `removeGroup` | Local (Android) | — | 6, 10 | +| 5 | Join and leave with CoreWLAN `CWInterface.scanForNetworks` / `associate` / `disassociate` | Wi-Fi (Mac) | — | 7, 10 | +| 6 | Pinned TLS connection on the group | TCP/TLS on the group | C→S | 8, 9 | +| 7 | Mac Wi-Fi state: `CWWiFiClient` events, `NWPathMonitor(requiredInterfaceType: .wifi)` | Local (Mac) | — | 3, 9, E7 | + +#### API 1 — `WS direct/start` + +- **URL:** N/A (control channel `0x01` of the Bluetooth link, 0.4.5) +- **Method:** `WS direct/start` (C→S), encrypted envelope (0.5.1); the response is an `ack` with data. +- **Request (`data`):** + +| Field | Type | Required | Description | +|--------|------|----------|-------| +| `reason` | enum{bulk} | Yes | Why a group is needed; only `bulk` in this version | + +- **Response (`ack.data`):** + +| Field | Type | Required | Description | +|--------|------|----------|-------| +| `ssid` | string(32) | Yes | `"DIRECT-"` ‖ 10 random characters from `[A-Za-z0-9]` (0.4.6) | +| `passphrase` | string(24) | Yes | base64url without padding of 18 random bytes | +| `host` | string | Yes | The group owner's IPv4 address (`WifiP2pInfo.groupOwnerAddress`), dotted decimal | +| `port` | int32 | Yes | The port A-SVC listens on (`CTL_PORT` or the fallback in use) | + + Errors: `PERMISSION_MISSING` (`details.permission`), `DIRECT_WIFI_UNAVAILABLE` (`details.reason`), `FEATURE_DISABLED`, + `RATE_LIMITED` (a group already exists for another pair). +- **Example:** + +```json +{"op":"start","data":{"reason":"bulk"}} +{"re":"0192f3c1-7c1e-7a55-9d0b-3f4c2a1b9e10","ok":true,"data":{"ssid":"DIRECT-q7Lm2Xc9Ta","passphrase":"3vQ8kN0pR2sT4uW6xY8zA1bC","host":"192.168.49.1","port":47800}} +``` + +- **Business logic:** + 1. Accepted only on a Bluetooth session; over a LAN session → `BAD_REQUEST` (bulk uses the LAN). + 2. One group at a time on the phone: a `direct/start` while this pair's group exists returns the same credentials; + for another pair → `RATE_LIMITED` with `details.retry_after_ms` = the time left on that group. + 3. The `ack` is sent only after `onGroupInfoAvailable` reports the group with the requested SSID, so the Mac never + scans before the group exists. + 4. The `ack` payload is personal data in the sense of 0.5.1 rule 5: never logged, not even its size. + +#### API 2 — `WS direct/stop` + +- **URL:** N/A (control channel `0x01` of the Bluetooth link) +- **Method:** `WS direct/stop` (both ways), encrypted envelope; answered with an `ack` without data. +- **Request (`data`):** + +| Field | Type | Required | Description | +|--------|------|----------|-------| +| `reason` | enum{idle\| max\| known_network\| lan\| join_failed\| group_lost} | Yes | `idle` after `DIRECT_WIFI_LINGER`, `max` at `DIRECT_WIFI_MAX`, others per E5–E9 | + +- **Response:** `ack` (`ok: true`). +- **Example:** `{"op":"stop","data":{"reason":"idle"}}` +- **Business logic:** + 1. Idempotent: a `stop` for a group that no longer exists is acknowledged with `ok: true`. + 2. The sender has already left (Mac) or removed the group (phone) before sending; the receiver does the same + without waiting for anything else. + 3. `join_failed` starts `DIRECT_WIFI_RETRY_AFTER` on the Mac; the other reasons do not. + +#### API 3 — `WS direct/want` + +- **URL:** N/A (control channel `0x01` of the Bluetooth link) +- **Method:** `WS direct/want` (S→C), encrypted event, no `ack`. +- **Request (`data`):** + +| Field | Type | Required | Description | +|--------|------|----------|-------| +| `bytes` | int64 | Yes | Total size of the bulk waiting on the phone, for the Mac's decision only | + +- **Response:** N/A; the Mac answers, when the entry rules hold, with `direct/start` (API 1). +- **Example:** `{"op":"want","data":{"bytes":5242880}}` +- **Business logic:** + 1. Sent once per waiting transfer, and not while this pair's group exists. + 2. The Mac decides alone (AC4 depends on the Mac's Wi-Fi state); the phone never creates a group without + `direct/start`. + +#### API 4 — Wi-Fi Direct group (Android) + +- **URL:** N/A +- **Method:** `WifiP2pManager.initialize(context, looper, listener)` → `Channel`; + `createGroup(channel, config, actionListener)`; `requestGroupInfo(channel, listener)` and + `requestConnectionInfo(channel, listener)`; `removeGroup(channel, actionListener)`; broadcast + `WIFI_P2P_CONNECTION_CHANGED_ACTION`. +- **Request:** `WifiP2pConfig.Builder().setNetworkName(ssid).setPassphrase(passphrase) + .setGroupOperatingBand(GROUP_OWNER_BAND_AUTO)`, plus `setPccModeConnectionType(PCC_MODE_CONNECTION_TYPE_LEGACY_OR_R2)` + on API 36+. +- **Response:** `ActionListener.onSuccess()` or `onFailure(reason)` with `P2P_UNSUPPORTED`, `BUSY` or `ERROR`; + `WifiP2pGroup.getNetworkName()`, `WifiP2pInfo.groupOwnerAddress`; the client list in `WifiP2pGroup.getClientList()`. +- **Example:** `onFailure(BUSY)` → `ack` `DIRECT_WIFI_UNAVAILABLE` `{reason: "busy"}`. +- **Business logic:** + 1. `P2P_UNSUPPORTED` → `p2p_unsupported`; `BUSY` → `busy`; `ERROR` → `error`; Wi-Fi off + (`WifiManager.isWifiEnabled() = false`) → `wifi_off` without calling `createGroup`; Android 10–12 with location + services off → `location_off`. + 2. A group left by an earlier crash (`requestGroupInfo` returns one) is removed before creating a new one. + 3. No client in `getClientList()` within `DIRECT_WIFI_JOIN_TIMEOUT` after the `ack` → `removeGroup`, `direct/stop + {reason: join_failed}`. + 4. The group disappearing while needed (connection-changed broadcast) → `direct/stop {reason: group_lost}` (E8). + 5. A-SVC keeps listening on every interface (0.4.1); on a connection whose local address is the group owner address + it serves only the bulk stream path `/v1/stream/bulk` (0.4.6, 0.4.7). + +#### API 5 — Join and leave the group (Mac) + +- **URL:** N/A +- **Method:** `CWWiFiClient.shared().interface()`; `scanForNetworks(withSSID: Data(ssid.utf8))`; + `associate(to: network, password: passphrase)`; `disassociate()`; saved-network removal through + `CWMutableConfiguration` (`networkProfiles`) and `commitConfiguration(_:authorization:)` only when it needs no + administrator prompt. +- **Request:** the `ssid` and `passphrase` of API 1. +- **Response:** a `CWNetwork` set; `associate` throws a `CWErr` on failure. +- **Example:** scan returns one network `DIRECT-q7Lm2Xc9Ta` → `associate` succeeds → `interface.ssid()` = + `"DIRECT-q7Lm2Xc9Ta"`. +- **Business logic:** + 1. Needs Location access: without it CoreWLAN returns networks with no SSID (research section 2), so the entry rules + already require it. + 2. Never `networksetup` with the passphrase on its command line (plan G7 pass criterion). + 3. Scan up to two times within `DIRECT_WIFI_JOIN_TIMEOUT`; several networks with the same SSID → try the strongest + first; the pinned TLS of API 6 tells the real phone apart. + 4. Leaving: `disassociate()`; macOS then joins a known network by itself when one is in range. + +#### API 6 — Pinned TLS connection on the group + +- **URL:** `wss://{host}:{port}/v1/stream/bulk` +- **Method:** TLS 1.3 with the certificate pinned to `peer_tls_sha256` and no hostname check, as on the LAN (0.4.1); + `NWParameters.requiredInterface` = the Wi-Fi interface that joined the group. +- **Request:** `bulk/stream_hello` (0.6.3 step 7, channel `bulk`), keyed by the live Bluetooth control session; then the per-connection keys `K_bulk` (0.4.7). +- **Response:** `bulk/stream_welcome`; a pin mismatch → `TLS_PIN_MISMATCH` before any byte is sent. +- **Example:** `wss://192.168.49.1:47800/…` with the pinned certificate SHA-256 of PAIR-01. +- **Business logic:** + 1. The first successful TLS handshake on the group ends `DIRECT_WIFI_JOIN_TIMEOUT` (step 8). + 2. Pin mismatch → E6; the Mac never sends data to an unpinned endpoint. + 3. The connection is never used for `/v1/ctl`: the session stays on Bluetooth (0.4.6 "Not a LAN"). + +#### API 7 — Mac Wi-Fi state + +- **URL:** N/A +- **Method:** `CWWiFiClient.shared().delegate` with `startMonitoringEvent(with: .ssidDidChange)` and `.linkDidChange`; + `CWInterface.powerOn()`, `ssid()`; `CWConfiguration.networkProfiles` for the known networks; `NWPathMonitor` on the + Wi-Fi interface. +- **Request:** none. +- **Response:** events and the current values. +- **Example:** `ssid()` nil and no satisfied Wi-Fi path for 30 s → unassociated for `DIRECT_WIFI_IDLE_MIN`. +- **Business logic:** + 1. "Unassociated since" restarts at every association; a scan whose results contain any SSID of `networkProfiles` + means "a known network is visible". + 2. While on the group: scan every `DIRECT_WIFI_SCAN_INTERVAL`; a known network → E7. + 3. `ssidDidChange` to anything but the group's SSID while on the group → E7. + +#### Query + +No database access. Settings read: `link.direct_wifi` (0.9.5) on both sides. + +```text +# [Design] Mac UserDefaults and Android DataStore +defaults.bool(forKey: "link.direct_wifi") # M-APP, step 3 +prefs[booleanPreferencesKey("link.direct_wifi")] ?: true # A-SVC, step 5 +``` diff --git a/docs/detailed-design/03-connectivity.vi.md b/docs/detailed-design/03-connectivity.vi.md index eaa49be..a4ec115 100644 --- a/docs/detailed-design/03-connectivity.vi.md +++ b/docs/detailed-design/03-connectivity.vi.md @@ -17,7 +17,7 @@ | Tác nhân | Chính: Hệ thống (M-APP / I-APP, A-SVC). Người dùng tác động gián tiếp (mở ứng dụng, bật WiFi, mở nắp máy) hoặc bấm "Kết nối lại ngay". | | Điều kiện trước | 1. Có một cặp hiệu lực (PAIR-01).
2. A-SVC đang chạy dưới dạng foreground service.
3. Hai thiết bị cùng LAN và mạng cho phép multicast mDNS.
4. Client đã được cấp quyền mạng cục bộ (iOS 14+, macOS 15+) theo SET-03. | | Điều kiện sau | **Thành công:** trạng thái `Connected` (LAN); khóa phiên sẵn sàng; capability của đối phương lưu vào `features_json`; `last_seen_at`, `last_host`, `last_port` cập nhật; tính năng hiệu lực được bật; thông báo foreground service trên Android ghi "Đã kết nối với ".
**Thất bại:** chuyển CONN-03 sau `LAN_DISCOVERY_GRACE` (nếu relay bật) hoặc sang `Backoff` (CONN-02). | -| Ngoại lệ | E1 — Không thấy instance có hint khớp trong 10 s → CONN-03 (relay bật) hoặc tiếp tục duyệt.
E2 — `TLS_PIN_MISMATCH`: bỏ instance này (có thể là thiết bị khác hoặc giả mạo), thử instance kế tiếp; mọi instance của cặp đều lệch ghim (điện thoại đã sinh lại khóa TLS, 0.6.1) → dừng thử, hiển thị "Cần ghép nối lại" (PAIR-02).
E3 — `session/error AUTH_FAILED` → báo "Không xác thực được điện thoại", backoff dài 5 phút, không thử liên tục.
E4 — `PAIR_UNKNOWN` hoặc `PAIR_REVOKED` (4403) trong LAN (TLS ghim tới điện thoại) → dọn cặp theo PAIR-03 luồng B, yêu cầu ghép nối lại. Qua relay, cùng các mã này chỉ dẫn đến `Backoff` (CONN-03 E9).
E5 — 4426 `UNSUPPORTED_VERSION` → nhắc cập nhật ứng dụng ở thiết bị cũ hơn.
E6 — Bắt tay quá 5 s (4408) → CONN-02 backoff.
E7 — Mạng cách ly client (Wi-Fi khách, mDNS bị chặn) → như E1.
E8 — Quyền mạng cục bộ bị từ chối → báo và mở hướng dẫn SET-03. | +| Ngoại lệ | E1 — Không thấy instance có hint khớp trong 10 s → CONN-03 (relay bật) hoặc tiếp tục duyệt.
E2 — `TLS_PIN_MISMATCH`: bỏ instance này (có thể là thiết bị khác hoặc giả mạo), thử instance kế tiếp; mọi instance của cặp đều lệch ghim (điện thoại đã sinh lại khóa TLS, 0.6.1) → dừng thử, hiển thị "Cần ghép nối lại" (PAIR-02).
E3 — `session/error AUTH_FAILED` → báo "Không xác thực được điện thoại", backoff dài 5 phút, không thử liên tục.
E4 — `PAIR_UNKNOWN` hoặc `PAIR_REVOKED` (4403) trước khi điện thoại được xác thực: trên route có `phoneAuthenticatedByTLS` đúng (LAN, USB: TLS ghim tới điện thoại) → dọn cặp theo PAIR-03 luồng B, yêu cầu ghép nối lại. Trên route có thuộc tính sai (relay, Bluetooth), cùng các mã này chỉ dẫn đến `Backoff` (0.11, CONN-03 E9, CONN-05 E5).
E5 — 4426 `UNSUPPORTED_VERSION` → nhắc cập nhật ứng dụng ở thiết bị cũ hơn.
E6 — Bắt tay quá 5 s (4408) → CONN-02 backoff.
E7 — Mạng cách ly client (Wi-Fi khách, mDNS bị chặn) → như E1.
E8 — Quyền mạng cục bộ bị từ chối → báo và mở hướng dẫn SET-03. | | Yêu cầu đặc biệt | **Hiệu năng:** kết nối lại < 3 s khi đã biết `last_host` (chỉ số thành công của dự án); bắt tay ≤ 300 ms trong LAN.
**Bảo mật:** chỉ TLS 1.3; ghim SHA-256 chứng chỉ, không kiểm hostname; TXT mDNS không chứa định danh tĩnh (0.4.1).
**Nền tảng:** iOS/macOS khai báo `NSLocalNetworkUsageDescription` và `NSBonjourServices = ["_handlive._tcp"]`; Android chạy A-SVC type `connectedDevice` (quyền `FOREGROUND_SERVICE_CONNECTED_DEVICE` + `CHANGE_NETWORK_STATE`) với thông báo thường trực.
**Độc lập tính năng:** capability quyết định từng tính năng; một tính năng thiếu quyền không chặn các tính năng khác. | ### 3.1.2 Màn hình @@ -285,7 +285,7 @@ WHERE revoked_at IS NULL; | Tác nhân | Chính: Hệ thống (M-APP / I-APP, A-SVC). Người dùng có thể bấm "Kết nối lại ngay". | | Điều kiện trước | Đã từng có phiên (`Connected`) hoặc đang ở `Backoff`/`Discovering`. | | Điều kiện sau | Phiên được duy trì hoặc thiết lập lại; trạng thái hiển thị đúng thực tế trong ≤ 1 s sau khi phát hiện; `sms_outbox` còn `pending` được gửi lại; SMS-01 và CALL-04 chạy bù. | -| Ngoại lệ | E1 — Mất mạng hoàn toàn: chuyển `Idle`, chờ `NWPathMonitor`/`NetworkCallback` báo có mạng, không backoff vô ích.
E2 — Mac ngủ: gửi `session/bye {reason: shutdown}` nếu còn kịp; khi thức dậy chạy CONN-01 ngay.
E3 — iOS vào nền (`scenePhase` của ứng dụng thành `.background`; bỏ qua `.inactive`): I-APP giữ phiên thêm một khoảng ngắn thay vì đóng ngay. I-APP bắt đầu một tác vụ nền (`beginBackgroundTask`); iOS từ chối → `session/bye {reason: shutdown}` và đóng ngay. Nếu không, phiên vẫn mở (ping vẫn chạy) tới min(`IOS_BACKGROUND_GRACE`, thời gian nền iOS còn cho − 5 s), rồi `session/bye {reason: shutdown}`, đóng 1000 và kết thúc tác vụ.
Thời gian giữ là một bên giữ phiên; một thao tác từ thông báo (SMS-04 bước B2, CALL-02 bước B2) là một bên khác và vẫn dùng tác vụ nền riêng như hiện nay. I-APP chỉ gửi `bye` và đóng khi không còn bên nào giữ và ứng dụng không ở foreground. Khi iOS cho hết hạn tác vụ của thời gian giữ, thời gian giữ nhả phần của nó và kết thúc tác vụ ngay, không chờ; thao tác còn đang chạy vẫn giữ phiên bằng tác vụ của chính nó. Thao tác đến sau khi phiên đã đóng thì kết nối như trước (CONN-01 hoặc CONN-03).
Đóng và mở chạy lần lượt trên một hàng đợi (hủy hẹn giờ, hẹn giờ kích hoạt, `bye`, đóng, CONN-01). Quay lại foreground: chưa bắt đầu đóng → hủy hẹn giờ, kết thúc tác vụ, giữ phiên (không `bye`, không chạy lại CONN-01); đã gửi `bye` → chờ đóng xong rồi mới chạy CONN-01, nên client không bao giờ có hai phiên của cùng một cặp (không tự gây 4409).
Phiên rớt trong lúc giữ → nhả thời gian giữ ngay: hủy hẹn giờ, kết thúc tác vụ, không `bye`, không nối lại khi đang ở nền.
Trong lúc giữ: SMS hoặc cuộc gọi đến hiện thông báo người dùng nhận được qua push (CONN-04), đăng cục bộ, không có chuỗi mới, theo cùng luật nội dung như I-NSE (`sms.preview`, iPhone đang khóa, SMS-02 trường 2); `clipboard/push` chỉ được ghi khi `UIPasteboard.changeCount` vẫn bằng giá trị mốc, lấy lúc ứng dụng vào nền và dời theo mỗi lần HandLive ghi hoặc xóa trong thời gian giữ (CLIP-04 E2, CLIP-05); ghi thành công được nhớ là lần ghi của chính HandLive, ghi thất bại theo đường lỗi sẵn có (`ack` như hiện nay).
Sau thời gian giữ, khi bị treo, tin đến qua push (CONN-04).
E4 — Rekey không nhận `ack` trong 10 s → đóng phiên, kết nối lại (bắt tay mới sinh khóa mới).
E5 — `DECRYPT_FAILED` → đóng 4400, kết nối lại.
E6 — Hệ điều hành dừng A-SVC (OEM tắt nền) → client thấy mất kết nối; A-SVC `START_STICKY` tự khởi động lại; SET-01 đã xin miễn tối ưu pin.
E7 — Đóng 4409 (bị thay bởi kết nối mới của chính client; qua relay: `session/bye {reason: replaced}`, API 4) → không kết nối lại từ phiên cũ. | +| Ngoại lệ | E1 — Mất mạng hoàn toàn: phiên LAN và relay kết thúc; chuyển `Idle`, chờ `NWPathMonitor`/`NetworkCallback` báo có mạng, không backoff vô ích. Trên Mac khi còn dùng được Bluetooth, phiên Bluetooth vẫn giữ và client tiếp tục tìm qua Bluetooth thay vì về `Idle` (0.11, CONN-05).
E2 — Mac ngủ: gửi `session/bye {reason: shutdown}` nếu còn kịp; khi thức dậy chạy CONN-01 ngay.
E3 — iOS vào nền (`scenePhase` của ứng dụng thành `.background`; bỏ qua `.inactive`): I-APP giữ phiên thêm một khoảng ngắn thay vì đóng ngay. I-APP bắt đầu một tác vụ nền (`beginBackgroundTask`); iOS từ chối → `session/bye {reason: shutdown}` và đóng ngay. Nếu không, phiên vẫn mở (ping vẫn chạy) tới min(`IOS_BACKGROUND_GRACE`, thời gian nền iOS còn cho − 5 s), rồi `session/bye {reason: shutdown}`, đóng 1000 và kết thúc tác vụ.
Thời gian giữ là một bên giữ phiên; một thao tác từ thông báo (SMS-04 bước B2, CALL-02 bước B2) là một bên khác và vẫn dùng tác vụ nền riêng như hiện nay. I-APP chỉ gửi `bye` và đóng khi không còn bên nào giữ và ứng dụng không ở foreground. Khi iOS cho hết hạn tác vụ của thời gian giữ, thời gian giữ nhả phần của nó và kết thúc tác vụ ngay, không chờ; thao tác còn đang chạy vẫn giữ phiên bằng tác vụ của chính nó. Thao tác đến sau khi phiên đã đóng thì kết nối như trước (CONN-01 hoặc CONN-03).
Đóng và mở chạy lần lượt trên một hàng đợi (hủy hẹn giờ, hẹn giờ kích hoạt, `bye`, đóng, CONN-01). Quay lại foreground: chưa bắt đầu đóng → hủy hẹn giờ, kết thúc tác vụ, giữ phiên (không `bye`, không chạy lại CONN-01); đã gửi `bye` → chờ đóng xong rồi mới chạy CONN-01, nên client không bao giờ có hai phiên của cùng một cặp (không tự gây 4409).
Phiên rớt trong lúc giữ → nhả thời gian giữ ngay: hủy hẹn giờ, kết thúc tác vụ, không `bye`, không nối lại khi đang ở nền.
Trong lúc giữ: SMS hoặc cuộc gọi đến hiện thông báo người dùng nhận được qua push (CONN-04), đăng cục bộ, không có chuỗi mới, theo cùng luật nội dung như I-NSE (`sms.preview`, iPhone đang khóa, SMS-02 trường 2); `clipboard/push` chỉ được ghi khi `UIPasteboard.changeCount` vẫn bằng giá trị mốc, lấy lúc ứng dụng vào nền và dời theo mỗi lần HandLive ghi hoặc xóa trong thời gian giữ (CLIP-04 E2, CLIP-05); ghi thành công được nhớ là lần ghi của chính HandLive, ghi thất bại theo đường lỗi sẵn có (`ack` như hiện nay).
Sau thời gian giữ, khi bị treo, tin đến qua push (CONN-04).
E4 — Rekey không nhận `ack` trong 10 s → đóng phiên, kết nối lại (bắt tay mới sinh khóa mới).
E5 — `DECRYPT_FAILED` → đóng 4400, kết nối lại.
E6 — Hệ điều hành dừng A-SVC (OEM tắt nền) → client thấy mất kết nối; A-SVC `START_STICKY` tự khởi động lại; SET-01 đã xin miễn tối ưu pin.
E7 — Đóng 4409 (bị thay bởi kết nối mới của chính client; qua relay: `session/bye {reason: replaced}`, API 4) → không kết nối lại từ phiên cũ. | | Yêu cầu đặc biệt | **Hiệu năng:** kết nối lại < 3 s sau khi mạng trở lại; phát hiện mất kết nối ≤ 25 s (15 s ping + 10 s chờ pong).
**Pin:** client chủ động ping, Android chỉ trả pong và đóng kết nối im lặng quá 45 s; Android không giữ relay khi rảnh quá 5 phút.
**Backoff:** 0,5 → 1 → 2 → 4 → 8 → 16 → 30 s, jitter ±20 %, về đầu khi một phiên đã giữ kết nối được 30 s (`RECONNECT_BACKOFF`, 0.10). | ### 3.2.2 Màn hình @@ -448,7 +448,7 @@ SELECT stream, cursor FROM sync_cursor WHERE pair_id = :pair_id; | Tác nhân | Chính: Hệ thống (M-APP / I-APP, A-SVC, R-API, R-KV, R-DB). | | Điều kiện trước | 1. Có cặp hiệu lực, đã đăng ký relay hoặc đăng ký được ngay (PAIR-01 API 8). 2. `relay.enabled = true` ở cả hai thiết bị. 3. Có Internet. | | Điều kiện sau | **Thành công:** phiên E2E qua relay, trạng thái "Đã kết nối qua Internet"; các tính năng dữ liệu hoạt động như LAN (trừ camera và âm thanh cuộc gọi — cần ở gần).
**Điện thoại offline:** trạng thái `WaitingPeer` ("Điện thoại ngoại tuyến"), đã gửi push đánh thức. | -| Ngoại lệ | E1 — Relay không phản hồi hoặc 5xx → backoff (CONN-02).
E2 — 401 `SIGNATURE_INVALID` hoặc 404 `DEVICE_NOT_FOUND` → đăng ký lại thiết bị rồi thử lại một lần.
E3 — 410 `DEVICE_REVOKED` → báo "Thiết bị đã bị xóa khỏi dịch vụ Internet", tắt relay cho tới khi người dùng bật lại (đăng ký mới).
E4 — `relay.error NOT_PAIRED` khi gửi → gọi `GET /v1/pairs`: đã thu hồi → PAIR-03 luồng B; chưa đăng ký → `POST /v1/pairs` rồi thử lại.
E5 — Điện thoại không online trong 60 s sau push → giữ `WaitingPeer`; không push lại quá 1 lần/5 phút.
E6 — 429 `RATE_LIMITED` → chờ `Retry-After`; trường 4: "Quá nhiều yêu cầu. Thử lại sau {duration}." (`{duration}` là thời gian chờ `Retry-After` còn lại, định dạng bằng formatter của hệ thống).
E7 — Chứng chỉ relay không khớp ghim, trên mọi thiết bị (kể cả Android) → không kết nối; trường 4: "Chứng chỉ máy chủ không đáng tin cậy nên HandLive không kết nối qua Internet."
E8 — `relay.error NOT_CONNECTED` (đối phương vừa rời) → quay về `WaitingPeer`.
E9 — `session/error` `PAIR_UNKNOWN` hoặc `PAIR_REVOKED` qua relay, trước khi bước bắt tay xác thực được điện thoại → `Backoff`, không bao giờ hủy cặp: bất kỳ ai trên đường relay cũng có thể giả mạo nó. Việc thu hồi chỉ được xử lý qua `pair_revoked` có chữ ký (PAIR-03 API 4), `GET /v1/pairs` (PAIR-02) hoặc qua LAN (CONN-01 E4). `UNSUPPORTED_VERSION` hoặc `AUTH_FAILED` trước xác thực qua relay vẫn hiện thông báo của nó (CONN-01 E3, E5) nhưng giữ lịch backoff bình thường thay vì dừng hoặc chờ 5 phút, để lỗi giả mạo không làm client đứng yên. | +| Ngoại lệ | E1 — Relay không phản hồi hoặc 5xx → backoff (CONN-02).
E2 — 401 `SIGNATURE_INVALID` hoặc 404 `DEVICE_NOT_FOUND` → đăng ký lại thiết bị rồi thử lại một lần.
E3 — 410 `DEVICE_REVOKED` → báo "Thiết bị đã bị xóa khỏi dịch vụ Internet", tắt relay cho tới khi người dùng bật lại (đăng ký mới).
E4 — `relay.error NOT_PAIRED` khi gửi → gọi `GET /v1/pairs`: đã thu hồi → PAIR-03 luồng B; chưa đăng ký → `POST /v1/pairs` rồi thử lại.
E5 — Điện thoại không online trong 60 s sau push → giữ `WaitingPeer`; không push lại quá 1 lần/5 phút.
E6 — 429 `RATE_LIMITED` → chờ `Retry-After`; trường 4: "Quá nhiều yêu cầu. Thử lại sau {duration}." (`{duration}` là thời gian chờ `Retry-After` còn lại, định dạng bằng formatter của hệ thống).
E7 — Chứng chỉ relay không khớp ghim, trên mọi thiết bị (kể cả Android) → không kết nối; trường 4: "Chứng chỉ máy chủ không đáng tin cậy nên HandLive không kết nối qua Internet."
E8 — `relay.error NOT_CONNECTED` (đối phương vừa rời) → quay về `WaitingPeer`.
E9 — `session/error` `PAIR_UNKNOWN` hoặc `PAIR_REVOKED` qua relay, trước khi bước bắt tay xác thực được điện thoại → `Backoff`, không bao giờ hủy cặp: route relay có `phoneAuthenticatedByTLS` sai (0.11), vì bất kỳ ai trên đường relay cũng có thể giả mạo nó. Việc thu hồi chỉ được xử lý qua `pair_revoked` có chữ ký (PAIR-03 API 4), `GET /v1/pairs` (PAIR-02) hoặc qua route có `phoneAuthenticatedByTLS` đúng (CONN-01 E4). `UNSUPPORTED_VERSION` hoặc `AUTH_FAILED` trước xác thực qua relay vẫn hiện thông báo của nó (CONN-01 E3, E5) nhưng giữ lịch backoff bình thường thay vì dừng hoặc chờ 5 phút, để lỗi giả mạo không làm client đứng yên. Cùng quy tắc route này áp dụng cho Bluetooth (CONN-05 E5). | | Yêu cầu đặc biệt | **Bảo mật:** relay chỉ thấy lớp bọc (`to`/`from`, `type`, kích thước, thời điểm); không có khóa E2E; JWT 15 phút; ghim SPKI ISRG Root X1/X2 + khóa dự phòng.
**Tài nguyên:** tối đa 2 MiB/s mỗi cặp mỗi chiều; envelope ≤ 256 KiB.
**Hiệu năng tham khảo:** SMS, thông báo cuộc gọi qua relay ≤ 1 s khi hai bên đã online.
**Vận hành:** relay stateless; presence và định tuyến giữa instance qua Redis; thống kê chỉ theo `device_hash`. | ### 3.3.2 Màn hình @@ -967,3 +967,471 @@ LIMIT 20; -- [Thiết kế] Android: dọn push đã gửi hoặc hết hạn DELETE FROM push_outbox WHERE id = :id OR expires_at <= :now; ``` + +--- + +## 3.5 CONN-05 — Kết nối qua Bluetooth khi không chung LAN + +### 3.5.1 Thông tin chung + +| Mục | Nội dung | +|-----|----------| +| Tên | CONN-05 — Kết nối qua Bluetooth khi không chung LAN | +| Mô tả | Khi Mac và điện thoại đã ghép ở gần nhưng không chung LAN (không có mạng hoặc hai mạng khác), M-APP tìm gói quảng bá BLE, mở kênh L2CAP, chạy bắt tay bảo mật link (0.4.5), rồi bắt tay phiên (0.6.3) và trao đổi capability. Làn điều khiển (clipboard, SMS, thông tin gọi, `ping`) chạy như LAN; phiên nâng cấp lên LAN khi thấy (0.11).
Chạy mỗi khi ở `Discovering` còn dùng được Bluetooth (0.11): mở app, thức dậy, mất mạng, toggle Bluetooth, mỗi lần CONN-02 thử. Chỉ Mac; iOS không đổi. Ghép đôi lần đầu khi không có mạng: luồng B của PAIR-01. Ảnh (làn bulk): 0.4.7. Ảnh qua Wi-Fi Direct: CONN-06. Hỏi quyền và Bluetooth tắt: SET-01, SET-03. | +| Tác nhân | Chính: Hệ thống (M-APP, A-SVC). Người dùng tác động gián tiếp (bật Bluetooth, mở nắp máy, mang điện thoại lại gần). | +| Điều kiện trước | 1. Có cặp hợp lệ (PAIR-01).
2. A-SVC chạy dạng foreground service loại `connectedDevice`.
3. Bluetooth bật ở cả hai máy; Android 12+ đã cấp Nearby devices; Mac đã cấp quyền Bluetooth.
4. `link.bluetooth` bật ở cả hai máy. | +| Điều kiện sau | **Thành công:** trạng thái `Connected` trên route `bluetooth`; dòng trạng thái "Đã kết nối qua Bluetooth với "; capability của bên kia được lưu và các tính năng hiệu lực được bật; cập nhật `last_seen_at` (không đụng `last_host`, `last_port`); A-SVC thôi quảng bá hint của cặp này khi phiên còn sống.
**Thất bại:** `Backoff` (CONN-02), vẫn tiếp tục quét. | +| Ngoại lệ | E1 — Trên Mac, Bluetooth tắt, thiếu quyền Bluetooth hoặc `link.bluetooth` tắt → không quét; không có mạng thì client ở `Idle` và dòng trạng thái báo Bluetooth đang tắt (câu chữ: SET-03 trường 18; yêu cầu bật Bluetooth trên Android: SET-01 API 10).
E2 — Không thấy điện thoại (ngoài tầm, điện thoại tắt Bluetooth, A-SVC dừng) → tiếp tục quét ở `Discovering`; không có hạn giờ riêng.
E3 — `noise2` sai tag (thiết bị chép gói quảng bá nhưng không có `PRK`) → đóng kênh, `Backoff`; khi nhiều máy quảng bá cùng hint, lần thử sau chọn máy khác.
E4 — `reject` (`LINK_REJECTED`), hoặc kênh bị đóng giữa bắt tay bảo mật link → `Backoff` theo lịch thường, không bao giờ hủy cặp (0.11).
E5 — `session/error` `PAIR_UNKNOWN`, `PAIR_REVOKED`, `UNSUPPORTED_VERSION` hoặc `AUTH_FAILED` trước khi điện thoại được xác thực → quy tắc route của 0.11 (`phoneAuthenticatedByTLS` = sai): `Backoff`, không bao giờ hủy cặp, không bao giờ ngừng kết nối lại; thông báo về phiên bản và về xác minh vẫn hiện (CONN-01 E3, E5).
E6 — Điện thoại tắt Bluetooth hoặc ra ngoài tầm khi đang có phiên → Mac thấy mất kết nối (`didDisconnectPeripheral`) → `Backoff`, rồi `Discovering` khi thấy lại gói quảng bá (AC2).
E7 — Điện thoại bật lại Bluetooth → A-SVC dựng lại L2CAP server và advertiser khi nhận `STATE_ON` (API 6); không làm vậy thì điện thoại vô hình tới khi app khởi động lại.
E8 — Bản ghi sai dạng, `rlen` ngoài giới hạn, `chan` lạ, message vượt `BT_MESSAGE_MAX` hoặc bản ghi không giải mã được → đóng link, không trả lời; client tính là mất kết nối (`Backoff`).
E9 — Không nhận bản ghi nào trong `BT_IDLE_TIMEOUT` → đóng link, kết nối lại.
E10 — Thấy LAN khi đang có phiên Bluetooth → nâng cấp theo 0.11; phiên Bluetooth nhận `session/bye {reason: replaced}` khi phiên LAN đã xác nhận khóa.
E11 — Mất mạng khi đang có phiên Bluetooth → phiên không bị ảnh hưởng (0.11). | +| Yêu cầu đặc biệt | **Hiệu năng** (mục tiêu có số đo của cổng G7, `plans/20260925-implementation/reports/phase-07-spike-g7.md`; độ trễ một chiều ước bằng RTT/2, theo cách đo của G7):
1. Envelope văn bản một chiều, p95 ≤ 200 ms trên link đã lập đang rảnh (AC1). G7 Lần 2: p50/p95 91.72/133.97 ms.
2. Vẫn ≤ 200 ms khi đang truyền bulk trên link (AC1). G7 trượt với một luồng FIFO (p95 4 769.3 ms ở Lần 2, 7 768.4 ms ở Lần 1), nên bộ lập lịch điều khiển-trước-bulk của 0.4.5 là bắt buộc; đo ở A7.4.
3. Điện thoại ở Doze, tắt màn hình: link vẫn giữ và độ trễ chấp nhận là RTT `ping` p95 ≤ 3 s (một chiều ≤ 1,5 s; sếp chốt ngày 2026-10-28). G7 Lần 5b: 29/29 ping, 0 `bt_lost` trong 15 phút Doze ép, RTT p50/p95 232.4/1 328.7 ms. Không tinh chỉnh connection priority: L2CAP CoC không có GATT thì không có API công khai cho việc này (G7 mục "Yêu cầu phát hiện cho sản phẩm", ý 4); chỉ dựng GATT server tối giản để xin priority nếu T7.1 trượt mục tiêu này.
4. Nối lại ≤ 3 s tính từ lúc điện thoại bật lại Bluetooth, hoặc lại thấy điện thoại, tới `Connected` (AC2). G7 Lần 4 (bật Bluetooth → mở kênh L2CAP, chưa gồm hai bước bắt tay): p50 2 793 ms, max 10 583 ms, n = 3, nên biên rất mỏng và T7.1 đo lại với nhiều vòng hơn.
5. Phát hiện mất kết nối: Mac báo `bt_lost` khoảng 0,7 s sau khi điện thoại tắt Bluetooth (G7 Lần 3; 67 và 42 ms ở vòng 1 và 2 của Lần 4).
6. Thông lượng, để tham khảo: 12.9 KB/s (G7 Lần 2): chỉ đủ cho làn điều khiển; ảnh đi làn bulk (0.4.7).
**Bảo mật:** AC8 — không message trước xác thực nào qua Bluetooth được đổi trạng thái cặp, xóa cặp hay dừng kết nối lại (0.11, E4, E5). AC9 — không có gì cố định trên sóng: gói quảng bá không có tên, `pair_id`, `device_id`, khóa hay `pr`; hint xoay mỗi `BT_HINT_PERIOD` cùng PSM và advertising set; lớp bảo mật link giấu mọi trường của envelope và phần đệm giấu `type` (0.4.5). Mọi độ dài được kiểm trước khi cấp phát (0.4.5). Tiếp nhận theo 0.4.5.
**Nền tảng:** Android: API 29+ (L2CAP CoC), `BLUETOOTH_ADVERTISE` và `BLUETOOTH_CONNECT` (Android 12+), A-SVC đăng ký `BluetoothAdapter.ACTION_STATE_CHANGED`. Mac: CoreBluetooth, `NSBluetoothAlwaysUsageDescription`; không bao giờ khởi động ghép đôi của hệ điều hành. | + +### 3.5.2 Màn hình + +N/A — chưa có wireframe được duyệt. + +### 3.5.3 Mô tả chi tiết các thành phần + +| # | Trường | Kiểu dữ liệu | Input/Output | Giá trị khởi tạo | Mô tả | +|---|--------|--------------|--------------|------------------|-------| +| 1 | Biểu tượng trạng thái (thanh menu Mac) | enum{connected\| connecting\| peer_offline\| disconnected} | Output | `connecting` | Theo 0.11; phiên Bluetooth hiện `connected` | +| 2 | Dòng trạng thái | string | Output | "Đang kết nối…" | "Đã kết nối qua Bluetooth với " (khóa catalog thêm ở S7.1) | +| 3 | Route của phiên | enum{lan\| relay\| bluetooth\| usb} | Output | Rỗng | Nội bộ; quyết định câu trạng thái và quy tắc trước xác thực (`phoneAuthenticatedByTLS`, 0.11) | +| 4 | Thông báo không dùng được Bluetooth | string | Output | Rỗng | Theo E1; câu chữ và nút: SET-03 trường 18 | +| 5 | Thông báo foreground service (Android) | string | Output | "Đang chờ kết nối" | Không đổi: "Đã kết nối với " dù route nào | + +### 3.5.4 Luồng nghiệp vụ + +```mermaid +flowchart TB + subgraph ND["Người dùng"] + U1["(1) Hai máy ở gần, Bluetooth bật, mở nắp máy"] + U10["(10) Thấy Đã kết nối qua Bluetooth"] + end + subgraph HT["Hệ thống"] + S2["(2) A-SVC quảng bá hint xoay vòng và PSM"] + S3["(3) M-APP quét BT_SERVICE_UUID và khớp hint"] + D4{"(4) Có ứng viên LAN?"} + S5["(5) Mở kênh L2CAP tới PSM"] + S6["(6) challenge, noise1, noise2"] + D7{"(7) Bắt tay bảo mật link OK?"} + S8["(8) session/hello và session/welcome trong link"] + S9["(9) capability/hello, lưu trạng thái, chạy đồng bộ"] + X1(["Backoff (CONN-02), vẫn quét"]) + X2(["CONN-01 trong LAN"]) + end + U1 --> S2 --> S3 --> D4 + D4 -- "Có" --> X2 + D4 -- "Không" --> S5 --> S6 --> D7 + D7 -- "Có" --> S8 --> S9 --> U10 + D7 -- "Không (E3, E4)" --> X1 + S8 -- "session/error (E5)" --> X1 +``` + +| Bước | Tác nhân | Thành phần | Mô tả | Ngoại lệ / Ghi chú | +|------|----------|-----------|-------|--------------------| +| 1 | Người dùng | — | Mang hai máy lại gần, bật Bluetooth, mở nắp Mac. | Luồng cũng tự chạy từ 0.11 và CONN-02. | +| 2 | Hệ thống | A-SVC | Quảng bá theo 0.4.5: hint của các cặp chưa có phiên sống, PSM hiện tại; hint, PSM và advertising set xoay mỗi `BT_HINT_PERIOD`. | E7. | +| 3 | Hệ thống | M-APP | Quét với bộ lọc `BT_SERVICE_UUID`, đọc service data, tính hint của các epoch từ e − 4 tới e + 4 bằng `K_bt_disc`; gói quảng bá có `ver` = `0x01` và hint khớp là ứng viên. | E1, E2. | +| 4 | Hệ thống | M-APP | Ưu tiên LAN > Bluetooth: ứng viên LAN (CONN-01 bước 3) được thử trước; không có thì nối ngay ứng viên Bluetooth. | | +| 5 | Hệ thống | M-APP → A-SVC | Kết nối tới peripheral và mở kênh L2CAP tới PSM đã quảng bá; A-SVC áp quy tắc tiếp nhận (0.4.5). | Vượt giới hạn → `reject` (E4). | +| 6 | Hệ thống | Hai bên | Bắt tay bảo mật link, 0.4.5 bước 1–5. | E3, E4. | +| 7 | Hệ thống | Hai bên | Kết quả bắt tay: M-APP đã kiểm `noise2`, A-SVC đã giải mã được bản ghi transport đầu tiên. | E3, E4. | +| 8 | Hệ thống | Hai bên | Bắt tay phiên (0.6.3) trên kênh điều khiển `0x01`, đúng như CONN-01 bước 6–8; A-SVC kiểm thêm `pair_id` là cặp đã gắn với link. | E5. | +| 9 | Hệ thống | Hai bên | `capability/hello`, tính năng hiệu lực, `last_seen_at`; trạng thái `Connected` trên route `bluetooth`; các đồng bộ bắt đầu như CONN-01 bước 10. A-SVC bỏ hint của cặp này khỏi gói quảng bá (dừng quảng bá khi không còn cặp nào). | E6, E8–E11. | +| 10 | Người dùng | M-APP | Thấy "Đã kết nối qua Bluetooth" trên Mac và thông báo quen thuộc trên Android. | | + +### 3.5.5 Đặc tả API/service + +**Danh sách lời gọi** + +| # | API/service | Kênh | Chiều | Dùng ở bước | +|---|-------------|------|-------|-------------| +| 1 | Quảng bá BLE `BluetoothLeAdvertiser.startAdvertisingSet` | Quảng bá BLE | Android | 2 | +| 2 | L2CAP server `BluetoothAdapter.listenUsingInsecureL2capChannel` | L2CAP | Android | 2, 5 | +| 3 | Quét BLE `CBCentralManager.scanForPeripherals` | Quảng bá BLE | Mac | 3 | +| 4 | Mở kênh `CBPeripheral.openL2CAPChannel` | L2CAP | C→S | 5 | +| 5 | Bắt tay bảo mật link (bản ghi `challenge`, `noise1`, `noise2`, `reject`) | L2CAP | Hai chiều | 6, 7 | +| 6 | Trạng thái Bluetooth `ACTION_STATE_CHANGED` / `centralManagerDidUpdateState` | Hệ thống | Hai bên | E1, E6, E7 | +| 7 | Envelope điều khiển qua link | Kênh `0x01` của link | Hai chiều | 8, 9 | + +#### API 1 — Quảng bá BLE (Android) + +- **URL:** N/A (kênh quảng bá BLE) +- **Method:** `BluetoothLeAdvertiser.startAdvertisingSet(parameters, advertiseData, null, null, null, callback)` +- **Request:** + +| Thuộc tính | Giá trị | Mô tả | +|-----------|---------|-------| +| `setLegacyMode` | `true` | Quảng bá legacy 31 byte, central nào cũng thấy | +| `setConnectable` | `true` | | +| `setScannable` | `true` | Scan response mang service data | +| `setInterval` | `INTERVAL_LOW` | Cấu hình probe G7 đã cho số đo AC2 | +| `setTxPowerLevel` | `TX_POWER_HIGH` | Như probe G7 | +| UUID dịch vụ | `BT_SERVICE_UUID` | 128-bit, trong gói quảng bá; service data nằm trong scan response | +| Service data | `ver` ‖ `psm` ‖ `hints` | 0.4.5 | +| `setIncludeDeviceName`, `setIncludeTxPowerLevel` | `false` | Không tên, không trường TX power | + +- **Response:** `AdvertisingSetCallback.onAdvertisingSetStarted(set, txPower, status)`; `status` ≠ + `ADVERTISE_SUCCESS` → logic 3. +- **Example** (hai cặp, PSM `0x0085`, `U×16` = `BT_SERVICE_UUID` little-endian): gói quảng bá `02 01 06` · + `11 07 U×16` (21 byte); scan response `1c 21 U×16 01 85 00 1a 2b 3c 4d 77 e0 aa 19` (29 byte). +- **Business logic:** + 1. Khởi động lại set khi tập cặp, một phiên sống, epoch `BT_HINT_PERIOD` hoặc cửa sổ ghép đôi QR (mở hay đóng, + PAIR-01 B1) thay đổi, tối đa một lần mỗi giây + (gom thay đổi). + 2. Mỗi lần khởi động lại: dừng set, đóng server socket (link đã nhận vẫn giữ), mở server socket mới (API 2), chạy + set mới với PSM của nó. + 3. Khởi động lỗi → thử lại sau 5 s, 30 s, rồi mỗi 5 phút, như mDNS (CONN-01 API 1). + `ADVERTISE_FAILED_DATA_TOO_LARGE` là lỗi lập trình: vector S7.1 có gói quảng bá lớn nhất. + 4. Không cặp nào cần Bluetooth và không có cửa sổ ghép đôi QR đang mở, `link.bluetooth` tắt hoặc Bluetooth tắt → + dừng set. + +#### API 2 — L2CAP server (Android) + +- **URL:** N/A (PSM LE động) +- **Method:** `BluetoothAdapter.listenUsingInsecureL2capChannel()` → `BluetoothServerSocket`; `getPsm()` cho PSM + quảng bá ở API 1; `accept()` trên luồng riêng của A-SVC. +- **Request:** không có. +- **Response:** mỗi kênh được nhận là một `BluetoothSocket`. +- **Example:** PSM `0x0085` (133), trong gói quảng bá là `85 00`. +- **Business logic:** + 1. Mỗi socket được nhận qua bước tiếp nhận của 0.4.5 trước, rồi bắt tay bảo mật link (API 5); link đã xác nhận + thành một `BluetoothPeerSocket`, được phục vụ bằng cùng logic phiên `/v1/ctl` như peer LAN và relay. + 2. Đóng server socket khi xoay vòng không bao giờ đóng các socket đã nhận. + 3. `accept()` lỗi vì Bluetooth tắt thì kết thúc vòng lặp; API 6 khởi động lại. + +#### API 3 — Quét BLE (Mac) + +- **URL:** N/A (kênh quảng bá BLE) +- **Method:** `CBCentralManager.scanForPeripherals(withServices: [CBUUID(BT_SERVICE_UUID)], options: nil)`, như probe + G7. +- **Request:** chỉ có bộ lọc dịch vụ. +- **Response:** `centralManager(_:didDiscover:advertisementData:rssi:)`; service data là + `advertisementData[CBAdvertisementDataServiceDataKey][CBUUID(BT_SERVICE_UUID)]`. +- **Example:** service data `01 85 00 1a 2b 3c 4d 77 e0 aa 19` → `ver` 1, PSM 133, hai hint. +- **Business logic:** + 1. Service data đến từ scan response, CoreBluetooth gộp nó vào `advertisementData`; G7 không dùng scan response + nên M7.1 kiểm điều này trước tiên trên S25 + Mac. Bỏ qua service data ngắn hơn 3 byte, có `ver` ≠ `0x01`, hoặc độ dài trừ 3 không chia hết cho 4. + 2. Quét khi client ở `Discovering` hoặc `Backoff` và còn dùng được Bluetooth; dừng khi `Connected` trên bất kỳ + route nào (nâng cấp lên LAN đến từ mDNS). + 3. Nhiều máy quảng bá cùng hint khớp (gói quảng bá bị chép) → thử lần lượt; chỉ điện thoại thật qua được + `noise2` (E3). + +#### API 4 — Mở kênh L2CAP (Mac) + +- **URL:** N/A +- **Method:** `CBCentralManager.connect(peripheral, options: nil)`, rồi + `CBPeripheral.openL2CAPChannel(psm)` → `peripheral(_:didOpen:error:)` với một `CBL2CAPChannel`. +- **Request:** PSM đọc ở API 3. +- **Response:** `CBL2CAPChannel.inputStream` / `outputStream`, đọc và ghi theo bản ghi link (0.4.5). +- **Example:** `openL2CAPChannel(0x0085)`. +- **Business logic:** + 1. Kết nối và mở kênh phải xong trong `BT_PREAUTH_TIMEOUT`; không thì `cancelPeripheralConnection` và + `Backoff`. + 2. Kênh không bao giờ khởi động ghép đôi của hệ điều hành: không đọc characteristic GATT mã hóa nào. + 3. `didDisconnectPeripheral` khi đang có phiên → E6. + +#### API 5 — Bắt tay bảo mật link + +- **URL:** N/A (bản ghi trên kênh L2CAP) +- **Method:** các bản ghi `challenge` (S→C), `noise1` (C→S), `noise2` (S→C), `reject` (S→C), rồi bản ghi transport + đầu tiên của C (0.4.5). +- **Request (`noise1`):** `rlen` = `0x0031` ‖ `0x02` ‖ `e` (32) ‖ tag (16). +- **Response:** `noise2` (`rlen` = `0x0031` ‖ `0x03` ‖ `e` ‖ tag) hoặc `reject` (`rlen` = `0x0001` ‖ `0x7F`). +- **Example:** `00 21 01` ‖ 32 byte ngẫu nhiên (`challenge`); vector S7.1 có bản ghi đầy đủ. +- **Business logic:** + 1. Các bước 1–6 của bắt tay bảo mật link ở 0.4.5; A-SVC thử psk của mọi cặp chưa thu hồi (Query). + 2. Lỗi được đếm vào `BT_AUTH_FAIL_BLOCK` như định nghĩa ở 0.4.5. + 3. Với client, `reject` và kênh bị đóng là như nhau: E4. + +#### API 6 — Trạng thái Bluetooth + +- **URL:** N/A +- **Method:** Android: receiver cho `BluetoothAdapter.ACTION_STATE_CHANGED`, A-SVC đăng ký suốt vòng đời của nó; + Mac: `centralManagerDidUpdateState(_:)`. +- **Request:** `EXTRA_STATE` (Android); `CBManager.state` (Mac). +- **Response:** N/A. +- **Example:** `STATE_TURNING_OFF` → `STATE_OFF` → `STATE_ON`. +- **Business logic:** + 1. Android `STATE_TURNING_OFF` hoặc `STATE_OFF`: đóng mọi link (bên kia thấy mất kết nối), dừng advertising set. + 2. Android `STATE_ON`: mở server socket mới (PSM mới) và chạy advertising set mới, đúng như lúc A-SVC khởi động. + G7 thấy probe im lặng sau một chu kỳ tắt/bật thật cho tới khi làm việc này (Lần 3). + 3. Mac `.poweredOn`: quét theo API 3 khi client ở `Discovering` hoặc `Backoff`. `.poweredOff`, + `.unauthorized`: kết thúc phiên Bluetooth và lượt quét, rồi theo 0.11. + +#### API 7 — Envelope điều khiển qua link + +- **URL:** N/A (kênh điều khiển `0x01` của link) +- **Method:** JSON envelope của 0.5.1, mỗi message một envelope, chia mảnh theo 0.4.5. +- **Request:** như trên `/v1/ctl`: `session/hello` (CONN-01 API 4), rồi mọi envelope của 0.7.1 dùng `/v1/ctl`. +- **Response:** như trên `/v1/ctl`: `session/welcome` hoặc `session/error` (CONN-01 API 5, 6), `ack`. +- **Example:** xem CONN-01 API 4. +- **Business logic:** + 1. Link không có WebSocket ping: client gửi `ping/ping` mỗi `BT_PING_INTERVAL` khi không có lưu lượng khác + (CONN-02 API 2). + 2. Phiên kết thúc bằng `session/bye` theo quy tắc của phiên qua relay (CONN-02 API 4), rồi đóng kênh. + 3. `camera/start` qua Bluetooth nhận `CAM_TRANSPORT_UNSUPPORTED`, như qua relay. + 4. Nội dung bulk và kênh `0x02`: 0.4.7 (message của CLIP-03, mảnh tối đa `BT_BULK_FRAGMENT_MAX`). + +#### Query + +```sql +-- [Thiết kế] Android, API 1: các cặp có thể quảng bá hint Bluetooth (phiên sống được lọc trong bộ nhớ) +SELECT pair_id, prk_enc, last_seen_at +FROM paired_device +WHERE revoked_at IS NULL +ORDER BY last_seen_at DESC; + +-- [Thiết kế] Android, API 5: psk ứng viên cho noise1 +SELECT pair_id, prk_enc +FROM paired_device +WHERE revoked_at IS NULL; + +-- [Thiết kế] Mac, API 3: các cặp cần khớp hint +SELECT pair_id, peer_device_id, peer_name +FROM paired_device +WHERE revoked_at IS NULL; + +-- [Thiết kế] Hai bên, bước 9 +UPDATE paired_device +SET last_seen_at = :now, features_json = :features_json +WHERE pair_id = :pair_id; +``` + +--- + +## 3.6 CONN-06 — Mở kênh Wi-Fi Direct cho ảnh khi không chung LAN + +### 3.6.1 Thông tin chung + +| Mục | Nội dung | +|-----|----------| +| Tên | CONN-06 — Mở kênh Wi-Fi Direct cho ảnh khi không chung LAN | +| Mô tả | Khi phiên đang chạy qua Bluetooth (CONN-05) và có một ảnh hay lần truyền bulk khác đang chờ, Mac nhờ điện thoại qua làn điều khiển đã mã hóa tạo một group Wi-Fi Direct, vào group như một client Wi-Fi thường, kết nối bằng TLS đã ghim như trong LAN, và rời ngay khi truyền xong (0.4.6). Làn điều khiển vẫn ở Bluetooth. Chỉ Mac; iOS không đổi. Làn bulk, khóa theo kết nối và việc làm lại: 0.4.7. | +| Tác nhân | Chính: Hệ thống (M-APP, A-SVC). Người dùng tác động gián tiếp (sao chép một ảnh trên một thiết bị). | +| Điều kiện trước | 1. `Connected` trên route `bluetooth` (CONN-05); không có phiên LAN.
2. Có một lần truyền bulk đang chờ (0.4.7) theo chiều nào cũng được.
3. Cả hai thiết bị đều bật `link.direct_wifi`.
4. Android: Wi-Fi bật, đã cấp Nearby devices (Android 13+) hoặc đã cấp Vị trí và bật dịch vụ vị trí (Android 10–12) (SET-01).
5. Mac: đã cấp quyền Vị trí (SET-03); Wi-Fi bật và không nối mạng nào trong ≥ `DIRECT_WIFI_IDLE_MIN`; lần quét gần nhất không thấy mạng đã biết nào. | +| Điều kiện sau | **Thành công:** Mac ở trong group với một kết nối TLS đã ghim tới A-SVC; các stream bulk của 0.4.7 chạy trên đó; sau `DIRECT_WIFI_LINGER` không có dữ liệu bulk, hoặc tới `DIRECT_WIFI_MAX`, Mac rời group, điện thoại xóa group và cả hai xóa thông tin vào mạng; Wi-Fi của Mac trở lại trạng thái không nối mạng.
**Thất bại:** không còn group nào; việc truyền đi qua Bluetooth ở nền; không thử lại trước `DIRECT_WIFI_RETRY_AFTER`. Trong mọi trường hợp không thiết bị nào rời hay đổi mạng đang nối (AC4). | +| Ngoại lệ | E1 — Một điều kiện vào không đạt (Mac đang nối mạng, thấy mạng đã biết, Wi-Fi tắt, thiếu quyền Vị trí, `link.direct_wifi` tắt) → không gửi `direct/start`; đi Bluetooth ở nền.
E2 — `ack` mang `PERMISSION_MISSING` (điện thoại thiếu Nearby devices hoặc Vị trí) → đi Bluetooth ở nền; A-SVC đăng thông báo gợi ý cấp quyền (SET-01 trường 17, E14); thử lại sau `DIRECT_WIFI_RETRY_AFTER`.
E3 — `ack` mang `DIRECT_WIFI_UNAVAILABLE` (`wifi_off`, `location_off`, `p2p_unsupported`, `busy`, `error`) → đi Bluetooth ở nền, thử lại sau `DIRECT_WIFI_RETRY_AFTER`; HandLive không bao giờ tự bật Wi-Fi hay dịch vụ vị trí.
E4 — `ack` mang `FEATURE_DISABLED` (`link.direct_wifi` tắt trên điện thoại) hoặc `UNSUPPORTED_TYPE` (điện thoại bản cũ) → không gửi `direct/start` nữa trong phiên này.
E5 — Không thấy SSID, `associate` lỗi hoặc chưa có kết nối TLS đã ghim trong `DIRECT_WIFI_JOIN_TIMEOUT` → `direct/stop {reason: join_failed}`, ngắt khỏi mạng, đi Bluetooth ở nền, thử lại sau `DIRECT_WIFI_RETRY_AFTER`.
E6 — Chứng chỉ trên group không khớp `peer_tls_sha256` (`TLS_PIN_MISMATCH`: một thiết bị khác trả lời ở địa chỉ đó) → đóng, rời group, `direct/stop {reason: join_failed}`; không đụng tới cặp (AC8).
E7 — macOS tự chuyển sang mạng khác, hoặc lần quét định kỳ thấy mạng đã biết → rời ngay (AC4), `direct/stop {reason: known_network}`; phần truyền chưa xong tiếp tục qua Bluetooth (0.4.7).
E8 — Group của điện thoại mất (điện thoại tắt Wi-Fi, hệ điều hành xóa group) → A-SVC gửi `direct/stop {reason: group_lost}`; Mac thấy mất kết nối mạng và quay về Bluetooth.
E9 — Xuất hiện phiên LAN → rời group, `direct/stop {reason: lan}`; bulk chuyển sang LAN.
E10 — Phiên Bluetooth kết thúc → cả hai bên kết thúc group (khóa stream thuộc phiên đó).
E11 — Thiết bị không có khóa phiên vào group và kết nối tới A-SVC → `/v1/ctl`, `/v1/pair` và đường lạ bị đóng 4400, bắt tay stream thất bại bị đóng 4401 (0.4.6). | +| Yêu cầu đặc biệt | **Hiệu năng:** AC3 — ảnh 5 MB ở X: ảnh đầu ≤ 10 s, tính cả thời gian vào group (`DIRECT_WIFI_JOIN_TIMEOUT` 8 s chừa khoảng 2 s để truyền); các ảnh sau ≤ 2 s khi group còn mở (`DIRECT_WIFI_LINGER`).
**Pin:** group chỉ tồn tại khi có truyền đang chờ hoặc đang chạy, tối đa `DIRECT_WIFI_MAX`; không giữ group khi không có lý do (AC4).
**Bảo mật:** thông tin vào mạng mới cho mỗi group, passphrase 144 bit, chỉ gửi bên trong làn điều khiển E2E, không bao giờ quảng bá hay ghi log; TLS đã ghim và bắt tay stream khóa bằng phiên trên group; group không bao giờ thành route của phiên (AC8, 0.4.6).
**Bất biến mạng (AC4):** Mac chỉ vào group khi không nối mạng nào và không thấy mạng đã biết, nên không bao giờ mất mạng khác; điện thoại giữ Wi-Fi riêng của nó (STA + P2P).
**Chưa đo (A7.5/M7.5 kiểm đầu tiên trên S25 + Mac; G7 không thử Wi-Fi Direct, báo cáo spike mục "Brief items", ý 7):** Mac vào group của S25 từ app đã notarize khi đã cấp Vị trí; thời gian vào mạng so với `DIRECT_WIFI_JOIN_TIMEOUT` và AC3; đọc "đang nối hay rảnh" và SSID trên macOS 27 và macOS 13 hoặc 14; macOS có giữ SSID vừa vào trong danh sách mạng ưu tiên không và app có xóa được mà không hỏi quyền quản trị không; S25 chạy đồng thời STA + P2P khi đang nối Wi-Fi riêng; địa chỉ group owner mà S25 cấp (không phải lúc nào cũng là `192.168.49.1`, nghiên cứu mục 2). | + +### 3.6.2 Màn hình + +N/A — chưa có wireframe được duyệt. + +### 3.6.3 Mô tả chi tiết các thành phần + +| # | Trường | Kiểu dữ liệu | Input/Output | Giá trị khởi tạo | Mô tả | +|---|--------|--------------|--------------|------------------|-------| +| 1 | Đường bulk | enum{lan\| direct_wifi\| bluetooth} | Output | Rỗng | Nội bộ; chọn theo bảng route của plan (LAN > Wi-Fi Direct > Bluetooth); không có câu chữ riêng | +| 2 | Trạng thái truyền (Mac) | string | Output | Rỗng | Giữ nguyên câu chữ của CLIP-03; không có câu riêng cho Wi-Fi Direct, người dùng không cần biết đường đi | +| 3 | Thông tin vào mạng Wi-Fi Direct | object{ssid, passphrase, host, port} | Input (Mac), Output (Android) | Rỗng | Chỉ trong bộ nhớ, xóa khi group kết thúc; không ghi log | +| 4 | Dòng trạng thái (Mac) | string | Output | "Đã kết nối qua Bluetooth với \" | Không đổi khi đang ở group: route của phiên vẫn là `bluetooth` (0.4.6) | + +### 3.6.4 Luồng nghiệp vụ + +```mermaid +flowchart TB + subgraph ND["Người dùng"] + U1["(1) Sao chép một ảnh trên một thiết bị"] + U11["(11) Ảnh tới thiết bị kia"] + end + subgraph HT["Hệ thống"] + S2["(2) Có bulk đang chờ trên phiên Bluetooth, điện thoại gửi direct/want cho phần của nó"] + D3{"(3) Mac đủ điều kiện vào?"} + S4["(4) Mac gửi direct/start qua làn điều khiển"] + D5{"(5) Điện thoại tạo được group?"} + S6["(6) Điện thoại tạo group với thông tin mới, ack kèm ssid, passphrase, host, port"] + S7["(7) Mac quét tìm SSID và vào mạng"] + D8{"(8) Có TLS đã ghim trong DIRECT_WIFI_JOIN_TIMEOUT?"} + S9["(9) Stream bulk trên group (0.4.7)"] + S10["(10) Gặp điều kiện rời: rời mạng, direct/stop, xóa group, xóa thông tin vào mạng"] + X1(["Bluetooth ở nền"]) + end + U1 --> S2 --> D3 + D3 -- "Không (E1)" --> X1 + D3 -- "Có" --> S4 --> D5 + D5 -- "Không (E2, E3, E4)" --> X1 + D5 -- "Có" --> S6 --> S7 --> D8 + D8 -- "Không (E5, E6)" --> X1 + D8 -- "Có" --> S9 --> U11 + S9 --> S10 + S10 -- "Còn phần truyền (E7 tới E10)" --> X1 +``` + +| Bước | Tác nhân | Thành phần | Mô tả | Ngoại lệ / Ghi chú | +|------|----------|-----------|-------|--------------------| +| 1 | Người dùng | — | Sao chép một ảnh (CLIP-03) trên điện thoại hoặc trên Mac khi hai máy không chung LAN. | Lần truyền bulk khác của 0.4.7 bắt đầu cùng cách. | +| 2 | Hệ thống | M-APP, A-SVC | Lần truyền vào hàng đợi của làn bulk (0.4.7). Có lần truyền chờ trên điện thoại → A-SVC gửi `direct/want {bytes}` (API 3), tối đa một lần cho mỗi lần truyền đang chờ. | Khi chưa có đường nhanh hơn, lần truyền có thể bắt đầu qua Bluetooth ở nền (0.4.7). | +| 3 | Hệ thống | M-APP | Kiểm điều kiện vào của 0.4.6 bằng API 7: phiên Bluetooth, không có phiên LAN, `link.direct_wifi`, đã cấp Vị trí, Wi-Fi bật và không nối mạng nào trong ≥ `DIRECT_WIFI_IDLE_MIN`, lần quét gần nhất không có mạng đã biết, không có lần thất bại nào trong `DIRECT_WIFI_RETRY_AFTER`. | Một điều kiện không đạt → E1. | +| 4 | Hệ thống | M-APP → A-SVC | Gửi `direct/start` (API 1) và bắt đầu đếm `DIRECT_WIFI_JOIN_TIMEOUT`. | | +| 5 | Hệ thống | A-SVC | Kiểm `link.direct_wifi`, các quyền của SET-01 (bảng API 2), Wi-Fi bật, dịch vụ vị trí bật (Android 10–12). | E2, E3, E4. | +| 6 | Hệ thống | A-SVC | Sinh `ssid` và `passphrase` (0.4.6), gọi `createGroup` (API 4), đọc địa chỉ group owner từ `WifiP2pInfo.groupOwnerAddress` và trả `ack` với `{ssid, passphrase, host, port}`. Bắt đầu đếm `DIRECT_WIFI_JOIN_TIMEOUT`, `DIRECT_WIFI_LINGER` và `DIRECT_WIFI_MAX` phía nó. | `createGroup` lỗi → `DIRECT_WIFI_UNAVAILABLE` (E3). | +| 7 | Hệ thống | M-APP | Quét tìm SSID vừa nhận và vào mạng bằng passphrase (API 5). | E5. | +| 8 | Hệ thống | M-APP → A-SVC | Mở TLS tới `host`:`port` trên giao diện Wi-Fi và kiểm ghim (API 6); thành công trong `DIRECT_WIFI_JOIN_TIMEOUT` nghĩa là đã vào được. | E5, E6. | +| 9 | Hệ thống | Hai bên | Các stream bulk của 0.4.7 chạy qua group; các ảnh sau dùng lại group khi nó còn mở. Trên địa chỉ group owner, A-SVC chỉ nhận đường stream bulk. | E11. | +| 10 | Hệ thống | Hai bên | Gặp một điều kiện rời của 0.4.6 (linger, tối đa, mạng đã biết, LAN, mất Bluetooth): Mac ngắt khỏi mạng và xóa mạng đã lưu khi được (API 5), gửi `direct/stop` (API 2); A-SVC gọi `removeGroup`; cả hai xóa thông tin vào mạng. | E7–E10. | +| 11 | Người dùng | M-APP / A-UI | Ảnh có trên thiết bị kia (CLIP-03). | | + +### 3.6.5 Đặc tả API/service + +**Danh sách lời gọi** + +| # | API/service | Kênh | Chiều | Dùng ở bước | +|---|-------------|------|-------|-------------| +| 1 | `WS direct/start` | Kênh `0x01` của link (Bluetooth) | C→S | 4, 5, 6 | +| 2 | `WS direct/stop` | Kênh `0x01` của link (Bluetooth) | Hai chiều | 10, E5–E10 | +| 3 | `WS direct/want` | Kênh `0x01` của link (Bluetooth) | S→C | 2 | +| 4 | Group Wi-Fi Direct `WifiP2pManager.createGroup` / `removeGroup` | Cục bộ (Android) | — | 6, 10 | +| 5 | Vào và rời mạng bằng CoreWLAN `CWInterface.scanForNetworks` / `associate` / `disassociate` | Wi-Fi (Mac) | — | 7, 10 | +| 6 | Kết nối TLS đã ghim trên group | TCP/TLS trên group | C→S | 8, 9 | +| 7 | Trạng thái Wi-Fi của Mac: sự kiện `CWWiFiClient`, `NWPathMonitor(requiredInterfaceType: .wifi)` | Cục bộ (Mac) | — | 3, 9, E7 | + +#### API 1 — `WS direct/start` + +- **URL:** N/A (kênh điều khiển `0x01` của link Bluetooth, 0.4.5) +- **Method:** `WS direct/start` (C→S), envelope đã mã hóa (0.5.1); phản hồi là `ack` kèm dữ liệu. +- **Request (`data`):** + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|-------| +| `reason` | enum{bulk} | Có | Lý do cần group; bản này chỉ có `bulk` | + +- **Response (`ack.data`):** + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|-------| +| `ssid` | string(32) | Có | `"DIRECT-"` ‖ 10 ký tự ngẫu nhiên trong `[A-Za-z0-9]` (0.4.6) | +| `passphrase` | string(24) | Có | base64url không đệm của 18 byte ngẫu nhiên | +| `host` | string | Có | Địa chỉ IPv4 của group owner (`WifiP2pInfo.groupOwnerAddress`), dạng chấm thập phân | +| `port` | int32 | Có | Cổng A-SVC đang lắng nghe (`CTL_PORT` hoặc cổng dự phòng đang dùng) | + + Lỗi: `PERMISSION_MISSING` (`details.permission`), `DIRECT_WIFI_UNAVAILABLE` (`details.reason`), `FEATURE_DISABLED`, + `RATE_LIMITED` (đang có group cho một cặp khác). +- **Example:** + +```json +{"op":"start","data":{"reason":"bulk"}} +{"re":"0192f3c1-7c1e-7a55-9d0b-3f4c2a1b9e10","ok":true,"data":{"ssid":"DIRECT-q7Lm2Xc9Ta","passphrase":"3vQ8kN0pR2sT4uW6xY8zA1bC","host":"192.168.49.1","port":47800}} +``` + +- **Logic nghiệp vụ:** + 1. Chỉ nhận trên phiên Bluetooth; trên phiên LAN → `BAD_REQUEST` (bulk dùng LAN). + 2. Điện thoại mỗi lúc chỉ có một group: `direct/start` khi group của cặp này đang tồn tại thì trả lại cùng thông tin vào + mạng; cho một cặp khác → `RATE_LIMITED` với `details.retry_after_ms` = thời gian còn lại của group đó. + 3. `ack` chỉ được gửi sau khi `onGroupInfoAvailable` báo group có đúng SSID đã yêu cầu, nên Mac không bao giờ quét + trước khi group tồn tại. + 4. Nội dung `ack` là dữ liệu cá nhân theo nghĩa 0.5.1 luật 5: không bao giờ ghi log, kể cả kích thước. + +#### API 2 — `WS direct/stop` + +- **URL:** N/A (kênh điều khiển `0x01` của link Bluetooth) +- **Method:** `WS direct/stop` (hai chiều), envelope đã mã hóa; trả lời bằng `ack` không có dữ liệu. +- **Request (`data`):** + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|-------| +| `reason` | enum{idle\| max\| known_network\| lan\| join_failed\| group_lost} | Có | `idle` sau `DIRECT_WIFI_LINGER`, `max` khi tới `DIRECT_WIFI_MAX`, các giá trị khác theo E5–E9 | + +- **Response:** `ack` (`ok: true`). +- **Example:** `{"op":"stop","data":{"reason":"idle"}}` +- **Logic nghiệp vụ:** + 1. Idempotent: `stop` cho group không còn tồn tại vẫn được trả `ok: true`. + 2. Bên gửi đã rời mạng (Mac) hoặc đã xóa group (điện thoại) trước khi gửi; bên nhận làm tương tự mà không chờ gì + thêm. + 3. `join_failed` bắt đầu đếm `DIRECT_WIFI_RETRY_AFTER` trên Mac; các lý do khác thì không. + +#### API 3 — `WS direct/want` + +- **URL:** N/A (kênh điều khiển `0x01` của link Bluetooth) +- **Method:** `WS direct/want` (S→C), sự kiện đã mã hóa, không có `ack`. +- **Request (`data`):** + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|-------| +| `bytes` | int64 | Có | Tổng kích thước bulk đang chờ trên điện thoại, chỉ để Mac quyết định | + +- **Response:** N/A; khi đủ điều kiện vào, Mac đáp bằng `direct/start` (API 1). +- **Example:** `{"op":"want","data":{"bytes":5242880}}` +- **Logic nghiệp vụ:** + 1. Gửi một lần cho mỗi lần truyền đang chờ, và không gửi khi group của cặp này đang tồn tại. + 2. Mac tự quyết (AC4 phụ thuộc trạng thái Wi-Fi của Mac); điện thoại không bao giờ tạo group khi chưa có + `direct/start`. + +#### API 4 — Group Wi-Fi Direct (Android) + +- **URL:** N/A +- **Method:** `WifiP2pManager.initialize(context, looper, listener)` → `Channel`; + `createGroup(channel, config, actionListener)`; `requestGroupInfo(channel, listener)` và + `requestConnectionInfo(channel, listener)`; `removeGroup(channel, actionListener)`; broadcast + `WIFI_P2P_CONNECTION_CHANGED_ACTION`. +- **Request:** `WifiP2pConfig.Builder().setNetworkName(ssid).setPassphrase(passphrase) + .setGroupOperatingBand(GROUP_OWNER_BAND_AUTO)`, thêm `setPccModeConnectionType(PCC_MODE_CONNECTION_TYPE_LEGACY_OR_R2)` + từ API 36. +- **Response:** `ActionListener.onSuccess()` hoặc `onFailure(reason)` với `P2P_UNSUPPORTED`, `BUSY` hoặc `ERROR`; + `WifiP2pGroup.getNetworkName()`, `WifiP2pInfo.groupOwnerAddress`; danh sách client trong `WifiP2pGroup.getClientList()`. +- **Example:** `onFailure(BUSY)` → `ack` `DIRECT_WIFI_UNAVAILABLE` `{reason: "busy"}`. +- **Logic nghiệp vụ:** + 1. `P2P_UNSUPPORTED` → `p2p_unsupported`; `BUSY` → `busy`; `ERROR` → `error`; Wi-Fi tắt + (`WifiManager.isWifiEnabled() = false`) → `wifi_off` mà không gọi `createGroup`; Android 10–12 tắt dịch vụ vị trí + → `location_off`. + 2. Group còn sót từ một lần crash trước (`requestGroupInfo` trả về một group) được xóa trước khi tạo group mới. + 3. Không có client nào trong `getClientList()` trong `DIRECT_WIFI_JOIN_TIMEOUT` sau `ack` → `removeGroup`, + `direct/stop {reason: join_failed}`. + 4. Group biến mất khi còn cần (broadcast đổi kết nối) → `direct/stop {reason: group_lost}` (E8). + 5. A-SVC vẫn lắng nghe trên mọi giao diện (0.4.1); trên kết nối có địa chỉ cục bộ là địa chỉ group owner, nó chỉ + phục vụ đường stream bulk `/v1/stream/bulk` (0.4.6, 0.4.7). + +#### API 5 — Vào và rời group (Mac) + +- **URL:** N/A +- **Method:** `CWWiFiClient.shared().interface()`; `scanForNetworks(withSSID: Data(ssid.utf8))`; + `associate(to: network, password: passphrase)`; `disassociate()`; xóa mạng đã lưu qua `CWMutableConfiguration` + (`networkProfiles`) và `commitConfiguration(_:authorization:)` chỉ khi không cần hỏi quyền quản trị. +- **Request:** `ssid` và `passphrase` của API 1. +- **Response:** một tập `CWNetwork`; `associate` ném `CWErr` khi lỗi. +- **Example:** quét thấy một mạng `DIRECT-q7Lm2Xc9Ta` → `associate` thành công → `interface.ssid()` = + `"DIRECT-q7Lm2Xc9Ta"`. +- **Logic nghiệp vụ:** + 1. Cần quyền Vị trí: không có quyền thì CoreWLAN trả các mạng không có SSID (nghiên cứu mục 2), nên điều kiện vào + đã đòi quyền này. + 2. Không bao giờ dùng `networksetup` với passphrase trên dòng lệnh (tiêu chí đạt của G7 trong plan). + 3. Quét tối đa hai lần trong `DIRECT_WIFI_JOIN_TIMEOUT`; nhiều mạng cùng SSID → thử mạng mạnh nhất trước; TLS đã ghim + ở API 6 phân biệt được điện thoại thật. + 4. Rời mạng: `disassociate()`; sau đó macOS tự vào một mạng đã biết nếu có trong tầm. + +#### API 6 — Kết nối TLS đã ghim trên group + +- **URL:** `wss://{host}:{port}/v1/stream/bulk` +- **Method:** TLS 1.3 với chứng chỉ ghim theo `peer_tls_sha256` và không kiểm tên máy, như trong LAN (0.4.1); + `NWParameters.requiredInterface` = giao diện Wi-Fi đã vào group. +- **Request:** `bulk/stream_hello` (0.6.3 bước 7, kênh `bulk`), khóa bằng phiên điều khiển Bluetooth đang sống; sau đó là khóa theo kết nối `K_bulk` (0.4.7). +- **Response:** `bulk/stream_welcome`; lệch ghim → `TLS_PIN_MISMATCH` trước khi gửi bất kỳ byte nào. +- **Example:** `wss://192.168.49.1:47800/…` với SHA-256 chứng chỉ đã ghim ở PAIR-01. +- **Logic nghiệp vụ:** + 1. Bắt tay TLS thành công đầu tiên trên group kết thúc `DIRECT_WIFI_JOIN_TIMEOUT` (bước 8). + 2. Lệch ghim → E6; Mac không bao giờ gửi dữ liệu tới một điểm cuối chưa ghim. + 3. Kết nối không bao giờ dùng cho `/v1/ctl`: phiên vẫn ở Bluetooth (0.4.6 "Không phải LAN"). + +#### API 7 — Trạng thái Wi-Fi của Mac + +- **URL:** N/A +- **Method:** `CWWiFiClient.shared().delegate` với `startMonitoringEvent(with: .ssidDidChange)` và `.linkDidChange`; + `CWInterface.powerOn()`, `ssid()`; `CWConfiguration.networkProfiles` cho các mạng đã biết; `NWPathMonitor` trên giao + diện Wi-Fi. +- **Request:** không có. +- **Response:** các sự kiện và giá trị hiện tại. +- **Example:** `ssid()` nil và không có đường Wi-Fi nào thỏa trong 30 s → đã không nối mạng đủ `DIRECT_WIFI_IDLE_MIN`. +- **Logic nghiệp vụ:** + 1. Mốc "không nối mạng từ lúc" đặt lại mỗi lần nối mạng; lần quét có bất kỳ SSID nào trong `networkProfiles` nghĩa + là "thấy một mạng đã biết". + 2. Khi đang ở group: quét mỗi `DIRECT_WIFI_SCAN_INTERVAL`; thấy mạng đã biết → E7. + 3. `ssidDidChange` sang SSID khác SSID của group khi đang ở group → E7. + +#### Query + +Không truy cập cơ sở dữ liệu. Đọc cài đặt: `link.direct_wifi` (0.9.5) ở cả hai bên. + +```text +# [Thiết kế] UserDefaults trên Mac và DataStore trên Android +defaults.bool(forKey: "link.direct_wifi") # M-APP, bước 3 +prefs[booleanPreferencesKey("link.direct_wifi")] ?: true # A-SVC, bước 5 +``` diff --git a/docs/detailed-design/04-clipboard.md b/docs/detailed-design/04-clipboard.md index e942bbe..2d477f6 100644 --- a/docs/detailed-design/04-clipboard.md +++ b/docs/detailed-design/04-clipboard.md @@ -828,12 +828,12 @@ prefs[intPreferencesKey("clip.auto_clear_s")] ?: 60 # step 10: schedu | Item | Content | |-----|----------| | Name | CLIP-03 — Sync clipboard images between Android and Mac | -| Description | Syncs copied images in both directions.
Source: Android — a `ClipData` item that is a URI with MIME `image/*`, opened with `ContentResolver` right inside `ClipboardReadActivity` (the automatic or manual path of CLIP-01); Mac — a clipboard item with `public.png`, `public.jpeg` or `public.tiff`, or an image file copied in Finder (read from the file; detected as in CLIP-02).
PNG and JPEG are kept as they are; other formats (TIFF, HEIC, WebP, GIF…) are converted to PNG; an image larger than `CLIP_MAX_IMAGE` (10 MiB) after normalization is blocked.
Transfer: `clipboard/push` with `transfer`, then 64 KiB `clipboard/chunk` messages in order (binary plaintext, 0.5.1); the receiver writes a temporary file, checks the SHA-256, then writes the clipboard — Android through a `FileProvider` URI (`ClipData.newUri`), the Mac with `setData`.
The chunk mechanism is shared by text larger than `CLIP_INLINE_MAX` (QC5) and by iPhone/iPad images (CLIP-04). | +| Description | Syncs copied images in both directions.
Source: Android — a `ClipData` item that is a URI with MIME `image/*`, opened with `ContentResolver` right inside `ClipboardReadActivity` (the automatic or manual path of CLIP-01); Mac — a clipboard item with `public.png`, `public.jpeg` or `public.tiff`, or an image file copied in Finder (read from the file; detected as in CLIP-02).
PNG and JPEG are kept as they are; other formats (TIFF, HEIC, WebP, GIF…) are converted to PNG; an image larger than `CLIP_MAX_IMAGE` (10 MiB) after normalization is blocked.
Transfer: `clipboard/push` with `transfer`, then 64 KiB `clipboard/chunk` messages in order (binary plaintext, 0.5.1); the receiver writes a temporary file, checks the SHA-256, then writes the clipboard — Android through a `FileProvider` URI (`ClipData.newUri`), the Mac with `setData`.
The chunk mechanism is shared by text larger than `CLIP_INLINE_MAX` (QC5) and by iPhone/iPad images (CLIP-04).
From P7, when the two share no LAN, the same messages travel on the bulk lane of 0.4.7: over Wi-Fi Direct (`/v1/stream/bulk`, CONN-06) for images of at least `BULK_DIRECT_MIN` when the Mac can join, otherwise over the Bluetooth link (channel `0x02`) in the background. | | Actors | Primary: User. System: A-CLIP (`ClipboardReadActivity`, `ClipboardModule`), A-SVC, A-UI, M-APP, OS (`ClipboardManager`, `ContentResolver`, `FileProvider`, `ImageDecoder`, `NSPasteboard`, ImageIO), I-APP (receives the forwarded copy). | | Preconditions | 1.
Clipboard is active and images are allowed per QC1 (`clip.send_images = true` on the sender, the receiver's `mimes` include the image MIME type).
2.
Android: the image is read through the automatic or manual path of CLIP-01 (the Share target accepts only text).
Mac: CLIP-02 polling is running and reading is allowed (C10).
3.
The receiver has room for the temporary file. | | Postconditions | **Success:** the receiver's clipboard holds the image (Android: a HandLive `content://` URI pointing to a file in `cache/clip/`; Mac: PNG or JPEG data); the receiver has recorded the QC4 trace and scheduled CLIP-05; the transfer's temporary file has been deleted (Android keeps the file of the clip currently on the clipboard); Android forwards the clip to the other clients (QC6).
**Failure or cancellation:** no side changes its clipboard; the temporary files are deleted. | -| Exceptions | E1 — Images not allowed (`clip.send_images = false` on the sender, or the receiver's `mimes` lacks this MIME type): skipped, no notification.
E2 — Image (source or after normalization) > 10 MiB: `CLIP_TOO_LARGE`, reported in place "Image is too large (up to 10 MB)" (toast on Android, status line in the menu bar menu on the Mac).
E3 — The image cannot be decoded or converted: `CLIP_UNSUPPORTED_MIME`, logged only (the manual path shows "Couldn't read the image").
E4 — SHA-256 mismatch: the receiver returns `CLIP_CHECKSUM_MISMATCH`; the sender resends once with a new `transfer_id`; wrong again → "Couldn't send the image" in place (a status line in the menu on the Mac, a toast on Android; no system notification, C19).
E5 — A newer clip appears during the transfer: the sender sends `clipboard/cancel` `superseded`.
E6 — The user chooses "Cancel" on the progress (sender or receiver side): `clipboard/cancel` `user`.
E7 — The receiver gets no chunk within `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): it sends `clipboard/cancel` `timeout` and deletes the temporary file.
E8 — Connection lost midway: both sides delete their temporary files, no resume; when a new session starts, the whole image is replayed if still within 120 s (QC7).
E9 — Not enough space for the temporary file: `ack` `INTERNAL`, the receiver shows "Not enough storage to receive the image".
E10 — Android: the URI cannot be read (the permission was lost because the clip changed, the clipboard never granted it, or the URI fails CLIP-01 API 2 logic 7): skipped on the automatic path; the manual path shows "Couldn't read the image". Debug builds log `clip_read_failed` with the reason and, for a `content` URI, the authority. A logic 7 refusal also logs `why=uri_not_allowed` and never the path. | -| Special requirements | **Performance:** a 5 MB image < 2 s on the LAN (80 chunks); through the relay the limit is 2 MiB/s per pair, so a 10 MiB image takes ≥ 5 s.
Progress is shown for images > 1 MiB.
**Resources:** the receiver writes chunks straight to the file and hashes SHA-256 incrementally, never holding the whole image in memory; at most one incoming transfer from each peer; at most 4 chunks waiting in the WebSocket send buffer so that other envelopes (calls, SMS) can get ahead.
**Security:** every chunk is an encrypted envelope; the relay sees only `type = clipboard` and the size; temporary files live in the app's private storage; `FileProvider` exposes only the `cache/clip/` directory, and the pasting app gets a temporary read permission from the system. | +| Exceptions | E1 — Images not allowed (`clip.send_images = false` on the sender, or the receiver's `mimes` lacks this MIME type): skipped, no notification.
E2 — Image (source or after normalization) > 10 MiB: `CLIP_TOO_LARGE`, reported in place "Image is too large (up to 10 MB)" (toast on Android, status line in the menu bar menu on the Mac).
E3 — The image cannot be decoded or converted: `CLIP_UNSUPPORTED_MIME`, logged only (the manual path shows "Couldn't read the image").
E4 — SHA-256 mismatch: the receiver returns `CLIP_CHECKSUM_MISMATCH`; the sender resends once with a new `transfer_id`; wrong again → "Couldn't send the image" in place (a status line in the menu on the Mac, a toast on Android; no system notification, C19).
E5 — A newer clip appears during the transfer: the sender sends `clipboard/cancel` `superseded`.
E6 — The user chooses "Cancel" on the progress (sender or receiver side): `clipboard/cancel` `user`.
E7 — The receiver gets no chunk within `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): it sends `clipboard/cancel` `timeout` and deletes the temporary file.
E8 — Connection lost midway: both sides delete their temporary files, no resume; when a new session starts, the whole image is replayed if still within 120 s (QC7).
E9 — Not enough space for the temporary file: `ack` `INTERNAL`, the receiver shows "Not enough storage to receive the image".
E10 — Android: the URI cannot be read (the permission was lost because the clip changed, the clipboard never granted it, or the URI fails CLIP-01 API 2 logic 7): skipped on the automatic path; the manual path shows "Couldn't read the image". Debug builds log `clip_read_failed` with the reason and, for a `content` URI, the authority. A logic 7 refusal also logs `why=uri_not_allowed` and never the path.
E11 — No LAN, the lane of a running transfer changes (Wi-Fi Direct comes up while it runs over Bluetooth with more than half left, the group is lost, a LAN session replaces the Bluetooth one): the sender sends `clipboard/cancel` `rerouted` and restarts the whole image on the new lane with a new `transfer_id` (0.4.7), at most `BULK_MAX_RESTARTS` times per clip, then E8.
E12 — No LAN and no Wi-Fi Direct (the Mac is on another network, a permission is missing, `DIRECT_WIFI_UNAVAILABLE`, or the image is under `BULK_DIRECT_MIN`): the image goes over Bluetooth in the background; field 10 shows with the progress; the user can cancel (E6). | +| Special requirements | **Performance:** a 5 MB image < 2 s on the LAN (80 chunks); through the relay the limit is 2 MiB/s per pair, so a 10 MiB image takes ≥ 5 s. Without a LAN (0.4.7): over Wi-Fi Direct the first 5 MB image ≤ 10 s including the join and the next ones ≤ 2 s while the group is open (AC3; not measured yet, A7.4/M7.5 measure it first); over Bluetooth in the background at about 12.9 KB/s (G7 Lần 2), so 5 MB takes about 6.5 min and 10 MiB about 13.5 min, while control envelopes stay ≤ 200 ms (AC1; A7.4 measures it).
Progress is shown for images > 1 MiB, and over Bluetooth for transfers of at least `BULK_DIRECT_MIN`.
**Resources:** the receiver writes chunks straight to the file and hashes SHA-256 incrementally, never holding the whole image in memory; at most one incoming transfer from each peer; at most 4 chunks waiting in the WebSocket send buffer so that other envelopes (calls, SMS) can get ahead.
**Security:** every chunk is an encrypted envelope; the relay sees only `type = clipboard` and the size; temporary files live in the app's private storage; `FileProvider` exposes only the `cache/clip/` directory, and the pasting app gets a temporary read permission from the system. | ### 4.3.2 Screens @@ -844,7 +844,7 @@ N/A — no approved wireframe yet. | # | Field | Data type | Input/Output | Initial value | Description | |---|--------|--------------|--------------|------------------|-------| | 1 | Copied image | image (PNG, JPEG, TIFF, HEIC…) | Input | — | The user copies an image on Android or the Mac | -| 2 | Send/receive progress | int32 (%) | Output | 0 | Only when the image > 1 MiB. Android: notification with a progress bar; Mac: in the menu bar. Example "Sending image to Lan's Pixel — 45%"; receiver: "Receiving image from Lan's MacBook — 45%" | +| 2 | Send/receive progress | int32 (%) | Output | 0 | Only when the image > 1 MiB (over Bluetooth: ≥ `BULK_DIRECT_MIN`, with field 10). Android: notification with a progress bar; Mac: in the menu bar. Example "Sending image to Lan's Pixel — 45%"; receiver: "Receiving image from Lan's MacBook — 45%" | | 3 | Image size | int64 (bytes) | Output | `transfer.size` | Shown in MB next to the progress | | 4 | "Cancel" button | action | Input | — | On the progress on both sides → `clipboard/cancel` `user` | | 5 | Image too large message | string | Output | — | "Image is too large (up to 10 MB)" | @@ -852,6 +852,7 @@ N/A — no approved wireframe yet. | 7 | Not enough storage message | string | Output | — | "Not enough storage to receive the image" (E9) | | 8 | Image on the receiver's clipboard | image | Output | The image just received | Can be pasted right away in apps that accept images; auto-cleared per CLIP-05 | | 9 | Option "Sync Images" | bool | Input/Output | `clip.send_images` = `true` | Settings → Clipboard (SET-02) on each device | +| 10 | Bluetooth transfer note | string | Output | Hidden | Under the progress (field 2) when the transfer runs over Bluetooth: "Sending over Bluetooth. This can take a few minutes." (key `clipboard.bulk_bluetooth_sending`); receiver: "Receiving over Bluetooth. This can take a few minutes." (key `clipboard.bulk_bluetooth_receiving`) | ### 4.3.4 Business flow @@ -886,6 +887,7 @@ flowchart TB S8 -.-> U9 U9 -- "Cancel (E6)" --> X2 S8 -- "Newer clip (E5), no chunk for 30 s (E7), connection lost (E8)" --> X2 + S8 -- "Lane changed (E11)" --> S6 D10 -- "Yes" --> S11 --> U13 D10 -- "SHA-256 mismatch (E4)" --> S12 S12 -- "First resend" --> S6 @@ -915,9 +917,9 @@ flowchart TB |---|-------------|------|-------|-------------| | 1 | Android reads the image: `ClipData.Item.getUri`, `ContentResolver.getType` / `openInputStream`, `ImageDecoder` | Local (Android) | A-CLIP ↔ OS | 2, 4 | | 2 | Mac reads the image: `NSPasteboardItem.data(forType:)`, ImageIO (`CGImageSource`, `CGImageDestination`) | Local (Mac) | M-APP ↔ OS | 2, 4 | -| 3 | `WS clipboard/push` with `transfer` | `/v1/ctl` (LAN or relay) | Both ways | 6, 8, 10, 11, 12 | -| 4 | `WS clipboard/chunk` | `/v1/ctl` (LAN or relay) | Both ways | 7, 8 | -| 5 | `WS clipboard/cancel` | `/v1/ctl` (LAN or relay) | Both ways | 6, 8, 9 | +| 3 | `WS clipboard/push` with `transfer` | Bulk lane (0.4.7): `/v1/ctl` (LAN or relay), `/v1/stream/bulk` (Wi-Fi Direct) or link channel `0x02` (Bluetooth) | Both ways | 6, 8, 10, 11, 12 | +| 4 | `WS clipboard/chunk` | Bulk lane (0.4.7) | Both ways | 7, 8 | +| 5 | `WS clipboard/cancel` | Bulk lane (0.4.7) | Both ways | 6, 8, 9 | | 6 | Android writes the image: `FileProvider.getUriForFile`, `ClipData.newUri`, `ClipboardManager.setPrimaryClip` | Local (Android) | A-CLIP → OS | 11 | | 7 | Mac writes the image: `NSPasteboard.setData(_:forType:)`, `NSPasteboardItemDataProvider` | Local (Mac) | M-APP → OS | 11 | @@ -1074,7 +1076,7 @@ Envelope = {"v":1,"type":"clipboard","id":"0192f3f1-2c40-7c21-9d32-e43f54a65b76" | Field | Type | Required | Description | |--------|------|----------|-------| | `transfer_id` | uuid | Yes | The transfer being cancelled | -| `reason` | enum{superseded\| user\| timeout} | Yes | `superseded`: the sender has a newer clip; `user`: the user chose "Cancel" on either side; `timeout`: the receiver got no chunk for 30 s | +| `reason` | enum{superseded\| user\| timeout\| rerouted} | Yes | `superseded`: the sender has a newer clip; `user`: the user chose "Cancel" on either side; `timeout`: the receiver got no chunk for 30 s; `rerouted`: the sender restarts the transfer on another lane (0.4.7, E11) | - **Response:** N/A. - **Example:** @@ -1086,6 +1088,9 @@ Envelope = {"v":1,"type":"clipboard","id":"0192f3f1-2c40-7c21-9d32-e43f54a65b76" `ack` `ignored` /`cancelled`; the sender stops sending chunks. `user` → that clip is not replayed; `timeout` → treated as not received by the peer (QC7). 3. `cancel` for a `transfer_id` that does not exist → ignored. + 4. `rerouted`: the receiver deletes the temporary file and expects a new `push` with the same `clip_id` and a new + `transfer_id` on the new lane; that clip is still replayed (QC7). A receiver that does not know `rerouted` handles + it as `superseded` (0.5.1 rule 6), with the same effect. #### API 6 — Android writes the image to the clipboard diff --git a/docs/detailed-design/04-clipboard.vi.md b/docs/detailed-design/04-clipboard.vi.md index beaa51f..d9e789e 100644 --- a/docs/detailed-design/04-clipboard.vi.md +++ b/docs/detailed-design/04-clipboard.vi.md @@ -829,12 +829,12 @@ prefs[intPreferencesKey("clip.auto_clear_s")] ?: 60 # bước 10: h | Mục | Nội dung | |-----|----------| | Tên | CLIP-03 — Đồng bộ ảnh clipboard giữa Android và Mac | -| Mô tả | Đồng bộ ảnh đã sao chép theo hai chiều.
Nguồn: Android — item của `ClipData` là URI có MIME `image/*`, mở bằng `ContentResolver` ngay trong `ClipboardReadActivity` (đường tự động hoặc thủ công của CLIP-01); Mac — mục clipboard có `public.png`, `public.jpeg` hoặc `public.tiff`, hoặc tệp ảnh sao chép trong Finder (đọc từ tệp; phát hiện như CLIP-02).
PNG, JPEG giữ nguyên; định dạng khác (TIFF, HEIC, WebP, GIF…) chuyển sang PNG; ảnh sau chuẩn hóa > `CLIP_MAX_IMAGE` (10 MiB) bị chặn.
Truyền: `clipboard/push` kèm `transfer`, rồi các `clipboard/chunk` 64 KiB theo thứ tự (plaintext nhị phân, 0.5.1); bên nhận ghi tệp tạm, kiểm SHA-256, rồi ghi clipboard — Android bằng URI của `FileProvider` (`ClipData.newUri`), Mac bằng `setData`.
Cơ chế chunk dùng chung cho văn bản lớn hơn `CLIP_INLINE_MAX` (QC5) và ảnh của iPhone/iPad (CLIP-04). | +| Mô tả | Đồng bộ ảnh đã sao chép theo hai chiều.
Nguồn: Android — item của `ClipData` là URI có MIME `image/*`, mở bằng `ContentResolver` ngay trong `ClipboardReadActivity` (đường tự động hoặc thủ công của CLIP-01); Mac — mục clipboard có `public.png`, `public.jpeg` hoặc `public.tiff`, hoặc tệp ảnh sao chép trong Finder (đọc từ tệp; phát hiện như CLIP-02).
PNG, JPEG giữ nguyên; định dạng khác (TIFF, HEIC, WebP, GIF…) chuyển sang PNG; ảnh sau chuẩn hóa > `CLIP_MAX_IMAGE` (10 MiB) bị chặn.
Truyền: `clipboard/push` kèm `transfer`, rồi các `clipboard/chunk` 64 KiB theo thứ tự (plaintext nhị phân, 0.5.1); bên nhận ghi tệp tạm, kiểm SHA-256, rồi ghi clipboard — Android bằng URI của `FileProvider` (`ClipData.newUri`), Mac bằng `setData`.
Cơ chế chunk dùng chung cho văn bản lớn hơn `CLIP_INLINE_MAX` (QC5) và ảnh của iPhone/iPad (CLIP-04).
Từ P7, khi hai máy không chung LAN, cùng các message đó đi trên làn bulk của 0.4.7: qua Wi-Fi Direct (`/v1/stream/bulk`, CONN-06) với ảnh từ `BULK_DIRECT_MIN` trở lên khi Mac vào được group, ngược lại qua link Bluetooth (kênh `0x02`) ở nền. | | Tác nhân | Chính: Người dùng. Hệ thống: A-CLIP (`ClipboardReadActivity`, `ClipboardModule`), A-SVC, A-UI, M-APP, OS (`ClipboardManager`, `ContentResolver`, `FileProvider`, `ImageDecoder`, `NSPasteboard`, ImageIO), I-APP (nhận bản chuyển tiếp). | | Điều kiện trước | 1.
Clipboard hiệu lực và ảnh được phép theo QC1 (`clip.send_images = true` ở bên gửi, `mimes` của bên nhận có MIME ảnh).
2.
Android: ảnh được đọc qua đường tự động hoặc thủ công của CLIP-01 (đích Chia sẻ chỉ nhận văn bản).
Mac: hỏi vòng CLIP-02 đang chạy và được phép đọc (C10).
3.
Bên nhận còn chỗ cho tệp tạm. | | Điều kiện sau | **Thành công:** clipboard bên nhận chứa ảnh (Android: URI `content://` của HandLive trỏ tới tệp trong `cache/clip/`; Mac: dữ liệu PNG hoặc JPEG); bên nhận đã ghi dấu vết QC4, hẹn CLIP-05; tệp tạm của lần truyền đã xóa (Android giữ tệp của clip đang nằm trên clipboard); Android chuyển tiếp tới client khác (QC6).
**Thất bại hoặc hủy:** không bên nào đổi clipboard; tệp tạm bị xóa. | -| Ngoại lệ | E1 — Ảnh không được phép (`clip.send_images = false` ở bên gửi, hoặc `mimes` của bên nhận không có MIME này): bỏ qua, không thông báo.
E2 — Ảnh (nguồn hoặc sau chuẩn hóa) > 10 MiB: `CLIP_TOO_LARGE`, báo tại chỗ "Ảnh quá lớn (tối đa 10 MB)" (toast trên Android, dòng trạng thái trong menu thanh menu trên Mac).
E3 — Không giải mã hoặc chuyển đổi được ảnh: `CLIP_UNSUPPORTED_MIME`, chỉ log (đường thủ công báo "Không đọc được ảnh").
E4 — SHA-256 không khớp: bên nhận trả `CLIP_CHECKSUM_MISMATCH`; bên gửi gửi lại một lần với `transfer_id` mới; sai lần nữa → báo tại chỗ "Không gửi được ảnh" (dòng trạng thái trong menu trên Mac, toast trên Android; không đẩy thông báo hệ thống, C19).
E5 — Có clip mới hơn khi đang truyền: bên gửi gửi `clipboard/cancel` `superseded`.
E6 — Người dùng bấm "Hủy" trên tiến trình (bên gửi hoặc bên nhận): `clipboard/cancel` `user`.
E7 — Bên nhận không nhận được khối nào trong `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): gửi `clipboard/cancel` `timeout`, xóa tệp tạm.
E8 — Mất kết nối giữa chừng: hai bên xóa tệp tạm, không nối tiếp; khi có phiên mới, phát lại cả ảnh nếu còn trong 120 s (QC7).
E9 — Không đủ chỗ ghi tệp tạm: `ack` `INTERNAL`, bên nhận báo "Không đủ bộ nhớ để nhận ảnh".
E10 — Android: không đọc được URI (quyền mất vì clip đã đổi, clipboard chưa từng cấp quyền, hoặc URI không qua CLIP-01 API 2 logic 7): bỏ qua ở đường tự động; đường thủ công báo "Không đọc được ảnh". Bản debug ghi `clip_read_failed` kèm lý do và, với URI `content`, authority. Lần từ chối theo logic 7 còn ghi `why=uri_not_allowed` và không ghi đường dẫn. | -| Yêu cầu đặc biệt | **Hiệu năng:** ảnh 5 MB < 2 s trong LAN (80 khối); qua relay giới hạn 2 MiB/s mỗi cặp nên ảnh 10 MiB mất ≥ 5 s.
Hiện tiến trình khi ảnh > 1 MiB.
**Tài nguyên:** bên nhận ghi khối thẳng vào tệp và băm SHA-256 dần, không giữ cả ảnh trong bộ nhớ; tối đa một lần truyền đến từ mỗi đối phương; tối đa 4 khối chờ trong bộ đệm gửi của WebSocket để envelope khác (cuộc gọi, SMS) được chen trước.
**Bảo mật:** mỗi khối là một envelope mã hóa; relay chỉ thấy `type = clipboard` và kích thước; tệp tạm nằm trong vùng riêng của ứng dụng; `FileProvider` chỉ lộ thư mục `cache/clip/`, ứng dụng dán được hệ thống cấp quyền đọc tạm thời. | +| Ngoại lệ | E1 — Ảnh không được phép (`clip.send_images = false` ở bên gửi, hoặc `mimes` của bên nhận không có MIME này): bỏ qua, không thông báo.
E2 — Ảnh (nguồn hoặc sau chuẩn hóa) > 10 MiB: `CLIP_TOO_LARGE`, báo tại chỗ "Ảnh quá lớn (tối đa 10 MB)" (toast trên Android, dòng trạng thái trong menu thanh menu trên Mac).
E3 — Không giải mã hoặc chuyển đổi được ảnh: `CLIP_UNSUPPORTED_MIME`, chỉ log (đường thủ công báo "Không đọc được ảnh").
E4 — SHA-256 không khớp: bên nhận trả `CLIP_CHECKSUM_MISMATCH`; bên gửi gửi lại một lần với `transfer_id` mới; sai lần nữa → báo tại chỗ "Không gửi được ảnh" (dòng trạng thái trong menu trên Mac, toast trên Android; không đẩy thông báo hệ thống, C19).
E5 — Có clip mới hơn khi đang truyền: bên gửi gửi `clipboard/cancel` `superseded`.
E6 — Người dùng bấm "Hủy" trên tiến trình (bên gửi hoặc bên nhận): `clipboard/cancel` `user`.
E7 — Bên nhận không nhận được khối nào trong `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): gửi `clipboard/cancel` `timeout`, xóa tệp tạm.
E8 — Mất kết nối giữa chừng: hai bên xóa tệp tạm, không nối tiếp; khi có phiên mới, phát lại cả ảnh nếu còn trong 120 s (QC7).
E9 — Không đủ chỗ ghi tệp tạm: `ack` `INTERNAL`, bên nhận báo "Không đủ bộ nhớ để nhận ảnh".
E10 — Android: không đọc được URI (quyền mất vì clip đã đổi, clipboard chưa từng cấp quyền, hoặc URI không qua CLIP-01 API 2 logic 7): bỏ qua ở đường tự động; đường thủ công báo "Không đọc được ảnh". Bản debug ghi `clip_read_failed` kèm lý do và, với URI `content`, authority. Lần từ chối theo logic 7 còn ghi `why=uri_not_allowed` và không ghi đường dẫn.
E11 — Không có LAN, làn của một lần truyền đang chạy thay đổi (Wi-Fi Direct lên khi đang chạy qua Bluetooth mà còn hơn một nửa, mất group, phiên LAN thay phiên Bluetooth): bên gửi gửi `clipboard/cancel` `rerouted` và truyền lại toàn bộ ảnh trên làn mới với `transfer_id` mới (0.4.7), tối đa `BULK_MAX_RESTARTS` lần cho mỗi clip, sau đó E8.
E12 — Không có LAN và không có Wi-Fi Direct (Mac đang nối mạng khác, thiếu quyền, `DIRECT_WIFI_UNAVAILABLE`, hoặc ảnh nhỏ hơn `BULK_DIRECT_MIN`): ảnh đi qua Bluetooth ở nền; trường 10 hiện cùng tiến trình; người dùng hủy được (E6). | +| Yêu cầu đặc biệt | **Hiệu năng:** ảnh 5 MB < 2 s trong LAN (80 khối); qua relay giới hạn 2 MiB/s mỗi cặp nên ảnh 10 MiB mất ≥ 5 s. Khi không có LAN (0.4.7): qua Wi-Fi Direct ảnh 5 MB đầu tiên ≤ 10 s tính cả thời gian vào group và các ảnh sau ≤ 2 s khi group còn mở (AC3; chưa đo, A7.4/M7.5 đo trước); qua Bluetooth chạy ở nền với khoảng 12.9 KB/s (G7 Lần 2), nên 5 MB mất khoảng 6,5 phút và 10 MiB khoảng 13,5 phút, trong khi envelope điều khiển vẫn ≤ 200 ms (AC1; A7.4 đo).
Hiện tiến trình khi ảnh > 1 MiB, và qua Bluetooth với lần truyền từ `BULK_DIRECT_MIN` trở lên.
**Tài nguyên:** bên nhận ghi khối thẳng vào tệp và băm SHA-256 dần, không giữ cả ảnh trong bộ nhớ; tối đa một lần truyền đến từ mỗi đối phương; tối đa 4 khối chờ trong bộ đệm gửi của WebSocket để envelope khác (cuộc gọi, SMS) được chen trước.
**Bảo mật:** mỗi khối là một envelope mã hóa; relay chỉ thấy `type = clipboard` và kích thước; tệp tạm nằm trong vùng riêng của ứng dụng; `FileProvider` chỉ lộ thư mục `cache/clip/`, ứng dụng dán được hệ thống cấp quyền đọc tạm thời. | ### 4.3.2 Màn hình @@ -845,7 +845,7 @@ N/A — chưa có wireframe được duyệt. | # | Trường | Kiểu dữ liệu | Input/Output | Giá trị khởi tạo | Mô tả | |---|--------|--------------|--------------|------------------|-------| | 1 | Ảnh được sao chép | image (PNG, JPEG, TIFF, HEIC…) | Input | — | Người dùng sao chép ảnh trên Android hoặc Mac | -| 2 | Tiến trình gửi/nhận | int32 (%) | Output | 0 | Chỉ khi ảnh > 1 MiB. Android: thông báo có thanh tiến trình; Mac: trong menu bar. Ví dụ "Đang gửi ảnh tới Pixel của Lan — 45 %"; bên nhận: "Đang nhận ảnh từ MacBook của Lan — 45 %" | +| 2 | Tiến trình gửi/nhận | int32 (%) | Output | 0 | Chỉ khi ảnh > 1 MiB (qua Bluetooth: ≥ `BULK_DIRECT_MIN`, kèm trường 10). Android: thông báo có thanh tiến trình; Mac: trong menu bar. Ví dụ "Đang gửi ảnh tới Pixel của Lan — 45 %"; bên nhận: "Đang nhận ảnh từ MacBook của Lan — 45 %" | | 3 | Kích thước ảnh | int64 (byte) | Output | `transfer.size` | Hiển thị dạng MB cạnh tiến trình | | 4 | Nút "Hủy" | action | Input | — | Trên tiến trình ở cả hai bên → `clipboard/cancel` `user` | | 5 | Thông báo ảnh quá lớn | string | Output | — | "Ảnh quá lớn (tối đa 10 MB)" | @@ -853,6 +853,7 @@ N/A — chưa có wireframe được duyệt. | 7 | Thông báo thiếu bộ nhớ | string | Output | — | "Không đủ bộ nhớ để nhận ảnh" (E9) | | 8 | Ảnh trên clipboard bên nhận | image | Output | Ảnh vừa nhận | Dán được ngay trong ứng dụng nhận ảnh; tự xóa theo CLIP-05 | | 9 | Tùy chọn "Đồng bộ ảnh" | bool | Input/Output | `clip.send_images` = `true` | Cài đặt → Bảng nhớ tạm (SET-02) trên mỗi thiết bị | +| 10 | Ghi chú truyền qua Bluetooth | string | Output | Ẩn | Dưới tiến trình (trường 2) khi lần truyền chạy qua Bluetooth: "Đang gửi qua Bluetooth. Có thể mất vài phút." (khóa `clipboard.bulk_bluetooth_sending`); bên nhận: "Đang nhận qua Bluetooth. Có thể mất vài phút." (khóa `clipboard.bulk_bluetooth_receiving`) | ### 4.3.4 Luồng nghiệp vụ @@ -887,6 +888,7 @@ flowchart TB S8 -.-> U9 U9 -- "Hủy (E6)" --> X2 S8 -- "Clip mới hơn (E5), 30 s không có khối (E7), mất kết nối (E8)" --> X2 + S8 -- "Đổi làn (E11)" --> S6 D10 -- "Có" --> S11 --> U13 D10 -- "SHA-256 sai (E4)" --> S12 S12 -- "Gửi lại lần đầu" --> S6 @@ -916,9 +918,9 @@ flowchart TB |---|-------------|------|-------|-------------| | 1 | Android đọc ảnh: `ClipData.Item.getUri`, `ContentResolver.getType` / `openInputStream`, `ImageDecoder` | Cục bộ Android | A-CLIP ↔ OS | 2, 4 | | 2 | Mac đọc ảnh: `NSPasteboardItem.data(forType:)`, ImageIO (`CGImageSource`, `CGImageDestination`) | Cục bộ Mac | M-APP ↔ OS | 2, 4 | -| 3 | `WS clipboard/push` kèm `transfer` | `/v1/ctl` (LAN hoặc relay) | Hai chiều | 6, 8, 10, 11, 12 | -| 4 | `WS clipboard/chunk` | `/v1/ctl` (LAN hoặc relay) | Hai chiều | 7, 8 | -| 5 | `WS clipboard/cancel` | `/v1/ctl` (LAN hoặc relay) | Hai chiều | 6, 8, 9 | +| 3 | `WS clipboard/push` kèm `transfer` | Làn bulk (0.4.7): `/v1/ctl` (LAN hoặc relay), `/v1/stream/bulk` (Wi-Fi Direct) hoặc kênh `0x02` của link (Bluetooth) | Hai chiều | 6, 8, 10, 11, 12 | +| 4 | `WS clipboard/chunk` | Làn bulk (0.4.7) | Hai chiều | 7, 8 | +| 5 | `WS clipboard/cancel` | Làn bulk (0.4.7) | Hai chiều | 6, 8, 9 | | 6 | Android ghi ảnh: `FileProvider.getUriForFile`, `ClipData.newUri`, `ClipboardManager.setPrimaryClip` | Cục bộ Android | A-CLIP → OS | 11 | | 7 | Mac ghi ảnh: `NSPasteboard.setData(_:forType:)`, `NSPasteboardItemDataProvider` | Cục bộ Mac | M-APP → OS | 11 | @@ -1072,7 +1074,7 @@ Envelope = {"v":1,"type":"clipboard","id":"0192f3f1-2c40-7c21-9d32-e43f54a65b76" | Trường | Kiểu | Bắt buộc | Mô tả | |--------|------|----------|-------| | `transfer_id` | uuid | Có | Lần truyền bị hủy | -| `reason` | enum{superseded\| user\| timeout} | Có | `superseded`: bên gửi có clip mới hơn; `user`: người dùng bấm "Hủy" ở một trong hai bên; `timeout`: bên nhận 30 s không nhận được khối | +| `reason` | enum{superseded\| user\| timeout\| rerouted} | Có | `superseded`: bên gửi có clip mới hơn; `user`: người dùng bấm "Hủy" ở một trong hai bên; `timeout`: bên nhận 30 s không nhận được khối; `rerouted`: bên gửi truyền lại trên làn khác (0.4.7, E11) | - **Response:** N/A. - **Ví dụ:** @@ -1084,6 +1086,8 @@ Envelope = {"v":1,"type":"clipboard","id":"0192f3f1-2c40-7c21-9d32-e43f54a65b76" bên gửi dừng gửi khối. `user` → không phát lại clip đó; `timeout` → coi như đối phương chưa nhận (QC7). 3. `cancel` cho `transfer_id` không tồn tại → bỏ qua. + 4. `rerouted`: bên nhận xóa tệp tạm và chờ một `push` mới cùng `clip_id`, `transfer_id` mới trên làn mới; clip đó vẫn + được gửi lại (QC7). Bên nhận không biết `rerouted` xử lý như `superseded` (0.5.1 luật 6), cùng kết quả. #### API 6 — Android ghi ảnh vào clipboard diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6a8da13..604be26 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -4,7 +4,7 @@ English | [Tiếng Việt](project-roadmap.vi.md) > Source: `plans/20260924-definitive-architecture/plan.md`, sections 8 and 11. Task cards, Phase 0, gates and the device matrix are in `plans/20260925-implementation/plan.md`. **Live progress table:** hub [`README.md`](../README.md#status) (must be updated whenever a concrete task finishes). -HandLive brings ecosystem-native features, such as Apple Handoff, to Android. The roadmap below is built **in order**. Each phase is a usable piece. Each later phase stands on the infrastructure of the phase before it. The WebSocket, pairing and encryption from Phase 1 are reused by every later phase. One exception to the order (project owner's decision, 2026-09-28): Phases 5 and 6 are done before Phase 4. Phase 4 itself is stopped (project owner's decision, 2026-10-12): only macOS 27 is available, so calls on the computer stay notification + answer/decline/end only, no audio; gate G4 is closed, reopened only on a later owner decision. +HandLive brings ecosystem-native features, such as Apple Handoff, to Android. The roadmap below is built **in order**. Each phase is a usable piece. Each later phase stands on the infrastructure of the phase before it. The WebSocket, pairing and encryption from Phase 1 are reused by every later phase. One exception to the order (project owner's decision, 2026-09-28): Phases 5 and 6 are done before Phase 4. Phase 4 itself is stopped (project owner's decision, 2026-10-12): only macOS 27 is available, so calls on the computer stay notification + answer/decline/end only, no audio; gate G4 is closed, reopened only on a later owner decision. Phase 7 started on 2026-10-28 (project owner's decision) without waiting for Phases 4–6 or their gates. ## Progress snapshot (2026-10-12) @@ -21,7 +21,7 @@ HandLive brings ecosystem-native features, such as Apple Handoff, to Android. Th | 4 | Stopped (owner decision, 2026-10-12) | G4 closed: only macOS 27 available, calls stay notification + answer/decline/end, no audio | | 5 | Spike on `main` (`CameraSpike`, merged 2026-09-30) | G5: need paid Apple Developer team | | 6 | Spike probes on `main` (Apple+Android merged 2026-09-30); product cards not started | G6: owner browser decisions; Firefox/Edge/Brave rows | -| 7 | Proposed (2026-10-01) | Starts after G4/G5/G6 with spike G7 | +| 7 | Started (owner decision, 2026-10-28) | Spike G7 first, no wait on G4/G5/G6; both-offline case, no relay, no HFP bonding | | G0 | Done | Test vectors | | G1 | Open (before 1.0) | Real-device Phase 1 matrix | | G2 | Open (before 1.0) | Play Console SMS / call-log (Plan B: F-Droid/APK) | @@ -60,9 +60,9 @@ decision. **Starts with the G6 spike (3–5 days).** The web page open on one device continues on another: Android to Mac, Android to iPhone and iPad (while the app is open), Mac to Android. Android reads the foreground browser's address bar through a separate Accessibility service, limited to the supported browsers, behind a disclosure and off by default. The Mac reads the front tab through Apple Events, with the Automation permission per browser. The Mac shows the page in its menu, Android as a quiet notification, iPhone and iPad as a banner; the user opens it with one click or tap. Private tabs are never sent and pages are never stored. Built only on Phase 1 infrastructure (session, encryption, capabilities), so it does not depend on Phases 4 and 5; by the project owner's decision of 2026-09-28 it is done before Phase 4 finishes. Detailed design: group 9, WEB-01 to WEB-05, decision C21. **Gate G6:** Android address bar and incognito detection for Chrome, Samsung Internet, Firefox, Edge and Brave on Android 10 and 15, with the battery cost of the service; Apple Events for Safari, Chrome and Arc on macOS 13 and 26, Safari private windows, TCC for a Developer ID build; the current Play Accessibility policy. Go or no-go per browser. **Effort:** about 1.5 person-months (Android 3 weeks, macOS 2 weeks, iOS half a week, test 1 week, spike 1 week). -## Phase 7. Connect anywhere (proposed) +## Phase 7. Connect anywhere (started) -**Starts after gates G4, G5 and G6, with the G7 spike (about one week).** After the first QR pairing the phone and the Mac connect by themselves when they share no network — both offline, or phone on mobile data and Mac on public Wi-Fi — and neither device ever leaves or changes its current Wi-Fi. A Bluetooth link (BLE or classic, chosen by G7) carries the control channel; a new bulk lane carries images over the LAN, Wi-Fi Direct (only while the Mac's Wi-Fi is idle), the relay or Bluetooth; first pairing also works offline over BLE; HandLive starts the classic Bluetooth pairing for HFP itself and confirms it on the Mac only when the code is verified end to end. Every Bluetooth path is secured beyond the session handshake (link-security layer, admission policy). Plan: `plans/20260925-implementation/phase-07-ket-noi-moi-noi.md`. +**Started on 2026-10-28 by the project owner's decision, with the G7 spike (about one week), without waiting for gates G4, G5 and G6.** After the first QR pairing the phone and the Mac near each other connect by themselves when neither has a network (the owner's case), and neither device ever leaves or changes its current Wi-Fi; the same Bluetooth path also covers phone on mobile data and Mac on another Wi-Fi while they are near each other. A Bluetooth link (BLE or classic, chosen by G7) carries the control channel; a new bulk lane carries images over the LAN, Wi-Fi Direct (only while the Mac's Wi-Fi is idle) or Bluetooth; first pairing also works offline over BLE. Dropped on 2026-10-28: bulk over the relay (the relay is not deployed) and automatic HFP bonding (Phase 4 is stopped). If G7 fails there is no relay fallback and the product stays LAN only. Every Bluetooth path is secured beyond the session handshake (link-security layer, admission policy). Plan: `plans/20260925-implementation/phase-07-ket-noi-moi-noi.md`. **Measure:** text under 50 ms on the LAN and within 200 ms over Bluetooth; a 5 MB image within 2 s on the LAN, the first one offline within 10 s; no device changes its Wi-Fi. **Effort:** estimated after G7. ## Total effort diff --git a/docs/project-roadmap.vi.md b/docs/project-roadmap.vi.md index 1dc9f88..275a2a0 100644 --- a/docs/project-roadmap.vi.md +++ b/docs/project-roadmap.vi.md @@ -4,7 +4,7 @@ > Nguồn: `plans/20260924-definitive-architecture/plan.md`, mục 8 và 11. Thẻ việc, Phase 0, cổng kiểm và ma trận máy nằm ở `plans/20260925-implementation/plan.md`. **Bảng tiến độ sống:** [`README.vi.md`](../README.vi.md#trạng-thái) của hub (phải cập nhật mỗi khi hoàn thành một công việc cụ thể). -HandLive đưa các tính năng native riêng trong từng hệ sinh thái, như Handoff trên Apple, lên Android. Lộ trình dưới đây xây **theo thứ tự**. Mỗi phase là một phần dùng được. Phase sau đứng trên hạ tầng phase trước. WebSocket, ghép cặp và mã hóa từ Phase 1 dùng lại cho mọi phase sau. Một ngoại lệ về thứ tự (quyết định của chủ dự án, 28/09/2026): Phase 5 và 6 làm trước Phase 4. Phase 4 đã dừng (quyết định của chủ dự án, 12/10/2026): chỉ có Mac macOS 27, nên cuộc gọi trên máy tính chỉ còn thông báo và nút nghe/từ chối/kết thúc, không có âm thanh; cổng G4 đóng, chỉ mở lại khi chủ dự án quyết định sau. +HandLive đưa các tính năng native riêng trong từng hệ sinh thái, như Handoff trên Apple, lên Android. Lộ trình dưới đây xây **theo thứ tự**. Mỗi phase là một phần dùng được. Phase sau đứng trên hạ tầng phase trước. WebSocket, ghép cặp và mã hóa từ Phase 1 dùng lại cho mọi phase sau. Một ngoại lệ về thứ tự (quyết định của chủ dự án, 28/09/2026): Phase 5 và 6 làm trước Phase 4. Phase 4 đã dừng (quyết định của chủ dự án, 12/10/2026): chỉ có Mac macOS 27, nên cuộc gọi trên máy tính chỉ còn thông báo và nút nghe/từ chối/kết thúc, không có âm thanh; cổng G4 đóng, chỉ mở lại khi chủ dự án quyết định sau. Phase 7 bắt đầu ngày 28/10/2026 (quyết định của chủ dự án) mà không chờ Phase 4–6 hay các cổng của chúng. ## Tiến độ tóm tắt (12/10/2026) @@ -21,7 +21,7 @@ HandLive đưa các tính năng native riêng trong từng hệ sinh thái, như | 4 | Đã dừng (quyết định của chủ dự án, 12/10/2026) | G4 đóng: chỉ có macOS 27, cuộc gọi chỉ còn thông báo và nút nghe/từ chối/kết thúc, không âm thanh | | 5 | Spike trên `main` (`CameraSpike`, merge 30/09/2026) | G5: cần Apple Developer trả phí | | 6 | Probe spike trên `main` (Apple+Android merge 30/09/2026); chưa mở thẻ sản phẩm | G6: quyết định trình duyệt; hàng Firefox/Edge/Brave | -| 7 | Đề xuất (01/10/2026) | Bắt đầu sau G4/G5/G6 bằng spike G7 | +| 7 | Đã bắt đầu (quyết định của chủ dự án, 28/10/2026) | Spike G7 trước, không chờ G4/G5/G6; trường hợp cả hai offline, không relay, không tự ghép đôi HFP | | G0 | Xong | Vector kiểm thử | | G1 | Mở (trước 1.0) | Ma trận Phase 1 máy thật | | G2 | Mở (trước 1.0) | Tờ khai SMS / nhật ký Play Console (phương án B: F-Droid/APK) | @@ -60,9 +60,9 @@ dự án quyết định sau. **Bắt đầu bằng spike G6 (3–5 ngày).** Trang web đang mở trên một thiết bị được xem tiếp trên thiết bị khác: Android sang Mac, Android sang iPhone và iPad (khi ứng dụng đang mở), Mac sang Android. Android đọc thanh địa chỉ của trình duyệt ở foreground qua một dịch vụ Hỗ trợ tiếp cận riêng, giới hạn ở các trình duyệt được hỗ trợ, sau màn hình công bố, mặc định tắt. Mac đọc tab trước nhất qua Apple Events, với quyền Tự động hóa cho từng trình duyệt. Mac hiện trang trong menu, Android hiện thông báo im lặng, iPhone và iPad hiện banner; người dùng mở bằng một lần bấm hoặc chạm. Tab riêng tư không bao giờ được gửi và trang không bao giờ được lưu. Chỉ dựa trên hạ tầng Phase 1 (phiên, mã hóa, capability), nên không phụ thuộc Phase 4 và 5; theo quyết định của chủ dự án ngày 28/09/2026, phase này làm xong trước khi Phase 4 hoàn tất. Thiết kế chi tiết: nhóm 9, WEB-01 tới WEB-05, quyết định C21. **Cổng G6:** thanh địa chỉ và nhận biết ẩn danh trên Android cho Chrome, Samsung Internet, Firefox, Edge và Brave trên Android 10 và 15, kèm chi phí pin của dịch vụ; Apple Events cho Safari, Chrome và Arc trên macOS 13 và 26, cửa sổ riêng tư của Safari, TCC với bản Developer ID; chính sách Hỗ trợ tiếp cận hiện hành của Play. Đạt hoặc không đạt theo từng trình duyệt. **Công:** khoảng 1.5 person-month (Android 3 tuần, macOS 2 tuần, iOS nửa tuần, test 1 tuần, spike 1 tuần). -## Phase 7. Kết nối mọi nơi (đề xuất) +## Phase 7. Kết nối mọi nơi (đã bắt đầu) -**Bắt đầu sau các cổng G4, G5 và G6, bằng spike G7 (khoảng một tuần).** Sau lần ghép đôi QR đầu tiên, điện thoại và Mac tự kết nối khi không chung mạng — cả hai offline, hoặc điện thoại dùng dữ liệu di động còn Mac dùng Wi-Fi công cộng — và không máy nào phải rời hay đổi Wi-Fi hiện tại. Một liên kết Bluetooth (BLE hoặc cổ điển, chọn theo G7) chở kênh điều khiển; một làn dữ liệu lớn mới chở ảnh qua LAN, Wi-Fi Direct (chỉ khi Wi-Fi của Mac đang rảnh), relay hoặc Bluetooth; ghép đôi lần đầu cũng chạy được khi offline qua BLE; HandLive tự khởi động ghép đôi Bluetooth cổ điển cho HFP và Mac chỉ tự xác nhận khi mã đã được kiểm đầu-cuối. Mọi đường Bluetooth đều có bảo mật ngoài bắt tay phiên (lớp bảo mật link, chính sách admission). Plan: `plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md`. +**Bắt đầu 28/10/2026, spike G7 (khoảng một tuần), không chờ G4, G5, G6.** Sau ghép đôi QR, điện thoại và Mac ở gần tự kết nối khi cả hai offline (trường hợp chủ dự án), không rời hay đổi Wi-Fi; cùng đường Bluetooth phủ trường hợp điện thoại dùng 4G và Mac ở Wi-Fi khác khi gần nhau. Liên kết Bluetooth (BLE hoặc cổ điển, G7 chọn) chở kênh điều khiển; làn dữ liệu lớn mới chở ảnh qua LAN, Wi-Fi Direct (nhu cầu, Wi-Fi Mac rảnh), Bluetooth; ghép đôi đầu offline qua BLE. Bỏ: bulk relay (không deploy), ghép đôi HFP (P4 dừng). Nếu G7 hỏng thì không relay dự phòng, chỉ còn LAN. Bluetooth được bảo mật ngoài bắt tay phiên (lớp bảo mật link, chính sách admission). Plan: `plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md`. **Đo:** văn bản dưới 50 ms trên LAN và trong 200 ms qua Bluetooth; ảnh 5 MB trong 2 s trên LAN, ảnh đầu khi offline trong 10 s; không máy nào bị đổi Wi-Fi. **Công:** ước lượng sau G7. ## Công tổng diff --git a/plans/20260925-implementation/phase-07-ket-noi-moi-noi.md b/plans/20260925-implementation/phase-07-ket-noi-moi-noi.md index 1e71a77..1311d10 100644 --- a/plans/20260925-implementation/phase-07-ket-noi-moi-noi.md +++ b/plans/20260925-implementation/phase-07-ket-noi-moi-noi.md @@ -2,34 +2,44 @@ English | [Tiếng Việt](phase-07-ket-noi-moi-noi.vi.md) # Phase 7 — Connect anywhere -**Goal:** after the first QR pairing, the phone and the Mac connect by themselves wherever they are — both offline -(scenario X) or phone on mobile data and Mac on public Wi-Fi (scenario Y) — with no manual network step and **without -either device ever leaving or changing its current Wi-Fi**. A secured Bluetooth link carries the control channel when -there is no LAN; a bulk lane carries images over the fastest path allowed (LAN, Wi-Fi Direct only on demand while the -Mac's Wi-Fi is idle, relay, Bluetooth); HandLive also starts the classic Bluetooth pairing for HFP and confirms it on -the Mac only when the code is verified end to end. - -Status: proposed by the project owner on 2026-10-01 (design in `reports/phase-07-brainstorm.md`, research in +**Goal:** after the first QR pairing, the phone and the Mac near each other connect by themselves when neither has a +network (scenario X, the case the owner needs solved) — with no manual network step and **without either device ever +leaving or changing its current Wi-Fi**. A secured Bluetooth link carries the control channel when there is no LAN; a +bulk lane carries images over the fastest path allowed (LAN, Wi-Fi Direct only on demand while the Mac's Wi-Fi is idle, +Bluetooth in the background). The same Bluetooth path also covers scenario Y (phone on mobile data, Mac on another +Wi-Fi) while the two are near each other. No relay and no HFP bonding in this phase (scope cut below). + +Status: **started on 2026-10-28 by owner decision, without waiting for gates G4, G5 and G6** (Validation Session 2). +Proposed by the project owner on 2026-10-01 (design in `reports/phase-07-brainstorm.md`, research in `reports/research-direct-wifi-link-2026-10-01.md`), revised the same day after a red-team review (section at the end). -**Starts only after gates G4, G5 and G6 are closed** (owner decision 2026-10-01). Planned tests-first: every +The 2026-10-01 rule "starts only after G4, G5 and G6 are closed" is withdrawn. Planned tests-first: every implementation card writes its tests before its code. +**Scope cut 2026-10-28** (owner decision: no relay deployment; Phase 4 stopped on 2026-10-12, so no HFP): +- **Dropped — relay:** R7.1; every relay route in the routing table; the scenario-Y relay part of AC3; the relay parts + of A7.4/M7.4 (bulk over the relay, bulk-demand op, relay hold rule) and of D7.1. The existing relay code (CONN-03) + is left untouched and is not tested by this phase. +- **Dropped — HFP bonding:** SP5 (A7.6/M7.6), AC5, AC10, the bonding question of G7, the "during an HFP call" + measurement of G7, the AUDIO-01 change of D7.1 and the `link.auto_bond` setting. Reopens only with Phase 4. +- Rows below are kept and marked so the red-team history stays readable. + ## Gate G7 — spike (about one week, before any other task card) Questions: which Bluetooth bearer meets the targets between a Samsung phone and a Mac, how the Bluetooth link is -secured, can bonding be verified end to end, can the Mac use the phone's Wi-Fi Direct group safely, and at what cost? +secured, can the Mac use the phone's Wi-Fi Direct group safely, and at what cost? - **Bearer:** BLE L2CAP CoC (`listenUsingInsecureL2capChannel` ↔ `CBPeripheral.openL2CAPChannel`) versus classic RFCOMM (`listenUsingRfcommWithServiceRecord` ↔ `IOBluetoothDevice.openRFCOMMChannelSync`): 1 KB one-way latency p50/p95, 1 MB - and 5 MB throughput, reconnect time; screen off 10 minutes, Doze, during an HFP call, and with a bulk transfer running. - **RFCOMM counts as viable only with a proven way for the Mac to reach the phone before any bond exists** (Android apps - cannot read their own Bluetooth address; the BLE address is random). Otherwise BLE carries SP1/SP2 and classic is used - for HFP only. + and 5 MB throughput, reconnect time; screen off 10 minutes, Doze, and with a bulk transfer running. (*Dropped + 2026-10-28:* "during an HFP call" — no HFP after Phase 4 stopped.) + **RFCOMM counts as viable only with a proven way for the Mac to reach the phone without any bond** (Android apps + cannot read their own Bluetooth address; the BLE address is random; HandLive no longer starts a bond, SP5 dropped). + Otherwise BLE carries SP1/SP2. - **Background:** A-SVC (foreground service `connectedDevice`) keeps advertising and accepting on One UI with the screen off; the Mac central reconnects after sleep/wake. - **Security questions:** does an unbonded L2CAP CoC have link-layer encryption (expected: no → the link-security layer of D7.1 is mandatory); what a BLE sniffer sees with the prototype link-security layer; does macOS keep the `DIRECT-hl-…` network in its known networks after leaving; can it be removed. -- **Bonding (for HFP):** flow (b) phone `createBond` to the Mac address sent over E2E (`IOBluetoothHostController.addressAsString`) +- **Bonding (for HFP) — Dropped 2026-10-28 (SP5 dropped, Phase 4 stopped; kept for history):** flow (b) phone `createBond` to the Mac address sent over E2E (`IOBluetoothHostController.addressAsString`) and flow (c) cross-transport key derivation from a BLE bond. **Flow (a) — the phone made discoverable — is dropped** (it broadcasts the device name and lets a spoofed name in). Check whether the Android app can read the pairing value (`ACTION_PAIRING_REQUEST`) without a system permission; check `replyUserConfirmation` on the Mac. @@ -39,9 +49,9 @@ secured, can bonding be verified end to end, can the Mac use the phone's Wi-Fi D **Pass criterion:** "associated vs idle" and "our group vs another network" are detected correctly on macOS 27 and on macOS 13 or 14, with Location granted, from the app (not a CLI). `networksetup` with a passphrase on its argument list is not an option. -- Results in `reports/phase-07-spike-g7.md`: bearer, bonding flow, join path, link-security prototype, measured numbers - against AC1–AC10 (the owner confirms or adjusts AC5 and AC6). **No bearer meets AC1 on an established link → stop - Phase 7** (keep LAN + relay) and report. +- Results in `reports/phase-07-spike-g7.md`: bearer, join path, link-security prototype, measured numbers against + AC1–AC4 and AC6–AC9 (the owner confirms or adjusts AC6). **No bearer meets AC1 on an established link → stop + Phase 7** and report; the product then stays LAN only (no relay deployed, so there is no fallback). ## Context @@ -51,7 +61,8 @@ secured, can bonding be verified end to end, can the Mac use the phone's Wi-Fi D TLS to the relay, `rv_id` for the rendezvous), 0.6.3 (session handshake authenticated by `PRK`), 0.6.3 step 7 (stream keys, only `camera`/`call-audio` today), 0.11 (state machine: leaves `Idle` only with a network, drops every session on network loss); CONN-01 E4 and CONN-03 E9 (pre-auth errors: only the pinned-TLS LAN may unpair); PAIR-01; CLIP-03; - AUDIO-01 (rule "never pair over Bluetooth on the user's behalf" — changed by owner decision, see D7.1). + AUDIO-01 (rule "never pair over Bluetooth on the user's behalf" — the 2026-10-01 change is dropped with SP5 on + 2026-10-28, so the rule stays as is). - **The relay trust model does not carry over to Bluetooth as is:** the relay hides identifiers behind TLS and authenticates `device_id` before forwarding; an unbonded BLE link does neither. D7.1 adds a link-security layer, a Bluetooth admission policy and a route-based pre-auth rule. @@ -66,26 +77,27 @@ secured, can bonding be verified end to end, can the Mac use the phone's Wi-Fi D | # | Criterion | |---|---| -| AC1 | Text envelopes (clipboard text; SMS and call notifications ride the same lane), copy to available, 95th percentile: **LAN < 50 ms (Phase 1 target, unchanged)**; **Bluetooth ≤ 200 ms on an established link, also while a bulk transfer runs**; relay best effort | +| AC1 | Text envelopes (clipboard text; SMS and call notifications ride the same lane), copy to available, 95th percentile: **LAN < 50 ms (Phase 1 target, unchanged)**; **Bluetooth ≤ 200 ms on an established link, also while a bulk transfer runs**; ~~relay best effort~~ (relay dropped 2026-10-28) | | AC2 | Reconnect ≤ 3 s after a network change or after the Bluetooth peer is seen again (bench events `bt_seen`/`bt_lost`) | -| AC3 | 5 MB image: ≤ 2 s on the LAN; in X the first image ≤ 10 s (it includes the on-demand Wi-Fi Direct join) and the next ones ≤ 2 s while the group stays open; in Y bounded by the phone's mobile upload — relay cap unchanged (2 MiB/s per pair already exceeds typical uplink), measured and reported; Bluetooth only: background transfer, no time target and no Bluetooth-specific size cap (the CLIP-03 image limit applies) | +| AC3 | 5 MB image: ≤ 2 s on the LAN; in X the first image ≤ 10 s (it includes the on-demand Wi-Fi Direct join) and the next ones ≤ 2 s while the group stays open; ~~in Y bounded by the phone's mobile upload over the relay~~ (Dropped 2026-10-28, no relay: in Y near each other images go over Bluetooth); Bluetooth only: background transfer, no time target and no Bluetooth-specific size cap (the CLIP-03 image limit applies) | | AC4 | Invariant: no device changes or loses its current Wi-Fi association. The Mac joins Wi-Fi Direct only when an image is waiting, its Wi-Fi has been unassociated for ≥ `DIRECT_WIFI_IDLE_MIN` and a scan shows no known network; it stays on the group for further images and leaves when a periodic scan shows a known network or after `DIRECT_WIFI_MAX`; HandLive never toggles radio power and never stays on the group without a reason | -| AC5 | HFP bonding started by HandLive: ≤ 1 tap on the phone; the Mac confirms by itself only when the pairing value matches the value received over E2E, otherwise the user compares one code | +| AC5 | **Dropped 2026-10-28 (SP5 dropped, no HFP).** Was: HFP bonding started by HandLive: ≤ 1 tap on the phone; the Mac confirms by itself only when the pairing value matches the value received over E2E, otherwise the user compares one code | | AC6 | First pairing with both devices offline (QR over BLE): ≤ 15 s from scan to Paired (confirmed or adjusted by G7) | | AC7 | Only one-time OS prompts: Mac — Local Network, Bluetooth, Location; Android 12+ — Nearby devices; **Android 10–12 — also Location with location services on, for Wi-Fi Direct (owner decision 2026-10-01)**; plus the existing ones; plus a one-tap system request to turn Bluetooth on when no other path exists, at most once a day | -| AC8 | No pre-authentication message on a route without pinned TLS to the phone (Bluetooth, relay) changes pair state, removes a pair or stops reconnecting | +| AC8 | No pre-authentication message on a route without pinned TLS to the phone (Bluetooth; also the existing relay route) changes pair state, removes a pair or stops reconnecting | | AC9 | A BLE sniffer sees no stable identifier (`pair_id`, `device_id`, keys, names, `pr`) and no envelope `type`, `id` or timing per type | -| AC10 | No Bluetooth bond is confirmed automatically without a pairing value verified over E2E | +| AC10 | **Dropped 2026-10-28 (SP5 dropped, HandLive starts no bond).** Was: no Bluetooth bond is confirmed automatically without a pairing value verified over E2E | -Routing rules (control lane LAN > Bluetooth > relay; bulk lane LAN > Wi-Fi Direct > relay > Bluetooth): +Routing rules (2026-10-28: control lane LAN > Bluetooth; bulk lane LAN > Wi-Fi Direct (X only, AC4 rules) > Bluetooth +in the background). The 2026-10-01 relay steps (control "> relay", bulk "> relay >") are dropped. | Situation | Control lane | Bulk lane | |---|---|---| | Same LAN | LAN | LAN | | X, Mac Wi-Fi idle, image waiting | Bluetooth | Wi-Fi Direct (on demand, AC4 rules), else Bluetooth (background) | | X, Mac on a Wi-Fi without Internet | Bluetooth | Bluetooth (background) | -| Y, near each other | Bluetooth | Relay (joined on a bulk demand, D7.1), else Bluetooth (background) | -| Far apart, both online | Relay | Relay | +| Y, near each other | Bluetooth | Bluetooth (background) — *was relay on a bulk demand, dropped 2026-10-28* | +| Far apart | No path in this phase — *was relay, dropped 2026-10-28* | No path | ## Task cards @@ -94,62 +106,85 @@ before the code of the card. | Code | Task | Tests first | Outputs | Acceptance criteria | |------|------|-------------|---------|---------------------| -| T7.0 [test] | Gate G7 spike (above): throwaway probes on Android and macOS | — (measurement) | `reports/phase-07-spike-g7.md`; probes on the spike branch only, never merged | Bearer, bonding flow, join path and link-security prototype chosen; AC numbers measured; AC5/AC6 confirmed by the owner | -| D7.1 [docs] | Spec first, both languages. **0.11 and CONN:** start condition "a network or Bluetooth on and authorized"; network loss ends LAN/relay sessions only; pre-auth reactions only on routes with pinned TLS to the phone (`phoneAuthenticatedByTLS`), CONN-01 E4 and CONN-03 E9 rewritten as that rule. **0.4.5 Bluetooth link:** advertisement (service UUID, rotating hint; address rotated with the hint); link-security layer before any identifier (e.g. a Noise-style handshake keyed by `K_auth`, or a blinded identifier over a phone-issued nonce) and one generic pre-auth error; admission (global cap, one pre-auth link, phone nonce challenge, key-confirmation failures counted as `AUTH_FAILED`); frame = channel id ‖ length ‖ bytes, control before bulk, bulk fragments of a few KB, no close codes (end with encrypted `session/bye`). **Pairing over BLE:** `pr` = HMAC(`ps`, …), first frame MAC'd with a `ps` key before a slot is granted, one global slot with a short timeout, PIN refused per transport. **Bulk:** stream handshake for `bulk` with per-connection keys (`K_stream` + both nonces), whole transfer (push, chunks, ack) on the bulk lane, bulk inside HL binary frames with an encrypted channel byte (relay wire unchanged), bulk-demand op and relay hold rule while control rides Bluetooth. **Wi-Fi Direct (0.4.6):** SSID/passphrase derivation (≥ 128-bit passphrase), entry/exit rules of AC4, remove the saved network after leaving, HandLive socket bound to the P2P interface only. **Bonding (AUDIO-01, owner decision 2026-10-01):** HandLive may start bonding; the Mac auto-confirms only on an E2E-verified value; address exchange Mac → phone only; disclosure text updated. Plus constants, settings keys, error codes, SET-01/02/03, CONN-05/CONN-06 leaves, PAIR-01, CLIP-03 | — (contract; the vectors of S7.1 are its tests) | `docs/detailed-design/*.md` + `.vi.md` | `validate_design_docs.py` prints `problems=0`; `check_bilingual_docs.py` green; every new message type, error code, constant and string defined before code; AC8–AC10 traceable to spec rules | -| S7.1 [shared] | Contract data: test vectors — link-security handshake, frame codec with channel ids, advertisement service data, `pr` HMAC, Wi-Fi Direct SSID/passphrase, per-connection `bulk` keys, bonding value binding; JSON schemas for the new ops; UI strings; bench events `bt_seen`/`bt_lost`/`wifi_assoc` and a `reconnect_time.py` trigger for them | Vectors and negative samples are the tests: generators produce them, `check_*` validate them | `shared/test-vectors/`, `shared/schemas/`, `shared/tools/` (incl. `bench/`), `shared/strings/` | Generators reproducible; `check_schemas.py`, `check_strings.py` green; other platforms told to re-run | -| A7.1 [android] | Bluetooth control link: advertiser in A-SVC, server for the bearer chosen in G7, link-security layer, Bluetooth admission, `BluetoothPeerSocket` (virtual `WebSocketSession` like `RelayPeerSocket`) served by `ControlServer.serveBluetoothPeer`, frame codec with channel ids; a **debug-build-only loopback shim** for the e2e harness | Regression tests for `serveRelayPeer`; codec and link-security vector tests; fake-pipe tests: handshake, rekey, replayed hello, rotating addresses, pre-auth cap; a release-build test asserting the shim is absent | `android/core/transport`, `android/feature/connection`, `android/app` | All tests green; CONN-05 exceptions covered; AC1 (Bluetooth) measured on the S25 | -| M7.1 [macOS] | Bluetooth control link: central (CoreBluetooth or IOBluetooth per G7), link-security layer, `BluetoothChannel: MessageChannel`, route `.bluetooth`; 0.11 changes (start without a network, per-route network loss, `phoneAuthenticatedByTLS` instead of `route == .relay`); priority LAN > Bluetooth > relay with upgrades; exhaustive route switches in the UI packages | Red first: `networkLost` while `connected(.bluetooth)` stays connected; `idle(noNetwork)` + peer seen → connecting; forged `PAIR_UNKNOWN`/`PAIR_REVOKED`/`UNSUPPORTED_VERSION`/`AUTH_FAILED` before authentication over Bluetooth never unpairs or stops reconnecting; codec vectors; in-memory channel tests | `apple/Packages/HLTransport`, `apple/Packages/HLMacUI`, `apple/Packages/HLiOSUI`, `apple/macOS/HandLive` | All tests green; iOS builds and behaves as before; AC1 (Bluetooth), AC2, AC8 measured | -| A7.2 [android] | Offline first pairing: advertise `pr` (HMAC form) during QR mode; grant the single pairing slot only after a valid `ps`-MAC'd first frame; PAIR-01 over the secured Bluetooth link; PIN refused on Bluetooth explicitly | PAIR-01 vectors over the fake pipe; negative tests: PIN over Bluetooth, missing/invalid first-frame MAC, slot hogging, second claimant | `android/feature/pairing`, `android/core/transport` | PAIR-01 exceptions over Bluetooth covered; AC6 measured | -| M7.2 [macOS] | Offline first pairing: scan for `pr`, try every candidate with the same `pr`, run PAIR-01 over the secured `BluetoothChannel`, then pin and store as today | Same vectors; timeout, wrong `pr`, spoofed advertiser with the right `pr` | `apple/Packages/HLTransport` (`Pairing*`) | AC6 measured; no QR format change | +| T7.0 [test] | Gate G7 spike (above): throwaway probes on Android and macOS | — (measurement) | `reports/phase-07-spike-g7.md`; probes on the spike branch only, never merged | Bearer, join path and link-security prototype chosen; AC numbers measured; AC6 confirmed by the owner (bonding flow and AC5 dropped 2026-10-28) | +| ~~T7.0a~~ [test] | *(Added 2026-10-28, lead decision.)* Scan-response probe: the throwaway probes of T7.0 with the 0.4.5 layout (128-bit `BT_SERVICE_UUID` in the advertisement, service data in the scan response) on the S25 + Mac, Mac scanning in the foreground and in the background | — (measurement) | A section in `reports/phase-07-spike-g7.md`; probes on the spike branch only, never merged | **Dropped 2026-10-28 (owner decision): scan response checked in A7.1b/M7.1c on real devices.** Was: service data received on the Mac in both cases; otherwise the advertisement layout of 0.4.5 changes (D) before S7.1a freezes its vectors | +| D7.1 [docs] | Spec first, both languages. **0.11 and CONN:** start condition "a network or Bluetooth on and authorized"; network loss ends LAN/relay sessions only; pre-auth reactions only on routes with pinned TLS to the phone (`phoneAuthenticatedByTLS`), CONN-01 E4 and CONN-03 E9 rewritten as that rule. **0.4.5 Bluetooth link:** advertisement (service UUID, rotating hint; address rotated with the hint); link-security layer before any identifier (e.g. a Noise-style handshake keyed by `K_auth`, or a blinded identifier over a phone-issued nonce) and one generic pre-auth error; admission (global cap, one pre-auth link, phone nonce challenge, key-confirmation failures counted as `AUTH_FAILED`); frame = channel id ‖ length ‖ bytes, control before bulk, bulk fragments of a few KB, no close codes (end with encrypted `session/bye`). **Pairing over BLE:** `pr` = HMAC(`ps`, …), first frame MAC'd with a `ps` key before a slot is granted, one global slot with a short timeout; ~~PIN refused per transport~~ *changed 2026-10-28 (owner decision): the PIN pairs over Bluetooth through CPace (`CPACE-X25519-SHA512`), one online guess per channel, 3 per PIN, lockout (D7.1e, PAIR-01 flow C)*. **Bulk:** stream handshake for `bulk` with per-connection keys (`K_stream` + both nonces), whole transfer (push, chunks, ack) on the bulk lane over LAN, Wi-Fi Direct or Bluetooth (*dropped 2026-10-28:* bulk inside relay HL binary frames, bulk-demand op, relay hold rule). **Wi-Fi Direct (0.4.6):** SSID/passphrase (random per group, ≥ 128-bit passphrase), entry/exit rules of AC4, remove the saved network after leaving, HandLive socket bound to the P2P interface only. ~~**Bonding (AUDIO-01)**~~ *dropped 2026-10-28 with SP5; AUDIO-01 unchanged.* Plus constants, settings keys, error codes, SET-01/02/03, CONN-05/CONN-06 leaves, PAIR-01, CLIP-03 | — (contract; the vectors of S7.1 are its tests) | `docs/detailed-design/*.md` + `.vi.md` | `validate_design_docs.py` prints `problems=0`; `check_bilingual_docs.py` green; every new message type, error code, constant and string defined before code; AC8–AC9 traceable to spec rules | +| S7.1a [shared] | *(Split 2026-10-28, lead decision; was S7.1.)* For SP1, link contract: link-security handshake (`challenge`, `noise1`, `noise2`, prologue bound to `n_s`, `K_bt_disc`/`K_bt_link`), record codec (`rlen` bounds; 272, 1,040, 4,112) and frame codec (channel ids, `more`/`long`, short and long control lanes, padding) with negative samples (`rlen` over its bound, unknown `chan`, `more` without `long`, a long-lane fragment over `BT_BULK_FRAGMENT_MAX`, a message over `BT_MESSAGE_MAX`), advertisement service data (frozen now from the 0.4.5 table; checked on real devices in A7.1b/M7.1c); UI strings for the Bluetooth prompts and the Bluetooth-off line; bench events `bt_seen`/`bt_lost` and a `reconnect_time.py` trigger for them | Vectors and negative samples are the tests: generators produce them, `check_*` validate them | `shared/test-vectors/`, `shared/strings/`, `shared/tools/` (incl. `bench/`) | Generators reproducible; `check_schemas.py`, `check_strings.py` green; other platforms told to re-run | +| S7.1b [shared] | For SP2, made while SP1 runs: `pr_bt` HMAC, `K_bt_pdisc`/`K_bt_plink`, `BT_PIN_HINT`; CPace for the Bluetooth PIN (`cpace.json`, Kotlin and Swift: the draft's own `CPACE-X25519-SHA512` vectors, then the HandLive cases and negatives of 0.6.1), `K_bt_pinlink`/`K_bt_pin`; schemas for the pairing envelopes on a Bluetooth link; strings for the PIN over Bluetooth and `BT_PIN_LOCKOUT` | Vectors and negative samples are the tests: generators produce them, `check_*` validate them | `shared/test-vectors/`, `shared/schemas/`, `shared/strings/` | Generators reproducible; `check_schemas.py`, `check_strings.py` green; other platforms told to re-run; the pentester has reviewed `cpace.json` before A7.2a/M7.2a start | +| S7.1c [shared] | For SP3: per-connection `bulk` keys (`K_bulk` from `K_stream` and both nonces, 0.4.7); schemas for `bulk/stream_hello` and `bulk/stream_welcome` | Vectors and negative samples are the tests: generators produce them, `check_*` validate them | `shared/test-vectors/`, `shared/schemas/` | Generators reproducible; `check_schemas.py`, `check_strings.py` green; other platforms told to re-run | +| S7.1d [shared] | For SP4: Wi-Fi Direct `ssid` and `passphrase` are random for every group (0.4.6), so no derivation vectors: format and charset samples, positive and negative (prefix `DIRECT-`, length, alphabet, passphrase entropy); schemas for `direct/start`, `direct/stop`, `direct/want` and the `direct/start` ack `{ssid, passphrase, host, port}` (00-common-specs 0.7.1); bench event `wifi_assoc`; strings for the Location prompts | Vectors and negative samples are the tests: generators produce them, `check_*` validate them | `shared/test-vectors/`, `shared/schemas/`, `shared/strings/`, `shared/tools/bench/` | Generators reproducible; `check_schemas.py`, `check_strings.py` green; other platforms told to re-run | +| A7.1a [android] | *(Split 2026-10-28, lead decision; was A7.1.)* Link core, no radio: record and frame codecs with every length checked before allocating, short and long control lanes with reassembly, the scheduler of 0.4.5 "Priority" (short control > long control > bulk, `BT_BULK_INFLIGHT`; moved here from A7.4 because G7 condition 1 makes it part of the control lane), link-security layer (Noise responder, trial of each pair psk, link rekey), Bluetooth admission for the whole listener (`BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, `BT_PREAUTH_TIMEOUT`, `BT_AUTH_FAIL_BLOCK`; never keyed by address) | Codec and link-security vectors of S7.1a; fake-pipe tests: handshake, a replayed `noise1` on a new channel, link rekey, pre-auth cap, block after `BT_AUTH_FAIL_BLOCK`, oversized `rlen`, a short envelope sent after a 256 KiB one arrives first, two envelopes of the same `type` keep their order, inflight bound | `android/core/transport` | All tests green | +| A7.1b [android] | Radio and wiring: advertiser and L2CAP server in A-SVC (rotation every `BT_HINT_PERIOD`, rebuilt on `STATE_ON`, independent of the LAN server and of the network), `SessionTransport.BLUETOOTH` and every `when` on it (`PeerSession.Channel` and its users in the feature modules), `BluetoothPeerSocket` (virtual `WebSocketSession` like `RelayPeerSocket`) served by `ControlServer.serveBluetoothPeer`, `session/bye` before every close on Bluetooth; a **debug-build-only loopback shim** for the e2e harness (not needed for the SP1 measurement, may land last) | Regression tests for `serveRelayPeer`; session tests over the fake pipe; rotation and `STATE_ON` rebuild with a fake adapter; a release-build test asserting the shim is absent | `android/core/transport`, `android/feature/connection`, `android/feature/*` (route mapping only), `android/app` | All tests green; CONN-05 exceptions covered; AC1 (Bluetooth) measured on the S25, idle and under a debug-only long-control load through the scheduler; scan response contains service data read on Mac, even when app is in background | +| M7.1a [macOS] | *(Split 2026-10-28, lead decision; was M7.1.)* State machine of 0.11, pure logic, first card of SP1: route `.bluetooth` and `ConnectingBT`; start without a network when Bluetooth is usable; network loss ends LAN/relay sessions only; `phoneAuthenticatedByTLS` instead of `route == .relay` (`ConnectionManager+Run`, `ConnectionManager+Supervise`, `ControlSession`); priority LAN > Bluetooth with upgrades (the existing relay route stays in the code untouched, last, and is not deployed); exhaustive route switches in the packages that read the route (`HLMacUI`, `HLiOSUI`, `HLSMS`, `HLDesignSystem`, `Tools/HandLiveDevClient`) | Red first: `networkLost` while `connected(.bluetooth)` stays connected; `idle(noNetwork)` + peer seen → connecting; forged `PAIR_UNKNOWN`/`PAIR_REVOKED`/`UNSUPPORTED_VERSION`/`AUTH_FAILED` before authentication over Bluetooth never unpairs or stops reconnecting; a LAN session replaces the Bluetooth one | `apple/Packages/HLTransport`, `apple/Packages/HLMacUI`, `apple/Packages/HLiOSUI`, `apple/Packages/HLSMS`, `apple/Packages/HLDesignSystem`, `apple/Tools/HandLiveDevClient` | All tests green; iOS builds and behaves as before | +| M7.1b [macOS] | Link core, no radio, initiator side of A7.1a: record and frame codecs, short and long control lanes, the scheduler, link-security initiator (`noise1`, `noise2` check, link rekey), `BluetoothChannel: MessageChannel` over an in-memory pipe | Codec and link-security vectors of S7.1a; in-memory channel tests (the list of A7.1a, initiator side) | `apple/Packages/HLTransport` | All tests green | +| M7.1c [macOS] | Radio and wiring: CoreBluetooth central (scan with the `BT_SERVICE_UUID` filter, hints over `e − 4`…`e + 4`, `openL2CAPChannel`), created and injected by the macOS app only (iOS never creates it), route `.bluetooth` live in `ConnectionManager` | Fake-central tests: hint match, rotated PSM, next candidate after a `noise2` failure (CONN-05 E3), disconnect → `Backoff` | `apple/Packages/HLTransport`, `apple/macOS/HandLive` | All tests green; iOS builds and behaves as before; AC1 (Bluetooth), AC2, AC8 measured; scan response contains service data read on the Mac, even when app is in background | +| A7.2a [android] | *(Added 2026-10-28, lead decision.)* CPace primitive `CPACE-X25519-SHA512`, no Bluetooth: own field arithmetic mod 2^255 − 19 in constant time (no `BigInteger`, no branch or table index on secret data), Elligator2 map of RFC 9380, `calculate_generator`, `ISK` | All `cpace.json` vectors of S7.1b, negatives included | `android/core/crypto` | All vectors pass; the pentester has reviewed the constant-time code | +| A7.2 [android] | Offline first pairing: advertise `pr` (HMAC form) during QR mode; grant the single pairing slot only after a valid `ps`-MAC'd first frame; PAIR-01 over the secured Bluetooth link; PIN over Bluetooth through CPace on the primitive of A7.2a (PAIR-01 flow C: `BT_PIN_HINT` advertising, `cpace1`/`cpace2`, attempt counting, `BT_PIN_LOCKOUT`) — *changed 2026-10-28 by owner decision, was "PIN refused on Bluetooth explicitly"* | PAIR-01 vectors over the fake pipe; CPace vectors of S7.1 (`cpace.json`); negative tests: wrong PIN (3 attempts then lockout), low-order `Ya`, `cpace1` outside a PIN window, `mode` not matching the link, missing/invalid first-frame MAC, slot hogging, second claimant | `android/feature/pairing`, `android/core/transport` | PAIR-01 exceptions over Bluetooth covered; AC6 measured | +| M7.2a [macOS] | *(Added 2026-10-28, lead decision.)* CPace primitive, same scope as A7.2a (CryptoKit has no field arithmetic) | Same as A7.2a | `apple/Packages/HLCrypto` | Same as A7.2a | +| M7.2 [macOS] | Offline first pairing: scan for `pr`, try every candidate with the same `pr`, run PAIR-01 over the secured `BluetoothChannel`, then pin and store as today; PIN: scan for `BT_PIN_HINT`, CPace initiator on the primitive of M7.2a, count wrong PINs, new PIN at 3 (*added 2026-10-28, owner decision*) | Same vectors plus `cpace.json`; timeout, wrong `pr`, spoofed advertiser with the right `pr`, a phone in PIN mode with another PIN, an attacker answering `cpace1` | `apple/Packages/HLTransport` (`Pairing*`) | AC6 measured; no QR format change | | A7.3 [android] | Stream channel infrastructure: `/v1/stream/*` server on A-SVC (`stream_hello`/`stream_welcome`, 0.6.3 step 7) — **reuse it if a Phase 4 or 5 product card built it first** (plan decision I9) | Stream handshake vector tests (written first) | `android/core/transport`, `android/core/crypto` | Stream handshake works on the LAN; `camera`/`call-audio` unaffected | | M7.3 [macOS] | Stream channel client, same scope and reuse rule as A7.3 | Same | `apple/Packages/HLTransport` | Same | -| R7.1 [relay] | Confirm the relay carries bulk unchanged: `HR` binary frames of bulk size at the existing cap; no wire or cap change | Forwarding tests for bulk-sized `HR` frames (red if the relay misbehaves) | `relay/` tests only | `cargo test`, `cargo clippy` green; no change to `wire.rs` or the cap | -| A7.4 [android] | Bulk lane: `bulk` stream with per-connection keys; the whole CLIP-03 transfer on the bulk lane; path selection per the routing table; bulk over the relay as encrypted HL binary frames split in `RelayPeerMux`; bulk-demand op and relay hold rule (both flavors, foss without FCM); Bluetooth scheduler giving control frames priority | CLIP-03 regression over `/v1/ctl` first; then red: chunks before push, bulk over relay reaches the bulk session not the control session, relay joined on a bulk demand while control is on Bluetooth, AC1 under a 5 MB bulk transfer, a 10 MiB image over Bluetooth only completes in the background | `android/core/transport` (incl. `relay/`), `android/feature/clipboard` | CLIP-03 exceptions pass on every path; AC1 (LAN) unchanged; AC3 (LAN) unchanged | -| M7.4 [macOS] | Bulk lane on the Mac side, same scope as A7.4 (`RelayLink`/`RelayPeerChannel` split, `ClipboardPeer` seam) | Same order as A7.4 | `apple/Packages/HLTransport`, `apple/Packages/HLAppCore` (clipboard) | Same as A7.4 | -| A7.5 [android] | Wi-Fi Direct host (X only, on demand): group with derived SSID/passphrase, `LEGACY_OR_R2` on API 36, started/stopped on request over the control lane, STA kept; `NEARBY_WIFI_DEVICES` (13+), `ACCESS_FINE_LOCATION` + location services (10–12); Wi-Fi off → report, never toggle | Derivation vectors; lifecycle tests with a fake `WifiP2pManager` (start, stop, crash, STA unchanged, stop after `DIRECT_WIFI_MAX`) | `android/feature/connection` (or a new `feature/directlink`) | AC4 verified on the S25; AC3 in X measured | +| R7.1 [relay] | **Dropped 2026-10-28 (no relay deployment).** Was: confirm the relay carries bulk unchanged: `HR` binary frames of bulk size at the existing cap; no wire or cap change | Forwarding tests for bulk-sized `HR` frames (red if the relay misbehaves) | `relay/` tests only | `cargo test`, `cargo clippy` green; no change to `wire.rs` or the cap | +| A7.4 [android] | Bulk lane: `bulk` stream with per-connection keys; the whole CLIP-03 transfer on the bulk lane; path selection per the routing table (LAN > Wi-Fi Direct > Bluetooth); bulk channel `0x02` plugged into the scheduler of A7.1a (the scheduler moved to A7.1a on 2026-10-28). *Dropped 2026-10-28:* bulk over the relay split in `RelayPeerMux`, bulk-demand op, relay hold rule | CLIP-03 regression over `/v1/ctl` first; then red: chunks before push, AC1 under a 5 MB bulk transfer, a 10 MiB image over Bluetooth only completes in the background (*dropped:* the two relay tests) | `android/core/transport`, `android/feature/clipboard` | CLIP-03 exceptions pass on every path; AC1 (LAN) unchanged; AC3 (LAN) unchanged | +| M7.4 [macOS] | Bulk lane on the Mac side, same scope as A7.4, on the scheduler of M7.1b (`ClipboardPeer` seam; the `RelayLink`/`RelayPeerChannel` split is dropped 2026-10-28) | Same order as A7.4 | `apple/Packages/HLTransport`, `apple/Packages/HLAppCore` (clipboard) | Same as A7.4 | +| A7.5 [android] | Wi-Fi Direct host (X only, on demand): group with an SSID and passphrase drawn at random for every group (0.4.6), `LEGACY_OR_R2` on API 36, started/stopped on request over the control lane, STA kept; `NEARBY_WIFI_DEVICES` (13+), `ACCESS_FINE_LOCATION` + location services (10–12); Wi-Fi off → report, never toggle | Format and charset vectors of S7.1d (*changed 2026-10-28: there is no derivation*); lifecycle tests with a fake `WifiP2pManager` (start, stop, crash, STA unchanged, stop after `DIRECT_WIFI_MAX`) | `android/feature/connection` (or a new `feature/directlink`) | AC4 verified on the S25; AC3 in X measured | | M7.5 [macOS] | Wi-Fi Direct join (X only): entry/exit rules of AC4 (on demand, idle ≥ `DIRECT_WIFI_IDLE_MIN`, no known network visible, periodic scan, `DIRECT_WIFI_MAX`), CoreWLAN join, gateway connect with the pinned certificate, socket bound to the P2P interface, saved network removed after leaving | Red first: never join while associated, never join when a known network is visible (deauth case), leave on known network, leave at max duration, saved network removed | `apple/Packages/HLTransport`, `apple/macOS/HandLive` | AC4 verified incl. the deauth scenario; AC3 in X ≤ 2 s | -| A7.6 [android] | Automatic classic bonding, **started right after QR pairing** (and once for existing pairs when the feature ships, or whenever the bond is missing) , flow from G7 ((b) or (c)): bonding request over E2E, `createBond` to the Mac address received over E2E, pairing value sent over E2E when readable | Fake-adapter tests per state; never bond without an authenticated session; no discoverable request | `android/feature/call` or `android/feature/connection` | AC5, AC10 measured; refusal and timeout covered | -| M7.6 [macOS] | Automatic classic bonding: `IOBluetoothDevicePair`; auto-confirm only when the value matches the E2E value, otherwise show the code for one comparison; hand the bonded device to the HFP stack | Confirm/reject decision tests with a fake delegate (match, mismatch, missing value) | `apple/Packages/…` (call audio), `apple/macOS/HandLive` | AC5, AC10; mismatch or missing value never auto-confirmed | -| A7.7 [android] | Permission onboarding (owner of AC7 on Android): SET-01 prompts for Nearby devices (12+), Location + location services (10–12, Wi-Fi Direct), explained by primers; Bluetooth-off request (`ACTION_REQUEST_ENABLE`) only when no LAN or relay path exists, at most once a day | Prompt-count tests per API level (29, 31, 33, 37) | `android/app`, `android/feature/connection` | AC7 per API level | -| M7.7 [macOS] | Permission onboarding (owner of AC7 on the Mac): Bluetooth and Location primers (SET-03), `NSBluetoothAlwaysUsageDescription`, Location usage text, entitlement `com.apple.security.personal-information.location`, release signing step; Bluetooth-off status line with a button to Bluetooth settings, same rule as A7.7 | Prompt-sequence tests; release-build entitlement check | `apple/macOS/HandLive`, `apple/macOS/HandLive.entitlements`, `docs/deployment-guide.md` (via D) | AC7 on the Mac; Location works in the notarized build | -| T7.1 [test] | End-to-end matrix: X and Y with S25 + Mac, same LAN, far apart; screen off, sleep/wake, Bluetooth off, phone Wi-Fi off, Location denied, relay down; **adversarial:** cloned advertiser, BLE sniff (AC9), replayed hello, forged pre-auth errors (AC8), pairing-slot hogging, deauth of the Mac's Wi-Fi (AC4), bonding MITM attempt (AC10); first and next image timing in X; a 10 MiB image over Bluetooth only with AC1 held; Bluetooth off on each side; Android 10–12 prompt count; en and vi; TalkBack/VoiceOver | `shared/tools/e2e` scenarios through the debug shim of A7.1, written first | `shared/tools/e2e/`, `reports/phase-07-T7.1.md` | AC1–AC10 met; Wi-Fi association recorded from the app's debug log before/after on both devices | +| A7.6 [android] | **Dropped 2026-10-28 (SP5; Phase 4 stopped, no HFP).** Was: automatic classic bonding, **started right after QR pairing** (and once for existing pairs when the feature ships, or whenever the bond is missing) , flow from G7 ((b) or (c)): bonding request over E2E, `createBond` to the Mac address received over E2E, pairing value sent over E2E when readable | Fake-adapter tests per state; never bond without an authenticated session; no discoverable request | `android/feature/call` or `android/feature/connection` | AC5, AC10 measured; refusal and timeout covered | +| M7.6 [macOS] | **Dropped 2026-10-28 (SP5; Phase 4 stopped, no HFP).** Was: automatic classic bonding: `IOBluetoothDevicePair`; auto-confirm only when the value matches the E2E value, otherwise show the code for one comparison; hand the bonded device to the HFP stack | Confirm/reject decision tests with a fake delegate (match, mismatch, missing value) | `apple/Packages/…` (call audio), `apple/macOS/HandLive` | AC5, AC10; mismatch or missing value never auto-confirmed | +| A7.7a [android] | *(Split 2026-10-28, lead decision; was A7.7.)* Bluetooth prompts, SP1 (owner of AC7 on Android, Bluetooth part): SET-01 prompt for Nearby devices (12+) with its primer; Bluetooth-off request (`ACTION_REQUEST_ENABLE`) only when no LAN path exists, at most once a day | Prompt-count tests per API level (31, 33, 37) | `android/app`, `android/feature/connection` | AC7 (Bluetooth) per API level | +| A7.7b [android] | Location prompts, SP4 (Wi-Fi Direct): Location + location services (10–12) with their primer | Prompt-count tests per API level (29, 31, 33, 37) | `android/app`, `android/feature/connection` | AC7 per API level | +| M7.7a [macOS] | *(Split 2026-10-28, lead decision; was M7.7.)* Bluetooth prompts, SP1 (owner of AC7 on the Mac, Bluetooth part): Bluetooth primer (SET-03), `NSBluetoothAlwaysUsageDescription`; Bluetooth-off status line with a button to Bluetooth settings, same rule as A7.7a | Prompt-sequence tests (Bluetooth) | `apple/macOS/HandLive` | AC7 (Bluetooth) on the Mac | +| M7.7b [macOS] | Location prompts, SP4: Location primer (SET-03), Location usage text, entitlement `com.apple.security.personal-information.location`, release signing step | Prompt-sequence tests; release-build entitlement check | `apple/macOS/HandLive`, `apple/macOS/HandLive.entitlements`, `docs/deployment-guide.md` (via D) | AC7 on the Mac; Location works in the notarized build | +| T7.1 [test] | End-to-end matrix: X and Y (near each other) with S25 + Mac, same LAN; screen off, sleep/wake, Bluetooth off, phone Wi-Fi off, Location denied; **adversarial:** cloned advertiser, BLE sniff (AC9), replayed hello, forged pre-auth errors (AC8), pairing-slot hogging, deauth of the Mac's Wi-Fi (AC4); first and next image timing in X; a 10 MiB image over Bluetooth only with AC1 held; Bluetooth off on each side; Android 10–12 prompt count; en and vi; TalkBack/VoiceOver. *Dropped 2026-10-28:* far apart, relay down, bonding MITM (AC10) | `shared/tools/e2e` scenarios through the debug shim of A7.1, written first | `shared/tools/e2e/`, `reports/phase-07-T7.1.md` | AC1–AC4 and AC6–AC9 met; Wi-Fi association recorded from the app's debug log before/after on both devices | ## Branch and work order -- Branch `feat/phase-07-connect-anywhere` in the hub, handlive-shared, handlive-android, handlive-apple (and - handlive-relay only for the R7.1 tests). -- T7.0 first and alone (after G4/G5/G6). Then D7.1 → S7.1 (every platform card waits for its spec, vectors, strings). -- Then in sub-project order: SP1 (A7.1 ∥ M7.1, with A7.7 ∥ M7.7 for the Bluetooth prompts) → SP2 (A7.2 ∥ M7.2) → SP3 - (A7.3 ∥ M7.3 unless already built, R7.1, then A7.4 ∥ M7.4) → SP4 (A7.5 ∥ M7.5) → SP5 (A7.6 ∥ M7.6, after the Phase 4 - HFP product cards) → T7.1. **If G7 chooses RFCOMM and it needs a bond, SP5 moves before SP1** and stops waiting for - Phase 4. +- Branch `feat/phase-07-connect-anywhere` in the hub, handlive-shared, handlive-android, handlive-apple (handlive-relay + no longer takes part: R7.1 dropped 2026-10-28). +- T7.0 first and alone (from 2026-10-28, no wait on G4/G5/G6). Then D7.1, then each shared card S7.1a–d just before + the sub-project that uses it (every platform card waits for its spec, vectors, strings). Platform cards are created + only after the G7 numbers are in. +- Then in sub-project order (*split 2026-10-28, lead decision*): + 1. SP1: S7.1a (T7.0a dropped 2026-10-28); M7.1a right away (pure state machine, needs no vectors); then A7.1a ∥ M7.1b, then + A7.1b ∥ M7.1c, with A7.7a ∥ M7.7a. On Android, A7.1b and A7.7a both touch `android/app` and + `feature/connection`: one after the other. S7.1b runs during SP1, so `cpace.json` is reviewed before SP2. + 2. SP2: A7.2a ∥ M7.2a (may start as soon as S7.1b is reviewed), then A7.2 ∥ M7.2. + 3. SP3: S7.1c; A7.3 ∥ M7.3 unless already built, then A7.4 ∥ M7.4. + 4. SP4: S7.1d; A7.5 ∥ M7.5, with A7.7b ∥ M7.7b. + 5. T7.1. +- Each sub-project ends with a measurement on the S25 + Mac before the next one opens: SP1 AC1 (idle and under load), + AC2, scan response; SP2 AC6. +- *Dropped 2026-10-28:* SP5 (A7.6 ∥ M7.6) and the rule "if RFCOMM needs a bond, SP5 moves before SP1" — an RFCOMM + bearer that needs a bond now fails G7 (see Bearer above). - One commit per logical step, `shared/` before the platform, never across repositories. ## Testing -- Tests first (per card, committed red before the code): vectors from S7.1, regression tests for the code a card - refactors (`serveRelayPeer`, CLIP-03 over `/v1/ctl`, `ConnectionStateMachine`, relay peer split), then the new - behavior, then the adversarial cases. +- Tests first (per card, committed red before the code): vectors from S7.1a–d, regression tests for the code a card + refactors (`serveRelayPeer`, CLIP-03 over `/v1/ctl`, `ConnectionStateMachine`), then the new behavior, then the + adversarial cases. - Regression gate after every card: Android `./gradlew check`; Apple `swift test` in the touched packages and - `xcodebuild test` for the app targets; relay `cargo test` and `cargo clippy`; hub `python3 + `xcodebuild test` for the app targets; hub `python3 tools/docs/validate_design_docs.py` and `tools/docs/check_bilingual_docs.py`; shared `check_schemas.py`, `check_strings.py`. -- Real devices: S25 Ultra + this Mac (macOS 27) for X and Y, plus a Mac on macOS 13 or 14 and an Android 10–12 phone +- Real devices: S25 Ultra + this Mac (macOS 27) for X and Y near each other, plus a Mac on macOS 13 or 14 and an Android 10–12 phone for the prompt and idle-detection checks; AC timings from `BenchLog` and the bench events of S7.1. ## Risks and rollback -- G7 fails (no bearer meets AC1) → stop Phase 7; LAN + relay stay the product answer. +- **G7 fails (no bearer meets AC1) → stop Phase 7, and there is no fallback:** the relay is not deployed (owner + decision 2026-10-28), so the product stays LAN only and scenarios X and Y keep "no connection". Reported to the owner + with the G7 numbers. - **Security:** an attacker in BLE range can try to clone the advertiser, replay hellos, hog pairing slots or forge pre-auth errors → covered by the link-security layer, the Bluetooth admission policy and the route-based pre-auth rule - (AC8, AC9), and tested adversarially in T7.1. A bond confirmed without verification would expose call audio → AC10. + (AC8, AC9), and tested adversarially in T7.1. (The bond risk behind AC10 is gone with SP5.) - One UI throttles background BLE → classic page-scan path or a visible "nearby" notification; measured in G7. -- IOBluetooth fragility (shared with HFP) → bearer behind the `MessageChannel` / `WebSocketSession` seam; BLE fallback. +- IOBluetooth fragility → bearer behind the `MessageChannel` / `WebSocketSession` seam; BLE fallback. - macOS prompts on joining or moves back to another network → Wi-Fi Direct stays opportunistic; images fall back to - Bluetooth or the relay; AC3 in X reported as measured. -- Relay upload too slow on mobile data → AC3 in Y reported as measured (accepted). -- Each sub-project ships behind a setting (`link.bluetooth`, `link.direct_wifi`, `link.auto_bond`, names fixed in D7.1) - so it can be turned off without a release. + Bluetooth; AC3 in X reported as measured. +- *Dropped 2026-10-28:* "relay upload too slow on mobile data" (no relay). +- Each sub-project ships behind a setting (`link.bluetooth`, `link.direct_wifi`, names fixed in D7.1; `link.auto_bond` + dropped with SP5) so it can be turned off without a release. ## Red Team Review @@ -160,21 +195,24 @@ Reviewers: Security Adversary (fact checker), Assumption Destroyer (scope audito | # | Finding | Severity | Disposition | Applied to | |---|---------|----------|-------------|------------| -| 1 | Forged pre-auth errors over Bluetooth could unpair or stop reconnecting (Mac code exempts only `route == .relay`) | Critical | Accept | D7.1 (route rule), M7.1, AC8, T7.1 | -| 2 | Scenario X blocked: the Mac leaves `Idle` only with a network and drops every session on network loss | Critical | Accept | D7.1 (0.11), M7.1 | +| 1 | Forged pre-auth errors over Bluetooth could unpair or stop reconnecting (Mac code exempts only `route == .relay`) | Critical | Accept | D7.1 (route rule), M7.1a, AC8, T7.1 | +| 2 | Scenario X blocked: the Mac leaves `Idle` only with a network and drops every session on network loss | Critical | Accept | D7.1 (0.11), M7.1a | | 3 | Unbonded BLE exposes identifiers, metadata and the pairing transcript; `pr` is a stable id | High | Accept | D7.1 (link-security layer, `pr` HMAC), AC9 | -| 4 | Pre-auth admission keyed by IP or `device_id` does not work on BLE; hellos replayable | High | Accept | D7.1 (admission), A7.1 | +| 4 | Pre-auth admission keyed by IP or `device_id` does not work on BLE; hellos replayable | High | Accept | D7.1 (admission), A7.1a | | 5 | Automatic bonding reverses AUDIO-01; MITM defence relied on an unproven capability | High | Accept (owner: verified auto-confirm only, flow (a) dropped) | G7, D7.1, A7.6, M7.6, AC5, AC10 | | 6 | Bulk over the relay had no channel split; cap raise needless; stream keys not bound per connection | High | Accept | D7.1 (bulk framing, keys), R7.1 (tests only), A7.4, M7.4, AC3 | | 7 | In Y the phone never joins the relay while a Bluetooth session exists | High | Accept | D7.1 (bulk demand), A7.4, M7.4 | | 8 | No stream channel infrastructure exists; chunks before push are dropped | High | Accept | A7.3, M7.3, plan decision I9, A7.4, M7.4 | -| 9 | Control and bulk shared one Bluetooth link without priority; AC1 never measured under load | High | Accept | D7.1 (channel ids, scheduler), AC1, A7.4 | +| 9 | Control and bulk shared one Bluetooth link without priority; AC1 never measured under load | High | Accept | D7.1 (channel ids, scheduler, control lanes), AC1, A7.1a, M7.1b | | 10 | RFCOMM needs the phone's address or a bond before SP1/SP2 | High | Accept | G7, work order | -| 11 | "Wi-Fi idle" unreliable without Location; deauth could push the Mac onto Wi-Fi Direct and keep it there | High | Accept | G7, AC4, A7.5, M7.5, M7.7, T7.1 | -| 12 | Offline pairing over BLE open to slot hogging; PIN blocked only indirectly | Medium | Accept | D7.1, A7.2, M7.2 | -| 13 | The e2e harness had no way to reach a Bluetooth pipe in the real app | Medium | Accept | A7.1 (debug shim), T7.1 | -| 14 | AC7 broken on Android 10–12 by Wi-Fi Direct's Location need | Medium | Accept (owner: relax AC7 for Android 10–12) | AC7, A7.5, A7.7 | -| 15 | AC1 loosened the LAN target; AC2 unmeasurable for Bluetooth; no security criteria | Medium | Accept | AC1, AC2, AC8–AC10, S7.1, G7, T7.1 | +| 11 | "Wi-Fi idle" unreliable without Location; deauth could push the Mac onto Wi-Fi Direct and keep it there | High | Accept | G7, AC4, A7.5, M7.5, M7.7b, T7.1 | +| 12 | Offline pairing over BLE open to slot hogging; PIN blocked only indirectly (2026-10-28: the PIN now runs over Bluetooth through CPace, online guessing only, 3 per PIN plus lockout) | Medium | Accept | D7.1, A7.2, M7.2 | +| 13 | The e2e harness had no way to reach a Bluetooth pipe in the real app | Medium | Accept | A7.1b (debug shim), T7.1 | +| 14 | AC7 broken on Android 10–12 by Wi-Fi Direct's Location need | Medium | Accept (owner: relax AC7 for Android 10–12) | AC7, A7.5, A7.7b | +| 15 | AC1 loosened the LAN target; AC2 unmeasurable for Bluetooth; no security criteria | Medium | Accept | AC1, AC2, AC8–AC10, S7.1a, G7, T7.1 | + +*Note 2026-10-28:* findings 5 (bonding) and 6, 7 (bulk over the relay) no longer apply — the cards they changed are +dropped (Validation Session 2). Finding 10 now resolves as "RFCOMM needing a bond fails G7". The table is kept as is. ### Whole-Plan Consistency Sweep @@ -222,7 +260,7 @@ failed 0, unverified 0; tier Light. - Bluetooth off: one-tap system request (Android) / status line with a settings button (Mac), only without LAN/relay, at most once a day. - Images over Bluetooth: no Bluetooth-specific size cap; the scheduler keeps AC1 during long transfers. -- HFP bonding starts right after QR pairing. +- HFP bonding starts right after QR pairing. *(Superseded 2026-10-28: SP5 dropped, see Session 2.)* #### Impact on Phases - AC3, AC4 (stay for further images), AC7 (Bluetooth-off request), A7.4 (10 MiB over Bluetooth), A7.6 (trigger), A7.7, @@ -232,3 +270,44 @@ failed 0, unverified 0; tier Light. - Searched all plan files, the roadmap and the brainstorm note for "2 s … offline", "on demand", "bonding", "Bluetooth off"; updated `plan.md`/`plan.vi.md` row 7, `docs/project-roadmap*`, the brainstorm notes. Unresolved contradictions: none. + +### Session 2 — 2026-10-28 (owner decision) +**Trigger:** the owner reported that HandLive is unusable when a device has no network; the lead asked three questions +(team chat, 2026-10-28). **Questions asked:** 3. + +#### Questions & Answers (owner's answers verbatim) + +1. Which case is the owner facing? — Options: Mac/iPhone fully offline, phone on 4G | Phone offline, Mac online | Both + on the same Wi-Fi without Internet | Phone on 4G, Mac on another Wi-Fi | Both offline + - **Answer:** «Cả hai đều không có mạng» (both offline). +2. Which direction? — Options: Short term, deploy the relay + hotspot guidance / gateway candidate | Hotspot only, no + relay yet | Reopen Phase 7 BLE now, skip the G4/G5/G6 condition | Report only + - **Answer:** «Mở lại Phase 7 BLE ngay, bỏ qua điều kiện G4/G5/G6» (reopen Phase 7 BLE now, skip the G4/G5/G6 + condition). +3. If the relay is deployed: does the owner have a VPS, an APNs key and a Firebase (FCM) project? — Options: All, will + send | Not yet, the team writes a preparation guide | No relay deployment + - **Answer:** «Không deploy relay» (no relay deployment). + +#### Confirmed Decisions +- Scenario X (both offline) is the target; Y is covered only while the devices are near each other, over Bluetooth. +- Phase 7 started on 2026-10-28; the 2026-10-01 rule "after G4, G5 and G6" is withdrawn. +- No relay in this phase: R7.1, relay routes, the relay parts of AC1/AC3, A7.4/M7.4 and D7.1 dropped. +- Phase 4 stopped (2026-10-12) → SP5 (A7.6/M7.6), AC5, AC10, G7 bonding and "during an HFP call" dropped; Session 1 + question 4 no longer applies. +- Order: T7.0 → D7.1 → S7.1 → SP1 (with A7.7/M7.7) → SP2 → SP3 → SP4 → T7.1; platform cards created after G7. + +#### Impact on Phases +- This file (goal, status, G7, AC1/AC3/AC5/AC8/AC10, routing, D7.1, S7.1, M7.1, R7.1, A7.4, M7.4, A7.6, M7.6, A7.7, + T7.1, work order, risks); `plan.md`/`plan.vi.md` status, row 7, effort line, gate G7; `docs/project-roadmap*` + row 7 and Phase 7 section. +- Risk accepted: if G7 fails there is no relay fallback; the product stays LAN only. + +### Card split — 2026-10-28 (lead decision, team chat) +- The scheduler (control before bulk) belongs to the control lane (G7 condition 1): moved from A7.4/M7.4 to + A7.1a/M7.1b. Long control envelopes (up to 256 KiB, about 20 s on the air) get their own long lane in 0.4.5, so they + never hold back a short one beyond AC1. +- SP1 split into M7.1a → A7.1a ∥ M7.1b → A7.1b ∥ M7.1c; S7.1 split into S7.1a–d, one per sub-project; CPace primitive + in its own cards A7.2a/M7.2a; A7.7/M7.7 split into the Bluetooth part (SP1) and the Location part (SP4); T7.0a + (scan-response probe) added before the advertisement vectors are frozen. +- Wi-Fi Direct `ssid`/`passphrase` are random per group (0.4.6): S7.1d and A7.5 check format and charset, no + derivation vectors. diff --git a/plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md b/plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md index 9e5ba89..1ea30bc 100644 --- a/plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md +++ b/plans/20260925-implementation/phase-07-ket-noi-moi-noi.vi.md @@ -2,53 +2,48 @@ # Phase 7 — Kết nối mọi nơi -**Mục tiêu:** sau lần ghép đôi QR đầu tiên, điện thoại và Mac tự kết nối với nhau ở bất cứ đâu — cả hai offline (kịch -bản X), hoặc điện thoại dùng dữ liệu di động còn Mac dùng Wi-Fi công cộng (kịch bản Y) — không có bước mạng thủ công nào -và **không máy nào phải rời hay đổi Wi-Fi hiện tại**. +**Mục tiêu:** sau lần ghép đôi QR đầu tiên, điện thoại và Mac ở gần nhau tự kết nối với nhau khi cả hai đều không có +mạng (kịch bản X, trường hợp chủ dự án cần giải) — không có bước mạng thủ công nào và **không máy nào phải rời hay đổi +Wi-Fi hiện tại**. - Khi không có LAN, một liên kết Bluetooth đã được bảo mật chở kênh điều khiển. - Một làn dữ liệu lớn chở ảnh qua đường nhanh nhất được phép: LAN, Wi-Fi Direct (chỉ theo nhu cầu, khi Wi-Fi của Mac - đang rảnh), relay, Bluetooth. -- HandLive cũng tự khởi động ghép đôi Bluetooth cổ điển cho HFP, và Mac chỉ tự xác nhận khi mã đã được kiểm đầu-cuối. + đang rảnh), Bluetooth (chạy nền). +- Cùng đường Bluetooth đó cũng phủ kịch bản Y (điện thoại dùng dữ liệu di động, Mac ở một Wi-Fi khác) khi hai máy ở gần + nhau. Phase này không có relay và không có ghép đôi HFP (xem phần thu gọn phạm vi bên dưới). -Trạng thái: chủ dự án đề xuất ngày 2026-10-01 (thiết kế trong `reports/phase-07-brainstorm.md`, nghiên cứu trong -`reports/research-direct-wifi-link-2026-10-01.md`); sửa lại cùng ngày sau vòng red team (mục cuối file). **Chỉ bắt -đầu sau khi đóng các gate G4, G5 và G6** (quyết định của chủ dự án 2026-10-01). Lập theo kiểu viết test trước: mọi thẻ -triển khai viết test trước khi viết code. +Trạng thái: **bắt đầu ngày 2026-10-28 theo quyết định của chủ dự án, không chờ các gate G4, G5 và G6** (Phiên xác thực 2). Chủ dự án đề xuất ngày 2026-10-01 (thiết kế trong `reports/phase-07-brainstorm.md`, nghiên cứu trong `reports/research-direct-wifi-link-2026-10-01.md`); sửa lại cùng ngày sau vòng red team (mục cuối file). Bỏ quy tắc ngày 2026-10-01 "chỉ bắt đầu sau khi đóng G4, G5 và G6". Lập theo kiểu viết test trước: mọi thẻ triển khai viết test trước khi viết code. + +**Thu gọn phạm vi 2026-10-28** (quyết định của chủ dự án: không deploy relay; Phase 4 đã dừng ngày 2026-10-12 nên không +có HFP): +- **Bỏ — relay:** R7.1; đường relay bảng định tuyến; phần relay kịch bản Y AC3; phần relay A7.4/M7.4 (bulk qua relay, op nhu cầu bulk, quy tắc giữ relay) và D7.1. Code relay hiện tại (CONN-03) giữ nguyên, phase không test. +- **Bỏ — ghép đôi HFP:** SP5 (A7.6/M7.6), AC5, AC10, câu hỏi ghép đôi G7, phép đo "trong cuộc gọi HFP" G7, thay đổi AUDIO-01 D7.1 và `link.auto_bond`. Mở lại khi Phase 4 tiếp tục. +- Dòng dưới giữ và đánh dấu, lịch sử red-team còn đọc được. ## Gate G7 — spike (khoảng một tuần, trước mọi thẻ việc khác) -Câu hỏi: lớp mang Bluetooth nào đạt mục tiêu giữa một điện thoại Samsung và một Mac; bảo mật liên kết Bluetooth thế nào; -có kiểm được việc ghép đôi đầu-cuối không; Mac có dùng nhóm Wi-Fi Direct của điện thoại một cách an toàn được không; và -tốn bao nhiêu? +Câu hỏi: lớp mang Bluetooth nào đạt mục tiêu giữa điện thoại Samsung và Mac; bảo mật liên kết Bluetooth thế nào; Mac có dùng nhóm Wi-Fi Direct của điện thoại an toàn được không; và tốn chi phí bao nhiêu? - **Lớp mang:** - - So BLE L2CAP CoC (`listenUsingInsecureL2capChannel` ↔ `CBPeripheral.openL2CAPChannel`) với RFCOMM cổ điển - (`listenUsingRfcommWithServiceRecord` ↔ `IOBluetoothDevice.openRFCOMMChannelSync`). - - Đo độ trễ một chiều 1 KB (p50/p95), tốc độ 1 MB và 5 MB, thời gian nối lại — khi tắt màn hình 10 phút, khi Doze, - khi đang có cuộc gọi HFP, và khi đang có một lượt truyền bulk. - - **RFCOMM chỉ được coi là khả thi nếu chứng minh được cách để Mac tới được điện thoại trước khi có bond** (app - Android không đọc được địa chỉ Bluetooth của chính nó; địa chỉ BLE là ngẫu nhiên). Nếu không, BLE chở SP1/SP2 và - Bluetooth cổ điển chỉ dùng cho HFP. -- **Chạy nền:** A-SVC (foreground service `connectedDevice`) vẫn quảng bá và nhận kết nối trên One UI khi tắt màn hình; - central trên Mac nối lại sau khi ngủ/thức. + - So sánh BLE L2CAP CoC (`listenUsingInsecureL2capChannel` ↔ `CBPeripheral.openL2CAPChannel`) với RFCOMM cổ điển (`listenUsingRfcommWithServiceRecord` ↔ `IOBluetoothDevice.openRFCOMMChannelSync`). + - Đo trễ một chiều 1 KB (p50/p95), tốc độ 1 MB và 5 MB, thời gian nối lại — khi tắt màn hình 10 phút, khi Doze, khi có lượt truyền bulk. (*Bỏ 2026-10-28:* "khi có cuộc gọi HFP" — không HFP sau Phase 4 dừng.) + - **RFCOMM chỉ được coi là khả thi khi đã chứng minh được cách để Mac tới điện thoại mà không cần bond nào** (app Android không đọc được địa chỉ Bluetooth của chính nó; địa chỉ BLE là ngẫu nhiên; HandLive không còn tạo bond vì đã bỏ SP5). Nếu không, BLE chở SP1/SP2. +- **Chạy nền:** A-SVC (foreground service `connectedDevice`) quảng bá và nhận kết nối trên One UI khi tắt màn hình; central trên Mac nối lại sau ngủ/thức. - **Câu hỏi bảo mật:** - L2CAP CoC chưa bond có mã hóa tầng link không (dự kiến: không → lớp bảo mật link của D7.1 là bắt buộc). - Một sniffer BLE thấy gì khi có lớp bảo mật link bản thử. - macOS có giữ mạng `DIRECT-hl-…` trong danh sách mạng đã biết sau khi rời không, và có xóa được không. -- **Ghép đôi (cho HFP):** thử luồng (b) — điện thoại `createBond` tới địa chỉ Mac gửi qua E2E +- **Ghép đôi (cho HFP) — Bỏ 2026-10-28 (đã bỏ SP5, Phase 4 đã dừng; giữ lại làm lịch sử):** thử luồng (b) — điện thoại `createBond` tới địa chỉ Mac gửi qua E2E (`IOBluetoothHostController.addressAsString`) — và luồng (c) — suy khóa chéo từ một liên kết BLE đã bond. **Bỏ luồng (a)** (bật chế độ cho phép tìm thấy), vì nó phát tên thiết bị và cho phép giả tên. Kiểm tra app Android có đọc được mã ghép đôi (`ACTION_PAIRING_REQUEST`) mà không cần quyền hệ thống không; kiểm tra `replyUserConfirmation` trên Mac. - **Wi-Fi Direct:** - - S25 tạo nhóm tự trị với SSID `DIRECT-hl-…` cố định, trong lúc điện thoại vẫn giữ Wi-Fi của mình (STA + P2P). - - Mac join bằng CoreWLAN từ bản đã notarize, có hardened runtime, đã được cấp quyền Location (entitlement - `com.apple.security.personal-information.location`). - - Ghi lại: thời gian join; các hộp thoại xuất hiện; macOS có tự quay về mạng khác không. - - **Tiêu chí đạt:** phân biệt đúng "đang kết nối hay rảnh" và "nhóm của mình hay mạng khác", trên macOS 27 và trên - macOS 13 hoặc 14, có quyền Location, đo từ chính app (không phải từ CLI). - - Không dùng `networksetup` với passphrase nằm trong danh sách tham số. -- **Kết quả** ghi vào `reports/phase-07-spike-g7.md`: lớp mang, luồng ghép đôi, cách join, bản thử lớp bảo mật link, - các số đo so với AC1–AC10 (chủ dự án xác nhận hoặc chỉnh AC5 và AC6). **Không lớp mang nào đạt AC1 trên liên kết đã - nối sẵn → dừng Phase 7** (giữ LAN + relay) và viết báo cáo. + - S25 tạo nhóm tự trị với SSID `DIRECT-hl-…` cố định, trong khi điện thoại giữ Wi-Fi của mình (STA + P2P). + - Mac join bằng CoreWLAN từ bản đã notarize, hardened runtime, cấp quyền Location (entitlement `com.apple.security.personal-information.location`). + - Đo: thời gian join; hộp thoại nào hiện lên; macOS có quay về mạng khác không. + - **Tiêu chí đạt:** phân biệt đúng "kết nối hay rảnh" và "nhóm của mình hay mạng khác", trên macOS 27 và 13 hoặc 14, có quyền Location, từ chính app (không CLI). + - Không dùng `networksetup` với passphrase trong danh sách tham số. +- **Kết quả** ghi vào `reports/phase-07-spike-g7.md`: lớp mang, cách join, bản thử lớp bảo mật link, các số đo so với + AC1–AC4 và AC6–AC9 (chủ dự án xác nhận hoặc chỉnh AC6). **Không lớp mang nào đạt AC1 trên liên kết đã nối sẵn → dừng + Phase 7** và viết báo cáo; khi đó sản phẩm chỉ còn LAN (không deploy relay nên không có đường dự phòng). ## Bối cảnh @@ -60,8 +55,8 @@ tốn bao nhiêu? - 0.6.3 (bắt tay phiên xác thực bằng `PRK`); 0.6.3 bước 7 (khóa stream, hiện chỉ có `camera`/`call-audio`); - 0.11 (máy trạng thái: chỉ rời `Idle` khi có mạng, mất mạng thì bỏ mọi phiên); - CONN-01 E4 và CONN-03 E9 (lỗi pre-auth: chỉ LAN có TLS ghim mới được hủy ghép đôi); PAIR-01; CLIP-03; - - AUDIO-01 (quy tắc "không bao giờ tự ghép đôi Bluetooth thay người dùng" — đổi theo quyết định của chủ dự án, xem - D7.1). + - AUDIO-01 (quy tắc "không bao giờ tự ghép đôi Bluetooth thay người dùng" — thay đổi ngày 2026-10-01 đã bỏ cùng SP5 + ngày 2026-10-28, nên quy tắc giữ nguyên). - **Mô hình tin cậy của relay không mang nguyên sang Bluetooth được:** relay che định danh sau TLS và xác thực `device_id` trước khi chuyển tiếp; liên kết BLE chưa bond thì không làm được việc nào. D7.1 thêm một lớp bảo mật link, một chính sách admission cho Bluetooth và một quy tắc pre-auth theo route. @@ -80,26 +75,26 @@ tốn bao nhiêu? | # | Tiêu chí | |---|---| -| AC1 | Envelope văn bản (văn bản bảng nhớ tạm; SMS và thông báo cuộc gọi đi cùng làn), từ lúc sao chép đến lúc dùng được, phân vị 95: **LAN < 50 ms (mục tiêu Phase 1, giữ nguyên)**; **Bluetooth ≤ 200 ms trên liên kết đã nối sẵn, kể cả khi đang có một lượt truyền bulk**; relay ở mức cố gắng tốt nhất | +| AC1 | Envelope văn bản (văn bản bảng nhớ tạm; SMS và thông báo cuộc gọi đi cùng làn), từ lúc sao chép đến lúc dùng được, phân vị 95: **LAN < 50 ms (mục tiêu Phase 1, giữ nguyên)**; **Bluetooth ≤ 200 ms trên liên kết đã nối sẵn, kể cả khi đang truyền bulk**; ~~relay ở mức cố gắng tốt nhất~~ (bỏ relay 2026-10-28) | | AC2 | Nối lại ≤ 3 s sau khi mạng thay đổi hoặc sau khi lại thấy thiết bị Bluetooth bên kia (sự kiện bench `bt_seen`/`bt_lost`) | -| AC3 | Ảnh 5 MB: ≤ 2 s trên LAN; trong X ảnh đầu tiên ≤ 10 s (gồm cả lúc join Wi-Fi Direct theo nhu cầu) và các ảnh sau ≤ 2 s khi nhóm còn mở; trong Y bị giới hạn bởi upload di động của điện thoại — giữ nguyên giới hạn relay (2 MiB/s mỗi cặp đã cao hơn uplink thông thường), đo và báo cáo; chỉ có Bluetooth: truyền nền, không có mục tiêu thời gian và không có giới hạn dung lượng riêng cho Bluetooth (áp giới hạn ảnh của CLIP-03) | -| AC4 | Bất biến: không máy nào bị đổi hoặc mất kết nối Wi-Fi hiện tại. Mac chỉ join Wi-Fi Direct khi có ảnh đang chờ, Wi-Fi của nó đã không kết nối mạng nào trong ≥ `DIRECT_WIFI_IDLE_MIN`, và quét không thấy mạng quen nào; nó ở lại nhóm cho các ảnh tiếp theo và rời khi một lần quét định kỳ thấy mạng quen, hoặc sau `DIRECT_WIFI_MAX`; HandLive không bao giờ tự bật/tắt radio và không ở lại nhóm khi không có lý do | -| AC5 | Ghép đôi HFP do HandLive khởi động: ≤ 1 chạm trên điện thoại; Mac chỉ tự xác nhận khi mã ghép đôi khớp với mã nhận qua E2E, nếu không thì người dùng so một mã | +| AC3 | Ảnh 5 MB: ≤ 2 s trên LAN; trong X, ảnh đầu ≤ 10 s (gồm cả thời gian join Wi-Fi Direct theo nhu cầu) và các ảnh sau ≤ 2 s khi nhóm còn mở; ~~trong Y bị giới hạn bởi tốc độ upload di động của điện thoại qua relay~~ (bỏ 2026-10-28, không có relay: trong Y, khi hai máy ở gần nhau, ảnh đi qua Bluetooth); chỉ còn Bluetooth: truyền nền, không có mục tiêu thời gian và không có giới hạn dung lượng riêng cho Bluetooth (vẫn áp giới hạn ảnh của CLIP-03) | +| AC4 | Bất biến: không máy nào bị đổi hay mất kết nối Wi-Fi hiện tại. Mac chỉ join Wi-Fi Direct khi có ảnh đang chờ, Wi-Fi của Mac đã không kết nối mạng nào trong ≥ `DIRECT_WIFI_IDLE_MIN` và lượt quét không thấy mạng quen nào; Mac ở lại nhóm cho các ảnh tiếp theo và rời khi một lượt quét định kỳ thấy mạng quen, hoặc sau `DIRECT_WIFI_MAX`; HandLive không bao giờ tự bật/tắt radio và không ở lại nhóm khi không có lý do | +| AC5 | **Bỏ 2026-10-28 (đã bỏ SP5, không có HFP).** Trước đây: ghép đôi HFP do HandLive khởi động: ≤ 1 chạm trên điện thoại; Mac chỉ tự xác nhận khi mã ghép đôi khớp với mã nhận qua E2E, nếu không thì người dùng so một mã | | AC6 | Ghép đôi lần đầu khi cả hai offline (QR qua BLE): ≤ 15 s từ lúc quét đến lúc ghép đôi xong (G7 xác nhận hoặc chỉnh) | | AC7 | Chỉ còn các hộp thoại xin quyền một lần: Mac — Local Network, Bluetooth, Location; Android 12+ — Nearby devices; **Android 10–12 — thêm Location và bật dịch vụ vị trí, cho Wi-Fi Direct (quyết định của chủ dự án 2026-10-01)**; cộng các quyền đã có; cộng một yêu cầu hệ thống một chạm để bật Bluetooth khi không còn đường nào khác, tối đa một lần mỗi ngày | -| AC8 | Không thông điệp pre-auth nào trên một route không có TLS ghim tới điện thoại (Bluetooth, relay) được làm thay đổi trạng thái cặp, xóa cặp hay dừng việc nối lại | -| AC9 | Một sniffer BLE không thấy định danh cố định nào (`pair_id`, `device_id`, khóa, tên, `pr`) và không thấy `type`, `id` hay nhịp thời gian theo loại của envelope | -| AC10 | Không liên kết Bluetooth nào được tự xác nhận nếu chưa có mã ghép đôi đã kiểm qua E2E | +| AC8 | Thông điệp pre-auth trên route không TLS ghim tới điện thoại (Bluetooth; cũng relay hiện tại) không làm đổi trạng thái cặp, xóa cặp hay dừng nối | +| AC9 | Sniffer BLE không thấy định danh cố định nào (`pair_id`, `device_id`, khóa, tên, `pr`) và không thấy `type`, `id` hay nhịp thời gian theo từng loại envelope | +| AC10 | **Bỏ 2026-10-28 (đã bỏ SP5, HandLive không tạo bond nào).** Trước đây: không bond Bluetooth nào được tự xác nhận nếu chưa có mã ghép đôi đã kiểm qua E2E | -Quy tắc định tuyến (làn điều khiển LAN > Bluetooth > relay; làn dữ liệu lớn LAN > Wi-Fi Direct > relay > Bluetooth): +Quy tắc định tuyến (2026-10-28: làn điều khiển LAN > Bluetooth; làn dữ liệu lớn LAN > Wi-Fi Direct (chỉ X, theo quy tắc AC4) > Bluetooth chạy nền). Bỏ các bậc relay ngày 2026-10-01 (điều khiển "> relay", dữ liệu lớn "> relay >"). | Tình huống | Làn điều khiển | Làn dữ liệu lớn | |---|---|---| | Chung LAN | LAN | LAN | | X, Wi-Fi Mac rảnh, có ảnh đang chờ | Bluetooth | Wi-Fi Direct (theo nhu cầu, quy tắc AC4), nếu không được thì Bluetooth (chạy nền) | | X, Mac đang ở một Wi-Fi không có Internet | Bluetooth | Bluetooth (chạy nền) | -| Y, ở gần nhau | Bluetooth | Relay (lên relay khi có nhu cầu bulk, D7.1), nếu không được thì Bluetooth (chạy nền) | -| Ở xa, cả hai online | Relay | Relay | +| Y, ở gần nhau | Bluetooth | Bluetooth (chạy nền) — *trước đây là relay khi có nhu cầu bulk, bỏ 2026-10-28* | +| Ở xa nhau | Phase này không có đường nào — *trước đây là relay, bỏ 2026-10-28* | Không có đường | ## Thẻ việc @@ -108,71 +103,87 @@ và commit (ở trạng thái đỏ) trước code của thẻ đó. | Mã | Việc | Test viết trước | Đầu ra | Tiêu chí nghiệm thu | |------|------|-------------|---------|---------------------| -| T7.0 [test] | Spike gate G7 (ở trên): probe dùng một lần trên Android và macOS | — (đo đạc) | `reports/phase-07-spike-g7.md`; probe chỉ ở nhánh spike, không bao giờ merge | Đã chọn lớp mang, luồng ghép đôi, cách join và bản thử lớp bảo mật link; đã đo các con số AC; chủ dự án xác nhận AC5/AC6 | -| D7.1 [docs] | Spec trước, cả hai ngôn ngữ. **0.11 và CONN:** điều kiện khởi động "có mạng hoặc Bluetooth đang bật và đã được cấp quyền"; mất mạng chỉ kết thúc phiên LAN/relay; chỉ phản ứng theo lỗi pre-auth trên route có TLS ghim tới điện thoại (`phoneAuthenticatedByTLS`), viết lại CONN-01 E4 và CONN-03 E9 theo quy tắc đó. **0.4.5 liên kết Bluetooth:** quảng bá (service UUID, gợi ý xoay vòng; đổi địa chỉ cùng lúc với gợi ý); lớp bảo mật link trước bất kỳ định danh nào (ví dụ bắt tay kiểu Noise khóa bằng `K_auth`, hoặc định danh mù hóa trên một nonce do điện thoại cấp) và một mã lỗi pre-auth chung; admission (trần toàn cục, một link pre-auth, nonce challenge từ điện thoại, lỗi xác nhận khóa tính như `AUTH_FAILED`); frame = channel id ‖ độ dài ‖ byte, điều khiển đi trước bulk, mảnh bulk vài KB, không có close code (kết thúc bằng `session/bye` đã mã hóa). **Ghép đôi qua BLE:** `pr` = HMAC(`ps`, …), frame đầu tiên có MAC bằng khóa từ `ps` trước khi cấp slot, một slot toàn cục với timeout ngắn, từ chối PIN theo loại transport. **Bulk:** bắt tay stream cho `bulk` với khóa riêng mỗi kết nối (`K_stream` + cả hai nonce); cả lượt truyền (push, chunk, ack) đi trên làn bulk; bulk nằm trong frame nhị phân HL có byte kênh đã mã hóa (không đổi wire của relay); op nhu cầu bulk và quy tắc giữ relay khi làn điều khiển đi Bluetooth. **Wi-Fi Direct (0.4.6):** suy SSID/passphrase (passphrase ≥ 128 bit), quy tắc vào/ra của AC4, xóa mạng đã lưu sau khi rời, socket HandLive chỉ gắn vào giao diện P2P. **Ghép đôi (AUDIO-01, quyết định của chủ dự án 2026-10-01):** HandLive được khởi động ghép đôi; Mac chỉ tự xác nhận khi mã đã kiểm qua E2E; trao địa chỉ chỉ theo chiều Mac → điện thoại; cập nhật lời giải thích cho người dùng. Cộng: hằng số, khóa cài đặt, mã lỗi, SET-01/02/03, leaf CONN-05/CONN-06, PAIR-01, CLIP-03 | — (hợp đồng; vector của S7.1 là test của nó) | `docs/detailed-design/*.md` + `.vi.md` | `validate_design_docs.py` in `problems=0`; `check_bilingual_docs.py` xanh; mọi loại message, mã lỗi, hằng số và chuỗi mới được định nghĩa trước khi viết code; AC8–AC10 truy được về các quy tắc trong spec | -| S7.1 [shared] | Dữ liệu hợp đồng: test vector — bắt tay lớp bảo mật link, codec frame có channel id, service data quảng bá, HMAC `pr`, SSID/passphrase Wi-Fi Direct, khóa `bulk` riêng mỗi kết nối, việc gắn mã ghép đôi; JSON schema cho các op mới; chuỗi giao diện; sự kiện bench `bt_seen`/`bt_lost`/`wifi_assoc` và trigger cho chúng trong `reconnect_time.py` | Chính các vector và mẫu âm là test: generator tạo ra, `check_*` kiểm tra | `shared/test-vectors/`, `shared/schemas/`, `shared/tools/` (gồm `bench/`), `shared/strings/` | Generator tái tạo được; `check_schemas.py`, `check_strings.py` xanh; báo các nền tảng khác chạy lại test | -| A7.1 [android] | Liên kết Bluetooth cho làn điều khiển: bộ quảng bá trong A-SVC, server cho lớp mang chọn ở G7, lớp bảo mật link, admission cho Bluetooth, `BluetoothPeerSocket` (`WebSocketSession` ảo như `RelayPeerSocket`) phục vụ qua `ControlServer.serveBluetoothPeer`, codec frame có channel id; một **shim loopback chỉ có ở bản debug** cho bộ e2e | Test hồi quy cho `serveRelayPeer`; test vector codec và lớp bảo mật link; test qua ống giả: bắt tay, rekey, hello bị phát lại, địa chỉ xoay vòng, trần pre-auth; test bản release khẳng định không có shim | `android/core/transport`, `android/feature/connection`, `android/app` | Mọi test xanh; phủ các ngoại lệ của CONN-05; đo AC1 (Bluetooth) trên S25 | -| M7.1 [macOS] | Liên kết Bluetooth cho làn điều khiển: central (CoreBluetooth hoặc IOBluetooth theo G7), lớp bảo mật link, `BluetoothChannel: MessageChannel`, đường `.bluetooth`; thay đổi 0.11 (khởi động khi không có mạng, mất mạng theo từng route, `phoneAuthenticatedByTLS` thay cho `route == .relay`); ưu tiên LAN > Bluetooth > relay có nâng cấp; các `switch` theo route trong các package giao diện | Đỏ trước: `networkLost` khi đang `connected(.bluetooth)` vẫn giữ kết nối; `idle(noNetwork)` + thấy thiết bị bên kia → đang kết nối; `PAIR_UNKNOWN`/`PAIR_REVOKED`/`UNSUPPORTED_VERSION`/`AUTH_FAILED` giả mạo trước khi xác thực qua Bluetooth không bao giờ hủy ghép đôi hay dừng nối lại; vector codec; test kênh trong bộ nhớ | `apple/Packages/HLTransport`, `apple/Packages/HLMacUI`, `apple/Packages/HLiOSUI`, `apple/macOS/HandLive` | Mọi test xanh; bản iOS build và chạy như cũ; đo AC1 (Bluetooth), AC2, AC8 | -| A7.2 [android] | Ghép đôi lần đầu khi offline: quảng bá `pr` (dạng HMAC) trong chế độ QR; chỉ cấp slot ghép đôi duy nhất sau một frame đầu có MAC `ps` hợp lệ; PAIR-01 qua liên kết Bluetooth đã bảo mật; từ chối PIN qua Bluetooth một cách tường minh | Vector PAIR-01 qua ống giả; test âm: PIN qua Bluetooth, MAC frame đầu thiếu/sai, chiếm giữ slot, người thứ hai tranh slot | `android/feature/pairing`, `android/core/transport` | Phủ các ngoại lệ PAIR-01 qua Bluetooth; đo AC6 | -| M7.2 [macOS] | Ghép đôi lần đầu khi offline: quét `pr`, thử lần lượt mọi ứng viên có cùng `pr`, chạy PAIR-01 qua `BluetoothChannel` đã bảo mật, rồi ghim và lưu như hiện nay | Cùng bộ vector; timeout, `pr` sai, máy quảng bá giả có đúng `pr` | `apple/Packages/HLTransport` (`Pairing*`) | Đo AC6; không đổi định dạng QR | +| T7.0 [test] | Spike gate G7 (ở trên): probe dùng một lần trên Android và macOS | — (đo đạc) | `reports/phase-07-spike-g7.md`; probe chỉ ở nhánh spike, không bao giờ merge | Đã chọn lớp mang, cách join và bản thử lớp bảo mật link; đã đo các con số AC; chủ dự án xác nhận AC6 (bỏ luồng ghép đôi và AC5 ngày 2026-10-28) | +| ~~T7.0a~~ [test] | *(Thêm 2026-10-28 theo quyết định của Lead.)* Probe scan response: các probe dùng một lần của T7.0, với bố cục của 0.4.5 (`BT_SERVICE_UUID` 128-bit trong gói quảng bá, service data trong scan response) trên S25 + Mac, Mac quét cả khi ở tiền cảnh lẫn chạy nền | — (đo đạc) | Một mục trong `reports/phase-07-spike-g7.md`; probe chỉ nằm trên nhánh spike, không bao giờ merge | **Bỏ 2026-10-28 (sếp quyết định): scan response được kiểm trong A7.1b/M7.1c trên máy thật.** Trước đây: Mac nhận được service data trong cả hai trường hợp; nếu không, bố cục quảng bá của 0.4.5 đổi (D) trước khi S7.1a chốt vector | +| D7.1 [docs] | Spec trước, cả hai ngôn ngữ. **0.11 và CONN:** điều kiện khởi động "có mạng hoặc Bluetooth đang bật và đã được cấp quyền"; mất mạng chỉ kết thúc phiên LAN/relay; chỉ phản ứng theo lỗi pre-auth trên route có TLS ghim tới điện thoại (`phoneAuthenticatedByTLS`), viết lại CONN-01 E4 và CONN-03 E9 theo quy tắc đó. **0.4.5 liên kết Bluetooth:** quảng bá (service UUID, gợi ý xoay vòng; đổi địa chỉ cùng lúc với gợi ý); lớp bảo mật link trước bất kỳ định danh nào (ví dụ bắt tay kiểu Noise khóa bằng `K_auth`, hoặc định danh mù hóa trên một nonce do điện thoại cấp) và một mã lỗi pre-auth chung; admission (trần toàn cục, một link pre-auth, nonce challenge từ điện thoại, lỗi xác nhận khóa tính như `AUTH_FAILED`); frame = channel id ‖ độ dài ‖ byte, điều khiển đi trước bulk, mảnh bulk vài KB, không có close code (kết thúc bằng `session/bye` đã mã hóa). **Ghép đôi qua BLE:** `pr` = HMAC(`ps`, …), frame đầu tiên có MAC bằng khóa từ `ps` trước khi cấp slot, một slot toàn cục với timeout ngắn; ~~từ chối PIN theo loại transport~~ *đổi ngày 2026-10-28 (sếp quyết định): PIN ghép qua Bluetooth bằng CPace (`CPACE-X25519-SHA512`), một lần đoán trực tuyến mỗi kênh, 3 lần mỗi PIN, có khóa tạm (D7.1e, luồng C của PAIR-01)*. **Bulk:** bắt tay stream cho `bulk` với khóa riêng mỗi kết nối (`K_stream` + cả hai nonce); cả lượt truyền (push, chunk, ack) đi trên làn bulk qua LAN, Wi-Fi Direct hoặc Bluetooth (*bỏ 2026-10-28:* bulk trong frame nhị phân HL của relay, op nhu cầu bulk, quy tắc giữ relay). **Wi-Fi Direct (0.4.6):** SSID/passphrase (sinh ngẫu nhiên cho mỗi group, passphrase ≥ 128 bit), quy tắc vào/ra của AC4, xóa mạng đã lưu sau khi rời, socket HandLive chỉ gắn vào giao diện P2P. ~~**Ghép đôi (AUDIO-01)**~~ *bỏ 2026-10-28 cùng SP5; AUDIO-01 giữ nguyên.* Cộng: hằng số, khóa cài đặt, mã lỗi, SET-01/02/03, leaf CONN-05/CONN-06, PAIR-01, CLIP-03 | — (hợp đồng; vector của S7.1 là test của nó) | `docs/detailed-design/*.md` + `.vi.md` | `validate_design_docs.py` in `problems=0`; `check_bilingual_docs.py` xanh; mọi loại message, mã lỗi, hằng số và chuỗi mới được định nghĩa trước khi viết code; AC8–AC9 truy được về các quy tắc trong spec | +| S7.1a [shared] | *(Tách 2026-10-28 theo quyết định của Lead; trước là S7.1.)* Cho SP1, hợp đồng link: bắt tay lớp bảo mật link (`challenge`, `noise1`, `noise2`, prologue gắn `n_s`, `K_bt_disc`/`K_bt_link`), codec bản ghi (giới hạn `rlen`; 272, 1 040, 4 112) và codec frame (channel id, `more`/`long`, làn ngắn và làn dài của kênh điều khiển, đệm) kèm mẫu âm (`rlen` vượt giới hạn, `chan` lạ, `more` mà không có `long`, mảnh của làn dài vượt `BT_BULK_FRAGMENT_MAX`, message vượt `BT_MESSAGE_MAX`), service data quảng bá (chốt ngay theo bảng 0.4.5; kiểm trên máy thật ở A7.1b/M7.1c); chuỗi giao diện cho hộp thoại Bluetooth và dòng báo Bluetooth tắt; sự kiện bench `bt_seen`/`bt_lost` và trigger cho chúng trong `reconnect_time.py` | Chính các vector và mẫu âm là test: generator tạo ra, `check_*` kiểm tra | `shared/test-vectors/`, `shared/strings/`, `shared/tools/` (gồm `bench/`) | Generator tái tạo được; `check_schemas.py`, `check_strings.py` xanh; báo các nền tảng khác chạy lại test | +| S7.1b [shared] | Cho SP2, làm trong lúc SP1 chạy: HMAC `pr_bt`, `K_bt_pdisc`/`K_bt_plink`, `BT_PIN_HINT`; CPace cho PIN qua Bluetooth (`cpace.json`, Kotlin và Swift: vector `CPACE-X25519-SHA512` của chính bản draft, rồi các ca HandLive và mẫu âm của 0.6.1), `K_bt_pinlink`/`K_bt_pin`; schema cho envelope ghép đôi trên link Bluetooth; chuỗi cho PIN qua Bluetooth và `BT_PIN_LOCKOUT` | Chính các vector và mẫu âm là test: generator tạo ra, `check_*` kiểm tra | `shared/test-vectors/`, `shared/schemas/`, `shared/strings/` | Generator tái tạo được; `check_schemas.py`, `check_strings.py` xanh; báo các nền tảng khác chạy lại test; pentester đã duyệt `cpace.json` trước khi A7.2a/M7.2a bắt đầu | +| S7.1c [shared] | Cho SP3: khóa `bulk` riêng mỗi kết nối (`K_bulk` từ `K_stream` và hai nonce, 0.4.7); schema cho `bulk/stream_hello` và `bulk/stream_welcome` | Chính các vector và mẫu âm là test: generator tạo ra, `check_*` kiểm tra | `shared/test-vectors/`, `shared/schemas/` | Generator tái tạo được; `check_schemas.py`, `check_strings.py` xanh; báo các nền tảng khác chạy lại test | +| S7.1d [shared] | Cho SP4: `ssid` và `passphrase` Wi-Fi Direct sinh ngẫu nhiên cho mỗi group (0.4.6), nên không có vector suy khóa: mẫu kiểm định dạng và bộ ký tự, cả dương lẫn âm (tiền tố `DIRECT-`, độ dài, bảng chữ, entropy của passphrase); schema cho `direct/start`, `direct/stop`, `direct/want` và ack của `direct/start` `{ssid, passphrase, host, port}` (00-common-specs 0.7.1); sự kiện bench `wifi_assoc`; chuỗi cho hộp thoại Location | Chính các vector và mẫu âm là test: generator tạo ra, `check_*` kiểm tra | `shared/test-vectors/`, `shared/schemas/`, `shared/strings/`, `shared/tools/bench/` | Generator tái tạo được; `check_schemas.py`, `check_strings.py` xanh; báo các nền tảng khác chạy lại test | +| A7.1a [android] | *(Tách 2026-10-28 theo quyết định của Lead; trước là A7.1.)* Lõi link, chưa có radio: codec bản ghi và codec frame, mọi độ dài được kiểm trước khi cấp phát; làn ngắn và làn dài của kênh điều khiển cùng việc ráp mảnh; bộ lập lịch của 0.4.5 "Ưu tiên" (điều khiển ngắn > điều khiển dài > bulk, `BT_BULK_INFLIGHT`; chuyển từ A7.4 sang đây vì điều kiện 1 của G7 coi nó là một phần của làn điều khiển); lớp bảo mật link (bên đáp Noise, thử psk của từng cặp, rekey link); admission Bluetooth cho cả listener (`BT_LINK_LIMIT`, `BT_PREAUTH_LIMIT`, `BT_PREAUTH_TIMEOUT`, `BT_AUTH_FAIL_BLOCK`; không bao giờ theo địa chỉ) | Vector codec và lớp bảo mật link của S7.1a; test qua ống giả: bắt tay, `noise1` phát lại trên kênh mới, rekey link, trần pre-auth, chặn sau `BT_AUTH_FAIL_BLOCK`, `rlen` quá cỡ, envelope ngắn gửi sau một envelope 256 KiB vẫn tới trước, hai envelope cùng `type` giữ thứ tự, giới hạn số bản ghi chưa đi hết | `android/core/transport` | Mọi test xanh | +| A7.1b [android] | Radio và nối dây: bộ quảng bá và L2CAP server trong A-SVC (xoay vòng mỗi `BT_HINT_PERIOD`, dựng lại khi `STATE_ON`, độc lập với server LAN và với mạng), `SessionTransport.BLUETOOTH` và mọi `when` theo nó (`PeerSession.Channel` và nơi dùng nó trong các module feature), `BluetoothPeerSocket` (`WebSocketSession` ảo như `RelayPeerSocket`) phục vụ qua `ControlServer.serveBluetoothPeer`, `session/bye` trước mọi lần đóng trên Bluetooth; một **shim loopback chỉ có ở bản debug** cho bộ e2e (không cần cho phép đo SP1, có thể làm sau cùng) | Test hồi quy cho `serveRelayPeer`; test phiên qua ống giả; xoay vòng và dựng lại khi `STATE_ON` với adapter giả; test bản release khẳng định không có shim | `android/core/transport`, `android/feature/connection`, `android/feature/*` (chỉ phần ánh xạ route), `android/app` | Mọi test xanh; phủ các ngoại lệ của CONN-05; đo AC1 (Bluetooth) trên S25, khi rảnh và khi có tải điều khiển dài giả (chỉ bản debug) qua bộ lập lịch; scan response chứa service data đọc được trên Mac, kể cả khi app ở nền | +| M7.1a [macOS] | *(Tách 2026-10-28 theo quyết định của Lead; trước là M7.1.)* Máy trạng thái 0.11, thuần logic, thẻ đầu tiên của SP1: route `.bluetooth` và `ConnectingBT`; khởi động khi không có mạng nếu Bluetooth dùng được; mất mạng chỉ kết thúc phiên LAN/relay; `phoneAuthenticatedByTLS` thay cho `route == .relay` (`ConnectionManager+Run`, `ConnectionManager+Supervise`, `ControlSession`); ưu tiên LAN > Bluetooth có nâng cấp (route relay hiện có giữ nguyên trong code, xếp cuối, không deploy); các `switch` theo route đầy đủ trong các package đọc route (`HLMacUI`, `HLiOSUI`, `HLSMS`, `HLDesignSystem`, `Tools/HandLiveDevClient`) | Đỏ trước: `networkLost` khi đang `connected(.bluetooth)` vẫn giữ kết nối; `idle(noNetwork)` + thấy thiết bị bên kia → đang kết nối; `PAIR_UNKNOWN`/`PAIR_REVOKED`/`UNSUPPORTED_VERSION`/`AUTH_FAILED` giả mạo trước khi xác thực qua Bluetooth không bao giờ hủy ghép đôi hay dừng nối lại; phiên LAN thay phiên Bluetooth | `apple/Packages/HLTransport`, `apple/Packages/HLMacUI`, `apple/Packages/HLiOSUI`, `apple/Packages/HLSMS`, `apple/Packages/HLDesignSystem`, `apple/Tools/HandLiveDevClient` | Mọi test xanh; bản iOS build và chạy như cũ | +| M7.1b [macOS] | Lõi link, chưa có radio, phía khởi tạo của A7.1a: codec bản ghi và codec frame, làn ngắn và làn dài của kênh điều khiển, bộ lập lịch, bên khởi tạo lớp bảo mật link (`noise1`, kiểm `noise2`, rekey link), `BluetoothChannel: MessageChannel` trên ống trong bộ nhớ | Vector codec và lớp bảo mật link của S7.1a; test kênh trong bộ nhớ (danh sách của A7.1a, phía khởi tạo) | `apple/Packages/HLTransport` | Mọi test xanh | +| M7.1c [macOS] | Radio và nối dây: central CoreBluetooth (quét với bộ lọc `BT_SERVICE_UUID`, so hint trong `e − 4`…`e + 4`, `openL2CAPChannel`), chỉ app macOS tạo và inject (iOS không bao giờ tạo), route `.bluetooth` chạy thật trong `ConnectionManager` | Test với central giả: khớp hint, PSM đã xoay, ứng viên kế tiếp sau khi `noise2` hỏng (CONN-05 E3), mất kết nối → `Backoff` | `apple/Packages/HLTransport`, `apple/macOS/HandLive` | Mọi test xanh; bản iOS build và chạy như cũ; đo AC1 (Bluetooth), AC2, AC8; scan response chứa service data đọc được trên Mac, kể cả khi app ở nền | +| A7.2a [android] | *(Thêm 2026-10-28 theo quyết định của Lead.)* Primitive CPace `CPACE-X25519-SHA512`, chưa có Bluetooth: tự viết số học trường mod 2^255 − 19 chạy thời gian hằng (không `BigInteger`, không rẽ nhánh hay tra bảng theo dữ liệu bí mật), ánh xạ Elligator2 của RFC 9380, `calculate_generator`, `ISK` | Toàn bộ vector `cpace.json` của S7.1b, gồm cả mẫu âm | `android/core/crypto` | Mọi vector qua; pentester đã duyệt phần code thời gian hằng | +| A7.2 [android] | Ghép đôi lần đầu khi offline: quảng bá `pr` (dạng HMAC) trong chế độ QR; chỉ cấp slot ghép đôi duy nhất sau một frame đầu có MAC `ps` hợp lệ; PAIR-01 qua liên kết Bluetooth đã bảo mật; PIN qua Bluetooth bằng CPace trên primitive của A7.2a (luồng C của PAIR-01: quảng bá `BT_PIN_HINT`, `cpace1`/`cpace2`, đếm lần thử, `BT_PIN_LOCKOUT`) — *đổi ngày 2026-10-28 theo quyết định của sếp, trước là "từ chối PIN qua Bluetooth một cách tường minh"* | Vector PAIR-01 qua ống giả; vector CPace của S7.1 (`cpace.json`); test âm: PIN sai (3 lần rồi khóa tạm), `Ya` bậc thấp, `cpace1` ngoài cửa sổ PIN, `mode` không khớp loại link, MAC frame đầu thiếu/sai, chiếm giữ slot, người thứ hai tranh slot | `android/feature/pairing`, `android/core/transport` | Phủ các ngoại lệ PAIR-01 qua Bluetooth; đo AC6 | +| M7.2a [macOS] | *(Thêm 2026-10-28 theo quyết định của Lead.)* Primitive CPace, cùng phạm vi với A7.2a (CryptoKit không có số học trường) | Như A7.2a | `apple/Packages/HLCrypto` | Như A7.2a | +| M7.2 [macOS] | Ghép đôi lần đầu khi offline: quét `pr`, thử lần lượt mọi ứng viên có cùng `pr`, chạy PAIR-01 qua `BluetoothChannel` đã bảo mật, rồi ghim và lưu như hiện nay; PIN: quét `BT_PIN_HINT`, làm bên khởi tạo CPace trên primitive của M7.2a, đếm số lần sai PIN, sinh PIN mới khi tới 3 (*thêm ngày 2026-10-28, sếp quyết định*) | Cùng bộ vector cộng `cpace.json`; timeout, `pr` sai, máy quảng bá giả có đúng `pr`, điện thoại ở chế độ PIN với PIN khác, kẻ tấn công trả lời `cpace1` | `apple/Packages/HLTransport` (`Pairing*`) | Đo AC6; không đổi định dạng QR | | A7.3 [android] | Hạ tầng stream channel: server `/v1/stream/*` trên A-SVC (`stream_hello`/`stream_welcome`, 0.6.3 bước 7) — **dùng lại nếu một thẻ sản phẩm của Phase 4 hoặc 5 đã xây trước** (quyết định I9 của plan) | Test vector bắt tay stream (viết trước) | `android/core/transport`, `android/core/crypto` | Bắt tay stream chạy trên LAN; `camera`/`call-audio` không bị ảnh hưởng | | M7.3 [macOS] | Client stream channel, cùng phạm vi và quy tắc dùng lại như A7.3 | Như A7.3 | `apple/Packages/HLTransport` | Như A7.3 | -| R7.1 [relay] | Xác nhận relay chở bulk mà không cần sửa: frame nhị phân `HR` cỡ bulk với giới hạn hiện có; không đổi wire hay giới hạn | Test chuyển tiếp cho frame `HR` cỡ bulk (đỏ nếu relay xử lý sai) | Chỉ test trong `relay/` | `cargo test`, `cargo clippy` xanh; không đổi `wire.rs` hay giới hạn | -| A7.4 [android] | Làn bulk: stream `bulk` với khóa riêng mỗi kết nối; cả lượt truyền CLIP-03 đi trên làn bulk; chọn đường theo bảng định tuyến; bulk qua relay là frame nhị phân HL đã mã hóa, được tách trong `RelayPeerMux`; op nhu cầu bulk và quy tắc giữ relay (cả hai flavor, foss không có FCM); bộ lập lịch Bluetooth ưu tiên frame điều khiển | Hồi quy CLIP-03 qua `/v1/ctl` trước; rồi đỏ: chunk tới trước push, bulk qua relay tới phiên bulk chứ không tới phiên điều khiển, lên relay khi có nhu cầu bulk trong lúc làn điều khiển đi Bluetooth, AC1 khi đang truyền bulk 5 MB, một ảnh 10 MiB chỉ qua Bluetooth vẫn truyền xong ở chế độ nền | `android/core/transport` (gồm `relay/`), `android/feature/clipboard` | Các ngoại lệ CLIP-03 qua trên mọi đường; AC1 (LAN) không đổi; AC3 (LAN) không đổi | -| M7.4 [macOS] | Làn bulk phía Mac, cùng phạm vi với A7.4 (tách trong `RelayLink`/`RelayPeerChannel`, điểm cắm `ClipboardPeer`) | Cùng thứ tự như A7.4 | `apple/Packages/HLTransport`, `apple/Packages/HLAppCore` (bảng nhớ tạm) | Như A7.4 | -| A7.5 [android] | Host Wi-Fi Direct (chỉ X, theo nhu cầu): nhóm với SSID/passphrase suy ra, `LEGACY_OR_R2` trên API 36, bật/tắt theo yêu cầu qua làn điều khiển, giữ nguyên STA; `NEARBY_WIFI_DEVICES` (13+), `ACCESS_FINE_LOCATION` + dịch vụ vị trí (10–12); Wi-Fi tắt → báo, không bao giờ tự bật | Vector suy khóa; test vòng đời với `WifiP2pManager` giả (bật, dừng, sập, STA không đổi, dừng sau `DIRECT_WIFI_MAX`) | `android/feature/connection` (hoặc `feature/directlink` mới) | Kiểm AC4 trên S25; đo AC3 trong X | +| R7.1 [relay] | **Bỏ 2026-10-28 (không deploy relay).** Trước đây: xác nhận relay chở bulk mà không cần sửa: frame nhị phân `HR` cỡ bulk với giới hạn hiện có; không đổi wire hay giới hạn | Test chuyển tiếp cho frame `HR` cỡ bulk (đỏ nếu relay xử lý sai) | Chỉ test trong `relay/` | `cargo test`, `cargo clippy` xanh; không đổi `wire.rs` hay giới hạn | +| A7.4 [android] | Làn bulk: stream `bulk` với khóa riêng mỗi kết nối; cả lượt truyền CLIP-03 đi trên làn bulk; chọn đường theo bảng định tuyến (LAN > Wi-Fi Direct > Bluetooth); cắm kênh bulk `0x02` vào bộ lập lịch của A7.1a (bộ lập lịch chuyển sang A7.1a ngày 2026-10-28). *Bỏ 2026-10-28:* bulk qua relay tách trong `RelayPeerMux`, op nhu cầu bulk, quy tắc giữ relay | Hồi quy CLIP-03 qua `/v1/ctl` trước; rồi đỏ: chunk tới trước push, AC1 khi đang truyền bulk 5 MB, một ảnh 10 MiB chỉ qua Bluetooth vẫn truyền xong ở chế độ nền (*bỏ:* hai test về relay) | `android/core/transport`, `android/feature/clipboard` | Các ngoại lệ CLIP-03 qua trên mọi đường; AC1 (LAN) không đổi; AC3 (LAN) không đổi | +| M7.4 [macOS] | Làn bulk phía Mac, cùng phạm vi với A7.4, trên bộ lập lịch của M7.1b (điểm cắm `ClipboardPeer`; bỏ phần tách trong `RelayLink`/`RelayPeerChannel` 2026-10-28) | Cùng thứ tự như A7.4 | `apple/Packages/HLTransport`, `apple/Packages/HLAppCore` (bảng nhớ tạm) | Như A7.4 | +| A7.5 [android] | Host Wi-Fi Direct (chỉ X, theo nhu cầu): nhóm với SSID và passphrase sinh ngẫu nhiên cho mỗi group (0.4.6), `LEGACY_OR_R2` trên API 36, bật/tắt theo yêu cầu qua làn điều khiển, giữ nguyên STA; `NEARBY_WIFI_DEVICES` (13+), `ACCESS_FINE_LOCATION` + dịch vụ vị trí (10–12); Wi-Fi tắt → báo, không bao giờ tự bật | Vector kiểm định dạng và bộ ký tự của S7.1d (*đổi 2026-10-28: không có suy khóa*); test vòng đời với `WifiP2pManager` giả (bật, dừng, sập, STA không đổi, dừng sau `DIRECT_WIFI_MAX`) | `android/feature/connection` (hoặc `feature/directlink` mới) | Kiểm AC4 trên S25; đo AC3 trong X | | M7.5 [macOS] | Join Wi-Fi Direct (chỉ X): quy tắc vào/ra của AC4 (theo nhu cầu, rảnh ≥ `DIRECT_WIFI_IDLE_MIN`, không thấy mạng quen, quét định kỳ, `DIRECT_WIFI_MAX`), join bằng CoreWLAN, kết nối gateway bằng chứng chỉ đã ghim, socket gắn vào giao diện P2P, xóa mạng đã lưu sau khi rời | Đỏ trước: không bao giờ join khi đang kết nối, không bao giờ join khi thấy mạng quen (trường hợp deauth), rời khi thấy mạng quen, rời khi hết thời gian tối đa, đã xóa mạng đã lưu | `apple/Packages/HLTransport`, `apple/macOS/HandLive` | Kiểm AC4, gồm cả kịch bản deauth; AC3 trong X ≤ 2 s | -| A7.6 [android] | Tự ghép đôi Bluetooth cổ điển, **khởi động ngay sau khi ghép đôi QR** (và một lần cho các cặp đã có khi tính năng ra mắt, hoặc bất cứ khi nào thiếu bond) , luồng từ G7 ((b) hoặc (c)): yêu cầu ghép đôi qua E2E, `createBond` tới địa chỉ Mac nhận qua E2E, gửi mã ghép đôi qua E2E khi đọc được | Test với adapter giả cho từng trạng thái; không bao giờ ghép đôi khi chưa có phiên đã xác thực; không yêu cầu chế độ cho phép tìm thấy | `android/feature/call` hoặc `android/feature/connection` | Đo AC5, AC10; phủ đường bị từ chối và timeout | -| M7.6 [macOS] | Tự ghép đôi Bluetooth cổ điển: `IOBluetoothDevicePair`; chỉ tự xác nhận khi mã khớp với mã E2E, nếu không thì hiện mã để người dùng so một lần; giao thiết bị đã bond cho stack HFP | Test quyết định xác nhận/từ chối với delegate giả (khớp, không khớp, thiếu mã) | `apple/Packages/…` (âm thanh cuộc gọi), `apple/macOS/HandLive` | AC5, AC10; không khớp hoặc thiếu mã thì không bao giờ tự xác nhận | -| A7.7 [android] | Luồng xin quyền (chủ sở hữu AC7 trên Android): SET-01 xin Nearby devices (12+), Location + dịch vụ vị trí (10–12, cho Wi-Fi Direct), có màn hình giải thích trước; yêu cầu bật Bluetooth (`ACTION_REQUEST_ENABLE`) chỉ khi không có đường LAN hay relay, tối đa một lần mỗi ngày | Test đếm số hộp thoại theo từng mức API (29, 31, 33, 37) | `android/app`, `android/feature/connection` | AC7 theo từng mức API | -| M7.7 [macOS] | Luồng xin quyền (chủ sở hữu AC7 trên Mac): màn hình giải thích Bluetooth và Location (SET-03), `NSBluetoothAlwaysUsageDescription`, lời giải thích dùng Location, entitlement `com.apple.security.personal-information.location`, bước ký bản release; dòng trạng thái khi Bluetooth tắt kèm nút mở cài đặt Bluetooth, cùng quy tắc với A7.7 | Test trình tự hộp thoại; kiểm entitlement trên bản release | `apple/macOS/HandLive`, `apple/macOS/HandLive.entitlements`, `docs/deployment-guide.md` (qua D) | AC7 trên Mac; Location chạy được trong bản đã notarize | -| T7.1 [test] | Ma trận đầu-cuối: X và Y với S25 + Mac, chung LAN, ở xa; tắt màn hình, ngủ/thức, tắt Bluetooth, tắt Wi-Fi điện thoại, từ chối Location, relay sập. **Đối kháng:** máy quảng bá nhái, sniff BLE (AC9), hello bị phát lại, lỗi pre-auth giả (AC8), chiếm giữ slot ghép đôi, deauth Wi-Fi của Mac (AC4), thử MITM khi ghép đôi (AC10). Thời gian ảnh đầu và ảnh sau trong X; một ảnh 10 MiB chỉ qua Bluetooth mà vẫn giữ AC1; tắt Bluetooth ở từng phía. Đếm hộp thoại trên Android 10–12; tiếng Anh và tiếng Việt; TalkBack/VoiceOver | Viết trước các kịch bản `shared/tools/e2e` đi qua shim debug của A7.1 | `shared/tools/e2e/`, `reports/phase-07-T7.1.md` | Đạt AC1–AC10; ghi lại kết nối Wi-Fi trước/sau trên cả hai máy từ log debug của app | +| A7.6 [android] | **Bỏ 2026-10-28 (SP5; Phase 4 đã dừng, không có HFP).** Trước đây: tự ghép đôi Bluetooth cổ điển, **khởi động ngay sau khi ghép đôi QR** (và một lần cho các cặp đã có khi tính năng ra mắt, hoặc bất cứ khi nào thiếu bond) , luồng từ G7 ((b) hoặc (c)): yêu cầu ghép đôi qua E2E, `createBond` tới địa chỉ Mac nhận qua E2E, gửi mã ghép đôi qua E2E khi đọc được | Test với adapter giả cho từng trạng thái; không bao giờ ghép đôi khi chưa có phiên đã xác thực; không yêu cầu chế độ cho phép tìm thấy | `android/feature/call` hoặc `android/feature/connection` | Đo AC5, AC10; phủ đường bị từ chối và timeout | +| M7.6 [macOS] | **Bỏ 2026-10-28 (SP5; Phase 4 đã dừng, không có HFP).** Trước đây: tự ghép đôi Bluetooth cổ điển: `IOBluetoothDevicePair`; chỉ tự xác nhận khi mã khớp với mã E2E, nếu không thì hiện mã để người dùng so một lần; giao thiết bị đã bond cho stack HFP | Test quyết định xác nhận/từ chối với delegate giả (khớp, không khớp, thiếu mã) | `apple/Packages/…` (âm thanh cuộc gọi), `apple/macOS/HandLive` | AC5, AC10; không khớp hoặc thiếu mã thì không bao giờ tự xác nhận | +| A7.7a [android] | *(Tách 2026-10-28 theo quyết định của Lead; trước là A7.7.)* Hộp thoại Bluetooth, SP1 (chủ sở hữu AC7 trên Android, phần Bluetooth): SET-01 xin Nearby devices (12+) kèm màn hình giải thích; yêu cầu bật Bluetooth (`ACTION_REQUEST_ENABLE`) chỉ khi không có đường LAN, tối đa một lần mỗi ngày | Test đếm số hộp thoại theo từng mức API (31, 33, 37) | `android/app`, `android/feature/connection` | AC7 (Bluetooth) theo từng mức API | +| A7.7b [android] | Hộp thoại Location, SP4 (Wi-Fi Direct): Location + dịch vụ vị trí (10–12) kèm màn hình giải thích | Test đếm số hộp thoại theo từng mức API (29, 31, 33, 37) | `android/app`, `android/feature/connection` | AC7 theo từng mức API | +| M7.7a [macOS] | *(Tách 2026-10-28 theo quyết định của Lead; trước là M7.7.)* Hộp thoại Bluetooth, SP1 (chủ sở hữu AC7 trên Mac, phần Bluetooth): màn hình giải thích Bluetooth (SET-03), `NSBluetoothAlwaysUsageDescription`; dòng trạng thái khi Bluetooth tắt kèm nút mở cài đặt Bluetooth, cùng quy tắc với A7.7a | Test trình tự hộp thoại (Bluetooth) | `apple/macOS/HandLive` | AC7 (Bluetooth) trên Mac | +| M7.7b [macOS] | Hộp thoại Location, SP4: màn hình giải thích Location (SET-03), lời giải thích dùng Location, entitlement `com.apple.security.personal-information.location`, bước ký bản release | Test trình tự hộp thoại; kiểm entitlement trên bản release | `apple/macOS/HandLive`, `apple/macOS/HandLive.entitlements`, `docs/deployment-guide.md` (qua D) | AC7 trên Mac; Location chạy được trong bản đã notarize | +| T7.1 [test] | Ma trận đầu-cuối: X và Y (ở gần nhau) với S25 + Mac, chung LAN; tắt màn hình, ngủ/thức, tắt Bluetooth, tắt Wi-Fi điện thoại, từ chối Location. **Đối kháng:** máy quảng bá nhái, sniff BLE (AC9), hello bị phát lại, lỗi pre-auth giả (AC8), chiếm giữ slot ghép đôi, deauth Wi-Fi của Mac (AC4). Thời gian ảnh đầu và ảnh sau trong X; một ảnh 10 MiB chỉ qua Bluetooth mà vẫn giữ AC1; tắt Bluetooth ở từng phía. Đếm hộp thoại trên Android 10–12; tiếng Anh và tiếng Việt; TalkBack/VoiceOver. *Bỏ 2026-10-28:* ở xa, relay sập, thử MITM khi ghép đôi (AC10) | Viết trước các kịch bản `shared/tools/e2e` đi qua shim debug của A7.1 | `shared/tools/e2e/`, `reports/phase-07-T7.1.md` | Đạt AC1–AC4 và AC6–AC9; ghi lại kết nối Wi-Fi trước/sau trên cả hai máy từ log debug của app | ## Nhánh và thứ tự làm -- Nhánh `feat/phase-07-connect-anywhere` trong hub, handlive-shared, handlive-android, handlive-apple (và - handlive-relay chỉ cho test R7.1). -- T7.0 làm trước và làm riêng (sau G4/G5/G6). Rồi D7.1 → S7.1 (mọi thẻ nền tảng chờ spec, vector và chuỗi). -- Sau đó theo thứ tự sub-project: - 1. SP1: A7.1 ∥ M7.1, cùng A7.7 ∥ M7.7 cho các hộp thoại Bluetooth - 2. SP2: A7.2 ∥ M7.2 - 3. SP3: A7.3 ∥ M7.3 (nếu chưa có), R7.1, rồi A7.4 ∥ M7.4 - 4. SP4: A7.5 ∥ M7.5 - 5. SP5: A7.6 ∥ M7.6 (sau các thẻ sản phẩm HFP của Phase 4) - 6. T7.1 -- **Nếu G7 chọn RFCOMM và RFCOMM cần bond, SP5 chuyển lên trước SP1** và không chờ Phase 4 nữa. +- Nhánh `feat/phase-07-connect-anywhere` trong hub, handlive-shared, handlive-android, handlive-apple (handlive-relay + không còn tham gia: đã bỏ R7.1 ngày 2026-10-28). +- T7.0 làm trước và làm riêng (từ 2026-10-28, không chờ G4/G5/G6). Rồi D7.1, rồi mỗi thẻ shared S7.1a–d làm ngay + trước sub-project dùng nó (mọi thẻ nền tảng chờ spec, vector và chuỗi). Thẻ nền tảng chỉ được tạo sau khi có số đo + của G7. +- Sau đó theo thứ tự sub-project (*tách 2026-10-28 theo quyết định của Lead*): + 1. SP1: S7.1a (đã bỏ T7.0a ngày 2026-10-28); M7.1a làm ngay (máy trạng thái thuần, không cần vector); rồi A7.1a ∥ M7.1b, rồi + A7.1b ∥ M7.1c, cùng A7.7a ∥ M7.7a. Trên Android, A7.1b và A7.7a cùng sửa `android/app` và + `feature/connection`: làm lần lượt. S7.1b chạy trong SP1 để `cpace.json` được duyệt trước SP2. + 2. SP2: A7.2a ∥ M7.2a (bắt đầu được ngay khi S7.1b đã được duyệt), rồi A7.2 ∥ M7.2. + 3. SP3: S7.1c; A7.3 ∥ M7.3 (nếu chưa có), rồi A7.4 ∥ M7.4. + 4. SP4: S7.1d; A7.5 ∥ M7.5, cùng A7.7b ∥ M7.7b. + 5. T7.1. +- Mỗi sub-project kết thúc bằng một lượt đo trên S25 + Mac rồi mới mở sub-project sau: SP1 đo AC1 (rảnh và khi có + tải), AC2, scan response; SP2 đo AC6. +- *Bỏ 2026-10-28:* SP5 (A7.6 ∥ M7.6) và quy tắc "nếu RFCOMM cần bond thì SP5 chuyển lên trước SP1" — lớp mang RFCOMM + cần bond giờ coi là không đạt G7 (xem mục Lớp mang ở trên). - Mỗi bước logic một commit, `shared/` trước nền tảng, không bao giờ một commit trải qua hai repository. ## Kiểm thử -- **Test viết trước** (theo từng thẻ, commit ở trạng thái đỏ trước code): vector từ S7.1; test hồi quy cho phần code mà - thẻ đó refactor (`serveRelayPeer`, CLIP-03 qua `/v1/ctl`, `ConnectionStateMachine`, phần tách peer của relay); rồi - hành vi mới; rồi các trường hợp đối kháng. +- **Test viết trước** (theo từng thẻ, commit ở trạng thái đỏ trước code): vector từ S7.1a–d; test hồi quy cho phần code mà + thẻ đó refactor (`serveRelayPeer`, CLIP-03 qua `/v1/ctl`, `ConnectionStateMachine`); rồi hành vi mới; rồi các + trường hợp đối kháng. - **Cổng hồi quy** sau mỗi thẻ: - Android: `./gradlew check`. - Apple: `swift test` trong các package bị đụng, `xcodebuild test` cho các target app. - - Relay: `cargo test` và `cargo clippy`. - Hub: `python3 tools/docs/validate_design_docs.py` và `tools/docs/check_bilingual_docs.py`. - Shared: `check_schemas.py`, `check_strings.py`. -- **Thiết bị thật:** S25 Ultra + Mac này (macOS 27) cho X và Y, thêm một Mac chạy macOS 13 hoặc 14 và một điện thoại +- **Thiết bị thật:** S25 Ultra + Mac này (macOS 27) cho X và Y khi ở gần nhau, thêm một Mac chạy macOS 13 hoặc 14 và một điện thoại Android 10–12 để kiểm hộp thoại và việc nhận biết Wi-Fi rảnh; số đo AC lấy từ `BenchLog` và các sự kiện bench của S7.1. ## Rủi ro và phương án lùi -- G7 thất bại (không lớp mang nào đạt AC1) → dừng Phase 7; LAN + relay vẫn là lời giải của sản phẩm. +- **G7 thất bại (không lớp mang nào đạt AC1) → dừng Phase 7, và không có đường dự phòng:** relay không được deploy + (quyết định của chủ dự án 2026-10-28), nên sản phẩm chỉ còn LAN và kịch bản X, Y vẫn "không kết nối được". Báo chủ dự + án kèm số đo của G7. - **Bảo mật:** kẻ tấn công trong tầm BLE có thể thử nhái máy quảng bá, phát lại hello, chiếm slot ghép đôi hoặc giả lỗi pre-auth → được che bởi lớp bảo mật link, chính sách admission cho Bluetooth và quy tắc pre-auth theo route (AC8, AC9), - và được test đối kháng trong T7.1. Một liên kết Bluetooth được xác nhận mà không kiểm mã sẽ làm lộ âm thanh cuộc gọi - → AC10. + và được test đối kháng trong T7.1. (Rủi ro bond đứng sau AC10 không còn, vì đã bỏ SP5.) - One UI bóp BLE khi chạy nền → dùng đường page-scan cổ điển hoặc một thông báo "ở gần" hiển thị; đo ở G7. -- IOBluetooth dễ vỡ (chung rủi ro với HFP) → đặt lớp mang sau điểm cắm `MessageChannel` / `WebSocketSession`; lùi về - BLE. -- macOS hỏi người dùng khi join, hoặc tự quay về mạng khác → Wi-Fi Direct chỉ là cơ hội; ảnh lùi về Bluetooth hoặc - relay; AC3 trong X báo cáo theo số đo thực. -- Upload qua relay bằng dữ liệu di động quá chậm → AC3 trong Y báo cáo theo số đo thực (đã chấp nhận). -- Mỗi sub-project phát hành sau một cài đặt (`link.bluetooth`, `link.direct_wifi`, `link.auto_bond`, tên chốt trong - D7.1) để có thể tắt mà không cần ra bản mới. +- IOBluetooth dễ vỡ → đặt lớp mang sau điểm cắm `MessageChannel` / `WebSocketSession`; lùi về BLE. +- macOS hỏi người dùng khi join, hoặc tự quay về mạng khác → Wi-Fi Direct chỉ là cơ hội; ảnh lùi về Bluetooth; AC3 + trong X báo cáo theo số đo thực. +- *Bỏ 2026-10-28:* "upload qua relay bằng dữ liệu di động quá chậm" (không có relay). +- Mỗi sub-project phát hành sau một cài đặt (`link.bluetooth`, `link.direct_wifi`, tên chốt trong D7.1; bỏ + `link.auto_bond` cùng SP5) để có thể tắt mà không cần ra bản mới. ## Đánh giá red team @@ -183,21 +194,24 @@ công bảo mật (kiểm sự thật), Kẻ phá giả định (kiểm phạm v | # | Phát hiện | Mức | Quyết định | Áp vào | |---|---------|----------|-------------|------------| -| 1 | Lỗi pre-auth giả qua Bluetooth có thể hủy ghép đôi hoặc dừng nối lại (code Mac chỉ miễn cho `route == .relay`) | Critical | Chấp nhận | D7.1 (quy tắc route), M7.1, AC8, T7.1 | -| 2 | Kịch bản X bị chặn: Mac chỉ rời `Idle` khi có mạng và bỏ mọi phiên khi mất mạng | Critical | Chấp nhận | D7.1 (0.11), M7.1 | +| 1 | Lỗi pre-auth giả qua Bluetooth có thể hủy ghép đôi hoặc dừng nối lại (code Mac chỉ miễn cho `route == .relay`) | Critical | Chấp nhận | D7.1 (quy tắc route), M7.1a, AC8, T7.1 | +| 2 | Kịch bản X bị chặn: Mac chỉ rời `Idle` khi có mạng và bỏ mọi phiên khi mất mạng | Critical | Chấp nhận | D7.1 (0.11), M7.1a | | 3 | BLE chưa bond làm lộ định danh, metadata và transcript ghép đôi; `pr` là định danh cố định | High | Chấp nhận | D7.1 (lớp bảo mật link, `pr` HMAC), AC9 | -| 4 | Admission pre-auth theo IP hay `device_id` không chạy trên BLE; hello phát lại được | High | Chấp nhận | D7.1 (admission), A7.1 | +| 4 | Admission pre-auth theo IP hay `device_id` không chạy trên BLE; hello phát lại được | High | Chấp nhận | D7.1 (admission), A7.1a | | 5 | Tự ghép đôi đảo ngược AUDIO-01; phòng MITM dựa vào một khả năng chưa được chứng minh | High | Chấp nhận (chủ dự án: chỉ tự xác nhận khi đã kiểm mã, bỏ luồng (a)) | G7, D7.1, A7.6, M7.6, AC5, AC10 | | 6 | Bulk qua relay không tách được kênh; nâng giới hạn là thừa; khóa stream không gắn theo kết nối | High | Chấp nhận | D7.1 (đóng khung bulk, khóa), R7.1 (chỉ test), A7.4, M7.4, AC3 | | 7 | Trong Y, điện thoại không bao giờ lên relay khi đang có phiên Bluetooth | High | Chấp nhận | D7.1 (nhu cầu bulk), A7.4, M7.4 | | 8 | Chưa có hạ tầng stream channel; chunk tới trước push bị bỏ | High | Chấp nhận | A7.3, M7.3, quyết định I9 của plan, A7.4, M7.4 | -| 9 | Điều khiển và bulk dùng chung một link Bluetooth không có ưu tiên; AC1 chưa bao giờ được đo khi có tải | High | Chấp nhận | D7.1 (channel id, bộ lập lịch), AC1, A7.4 | +| 9 | Điều khiển và bulk dùng chung một link Bluetooth không có ưu tiên; AC1 chưa bao giờ được đo khi có tải | High | Chấp nhận | D7.1 (channel id, bộ lập lịch, làn điều khiển), AC1, A7.1a, M7.1b | | 10 | RFCOMM cần địa chỉ điện thoại hoặc bond trước SP1/SP2 | High | Chấp nhận | G7, thứ tự làm | -| 11 | "Wi-Fi rảnh" không tin được khi thiếu Location; deauth có thể đẩy Mac lên Wi-Fi Direct và giữ ở đó | High | Chấp nhận | G7, AC4, A7.5, M7.5, M7.7, T7.1 | -| 12 | Ghép đôi offline qua BLE dễ bị chiếm slot; PIN chỉ bị chặn gián tiếp | Medium | Chấp nhận | D7.1, A7.2, M7.2 | -| 13 | Bộ e2e không có đường vào ống Bluetooth trong app thật | Medium | Chấp nhận | A7.1 (shim debug), T7.1 | -| 14 | AC7 vỡ trên Android 10–12 vì Wi-Fi Direct cần Location | Medium | Chấp nhận (chủ dự án: nới AC7 cho Android 10–12) | AC7, A7.5, A7.7 | -| 15 | AC1 vô tình nới mục tiêu LAN; AC2 không đo được cho Bluetooth; thiếu tiêu chí bảo mật | Medium | Chấp nhận | AC1, AC2, AC8–AC10, S7.1, G7, T7.1 | +| 11 | "Wi-Fi rảnh" không tin được khi thiếu Location; deauth có thể đẩy Mac lên Wi-Fi Direct và giữ ở đó | High | Chấp nhận | G7, AC4, A7.5, M7.5, M7.7b, T7.1 | +| 12 | Ghép đôi offline qua BLE dễ bị chiếm slot; PIN chỉ bị chặn gián tiếp (2026-10-28: PIN nay chạy qua Bluetooth bằng CPace, chỉ dò được trực tuyến, 3 lần mỗi PIN cộng khóa tạm) | Medium | Chấp nhận | D7.1, A7.2, M7.2 | +| 13 | Bộ e2e không có đường vào ống Bluetooth trong app thật | Medium | Chấp nhận | A7.1b (shim debug), T7.1 | +| 14 | AC7 vỡ trên Android 10–12 vì Wi-Fi Direct cần Location | Medium | Chấp nhận (chủ dự án: nới AC7 cho Android 10–12) | AC7, A7.5, A7.7b | +| 15 | AC1 vô tình nới mục tiêu LAN; AC2 không đo được cho Bluetooth; thiếu tiêu chí bảo mật | Medium | Chấp nhận | AC1, AC2, AC8–AC10, S7.1a, G7, T7.1 | + +*Ghi chú 2026-10-28:* phát hiện 5 (ghép đôi) và 6, 7 (bulk qua relay) không còn áp dụng — các thẻ chúng đã sửa nay bị +bỏ (Phiên xác thực 2). Phát hiện 10 giờ được giải bằng "RFCOMM cần bond thì không đạt G7". Bảng giữ nguyên. ### Quét nhất quán toàn plan @@ -242,7 +256,7 @@ mức Light. - Bluetooth tắt: yêu cầu hệ thống một chạm (Android) / dòng trạng thái kèm nút mở cài đặt (Mac), chỉ khi không có LAN/relay, tối đa một lần mỗi ngày. - Ảnh qua Bluetooth: không có giới hạn dung lượng riêng; bộ lập lịch giữ AC1 trong các lượt truyền dài. -- Ghép đôi HFP khởi động ngay sau ghép đôi QR. +- Ghép đôi HFP khởi động ngay sau ghép đôi QR. *(Không còn hiệu lực từ 2026-10-28: đã bỏ SP5, xem Phiên 2.)* #### Ảnh hưởng tới các phần - AC3, AC4 (ở lại cho các ảnh sau), AC7 (yêu cầu bật Bluetooth), A7.4 (10 MiB qua Bluetooth), A7.6 (thời điểm), A7.7, @@ -252,3 +266,43 @@ mức Light. - Đã tìm trong mọi file plan, roadmap và ghi chú của báo cáo brainstorm các cụm "2 s … offline", "theo nhu cầu", "ghép đôi", "Bluetooth tắt"; đã sửa dòng 7 trong `plan.md`/`plan.vi.md`, `docs/project-roadmap*`, ghi chú brainstorm. Mâu thuẫn chưa giải quyết: không có. + +### Phiên 2 — 2026-10-28 (quyết định của chủ dự án) +**Lý do:** chủ dự án báo HandLive không dùng được khi một máy mất mạng; lead hỏi ba câu (chat của đội, 2026-10-28). +**Số câu hỏi:** 3. + +#### Câu hỏi và trả lời (nguyên văn câu trả lời của chủ dự án) + +1. Chủ dự án đang gặp trường hợp nào? — Lựa chọn: Mac/iPhone mất mạng hẳn, điện thoại có 4G | Điện thoại mất mạng, Mac + có mạng | Hai máy cùng Wi-Fi nhưng Wi-Fi đó không có Internet | Điện thoại dùng 4G, Mac ở một Wi-Fi khác | Cả hai + đều không có mạng + - **Trả lời:** «Cả hai đều không có mạng». +2. Muốn đi hướng nào? — Lựa chọn: Ngắn hạn: deploy relay + hướng dẫn bật hotspot / ứng viên gateway | Chỉ hotspot, chưa + deploy relay | Mở lại Phase 7 BLE ngay, bỏ qua điều kiện G4/G5/G6 | Chỉ cần báo cáo, chưa làm gì + - **Trả lời:** «Mở lại Phase 7 BLE ngay, bỏ qua điều kiện G4/G5/G6». +3. Nếu deploy relay: đã có VPS, khóa APNs, dự án Firebase (FCM) chưa? — Lựa chọn: Có đủ, sẽ gửi | Chưa có, đội viết + hướng dẫn chuẩn bị | Không deploy relay + - **Trả lời:** «Không deploy relay». + +#### Quyết định đã xác nhận +- Mục tiêu là kịch bản X (cả hai offline); Y chỉ phủ khi hai máy ở gần nhau, qua Bluetooth. +- Phase 7 bắt đầu ngày 2026-10-28; bỏ quy tắc ngày 2026-10-01 "chỉ bắt đầu sau G4, G5 và G6". +- Phase này không relay: bỏ R7.1, đường relay, phần relay AC1/AC3, A7.4/M7.4 và D7.1. +- Phase 4 đã dừng (2026-10-12) → bỏ SP5 (A7.6/M7.6), AC5, AC10, câu hỏi ghép đôi, phép đo "trong cuộc gọi HFP" của G7; câu 4 Phiên 1 vô hiệu. +- Thứ tự: T7.0 → D7.1 → S7.1 → SP1 (kèm A7.7/M7.7) → SP2 → SP3 → SP4 → T7.1; tạo thẻ nền tảng sau G7. + +#### Ảnh hưởng tới các phần +- File này (mục tiêu, trạng thái, G7, AC1/AC3/AC5/AC8/AC10, định tuyến, D7.1, S7.1, M7.1, R7.1, A7.4, M7.4, A7.6, M7.6, + A7.7, T7.1, thứ tự làm, rủi ro); trạng thái, dòng 7, dòng ước lượng công và gate G7 trong `plan.md`/`plan.vi.md`; + dòng 7 và mục Phase 7 trong `docs/project-roadmap*`. +- Rủi ro đã chấp nhận: nếu G7 thất bại thì không có relay dự phòng; sản phẩm chỉ còn LAN. + +### Tách thẻ — 2026-10-28 (quyết định của Lead, chat của đội) +- Bộ lập lịch (điều khiển trước bulk) thuộc về làn điều khiển (điều kiện 1 của G7): chuyển từ A7.4/M7.4 sang + A7.1a/M7.1b. Envelope điều khiển dài (tới 256 KiB, khoảng 20 s chiếm sóng) có làn dài riêng trong 0.4.5, nên không + bao giờ làm một envelope ngắn chờ quá ngưỡng AC1. +- SP1 tách thành M7.1a → A7.1a ∥ M7.1b → A7.1b ∥ M7.1c; S7.1 tách thành S7.1a–d, mỗi thẻ cho một sub-project; + primitive CPace thành thẻ riêng A7.2a/M7.2a; A7.7/M7.7 tách phần Bluetooth (SP1) và phần Location (SP4); thêm T7.0a + (probe scan response) trước khi chốt vector quảng bá. +- `ssid`/`passphrase` Wi-Fi Direct sinh ngẫu nhiên cho mỗi group (0.4.6): S7.1d và A7.5 kiểm định dạng và bộ ký tự, + không có vector suy khóa. diff --git a/plans/20260925-implementation/plan.md b/plans/20260925-implementation/plan.md index 66ed78d..f6acf1c 100644 --- a/plans/20260925-implementation/plan.md +++ b/plans/20260925-implementation/plan.md @@ -2,7 +2,7 @@ English | [Tiếng Việt](plan.vi.md) # HandLive implementation plan — hand-off to coding agents -**Status:** Phase 0 and Phase 1 merged into `main` of every repository (2026-09-26, the project owner merged Phase 1 before gate G1 — the G1 real-device checks are still open, `reports/phase-01-merge.md`); Phase 2 merged into `main` (2026-09-27, by decision of the project owner before gates G1 and G2 and before its real-device, relay, APNs and FCM checks, which are still open, `reports/phase-02-merge.md`); Phase 3 merged into `main` (2026-09-28, by decision of the project owner before its real-device checks, `reports/phase-03-merge.md`); Phase 4 opened on `feat/phase-04-call-audio` with the spike of gate G4 (`reports/phase-04-spike-d1.md`); Phase 6 (Continue Browsing) proposed on 2026-09-28, waiting for its G6 spike; by decision of the project owner (2026-09-28) Phases 5 and 6 are done before Phase 4 finishes, since Phase 4 waits for the G4 HFP hardware spike; Phase 7 (Connect anywhere) proposed on 2026-10-01, planned tests-first, starts after gates G4, G5 and G6 (`phase-07-ket-noi-moi-noi.md`, `reports/phase-07-brainstorm.md`) · **Sources:** +**Status:** Phase 0 and Phase 1 merged into `main` of every repository (2026-09-26, the project owner merged Phase 1 before gate G1 — the G1 real-device checks are still open, `reports/phase-01-merge.md`); Phase 2 merged into `main` (2026-09-27, by decision of the project owner before gates G1 and G2 and before its real-device, relay, APNs and FCM checks, which are still open, `reports/phase-02-merge.md`); Phase 3 merged into `main` (2026-09-28, by decision of the project owner before its real-device checks, `reports/phase-03-merge.md`); Phase 4 opened on `feat/phase-04-call-audio` with the spike of gate G4 (`reports/phase-04-spike-d1.md`); Phase 6 (Continue Browsing) proposed on 2026-09-28, waiting for its G6 spike; by decision of the project owner (2026-09-28) Phases 5 and 6 are done before Phase 4 finishes, since Phase 4 waits for the G4 HFP hardware spike; Phase 7 (Connect anywhere) proposed on 2026-10-01, planned tests-first; started on 2026-10-28 by decision of the project owner without waiting for gates G4, G5 and G6, scope cut to the both-offline case with no relay and no HFP bonding (`phase-07-ket-noi-moi-noi.md`, `reports/phase-07-brainstorm.md`) · **Sources:** `plans/20260924-definitive-architecture/plan.md` (architecture, D1–D12), `docs/detailed-design/` v1.3 (38 leaf functions, C1–C21), `docs/design-system/` (Apple HIG, version 6), `docs/code-standards.md`, `docs/project-roadmap.md`. @@ -33,9 +33,9 @@ English | [Tiếng Việt](plan.vi.md) | 4 | Taking calls on the Mac | AUDIO-01…04, CALL-03 (hold, DTMF, mute over HFP) | Android, macOS | 3, spike D1 | MOS ≥ 3.5 (HFP), ≥ 3.0 (Opus/WS); ERL > 40 dB | [phase-04-am-thanh-cuoc-goi.md](phase-04-am-thanh-cuoc-goi.md) | | 5 | The phone as webcam and microphone | CAM-01…05 | Android, macOS | 1, spike D6 | Latency < 120 ms over Wi-Fi, < 70 ms over USB at 720p30 | [phase-05-camera-micro.md](phase-05-camera-micro.md) | | 6 | Continue Browsing (proposed) | WEB-01…05, SET-01 part B and SET-02 fields 34–37 for `web` | Android, macOS, iOS | 1, spike G6 | Sent after a 1.5 s settle; zero private pages sent; nothing stored | [phase-06-web-handoff.md](phase-06-web-handoff.md) | -| 7 | Connect anywhere (proposed 2026-10-01; starts after G4, G5, G6) | new CONN-05, CONN-06; PAIR-01 (BLE rendezvous), CLIP-03 (bulk lane), AUDIO (automatic bonding), SET-01/02/03 | Android, macOS, relay | 1, 2, 4, spike G7 | Text < 50 ms on the LAN, ≤ 200 ms over Bluetooth; 5 MB image ≤ 2 s on the LAN, first image offline ≤ 10 s; no device ever changes its Wi-Fi | [phase-07-ket-noi-moi-noi.md](phase-07-ket-noi-moi-noi.md) | +| 7 | Connect anywhere (started 2026-10-28 by owner decision, no wait on G4, G5, G6; relay and HFP bonding dropped) | new CONN-05, CONN-06; PAIR-01 (BLE rendezvous), CLIP-03 (bulk lane), SET-01/02/03 | Android, macOS | 1, spike G7 | Text < 50 ms on the LAN, ≤ 200 ms over Bluetooth; 5 MB image ≤ 2 s on the LAN, first image offline ≤ 10 s; no device ever changes its Wi-Fi | [phase-07-ket-noi-moi-noi.md](phase-07-ket-noi-moi-noi.md) | -Effort estimate (roadmap): P1 3.5 · P2 4 · P3 3 · P4 6 · P5 5.5 · P6 1.5 person-months; Phase 0 about 0.5; P7 estimated after gate G7. +Effort estimate (roadmap): P1 3.5 · P2 4 · P3 3 · P4 6 · P5 5.5 · P6 1.5 person-months; Phase 0 about 0.5; P7 estimated after gate G7 (scope cut 2026-10-28: no relay card, no bonding cards). ## 3. How to hand work to agents @@ -91,7 +91,7 @@ repository) only when a mismatch is found, and run the validator. | G4 (spike D1) | First week of Phase 4 | `IOBluetoothHandsFreeDevice` receives SCO audio in the HF role on macOS 13, 14, 15, 26 with Pixel and Samsung | Opus/WS becomes the primary path; HFP keeps only control; update AUDIO-02 and plan D1 | | G5 (spike D6) | First week of Phase 5 | CMIOExtension delivers frames to Zoom/Meet/FaceTime; the AudioServerPlugin loopback is audible in meeting apps | Stop Phase 5, write a report | | G6 (spike, plan W8) | First 3–5 days of Phase 6 | Android: address bar and incognito detection for Chrome, Samsung Internet, Firefox, Edge, Brave on API 29 and 35, battery cost measured; Mac: Apple Events for Safari, Chrome, Arc on macOS 13 and 26, Safari private windows, TCC for a Developer ID build; the Play Accessibility policy checked. Go/no-go per browser | Rejected browsers are listed in `09-web-handoff.md`; none passes → stop Phase 6, write a report | -| G7 (spike, Phase 7) | First week of Phase 7, after G4, G5, G6 | Bluetooth bearer (BLE L2CAP or RFCOMM) meets 200 ms text on an established link between a Samsung phone and a Mac, in the background and during an HFP call; bonding flow and Wi-Fi Direct join path chosen; battery measured | Stop Phase 7; LAN + relay stay the answer; write a report | +| G7 (spike, Phase 7) | First week of Phase 7 (from 2026-10-28, no wait on G4, G5, G6) | Bluetooth bearer (BLE L2CAP, or RFCOMM without a bond) meets 200 ms text on an established link between a Samsung phone and a Mac, in the background; Wi-Fi Direct join path chosen; battery measured (bonding flow and the HFP-call measurement dropped 2026-10-28) | Stop Phase 7; the product stays LAN only (no relay deployed, no fallback); write a report | Items to check on real devices (not open questions; they are tasks inside the phases): the system setting names in English and Vietnamese ("Privacy & Security" / "Quyền riêng tư & Bảo mật", "Focus" / "Tập trung", "Paste from Other Apps" / "Dán từ ứng dụng khác") — A1.4, M1.1; the Magic diff --git a/plans/20260925-implementation/plan.vi.md b/plans/20260925-implementation/plan.vi.md index 55ee8dd..99ea9ff 100644 --- a/plans/20260925-implementation/plan.vi.md +++ b/plans/20260925-implementation/plan.vi.md @@ -2,7 +2,7 @@ # Kế hoạch triển khai HandLive — giao cho agent viết mã -**Trạng thái:** Phase 0 và Phase 1 đã gộp vào `main` ở mọi kho (26/09/2026, chủ dự án cho gộp Phase 1 trước cổng G1 — việc kiểm G1 trên máy thật vẫn còn mở, `reports/phase-01-merge.md`); Phase 2 đã gộp vào `main` (27/09/2026, chủ dự án cho gộp trước cổng G1, G2 và trước việc kiểm trên máy thật, relay, APNs, FCM, các việc này vẫn còn mở, `reports/phase-02-merge.md`); Phase 3 đã gộp vào `main` (28/09/2026, chủ dự án cho gộp trước việc kiểm trên máy thật, `reports/phase-03-merge.md`); Phase 4 mở trên nhánh `feat/phase-04-call-audio` bằng spike của cổng G4 (`reports/phase-04-spike-d1.md`); Phase 6 (Duyệt web tiếp) được đề xuất ngày 28/09/2026, chờ spike G6; theo quyết định của chủ dự án (28/09/2026), Phase 5 và 6 làm xong trước khi Phase 4 hoàn tất, vì Phase 4 chờ spike phần cứng HFP của cổng G4; Phase 7 (Kết nối mọi nơi) được đề xuất ngày 2026-10-01, lập theo kiểu viết test trước, bắt đầu sau các cổng G4, G5 và G6 (`phase-07-ket-noi-moi-noi.vi.md`, `reports/phase-07-brainstorm.vi.md`) · **Nguồn:** +**Trạng thái:** Phase 0 và Phase 1 đã gộp vào `main` ở mọi kho (26/09/2026, chủ dự án cho gộp Phase 1 trước cổng G1 — việc kiểm G1 trên máy thật vẫn còn mở, `reports/phase-01-merge.md`); Phase 2 đã gộp vào `main` (27/09/2026, chủ dự án cho gộp trước cổng G1, G2 và trước việc kiểm trên máy thật, relay, APNs, FCM, các việc này vẫn còn mở, `reports/phase-02-merge.md`); Phase 3 đã gộp vào `main` (28/09/2026, chủ dự án cho gộp trước việc kiểm trên máy thật, `reports/phase-03-merge.md`); Phase 4 mở trên nhánh `feat/phase-04-call-audio` bằng spike của cổng G4 (`reports/phase-04-spike-d1.md`); Phase 6 (Duyệt web tiếp) được đề xuất ngày 28/09/2026, chờ spike G6; theo quyết định của chủ dự án (28/09/2026), Phase 5 và 6 làm xong trước khi Phase 4 hoàn tất, vì Phase 4 chờ spike phần cứng HFP của cổng G4; Phase 7 (Kết nối mọi nơi) được đề xuất ngày 2026-10-01, lập theo kiểu viết test trước; bắt đầu ngày 2026-10-28 theo quyết định của chủ dự án, không chờ các cổng G4, G5 và G6, thu gọn về trường hợp cả hai máy offline, không relay, không tự ghép đôi HFP (`phase-07-ket-noi-moi-noi.vi.md`, `reports/phase-07-brainstorm.vi.md`) · **Nguồn:** `plans/20260924-definitive-architecture/plan.md` (kiến trúc, D1–D12), `docs/detailed-design/` v1.3 (38 chức năng lá, C1–C21), `docs/design-system/` (Apple HIG, bản 6), `docs/code-standards.md`, `docs/project-roadmap.md`. @@ -33,9 +33,9 @@ | 4 | Nghe gọi trên Mac | AUDIO-01…04, CALL-03 (giữ máy, DTMF, tắt tiếng qua HFP) | Android, macOS | 3, spike D1 | MOS ≥ 3,5 (HFP), ≥ 3,0 (Opus/WS); ERL > 40 dB | [phase-04-am-thanh-cuoc-goi.md](phase-04-am-thanh-cuoc-goi.vi.md) | | 5 | Điện thoại làm webcam và micro | CAM-01…05 | Android, macOS | 1, spike D6 | Trễ < 120 ms Wi-Fi, < 70 ms USB ở 720p30 | [phase-05-camera-micro.md](phase-05-camera-micro.vi.md) | | 6 | Duyệt web tiếp (đề xuất) | WEB-01…05, SET-01 phần B và SET-02 trường 34–37 cho `web` | Android, macOS, iOS | 1, spike G6 | Gửi sau 1,5 s ổn định; không trang riêng tư nào bị gửi; không lưu gì | [phase-06-web-handoff.md](phase-06-web-handoff.vi.md) | -| 7 | Kết nối mọi nơi (đề xuất 2026-10-01; bắt đầu sau G4, G5, G6) | CONN-05, CONN-06 mới; PAIR-01 (rendezvous qua BLE), CLIP-03 (làn bulk), AUDIO (tự ghép đôi), SET-01/02/03 | Android, macOS, relay | 1, 2, 4, spike G7 | Văn bản < 50 ms trên LAN, ≤ 200 ms qua Bluetooth; ảnh 5 MB ≤ 2 s trên LAN, ảnh đầu khi offline ≤ 10 s; không máy nào bị đổi Wi-Fi | [phase-07-ket-noi-moi-noi.md](phase-07-ket-noi-moi-noi.vi.md) | +| 7 | Kết nối mọi nơi (bắt đầu 2026-10-28, không chờ G4/G5/G6; không relay, không ghép đôi HFP) | CONN-05, CONN-06 mới; PAIR-01 (rendezvous BLE), CLIP-03 (làn bulk), SET-01/02/03 | Android, macOS | 1, spike G7 | Văn bản < 50 ms LAN, ≤ 200 ms Bluetooth; ảnh 5 MB ≤ 2 s LAN, ảnh đầu offline ≤ 10 s; Wi-Fi không đổi | [phase-07-ket-noi-moi-noi.md](phase-07-ket-noi-moi-noi.vi.md) | -Ước lượng công (roadmap): P1 3,5 · P2 4 · P3 3 · P4 6 · P5 5,5 · P6 1,5 người-tháng; Phase 0 khoảng 0,5; P7 ước lượng sau gate G7. +Ước lượng công (roadmap): P1 3,5 · P2 4 · P3 3 · P4 6 · P5 5,5 · P6 1,5 người-tháng; Phase 0 khoảng 0,5; P7 ước lượng sau gate G7 (thu gọn 2026-10-28: không có thẻ relay, không có thẻ ghép đôi). ## 3. Cách giao việc cho agent @@ -86,7 +86,7 @@ nêu trong báo cáo để agent nền tảng khác chạy lại (CI nền tản | G4 (spike D1) | Tuần đầu Phase 4 | `IOBluetoothHandsFreeDevice` nhận được âm thanh SCO ở vai HF trên macOS 13, 14, 15, 26 với Pixel và Samsung | Opus/WS thành đường chính; HFP chỉ giữ điều khiển; cập nhật AUDIO-02, plan D1 | | G5 (spike D6) | Tuần đầu Phase 5 | CMIOExtension xuất khung vào Zoom/Meet/FaceTime; AudioServerPlugin loopback nghe được trong ứng dụng họp | Dừng Phase 5, ghi báo cáo | | G6 (spike, plan W8) | 3–5 ngày đầu Phase 6 | Android: thanh địa chỉ và nhận biết ẩn danh cho Chrome, Samsung Internet, Firefox, Edge, Brave trên API 29 và 35, đo chi phí pin; Mac: Apple Events cho Safari, Chrome, Arc trên macOS 13 và 26, cửa sổ riêng tư của Safari, TCC với bản Developer ID; kiểm chính sách Hỗ trợ tiếp cận của Play. Đạt/không đạt theo trình duyệt | Trình duyệt bị loại được liệt kê trong `09-web-handoff.md`; không trình duyệt nào đạt → dừng Phase 6, ghi báo cáo | -| G7 (spike, Phase 7) | Tuần đầu Phase 7, sau G4, G5, G6 | Lớp mang Bluetooth (BLE L2CAP hoặc RFCOMM) đạt 200 ms cho văn bản trên liên kết đã nối sẵn giữa một điện thoại Samsung và một Mac, khi chạy nền và khi đang có cuộc gọi HFP; đã chọn luồng ghép đôi và cách join Wi-Fi Direct; đã đo pin | Dừng Phase 7; LAN + relay vẫn là lời giải; ghi báo cáo | +| G7 (spike, Phase 7) | Tuần đầu Phase 7 (từ 2026-10-28, không chờ G4, G5, G6) | Lớp mang Bluetooth (BLE L2CAP, hoặc RFCOMM không cần bond) đạt 200 ms cho văn bản trên liên kết đã nối sẵn giữa một điện thoại Samsung và một Mac, khi chạy nền; đã chọn cách join Wi-Fi Direct; đã đo pin (bỏ luồng ghép đôi và phép đo khi có cuộc gọi HFP ngày 2026-10-28) | Dừng Phase 7; sản phẩm chỉ còn LAN (không deploy relay, không có đường dự phòng); ghi báo cáo | Điểm phải kiểm trên máy thật (không phải câu hỏi mở, là việc trong phase): tên mục hệ thống bằng tiếng Anh và tiếng Việt ("Privacy & Security" / "Quyền riêng tư & Bảo mật", "Focus" / "Tập trung", "Paste from Other Apps" / "Dán từ ứng dụng khác") — A1.4, M1.1; hiệu ứng Magic diff --git a/plans/20260925-implementation/reports/phase-07-sp1-measure.md b/plans/20260925-implementation/reports/phase-07-sp1-measure.md new file mode 100644 index 0000000..6c01404 --- /dev/null +++ b/plans/20260925-implementation/reports/phase-07-sp1-measure.md @@ -0,0 +1,168 @@ +English | [Tiếng Việt](phase-07-sp1-measure.vi.md) + +# SP1 real-device measurement: Bluetooth control channel (Samsung Galaxy S25 + Mac) + +Goal: measure AC1 (text envelope latency) and AC2 (reconnect time) of the Bluetooth control channel on real +hardware, with both devices offline. The owner decided to measure SP1 before continuing Phase 7. Targets: +`phase-07-ket-noi-moi-noi.md` AC1/AC2. `reports/phase-07-spike-g7.md` has the numbers from the throwaway spike; +this run repeats them on the real app. + +## Wiring check (done 2026-10-29, before this runbook was written) + +Confirmed on the real app path, not just classes that exist unused: + +- Android: `ConnectionRuntime.startLocked()` calls `bluetooth.start(...)` (`BluetoothAdvertising`) beside the LAN + server (`handlive-android/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt:213`). + `ConnectionRuntime` is created and run by the real foreground service, `HandLiveService.onStartCommand` + (`handlive-android/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/HandLiveService.kt:50`). +- macOS: `AppModel`'s default `makeManager` builds `ConnectionManager(localCapability:, relay:)` + (`handlive-apple/Packages/HLMacUI/Sources/HLMacUI/AppModel.swift:108`), whose default `bluetooth` parameter is + `ConnectionManager.defaultBluetoothCentral()`, which is `CoreBluetoothCentral()` on macOS + (`handlive-apple/Packages/HLTransport/Sources/HLTransport/ConnectionManager.swift:91-96`). + +Both sides are wired for real. No blocker to measuring AC1. + +**Known gap, not a blocker, flagged to Lead separately:** `bt_seen`/`bt_lost` bench events (named in AC2 and in +`shared/tools/bench/README.md`) are not emitted anywhere in production code — only in the throwaway spike +(`handlive-apple/Tools/BLESpike`, `handlive-android/tools/ble-spike`). `CoreBluetoothCentral.swift` has zero +`BenchLog.event` calls. `reconnect_time.py` still produces a usable number without them: it falls back to +`since_loss_ms` (Connected → leaves Connected → Connected again) when no `bt_seen`/`wake`/`net up` trigger is +found in the window, which is a safe upper bound on AC2's "time since the peer was seen again". Use that column. + +## Conditions + +- Both devices **offline**: Wi-Fi off on the S25 (Settings → Wi-Fi) and on the Mac (menu bar), mobile data off on + the S25. Bluetooth **on** on both. +- The two devices must already be **paired from a previous LAN session** (flow A, `pairing_secret` stored) — + SP1 only exercises the Bluetooth control channel of an existing pair, not pairing itself (A7.2/M7.2, not done + yet). +- S25 connected to this Mac over USB with `adb` (USB debugging on). Run the script on this same Mac, since it is + also the measurement device for the Bluetooth side. +- Xcode command-line tools and `xcodegen` installed (`brew install xcodegen` if missing). + +## Steps + +The measurement runs in up to three **log sessions**. `clip_latency.py` groups clips by type (text or image), not +by condition, so text clips from the idle pass, the load pass and the Doze pass must sit in separate log files or +their numbers mix. Each run of the script starts a new session with new log files. + +### Session 1: AC1 idle, AC2, scan response + +1. `zsh handlive/tools/measure-sp1.sh [adb_serial]` (omit `adb_serial` when only one device is attached). It: + - builds and installs the debug APK on the S25, grants `BLUETOOTH_ADVERTISE`/`BLUETOOTH_CONNECT` (there is no + permission screen yet — A7.7a/b are not built — so the script grants them directly via `pm grant`), opens + the app; + - runs `xcodegen generate` + `xcodebuild build` for the macOS app (scheme `HandLive`, Debug) and opens it; + - starts `adb logcat` and `log stream`, writing `HLBENCH/1` lines to + `handlive/.build-logs/sp1--android.log` and `...-mac.log`. The script prints both paths; keep + them for the checks below. +2. Confirm the link is really on Bluetooth. **The Mac's status indicator shows Wi-Fi even on a Bluetooth link** + (temporary placeholder label until the Bluetooth strings land, `AppModel+Link.swift:109`), so do not judge by + the label. In a second terminal, run: + + ```sh + grep 'ev=state' | tail -n 1 + ``` + + The line must end with `to=Connected channel=bluetooth`. Run it again whenever you need to check the link + during this session. +3. AC1 idle: copy a short text clip on one device, confirm it lands on the other, repeat **at least 30 times**, a + few seconds apart, alternating direction. When done, run the `grep` from step 2 again: if the last line is + not `to=Connected channel=bluetooth`, or the log shows `to=ConnectingBT` repeatedly, note it — that is itself a + finding, not expected noise. +4. AC2: toggle Bluetooth off then on again on the S25 (Settings → Connected devices → Bluetooth, or the Quick + Settings tile), wait until the `grep` from step 2 shows `to=Connected channel=bluetooth` again, then repeat + **at least 5 times**. A phone screen-off period between rounds is fine and closer to real use. +5. Scan-response check (spec risk, 0.4.5 "Advertisement"): lock the S25 screen (keep Bluetooth on) and wait about + a minute. On the Mac, quit HandLive (Cmd-Q), then open it again with `open ` (the script printed + the path on the `App macOS:` line). With the phone screen still off, confirm the Mac connects again (`grep` + from step 2) and that a new `bt_accept` line appears in the Android log: + + ```sh + grep -c 'ev=bt_accept' + ``` + + The count must go up by at least one. This checks the "service data read on Mac even in background" + acceptance line of A7.1b/M7.1c with the phone's screen off. +6. Press Ctrl-C in the script's terminal to stop logging. It prints the two log paths and line counts. + +### Session 2 (optional): AC1 under load + +7. Run `zsh handlive/tools/measure-sp1.sh --log-only [adb_serial]` — same as step 1 but skips the APK build/install + and the Xcode build, reusing the app already installed/built by session 1 and just reopening it on both + devices (fails with a clear message if session 1 was never run in full). Confirm the Bluetooth link (step 2). + Start a large image copy (≈5 MB) in one direction and, while it is in flight, do a few short-text copies in the + other direction. Repeat until you have about 10 text copies. Expected to be **much worse** per the spike (FIFO + queue, no priority lane yet — D7.1/A7.4 not built). Record the number; a miss here is not a blocker. Ctrl-C to + stop. + +### Session 3: AC1 with the phone in Doze + +8. Run `zsh handlive/tools/measure-sp1.sh --log-only [adb_serial]` (same reuse as step 7) and confirm the Bluetooth + link (step 2). Put the S25 into Doze with the same commands the spike used (add `-s ` after `adb` + when more than one device is attached): + + ```sh + adb shell dumpsys battery unplug + adb shell input keyevent KEYCODE_SLEEP + adb shell dumpsys deviceidle force-idle + ``` + +9. Leave the phone untouched. On the **Mac only**, copy a short text clip about every 30 seconds, **at least 30 + times** (about 15 minutes, like the spike's Doze pass). Only the Mac → phone direction is measured here: the + phone's screen stays off. +10. Restore the phone, then Ctrl-C to stop logging: + + ```sh + adb shell dumpsys deviceidle unforce + adb shell dumpsys battery reset + adb shell input keyevent KEYCODE_WAKEUP + ``` + +## Reading AC1 / AC2 from the logs + +The two tools below already exist and are channel-agnostic (they read `clip_read`/`clip_applied`/`state` +regardless of whether the session is LAN or Bluetooth — `ConnectionRoute.benchChannel` on macOS, and `state`'s +own `channel` field). Run them on each session's pair of logs: + +```sh +python3 handlive-shared/tools/bench/clip_latency.py \ + handlive/.build-logs/sp1--android.log handlive/.build-logs/sp1--mac.log --json + +python3 handlive-shared/tools/bench/reconnect_time.py \ + handlive/.build-logs/sp1--mac.log handlive/.build-logs/sp1--android.log --json +``` + +- AC1: `clip_latency.py`'s bucket **"text inline"** — from session 1 for idle, session 2 for under load, session + 3 for Doze. Its built-in target (50 ms, `--check`) is the **LAN** target from Phase 1 — ignore `--check`'s + verdict here and compare the printed `p95` by hand against **200 ms** (sessions 1 and 2) or **3000 ms** + (session 3, Doze — owner-accepted relaxed threshold for that condition only, since the spike measured p95 ≈ + 664 ms one-way under real Doze). +- AC2: `reconnect_time.py` on the session 1 logs. Read `since_loss_ms` (not `reconnect_ms`, which needs `bt_seen` + — see the gap above) at its **p95** across the step-4 rounds, compare by hand against **3000 ms**. +- A transfer or episode the tools can't pair (missing `clip_applied`, no return to `Connected`) is dropped + silently from the percentile — if the count in the output is smaller than the copies or rounds actually done, + say so in the results, don't just report the percentile. + +## Results (fill in after measuring) + +| Scenario | Metric | Target | n | p50 | p95 | Met? | +|---|---|---|---|---|---|---| +| AC1, idle, established link (session 1) | text inline latency (one-way, `clip_applied` − `clip_read`) | ≤ 200 ms | | | | | +| AC1, during a 5 MB image transfer (session 2) | same | ≤ 200 ms | | | | | +| AC1, phone in Doze, Mac → phone (session 3) | same | ≤ 3000 ms (relaxed) | | | | | +| AC2, Bluetooth toggle off/on (session 1) | `since_loss_ms` | ≤ 3000 ms | | | | | +| Scan response, phone screen off (session 1, step 5) | Mac reconnects, new `bt_accept` line | pass/fail | | — | — | | + +## Risks called out in the spec, carried into this measurement + +- **Scan response** (0.4.5 "Advertisement"): the spike never tried it; A7.1b/M7.1c's acceptance line requires + "scan response contains service data read on Mac, even when app is in background" — step 5 above is the first + real check of this on production code. +- **Doze RTT**: the spike measured p95 ≈ 664 ms one-way during 15 minutes of forced Doze on the spike's own + loopback, against the general 200 ms target — hence the separate 3000 ms threshold accepted for that condition + only; this run is the first measurement on the real app's scheduler/session code instead of the spike's. +- **Hint/PSM rotation** (`BT_HINT_PERIOD` = 15 minutes, `00-common-specs.md:1370`): a run longer than ~15 minutes + crosses a rotation boundary; if AC2 numbers look unusually bad for one round only, check whether that round + straddled a rotation (the advertising set and PSM both change at the boundary, `00-common-specs.md:225`) before + calling it a regression. diff --git a/plans/20260925-implementation/reports/phase-07-sp1-measure.vi.md b/plans/20260925-implementation/reports/phase-07-sp1-measure.vi.md new file mode 100644 index 0000000..1ce71f4 --- /dev/null +++ b/plans/20260925-implementation/reports/phase-07-sp1-measure.vi.md @@ -0,0 +1,164 @@ +[English](phase-07-sp1-measure.md) | Tiếng Việt + +# Đo thật SP1: kênh điều khiển Bluetooth (Samsung Galaxy S25 + Mac) + +Mục tiêu: đo AC1 (độ trễ gói văn bản) và AC2 (thời gian kết nối lại) của kênh điều khiển Bluetooth trên máy thật, +khi cả hai máy đều mất mạng. Sếp đã chốt đo SP1 trước rồi mới làm tiếp Phase 7. Ngưỡng: +`phase-07-ket-noi-moi-noi.md` AC1/AC2. `reports/phase-07-spike-g7.md` có số đo của spike dùng rồi bỏ; lần này đo +lại trên app thật. + +## Kiểm nối dây (đã làm ngày 2026-10-29, trước khi viết runbook này) + +Đã xác nhận trên đường chạy thật của app, không phải class có sẵn mà chưa ai gọi: + +- Android: `ConnectionRuntime.startLocked()` gọi `bluetooth.start(...)` (`BluetoothAdvertising`) song song với + server LAN (`handlive-android/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/ConnectionRuntime.kt:213`). + `ConnectionRuntime` do foreground service thật `HandLiveService.onStartCommand` tạo và chạy + (`handlive-android/feature/connection/src/main/kotlin/app/handlive/android/feature/connection/HandLiveService.kt:50`). +- macOS: `makeManager` mặc định của `AppModel` dựng `ConnectionManager(localCapability:, relay:)` + (`handlive-apple/Packages/HLMacUI/Sources/HLMacUI/AppModel.swift:108`); tham số `bluetooth` mặc định của nó là + `ConnectionManager.defaultBluetoothCentral()`, trên macOS trả về `CoreBluetoothCentral()` + (`handlive-apple/Packages/HLTransport/Sources/HLTransport/ConnectionManager.swift:91-96`). + +Cả hai phía đều đã nối thật. Không có gì cản việc đo AC1. + +**Thiếu sót đã biết, không chặn, đã báo riêng cho Lead:** bench event `bt_seen`/`bt_lost` (nêu trong AC2 và +`shared/tools/bench/README.md`) chưa được ghi ở đâu trong code thật, chỉ có trong spike dùng rồi bỏ +(`handlive-apple/Tools/BLESpike`, `handlive-android/tools/ble-spike`). `CoreBluetoothCentral.swift` không có lệnh +`BenchLog.event` nào. Thiếu hai event này, `reconnect_time.py` vẫn ra số dùng được: khi không thấy mốc +`bt_seen`/`wake`/`net up` nào trong khoảng đo, nó dùng `since_loss_ms` (rời Connected → vào lại Connected). Số này +là chặn trên an toàn cho "thời gian từ lúc thấy lại peer" mà AC2 cần. Dùng cột đó. + +## Điều kiện + +- Cả hai máy **mất mạng**: tắt Wi-Fi trên S25 (Cài đặt → Wi-Fi) và trên Mac (thanh menu), tắt dữ liệu di động + trên S25. **Bật** Bluetooth trên cả hai. +- Hai máy phải **đã ghép đôi từ một phiên LAN trước đó** (luồng A, đã lưu `pairing_secret`). SP1 chỉ chạy kênh + điều khiển Bluetooth của một cặp đã ghép, không đo việc ghép đôi (A7.2/M7.2 chưa làm). +- S25 cắm vào chính Mac này qua USB, `adb` thấy máy (đã bật USB debugging). Chạy script trên Mac này, vì Mac cũng + là máy đo phía Bluetooth. +- Đã cài công cụ dòng lệnh Xcode và `xcodegen` (chưa có thì `brew install xcodegen`). + +## Các bước + +Bài đo chạy tối đa ba **phiên log**. `clip_latency.py` gộp các lần copy theo loại (text hay ảnh), không theo điều +kiện đo, nên text của lượt không tải, lượt đang tải và lượt Doze phải nằm ở các file log riêng, nếu không số sẽ +lẫn vào nhau. Mỗi lần chạy script là một phiên mới với file log mới. + +### Phiên 1: AC1 không tải, AC2, scan response + +1. `zsh handlive/tools/measure-sp1.sh [adb_serial]` (bỏ `adb_serial` nếu chỉ cắm một máy). Script sẽ: + - build và cài APK debug lên S25, cấp quyền `BLUETOOTH_ADVERTISE`/`BLUETOOTH_CONNECT` (app chưa có màn xin quyền + vì A7.7a/b chưa làm, nên script cấp thẳng bằng `pm grant`), rồi mở app; + - chạy `xcodegen generate` + `xcodebuild build` cho app macOS (scheme `HandLive`, Debug) rồi mở app; + - chạy `adb logcat` và `log stream`, ghi các dòng `HLBENCH/1` vào + `handlive/.build-logs/sp1--android.log` và `...-mac.log`. Script in ra hai đường dẫn này; giữ lại + để dùng ở các bước kiểm bên dưới. +2. Xác nhận kết nối đang thật sự đi qua Bluetooth. **Chỉ báo trạng thái trên Mac hiện Wi-Fi kể cả khi đang chạy + Bluetooth** (nhãn tạm cho tới khi có chuỗi Bluetooth, `AppModel+Link.swift:109`), nên đừng dựa vào nhãn. Mở + một cửa sổ Terminal thứ hai và chạy: + + ```sh + grep 'ev=state' <đường dẫn log mac> | tail -n 1 + ``` + + Dòng in ra phải kết thúc bằng `to=Connected channel=bluetooth`. Cần kiểm lại kết nối lúc nào trong phiên này + thì chạy lại lệnh đó. +3. AC1 không tải: copy một đoạn text ngắn trên một máy, xác nhận máy kia nhận được, lặp lại **ít nhất 30 lần**, + cách nhau vài giây, luân phiên hai chiều. Xong thì chạy lại lệnh `grep` ở bước 2: nếu dòng cuối không phải + `to=Connected channel=bluetooth`, hoặc log có `to=ConnectingBT` lặp đi lặp lại, ghi lại: đó là một phát hiện, + không phải nhiễu bình thường. +4. AC2: tắt rồi bật lại Bluetooth trên S25 (Cài đặt → Thiết bị đã kết nối → Bluetooth, hoặc ô Cài đặt nhanh), chờ + tới khi lệnh `grep` ở bước 2 lại cho `to=Connected channel=bluetooth`, rồi lặp lại **ít nhất 5 lần**. Tắt màn + hình điện thoại giữa các vòng cũng được, càng giống dùng thật. +5. Kiểm tra scan response (rủi ro đã nêu trong spec, 0.4.5 "Advertisement"): khoá màn hình S25 (vẫn bật + Bluetooth) và chờ khoảng một phút. Trên Mac, thoát HandLive (Cmd-Q) rồi mở lại bằng `open <đường dẫn app macOS>` + (script đã in đường dẫn này ở dòng `App macOS:`). Trong lúc điện thoại vẫn tắt màn hình, xác nhận Mac kết nối + lại được (lệnh `grep` ở bước 2) và log Android có thêm dòng `bt_accept` mới: + + ```sh + grep -c 'ev=bt_accept' <đường dẫn log android> + ``` + + Số đếm phải tăng ít nhất một. Bước này kiểm điều kiện "service data đọc được trên Mac dù app đang ở nền" trong + tiêu chí nghiệm thu A7.1b/M7.1c, khi điện thoại đã tắt màn hình. +6. Bấm Ctrl-C ở cửa sổ đang chạy script để dừng ghi log. Script in ra đường dẫn hai file log và số dòng của mỗi + file. + +### Phiên 2 (tuỳ chọn): AC1 khi đang tải + +7. Chạy `zsh handlive/tools/measure-sp1.sh --log-only [adb_serial]` — giống bước 1 nhưng bỏ qua build/cài APK và + build Xcode, dùng lại app đã cài/đã build từ phiên 1, chỉ mở lại app ở hai máy (báo lỗi rõ nếu phiên 1 chưa + chạy đủ một lần không có `--log-only`). Xác nhận kết nối Bluetooth (bước 2). Copy một ảnh lớn (khoảng 5 MB) + theo một chiều, trong lúc ảnh đang truyền thì copy vài đoạn text ngắn theo chiều ngược lại. Lặp lại tới khi + được khoảng 10 lần copy text. Theo spike thì số sẽ **tệ hơn nhiều** (hàng đợi FIFO, chưa có làn ưu tiên vì + D7.1/A7.4 chưa làm). Cứ ghi số lại, không đạt ở phiên này không tính là chặn. Bấm Ctrl-C để dừng. + +### Phiên 3: AC1 khi điện thoại ở Doze + +8. Chạy `zsh handlive/tools/measure-sp1.sh --log-only [adb_serial]` (dùng lại app như bước 7) và xác nhận kết nối + Bluetooth (bước 2). Đưa S25 vào Doze bằng đúng các lệnh spike đã dùng (cắm nhiều máy thì thêm `-s ` + ngay sau `adb`): + + ```sh + adb shell dumpsys battery unplug + adb shell input keyevent KEYCODE_SLEEP + adb shell dumpsys deviceidle force-idle + ``` + +9. Không chạm vào điện thoại. **Chỉ trên Mac**, copy một đoạn text ngắn khoảng 30 giây một lần, **ít nhất 30 lần** + (khoảng 15 phút, giống lượt Doze của spike). Phiên này chỉ đo chiều Mac → điện thoại, vì màn hình điện thoại + luôn tắt. +10. Trả điện thoại về trạng thái thường, rồi bấm Ctrl-C để dừng ghi log: + + ```sh + adb shell dumpsys deviceidle unforce + adb shell dumpsys battery reset + adb shell input keyevent KEYCODE_WAKEUP + ``` + +## Đọc AC1 / AC2 từ log + +Hai công cụ dưới đây đã có sẵn và không phân biệt kênh (đọc `clip_read`/`clip_applied`/`state` bất kể phiên là LAN +hay Bluetooth: `ConnectionRoute.benchChannel` trên macOS, và trường `channel` riêng của event `state`). Chạy trên +cặp log của từng phiên: + +```sh +python3 handlive-shared/tools/bench/clip_latency.py \ + handlive/.build-logs/sp1--android.log handlive/.build-logs/sp1--mac.log --json + +python3 handlive-shared/tools/bench/reconnect_time.py \ + handlive/.build-logs/sp1--mac.log handlive/.build-logs/sp1--android.log --json +``` + +- AC1: nhóm **"text inline"** của `clip_latency.py`, lấy từ phiên 1 cho lúc không tải, phiên 2 cho lúc đang tải, + phiên 3 cho Doze. Ngưỡng dựng sẵn của công cụ (50 ms, cờ `--check`) là ngưỡng **LAN** của Phase 1, nên bỏ qua + kết luận của `--check`. Tự so `p95` in ra với **200 ms** (phiên 1 và 2) hoặc **3000 ms** (phiên 3, Doze; ngưỡng + nới này sếp đã chấp nhận riêng cho điều kiện đó, vì spike đo được p95 ≈ 664 ms một chiều khi Doze thật). +- AC2: chạy `reconnect_time.py` trên log của phiên 1. Đọc **p95** của `since_loss_ms` qua các vòng ở bước 4 + (không đọc `reconnect_ms`, vì cột đó cần `bt_seen`, xem thiếu sót ở trên), tự so với **3000 ms**. +- Lần copy hoặc vòng kết nối lại nào mà công cụ không ghép được cặp (thiếu `clip_applied`, không thấy quay lại + `Connected`) sẽ bị bỏ khỏi phép tính phân vị mà không báo gì. Nếu số đếm trong kết quả ít hơn số lần copy hoặc + số vòng đã làm, ghi rõ điều đó trong kết quả, đừng chỉ báo con số phân vị. + +## Kết quả (điền sau khi đo) + +| Bài đo | Chỉ số | Ngưỡng | n | p50 | p95 | Đạt? | +|---|---|---|---|---|---|---| +| AC1, không tải, link đã thiết lập (phiên 1) | độ trễ text inline (một chiều, `clip_applied` − `clip_read`) | ≤ 200 ms | | | | | +| AC1, trong lúc truyền ảnh 5 MB (phiên 2) | như trên | ≤ 200 ms | | | | | +| AC1, điện thoại ở Doze, Mac → điện thoại (phiên 3) | như trên | ≤ 3000 ms (nới) | | | | | +| AC2, tắt/bật Bluetooth (phiên 1) | `since_loss_ms` | ≤ 3000 ms | | | | | +| Scan response, điện thoại tắt màn hình (phiên 1, bước 5) | Mac kết nối lại, có dòng `bt_accept` mới | đạt/không | | — | — | | + +## Rủi ro đã nêu trong spec, mang theo vào lần đo này + +- **Scan response** (0.4.5 "Advertisement"): spike chưa từng thử; tiêu chí nghiệm thu A7.1b/M7.1c yêu cầu "scan + response chứa service data đọc được trên Mac, dù app đang ở nền". Bước 5 ở trên là lần kiểm thật đầu tiên trên + code sản phẩm. +- **RTT khi Doze**: spike đo được p95 ≈ 664 ms một chiều trong 15 phút Doze cưỡng ép, trên vòng loopback riêng của + spike, so với ngưỡng chung 200 ms. Vì vậy mới có ngưỡng riêng 3000 ms, chỉ áp cho điều kiện này. Lần này là lần + đầu đo trên scheduler/session của app thật thay vì của spike. +- **Xoay hint/PSM** (`BT_HINT_PERIOD` = 15 phút, `00-common-specs.md:1370`): bài đo dài hơn khoảng 15 phút sẽ đi + qua một mốc xoay. Nếu số AC2 xấu bất thường ở đúng một vòng, kiểm xem vòng đó có trùng lúc xoay không (bộ quảng + bá và PSM cùng đổi tại mốc xoay, `00-common-specs.md:225`) trước khi coi là hồi quy. diff --git a/plans/20260925-implementation/reports/phase-07-spike-g7.md b/plans/20260925-implementation/reports/phase-07-spike-g7.md new file mode 100644 index 0000000..3df020c --- /dev/null +++ b/plans/20260925-implementation/reports/phase-07-spike-g7.md @@ -0,0 +1,590 @@ +# Phase 7 — spike G7: BLE L2CAP between an S25 and a Mac + +Question (`plans/20260925-implementation/phase-07-ket-noi-moi-noi.md`, gate G7): does BLE L2CAP CoC +(`listenUsingInsecureL2capChannel` ↔ `CBPeripheral.openL2CAPChannel`, no bonding, no HFP, no relay) meet AC1 +(Bluetooth ≤ 200 ms on an established link, also while a bulk transfer runs), AC2 (reconnect ≤ 3 s, `bt_seen`/ +`bt_lost`) and AC6 (background behavior feeding into the ≤ 15 s pairing budget) between a Samsung Galaxy S25 and a +Mac? **No bearer meets AC1 on an established link → stop Phase 7**, so this spike is the gate for everything else +in the file. + +## Status: gate closed 2026-10-28 — bearer is BLE L2CAP CoC + D7.1; Phase 7 continues; supplementary numbers pending + +- **Gate closed (2026-10-28).** The owner ran `run-spike.sh` directly in a terminal over several sessions that day + (Lần 1 to 5b below). AC1 is met on an established, idle link (Lần 2: one-way est. p50 91.72 / p95 133.97 ms), so + the stop condition ("no bearer meets AC1 on an established link → stop Phase 7") did **not** trigger. +- **Bearer:** BLE L2CAP CoC (insecure) plus an app-layer link-security layer (D7.1), with 4 mandatory conditions + (see "Kết luận bearer"): (1) control-before-bulk priority, (2) rebuild the L2CAP server and advertiser on + `STATE_ON`, (3) accept the Doze latency or trade in a minimal GATT server for priority, (4) bound the frame + length. Conditions 1, 2 and 4 are now in product code (`core:transport`/`feature:connection`, topic "Viết code + Phase 7", closed by decision #3285): `BluetoothLinkScheduler` (1), `BluetoothLinkLifecycle`/ + `BluetoothAdvertising` (2), `BtRecordCodec.requireWithinBound`/`requireValidTransportRlen` (4). Condition 3 is a + product trade-off, not a code change. +- **Supplementary numbers pending:** more reconnect rounds for a real p95 (n=3 today), Mac sleep/wake, out of range, + and the AC6 QR-pairing flow itself. They need more S25 + Mac time (see "Chưa đo"/"What's needed next") and do not + reopen the bearer choice. + +The agent still cannot build or run the probes itself: + +1. **`adb`**: `could not install *smartsocket* listener: Operation not permitted` — the sandbox will not let a + process bind a local TCP port, at all, even after the owner granted `Bash(adb *)` and then `Bash(*)`. Those + grants govern which commands may run, not what the OS sandbox lets a running process do. +2. **Gradle** (`./gradlew -p tools/ble-spike assembleDebug`, and the raw `gradle` binary with `GRADLE_USER_HOME` + redirected into a writable dir to dodge the same lock-file permission problem in `~/.gradle`): fails for the + same reason one level up — `FileLockContentionHandler` needs a local port for its own cross-process lock + coordination. `Could not create service of type FileLockContentionHandler … java.net.SocketException: + Operation not permitted`. +3. **`swift build`**: fails differently but for a related reason — compiling `Package.swift` shells out through + `sandbox-exec`, and this sandbox refuses to let a sandboxed process apply a second, nested sandbox: + `sandbox-exec: sandbox_apply: Operation not permitted`. + +None of these are fixable by this agent; they are OS-level Seatbelt restrictions the owner would have to change. +Given that, the probes below were written to match the exact working patterns of the repo's other standalone +spikes (`tools/web-spike`, `tools/fake-call-app`, `Tools/HFPSpike`) as closely as possible, and reviewed by +re-reading every file against the Android/CoreBluetooth APIs by hand, since the usual `assembleDebug`/`swift build` +verification loop was not available this session. **Build verification is still owed** — see "What's needed next". + +## Prepared (2026-10-28) + +### Android probe — `handlive-android/tools/ble-spike/` + +- **Standalone build:** own `settings.gradle.kts` reusing `gradle/libs.versions.toml` (AGP 9.4.1, Kotlin 2.4.20), + not in the root build or CI. Package `app.handlive.spike.ble`, `minSdk 29` (L2CAP CoC needs API 29), + `targetSdk 35`. Build: `./gradlew -p tools/ble-spike assembleDebug`. +- **BLE peripheral, `connectedDevice` foreground service** (`BleSpikeService`): advertises a fixed service UUID + (`0000feed-0000-1000-8000-00805f9b34fb`, spike-only, Bluetooth Base UUID pattern so it encodes as a compact + 16-bit UUID and fits the 31-byte legacy advertising payload — see `BleSpikeService.kt`) with the dynamic L2CAP + PSM as 2 little-endian bytes of advertisement service data (no GATT server stood up — the L2CAP server socket from + `listenUsingInsecureL2capChannel` is independent of GATT); accepts one channel at a time, re-advertises and + re-accepts after every disconnect. A 30 s heartbeat log is the proof point for AC6 (screen off 10 min / Doze): + if `HLBLESPIKE heartbeat` keeps ticking on `adb logcat` while the S25 screen is off, advertising/accept survived. +- **Protocol** (`SpikeProtocol.kt`, mirrored in the Apple probe): 4-byte big-endian length, 1 type byte, payload. + `PING`→`PONG` for latency; `BULK_START`(8-byte total)/`BULK_DATA`*/`BULK_END`→`BULK_ACK` for throughput, with the + server able to answer a `PING` at any point mid-bulk-transfer (frames are typed, so the client can interleave + one PING between two BULK_DATA frames without a second channel). +- **Control:** `adb shell am start -n app.handlive.spike.ble/.CommandActivity --es cmd ` (adb entry + point pattern copied from `tools/fake-call-app`'s `CommandActivity`). +- **Runbook:** `tools/ble-spike/README.md` (build, install, runtime permission grants, adb commands, event list, + and what this spike does not cover — RFCOMM, Wi-Fi Direct, link encryption, see below). + +### macOS probe — `handlive-apple/Tools/BLESpike/` + +- **SwiftPM executable**, no app bundle, Info.plist embedded in the binary like `HFPSpike` (so macOS can prompt + for Bluetooth access run from a terminal). Links `CoreBluetooth` only. Build: `swift build -c release`. +- **Central** (`SpikeCentral`): scans for the fixed service UUID, reads the PSM out of + `CBAdvertisementDataServiceDataKey`, connects, opens the L2CAP channel (`openL2CAPChannel`, never triggers + pairing). `FramedChannel` wraps the channel's `InputStream`/`OutputStream` (Foundation `Stream`, not a raw + socket) with the same length-prefixed framing as the Android side. +- **Measurement sequence** (`Measurements.swift`, `MeasurementRunner.run`): 30 baseline PINGs → 1 MB bulk transfer + with a PING interleaved every ~10 % of the transfer → 5 MB the same way → a human-driven `bt_lost`/`bt_seen` + reconnection loop (default 3 rounds; the probe prints a prompt and waits for the peer to disappear and come + back — phone Bluetooth off, out of range, or Mac sleep/wake — rather than forcing the disconnect itself, so the + numbers reflect the real scenarios AC2 cites). +- **Methodology already decided (see `README.md`/`README.vi.md` for the full text)**, because the brief asks for + "ghi số thật, kèm cách đo": + - **Latency** is measured as a PING/PONG round trip (`rtt_ms`); there is no shared clock between the two + machines for a true one-way number, so the probe reports half the RTT as the one-way estimate and that is + what should be compared against AC1's 200 ms, not the raw RTT. + - **Throughput** is the sender's own `BULK_START` → all `BULK_DATA` → `BULK_END` → received `BULK_ACK` elapsed + time, which includes one final round trip, not pure transfer time. + - **Latency "while transferring"** is PINGs interleaved on the *same* L2CAP stream as the bulk data (a channel + has exactly one stream each way) — not a true parallel measurement; the report must say so next to the + numbers, not just show them. + - **Reconnection**: `bt_lost` = this process's `didDisconnectPeripheral`; `bt_seen` = the next successful + `didOpen` L2CAP channel after a fresh scan; record the cause (Bluetooth off / out of range / Mac sleep) per + round since AC2 may behave differently by cause. +- **`state` subcommand**: prints `CBManager.state` and exits — this is the check the team asked for in case + CoreBluetooth reports `poweredOff` or `unauthorized` on this Mac. **Not run yet** (blocked on the `swift build` + sandbox failure above); run it first, before anything else, once build access is unblocked. + +### One-shot runner — `handlive-android/tools/ble-spike/run-spike.sh` + +Chains build → install → grant the Nearby-devices/notification runtime permissions → start the Android service → +build the macOS probe → run the full measurement sequence → stop the service → pull `adb logcat -s HLBLESPIKE`. +Written because every step needs `adb` or local-port-binding `gradle`/`swift build`, none of which this agent's +sandbox allows; it is meant to be run directly in a normal terminal (`tools/ble-spike/run-spike.sh [rounds] +[out-dir]`), not through the agent. Usage is also in the Android README. + +### Scan-response layout, task #187 (2026-10-28) — code sẵn sàng, **chưa đo riêng theo quyết định của sếp (#3309)** + +Theo #3291 (Minh2), trước khi chốt vector service data cho A7.1/M7.1 cần thử layout quảng bá thật của 0.4.5 trên +S25 + Mac — UUID 128-bit đã chiếm 21/31 byte của gói quảng bá, nên service data phải nằm ở scan response (29 byte). +Cả hai bên đã có chế độ đo riêng, tách khỏi luồng PING/BULK hiện có (không đụng `SpikeProtocol.SERVICE_UUID` +16-bit đang dùng cho các "Lần" đo ở trên): + +- **Android** (`BleSpikeService.kt`): `adb shell am start -n app.handlive.spike.ble/.CommandActivity --es cmd + start --es layout scanresp` chuyển sang layout `BT_SERVICE_UUID` thật + (`3b3a01f9-ab27-44a8-bb68-b9b11a488bdb`, hằng số mới `SpikeProtocol.SERVICE_UUID_128`): gói quảng bá chỉ có + Flags (3, platform tự thêm) + Complete list 128-bit Service UUID (18) = 21 byte; scan response là một AD Service + Data 128-bit = header+UUID (18) + `ver`(1) ‖ `psm`(2) ‖ 2 hint giả 4-byte-0 (8) = 29 byte, đúng công thức bảng + 0.4.5 (18 + 3 + 4×2, `BT_ADV_MAX_HINTS`=2). Hint thật (HMAC-SHA256) là việc của A7.1, spike này chỉ test khung. + Event mới `advertise_layout` ghi layout đang dùng; `advertise_start_success`/`advertise_start_failure` (sẵn có) + trả lời câu "có báo ADVERTISE_FAILED không". +- **Mac** (`ScanResponseProbe.swift`, lệnh mới `BLESpike scanresp --rounds --log `): quét riêng theo UUID + 128-bit, đo thời gian từ lúc bắt đầu quét tới lần đầu thấy `CBAdvertisementDataServiceDataKey` cho UUID đó, lặp + mặc định 10 lần, không mở kết nối L2CAP. **Lưu ý đọc số**: CoreBluetooth trả về service data đã bóc UUID + header + AD, nên `service_data_bytes` log được sẽ là 11 (`ver`+`psm`+2 hint), không phải 29 — cộng 18 vào thì ra đúng tổng + scan response 29 byte của bảng spec; đừng đọc "11 byte" thành "Mac không nhận đủ 29 byte". +- **`run-spike.sh --scanresp [rounds=10] [out-dir]`**: build/install/grant như các mode khác, bật layout + `scanresp`, đo 10 vòng màn hình sáng (`mac-scanresp-screenon.jsonl`), tắt màn hình S25 (`KEYCODE_SLEEP`) rồi đo + tiếp 10 vòng (`mac-scanresp-screenoff.jsonl`), luôn đánh thức lại màn hình trước khi thoát (cả khi lỗi giữa + chừng, cờ `SCANRESP_SCREEN_OFF` trong `cleanup()`). +- **Build verification**: `swiftc -typecheck` (bỏ qua bước build manifest của `swift build` — chính bước đó mới + bị sandbox chặn `sandbox_apply`, xem "Status" đầu file) chạy sạch trên toàn bộ `Sources/BLESpike/*.swift`, 0 lỗi + — chỉ warning Sendable-closure đã có sẵn trong code cũ, không phải do thay đổi lần này. Phía Kotlin vẫn chặn bởi + cùng giới hạn sandbox đã ghi ở "Status" (Gradle cần bind port) — đã soát tay theo đúng chữ ký API Android + (`BluetoothLeAdvertiser.startAdvertising(settings, advertiseData, scanResponse, callback)`, overload có thật từ + API 21) và tính tay byte budget (khớp bảng 0.4.5, xem trên). +- **Không chạy đo riêng**: sếp quyết không cần (#3309) — code giữ nguyên trong ble-spike. Scan response chưa đo + riêng; kiểm khi A7.1b/M7.1c chạy trên S25 + Mac. + +## Measurements against AC1 / AC2 / AC6 + +### Lần 1 (trước khi tối ưu) — 2026-10-28 10:44, chạy thật trên S25 + Mac + +Nguồn: `reports/assets/ble-g7/mac-run.jsonl`, `run.log` (không có `android-HLBLESPIKE.log` cho lần này — script bị +harness dừng ở ~600 s trước khi tới bước `trap cleanup EXIT` kéo log Android). Đây là số liệu **trước** các thay +đổi rút gọn bài đo của task #165 (xem "Ghi nhận & thay đổi #165" ngay dưới bảng). + +| Scenario | Metric | Target | S25 ↔ Mac | Notes | +|---|---|---|---|---| +| 1 KB, established link, idle | latency p50 / p95 (one-way est.) | AC1 ≤ 200 ms | **149.76 / 179.38 ms** | n=30; đạt AC1 nhưng sát ngưỡng (xem nhận xét) | +| 1 KB, during 1 MB transfer | latency p50 / p95 (one-way est.) | AC1 ≤ 200 ms | **7725.3 / 7768.4 ms** | n=8; không đạt AC1 — xếp hàng FIFO sau dữ liệu bulk đang gửi, chưa có kênh ưu tiên (D7.1) | +| 1 KB, during 5 MB transfer | latency p50 / p95 (one-way est.) | AC1 ≤ 200 ms | ~19814.6 / ~33584.4 ms (tính tay) | n=9, bài chưa hoàn tất — tính trực tiếp từ 9 mẫu `bulk_5mb_in_flight_ping` trong log trước khi bị dừng | +| 1 MB transfer | throughput | — (informational) | **7.45 KB/s** (0.00745 MB/s), 134.19 s | thấp hơn nhiều so với L2CAP CoC thường đạt (50–100+ KB/s) | +| 5 MB transfer | throughput | — (informational) | **chưa xong**: 4,571,136/5,000,000 byte (91 %) ở ~564 s link time khi harness dừng lệnh ở 600 s wall-clock | ở tốc độ 7.45 KB/s, 5 MB đầy đủ cần ~671 s | +| Reconnect, Bluetooth off/on | bt_lost → bt_seen | AC2 ≤ 3 s | — | chưa tới được — bài 5 MB chưa xong nên `reconnectLoop` chưa chạy | +| Reconnect, out of range | bt_lost → bt_seen | AC2 ≤ 3 s | — | chưa tới được, lý do như trên | +| Reconnect, Mac sleep/wake | bt_lost → bt_seen | AC2 ≤ 3 s | — | chưa tới được, lý do như trên | +| S25 screen off 10 min / Doze | advertising + accept still running | AC6 (feeds the pairing budget) | — | pending, read from `HLBLESPIKE heartbeat` | + +**Nghi vấn nguyên nhân (chưa kết luận bearer):** RTT baseline dao động 150–330 ms ở 6 ping đầu rồi ổn định rất đều +quanh ~300 ms từ seq 6 trở đi (ví dụ seq14–17: 299.52/299.89/299.51/299.58 ms — sát nhau tới 2 số lẻ, không giống +jitter ngẫu nhiên). Khớp với giả thuyết: kiến trúc dùng `BluetoothSocket` qua `listenUsingInsecureL2capChannel` +(L2CAP CoC thuần, không dựng GATT server), nên **Android không có API công khai nào để yêu cầu connection interval +nhanh hơn** — `BluetoothGatt.requestConnectionPriority()` chỉ tồn tại trên kết nối GATT, không áp dụng cho +L2CAP socket thuần (đã ghi chú tại `BleSpikeService.kt:244-249`). Link có thể đã "rơi" về một connection interval +tiết kiệm năng lượng (~300 ms) ngay sau khi kết nối ổn định, do không bên nào yêu cầu giữ interval nhanh. Số +throughput 1 MB (7.45 KB/s) khớp chính xác với cách chia nhóm trong code đo: mỗi nhóm "7 chunk × 16 KB = 114688 +byte" giữa hai lần ping mất đều ~15.27 s (log `bulk_1mb_in_flight_ping`, các mốc `bytes_sent` cách nhau đúng +114688 với `t_ms` cách nhau đúng ~15270 ms) — tốc độ ổn định tuyến tính như vậy cho thấy thời gian bị quyết định +bởi tốc độ truyền thật của link, không phải do code đo chủ động sleep/poll. Ping chậm trong lúc truyền bulk +(7.7–33.6 s one-way) là hệ quả FIFO đã biết (một stream duy nhất, ping phải đợi hết bulk đã enqueue trước nó — +thuộc thiết kế D7.1, không phải lỗi của bài đo). + +**Lỗi thật đã tìm và sửa trong code thử (không phải nguyên nhân chính của các số trên, nhưng là race condition có +thật):** `FramedChannel.swift` — `send()` được gọi từ `Task` của Swift Concurrency (không ràng buộc vào main +thread), còn `pendingWrite` và các `Stream` lại được `schedule(in: .main, forMode: .default)`; gọi `send()` từ một +luồng khác đồng thời với `flushIfPossible()` (được gọi lại từ callback `.hasSpaceAvailable` trên main thread) tạo +race trên cùng biến `pendingWrite` (kiểu `Data`, không an toàn khi đọc/ghi đồng thời). Đã sửa bằng cách đưa toàn bộ +việc ghi `pendingWrite` + stream vào `DispatchQueue.main.async` (`FramedChannel.swift:25-31`). Sửa này không đổi +số đo (đường ghi thật vẫn phải chờ sự kiện `.hasSpaceAvailable` trên main thread mới thực sự đẩy byte đi), chỉ +loại bỏ hành vi không xác định/khả năng hỏng khung dữ liệu. + +**Chưa kết luận bearer** — bảng vẫn thiếu reconnect (3 dòng) và AC6; số liệu latency/throughput ở trên nghiêng +mạnh về nghi vấn "đặc tính connection interval của L2CAP CoC thuần", không phải lỗi logic trong bài đo, nhưng cần +chạy lại với bài đo đã rút gọn (xem dưới) để có đủ reconnect + AC6 trước khi quyết định dừng hay tiếp tục Phase 7. + +### Lần 2 (sau khi sửa race) — 2026-10-28 11:07, chạy thật trên S25 + Mac + +Nguồn: `reports/assets/ble-g7/mac-run.jsonl`, `run.log`, `android-HLBLESPIKE.log` (lần này script chạy xong, mã +thoát 0 — khác lần 1, kéo được log Android). Đây là số liệu **sau** các thay đổi của #165 (gate 5 MB, bỏ race +condition `FramedChannel.swift`, timeout từng bước/từng vòng). + +| Scenario | Metric | Target | S25 ↔ Mac | Notes | +|---|---|---|---|---| +| 1 KB, established link, idle | latency p50 / p95 (one-way est.) | AC1 ≤ 200 ms | **91.72 / 133.97 ms** | n=30; đạt AC1 rõ, không còn sát ngưỡng như Lần 1 | +| 1 KB, during 1 MB transfer | latency p50 / p95 (one-way est.) | AC1 ≤ 200 ms | **4395.8 / 4769.3 ms** | n=8; không đạt AC1 — vẫn hệ quả FIFO một stream (D7.1), như Lần 1 | +| 1 MB transfer | throughput | — (informational) | **12.92 KB/s** (0.012925 MB/s), 77.37 s | gần gấp đôi Lần 1 (7.45 KB/s), vẫn dưới ngưỡng gate 40 KB/s | +| 5 MB transfer | throughput | — (informational) | **bị bỏ qua đúng thiết kế**: `skipped_5mb`, lý do ghi trong log "bulk_1mb throughput 12.92 KB/s below 40 KB/s gate" | gate #165 hoạt động đúng | +| Reconnect, do script tự tắt/bật Bluetooth | bt_lost → bt_seen | AC2 ≤ 3 s | — | **cả 3 vòng `reconnect_round_timeout stage=bt_lost`** (45 s/vòng) — không ai tắt Bluetooth trên điện thoại, không phải lỗi code; đây là lý do có task #166 | +| S25 screen off 10 min / Doze | advertising + accept still running | AC6 (feeds the pairing budget) | — | pending, read from `HLBLESPIKE heartbeat` | + +**So với Lần 1:** latency rảnh tay cải thiện rõ (one-way p50 149.76→91.72 ms, p95 179.38→133.97 ms — từ "sát +ngưỡng" sang "đạt rõ"); throughput 1 MB tăng từ 7.45 lên 12.92 KB/s (~+73 %). Không thể quy hết cải thiện này cho +riêng bản sửa race condition ở `FramedChannel.swift` — hai lần đo cách nhau ~23 phút, điều kiện RF (RSSI đo được +-53 dBm ở Lần 2 so với -54 dBm ở Lần 1, gần như nhau nhưng không chứng minh được còn yếu tố nào khác không đổi, +n=1 mỗi cấu hình) có thể cũng góp phần; chỉ ghi nhận số đo tăng lên, chưa kết luận nguyên nhân chính xác. Thời +gian kết nối ban đầu (`connected`) lại **chậm hơn** Lần 1 (3.38 s so với 0.95 s) — biến động giữa các lần, không +đủ dữ liệu để nói đây là xu hướng. Bài kết nối lại vẫn **chưa đo được số thật** ở cả hai lần — Lần 1 vì bài 5 MB +chưa xong nên chưa chạy tới `reconnectLoop`, Lần 2 vì `reconnectLoop` đã chạy nhưng không có ai tắt/bật Bluetooth +trên điện thoại (bài đo gốc là con người chủ động, và không ai được báo cần làm việc đó) — xem #166 ngay dưới. + +### Ghi nhận & thay đổi #165 (rút gọn bài đo xuống dưới 540 s) + +- `handlive-apple/Tools/BLESpike/Sources/BLESpike/Measurements.swift`: bài 5 MB giờ chỉ chạy khi throughput 1 MB + ≥ 40 KB/s (`fiveMbThresholdKBps`); dưới ngưỡng thì ghi event `skipped_5mb` kèm lý do và bỏ qua — ở tốc độ đo + được lần này (7.45 KB/s), 5 MB sẽ bị bỏ qua. Khi 5 MB có chạy, không còn PING xen giữa (dùng kết quả PING-trong- + lúc-truyền của bài 1 MB làm đại diện, vì bản chất nghẽn giống nhau). Mỗi bước (`ping_baseline`, `bulk_1mb`, + `bulk_5mb` khi có chạy) có timeout riêng (`withStepTimeout`, 60/240/150 s) — hết giờ thì ghi `step_timeout` và + thoát tiến trình, giống cách `connectOnce` đã làm từ #156. Vòng kết nối lại (`reconnectLoop`) có timeout riêng + cho từng vòng, áp cho **cả hai nửa** của vòng: chờ `bt_lost` (tối đa 45 s) và quét kết nối lại cho `bt_seen` + (tối đa 45 s, qua `connectWithRoundTimeout` — xem mục sửa sau review của Huy ngay dưới). Hết giờ ở nửa nào thì + ghi `reconnect_round_timeout` kèm `stage` ("bt_lost" hoặc "bt_seen") và qua vòng kế tiếp, không dừng cả bài, vì + đây là bước con người chủ động (tắt/mở Bluetooth) — không nên mất hết số liệu đã đo nếu một vòng người làm chậm. +- **Sửa sau review của Huy**: `reconnectLoop` ban đầu chỉ bọc timeout cho nửa chờ `bt_lost`; nửa `bt_seen` vẫn gọi + `connectAndTimestamp()` → `connectOnce()` với timeout mặc định 30 s, mà `connectOnce` hết giờ là `exit(1)` luôn + (Measurements.swift:150-167 cũ) — nghĩa là nếu vòng 1/3 chậm ở bước quét lại, cả script dừng, mất luôn 2 vòng + còn lại, ngược với "mỗi vòng có timeout" (độc lập) trong brief. Đã thêm `connectWithRoundTimeout(timeoutSeconds:)` + (Measurements.swift, cạnh `waitForDisconnect`) — giống `connectOnce` nhưng hết giờ thì trả `nil` thay vì + `exit(1)`; xoá `connectAndTimestamp()` cũ, `reconnectLoop` giờ dùng hàm mới cho nửa `bt_seen` và `continue` sang + vòng kế khi `nil`. +- `handlive-apple/Tools/BLESpike/Sources/BLESpike/FramedChannel.swift`: sửa race condition nêu trên. +- `handlive-android/tools/ble-spike/src/main/kotlin/app/handlive/spike/ble/BleSpikeService.kt`: thêm chú thích tại + `startAdvertising` giải thích vì sao không có `requestConnectionPriority()` (không có GATT connection) — ghi lại + nghi vấn nguyên nhân ngay cạnh code liên quan, không đổi hành vi. +- **Ước tính thời gian lần chạy kế tiếp** (giữ nguyên tốc độ link ~7.45 KB/s, nên 5 MB bị bỏ qua): build+install + (~20 s) + connect (~1 s) + ping baseline (~10 s) + bulk 1 MB (~134 s, như lần này) + `skipped_5mb` (~0 s) + + 3 vòng reconnect (tuỳ người làm, mỗi vòng thường chỉ vài giây cho mỗi nửa nếu người làm nhanh) ≈ + **190–260 s** trong điều kiện bình thường — rất dưới 540 s. Trần an toàn theo timeout khai báo (mỗi nửa mỗi + vòng tối đa 45 s, 3 vòng × 2 nửa = tối đa 270 s cho riêng phần reconnect) cộng ping/bulk1mb ở đúng mức timeout + tối đa của chúng (60 + 240 s) cho ra **~591 s tổng nếu mọi bước đều "vừa xong trước khi hết giờ" ở mức tối đa + cùng lúc** — vượt 540 s trên lý thuyết, nhưng đây là tình huống cực đoan không thực tế (ping baseline mất gần + 60 s hoặc bulk 1 MB mất gần 240 s nghĩa là link đã hỏng nặng so với lần đo 10:44, và cả 3 vòng người làm đều + chậm tối đa ở cả hai nửa). Mục đích chính của các timeout này là **chặn treo vô hạn** như bài 5 MB cũ từng gặp + (dừng ở 600 s do harness, không phải do script tự dừng) — không phải cam kết tuyệt đối dưới 540 s trong mọi kịch + bản xấu nhất cộng dồn. Ở điều kiện link và người làm bình thường (như ước tính ~190–260 s trên), tổng vẫn chắc + chắn dưới 540 s. + +### Ghi nhận & thay đổi #166 (tự động hoá bài kết nối lại, bỏ phụ thuộc người bấm tay) + +Lần 2 (11:07) cho thấy bài `reconnectLoop` của #165 đúng về mặt thiết kế (mỗi vòng có timeout độc lập, không +`exit(1)` chéo vòng — xác nhận fix sau review của Huy hoạt động đúng trong lần chạy thật: cả 3 vòng đều ghi +`reconnect_round_timeout` riêng, không dừng cả bài ở vòng 1), nhưng vô dụng nếu không có ai tắt/bật Bluetooth +trên S25 — bài đo gốc giả định một con người làm việc đó, và lần chạy 11:07 không ai được báo cần làm. + +- `handlive-apple/Tools/BLESpike/Sources/BLESpike/Measurements.swift`: thêm `runReconnectOnly(rounds:, + roundTimeoutSeconds:)` — bỏ hẳn ping baseline và cả hai bài bulk, chỉ còn `connectOnce()` ban đầu rồi + `reconnectLoop` (tái dùng nguyên vẹn `connectWithRoundTimeout`/`waitForDisconnect` của #165). Timeout mỗi nửa + mỗi vòng giảm từ 45 s (dành cho người) xuống **20 s** (dành cho script qua adb, phản ứng trong vài giây, không + cần dư nhiều như con người tìm điện thoại rồi bấm tay). +- `handlive-apple/Tools/BLESpike/Sources/BLESpike/main.swift`: thêm cờ `--reconnect-only` cho subcommand `run` + (gọi `runReconnectOnly` thay vì `run`), cập nhật `usage()`. +- `handlive-android/tools/ble-spike/run-spike.sh`: thêm mode `--reconnect-only [rounds] [out-dir]` ở vị trí + tham số đầu tiên. Khi bật mode này, bước 7/7 chạy `BLESpike run --reconnect-only ...` **ở nền** (`&`) rồi hàm + `run_reconnect_only()` tự điều khiển từng vòng: + 1. `probe_bt_toggle()`: thử `adb shell cmd bluetooth_manager enable`, soát output có chứa các dấu hiệu bị + chặn (`unknown command`, `security`, `exception`, `not allowed`, `usage:` — không phân biệt hoa/thường) hay + không; nếu có thì chuyển sang fallback `adb shell svc bluetooth `. Kết quả (`cmd` hay `svc`) + được `echo` ra — đi vào `run.log` qua `exec > >(tee -a run.log)` đã có từ trước, thoả yêu cầu "ghi rõ lệnh + nào chạy được". **Chưa xác nhận thật trên máy** (cần lần chạy có S25 để biết Android 16 chấp nhận lệnh nào) + — ghi rõ ở "Ước tính thời gian" dưới. + 2. Mỗi vòng: `bt_toggle disable` → ghi `bt_toggle_off` vào file riêng `run-spike-events.jsonl` → chờ tối đa + 25 s thấy `"ev":"bt_lost".*"round":N` trong `mac-run.jsonl` (không thấy thì chỉ `WARN`, không dừng — mốc + chặn treo vô hạn thật là timeout 20 s phía Swift) → `bt_toggle enable` → ghi `bt_toggle_on` → `sleep 2` → + `adb shell am start ... --es cmd start` (khởi động lại peripheral để phát sóng lại) → ghi `am_start` → chờ + tối đa 25 s thấy `bt_seen`. + 3. `bt_toggle_off`/`bt_toggle_on`/`am_start` ghi vào **file riêng** `run-spike-events.jsonl`, không ghi chung + vào `mac-run.jsonl`: file đó đã có người viết riêng (tiến trình BLESpike), và `FileHandle(forWritingAtPath:)` + trong `EventLog.swift` không mở với `O_APPEND`, nên hai tiến trình ghi đồng thời vào cùng path với 2 fd độc + lập có thể đè/ xen byte lên nhau. + 4. Sửa lỗi phát hiện khi tự kiểm: `EventLog.swift` ghi JSON với `JSONSerialization.sortedKeys`, nên khoá + không luôn nằm cạnh `"ev"` theo đúng thứ tự chèn (xem thêm mục Khoa/#2987 dưới về `epoch_ms` chen vào + *trước* `"ev"`). Mẫu khớp ban đầu dùng chuỗi cố định (`grep -F`) sẽ không khớp `bt_seen`; đã đổi + `wait_for_marker` sang `grep -E` với pattern dạng `"ev":"X".*"round":N[,}]` (đã tự kiểm bằng `grep -E` tay + với dòng mẫu, xem notes). + 5. Mọi lệnh `adb`/`bt_toggle` đều có `|| true` hoặc được bọc `if ! ...; then WARN; fi` — một lần gọi + `adb`/toggle thất bại không được kéo cả script dừng qua `set -e`, vì mục tiêu là tự động hoá bền, không phải + thêm một điểm hỏng giòn mới. + +### Sửa theo finding của Khoa (#2987) và bổ sung của Lead — mốc AC2 phải tính từ lúc bật lại Bluetooth + +Bản đầu của #166 tính `reconnect_ms = bt_seen - bt_lost` (monotonic clock, phía Swift) và coi đó là số AC2 — +**sai**: khoảng này còn gồm cả thời gian `run-spike.sh` cố ý chờ (tới 25 s) rồi mới gọi `bt_toggle enable`, nên +cộng dư vào con số đáng lẽ đại diện "từ lúc bật lại Bluetooth tới lúc thấy lại nhau" mà AC2 (≤ 3 s) muốn đo. + +- **Đổi tên, không đổi cách tính cũ**: `Measurements.swift:258` — field đổi từ `reconnect_ms` thành + `since_lost_ms`, vẫn tính bằng `DispatchTime` (monotonic) như trước, chỉ đổi tên để không ai lấy nhầm làm số + AC2 (comment tại chỗ dẫn thẳng tới finding này). +- **Số AC2 thật (`reconnect_from_on_ms`) tính ở hậu xử lý, không phải lúc runtime** (theo gợi ý gọn nhất của + Lead ở #2987): `EventLog.swift` giờ ghi thêm `epoch_ms` (wall-clock epoch, `Date().timeIntervalSince1970*1000`) + vào **mọi** event, không riêng `bt_seen` — cùng đồng hồ hệ thống với `run-spike.sh` vì cả hai chạy trên một + Mac. `run-spike.sh` ghi `bt_toggle_on` kèm `epoch_ms` của chính nó (hàm `epoch_ms()`, xem dưới). Cuối bài, + hàm mới `summarize_reconnect()` (python3 inline, đọc `run-spike-events.jsonl` + `mac-run.jsonl`, khớp theo + `round`) in `reconnect_from_on_ms = bt_seen.epoch_ms − bt_toggle_on.epoch_ms` cho từng vòng kèm p50/max — đây + mới là số so với ngưỡng AC2 ≤ 3 s. Vòng thiếu `bt_toggle_on` hoặc `bt_seen` (timeout) bị bỏ qua, không làm vỡ + phép tính các vòng còn lại. +- **Độ chính xác thời gian**: `date -u +%Y-%m-%dT%H:%M:%SZ` cũ chỉ tới giây — không đủ phân biệt dưới ngưỡng + 3 s. Đổi sang `zmodload zsh/datetime` + `$EPOCHREALTIME` (giây thực, không fork tiến trình con) cho hàm + `epoch_ms()` của script; phía Swift dùng `Date().timeIntervalSince1970` (đã có sẵn, chỉ thêm field xuất ra). +- **Mốc `am_start` tách riêng**: ghi ngay sau lệnh `adb shell am start` (khởi động lại peripheral) để báo cáo + sau này tách được "thời gian Android khởi động lại dịch vụ" khỏi "thời gian BLE kết nối lại" nếu cần — hai cái + khác bản chất (foreground service restart vs. BLE reconnection) dù hiện chưa dùng tới trong + `summarize_reconnect()`. +- Vì thêm `epoch_ms` (chen vào *trước* `"ev"` theo bảng chữ cái: `epoch_ms` < `ev`) nên pattern của + `wait_for_marker` (`"ev":"X".*"round":N[,}]`) vẫn khớp đúng — đã tự kiểm lại bằng `grep -E` với dòng mẫu có cả + `epoch_ms` lẫn `since_lost_ms`. +- **Chưa có số liệu thật** cho `reconnect_from_on_ms` — lần chạy 11:21 dừng giữa đường vì lỗi cú pháp zsh + `$round[` (sửa ở #167), nên chưa tới lúc `summarize_reconnect()` chạy thật trên dữ liệu S25. Đã tự kiểm hàm + này bằng dữ liệu mẫu dựng tay (3 vòng khớp đủ `bt_toggle_on`/`bt_seen`, ra đúng + `bt_seen.epoch_ms − bt_toggle_on.epoch_ms`; thêm 1 vòng cố ý thiếu `bt_toggle_on` để kiểm không vỡ phép tính + các vòng khác) — không phải dữ liệu từ máy thật. +- **Chưa chạm `handlive-android/tools/ble-spike/src/main/**` (BleSpikeService.kt...)** — đúng phạm vi file brief + #166 chỉ cho sửa `run-spike.sh`, không phải cả cây `tools/ble-spike`. +- **Ước tính thời gian** cho `run-spike.sh --reconnect-only 3`: build+install (~20 s) + connect ban đầu (~1–3 s, + như Lần 1/2) + 3 vòng × (tắt BT ~1 s + chờ `bt_lost` thực tế chỉ vài giây nếu adb toggle thật sự làm điện + thoại mất kết nối + bật BT ~1 s + `sleep 2` + `am start` ~1 s + chờ `bt_seen` thực tế vài giây) ≈ **~60–100 s** + trong điều kiện bình thường — rất dưới 300 s. Trần an toàn theo timeout khai báo: mỗi nửa mỗi vòng tối đa 25 s + (phía script) hoặc 20 s (phía Swift, cái nào tới trước không quan trọng vì script chỉ WARN rồi vẫn tiếp tục) — + 3 vòng × 2 nửa × 25 s = 150 s, cộng build/connect (~25 s) ≈ **~175 s trần an toàn**, dưới 300 s cả hai cách + tính. **Chưa chạy thật được lần này** — cần S25 cắm máy; `cmd bluetooth_manager disable` có thực sự tắt được + Bluetooth trên Android 16 bản Samsung này hay bị OneUI chặn, bắt phải rơi về fallback `svc bluetooth`, là điều + chỉ biết được khi chạy thật (xem "Nghiệm thu" cuối task). + +### Lần 3 (11:30, reconnect-only) — peripheral không phát sóng lại, #168 + +`run-spike.sh --reconnect-only 3` lúc 11:30 (xem `run.log` dòng 1186-1221): `cmd bluetooth_manager +disable`/`enable` chạy được, Mac thấy `bt_lost` trong khoảng **0,7 s** (round 0) — nhanh hơn nhiều so với +ngưỡng AC2 ≤ 3 s, bằng chứng tốt rằng toggle qua adb là một kích thích thật, không phải giả lập. Nhưng sau +`enable` + `am start ... cmd start`, Mac không thấy lại peripheral (không `discovered`) trong 20 s — round 0 +timeout ở bt_seen. Round 1 và 2 sau đó lại chờ `bt_lost` dù đã mất kết nối từ round 0, nên cả hai cũng hết giờ +theo (cascading). `android-HLBLESPIKE.log` không có dòng nào trong khoảng 11:3x. + +**Nguyên nhân (suy luận từ code, chưa xác nhận bằng log thật — xem điểm dưới):** `BleSpikeService.serverLoop()` +(bản trước #168) chỉ bọc `catch (e: IOException)` quanh `listenUsingInsecureL2capChannel()`/`accept()`, và chỉ +kiểm tra `adapter.isEnabled` **một lần** trước khi vào `while` — nếu Bluetooth tắt rồi bật lại giữa lúc đang +`accept()`, không có gì chủ động đóng `BluetoothServerSocket`/`BluetoothLeAdvertiser` cũ hay tạo lại cái mới; +vòng lặp chỉ còn trông chờ `accept()` tự ném `IOException` (có thể không xảy ra, hoặc ném một loại exception +khác mà `catch` không bắt — ví dụ `SecurityException` khi gọi API Bluetooth đúng lúc radio vừa tắt — khiến +`serverThread` chết lặng lẽ, khớp với việc log trống hoàn toàn). + +**Cách sửa** (`handlive-android/tools/ble-spike/src/main/kotlin/app/handlive/spike/ble/BleSpikeService.kt`): + +- `bluetoothStateReceiver` (:59-81) đăng ký `BluetoothAdapter.ACTION_STATE_CHANGED` trong `start()` (:106-111, + qua `ContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED)`), gỡ trong `stop()` (:148). `STATE_OFF`: log + `bt_state_off`, chủ động `stopAdvertising()` + đóng `serverSocket` ngay (:67-74) — buộc `accept()` đang block + phải ném exception thay vì trông chờ OS tự làm. `STATE_ON`: log `bt_state_on`, giải phóng một permit của + `bluetoothRestart` (Semaphore 0 permit, :57, :75-78) để đánh thức `serverLoop` ngay, không chờ vòng lặp tự + nhiên tiếp theo. +- `serverLoop()` (:173-225) viết lại: kiểm `adapter.isEnabled` **mỗi vòng** `while` (không chỉ một lần trước + loop như bản cũ) — nếu tắt, `drainPermits()` rồi `acquire()` chặn tới khi có permit mới (từ `STATE_ON` hoặc từ + `stop()` để thoát). Khi bật, `advertiser` được lấy lại mới mỗi vòng (:196, không cache qua chu kỳ tắt/bật) rồi + `listenUsingInsecureL2capChannel()`/`startAdvertising()` lại như cũ, với PSM mới (đã có sẵn từ #156, không + đổi). `catch` đổi từ `IOException` sang `Exception` (:207) kèm log `server_loop_error` ghi cả `type` lẫn + `message` — nếu lần sau log vẫn trống thì ít nhất giờ biết chắc không phải do loại exception bị lọt qua catch. +- `cmd start` khi service đã `running` (tức `am start` lặp lại mà `run-spike.sh` luôn gọi sau mỗi lần `enable`) + không còn chỉ log `start_ignored_already_running` rồi bỏ qua: `ensureServerAlive()` (:138-144) kiểm + `serverSocket == null` (nghĩa là `serverLoop` đang park chờ Bluetooth, không phải đang accept() dở) và giải + phóng permit nếu đúng — một lớp dự phòng nếu `bluetoothStateReceiver` lỡ broadcast vì lý do gì đó. + +**Điều tra log trống** (`handlive-android/tools/ble-spike/run-spike.sh`'s `cleanup()`, :428-440): thêm lệnh pull +`adb logcat -b crash` (`android-crash.log`) để biết app có crash hẳn không, và `adb logcat --pid=$(adb shell +pidof ...)` (`android-pid.log`, lấy pid **trước** khi gọi `cmd stop` vì sau đó process đã chết) để bắt mọi dòng +log của process này bất kể tag, không chỉ tag `HLBLESPIKE`. Cả ba lệnh logcat đều thêm `-T` với mốc giờ lấy lúc +script bắt đầu (`RUN_START_LOGCAT_T`, :120-124 theo format `logcat -T` chứ không phải ISO8601 của `date -u`), để +không bị buffer ring cũ (đầy/xoay vòng từ lần chạy trước) làm loãng hay che mất đúng khoảng thời gian cần xem. +**Chưa xác nhận log trống là do crash hay do buffer** — chỉ biết được khi chạy thật với 3 file log mới này. + +**`run-spike.sh`/`Measurements.swift` không để một vòng hỏng kéo các vòng sau** (việc 3, chọn nhánh Swift + +mirror ở script, xem "Sửa theo finding của Khoa" phía trên cho ngữ cảnh `since_lost_ms`): `reconnectLoop` +(Measurements.swift:230-273) giờ theo dõi `isConnected` qua các vòng — nếu vòng trước timeout ở bt_seen (không +kết nối), vòng sau bỏ hẳn `waitForDisconnect` (ghi `reconnect_skip_bt_lost` thay cho `bt_lost`) và quét lại +luôn; khi quét lại thành công, `isConnected` trở lại `true` cho vòng kế. `run_reconnect_only()` +(run-spike.sh:208-256) mirror đúng state machine này bằng biến `connected`: vòng mà `connected=0` bỏ qua +`bt_toggle disable`/chờ `bt_lost`/`bt_toggle enable`, chỉ gọi lại `am start` rồi chờ `bt_seen`; vòng nào thấy lại +`bt_seen` thì `connected` quay lại `1` cho vòng sau. Đã tự test logic này bằng mock (xem "Tự kiểm" ở ghi chép +task #168) — mô phỏng đúng kịch bản 11:30 (round 0 miss bt_seen) cho kết quả round 1, 2 bỏ đúng bước tắt/bật +Bluetooth; mock thêm case "hồi phục" (round giữa thấy lại bt_seen) xác nhận round kế quay lại tắt/bật bình +thường — không bị kẹt ở trạng thái "bỏ qua" vĩnh viễn. + +**Yêu cầu cho sản phẩm:** A-SVC phải dựng lại L2CAP và advertiser khi Bluetooth bật lại (không chỉ khi app khởi +động lần đầu) — bài học trực tiếp từ bug này: một service nền sống qua Doze/screen-off (AC6) nhưng không sống +sót qua một chu kỳ tắt/bật Bluetooth thật của người dùng là vô dụng cho tính năng Continuity trên thực địa, nơi +người dùng tắt/bật Bluetooth thường xuyên hơn là khởi động lại app. + +**Chưa chạy thật lại được lần này** — cần S25 cắm máy để xác nhận: (1) peripheral có thực sự phát sóng lại sau +`bluetoothStateReceiver`, (2) `android-crash.log`/`android-pid.log` có lộ ra nguyên nhân log trống hay không. + +### Lần 4 (reconnect-only, sau #168) — 2026-10-28 11:42, chạy thật trên S25 + Mac + +Nguồn: `reports/assets/ble-g7/run-spike-events.jsonl`, `mac-run.jsonl`, `run.log`, `android-HLBLESPIKE.log` (lần +chạy sau fix #168 — peripheral giờ phát sóng lại đúng, cả 3 vòng ra số, không còn `reconnect_round_timeout` nào). + +| Round | since_lost_ms (bt_lost → bt_seen) | reconnect_from_on_ms (bt_toggle_on → bt_seen, số AC2 thật) | +|---|---|---| +| 0 | 2224.4 | **10583.0** | +| 1 | 3785.4 | 2793.0 | +| 2 | 3726.9 | 2658.0 | + +p50 = **2793.0 ms**, max = **10583.0 ms**, n=3 (`run.log:1541-1544`, output thật của `summarize_reconnect()`). +`since_lost_ms`/`reconnect_from_on_ms` khớp đúng `mac-run.jsonl:13,20,27` (trường `since_lost_ms`) và phép tính +`bt_seen.epoch_ms − bt_toggle_on.epoch_ms` cho từng round giữa `mac-run.jsonl:13,20,27` và +`run-spike-events.jsonl:2,5,8` (đã tính tay lại, khớp số script in ra tới 0.1 ms). + +**n=3 là nhỏ** — một lần chạy duy nhất, 3 vòng; không đủ để ước lượng p95 (ngưỡng AC2 ≤ 3 s dùng p95, không phải +max của 3 mẫu) hay để biết round 0 là ngoại lệ hiếm hay là hiện tượng lặp lại mỗi lần app/script mới khởi động — +cần nhiều lần chạy hơn (xem "Chưa đo"/"What's needed next"). + +**Vì sao vòng đầu (round 0) chậm gấp ~4x hai vòng sau — có bằng chứng trực tiếp từ thứ tự log, không phải suy +đoán:** + +- `run.log:1483` (`-- reconnect round 1/3: tắt Bluetooth trên S25 --`) và `run.log:1486` (`-- ... bật lại + Bluetooth --`) — tức toàn bộ chu kỳ tắt/bật Bluetooth của round 0 — in ra **trước** `run.log:1489`, dòng + `"ev":"host"` đầu tiên của tiến trình BLESpike **mới** cho lần chạy này (epoch `1793162552523`). Các dòng + `run.log:1483-1488` là `echo`/output `adb` của zsh (không bị buffer qua `tee`), nên thứ tự này đúng là thứ tự + thời gian thật, không phải ảnh của buffering. +- Diễn giải: `run_reconnect_only()`'s `wait_for_marker` cho `channel_opened` (đầu hàm) và cho `bt_lost + round:0` (đầu vòng lặp round 0) đều đọc file `mac-run.jsonl` — file này là **đường dẫn cố định**, và + `EventLog.init()` (phía Swift) chỉ `FileManager.createFile` (xoá/ghi lại rỗng) **sau khi** tiến trình Swift + mới đã fork+exec+load xong framework CoreBluetooth. Trong khoảng trễ đó (vài trăm ms, bằng chứng: + `run.log:1489` xảy ra sau `run.log:1486`), file `mac-run.jsonl` **vẫn còn nội dung của lần chạy 11:30 ngay + trước đó** (Lần 3) — vốn cũng có các dòng `"channel_opened"` và `"bt_lost","round":0`. `wait_for_marker` khớp + ngay vào các dòng cũ này (chỉ cần tồn tại trong file, không phân biệt được "cũ" hay "mới"), khiến round 0 bắt + đầu tắt/bật Bluetooth thật **trước khi** lần kết nối ban đầu của chính lần chạy này (`connectOnce()`, dẫn tới + `mac-run.jsonl:6` `channel_opened` epoch `1793162554806`) thậm chí xảy ra. Mốc "bắt đầu" của round 0 + (`bt_toggle_on` tại `run-spike-events.jsonl:2`, epoch `1793162552264`) vì vậy bị ghi **sớm hơn thực tế ~2.5 s** + so với lúc kết nối thật sự bắt đầu hình thành. +- Sau đó: kết nối thật đầu tiên (`mac-run.jsonl:6`, epoch `554806`) khớp với `android-HLBLESPIKE.log:13` + (`client_connected wall_clock_ms=1793162554762`, cách 44 ms — cùng một lần connect), nhưng phiên socket này bị + Android đóng chỉ 53 ms sau (`android-HLBLESPIKE.log:14-15`, `session_ended`/`client_disconnected` tại + `.815`). CoreBluetooth phía Mac không báo mất kết nối ngay — `mac-run.jsonl:7` (`disconnected`) chỉ xảy ra ở + epoch `1793162560623`, **5.8 s sau** khi socket Android đã đóng, và không khớp với bất kỳ sự kiện nào trong + `android-HLBLESPIKE.log` ở đúng mốc đó (dòng 17-18, khoảng 11:42:34.821–11:42:42.801, hoàn toàn im lặng phía + Android). Khoảng 5.8 s "im lặng" này khớp với tầm BLE connection supervision timeout tiêu chuẩn (thường 4-8 s + tuỳ connection parameters âm thầm) khi một bên đóng tầng socket L2CAP nhưng không chủ động ngắt hẳn liên kết ở + tầng GAP/link layer — **đây là suy luận hợp lý, chưa xác nhận tuyệt đối** (CoreBluetooth không lộ lý do + timeout qua API công khai). +- Tổng ba đoạn cộng lại khớp khá sát 10583 ms quan sát được: ~2.5 s (kết nối đầu, vô tình tính vào mốc round 0 do + bug thứ nhất) + ~5.8 s (chờ CoreBluetooth phát hiện mất kết nối orphaned, bug thứ hai) + ~2.3 s (quét + kết nối + lại thật, tương đương round 1/2) ≈ 10.6 s. +- **Loại trừ nguyên nhân phía Android**: `android-HLBLESPIKE.log:9-11` cho thấy `bt_state_on` → `server_listening` + → `advertise_start_success` của round 0 chỉ mất **14 ms** (`.313` → `.315` → `.327`) — và tương tự ở round 1 + (`:26-28`, ~13 ms) và round 2 (`:38-40`, ~14 ms). Fix #168 hoạt động đúng; peripheral không phải nguyên nhân + vòng đầu chậm. Nguyên nhân nằm ở phía script/Mac (file `mac-run.jsonl` không được dọn giữa các lần chạy), nằm + ngoài phạm vi file cho phép sửa của #169 — ghi nhận ở đây để ai làm task kế tiếp cân nhắc xoá hoặc đổi tên + `mac-run.jsonl`/`OUT_DIR` trước mỗi lần chạy, hoặc để `wait_for_marker` đợi thấy dòng `"host"` mới trước khi + chấp nhận bất kỳ marker nào khác trong file. +- Round 1 và 2 không bị ảnh hưởng bởi bug này (file đã được Swift tiến trình hiện tại ghi liên tục nhiều giây + trước đó) — `reconnect_from_on_ms` của chúng (2793, 2658 ms) phản ánh đúng một chu kỳ tắt/bật Bluetooth + + reconnect thật, và đều sát dưới ngưỡng AC2 ≤ 3 s (2793/2658 < 3000 ms) — **cả hai đạt AC2** tính riêng lẻ; chỉ + round 0 không đạt. + +### Lần 5a (doze-start 15, phía Mac hỏng) — 2026-10-28 12:23, chạy thật trên S25 + Mac + +Nguồn: snapshot của `snapshot_prev_logs()` trước khi #171 fix setsid (`reports/assets/ble-g7/prev-1793169060137/`). + +`run-spike.sh --doze-start 15` kết nối thành công, mở channel, bắt đầu đo (`doze_start` lúc 12:23:33.489, `mac-run.jsonl:7`), gửi đúng 1 ping thành công (seq 1, rtt 118.58 ms, `mac-run.jsonl:8`) rồi **tiến trình BLESpike nền phía Mac dừng đột ngột** — không còn dòng nào khác trong `mac-run.jsonl` sau đó. Nguyên nhân (đã sửa, xem #171/#3099): app chạy hộ lệnh `--doze-start` rồi tự kill process group ngay khi lệnh đó thoát, mà `nohup` chỉ chặn `SIGHUP`, không chặn kill-group — tiến trình BLESpike con (`nohup ... &`) bị kill theo. Bằng chứng gián tiếp phía Android: `session_ended`/`client_disconnected` xảy ra ngay 12:23:34.214–.215 (`android-HLBLESPIKE.log:2-3`), chỉ ~0,6 s sau ping đầu — khớp với socket Mac bị đóng đột ngột khi tiến trình bị kill, không phải lỗi BLE. + +Phía Android sau đó **vẫn chạy tiếp dù mất kết nối**: peripheral tự phát sóng lại (`server_listening`/`advertise_start_success`, `android-HLBLESPIKE.log:4-5`, 12:23:34.219/.228) và heartbeat 30 s chạy đều 8 tick, từ tick 1 lúc 12:23:54.704 tới tick 8 lúc 12:27:24.708 (`android-HLBLESPIKE.log:6-13`) — rồi **im hẳn**, không còn dòng `HLBLESPIKE` nào tới hết log; `android-crash.log` trống (không ghi nhận crash). `doze_enter` (force-idle thật, `run-spike-events.jsonl:1`, epoch `1793165014210` = 12:23:34.210) và `doze_exit` (khi `--doze-collect` gọi, không chờ đủ 15 phút vì phía Mac hỏng — xem #3100/#3101 — `run-spike-events.jsonl:2`, epoch `1793165266618` = 12:27:46.618) cách nhau ~4 phút 12 giây; tick 8 (heartbeat cuối) xảy ra 22 giây **trước** `doze_exit`, nên heartbeat dừng không phải do script chủ động — nó đã im lặng từ trước khi lệnh thu kết quả chạy. + +### Lần 5b (doze-start 15, bản đã sửa #171) — 2026-10-28 13:31–13:58, chạy thật trên S25 + Mac + +Nguồn: `reports/assets/ble-g7/` (hiện hành, sau fix setsid). + +Kết nối ổn định suốt bài: `doze_start` lúc 13:31:11.146 (`mac-run.jsonl:7`), 29/29 ping thành công mỗi 30 s đúng `ping_interval_s`, `doze_done pings_sent=29` lúc 13:46:43.006 (`mac-run.jsonl:37`), **0 lần `bt_lost`** — kết nối L2CAP giữ xuyên suốt 15 phút Doze thật (`deviceidle IDLE` cả lúc vào và lúc ra, xem output `--doze-collect`). RTT đo được: p50 = 232.4 ms, p95 = 1328.7 ms (n=29) → one-way est. (RTT/2): **p50 ≈ 116.2 ms (đạt AC1), p95 ≈ 664.4 ms — gấp ~3,3 lần ngưỡng 200 ms, không đạt**. + +Heartbeat Android chạy đều 30 s, **không đứt đoạn**, tick 1 lúc 13:31:35.519 tới tick 55 lúc 13:58:35.553 (`android-HLBLESPIKE.log:3-61`) — tổng 1650 s (27,5 phút), dài hơn 15 phút đo ping vì log chạy tiếp tới khi `--doze-collect` gọi lúc 13:58:39 (`doze_exit`, `run-spike-events.jsonl:2`, epoch `1793170719320`). Lưu ý: Mac ngắt kết nối ngay sau `doze_done` (`disconnected` lúc 13:46:43.010, `mac-run.jsonl:38`), peripheral phát sóng lại ngay (`session_ended`/`advertise_start_success`, `android-HLBLESPIKE.log:34,37`, cùng giây 13:46:43) nhưng **không có kết nối Mac mới** tới hết log — từ tick 32 (13:47:05.539) tới tick 55 (13:58:35.553), **12 phút liên tục không BLE**, heartbeat vẫn chạy đều, không sai nhịp. + +**So với Lần 5a: "cần ping thì CPU thức?"** — dữ liệu không ủng hộ giả thuyết này. Ở Lần 5a, heartbeat chạy 8 tick (4 phút) dù **không có ping/kết nối** sau ping đầu (Mac chết từ giây đầu) — tự chạy "mồ côi" rồi dừng, không phải dừng ngay vì thiếu ping. Ở Lần 5b, heartbeat chạy thêm 12 phút sau khi mất kết nối (tick 32–55) không đứt đoạn — phản ví dụ trực tiếp: **0 ping suốt 12 phút, heartbeat vẫn đều**. Điểm khác duy nhất: Lần 5a heartbeat dừng sau ~4 phút kể từ `doze_enter` (lý do không rõ, không crash log), Lần 5b không dừng suốt 27,5 phút. Dữ liệu chưa đủ để kết luận nguyên nhân Lần 5a dừng — nhưng chắc: **ping từ Mac không phải điều kiện cần** để heartbeat Android qua Doze, vì Lần 5b chạy tốt 12 phút hoàn toàn không ping. + +## Bảng tổng hợp theo AC (`phase-07-ket-noi-moi-noi.md`) + +| AC | Mục tiêu | Kết quả | Nguồn | +|---|---|---|---| +| AC1, khi rảnh | Bluetooth ≤ 200 ms một chiều (p95) | **Đạt** — p95 ~134 ms (Lần 2: 91.72/133.97 ms p50/p95) | Lần 2 | +| AC1, khi đang truyền bulk | ≤ 200 ms một chiều kể cả khi đang truyền bulk | **Chưa đạt** — 4395.8/4769.3 ms (Lần 2) và 7725.3/7768.4 ms (Lần 1) p50/p95 một chiều; một luồng FIFO duy nhất, ping phải đợi hết dữ liệu bulk đã enqueue trước nó | Lần 1/2; cần channel id riêng + bộ lập lịch ưu tiên frame điều khiển ở D7.1 (A7.4) | +| AC1, khi Doze (S25 force-idle 15 phút) | ≤ 200 ms một chiều (p95), kể cả khi màn hình tắt/Doze | **Chưa đạt (p95)** — RTT p50/p95 = 232.4/1328.7 ms (n=29) → p50 ≈ 116.2 ms (đạt), p95 ≈ 664.4 ms (3,3 lần ngưỡng) | Lần 5b | +| Giữ kết nối BLE qua Doze | — (informational: 0 `bt_lost` trong 15 phút) | **Giữ được** — 0 `bt_lost`, 29/29 ping xuyên suốt 15 phút Doze thật (`deviceidle IDLE` cả vào/ra) | Lần 5b | +| AC2 | Kết nối lại ≤ 3 s từ lúc phát hiện lại peer | **Một phần** — p50 2793 ms (đạt), max 10583 ms round 0 (không đạt); n=3 quá nhỏ để kết p95 | Lần 4: round 0 bị lệch do bug stale-file, xem trên — cần n lớn hơn để kết luận | +| Throughput (1 MB) | — (informational) | 12.9 KB/s — đủ văn bản/SMS/thông tin cuộc gọi; không đủ ảnh (Wi-Fi Direct theo D7.1) | Lần 2 | +| Phát hiện mất kết nối (toggle off → bt_lost) | — (informational) | ~0,7 s (Lần 3); Lần 4 round 1/2 nhanh hơn (67/42 ms) — không phải nút nghẽn AC2; phần lớn thời gian là quét + kết nối (discover→connect, ~1–2,5 s/round Lần 4) | Lần 3, 4 | +| AC6 (ghép đôi QR qua BLE ≤ 15 s) | ≤ 15 s từ quét tới ghép đôi xong | **Chưa đo** luồng ghép đôi/QR (spike không triển khai) — nhưng heartbeat `HLBLESPIKE` qua Doze: không nhất quán — Lần 5b (kết nối giữ) 55/55 tick suốt 27,5 phút không đứt; Lần 5a (Mac chết sớm) dừng sau tick 8 (~4 phút), không rõ lý do, không crash log. Chưa khẳng định "advertising + accept luôn sống Doze" | Lần 5a/5b | +| AC3, AC4, AC7, AC8, AC9 | (xem `phase-07-ket-noi-moi-noi.md`) | **Chưa đo** | xem "Chưa đo" dưới | + +## Kết luận bearer + +**BLE L2CAP CoC (insecure, `listenUsingInsecureL2capChannel`/`CBCentralManager` + `BluetoothSocket`), cộng lớp +bảo mật link ở tầng ứng dụng (D7.1)** — không phải tùy chọn vì kênh này không bond. + +- **RFCOMM: không khả thi** (ghi nhận "Brief items" dưới) — Android app không đọc được địa chỉ Bluetooth của máy, + BLE là ngẫu nhiên xoay vòng, HandLive không bond (SP5 đã bỏ) — không cách nào Mac "tới" điện thoại qua RFCOMM + mà không bond trước. +- **Kết luận G7**: BLE L2CAP CoC (insecure) đủ làm kênh điều khiển của Continuity — không phải ảnh/payload lớn + (Wi-Fi Direct theo D7.1; throughput BLE chỉ 12.9 KB/s, Lần 2 — đủ văn bản/SMS/thông tin cuộc gọi). Kèm 4 + điều kiện bắt buộc trước khi coi bearer này xong cho sản phẩm: + 1. **Ưu tiên control-trước-bulk ở D7.1/A7.4** — AC1 chỉ đạt khi rảnh (p95 ~134 ms, Lần 2); khi bulk thì không + (p95 4769–7768 ms, Lần 1/2) vì một luồng FIFO, ping phải đợi bulk enqueue trước — **bắt buộc** channel id + riêng + bộ lập lịch ưu tiên control trước khi bearer xong cho làn điều khiển. + 2. **A-SVC phải dựng lại L2CAP server/advertiser khi Bluetooth bật lại** (không chỉ khởi động lần đầu) — phát + hiện #168: service sống qua Doze/screen-off nhưng chết sau chu kỳ tắt/bật Bluetooth thật là vô dụng + Continuity; người dùng tắt/bật Bluetooth thường hơn khởi động lại app. + 3. **Chấp nhận hoặc giảm độ trễ Doze** — L2CAP giữ được kết nối suốt 15 phút Doze (0 `bt_lost`, 29/29 ping, + Lần 5b) nhưng chậm: p95 ~0,66 s (p50 ~116 ms đạt AC1, p95 vượt 3,3 lần ngưỡng 200 ms). Không có API công khai + chỉnh connection interval cho L2CAP thô — `requestConnectionPriority()` chỉ dùng được với GATT. Độ trễ phụ + thuộc interval do controller chọn, ứng dụng không điều khiển được. Sản phẩm phải chấp nhận ngưỡng này cho làn + điều khiển khi màn hình tắt, hoặc đánh đổi bằng một GATT server tối giản để xin priority. + 4. **Bound độ dài frame từ peer** — `FrameBuffer.append` (Swift)/`readFrame` (Kotlin) đọc length 4-byte rồi cấp + phát buffer mà không kiểm cận trên (finding Trang #165/#2960); chấp nhận được cho spike dev-only insecure cục + bộ, nhưng **bắt buộc** bound khi mang sang code sản phẩm. +- **Vẫn còn mở, không chặn kết luận**: + - AC2: "một phần" (n=3: p50 2793 ms đạt, max 10583 ms không đạt; quá ít mẫu để tính p95) — cần chạy thêm nhiều + lần để biết round 0 chậm là lặp lại hay chỉ nhiễu một lần. + - AC3–AC9: ngoài tầm spike này. AC6 mới có bằng chứng gián tiếp từ Lần 5a/5b (service sống qua Doze), chưa đo + luồng ghép đôi QR thật. Kết luận bearer dựa trên AC1/AC2/Doze + throughput, chưa phải bộ AC đầy đủ mà spec + yêu cầu — nhưng điều kiện dừng ("không bearer nào đạt AC1 khi rảnh → dừng") KHÔNG xảy ra (AC1 khi rảnh đạt), + nên Phase 7 tiếp tục; các AC còn lại chờ A7.1/M7.1 đo khi có code sản phẩm. + +## Yêu cầu phát hiện cho sản phẩm (rút từ spike, áp dụng A-SVC/A7.1) + +1. **A-SVC phải dựng lại L2CAP server/advertiser khi nhận `BluetoothAdapter.ACTION_STATE_CHANGED`/`STATE_ON`** + (không chỉ khi khởi động lần đầu) — phát hiện #168: service sống qua Doze/screen-off nhưng chết sau một lần + tắt/bật Bluetooth thật thì vô dụng ngoài thực tế; người dùng tắt/bật Bluetooth thường hơn khởi động lại app. +2. **Gói quảng bá ≤ 31 byte** — dùng service UUID 16-bit (Base UUID pattern), không tên (`setIncludeDeviceName + (false)`). Phát hiện #156: UUID 128-bit đầy đủ + service data vượt 31 byte → `ADVERTISE_FAILED_DATA_TOO_LARGE` + → điện thoại không phát sóng. +3. **Bound độ dài frame từ peer** — `FrameBuffer.append` (Swift) và `readFrame` (Kotlin) đọc length 4-byte rồi cấp + phát mà không kiểm cận trên (finding Trang #165/#2960) — chấp nhận được cho spike dev-only, nhưng **bắt buộc** + bound trong sản phẩm vì attacker kiểm soát length có thể buộc cấp phát bộ nhớ tuỳ ý. +4. **Không có API công khai chỉnh connection interval cho L2CAP thô** — `requestConnectionPriority()` chỉ dùng + được với GATT, không với `listenUsingInsecureL2capChannel`/`BluetoothSocket` (ghi chú + `BleSpikeService.kt:244-249`). Latency/throughput phụ thuộc interval do controller chọn, ứng dụng không điều + khiển được — A7.1/M7.1 cần cân nhắc có đáng dựng GATT server tối giản chỉ để xin priority, hay chấp nhận giới + hạn này. + +## Chưa đo + +Ngoài phạm vi spike này, chờ lần chạy riêng hoặc code sản phẩm (A7.1/M7.1/T7.1): + +- **Doze (Lần 5a/5b)** — đã chạy 15 phút force-idle: BLE giữ được kết nối qua Doze (0 `bt_lost`, Lần 5b), nhưng + trễ một chiều chậm (p95 ~0,66 s so với ~134 ms khi rảnh). Heartbeat không nhất quán giữa Lần 5a và 5b (5b: 55/55 + tick; 5a: dừng ở tick 8) — chưa khẳng định được service luôn sống qua Doze. Luồng ghép đôi QR của AC6 **chưa + đo** — spike không triển khai. +- **Mac sleep/wake**: chưa thử ngủ/thức Mac giữa reconnect, chỉ toggle Bluetooth S25 qua `adb`. +- **Out of range**: chưa đo (script không điều khiển vị trí vật lý, chỉ Bluetooth). +- **Wi-Fi Direct**: để SP4, xem "Brief items" dưới. +- **BLE sniff (AC9)**: chưa bắt gói sniffer để kiểm tra lộ định danh (`pair_id`, `device_id`, tên) — spike chỉ + PING/BULK, chưa envelope D7.1; AC9 chỉ đo khi code sản phẩm có. +- **Scan-response layout (task #187)**: code xong (xem "Scan-response layout, task #187" trên). Sếp quyết không + chạy đo riêng (#3309) — scan response chưa đo riêng; kiểm khi A7.1b/M7.1c chạy trên S25 + Mac. +- **AC3, AC4, AC7, AC8**: ngoài phạm vi spike — cần code sản phẩm (A7.1–A7.7/M7.1–M7.7). + +## Brief items not needing device (recorded now) + +- **Item 6, RFCOMM**: không triển khai (brief: "chỉ ghi nhận không khả thi nếu không tới máy trước bond"). Plan đã + kết luận độc lập: RFCOMM chỉ dùng được nếu Mac tới được điện thoại mà không cần bond — nhưng Android app không đọc + được địa chỉ Bluetooth (địa chỉ BLE ngẫu nhiên), và HandLive không bond (SP5 đã bỏ). **RFCOMM không khả thi.** +- **Item 7, Wi-Fi Direct**: không thử (Mac chỉ có 1 radio Wi-Fi, không có đường an toàn). Để SP4. +- **Item 5, mã hoá link của L2CAP chưa bond**: không probe — không có API nào truy vấn được trạng thái mã hoá của + L2CAP. Bluetooth spec và plan (`phase-07-ket-noi-moi-noi.md` dòng 39) đều kỳ vọng link chưa bond thì không mã + hoá — vì thế lớp bảo mật link của D7.1 là bắt buộc. Spike chỉ cho thấy chưa có gì trái kỳ vọng đó; không bác bỏ + được, cũng không bắt gói để xác nhận. + +## What's needed next + +Bearer đã chốt (xem "Kết luận bearer"), gate không dừng Phase 7 — mục này giờ chỉ còn cho các số liệu lẻ ở "Chưa +đo" (p95 reconnect với n lớn hơn, Mac sleep/wake, out of range, luồng ghép đôi QR của AC6), không phải để chốt lại +bearer. + +1. S25 on USB, debug on, Bluetooth on Mac — cần một phiên nữa của owner nếu team muốn số liệu đó; không phải + điều kiện để dùng kết luận bearer hiện có. +2. Cách build/run `adb`/`gradle`/`swift`: vẫn đúng như trước — owner chạy `run-spike.sh` trực tiếp trong terminal, + không qua agent (xem "Status" đầu file). Đã tự kiểm lại session này (2026-10-29): biên dịch tay sạch cả hai + probe — Kotlin qua `K2JVMCompiler` với `android.jar`/`androidx.core`/coroutines (0 lỗi, trừ class `R` chưa sinh + vì cần AAPT2 thật), Swift qua `swiftc -emit-module` (0 lỗi, chỉ warning Sendable-closure có từ trước) — code + chưa rỉ sét từ lần chạy Lần 5b. +3. ~~Khi có số: điền bảng, chốt bearer (hay dừng)~~ — đã xong (xem "Bảng tổng hợp theo AC", "Kết luận bearer"). + 3 trong 4 điều kiện bắt buộc cho sản phẩm đã vào code thật ở `core:transport`/`feature:connection` (topic "Viết code + Phase 7", đóng theo quyết định #3285): `BluetoothLinkScheduler` (ưu tiên control-trước-bulk), + `BluetoothLinkLifecycle`/`BluetoothAdvertising` (dựng lại khi `STATE_ON`), `BtRecordCodec.requireWithinBound`/ + `requireValidTransportRlen` (bound độ dài frame). Điều kiện còn lại (độ trễ Doze) là đánh đổi sản phẩm, không + phải code. Luồng ghép đôi QR thật của AC6 vẫn chưa đo — việc của A7.2/M7.2 + khi luồng đó có code, không phải của spike này. diff --git a/plans/20260925-implementation/reports/security-scan-0.2.0-2026-10-30.md b/plans/20260925-implementation/reports/security-scan-0.2.0-2026-10-30.md new file mode 100644 index 0000000..b7db430 --- /dev/null +++ b/plans/20260925-implementation/reports/security-scan-0.2.0-2026-10-30.md @@ -0,0 +1,68 @@ +# Security scan before v0.2.0 (2026-10-30) + +Owner request: merge Phase 7 (Bluetooth offline pairing) and release v0.2.0. This scan gates the tag per +`docs/deployment-guide.md` ("Cutting a release"): no critical or high finding may stay open. + +## Scope + +`git log`/`git diff --shortstat v0.1.1..HEAD` per repository. Numbers measured by Long (#4130) and Huy (#4138, #4152) +and cross-checked against each repo's reflog by this reviewer. The changed source was read directly, not only counted. + +| Repo | HEAD | Commits since v0.1.1 | Files | Content | +|------|------|------|------|------| +| hub | `facf1c3` | 12 | 27 | `tools/measure-sp1{,-prep,-auto}.sh`, `tools/verify-phase7.sh`, `tools/verify-android-ui.sh`, `tools/release-push.sh` and `release-tag.sh` (#234; `facf1c3` only changes `release-tag.sh`), 5 commits of Phase 7 design docs EN+VI (#238), `.gitignore` | +| shared | `259217d` | 6 | 38 | Bluetooth/CPace test vectors (`cpace.json`, `bt-pairing.json`, `bt-link.json`, `bt-frame.json`, `bt-advertisement.json`), `tools/setup-cpace-refs.sh`, `tools/vectors/*`, Bluetooth status strings, Noise psk vector-generator fix | +| android | `3900d8b` | 16 | 89 | `core/crypto` (CPaceX25519SHA512, Field25519, NoiseNNpsk0), `core/protocol` + `core/transport` (Bluetooth link, `BluetoothLinkAdmission`/`Acceptor`/`PreauthSession`, `BtPinPairingResponder`), `feature/connection` (`BluetoothAdvertising`, manifest permissions), `feature/pairing` (QR and PIN pairing over Bluetooth), "Connected via Bluetooth" label, version 0.2.0 | +| apple | `28bf435` | 29 | 93 | `HLCrypto` (CPaceX25519SHA512, Field25519, NoiseNNpsk0 initiator), `HLTransport` (CoreBluetooth central/L2CAP/channel, `BtPinPairingSearch`, `BtQrPairingSearch`, `PinAttemptCounter`), `HLAppCore` (`BluetoothFallbackPairingSearch`, `PairingController`, shared PIN counter fix #3932), macOS `NSBluetoothAlwaysUsageDescription`, "Connected via Bluetooth" label, version 0.2.0 | +| relay | `17f367b` | 0 | 0 | Unchanged; same HEAD as the v0.1.1 scan. | + +Outside this scan: the two-phase logic of `tools/release-tag.sh` (#234) was reviewed separately (approved at +`facf1c3`, #4147); its code is in the hub row above and holds no injection, secret or `--force`. +CHANGELOG/README/CLAUDE.md (#235) and this report file (#237) are editorial and not scanned as product code. + +Verdict, all four repositories: **0 critical, 0 high, 0 medium, 0 low.** PASS; the `v0.2.0` tag may proceed. + +## Findings + +None. + +## Checked and clean + +- **CPace-X25519-SHA512 PIN exchange** (android `CPaceX25519SHA512.kt`, apple `CPaceX25519SHA512.swift`). Follows + draft-irtf-cfrg-cpace-21 Appendix B.1. The generator string is zero-padded to the SHA-512 block so hashing time does + not depend on PIN length. A low-order point makes X25519 throw and the channel closes without an answer. The PIN + never goes over the air; only `Ya`/`Yb`, then a Noise-encrypted channel. +- **Shared PIN-lockout counter, both platforms.** Android: one `AtomicInteger offers` in `PairingWindow.Pin`, spent by + both the LAN `pair/offer` path and `BtPinPairingKeySource.onPinChannelFailedAfterCpace1()`. Apple: + `PinAttemptCounter` is an actor created once per PIN in `PairingController` and passed by reference to both the LAN + and Bluetooth searches in `BluetoothFallbackPairingSearch`. This closes this reviewer's earlier finding #3932, + re-checked against the code. +- **Noise_NNpsk0 link handshake** (android `NoiseNNpsk0.kt`, apple initiator). Correct psk0 pattern (Noise rev. 34 + §9.2). A failed AEAD tag returns `null` without saying which psk failed. +- **Bluetooth admission** (`BluetoothLinkAdmission.kt`). BLE gives no client address, so failures lock the whole + listener for `BT_AUTH_FAIL_WINDOW` after `BT_AUTH_FAIL_BLOCK` failures. Documented trade-off. +- **Android Bluetooth permissions** (`feature/connection/src/main/AndroidManifest.xml`). Only `BLUETOOTH_ADVERTISE` and + `BLUETOOTH_CONNECT` on API 31+; no `BLUETOOTH_SCAN` because the phone never scans. Legacy `BLUETOOTH`/`BLUETOOTH_ADMIN` + capped at `maxSdkVersion=30`. No other module requests Bluetooth. +- **New scripts, all run by the owner on the Mac, none shipped.** `measure-sp1*.sh`, `verify-phase7.sh`, + `verify-android-ui.sh`: arguments go into zsh arrays, never `eval` or `sh -c`. `setup-cpace-refs.sh`: downloads the + public IETF draft over HTTPS; the version is limited to two digits before it reaches the URL; the download is + gitignored. `release-push.sh`/`release-tag.sh`: no `--force`, `$TAG` must start with `v`, all variables quoted, tag + SHAs come from `git rev-parse`, and the two-phase `release-tag.sh` checks local and remote tags in all four repos + before tagging any. +- **Vectors and Phase 7 docs.** Key values in `bt-*.json` are synthetic fixtures. A grep of the new docs and plans for + keys, tokens and passwords found nothing real (two `bearer` hits are the BLE transport term). +- **Secrets across the diff.** Huy grepped `git log -p v0.1.1..main` on all four repos for private keys, API keys and + AWS keys (#4109); this reviewer grepped the new scripts, vectors and docs. Clean. Tien's independent review (#4151, + #4160) agrees. +- **Version bumps to 0.2.0.** One line in `handlive-android/app/build.gradle.kts`, one block (3 targets) in + `handlive-apple/project.yml`, nothing else. + +## Unresolved questions + +None. + +Status: DONE +Summary: every commit since `v0.1.1` in the four repositories was read (relay unchanged). 0 critical, 0 high, 0 +medium, 0 low. The `v0.2.0` tag may proceed. +Concerns/Blockers: none for the tag. diff --git a/tools/measure-sp1-auto.sh b/tools/measure-sp1-auto.sh new file mode 100755 index 0000000..a279342 --- /dev/null +++ b/tools/measure-sp1-auto.sh @@ -0,0 +1,316 @@ +#!/bin/zsh +# Chay NGOAI sandbox qua nut run cua sep (S25 cam qua USB, Mac nay la may do), khong co nguoi ngoi theo doi: +# zsh handlive/tools/measure-sp1-auto.sh --session 1|2|3 [adb_serial] +# +# Lam tron mot phien cua runbook handlive/plans/20260925-implementation/reports/phase-07-sp1-measure.md ma +# khong can thao tac tay: goi measure-sp1.sh nen de build/mo app + ghi log HLBENCH, cho link len Bluetooth, +# tu lam cac buoc copy/toggle/doze cua phien, roi dung ghi log va chay clip_latency.py/reconnect_time.py. +# Sandbox cua agent chan moi ket noi adb (xem #3717-#3720), nen toan bo khoi nay phai chay qua nut run. +# +# --session 1: phien day du (khong --log-only) — build + cai lai APK/app macOS tu dau. +# --session 2|3: dung lai APK/app macOS cua lan --session 1 gan nhat (them --log-only vao measure-sp1.sh). +# +# Chieu S25->Mac tu dong hoa bang broadcast cua app "Clipper" (ca.zgrs.clipper, trick quen thuoc de set +# clipboard Android qua adb ma khong can UI — Android chan ClipboardManager tu background tu Android 10). +# Khong co app do tren S25 thi buoc nay tu bo qua va in ro, chi con chieu Mac->S25 (pbcopy) nhu runbook cho phep. +set -uo pipefail +zmodload zsh/datetime + +epoch_ms() { + printf '%.0f' $(( EPOCHREALTIME * 1000 )) +} + +SESSION="" +SERIAL="" +while [ $# -gt 0 ]; do + case "$1" in + --session) + shift + SESSION="${1:-}" + shift + ;; + -*) + echo "LOI: khong biet tuy chon: $1" >&2 + exit 2 + ;; + *) + SERIAL="$1" + shift + ;; + esac +done + +case "$SESSION" in + 1|2|3) ;; + *) + echo "Dung: zsh $0 --session 1|2|3 [adb_serial]" >&2 + exit 2 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HUB_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" +SHARED_DIR="$WORKSPACE_ROOT/handlive-shared" +MEASURE_SH="$HUB_ROOT/tools/measure-sp1.sh" +LOG_DIR="$HUB_ROOT/.build-logs" +mkdir -p "$LOG_DIR" +TS="$(date +%Y%m%d-%H%M%S)" +: "${TMPDIR:=/tmp}" + +[ -f "$MEASURE_SH" ] || { echo "LOI: khong thay $MEASURE_SH" >&2; exit 1; } +command -v adb >/dev/null || { echo "LOI: khong thay adb trong PATH" >&2; exit 1; } +command -v osascript >/dev/null || { echo "LOI: khong thay osascript trong PATH" >&2; exit 1; } +command -v pbcopy >/dev/null || { echo "LOI: khong thay pbcopy trong PATH" >&2; exit 1; } + +typeset -a ADB_ARGS +if [ -n "$SERIAL" ]; then + ADB_ARGS=(-s "$SERIAL") +fi + +ANDROID_LOG="" +MAC_LOG="" +MAC_APP_PATH="" +MEASURE_PID="" +LOGGING_STOPPED=0 + +fail() { + echo "LOI: $1" >&2 + if [ -n "$ANDROID_LOG" ] && [ -f "$ANDROID_LOG" ]; then + echo "--- 20 dong cuoi $ANDROID_LOG ---" >&2 + tail -n 20 "$ANDROID_LOG" >&2 + fi + if [ -n "$MAC_LOG" ] && [ -f "$MAC_LOG" ]; then + echo "--- 20 dong cuoi $MAC_LOG ---" >&2 + tail -n 20 "$MAC_LOG" >&2 + fi + exit 1 +} + +cleanup() { + echo + echo "== Don dep: dung ghi log (neu con) va khoi phuc S25 ==" + if [ -n "$MEASURE_PID" ] && [ "$LOGGING_STOPPED" -eq 0 ]; then + kill -INT "$MEASURE_PID" 2>/dev/null || true + wait "$MEASURE_PID" 2>/dev/null || true + LOGGING_STOPPED=1 + fi + adb "${ADB_ARGS[@]}" shell dumpsys deviceidle unforce >/dev/null 2>&1 || true + adb "${ADB_ARGS[@]}" shell dumpsys battery reset >/dev/null 2>&1 || true + adb "${ADB_ARGS[@]}" shell svc wifi enable >/dev/null 2>&1 || true + adb "${ADB_ARGS[@]}" shell svc data enable >/dev/null 2>&1 || true + adb "${ADB_ARGS[@]}" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true +} +trap cleanup EXIT + +wait_for_connected() { + local timeout="${1:-120}" waited=0 + while [ "$waited" -lt "$timeout" ]; do + if grep 'ev=state' "$MAC_LOG" 2>/dev/null | tail -n 1 | grep -q 'to=Connected channel=bluetooth'; then + return 0 + fi + sleep 2 + waited=$((waited + 2)) + done + return 1 +} + +# Trick "Clipper" (ca.zgrs.clipper): `am broadcast -a clipper.set -e text VALUE` set duoc clipboard Android tu +# adb ma khong can UI. `am broadcast` luon tra "result=0" du co receiver nhan hay khong (khong co cach phan +# biet qua ma ket qua), nen kiem truoc bang pm list packages (goi 1 lan trong session 1, bien CLIPPER_INSTALLED +# duoi day): khong cai app nay thi bo qua ngay, co cai thi gui broadcast va coi la thanh cong (khong co cach +# nao xac nhan sau hon tu phia adb). +CLIPPER_INSTALLED=0 + +android_copy_text() { + local text="$1" + [ "$CLIPPER_INSTALLED" -eq 1 ] || return 1 + adb "${ADB_ARGS[@]}" shell am broadcast -a clipper.set -e text "$text" >/dev/null 2>&1 +} + +# Tao mot anh ~5 MB (BMP ghi tay bang python3 stdlib roi doi sang PNG bang sips, khong can PIL) de dua len +# pasteboard Mac bang osascript — khong dung noi dung chep tu dau khac vi khong co file anh san co dung kich co. +make_bench_image() { + local out_bmp="$1" out_png="$2" + python3 - "$out_bmp" <<'PYEOF' +import os +import struct +import sys + +path = sys.argv[1] +width, height = 1700, 1024 # 24-bit BMP, hang khong can padding (1700*3=5100 chia het cho 4) -> khoang 5.2 MB. +row_bytes = width * 3 +pixel_data_size = row_bytes * height +file_size = 54 + pixel_data_size +with open(path, "wb") as f: + f.write(b"BM") + f.write(struct.pack("/dev/null +} + +set_mac_image_clipboard() { + local png_path="$1" + osascript -e "set the clipboard to (read (POSIX file \"$png_path\") as «class PNGf»)" +} + +echo "== 1. adb devices ==" +adb devices + +echo "== 2. Tat Wi-Fi + du lieu di dong tren S25 (Mac van bat Wi-Fi) ==" +adb "${ADB_ARGS[@]}" shell svc wifi disable || fail "svc wifi disable that bai" +adb "${ADB_ARGS[@]}" shell svc data disable || fail "svc data disable that bai" + +typeset -a MEASURE_ARGS +if [ "$SESSION" != "1" ]; then + MEASURE_ARGS+=(--log-only) +fi +if [ -n "$SERIAL" ]; then + MEASURE_ARGS+=("$SERIAL") +fi + +MEASURE_OUT="$LOG_DIR/sp1-auto-session${SESSION}-$TS-measure.out" +echo "== 3. Chay measure-sp1.sh nen (session $SESSION, args: ${MEASURE_ARGS[*]:-}) ==" +zsh "$MEASURE_SH" "${MEASURE_ARGS[@]}" > "$MEASURE_OUT" 2>&1 & +MEASURE_PID=$! +echo "measure-sp1.sh PID=$MEASURE_PID, output day du tai: $MEASURE_OUT" + +echo "== 4. Doi measure-sp1.sh in duong dan log (toi da 600s, co build app) ==" +waited=0 +while [ "$waited" -lt 600 ]; do + if ! kill -0 "$MEASURE_PID" 2>/dev/null; then + fail "measure-sp1.sh da thoat truoc khi in duong dan log, xem $MEASURE_OUT" + fi + ANDROID_LOG="$(grep -m1 '^Android: ' "$MEASURE_OUT" 2>/dev/null | sed 's/^Android: //')" + MAC_LOG="$(grep -m1 '^Mac:' "$MEASURE_OUT" 2>/dev/null | sed -E 's/^Mac:[[:space:]]*//')" + MAC_APP_PATH="$(grep -m1 '^App macOS: ' "$MEASURE_OUT" 2>/dev/null | sed 's/^App macOS: //')" + if [ -n "$ANDROID_LOG" ] && [ -n "$MAC_LOG" ]; then + break + fi + sleep 2 + waited=$((waited + 2)) +done +[ -n "$ANDROID_LOG" ] && [ -n "$MAC_LOG" ] || fail "qua 600s khong thay measure-sp1.sh in duong dan log, xem $MEASURE_OUT" +echo "Android log: $ANDROID_LOG" +echo "Mac log: $MAC_LOG" +[ -n "$MAC_APP_PATH" ] && echo "App macOS: $MAC_APP_PATH" + +echo "== 5. Cho link len Bluetooth (toi da 120s) ==" +wait_for_connected 120 || fail "qua 120s khong thay 'to=Connected channel=bluetooth' trong $MAC_LOG" +echo "Da thay Bluetooth Connected." + +case "$SESSION" in + 1) + echo "== 6. AC1 idle: 30 lan copy text, doi chieu, cach 3-4s ==" + ANDROID_COPY_OK=0 + if adb "${ADB_ARGS[@]}" shell pm list packages ca.zgrs.clipper 2>/dev/null | grep -q 'package:ca\.zgrs\.clipper'; then + CLIPPER_INSTALLED=1 + ANDROID_COPY_OK=1 + else + echo "CANH BAO: khong thay app Clipper (ca.zgrs.clipper) tren S25 — khong co cach set clipboard Android" \ + "tu adb ma khong co UI, bo qua chieu S25->Mac, chi copy chieu Mac->S25 ca 30 lan." >&2 + fi + for (( i = 1; i <= 30; i++ )); do + if [ "$ANDROID_COPY_OK" -eq 1 ] && (( i % 2 == 0 )); then + if ! android_copy_text "sp1 auto s25->mac $i $(epoch_ms)"; then + echo "CANH BAO: lan $i copy tu S25 that bai, bo qua lan nay" >&2 + fi + else + print -n -- "sp1 auto mac->s25 $i $(epoch_ms)" | pbcopy + fi + sleep $(( 3 + (RANDOM % 2) )) + done + + echo "== 7. 5 vong tat/bat Bluetooth tren S25, moi vong cho Connected lai (toi da 30s) ==" + for (( round = 1; round <= 5; round++ )); do + echo "-- vong $round/5: tat bluetooth_manager --" + adb "${ADB_ARGS[@]}" shell cmd bluetooth_manager disable || true + sleep 2 + echo "-- vong $round/5: bat lai bluetooth_manager --" + adb "${ADB_ARGS[@]}" shell cmd bluetooth_manager enable || true + if ! wait_for_connected 30; then + echo "CANH BAO: vong $round khong thay Connected lai trong 30s" >&2 + fi + done + + echo "== 8. Scan response: khoa man hinh S25, cho 60s, Cmd-Q + mo lai app Mac ==" + BT_ACCEPT_BEFORE="$(grep -c 'ev=bt_accept' "$ANDROID_LOG" 2>/dev/null || true)" + : "${BT_ACCEPT_BEFORE:=0}" + adb "${ADB_ARGS[@]}" shell input keyevent KEYCODE_SLEEP || true + sleep 60 + osascript -e 'quit app "HandLive"' || echo "CANH BAO: osascript quit app HandLive that bai" >&2 + if [ -n "$MAC_APP_PATH" ]; then + open "$MAC_APP_PATH" || echo "CANH BAO: mo lai app Mac that bai" >&2 + else + echo "CANH BAO: khong doc duoc duong dan app macOS tu $MEASURE_OUT, bo qua mo lai app" >&2 + fi + sleep 5 + adb "${ADB_ARGS[@]}" shell input keyevent KEYCODE_WAKEUP || true + wait_for_connected 30 || echo "CANH BAO: sau buoc scan-response khong thay Connected lai trong 30s" >&2 + BT_ACCEPT_AFTER="$(grep -c 'ev=bt_accept' "$ANDROID_LOG" 2>/dev/null || true)" + : "${BT_ACCEPT_AFTER:=0}" + echo "bt_accept truoc=$BT_ACCEPT_BEFORE sau=$BT_ACCEPT_AFTER" + if [ "$BT_ACCEPT_AFTER" -le "$BT_ACCEPT_BEFORE" ]; then + echo "CANH BAO: bt_accept khong tang sau buoc scan-response (xem 00-common-specs.md 0.4.5)" >&2 + fi + ;; + + 2) + echo "== 6. AC1 duoi tai: anh ~5MB len pasteboard Mac, xen 10 lan copy text ==" + IMG_BMP="$TMPDIR/handlive-sp1-bench-$TS.bmp" + IMG_PNG="$TMPDIR/handlive-sp1-bench-$TS.png" + make_bench_image "$IMG_BMP" "$IMG_PNG" || fail "tao anh bench that bai" + rm -f "$IMG_BMP" + for (( i = 1; i <= 10; i++ )); do + if (( i == 1 || i % 3 == 0 )); then + set_mac_image_clipboard "$IMG_PNG" || echo "CANH BAO: set anh len pasteboard Mac that bai (lan $i)" >&2 + sleep 1 + fi + print -n -- "sp1 auto load mac->s25 $i $(epoch_ms)" | pbcopy + sleep $(( 3 + (RANDOM % 2) )) + done + rm -f "$IMG_PNG" + ;; + + 3) + echo "== 6. Doze: ep S25 vao Doze ==" + adb "${ADB_ARGS[@]}" shell dumpsys battery unplug || true + adb "${ADB_ARGS[@]}" shell input keyevent KEYCODE_SLEEP || true + adb "${ADB_ARGS[@]}" shell dumpsys deviceidle force-idle || true + + echo "== 7. 30 lan copy text Mac->S25, cach 30s, man hinh S25 khong dong vao ==" + for (( i = 1; i <= 30; i++ )); do + print -n -- "sp1 auto doze mac->s25 $i $(epoch_ms)" | pbcopy + sleep 30 + done + + echo "== 8. Khoi phuc S25 sau Doze ==" + adb "${ADB_ARGS[@]}" shell dumpsys deviceidle unforce || true + adb "${ADB_ARGS[@]}" shell dumpsys battery reset || true + adb "${ADB_ARGS[@]}" shell input keyevent KEYCODE_WAKEUP || true + ;; +esac + +echo "== 9. Dung ghi log ==" +kill -INT "$MEASURE_PID" 2>/dev/null || true +wait "$MEASURE_PID" 2>/dev/null || true +LOGGING_STOPPED=1 + +echo "Android log: $ANDROID_LOG" +echo "Mac log: $MAC_LOG" + +command -v python3 >/dev/null || fail "khong thay python3 trong PATH" + +echo "== 10. clip_latency.py (AC1) ==" +python3 "$SHARED_DIR/tools/bench/clip_latency.py" "$ANDROID_LOG" "$MAC_LOG" --json \ + || echo "CANH BAO: clip_latency.py tra ma loi khac 0" >&2 + +echo "== 11. reconnect_time.py (AC2) ==" +python3 "$SHARED_DIR/tools/bench/reconnect_time.py" "$MAC_LOG" "$ANDROID_LOG" --json \ + || echo "CANH BAO: reconnect_time.py tra ma loi khac 0" >&2 + +echo "Xong session $SESSION." diff --git a/tools/measure-sp1-prep.sh b/tools/measure-sp1-prep.sh new file mode 100755 index 0000000..952d25d --- /dev/null +++ b/tools/measure-sp1-prep.sh @@ -0,0 +1,113 @@ +#!/bin/zsh +# Chuan bi ghep doi SP1: go sach ban cu, build va cai ban debug len S25 va Mac, +# tat Wi-Fi, ghep qua Bluetooth. +# Chay NGOAI sandbox qua nut run cua sep (S25 cam qua USB): +# zsh handlive/tools/measure-sp1-prep.sh +# +# Sau script thoat thanh cong, sxep tat Wi-Fi ca hai may, bat Bluetooth, roi ghep doi S25 voi Mac 1 lan (quét QR hoac nhap PIN, ~1 phut), +# roi team tu chay phien 1 do qua measure-sp1-auto.sh. +# +# Go sach app + du lieu Android cu va ban HandLive.app cu tren Mac truoc khi cai, +# de dam bao cai sach. Khong tac dong Wi-Fi, khong ghi log. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HUB_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" +ANDROID_DIR="$WORKSPACE_ROOT/handlive-android" +APPLE_DIR="$WORKSPACE_ROOT/handlive-apple" + +APP_ID="com.handlive.android" +MAIN_ACTIVITY="$APP_ID/app.handlive.android.MainActivity" + +fail() { + echo "LOI: $1" >&2 + exit 1 +} + +command -v adb >/dev/null || fail "khong thay adb trong PATH" +command -v xcodebuild >/dev/null || fail "khong thay xcodebuild trong PATH" +adb get-state >/dev/null 2>&1 || fail "khong thay thiet bi Android qua adb (cam S25, bat USB debugging)" + +echo "== 1. Go sach ban Android cu (app + du lieu) ==" +if adb shell pm list packages "$APP_ID" 2>/dev/null | grep -q "^package:$APP_ID$"; then + adb uninstall "$APP_ID" || fail "adb uninstall $APP_ID that bai" + echo "Da go $APP_ID (ca du lieu) tren S25" +else + echo "$APP_ID chua cai tren S25, bo qua go" +fi + +echo "== 2. Build APK debug (flavor foss) ==" +: "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" +: "${ANDROID_HOME:=/opt/homebrew/share/android-commandlinetools}" +export JAVA_HOME ANDROID_HOME + +GRADLEW="$ANDROID_DIR/gradlew" +[ -f "$GRADLEW" ] || fail "khong thay $GRADLEW" + +env JAVA_HOME="$JAVA_HOME" ANDROID_HOME="$ANDROID_HOME" "$GRADLEW" -p "$ANDROID_DIR" ":app:assembleFossDebug" \ + || fail "gradlew :app:assembleFossDebug that bai" + +APK_PATH="$(find "$ANDROID_DIR/app/build/outputs/apk/foss/debug" -name '*.apk' -print -quit 2>/dev/null)" +[ -n "$APK_PATH" ] || fail "khong tim thay APK debug duoi $ANDROID_DIR/app/build/outputs/apk/foss/debug" +echo "APK: $APK_PATH" + +echo "== 3. Cai APK len S25 ==" +adb install "$APK_PATH" || fail "adb install that bai" + +echo "== 4. Cap quyen Bluetooth ==" +adb shell pm grant "$APP_ID" android.permission.BLUETOOTH_ADVERTISE >/dev/null 2>&1 || true +adb shell pm grant "$APP_ID" android.permission.BLUETOOTH_CONNECT >/dev/null 2>&1 || true + +echo "== 5. Mo app tren S25 ==" +adb shell am start -n "$MAIN_ACTIVITY" || fail "adb shell am start that bai" + +echo +echo "== 6. Go sach ban Mac cu ==" +pkill -x HandLive >/dev/null 2>&1 || true +echo "Da dung HandLive dang chay (neu co)" + +MAC_APP_SYSTEM="/Applications/HandLive.app" +MAC_APP_USER="$HOME/Applications/HandLive.app" +if [ -d "$MAC_APP_SYSTEM" ]; then + rm -rf "$MAC_APP_SYSTEM" || fail "khong xoa duoc $MAC_APP_SYSTEM" + echo "Da xoa $MAC_APP_SYSTEM" +fi +if [ -d "$MAC_APP_USER" ]; then + rm -rf "$MAC_APP_USER" || fail "khong xoa duoc $MAC_APP_USER" + echo "Da xoa $MAC_APP_USER" +fi + +DERIVED_DATA="$APPLE_DIR/.build-logs/sp1-derived-data" +if [ -d "$DERIVED_DATA" ]; then + rm -rf "$DERIVED_DATA" || fail "khong xoa duoc $DERIVED_DATA" + echo "Da xoa DerivedData cu $DERIVED_DATA" +fi + +echo +echo "== 7. Build app macOS (debug) ==" +command -v xcodegen >/dev/null || fail "khong thay xcodegen trong PATH (brew install xcodegen)" +(cd "$APPLE_DIR" && xcodegen generate) || fail "xcodegen generate that bai" + +mkdir -p "$APPLE_DIR/.build-logs" +xcodebuild build \ + -workspace "$APPLE_DIR/HandLive.xcworkspace" \ + -scheme "HandLive" \ + -configuration Debug \ + -destination "platform=macOS" \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + || fail "xcodebuild build HandLive (macOS) that bai" + +MAC_APP_PATH="$(find "$DERIVED_DATA/Build/Products/Debug" -maxdepth 1 -name 'HandLive.app' -print -quit 2>/dev/null)" +[ -n "$MAC_APP_PATH" ] || fail "khong tim thay HandLive.app duoi $DERIVED_DATA/Build/Products/Debug" +echo "App macOS: $MAC_APP_PATH" + +echo "== 8. Ky ad-hoc de khong can provisioning profile ==" +codesign --force --deep --sign - "$MAC_APP_PATH" || fail "codesign ad-hoc that bai" + +echo "== 9. Mo app tren Mac ==" +open "$MAC_APP_PATH" || fail "mo HandLive.app that bai (sandbox chan 'open'; chay script nay ngoai sandbox)" + +echo +echo "SAN SANG: tat Wi-Fi ca hai may, bat Bluetooth, mo man ghep doi tren Mac roi quet QR (hoac nhap PIN) tren S25 — ghep qua Bluetooth" diff --git a/tools/measure-sp1.sh b/tools/measure-sp1.sh new file mode 100755 index 0000000..1403d18 --- /dev/null +++ b/tools/measure-sp1.sh @@ -0,0 +1,151 @@ +#!/bin/zsh +# Chay NGOAI sandbox, voi S25 cam qua USB (adb) va may Mac nay la may do: +# zsh handlive/tools/measure-sp1.sh [--log-only] [adb_serial] +# +# Chuan bi SP1 (kenh dieu khien Bluetooth) de do AC1/AC2 tren may that, theo +# handlive/plans/20260925-implementation/reports/phase-07-sp1-measure.md: +# 1. Build APK debug (:app:assembleDebug), cai len S25 qua adb, cap quyen +# BLUETOOTH_ADVERTISE/BLUETOOTH_CONNECT (chua co man xin quyen), mo app. +# 2. Build app macOS (xcodegen generate + xcodebuild, scheme HandLive, cau hinh Debug) va mo app. +# 3. Goi logcat (Android) va `log stream` (Mac) loc dong HLBENCH vao +# handlive/.build-logs/sp1--android.log va sp1--mac.log. +# +# --log-only: bo qua build+cai APK va bo qua xcodegen+xcodebuild; dung lai APK/app macOS cua lan +# chay full gan nhat (danh cho phien 2/3 cua runbook, do lai nhanh khong phai build lai). Van cap +# quyen Bluetooth (idempotent) va mo lai app o hai may. That bai ngay voi thong bao ro neu chua +# chay full it nhat mot lan (chua co .app macOS de mo). +# +# Dung tay: lam cac buoc do trong runbook (phase-07-sp1-measure.md), roi Ctrl-C de dung ghi log. +# Khong dung mang ngoai; chi dung JAVA_HOME/ANDROID_HOME nhu verify-phase7.sh. +set -uo pipefail + +LOG_ONLY=0 +SERIAL="" +for arg in "$@"; do + case "$arg" in + --log-only) LOG_ONLY=1 ;; + -*) echo "LOI: khong biet tuy chon: $arg" >&2; exit 2 ;; + *) SERIAL="$arg" ;; + esac +done + +HUB_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" +APPLE_DIR="$WORKSPACE_ROOT/handlive-apple" +ANDROID_DIR="$WORKSPACE_ROOT/handlive-android" + +LOG_DIR="$HUB_ROOT/.build-logs" +mkdir -p "$LOG_DIR" +TS="$(date +%Y%m%d-%H%M%S)" +ANDROID_LOG="$LOG_DIR/sp1-$TS-android.log" +MAC_LOG="$LOG_DIR/sp1-$TS-mac.log" + +APP_ID="com.handlive.android" +MAIN_ACTIVITY="$APP_ID/app.handlive.android.MainActivity" + +typeset -a ADB_ARGS +if [ -n "$SERIAL" ]; then + ADB_ARGS=(-s "$SERIAL") +fi + +fail() { + echo "LOI: $1" >&2 + exit 1 +} + +command -v adb >/dev/null || fail "khong thay adb (Android platform-tools) trong PATH" +command -v xcodebuild >/dev/null || fail "khong thay xcodebuild trong PATH" + +adb "${ADB_ARGS[@]}" get-state >/dev/null 2>&1 || fail "khong thay thiet bi Android qua adb (cam S25, bat USB debugging)" + +if [ "$LOG_ONLY" -eq 0 ]; then + echo "== 1. Build + cai APK debug len S25 ==" + : "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" + : "${ANDROID_HOME:=/opt/homebrew/share/android-commandlinetools}" + export JAVA_HOME ANDROID_HOME + + GRADLEW="$ANDROID_DIR/gradlew" + [ -f "$GRADLEW" ] || fail "khong thay $GRADLEW" + + env JAVA_HOME="$JAVA_HOME" ANDROID_HOME="$ANDROID_HOME" "$GRADLEW" -p "$ANDROID_DIR" ":app:assembleFossDebug" \ + || fail "gradlew :app:assembleFossDebug that bai" + + APK_PATH="$(find "$ANDROID_DIR/app/build/outputs/apk/foss/debug" -name '*.apk' -print -quit 2>/dev/null)" + [ -n "$APK_PATH" ] || fail "khong tim thay APK debug duoi $ANDROID_DIR/app/build/outputs/apk/foss/debug" + echo "APK: $APK_PATH" + + adb "${ADB_ARGS[@]}" install -r "$APK_PATH" || fail "adb install that bai" +else + echo "== 1. --log-only: bo qua build + cai APK (dung lai ban da cai tu lan chay full) ==" +fi + +echo "== 2. Cap quyen Bluetooth (chua co man xin quyen trong app) ==" +adb "${ADB_ARGS[@]}" shell pm grant "$APP_ID" android.permission.BLUETOOTH_ADVERTISE \ + || echo " CANH BAO: pm grant BLUETOOTH_ADVERTISE that bai (co the da cap san)" +adb "${ADB_ARGS[@]}" shell pm grant "$APP_ID" android.permission.BLUETOOTH_CONNECT \ + || echo " CANH BAO: pm grant BLUETOOTH_CONNECT that bai (co the da cap san)" + +echo "== 3. Mo app tren S25 ==" +adb "${ADB_ARGS[@]}" shell am start -n "$MAIN_ACTIVITY" || fail "adb shell am start that bai" + +DERIVED_DATA="$APPLE_DIR/.build-logs/sp1-derived-data" + +echo +if [ "$LOG_ONLY" -eq 0 ]; then + echo "== 4. Build app macOS (debug) ==" + command -v xcodegen >/dev/null || fail "khong thay xcodegen trong PATH (brew install xcodegen)" + (cd "$APPLE_DIR" && xcodegen generate) || fail "xcodegen generate that bai" + + mkdir -p "$APPLE_DIR/.build-logs" + xcodebuild build \ + -workspace "$APPLE_DIR/HandLive.xcworkspace" \ + -scheme "HandLive" \ + -configuration Debug \ + -destination "platform=macOS" \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + || fail "xcodebuild build HandLive (macOS) that bai" +else + echo "== 4. --log-only: bo qua xcodegen + xcodebuild (dung lai ban da build tu lan chay full) ==" +fi + +MAC_APP_PATH="$(find "$DERIVED_DATA/Build/Products/Debug" -maxdepth 1 -name 'HandLive.app' -print -quit 2>/dev/null)" +if [ -z "$MAC_APP_PATH" ]; then + if [ "$LOG_ONLY" -eq 1 ]; then + fail "khong thay HandLive.app duoi $DERIVED_DATA/Build/Products/Debug — chay mot lan khong co --log-only truoc" + fi + fail "khong tim thay HandLive.app duoi $DERIVED_DATA/Build/Products/Debug" +fi +echo "App macOS: $MAC_APP_PATH" + +echo "Ky ad-hoc de khong can provisioning profile" +codesign --force --deep --sign - "$MAC_APP_PATH" || fail "codesign ad-hoc that bai" + +echo "== 5. Mo app tren Mac ==" +open "$MAC_APP_PATH" || fail "mo HandLive.app that bai (sandbox chan 'open'; chay script nay ngoai sandbox)" + +echo +echo "== 6. Ghi log HLBENCH (Ctrl-C de dung) ==" +echo "Android: $ANDROID_LOG" +echo "Mac: $MAC_LOG" + +adb "${ADB_ARGS[@]}" logcat -c +adb "${ADB_ARGS[@]}" logcat -v raw -s HLBENCH:I > "$ANDROID_LOG" & +android_pid=$! +log stream --style compact --level info \ + --predicate 'subsystem == "app.handlive.mac" AND category == "bench"' > "$MAC_LOG" & +mac_pid=$! + +stop() { + kill "$android_pid" "$mac_pid" 2>/dev/null || true + wait 2>/dev/null || true + echo + echo "Da dung ghi log." + echo " Android: $(grep -c 'HLBENCH/1 ' "$ANDROID_LOG" 2>/dev/null || echo 0) dong, tai $ANDROID_LOG" + echo " Mac: $(grep -c 'HLBENCH/1 ' "$MAC_LOG" 2>/dev/null || echo 0) dong, tai $MAC_LOG" + echo "Doc ket qua theo handlive/plans/20260925-implementation/reports/phase-07-sp1-measure.md" +} +trap stop INT TERM + +echo "Lam cac buoc do trong runbook (phase-07-sp1-measure.md), roi an Ctrl-C de dung ghi log." +wait diff --git a/tools/release-push.sh b/tools/release-push.sh new file mode 100755 index 0000000..0a4d2a3 --- /dev/null +++ b/tools/release-push.sh @@ -0,0 +1,113 @@ +#!/bin/zsh +# Day nhanh release len origin va mo PR cho tung repo con. +# Chay NGOAI sandbox qua nut run cua sep: +# zsh handlive/tools/release-push.sh # chay thuc +# zsh handlive/tools/release-push.sh --dry-run # chi in lenh, khong push/tao PR +# +# Voi tung repo shared, android, apple, hub (theo thu tu do): +# - bo qua neu nhanh main local KHONG di truoc origin/main (khong co gi de day) +# - bo qua push neu origin/feat/p07-ghep-bluetooth-offline da co dung SHA cua main local +# - dung va bao loi neu origin/feat/p07-ghep-bluetooth-offline co SHA khac (khong --force) +# - git push origin main:feat/p07-ghep-bluetooth-offline (khong doi upstream cua main local) +# - bo qua tao PR neu da co PR open cho nhanh nay, chi in link +# - gh pr create --base main --head feat/p07-ghep-bluetooth-offline --title ... --body ... +# - in link PR +# Dung ngay khi mot buoc loi (khong tiep tuc repo sau). Chay lai nhieu lan an toan. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HUB_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" + +RELEASE_BRANCH="feat/p07-ghep-bluetooth-offline" +PR_TITLE="Phase 7: Bluetooth offline pairing (v0.2.0)" +PR_BODY="Ghep doi Bluetooth lan dau khong can mang (QR luong B, PIN luong C dung CPace-X25519-SHA512), nhan 'Connected via Bluetooth'. Bao cao bao mat PASS o hub reports/security-scan-0.2.0-2026-10-30.md. Merge bang 'Create a merge commit'." + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 +fi + +typeset -a REPO_NAMES +typeset -a REPO_PATHS +REPO_NAMES=(shared android apple hub) +REPO_PATHS=( + "$WORKSPACE_ROOT/handlive-shared" + "$WORKSPACE_ROOT/handlive-android" + "$WORKSPACE_ROOT/handlive-apple" + "$HUB_ROOT" +) + +fail() { + echo "LOI: $1" >&2 + exit 1 +} + +i=1 +while [ "$i" -le "${#REPO_NAMES[@]}" ]; do + name="${REPO_NAMES[$i]}" + repo_path="${REPO_PATHS[$i]}" + i=$((i + 1)) + + echo + echo "== $name ($repo_path) ==" + [ -d "$repo_path" ] || fail "khong thay thu muc $repo_path" + + (cd "$repo_path" && git rev-parse --is-inside-work-tree >/dev/null 2>&1) \ + || fail "$name: khong phai git repo" + + current_branch="$(cd "$repo_path" && git branch --show-current)" + [ "$current_branch" = "main" ] || fail "$name: dang o nhanh '$current_branch', can 'main'" + + echo "+ git fetch origin main" + (cd "$repo_path" && git fetch origin main) || fail "$name: git fetch that bai" + + ahead="$(cd "$repo_path" && git rev-list --count origin/main..main 2>/dev/null)" + if [ -z "$ahead" ]; then + fail "$name: khong doc duoc so commit di truoc origin/main (fetch truoc?)" + fi + if [ "$ahead" -eq 0 ]; then + echo "$name: main khong di truoc origin/main, bo qua" + continue + fi + echo "$name: main di truoc origin/main $ahead commit" + + local_sha="$(cd "$repo_path" && git rev-parse main)" + remote_branch_sha="$(cd "$repo_path" && git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')" + + echo "+ git ls-remote origin refs/heads/$RELEASE_BRANCH" + if [ -n "$remote_branch_sha" ] && [ "$remote_branch_sha" = "$local_sha" ]; then + echo "$name: origin/$RELEASE_BRANCH da dung SHA ($local_sha), bo qua push" + elif [ -n "$remote_branch_sha" ]; then + fail "$name: origin/$RELEASE_BRANCH da ton tai voi SHA khac ($remote_branch_sha != $local_sha), khong tu --force" + else + echo "+ git push origin main:$RELEASE_BRANCH" + if [ "$DRY_RUN" -eq 1 ]; then + echo "$name: (dry-run) khong push" + else + (cd "$repo_path" && git push origin "main:$RELEASE_BRANCH") \ + || fail "$name: git push that bai" + fi + fi + + echo "+ gh pr list --head $RELEASE_BRANCH --state open --json url" + if [ "$DRY_RUN" -eq 1 ]; then + echo "+ gh pr create --base main --head $RELEASE_BRANCH --title ... --body ..." + echo "$name: (dry-run) khong tao PR" + continue + fi + + existing_pr_url="$(cd "$repo_path" && gh pr list --head "$RELEASE_BRANCH" --state open --json url --jq '.[0].url' 2>/dev/null)" + if [ -n "$existing_pr_url" ]; then + echo "$name: PR da co -> $existing_pr_url" + continue + fi + + pr_url="$(cd "$repo_path" && gh pr create --base main --head "$RELEASE_BRANCH" --title "$PR_TITLE" --body "$PR_BODY")" \ + || fail "$name: gh pr create that bai" + + echo "$name: PR -> $pr_url" +done + +echo +echo "XONG. Merge tung PR tren GitHub bang 'Create a merge commit', roi chay: zsh handlive/tools/release-tag.sh " diff --git a/tools/release-tag.sh b/tools/release-tag.sh new file mode 100755 index 0000000..79b9a78 --- /dev/null +++ b/tools/release-tag.sh @@ -0,0 +1,172 @@ +#!/bin/zsh +# Gan tag release va day len tung repo, sau khi PR cua release-push.sh da merge vao main. +# Chay NGOAI sandbox qua nut run cua sep: +# zsh handlive/tools/release-tag.sh v0.2.0 +# +# Voi tag (dang vN.N.N): +# 1. git fetch origin cho tung repo +# 2. neu main local khac origin/main: noi dung cay (tree) cua hai ben phai giong het nhau, +# khac la dung (origin/main co thu chua kiem, hoac main local co commit chua vao PR). +# Cay giong nhau thi dua main local ve origin/main: +# - main local la ancestor (merge commit): git merge --ff-only origin/main +# - lich su bi viet lai (rebase/squash, SHA moi): git reset --soft origin/main +# (cay giong het nen index va working tree khong doi; commit cu van con trong reflog) +# 3. kiem tag chua ton tai o local va tren origin (git ls-remote) +# Buoc 1-3 (Pha 1) chay du 4 repo truoc; repo nao loi thi dung, KHONG repo nao duoc gan tag. +# 4. (Pha 2) chi khi ca 4 repo qua Pha 1: git tag roi git push, thu tu hub -> shared -> android -> apple +# Truoc Pha 1: phan loi cua tag phai khop versionName (Android) va MARKETING_VERSION (Apple, ca 3 target). +# +# Merge PR tren GitHub nen dung "Create a merge commit" (giu nguyen SHA cua commit da kiem). +# "Rebase and merge" va "Squash and merge" deu tao SHA moi; script van chay duoc nho buoc so +# cay o tren, nhung commit tren GitHub se khong trung SHA voi commit da review. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HUB_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" + +ANDROID_DIR="$WORKSPACE_ROOT/handlive-android" +APPLE_DIR="$WORKSPACE_ROOT/handlive-apple" + +ANDROID_GRADLE="$ANDROID_DIR/app/build.gradle.kts" +APPLE_PROJECT_YML="$APPLE_DIR/project.yml" + +fail() { + echo "LOI: $1" >&2 + exit 1 +} + +TAG="${1:-}" +[ -n "$TAG" ] || fail "thieu tham so tag, dung: zsh release-tag.sh (vi du v0.2.0)" + +case "$TAG" in + v*) TAG_CORE="${TAG#v}" ;; + *) fail "tag '$TAG' phai bat dau bang 'v' (vi du v0.2.0)" ;; +esac + +echo "== Doc version hien tai ==" +[ -f "$ANDROID_GRADLE" ] || fail "khong thay $ANDROID_GRADLE" +VERSION_NAME="$(grep -E '^\s*versionName\s*=' "$ANDROID_GRADLE" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')" +[ -n "$VERSION_NAME" ] || fail "khong doc duoc versionName tu $ANDROID_GRADLE" +echo "Android versionName = $VERSION_NAME" + +[ -f "$APPLE_PROJECT_YML" ] || fail "khong thay $APPLE_PROJECT_YML" + +typeset -a MARKETING_VALUES +MARKETING_VALUES=("${(@f)$(grep -E '^\s*MARKETING_VERSION:' "$APPLE_PROJECT_YML" | sed -E 's/.*"([^"]+)".*/\1/')}") +[ "${#MARKETING_VALUES[@]}" -gt 0 ] || fail "khong doc duoc MARKETING_VERSION tu $APPLE_PROJECT_YML" +MARKETING_VERSION="${MARKETING_VALUES[1]}" +for v in "${MARKETING_VALUES[@]}"; do + [ "$v" = "$MARKETING_VERSION" ] \ + || fail "$APPLE_PROJECT_YML co nhieu gia tri MARKETING_VERSION khac nhau ('$MARKETING_VERSION' va '$v') — bump version chua dong bo het target" +done +echo "Apple MARKETING_VERSION = $MARKETING_VERSION (${#MARKETING_VALUES[@]} target, deu khop)" + +typeset -a PROJECT_VERSION_VALUES +PROJECT_VERSION_VALUES=("${(@f)$(grep -E '^\s*CURRENT_PROJECT_VERSION:' "$APPLE_PROJECT_YML" | sed -E 's/.*"([^"]+)".*/\1/')}") +[ "${#PROJECT_VERSION_VALUES[@]}" -gt 0 ] || fail "khong doc duoc CURRENT_PROJECT_VERSION tu $APPLE_PROJECT_YML" +FIRST_PROJECT_VERSION="${PROJECT_VERSION_VALUES[1]}" +for v in "${PROJECT_VERSION_VALUES[@]}"; do + [ "$v" = "$FIRST_PROJECT_VERSION" ] \ + || fail "$APPLE_PROJECT_YML co nhieu gia tri CURRENT_PROJECT_VERSION khac nhau ('$FIRST_PROJECT_VERSION' va '$v') — bump version chua dong bo het target" +done +echo "Apple CURRENT_PROJECT_VERSION = $FIRST_PROJECT_VERSION (${#PROJECT_VERSION_VALUES[@]} target, deu khop)" + +[ "$TAG_CORE" = "$VERSION_NAME" ] \ + || fail "tag '$TAG' (phan loi '$TAG_CORE') khong khop versionName Android '$VERSION_NAME'" +[ "$TAG_CORE" = "$MARKETING_VERSION" ] \ + || fail "tag '$TAG' (phan loi '$TAG_CORE') khong khop MARKETING_VERSION Apple '$MARKETING_VERSION'" +echo "Tag '$TAG' khop versionName va MARKETING_VERSION ($TAG_CORE)" + +typeset -a REPO_NAMES +typeset -a REPO_PATHS +REPO_NAMES=(hub shared android apple) +REPO_PATHS=( + "$HUB_ROOT" + "$WORKSPACE_ROOT/handlive-shared" + "$ANDROID_DIR" + "$APPLE_DIR" +) + +typeset -a TAG_SHAS + +# Pha 1: kiem du 4 repo, chua gan tag nao. Repo nao loi thi dung, khong repo nao co tag. +echo +echo "==== Pha 1: kiem 4 repo ====" +i=1 +while [ "$i" -le "${#REPO_NAMES[@]}" ]; do + name="${REPO_NAMES[$i]}" + repo_path="${REPO_PATHS[$i]}" + + echo + echo "== $name ($repo_path) ==" + [ -d "$repo_path" ] || fail "khong thay thu muc $repo_path" + (cd "$repo_path" && git rev-parse --is-inside-work-tree >/dev/null 2>&1) \ + || fail "$name: khong phai git repo" + + echo "+ git fetch origin" + (cd "$repo_path" && git fetch origin) || fail "$name: git fetch that bai" + + current_branch="$(cd "$repo_path" && git branch --show-current)" + [ "$current_branch" = "main" ] || fail "$name: dang o nhanh '$current_branch', can 'main'" + + local_sha="$(cd "$repo_path" && git rev-parse main)" + remote_sha="$(cd "$repo_path" && git rev-parse origin/main)" + if [ "$local_sha" != "$remote_sha" ]; then + (cd "$repo_path" && git diff --quiet main origin/main) \ + || fail "$name: noi dung main local khac origin/main — PR chua merge, origin/main co commit khac, hoac main local co commit chua vao PR" + if (cd "$repo_path" && git merge-base --is-ancestor main origin/main); then + echo "+ git merge --ff-only origin/main" + (cd "$repo_path" && git merge --ff-only origin/main) \ + || fail "$name: git merge --ff-only origin/main that bai" + else + echo "+ git reset --soft origin/main (PR merge bang rebase/squash, cay giong het)" + (cd "$repo_path" && git reset --soft origin/main) \ + || fail "$name: git reset --soft origin/main that bai" + fi + local_sha="$(cd "$repo_path" && git rev-parse main)" + fi + [ "$local_sha" = "$remote_sha" ] \ + || fail "$name: main local ($local_sha) khac origin/main ($remote_sha) sau khi dong bo" + echo "$name: main khop origin/main ($local_sha)" + + if (cd "$repo_path" && git rev-parse -q --verify "refs/tags/$TAG" >/dev/null); then + fail "$name: tag '$TAG' da ton tai o local" + fi + (cd "$repo_path" && git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null) + ls_code=$? + case "$ls_code" in + 2) ;; + 0) fail "$name: tag '$TAG' da ton tai tren origin" ;; + *) fail "$name: git ls-remote that bai (ma $ls_code)" ;; + esac + echo "$name: tag '$TAG' chua co o local va origin" + + TAG_SHAS[$i]="$local_sha" + i=$((i + 1)) +done + +# Pha 2: ca 4 repo da qua Pha 1, moi gan tag va day. +echo +echo "==== Pha 2: tag va day ====" +i=1 +while [ "$i" -le "${#REPO_NAMES[@]}" ]; do + name="${REPO_NAMES[$i]}" + repo_path="${REPO_PATHS[$i]}" + sha="${TAG_SHAS[$i]}" + i=$((i + 1)) + + echo + echo "== $name ==" + echo "+ git tag $TAG $sha" + (cd "$repo_path" && git tag "$TAG" "$sha") || fail "$name: git tag that bai" + + echo "+ git push origin refs/tags/$TAG" + (cd "$repo_path" && git push origin "refs/tags/$TAG") \ + || fail "$name: git push tag that bai — cac repo truoc da co tag tren origin; sua loi roi day tay: git -C $repo_path push origin refs/tags/$TAG" + + echo "$name: da tag va day $TAG" +done + +echo +echo "XONG: $TAG da tag va day theo thu tu hub -> shared -> android -> apple." diff --git a/tools/verify-android-ui.sh b/tools/verify-android-ui.sh new file mode 100755 index 0000000..e01a460 --- /dev/null +++ b/tools/verify-android-ui.sh @@ -0,0 +1,78 @@ +#!/bin/zsh +# Chay NGOAI sandbox: zsh handlive/tools/verify-android-ui.sh +# +# Kiem tra Android UI (nhan Bluetooth trong danh sach thiet bi): +# 1. core:design:check — hieu ung, thanh phan UI +# 2. core:strings:check — chuoi tim kiem, catalog +# 3. feature:pairing:check — logic cap, UI pairing +# 4. app:testFossDebugUnitTest — test toan bo app (Foss flavor, Debug) +# Toan bo output ghi vao handlive/.build-logs/gradle-android-ui.log (gitignore), +# in 60 dong cuoi. Khong dung khi mot buoc do — chay het roi in tom tat; +# thoat 0 chi khi tat ca buoc xanh. +set -uo pipefail + +HUB_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" +ANDROID_DIR="$WORKSPACE_ROOT/handlive-android" + +LOG_DIR="$HUB_ROOT/.build-logs" +mkdir -p "$LOG_DIR" +GRADLE_LOG="$LOG_DIR/gradle-android-ui.log" + +overall_ok=1 +TEST_SUMMARY="" + +echo "== Android: gradlew check cho core:design, core:strings, feature:pairing va app:testFossDebugUnitTest ==" +: "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" +: "${ANDROID_HOME:=/opt/homebrew/share/android-commandlinetools}" +export JAVA_HOME ANDROID_HOME + +GRADLEW="$ANDROID_DIR/gradlew" +if [ ! -f "$GRADLEW" ]; then + echo " LOI: khong thay $GRADLEW" + overall_ok=0 + EXIT=1 +else + env JAVA_HOME="$JAVA_HOME" ANDROID_HOME="$ANDROID_HOME" "$GRADLEW" -p "$ANDROID_DIR" --continue \ + ":core:design:check" ":core:strings:check" ":feature:pairing:check" ":app:testFossDebugUnitTest" \ + > "$GRADLE_LOG" 2>&1 + EXIT=$? + echo " == 60 dong cuoi cua $GRADLE_LOG ==" + tail -n 60 "$GRADLE_LOG" + + # Gradle khong in so test ra console theo mac dinh (khong co testLogging trong build.gradle.kts), + # nen cong thuoc tinh tests= tu cac file JUnit XML ma gradle tu ghi cho moi module. + total_tests=0 + xml_files=0 + while IFS= read -r xml; do + [ -z "$xml" ] && continue + n="$(grep -o 'tests="[0-9]*"' "$xml" | head -1 | grep -o '[0-9]*')" + [ -n "$n" ] && total_tests=$((total_tests + n)) + xml_files=$((xml_files + 1)) + done < <(find "$ANDROID_DIR/core/design" "$ANDROID_DIR/core/strings" "$ANDROID_DIR/feature/pairing" "$ANDROID_DIR/app" \ + -path '*/build/test-results/*' -name 'TEST-*.xml' 2>/dev/null) + + if [ "$xml_files" -gt 0 ]; then + TEST_SUMMARY="$total_tests test trong $xml_files file XML duoi */build/test-results" + else + TEST_SUMMARY="khong tim thay file XML ket qua test duoi */build/test-results, xem $GRADLE_LOG" + fi +fi + +echo +echo "== Tom tat ==" +if [ "$EXIT" -eq 0 ]; then + echo " core:design:check — XANH" + echo " core:strings:check — XANH" + echo " feature:pairing:check — XANH" + echo " app:testFossDebugUnitTest — XANH ($TEST_SUMMARY)" + echo "XONG: tat ca buoc xanh." +else + echo " Gradle exit $EXIT" + echo " Test summary: $TEST_SUMMARY" + echo "LOI: co buoc do, xem log tren." + overall_ok=0 +fi + +echo "EXIT=$EXIT" +exit "$EXIT" diff --git a/tools/verify-phase7.sh b/tools/verify-phase7.sh new file mode 100755 index 0000000..7139337 --- /dev/null +++ b/tools/verify-phase7.sh @@ -0,0 +1,103 @@ +#!/bin/zsh +# Chay NGOAI sandbox: zsh handlive/tools/verify-phase7.sh +# +# Gop toan bo kiem tra Phase 7 (Bluetooth/Wi-Fi Direct offline) thanh mot lan bam: +# 1. Apple: swift test cho HLProtocol, HLCrypto, HLTransport qua +# ../handlive-apple/Tools/run-package-tests.sh +# 2. Android: ../handlive-android/gradlew check cho core/protocol, core/crypto, +# core/transport va testDebugUnitTest cho feature/connection; toan bo output +# ghi vao handlive/.build-logs/gradle-phase7.log (gitignore), in 60 dong cuoi. +# +# Khong dung khi mot buoc do — chay het roi in tom tat; thoat 0 chi khi moi buoc xanh. +set -uo pipefail + +HUB_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKSPACE_ROOT="$(cd "$HUB_ROOT/.." && pwd)" +APPLE_DIR="$WORKSPACE_ROOT/handlive-apple" +ANDROID_DIR="$WORKSPACE_ROOT/handlive-android" + +LOG_DIR="$HUB_ROOT/.build-logs" +mkdir -p "$LOG_DIR" +GRADLE_LOG="$LOG_DIR/gradle-phase7.log" + +typeset -a RESULTS +overall_ok=1 + +echo "== Apple: swift test cho HLProtocol, HLCrypto, HLTransport ==" +RUNNER="$APPLE_DIR/Tools/run-package-tests.sh" +for PKG in HLProtocol HLCrypto HLTransport; do + echo "-- $PKG --" + if [ ! -f "$RUNNER" ]; then + echo " LOI: khong thay $RUNNER" + RESULTS+=("Apple $PKG: LOI (khong thay run-package-tests.sh)") + overall_ok=0 + continue + fi + if zsh "$RUNNER" "$PKG"; then + PKG_LOG="$APPLE_DIR/.build-logs/$PKG-test.log" + COUNT_LINE="$(grep -E 'Test run with [0-9]+ tests? in [0-9]+ suites? (passed|failed)' "$PKG_LOG" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')" + [ -z "$COUNT_LINE" ] && COUNT_LINE="khong doc duoc so test tu $PKG_LOG" + RESULTS+=("Apple $PKG: XANH — $COUNT_LINE") + else + RESULTS+=("Apple $PKG: DO (xem $APPLE_DIR/.build-logs/$PKG-test.log)") + overall_ok=0 + fi +done + +echo +echo "== Android: gradlew check cho core/protocol, core/crypto, core/transport, feature/connection ==" +: "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" +: "${ANDROID_HOME:=/opt/homebrew/share/android-commandlinetools}" +export JAVA_HOME ANDROID_HOME + +GRADLEW="$ANDROID_DIR/gradlew" +if [ ! -f "$GRADLEW" ]; then + echo " LOI: khong thay $GRADLEW" + RESULTS+=("Android: LOI (khong thay gradlew)") + overall_ok=0 +else + env JAVA_HOME="$JAVA_HOME" ANDROID_HOME="$ANDROID_HOME" "$GRADLEW" -p "$ANDROID_DIR" --continue \ + ":core:protocol:check" ":core:crypto:check" ":core:transport:check" ":feature:connection:testDebugUnitTest" \ + > "$GRADLE_LOG" 2>&1 + GRADLE_EXIT=$? + echo " == 60 dong cuoi cua $GRADLE_LOG ==" + tail -n 60 "$GRADLE_LOG" + + # Gradle khong in so test ra console theo mac dinh (khong co testLogging trong build.gradle.kts), + # nen cong thuoc tinh tests= tu cac file JUnit XML ma gradle tu ghi cho moi module. + total_tests=0 + xml_files=0 + while IFS= read -r xml; do + [ -z "$xml" ] && continue + n="$(grep -o 'tests="[0-9]*"' "$xml" | head -1 | grep -o '[0-9]*')" + [ -n "$n" ] && total_tests=$((total_tests + n)) + xml_files=$((xml_files + 1)) + done < <(find "$ANDROID_DIR/core/protocol" "$ANDROID_DIR/core/crypto" "$ANDROID_DIR/core/transport" "$ANDROID_DIR/feature/connection" \ + -path '*/build/test-results/*' -name 'TEST-*.xml' 2>/dev/null) + + if [ "$xml_files" -gt 0 ]; then + TEST_SUMMARY="$total_tests test trong $xml_files file XML duoi */build/test-results" + else + TEST_SUMMARY="khong tim thay file XML ket qua test duoi */build/test-results, xem $GRADLE_LOG" + fi + if [ "$GRADLE_EXIT" -eq 0 ]; then + RESULTS+=("Android (4 module): XANH — $TEST_SUMMARY") + else + RESULTS+=("Android (4 module): DO (exit=$GRADLE_EXIT) — $TEST_SUMMARY") + overall_ok=0 + fi +fi + +echo +echo "== Tom tat ==" +for r in "${RESULTS[@]}"; do + echo " $r" +done + +if [ "$overall_ok" -eq 1 ]; then + echo "XONG: tat ca buoc xanh." + exit 0 +else + echo "LOI: co buoc do, xem log tren." + exit 1 +fi