Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/detailed-design/04-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ override fun onWindowFocusChanged(hasFocus: Boolean) {
checks the same condition as for reading, since Android 10). A-UI registers it in `onResume` and
removes it in `onPause`; when called, it reads directly as in logic 2, without opening the
transparent activity.
7. A clip item URI is opened only when its scheme is `content` and
`PackageManager.resolveContentProvider(authority, 0)` returns a provider whose package is not
HandLive's. Any other scheme, an authority that does not resolve, or HandLive's own provider is
not opened: `getType`, `openInputStream`, `coerceToText` and `coerceToHtmlText` are not called on
it. An image item then fails as unreadable (manual path: "Couldn't read the image", same wording
as CLIP-03 E10). A debug build logs `clip_read_failed` with `why=uri_not_allowed` and, for a
`content` URI, the authority only, never the path. Text already carried on the item is still
sent. An item with no `text` whose URI fails this check is not read.

#### API 3 — Quick Settings tile and "Send Clipboard" button on the notification

Expand Down Expand Up @@ -824,7 +832,7 @@ prefs[intPreferencesKey("clip.auto_clear_s")] ?: 60 # step 10: schedu
| 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.<br>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).<br>2.<br>Android: the image is read through the automatic or manual path of CLIP-01 (the Share target accepts only text).<br>Mac: CLIP-02 polling is running and reading is allowed (C10).<br>3.<br>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).<br>**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.<br>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).<br>E3 — The image cannot be decoded or converted: `CLIP_UNSUPPORTED_MIME`, logged only (the manual path shows "Couldn't read the image").<br>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).<br>E5 — A newer clip appears during the transfer: the sender sends `clipboard/cancel` `superseded`.<br>E6 — The user chooses "Cancel" on the progress (sender or receiver side): `clipboard/cancel` `user`.<br>E7 — The receiver gets no chunk within `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): it sends `clipboard/cancel` `timeout` and deletes the temporary file.<br>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).<br>E9 — Not enough space for the temporary file: `ack` `INTERNAL`, the receiver shows "Not enough storage to receive the image".<br>E10 — Android: the URI cannot be read (the permission was lost because the clip changed, or the clipboard never granted it): skipped on the automatic path; the manual path shows "Couldn't read the image". Debug builds log `clip_read_failed` with the reason and the URI's authority. |
| Exceptions | E1 — Images not allowed (`clip.send_images = false` on the sender, or the receiver's `mimes` lacks this MIME type): skipped, no notification.<br>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).<br>E3 — The image cannot be decoded or converted: `CLIP_UNSUPPORTED_MIME`, logged only (the manual path shows "Couldn't read the image").<br>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).<br>E5 — A newer clip appears during the transfer: the sender sends `clipboard/cancel` `superseded`.<br>E6 — The user chooses "Cancel" on the progress (sender or receiver side): `clipboard/cancel` `user`.<br>E7 — The receiver gets no chunk within `CLIP_TRANSFER_IDLE_TIMEOUT` (30 s): it sends `clipboard/cancel` `timeout` and deletes the temporary file.<br>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).<br>E9 — Not enough space for the temporary file: `ack` `INTERNAL`, the receiver shows "Not enough storage to receive the image".<br>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.<br>Progress is shown for images > 1 MiB.<br>**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.<br>**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
Expand Down Expand Up @@ -928,6 +936,7 @@ flowchart TB
```kotlin
// [Design] In ClipboardReadActivity, on the IO thread, before finish()
val uri = clip.getItemAt(0).uri ?: return
// Open only after CLIP-01 API 2 logic 7: scheme content, provider in another package.
val srcMime = contentResolver.getType(uri) ?: clip.description.getMimeType(0)
val src = File(cacheDir, "clip/$transferId.src")
contentResolver.openInputStream(uri)?.use { copyLimited(it, src, limit = CLIP_MAX_IMAGE + 1L) } // over the limit → E2
Expand All @@ -946,6 +955,8 @@ val out = if (srcMime == "image/png" || srcMime == "image/jpeg") src
4. The source and converted files live in `cache/clip/`; they are kept until `CLIP_STALE_AFTER` has
passed, for replay (QC7), then deleted.
5. For images, QC3 looks only at the `IS_SENSITIVE` extra (no Luhn check).
6. `getType` and `openInputStream` run only after CLIP-01 API 2 logic 7. A refused URI is not
opened; the read fails as E10.

