diff --git a/docs/detailed-design/04-clipboard.md b/docs/detailed-design/04-clipboard.md
index 853ec7c..e942bbe 100644
--- a/docs/detailed-design/04-clipboard.md
+++ b/docs/detailed-design/04-clipboard.md
@@ -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
@@ -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.
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, 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.
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. |
### 4.3.2 Screens
@@ -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
@@ -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`
diff --git a/docs/detailed-design/04-clipboard.vi.md b/docs/detailed-design/04-clipboard.vi.md
index b28602e..beaa51f 100644
--- a/docs/detailed-design/04-clipboard.vi.md
+++ b/docs/detailed-design/04-clipboard.vi.md
@@ -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
@@ -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.
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, 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.
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. |
### 4.3.2 Màn hình
@@ -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
@@ -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`