#### API 2 — Mac reads the image from `NSPasteboard`

Expand Down
13 changes: 12 additions & 1 deletion docs/detailed-design/04-clipboard.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,14 @@ override fun onWindowFocusChanged(hasFocus: Boolean) {
6. `OnPrimaryClipChangedListener` chỉ được hệ thống gọi khi HandLive có focus (mã nguồn AOSP kiểm
cùng điều kiện với việc đọc, từ Android 10). A-UI đăng ký ở `onResume`, gỡ ở `onPause`; khi
được gọi thì đọc trực tiếp như logic 2, không mở activity trong suốt.
7. URI của item chỉ được mở khi scheme là `content` và
`PackageManager.resolveContentProvider(authority, 0)` trả về provider có package khác package
của HandLive. Scheme khác, authority không resolve được, hoặc provider của chính HandLive thì
không mở: không gọi `getType`, `openInputStream`, `coerceToText` hay `coerceToHtmlText` trên URI
đó. Item ảnh thì coi là không đọc được (đường thủ công: "Không đọc được ảnh", cùng câu với
CLIP-03 E10). Bản debug ghi `clip_read_failed` với `why=uri_not_allowed` và, với URI `content`,
chỉ authority, không ghi đường dẫn. `text` đã có sẵn trên item vẫn được gửi. Item không có
`text` mà URI không qua kiểm tra này thì không đọc.

#### API 3 — Ô Cài đặt nhanh và nút "Gửi bảng nhớ tạm" trên thông báo

Expand Down Expand Up @@ -825,7 +833,7 @@ prefs[intPreferencesKey("clip.auto_clear_s")] ?: 60 # bước 10: h
| 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.<br>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).<br>2.<br>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).<br>Mac: hỏi vòng CLIP-02 đang chạy và được phép đọc (C10).<br>3.<br>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).<br>**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.<br>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).<br>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").<br>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).<br>E5 — Có clip mới hơn khi đang truyền: bên gửi gửi `clipboard/cancel` `superseded`.<br>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`.<br>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.<br>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).<br>E9 — Không đủ chỗ ghi tệp tạm: `ack` `INTERNAL`, bên nhận báo "Không đủ bộ nhớ để nhận ảnh".<br>E10 — Android: không đọc được URI (quyền mất vì clip đã đổi, hoặc clipboard chưa từng cấp quyền): 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à authority của URI. |
| 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.<br>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).<br>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").<br>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).<br>E5 — Có clip mới hơn khi đang truyền: bên gửi gửi `clipboard/cancel` `superseded`.<br>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`.<br>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.<br>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).<br>E9 — Không đủ chỗ ghi tệp tạm: `ack` `INTERNAL`, bên nhận báo "Không đủ bộ nhớ để nhận ảnh".<br>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.<br>Hiện tiến trình khi ảnh > 1 MiB.<br>**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.<br>**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
Expand Down Expand Up @@ -929,6 +937,7 @@ flowchart TB
```kotlin
// [Thiết kế] Trong ClipboardReadActivity, trên luồng IO, trước khi finish()
val uri = clip.getItemAt(0).uri ?: return
// Chỉ mở sau CLIP-01 API 2 logic 7: scheme content, provider thuộc package khác.
val srcMime = contentResolver.getType(uri) ?: clip.description.getMimeType(0)
val src = File(cacheDir, "clip/$transferId.src")
contentResolver.openInputStream(uri)?.use { copyLimited(it, src, limit = CLIP_MAX_IMAGE + 1L) } // quá → E2
Expand All @@ -945,6 +954,8 @@ val out = if (srcMime == "image/png" || srcMime == "image/jpeg") src
4. Tệp nguồn và tệp chuyển đổi nằm trong `cache/clip/`; giữ tới khi quá `CLIP_STALE_AFTER` để phát
lại (QC7), sau đó xóa.
5. QC3 với ảnh chỉ xét extra `IS_SENSITIVE` (không kiểm Luhn).
6. `getType` và `openInputStream` chỉ chạy sau CLIP-01 API 2 logic 7. URI bị từ chối thì không
mở; lần đọc thất bại theo E10.

#### API 2 — Mac đọc ảnh từ `NSPasteboard`

Expand Down
Loading