From edc1c5f69836bd78fc3b2becaf04d599f831cd2f Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:24:57 +0000 Subject: [PATCH 01/10] docs(specs): file browser redesign - tree, live preview, SHA-256 on both platforms Explorer-style three-pane PC screen and an adaptive two-sided Android screen, one new range-read message for live preview, and file_hash_request relayed to phones read-only. Records every decision from the planning Q&A and the six-phase delivery plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- .../specs/2026-10-08-file-browser-redesign.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/specs/2026-10-08-file-browser-redesign.md diff --git a/docs/specs/2026-10-08-file-browser-redesign.md b/docs/specs/2026-10-08-file-browser-redesign.md new file mode 100644 index 00000000..61bed2de --- /dev/null +++ b/docs/specs/2026-10-08-file-browser-redesign.md @@ -0,0 +1,233 @@ +# File browser redesign: tree, live preview, and SHA-256 on both platforms + +Status: **proposed** (2026-10-08). Branch `claude/clever-cerf-lkvh1u`. No beads for this work (Connor, 2026-10-08: +the cloud clone has no `.beads/`, and this work is tracked by this spec and its commits instead). + +## 1. What Connor asked for, and the decisions behind it + +The File Transfer screen on both the PC and the phone is replaced outright, not reskinned. It needs to be +modern and streamlined, with a folder tree for moving between levels. The phone gains the SHA-256 +verification the PC already does silently, and both platforms get a live preview of files. + +Every choice below was made by Connor in the planning Q&A. Don't re-litigate them; change this table first. + +| Topic | Decision | +|---|---| +| PC layout | Explorer three-pane: **tree | contents | preview**, transfer strip along the bottom | +| PC tree | One tree. Top-level nodes are **This PC** and **each connected phone** | +| Android layout | Adaptive. Expanded width: permanent tree + contents + preview. Compact: tree in a drawer, breadcrumbs, full-screen preview | +| Android scope | Two-sided: the tree holds **This phone** and **PC** | +| "This phone" contents | The folders already shared under *Access from your PC*, plus **phone-only bookmarks** added with the system folder picker. A bookmark is never visible to the PC until its own **Share with PC** toggle is turned on | +| SHA-256 features | All four: a **Verified** badge on finished transfers (both platforms), **view and copy** a file's hash, **compare against a pasted hash**, and **verify the PC copy against the phone copy** | +| Hash display | Lowercase hex (`sha256sum` / `Get-FileHash` style). The wire stays Base64. A pasted hash may be hex in either case, or Base64 | +| Preview | Follows the selection. **Images at full quality** with zoom and pan. **Text, code and logs** with live tail. No video, audio or PDF in this round | +| Preview transport | One new message pair: `file_read_range_request` / `file_read_range_response` | +| Preview limits | Text: first 2 MiB, or the last 256 KiB when tailing. Live tail polls every 2 s while visible. Images up to 40 MiB, fetched in 1 MiB reads with progress | +| Phone hashing from the PC | `file_hash_request` is relayed to phones: read-only, behind the same gate as browse, re-checked per request | +| PC extras | Thumbnails in list and grid, right-click menus, keyboard shortcuts, drag between devices | +| Old UI | Replaced outright. Tests that pinned the old structure are rewritten to keep their intent | + +## 2. Protocol changes (both sides change together) + +### 2.1 `file_read_range_request` / `file_read_range_response` (new) + +```jsonc +// requester → host +{ "requestId": "…", "rootId": "…", "relativePath": "Logs/app.log", + "offset": 0, // bytes from the start; ignored when fromEnd is true + "length": 1048576, // 1 … FileTransferLimits.ReadRangeMaxBytes (1 MiB) + "fromEnd": false } // true: read the LAST `length` bytes (tail mode) + +// host → requester +{ "requestId": "…", + "offset": 0, // where the returned bytes actually start + "dataBase64": "…", // ≤ 1 MiB raw (≈1.37 MB encoded; MessageSerializer caps a message at 4 MB) + "fileSize": 52344, // size at read time, so a tailing reader sees growth or truncation + "modifiedUtc": 1759900000000, + "eof": true, + "errorMessage": null } +``` + +- **Host rules (PC `FileTransferService`, phone `FileHostHandler`).** The path resolves exactly as a download's + source does (PC: `SharedRootReadResolver`; phone: `SharedPathPolicy` plus the current *Access from your PC* + roots, re-read per request). Directories are refused. `offset` past the end returns zero bytes with + `eof: true` rather than an error. The PC opens with `FileShare.ReadWrite | FileShare.Delete` so a log + another process is still writing can be tailed. +- **Validation** lives in `remex.core/Validation/` (offset ≥ 0, 1 ≤ length ≤ 1 MiB, the usual + `FilePathValidation` root id and path rules). It is NativeAOT-safe, and the records get `[JsonSerializable]` entries. +- **Capability.** `FileCapabilities.readRange` (bool, additive, default false). Peers that don't send it get + the fallback in §2.4. + +### 2.2 `file_hash_request` relayed to phones (widened) + +- The phone's `FileHostHandler` answers `file_hash_request` (new handler), with the same read gate as §2.1. + It streams through `MessageDigest` and is cancellable, and it never requires *Let your PC change files*. +- `FileCapabilities.hash` (bool, additive, default false) says the host answers it. The PC already did. +- `PhoneFileRelay.RequestForReply` gains `file_hash_response → file_hash_request` and + `file_read_range_response → file_read_range_request`. **This is the product decision** the relay guard in + `docs/REGRESSION-GUARDS.md` says a widening needs. That guard entry is updated by hand, and + `TheAllowlist_HoldsTheSevenReadOnlyTypesAndManage_AndNotRootManagement` is rewritten to pin nine read-only + types plus manage. Root management stays out, and its refusal test is unchanged. +- The relay timeout for hashing a large phone file is longer, like copy/move's. + +### 2.3 Hash display + +`HashFormat` in `remex.core` (C#) and a Kotlin twin: `ToHex(base64)`, and `TryNormalize(input)`, which accepts +64 hex chars (any case, spaces and colons ignored) or 44-char Base64 and yields the canonical bytes. Compare +is a constant-time byte compare. Both platforms run one shared set of test vectors. + +### 2.4 Mixed versions + +| Peer lacks | What the screen does | +|---|---| +| `readRange` | Preview falls back to the existing thumbnail (images) or details only, plus a plain line: *"Update RemEx on your phone (or PC) to preview this file."* | +| `hash` | **Compute SHA-256** is hidden for that device. A transfer's Verified badge still works, because that hash comes from the transfer itself | + +## 3. PC screen (`remex.desktop` + `remex.agent`) + +``` +┌──────────────┬─────────────────────────────────────────┬──────────────────┐ +│ ▾ This PC │ [This PC › Documents › 2026] 🔍 ⟳ ⋯ │ IMG_0142.jpg │ +│ ▸ Documents│ [⬆ Upload] [⬇ Download] [+ Folder] ☰ ▦ │ ┌────────────┐ │ +│ ▾ Photos │ ─────────────────────────────────────── │ │ (image, │ │ +│ ▸ 2026 │ 🖼 IMG_0142.jpg 2.1 MB Oct 6 │ │ zoom/pan) │ │ +│ ▾ Pixel 9 │ 📁 Taxes — Sep 30 │ └────────────┘ │ +│ ▸ DCIM │ 📄 notes.txt 4 KB Oct 1 │ 2.1 MB · JPEG │ +│ ▸ Download │ │ SHA-256 [Compute]│ +├──────────────┴─────────────────────────────────────────┴──────────────────┤ +│ ⇅ IMG_0142.jpg → Pixel 9/DCIM ███████░░ 72% ✓ 3 verified [Show all ▴]│ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +- **Tree (left, resizable with a `GridSplitter`, default 240 px).** Top-level nodes are **This PC** and each + paired phone that's connected right now (`IPhoneFileAccess`). A disconnected phone disappears, and if it was + open, the contents pane says so in plain words. Under each device are its shared roots (and, for a phone with + whole-device browsing on, its volumes). Children load lazily on expand through `file_browse_request`, folders + only. Selecting a node navigates the contents pane, and navigating the contents pane (double-click, + breadcrumb, Up) expands and selects the matching node. **Pin** and **Remove root** move to the tree node's + context menu. +- **Contents (centre).** + - One command bar replaces the 13-button wrap panel. It holds Upload, Download, and New folder, with the + selection-dependent actions in an overflow menu. It keeps exactly one primary button + (`ButtonVocabularyTests`). + - Breadcrumbs include the device name, and search sits in the bar. + - The details list and the icon grid both show **real thumbnails** for image files. They load lazily for + visible items only, go through an LRU cache keyed by device+root+path+modified time, and reuse the existing + `file_thumbnail_request`. + - Multi-select works in **both** views. This fixes today's bug where grid selection never reached the + view model. +- **Right-click menus** on items, empty space and tree nodes: Open/Preview, Download to…, Send to *device*…, + Rename, Copy, Cut, Paste, Delete, Copy path, Compute SHA-256, Properties. Items the current device can't do + are hidden, not greyed (`CanChangeFiles`, `CanManageFiles`). +- **Keyboard:** Space toggles the preview pane, Enter opens a folder, Backspace / Alt+↑ goes up, F2 renames, Del + deletes (with confirmation), Ctrl+C/X/V, Ctrl+A, F5 refreshes, Ctrl+F searches. They're bound with + `KeyBinding`s on the view, never injected from scripts (UI-verification rule). +- **Drag and drop.** + - Dropping OS files onto the contents pane or a tree folder uploads them (this exists today). + - Dragging items onto a tree folder **of another device** queues a transfer into that folder. + - On the **same device**, dragging moves the items, or copies them with Ctrl held (same-root rule as Paste + today). + - Dragging **out** to Explorer is supported for **This PC** items only, because their shared root resolves to + a real local path. Phone items offer *Download to…* instead, because Avalonia has no deferred + (virtual-file) drag source. +- **Preview pane (right, resizable, collapsible with Space or a toggle, default 320 px).** + - **It follows the selection.** It is debounced 150 ms, any in-flight read is cancelled when the selection + moves, and reads start only while the pane is open. + - **Image** (jpg, jpeg, png, gif, bmp, webp): range reads assemble the file, up to 40 MiB with a progress + bar, and it's decoded with `Bitmap`. You can zoom with the wheel or pinch, pan by dragging, and fit with + double-click. HEIC/HEIF and formats Skia can't decode show the host thumbnail plus *"Download to view + full size"*. + - **Text** (by extension plus a sniff: NUL bytes in the first 8 KiB mean binary): it's decoded with BOM + detection, then UTF-8 with replacement. It shows in a monospace viewer with line numbers and light + highlighting: log levels, JSON/XML strings, keys and comments, from a small pure tokenizer with tests. + **Live** is a toggle that tails the last 256 KiB, polls `fromEnd` every 2 s while the pane is visible, + appends growth, and resets when `fileSize` shrinks. + - **Folder or other file:** a large icon and the details only. + - **Details** are always shown: size, modified, created, type, read-only. + - **Integrity section:** + - **Compute SHA-256** shows the hex hash with a **Copy** button. + - **Compare…** takes a pasted hash and shows *✓ Matches* or *✗ Doesn't match*. + - **Verify against…** lets you pick the counterpart file on the other device in a tree picker. It defaults + to the same name in that device's current folder. Both sides are hashed and it says *Identical* or + *Different* in plain words. +- **Transfer strip (bottom).** A one-line summary expands into the full queue. The queue keeps its + `VirtualizingStackPanel`, capped `ScrollViewer`, and Cancel all / Clear finished commands + (`FileTransferQueueVirtualizationTests` keeps its intent). Finished rows say **✓ Verified** (tooltip: the hex + hash, plus a Copy SHA-256 menu item) instead of "Done". `TransferState.Verifying` is actually set while the + final hash compare runs. +- **Structure.** `FileTransferViewModel` (2,638 lines) is split rather than grown: `FileTreeViewModel`, + `FolderContentsViewModel`, `FilePreviewViewModel` (with `ImagePreviewLoader` / `TextPreviewLoader`), and + `IntegrityViewModel`, coordinated by a slimmer `FileTransferViewModel`. Transfer, conflict and queue logic is + reused, not rewritten. +- **Localization.** Every new string is in all 9 `.resx` files, added with a Python script using explicit UTF-8. + There's no interpolation in UI-bound properties. + +## 4. Android screen (`remex.android`) + +- **Adaptive layout (`currentWindowAdaptiveInfoV2()`, as `AppNavigation.kt` already does).** + - *Expanded* (tablet, unfolded): a permanent tree pane (280 dp) next to a `ListDetailPaneScaffold`, with + contents as the list and preview as the detail. + - *Medium*: the tree goes in a `ModalNavigationDrawer`, and contents and preview sit side by side. + - *Compact* (phone): the tree is in a drawer opened from the top-bar icon. Tappable breadcrumbs sit under the + top bar. Tapping a file opens the preview full-screen, and predictive back returns to the list. +- **Tree.** + - **This phone** lists the shared folders (*Access from your PC*, marked with a "Shared with PC" chip) and the + **phone-only bookmarks**. + - Bookmarks are added with **+ Add folder** (SAF `OpenDocumentTree` + `takePersistableUriPermission`) and + stored in their own DataStore key. They're never read by `AndroidFileTransferHost`/`SharedPathPolicy`. + - Each bookmark's menu has a **Share with PC** toggle, which adds it to (or removes it from) the existing + *Access from your PC* list, the one consent surface. Removing a bookmark releases its URI grant unless it is + still shared. + - **PC** lists the PC's shared roots and, after *Browse device*, its volumes. + - Local phone folders are listed with `DocumentsContract` child-document queries (not + `DocumentFile.listFiles()`, which does one IPC per child). +- **Contents.** One `LazyColumn` (list) or `LazyVerticalGrid`, with thumbnails as today. Phone-local images use + `ContentResolver.loadThumbnail`. Long-press starts multi-select. **Tapping a file opens the preview**, which + fixes list view doing nothing today. The toolbar is reduced to search, sort/view menu and one primary action. +- **Cross-device actions.** *Send to PC…* (from a phone item) and *Save to phone…* (from a PC item) open a + destination picker that is the same tree, limited to writable folders of the other device. They use the + existing transfer engine with a document URI as source or target. The system-picker uploads stay as a + secondary *Upload from…* action. +- **Preview.** It is the same model as the PC: debounced, cancellable, and range reads for PC files or direct + `ContentResolver` streams for phone files. + - Images are decoded with `ImageDecoder` (HEIC included), downsampled to the screen size, and support + pinch-zoom and pan (`transformable`). + - Text uses the same sniff, decode, tokenizer rules and live tail as the PC, in a `LazyColumn` of lines with + line numbers. + - The integrity card works as on the PC. Phone-local files are hashed on-device with `MessageDigest`, and PC + files through `file_hash_request`. *Verify against…* opens the other device's tree picker. +- **Verified badge.** Finished queue rows and the finish notification say **Verified**. A row's menu offers + *Copy SHA-256* (from `QueuedTransfer.sha256`, shown as hex). This is already computed today and only needs + surfacing. +- **M3 Expressive**, all three theme paths (seed, dynamic, static fallback) × light/dark × contrast. 48 dp + targets stay (`FileManagerGridItemTest`). Strings go in all 9 locale files via a Python UTF-8 script. +- **Structure.** `FileTransferViewModel.kt` (2,266 lines) is split the same way as the PC's: tree, contents, + preview and integrity holders, plus a `PhoneBookmarksRepository`. Engine, queue and conflict code is reused. + +## 5. Delivery: phases, each its own commit on this branch + +| # | Phase | Main files | Proof | +|---|---|---|---| +| 1 | **Protocol + hosts.** Range-read messages, validation, capabilities, the PC host handler, the phone `handleReadRange` + `handleHash`, the relay allowlist, `HashFormat` (C# + Kotlin) | `remex.core/Models/FileTransferMessages.cs`, `remex.core/Validation/*`, `remex.agent/Handlers/FileTransferHandler.cs`, `FileTransferService.cs`, `PhoneFileRelay.cs`, `FileHostHandler.kt` | Serialization round-trip, validation, handler, relay allowlist and phone host unit tests. Each new guard is defect-injected per hard rule 5 | +| 2 | **PC client plumbing.** `FileTransferClient.ReadRangeAsync`, a Verified state and hash on queue items, preview loaders, text sniff/decoder/tokenizer | `remex.desktop/Services/FileTransfer/*`, `FileTransferQueue.cs` | VM/service unit tests | +| 3 | **PC screen.** Tree, contents, preview, integrity, context menus, shortcuts, drag and drop, transfer strip, 9 locales | `remex.desktop/Views/FileTransferView.axaml(.cs)`, new view models | Rewritten view tests, render tests where `remex.desktop.render.tests` can host them | +| 4 | **Android plumbing.** Bookmarks repository, local `DocumentsContract` lister, range-read and hash client calls, preview loaders, Kotlin tokenizer | `service/*`, new `ui/files/*` | JVM unit tests | +| 5 | **Android screen.** Adaptive scaffold, tree, contents, preview, integrity, cross-device actions, 9 locales | `ui/screens/FileTransferScreen.kt` → `ui/files/*` | Unit tests, release lint | +| 6 | **Docs.** `CHANGELOG.md`, `API_CONTRACTS.md` §3, `FILE_SHARING.md`, `REGRESSION-GUARDS.md` (hand edit), XML/KDoc | — | `InstructionFileTests`, translations check | + +The gate is `pwsh ./scripts/verify.ps1` after each phase, and `-Scope all` once the Android SDK in this container +builds. A commit lands only on a passing gate (standing authorization). Pushes to this branch are explicitly +requested for this work. + +## 6. Known limits of this round + +- **No UI screenshot verification in this cloud session.** The `ui-verify` skill drives a live Windows window. + The PC screen should go through the 13-cell palette sweep (`scripts/ui-palette-sweep.ps1`) on Connor's + machine, and the Android screen on the AVD, before release. +- **PC preview of HEIC/HEIF** uses the host thumbnail. Full-size needs a decoder Skia lacks on Windows. +- **Dragging phone files out to Explorer** isn't possible without a virtual-file drag source. They get + *Download to…* instead. +- **Video, audio and PDF preview** were deliberately left out. The range-read message is the transport they'd + need later. +- **Linux parity.** Everything here is cross-platform Avalonia, with no Windows-only APIs. The Windows-specific + check is the palette sweep above. From a67b876c5f8d6caaa65fddd3691321dd8be5cfb2 Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:32:11 +0000 Subject: [PATCH 02/10] fix(verify): stop verify.ps1 dying at the fingerprint step on Linux PowerShell 7.6 appends its own platform newline to a string piped into a native command: CRLF on Windows (why the explicit trailing LF exists), but a bare LF on Linux, where the explicit one became an empty stdin record. git check-ignore rejects that as an empty pathspec and exits 128, so every Linux run threw "git check-ignore failed" before building anything. The explicit LF is now Windows-only; Windows behaviour is byte-for-byte unchanged. Reproduced in isolation (exit 128 with the trailing LF, exit 0 without) and confirmed by a full -Scope all run reaching the end on Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- scripts/verify.ps1 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index df2b2644..cdae8776 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -205,7 +205,14 @@ function Get-SourceFingerprint { # a terminating LF, and on Windows that corrupts its output - the last path # comes back misattributed with a stray trailing CR. A trailing newline avoids # that entirely. - $stdinPayload = ($files -join "`n") + "`n" + # + # WINDOWS ONLY, THOUGH. PowerShell appends its own platform newline to a string piped + # into a native command: CRLF on Windows (which is where the stray CR above came from, + # and why the explicit LF is needed there), but a bare LF on Linux, where the explicit + # one then becomes an empty record. git rejects an empty record as an empty pathspec + # and exits 128, so every Linux run of this script died here before building anything + # (pwsh 7.6, 2026-10-08). + $stdinPayload = ($files -join "`n") + $(if ($IsWindows) { "`n" } else { '' }) $ignoredOutput = @($stdinPayload | git check-ignore --no-index --stdin) $checkIgnoreExit = $LASTEXITCODE # Exit code 1 from check-ignore means "none of the paths are ignored" - that From 159a432f6160622ccf6783e53bb572636d265b00 Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:32:26 +0000 Subject: [PATCH 03/10] feat(files): live-preview range reads and phone-side SHA-256 (redesign phase 1) Protocol and hosts for docs/specs/2026-10-08-file-browser-redesign.md. - New file_read_range_request/response: up to 1 MiB of one file from an offset or from the end (live tail), base64 on the control channel. Additive, capability-flagged (fileCapabilities.readRange / hash). - PC host: FileTransferService.ReadRangeAsync / ReadVolumeRangeAsync, resolved like a download source (same root, consent and restricted-path checks); opened ReadWrite|Delete so a live log can be tailed. - Phone host: answers range reads and file_hash_request through facade.resolve (the browse gate, per request), off the collector, under small permit pools; a provider-reported size of 0 is treated as unknown. - PhoneFileRelay forwards both read-only requests (product decision, recorded in REGRESSION-GUARDS), after ReadRefusal checks root, path and range; hashes get a 30-minute wait like copy/move. - FileTransferClient.ReadRangeRemoteAsync (the PC UI's receiver) and an optional hash timeout for phone-sized hashes. - HashFormat (C# + Kotlin twins, shared vectors): hex for display, hex or Base64 accepted when pasted, constant-time compare. Review: csharp-reviewer and kotlin-reviewer both PASS; all their MEDIUM and LOW findings are fixed here. New guards defect-injected and seen red: relay ReadRefusal, hash timeout, service bounds, HashFormat length, phone folder check, range bounds, capability flags, unknown-size handling, read cap. Gate (verify.ps1 -Scope all, Linux): Android unit tests and lintRelease pass; core, desktop and render suites pass. The receipt says FAIL because of 10 remex.agent.tests failures that fail identically on a clean checkout of the base commit in this container (headless capture x7, root-only revoker injection, two HostIdentityUnification tests with a Remex.Agent/remex.agent path-case bug), plus ConnectionBecomesRegistryVisibleTests failing once under load (passes 3/3 alone, passed in the previous full run). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- docs/API_CONTRACTS.md | 45 +++- docs/CHANGELOG.md | 19 ++ docs/REGRESSION-GUARDS.md | 37 ++- remex.agent.tests/FakeFileTransferService.cs | 2 + remex.agent.tests/FileTransferHandlerTests.cs | 228 ++++++++++++++++ remex.agent.tests/PhoneFileRelayTests.cs | 146 +++++++++- remex.agent.tests/PhoneRelayTestKit.cs | 4 +- remex.agent/Handlers/FileTransferHandler.cs | 67 +++++ remex.agent/Handlers/PingPongHandler.cs | 6 + .../FileTransfer/FileTransferService.cs | 46 ++++ .../Services/FileTransfer/PhoneFileRelay.cs | 79 +++++- .../remex/service/FileHostHandler.kt | 179 ++++++++++++ .../remex/service/FileSystemFacade.kt | 42 +++ .../remex/service/FileTransferFrame.kt | 10 + .../clindsay94/remex/service/HashFormat.kt | 76 ++++++ .../remex/service/FileHostHandlerTest.kt | 255 +++++++++++++++++- .../remex/service/HashFormatTest.kt | 82 ++++++ .../FileReadRangeAndHashFormatTests.cs | 202 ++++++++++++++ remex.core/Helpers/HashFormat.cs | 98 +++++++ remex.core/Messages/MessageAudience.cs | 7 +- remex.core/Messages/RemexMessage.cs | 9 + remex.core/Models/FileTransferMessages.cs | 80 ++++++ .../RemexJsonSerializerContext.cs | 2 + .../FileTransfer/IFileTransferService.cs | 12 + .../Validation/FileReadRangeValidation.cs | 65 +++++ .../FileTransfer/ReadRangeClientTests.cs | 94 +++++++ .../FileTransfer/FileTransferClient.cs | 59 +++- 27 files changed, 1919 insertions(+), 32 deletions(-) create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/service/HashFormat.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/service/HashFormatTest.kt create mode 100644 remex.core.tests/FileReadRangeAndHashFormatTests.cs create mode 100644 remex.core/Helpers/HashFormat.cs create mode 100644 remex.core/Validation/FileReadRangeValidation.cs create mode 100644 remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 2888931c..75f2ccef 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -115,6 +115,34 @@ same way, so the meaning is fixed: listing from the first page (with totals) rather than failing. - `nextCursor` is absent on the last page and when the listing was truncated at the total-entry cap. +### Live preview: range reads (`file_read_range_request` / `file_read_range_response`, 2026-10-08) +Up to 1 MiB (`FileTransferLimits.ReadRangeMaxBytes`) of one file, answered on the control channel in base64 so +the reply stays well under the 4 MB message limit. Capability-flagged by `fileCapabilities.readRange`. + +```jsonc +// requester → host +{ "requestId": "…", "rootId": "…", "relativePath": "Logs/app.log", + "offset": 0, "length": 1048576, // 1 … 1 MiB + "fromEnd": false } // true: the LAST `length` bytes (live tail) +// host → requester +{ "requestId": "…", "offset": 0, "dataBase64": "…", "fileSize": 52344, + "modifiedUtc": 1759900000000, "eof": true, "errorMessage": null } +``` + +- Resolved exactly as a download's source is (PC: `ResolvePath` / a consented volume; phone: `facade.resolve`). + A directory, a missing file, an unshared root, a negative offset or a length outside 1 … 1 MiB is answered with + an `errorMessage`, never silence. +- An offset at or past the end returns empty `dataBase64` with `eof: true`, not an error: a tail that polls after a + truncation sees "nothing new", and `fileSize` going down tells it to start over. +- The PC opens with `FileShare.ReadWrite | Delete`, so a log another program is still writing can be tailed. +- The phone answers off its control-message collector and seeks with the provider's file descriptor when it can. + A provider that reports size 0 gets `fileSize` = at least what was read. +- Preview limits shared by both clients: text 2 MiB from the start, or the last 256 KiB when tailing (polled every + 2 s while visible); images up to 40 MiB, fetched in 1 MiB reads. + +`file_hash_request` (existing) is now also answered by the phone (`fileCapabilities.hash`). The wire stays Base64; +screens show lowercase hex (`HashFormat`, C# and Kotlin twins sharing one set of test vectors). + ### The PC browsing a paired phone (relay, RemEx-xt0af) The PC's own File Transfer screen can browse a paired phone. **The connection direction does not change**: the phone dials `/ws` and `/ws/files` as always, and the PC opens no socket to it. What is @@ -124,15 +152,15 @@ them exactly as it would answer any peer. No message type was added. **Relayed requests (host → phone), and nothing else:** `file_roots_request`, `file_browse_request`, `file_volumes_request`, `file_search_request`, `file_manifest_request`, `file_metadata_request`, -`file_thumbnail_request`, and (RemEx-fgmne) `file_manage_request`. Root management -(`file_root_manage_request`: which folders the phone shares is only ever the phone's decision), hashing and -the legacy v2 transfer are refused by the relay before they reach the wire. +`file_thumbnail_request`, (2026-10-08) `file_read_range_request` and `file_hash_request`, and (RemEx-fgmne) +`file_manage_request`. Root management (`file_root_manage_request`: which folders the phone shares is only ever +the phone's decision) and the legacy v2 transfer are refused by the relay before they reach the wire. Rules the host enforces, each pinned by `PhoneFileRelayTests`: - The target must be **paired** (`PairedClientRegistry`) **and connected** on a session that proved its identity; otherwise the PC side gets `PhoneNotConnectedException`. - A relayed request carries **no `clientId`** (it is nulled on the way out). -- A reply (`file_*_response` of the eight types above) is accepted only from a session with +- A reply (`file_*_response` of the ten types above) is accepted only from a session with `identityProven && !isLoopback`, only for a request this host sent to **that same client id**, and is delivered only to the relay connection that asked. Unrequested replies are dropped; nothing is broadcast. Loopback can never answer for a phone (RemEx-4215). @@ -177,6 +205,15 @@ browsing only while that switch is on. The phone resolves a root id only if it i (`IsValidRemoteRelativePath`): the same rules the phone applies. Copy and move are not subject to the 90-second request timeout: they stream the whole file on the phone before it answers, so they wait up to 30 minutes. +**Reading a phone's files: live preview and hashing (2026-10-08 redesign).** `file_read_range_request` and +`file_hash_request` are relayed READ-ONLY. They need nothing from *Let your PC change files*; the phone resolves +them through the same `facade.resolve` (and so the same `SharedPathPolicy` check against the folders shared right +now) as a browse, on every request. Before the wire the relay checks the root id, the path +(`IsValidRemoteRelativePath`, and never the shared folder itself) and, for a range read, the numbers +(`FileReadRangeValidation`). A hash waits up to 30 minutes (the phone hashes the whole file first); a range read +keeps the 90-second backstop. The phone advertises both with `fileCapabilities.readRange` / `hash` (additive, +default false); a PC hides preview and Compute SHA-256 for a phone that does not. + **Folder upload to a phone** is not a new request either: the PC makes each folder with `mkdir` (`relativePath` = the parent, `newName` = the folder, shallowest first, a folder that already exists is fine), then uploads each file as an ordinary `file_transfer_offer` with `mode: "upload"` into that folder. It is offered diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6df146b..be0838cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,25 @@ Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250- on Google Play. If you'd rather use the PC, **Open in browser instead** under the code still opens the listing there. (RemEx-2p7um) +### Internal + +- **Groundwork for the new File Transfer screen: live preview and SHA-256 on the phone.** Two protocol + additions, both additive (no `protocolVersion` bump) and capability-flagged so older peers are simply not + asked: + - A new `file_read_range_request` / `file_read_range_response` pair reads up to 1 MiB of a file from an + offset or from the end, for previews and live log tailing. The PC and the phone both answer it, through + the same shared-folder checks a download or a browse uses. + - The phone now answers `file_hash_request`, so the PC can check a phone's copy of a file against its own. + - The PC's relay to a paired phone now forwards these two read-only requests too, after checking the path and + the range itself. The "Let your PC change files" switch is not needed for either. + - A hash is shown as lowercase hex everywhere (the format `sha256sum` and `Get-FileHash` print), and a pasted + hash is accepted in hex or Base64. The C# and Kotlin versions share one set of test vectors. + See `docs/specs/2026-10-08-file-browser-redesign.md` and `docs/API_CONTRACTS.md` §3. +- **`scripts/verify.ps1` runs on Linux again.** It died at its first step on every Linux run: PowerShell 7.6 + adds its own newline to text piped into `git check-ignore`, so the script's extra one (needed on Windows, + where PowerShell adds CRLF) became an empty line that git refuses. The extra newline is now added on Windows + only; Windows behaviour is unchanged. + ## [3.0.0] — 2026-10-04 **Routines, one look on the PC and the phone, and a new startup splash.** The first release on Google Play diff --git a/docs/REGRESSION-GUARDS.md b/docs/REGRESSION-GUARDS.md index 08ae5868..14bbc643 100644 --- a/docs/REGRESSION-GUARDS.md +++ b/docs/REGRESSION-GUARDS.md @@ -1536,25 +1536,44 @@ owner never agreed: the first. `PhoneFileRelayTests.Reply_FromADifferentPairedPhone_IsDropped` goes red (defect-injected in RemEx-xt0af). - **Only the allowlisted request types are relayed** (`RelayedRequests`, derived from `RequestForReply`, - `PhoneFileRelay.cs:58-79`): the seven read-only ones plus `file_manage_request`. `file_root_manage_request` - (which folders a phone shares) and hashing stay out; `TheAllowlist_HoldsTheSevenReadOnlyTypesAndManage_AndNotRootManagement` - and `RootManagement_StaysRefused_EvenWhenThePhoneAllowsChanges` pin that. Widening it again is a product + `PhoneFileRelay.cs:59-87`): the nine read-only ones plus `file_manage_request`. `file_read_range_request` and + `file_hash_request` joined on 2026-10-08 — a product decision (Connor, file-browser redesign), read-only, no + change switch needed. `file_root_manage_request` (which folders a phone shares) stays out; + `TheAllowlist_HoldsTheNineReadOnlyTypesAndManage_AndNotRootManagement` and + `RootManagement_StaysRefused_EvenWhenThePhoneAllowsChanges` pin that. Widening it again is a product decision, not a fix. +- **And a range read or hash has its root, path and range checked before the wire** (`ReadRefusal`, called in + `Connection.SendAsync` before the reachability check): a root id, `IsValidRemoteRelativePath`, never the shared + folder itself, and `FileReadRangeValidation`. Delete the call and a `..` path or a 2 GB read goes to the phone, + whose `facade.resolve` is then the only defence. `Reads_WithAnUnsafePathOrRange_AreRefusedAndNeverReachThePhone` + goes red in all 11 cases (defect-injected 2026-10-08). A hash waits `HashTimeout`, not the 90 s backstop + (`AHashOutlivesTheShortRequestTimeout_ButARangeReadDoesNot`, defect-injected 2026-10-08). +- **On the phone, reads and hashes resolve through `facade.resolve` like a browse** (`FileHostHandler.handleReadRange` + / `handleHash`) and refuse a folder, a missing file and an unshared root with `READ_NOT_A_FILE_MESSAGE`; a failure + part-way says `READ_FAILED_MESSAGE`, never the provider's words (which can carry `/storage/...` paths). + `hash_ofAFolder_aMissingFile_orAnUnsharedRoot_isRefusedWithAPlainReason` and + `readRange_outOfBounds_isRefused_beforeAnythingIsRead` go red (defect-injected 2026-10-08). + Two quieter rules ride along. A size of 0 is treated as UNKNOWN, not empty (`sizeKnown`), because + `DocumentFile.length()` is 0 whenever a provider omits `COLUMN_SIZE` (RemEx-xrb2v): trust it and every read says + `eof` and a live tail serves the top of the log + (`readRange_tailWithTheSizeUnknown_returnsTheRealEnd_notTheTop`, defect-injected). And reads and hashes run under + small permit pools (`READ_CONCURRENCY`, `HASH_CONCURRENCY`) so a two-second tail against a slow provider cannot + stack reads up on the IO pool (`rangeReads_runAtMostTwoAtATime_andTheRestWaitTheirTurn`, defect-injected). - **Outbound requests carry no `ClientId`.** - **A `file_manage_request` goes out only after THIS phone's own roots reply said `pcChanges: true`** - (`Connection.SendAsync`, the `_phoneAllowsChanges` gate at `PhoneFileRelay.cs:530-540`, set in `TryComplete` - at `PhoneFileRelay.cs:605`, which only a reply that already passed `TryDeliverReply`'s loopback, proven and + (`Connection.SendAsync`, the `_phoneAllowsChanges` gate at `PhoneFileRelay.cs:596-606`, set in `TryComplete` + at `PhoneFileRelay.cs:678`, which only a reply that already passed `TryDeliverReply`'s loopback, proven and paired checks can reach). Delete the gate and the PC offers and sends renames and deletes to a phone whose owner never turned them on, and to every older phone. `Manage_BeforeThePhoneHasSaidItAllowsChanges_...`, `Manage_WhenThePhoneSwitchIsOff_...` and `APlantedRootsReply_FromLoopbackOrAnUnprovenSession_CannotTurnManageOn` go red (gate defect-injected in RemEx-fgmne). - **And its operation, names and paths are validated before the wire** (`ManageRefusal`, - `PhoneFileRelay.cs:447`): one of five operations, a root id, `FilePathValidation.IsValidRemoteName` / + `PhoneFileRelay.cs:505`): one of five operations, a root id, `FilePathValidation.IsValidRemoteName` / `IsValidRemoteRelativePath`, and never the shared folder itself. `Manage_WithAnUnsafeNameOrPathOrOperation_IsRefusedEvenWhenThePhoneAllowsChanges` covers each case. **On the phone, the switch is the guard, and it is checked first.** `FileHostHandler.handleManage` -(`FileHostHandler.kt:399`, `if (!rootsProvider.isPcChangeAllowed())`) refuses every operation, mkdir +(`FileHostHandler.kt:411`, `if (!rootsProvider.isPcChangeAllowed())`) refuses every operation, mkdir included, BEFORE anything is resolved, and `AndroidFileTransferHost` reads the person's `pcMayChangeFilesFlow` into `pcMayChangeFiles` (`AndroidFileTransferHost.kt:68`, default `false`) and serves it through `isPcChangeAllowed()` (`:176`), which also gates `canRename/canMove/canDelete` on each root @@ -1564,8 +1583,8 @@ PC's relay gate becomes the only thing standing between a PC and the phone's fil stale (the person turned the switch off while the screen was open) and cannot be trusted by a forged request. `FileHostHandlerTest.everyManageOperation_withTheSwitchOff_isRefusedWithAPlainReasonAndChangesNothing` and `theSwitch_isReadOnEveryRequest_notOnceAtStartup` go red (defect-injected in RemEx-fgmne). The same function -refuses `..`/backslash/NUL/empty-segment paths (`SharedPathPolicy.segments`, `:420`) and the shared folder -itself (`SHARED_FOLDER_ITSELF_MESSAGE`, `:431`) before the resolver runs. It also refuses the whole-device +refuses `..`/backslash/NUL/empty-segment paths (`SharedPathPolicy.segments`, `:432`) and the shared folder +itself (`SHARED_FOLDER_ITSELF_MESSAGE`, `:443`) before the resolver runs. It also refuses the whole-device (full-browse) volume (`FULL_BROWSE_READ_ONLY_MESSAGE`: only folders the person shared by name are writable from the PC; `theWholeDeviceView_isReadOnlyFromThePc_forEveryOperation`), and never sends a SAF provider's exception text to the PC (`reportManageFailure`; the text can hold `/storage/emulated/0/...`). diff --git a/remex.agent.tests/FakeFileTransferService.cs b/remex.agent.tests/FakeFileTransferService.cs index 8474c6df..c52bc4fc 100644 --- a/remex.agent.tests/FakeFileTransferService.cs +++ b/remex.agent.tests/FakeFileTransferService.cs @@ -69,5 +69,7 @@ private string ResolveDestination(string relativePath) public Task GetVolumeMetadataAsync(string volumeAbsolutePath, string relativePath, CancellationToken ct) => throw new NotSupportedException(); public Task GetThumbnailBase64Async(string rootId, string relativePath, int maxDim, CancellationToken ct) => throw new NotSupportedException(); public Task GetVolumeThumbnailBase64Async(string volumeAbsolutePath, string relativePath, int maxDim, CancellationToken ct) => throw new NotSupportedException(); + public Task ReadRangeAsync(string rootId, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) => throw new NotSupportedException(); + public Task ReadVolumeRangeAsync(string volumeAbsolutePath, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) => throw new NotSupportedException(); public Task<(string RootId, string RelativePath)?> TryMapVolumePathToConfiguredRootAsync(string volumeAbsolutePath, string relativePath, CancellationToken ct) => Task.FromResult<(string, string)?>(null); } diff --git a/remex.agent.tests/FileTransferHandlerTests.cs b/remex.agent.tests/FileTransferHandlerTests.cs index 2f1a37ab..a45ede61 100644 --- a/remex.agent.tests/FileTransferHandlerTests.cs +++ b/remex.agent.tests/FileTransferHandlerTests.cs @@ -1031,6 +1031,183 @@ await handler.HandleFileMetadataRequestAsync(new RemexMessage private static FileThumbnailResponse LastThumbnail(FakeWebSocket ws) => ws.ReceivedMessages.Last(m => m.Type == MessageTypes.FileThumbnailResponse).FileThumbnailResponse!; + // ── Live preview: file_read_range_request (2026-10-08 redesign) ────────────── + + private static FileReadRangeResponse LastRead(FakeWebSocket ws) + => ws.ReceivedMessages.Last(m => m.Type == MessageTypes.FileReadRangeResponse).FileReadRangeResponse!; + + private static RemexMessage ReadRange(string path, long offset = 0, int length = 1024, bool fromEnd = false, string rootId = "root-1") + => new() + { + Type = MessageTypes.FileReadRangeRequest, + FileReadRangeRequest = new FileReadRangeRequest + { + RequestId = "rr-1", RootId = rootId, RelativePath = path, Offset = offset, Length = length, FromEnd = fromEnd, + }, + }; + + private static string Text(FileReadRangeResponse r) => + System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(r.DataBase64!)); + + [Fact] + public async Task ReadRange_FromAnOffset_ReturnsThoseBytes_AndTheFileSize() + { + var (handler, _, rootDir) = CreateRealServiceHandler(); + await File.WriteAllTextAsync(Path.Combine(rootDir, "a.log"), "0123456789"); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("a.log", offset: 3, length: 4), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.Null(r.ErrorMessage); + Assert.Equal("rr-1", r.RequestId); + Assert.Equal(3, r.Offset); + Assert.Equal("3456", Text(r)); + Assert.Equal(10, r.FileSize); + Assert.False(r.Eof); + Assert.True(r.ModifiedUtc > 0); + } + + [Fact] + public async Task ReadRange_FromTheEnd_ReturnsTheTail_AndSaysEof() + { + var (handler, _, rootDir) = CreateRealServiceHandler(); + await File.WriteAllTextAsync(Path.Combine(rootDir, "a.log"), "0123456789"); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("a.log", length: 4, fromEnd: true), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.Equal(6, r.Offset); + Assert.Equal("6789", Text(r)); + Assert.True(r.Eof); + } + + [Fact] + public async Task ReadRange_PastTheEnd_ReturnsNoBytes_NotAnError() + { + // A tail that polls after the file was truncated asks past the end. That is "nothing new", not a failure. + var (handler, _, rootDir) = CreateRealServiceHandler(); + await File.WriteAllTextAsync(Path.Combine(rootDir, "a.log"), "abc"); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("a.log", offset: 50), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.Null(r.ErrorMessage); + Assert.Equal(string.Empty, r.DataBase64); + Assert.Equal(3, r.FileSize); + Assert.True(r.Eof); + } + + [WindowsOnlyFact("FileShare is advisory on Unix, so only Windows refuses a FileShare.Read open against a writer; " + + "on Linux this passes with either share mode and proves nothing (defect-injected 2026-10-08)")] + public async Task ReadRange_OfALogAnotherProgramHasOpenForWriting_Works() + { + // The point of live tail. The transfer paths open with FileShare.Read, which fails against a writer + // on Windows; a preview that used that share mode could never tail a live log. + var (handler, _, rootDir) = CreateRealServiceHandler(); + var path = Path.Combine(rootDir, "live.log"); + await using var writer = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + await writer.WriteAsync("first line\n"u8.ToArray()); + await writer.FlushAsync(); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("live.log", length: 100, fromEnd: true), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.Null(r.ErrorMessage); + Assert.Equal("first line\n", Text(r)); + } + + [Fact] + public async Task ReadRange_ThatEscapesTheRoot_IsRefused_AndReadsNothing() + { + var (handler, _, rootDir) = CreateRealServiceHandler(); + var outside = Path.Combine(Path.GetDirectoryName(rootDir)!, "secret.txt"); + await File.WriteAllTextAsync(outside, "do not read"); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("../secret.txt"), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.False(string.IsNullOrEmpty(r.ErrorMessage)); + Assert.Null(r.DataBase64); + } + + [Theory] + [InlineData(-1, 10)] + [InlineData(0, 0)] + [InlineData(0, FileTransferLimits.ReadRangeMaxBytes + 1)] + public async Task ReadRange_OutOfBounds_IsRefused_BeforeAnythingIsOpened(long offset, int length) + { + var (handler, _, rootDir) = CreateRealServiceHandler(); + await File.WriteAllTextAsync(Path.Combine(rootDir, "a.log"), "abc"); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("a.log", offset, length), ws, null, CancellationToken.None); + + var r = LastRead(ws); + Assert.Equal("rr-1", r.RequestId); + Assert.False(string.IsNullOrEmpty(r.ErrorMessage)); + Assert.Null(r.DataBase64); + } + + [Theory] + [InlineData(-1, 10)] + [InlineData(0, 0)] + [InlineData(0, FileTransferLimits.ReadRangeMaxBytes + 1)] + public async Task TheService_RefusesAnOutOfBoundsRead_Itself(long offset, int length) + { + // Defence in depth: the handler validates first, and the service refuses again, so a future caller + // that skips the handler (a test seam, a new endpoint) still cannot ask for an unbounded read. + var (_, service, rootDir) = CreateRealServiceHandler(); + await File.WriteAllTextAsync(Path.Combine(rootDir, "a.log"), "abc"); + + await Assert.ThrowsAsync( + () => service.ReadRangeAsync("root-1", "a.log", offset, length, fromEnd: false, CancellationToken.None)); + } + + [Fact] + public async Task ReadRange_OfAFolder_OrAMissingFile_IsAnsweredWithAReason() + { + var (handler, _, rootDir) = CreateRealServiceHandler(); + Directory.CreateDirectory(Path.Combine(rootDir, "Sub")); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("Sub"), ws, null, CancellationToken.None); + Assert.False(string.IsNullOrEmpty(LastRead(ws).ErrorMessage)); + + await handler.HandleFileReadRangeRequestAsync(ReadRange("nope.txt"), ws, null, CancellationToken.None); + Assert.False(string.IsNullOrEmpty(LastRead(ws).ErrorMessage)); + Assert.Equal(2, ws.ReceivedMessages.Count(m => m.Type == MessageTypes.FileReadRangeResponse)); + } + + [Fact] + public async Task ReadRange_WithNoBody_IsStillAnswered() + { + var (handler, _, _) = CreateRealServiceHandler(); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync( + new RemexMessage { Type = MessageTypes.FileReadRangeRequest }, ws, null, CancellationToken.None); + + Assert.False(string.IsNullOrEmpty(LastRead(ws).ErrorMessage)); + } + + [Fact] + public async Task TheRootsReply_AdvertisesRangeReadsAndHashing() + { + var (handler, _, _) = CreateRealServiceHandler(); + var ws = new FakeWebSocket(); + + await handler.HandleFileRootsRequestAsync(ws, CancellationToken.None); + + var caps = ws.ReceivedMessages.Last(m => m.Type == MessageTypes.FileRootsResponse).FileRootsResponse!.FileCapabilities!; + Assert.True(caps.ReadRange); + Assert.True(caps.Hash); + } + [Fact] public async Task Thumbnail_ForImage_ReturnsScaledJpegUnderBudget() { @@ -1178,6 +1355,57 @@ await handler.HandleFileMetadataRequestAsync(new RemexMessage Assert.Contains("full-device browsing", resp.ErrorMessage, StringComparison.OrdinalIgnoreCase); } + // ── Range reads on the full-device view: the consent gate (2026-10-08 redesign review) ── + + [Fact] + public async Task ReadRange_FromFullDeviceVolume_WithGrantedConsent_Succeeds() + { + var (handler, volumes, baseTemp) = await CreateVolumeTestHandlerAsync("client-a", grantFullBrowse: true); + var volumeRoot = Path.GetPathRoot(baseTemp)!; + Assert.Contains(volumes.Enumerate(), v => v.Id == volumeRoot); // sanity: host must expose this drive + var filePath = Path.Combine(baseTemp, "drive.log"); + await File.WriteAllTextAsync(filePath, "volume bytes"); + var relativePath = Path.GetRelativePath(volumeRoot, filePath).Replace('\\', '/'); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange(relativePath, rootId: volumeRoot), ws, "client-a", CancellationToken.None); + + var r = LastRead(ws); + Assert.Null(r.ErrorMessage); + Assert.Equal("volume bytes", Text(r)); + } + + [Fact] + public async Task ReadRange_FromFullDeviceVolume_WithoutConsent_ReturnsError_AndNoBytes() + { + var (handler, _, baseTemp) = await CreateVolumeTestHandlerAsync("client-a", grantFullBrowse: false); + var volumeRoot = Path.GetPathRoot(baseTemp)!; + var filePath = Path.Combine(baseTemp, "drive.log"); + await File.WriteAllTextAsync(filePath, "not for you"); + var relativePath = Path.GetRelativePath(volumeRoot, filePath).Replace('\\', '/'); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync(ReadRange(relativePath, rootId: volumeRoot), ws, "client-a", CancellationToken.None); + + var r = LastRead(ws); + Assert.Contains("full-device browsing", r.ErrorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Null(r.DataBase64); + } + + [Fact] + public async Task ReadRange_FromUnknownVolumeId_ReturnsError_AndNoBytes() + { + var (handler, _, _) = await CreateVolumeTestHandlerAsync("client-a", grantFullBrowse: true); + var ws = new FakeWebSocket(); + + await handler.HandleFileReadRangeRequestAsync( + ReadRange("anything.txt", rootId: "not-a-volume-or-root"), ws, "client-a", CancellationToken.None); + + var r = LastRead(ws); + Assert.False(string.IsNullOrEmpty(r.ErrorMessage)); + Assert.Null(r.DataBase64); + } + [Fact] public async Task Metadata_FromUnknownVolumeId_ReturnsError() { diff --git a/remex.agent.tests/PhoneFileRelayTests.cs b/remex.agent.tests/PhoneFileRelayTests.cs index ddfb989f..2f8fac8d 100644 --- a/remex.agent.tests/PhoneFileRelayTests.cs +++ b/remex.agent.tests/PhoneFileRelayTests.cs @@ -130,11 +130,11 @@ public static TheoryData RefusedRequests() => Type = MessageTypes.FileRootManageRequest, FileRootManageRequest = new FileRootManageRequest { RequestId = "rm", Operation = "remove", RootId = "root" }, }, - new RemexMessage - { - Type = MessageTypes.FileHashRequest, - FileHashRequest = new FileHashRequest { RequestId = "h", RootId = "root", RelativePath = "a.jpg" }, - }, + // file_hash_request and file_read_range_request are NOT here any more (2026-10-08 redesign): they are + // relayed, read-only. Their own refusals are pinned by the "read" tests below; with no body they are + // refused like any other type. + new RemexMessage { Type = MessageTypes.FileHashRequest }, + new RemexMessage { Type = MessageTypes.FileReadRangeRequest }, new RemexMessage { Type = MessageTypes.FileTransferOffer, @@ -391,13 +391,143 @@ await Assert.ThrowsAsync(() => connection.Send } [Fact] - public void TheAllowlist_HoldsTheSevenReadOnlyTypesAndManage_AndNotRootManagement() + public void TheAllowlist_HoldsTheNineReadOnlyTypesAndManage_AndNotRootManagement() { - Assert.Equal(8, PhoneFileRelay.RelayedRequests.Count); + // Widened twice, each time by a product decision: manage (RemEx-fgmne), then range reads and hashing + // (2026-10-08 redesign). Root management has never been on it and must not be. + Assert.Equal(10, PhoneFileRelay.RelayedRequests.Count); IEnumerable allowlist = PhoneFileRelay.RelayedRequests; Assert.Contains(MessageTypes.FileManageRequest, allowlist); + Assert.Contains(MessageTypes.FileReadRangeRequest, allowlist); + Assert.Contains(MessageTypes.FileHashRequest, allowlist); Assert.DoesNotContain(MessageTypes.FileRootManageRequest, allowlist); - Assert.DoesNotContain(MessageTypes.FileHashRequest, allowlist); + } + + // ─── Reading a phone's files: live preview and hashing (2026-10-08 redesign) ─── + + private static RemexMessage ReadRangeRequest( + string requestId, string relativePath = "DCIM/a.jpg", long offset = 0, int length = 4096, + bool fromEnd = false, string rootId = "root") => new() + { + Type = MessageTypes.FileReadRangeRequest, + FileReadRangeRequest = new FileReadRangeRequest + { + RequestId = requestId, RootId = rootId, RelativePath = relativePath, + Offset = offset, Length = length, FromEnd = fromEnd, + }, + }; + + private static RemexMessage HashRequest(string requestId, string relativePath = "DCIM/a.jpg", string rootId = "root") => new() + { + Type = MessageTypes.FileHashRequest, + FileHashRequest = new FileHashRequest { RequestId = requestId, RootId = rootId, RelativePath = relativePath }, + }; + + [Fact] + public async Task ReadsAndHashes_ReachThePhone_WithoutTheChangeSwitch_AndWithNoClientId() + { + using var kit = new PhoneRelayTestKit(); + var (socket, _) = kit.ConnectProvenPhone(PhoneA); + using var connection = kit.Relay.Open(PhoneA); + // Deliberately the phone that said NO to changes: reading is not changing. + await PhoneSaysAsync(kit, connection, allowsChanges: false); + + await connection.SendAsync(ReadRangeRequest("r1", fromEnd: true) with { ClientId = "someone-else" }); + await connection.SendAsync(HashRequest("h1") with { ClientId = "someone-else" }); + + var read = Assert.Single(socket.MessagesOfType(MessageTypes.FileReadRangeRequest)); + Assert.Equal("r1", read.FileReadRangeRequest!.RequestId); + Assert.True(read.FileReadRangeRequest.FromEnd); + Assert.Null(read.ClientId); + var hash = Assert.Single(socket.MessagesOfType(MessageTypes.FileHashRequest)); + Assert.Equal("h1", hash.FileHashRequest!.RequestId); + Assert.Null(hash.ClientId); + } + + public static TheoryData UnsafeReads() => + [ + ReadRangeRequest("r", relativePath: "DCIM/../secret.txt"), + ReadRangeRequest("r", relativePath: "DCIM\\a.jpg"), + ReadRangeRequest("r", relativePath: ""), + ReadRangeRequest("r", relativePath: "/"), + ReadRangeRequest("r", rootId: " "), + ReadRangeRequest("r", offset: -1), + ReadRangeRequest("r", length: 0), + ReadRangeRequest("r", length: FileTransferLimits.ReadRangeMaxBytes + 1), + HashRequest("h", relativePath: "DCIM/../secret.txt"), + HashRequest("h", relativePath: ""), + HashRequest("h", rootId: ""), + ]; + + [Theory] + [MemberData(nameof(UnsafeReads))] + public async Task Reads_WithAnUnsafePathOrRange_AreRefusedAndNeverReachThePhone(RemexMessage request) + { + using var kit = new PhoneRelayTestKit(); + var (socket, _) = kit.ConnectProvenPhone(PhoneA); + using var connection = kit.Relay.Open(PhoneA); + + await Assert.ThrowsAsync(() => connection.SendAsync(request)); + + Assert.Empty(socket.Messages); + } + + [Fact] + public async Task ReadReplies_FromThePhoneThatWasAsked_AreDelivered_AndFromLoopbackOrAnotherPhoneDropped() + { + using var kit = new PhoneRelayTestKit(); + kit.ConnectProvenPhone(PhoneA); + kit.ConnectProvenPhone(PhoneB); + using var connection = kit.Relay.Open(PhoneA); + var received = new List(); + connection.FileTransferMessageReceived += received.Add; + await connection.SendAsync(ReadRangeRequest("r1")); + await connection.SendAsync(HashRequest("h1")); + + var readReply = new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse { RequestId = "r1", DataBase64 = "aGk=", FileSize = 2, Eof = true }, + }; + var hashReply = new RemexMessage + { + Type = MessageTypes.FileHashResponse, + FileHashResponse = new FileHashResponse { RequestId = "h1", Sha256Base64 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=" }, + }; + + Assert.False(kit.Relay.TryDeliverReply(PhoneA, identityProven: true, isLoopback: true, readReply)); + Assert.False(kit.Relay.TryDeliverReply(PhoneB, identityProven: true, isLoopback: false, hashReply)); + Assert.Empty(received); + + Assert.True(kit.Relay.TryDeliverReply(PhoneA, identityProven: true, isLoopback: false, readReply)); + Assert.True(kit.Relay.TryDeliverReply(PhoneA, identityProven: true, isLoopback: false, hashReply)); + Assert.Equal(["r1", "h1"], received.Select(PhoneFileRelay.ReplyRequestId)); + } + + [Fact] + public async Task AHashOutlivesTheShortRequestTimeout_ButARangeReadDoesNot() + { + // A phone hashes the whole file before it answers; a long video can take minutes. A range read is at + // most 1 MiB and keeps the short backstop, so a dead phone still fails the preview quickly. + using var kit = new PhoneRelayTestKit(requestTimeout: TimeSpan.FromMilliseconds(120), hashTimeout: TimeSpan.FromSeconds(30)); + kit.ConnectProvenPhone(PhoneA); + using var connection = kit.Relay.Open(PhoneA); + var received = new List(); + connection.FileTransferMessageReceived += received.Add; + + await connection.SendAsync(ReadRangeRequest("r1")); + await connection.SendAsync(HashRequest("h1")); + // Poll to a generous deadline rather than sleep a fixed time: under a loaded test run (RemEx-w7ei) a + // 120 ms timer can fire late, and a fixed delay would then fail on timing, not on behaviour. + var deadline = DateTime.UtcNow.AddSeconds(10); + while (received.Count == 0 && DateTime.UtcNow < deadline) + await Task.Delay(25); + await Task.Delay(200); // room for a wrong second timeout (the hash's) to show up + + var timedOut = Assert.Single(received); + Assert.Equal(MessageTypes.FileReadRangeResponse, timedOut.Type); + Assert.Equal("r1", timedOut.FileReadRangeResponse!.RequestId); + Assert.False(string.IsNullOrEmpty(timedOut.FileReadRangeResponse.ErrorMessage)); } [Fact] diff --git a/remex.agent.tests/PhoneRelayTestKit.cs b/remex.agent.tests/PhoneRelayTestKit.cs index bcde7eb7..59a07b70 100644 --- a/remex.agent.tests/PhoneRelayTestKit.cs +++ b/remex.agent.tests/PhoneRelayTestKit.cs @@ -32,7 +32,8 @@ public PhoneRelayTestKit( TimeSpan? requestTimeout = null, Action? configure = null, IStagedFilePromoter? promoter = null, - TimeSpan? copyMoveTimeout = null) + TimeSpan? copyMoveTimeout = null, + TimeSpan? hashTimeout = null) { Paired = new PairedClientRegistry(NullLogger.Instance, Path.Combine(_root.FullName, "paired.json")); Sessions = new ClientSessionRegistry(); @@ -63,6 +64,7 @@ public PhoneRelayTestKit( { RequestTimeout = requestTimeout ?? TimeSpan.FromSeconds(30), CopyMoveTimeout = copyMoveTimeout ?? TimeSpan.FromMinutes(30), + HashTimeout = hashTimeout ?? TimeSpan.FromMinutes(30), }; } diff --git a/remex.agent/Handlers/FileTransferHandler.cs b/remex.agent/Handlers/FileTransferHandler.cs index 40ca93be..4f482942 100644 --- a/remex.agent/Handlers/FileTransferHandler.cs +++ b/remex.agent/Handlers/FileTransferHandler.cs @@ -86,6 +86,11 @@ private sealed class FileTransferState // nothing while reading as though it were. An inbound file_push_offer is therefore not answered // at all. Do not "fix" that silence by reviving the handler without re-reading RemEx-e11w. Push = true, + + // Live preview and on-demand hashing (2026-10-08 redesign). This host has always answered + // file_hash_request; saying so lets a requester tell it apart from an older phone that never did. + ReadRange = true, + Hash = true, }; public async Task HandleFileRootsRequestAsync(WebSocket ws, CancellationToken ct) @@ -1097,6 +1102,68 @@ public async Task HandleFileThumbnailRequestAsync(RemexMessage message, WebSocke await MessageSerializer.SendAsync(ws, response, ct); } + /// + /// Handles a file_read_range_request (live preview, 2026-10-08 redesign): up to 1 MiB of one file, + /// from an offset or from the end. Resolved like a thumbnail or a download source: a configured root, or + /// a volume this client was granted. Always answered, so a preview never waits on silence. + /// + public async Task HandleFileReadRangeRequestAsync(RemexMessage message, WebSocket ws, string? clientId, CancellationToken ct) + { + var req = message.FileReadRangeRequest; + if (!FileReadRangeValidation.IsValid(req, out var invalid)) + { + logger.LogWarning("Refused a file_read_range_request: {Reason}", invalid); + await MessageSerializer.SendAsync(ws, new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = req?.RequestId ?? string.Empty, + ErrorMessage = invalid, + } + }, ct); + return; + } + + RemexMessage response; + try + { + var read = await IsConfiguredRootAsync(req!.RootId, ct) + ? await fileTransferService.ReadRangeAsync(req.RootId, req.RelativePath, req.Offset, req.Length, req.FromEnd, ct) + : await fileTransferService.ReadVolumeRangeAsync( + (await ResolveConsentedVolumeAsync(req.RootId, clientId, ct)).Path, + req.RelativePath, req.Offset, req.Length, req.FromEnd, ct); + response = new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = req.RequestId, + Offset = read.Offset, + DataBase64 = Convert.ToBase64String(read.Data), + FileSize = read.FileSize, + ModifiedUtc = read.ModifiedUtc, + Eof = read.Eof, + } + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "File range read failed for root {RootId}, path {Path}", req!.RootId, req.RelativePath); + response = new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = req.RequestId, + ErrorMessage = ex.Message, + } + }; + } + + await MessageSerializer.SendAsync(ws, response, ct); + } + /// private Task IsConfiguredRootAsync(string rootId, CancellationToken ct) => readResolver.IsConfiguredRootAsync(rootId, ct); diff --git a/remex.agent/Handlers/PingPongHandler.cs b/remex.agent/Handlers/PingPongHandler.cs index 68e9ff2f..1dd1417c 100644 --- a/remex.agent/Handlers/PingPongHandler.cs +++ b/remex.agent/Handlers/PingPongHandler.cs @@ -1175,6 +1175,10 @@ await MessageSerializer.SendAsync( await fileTransferHandler.HandleFileThumbnailRequestAsync(message, webSocket, connectionClientId, ct); break; + case MessageTypes.FileReadRangeRequest: + await fileTransferHandler.HandleFileReadRangeRequestAsync(message, webSocket, connectionClientId, ct); + break; + // ── 2.1 File Sharing Overhaul (protocolVersion 3) — WP-jjdb: consent-response ── // file_consent_response resolves a consent prompt this host raised. Only full-browse // raises one now: the incoming-push prompt went with the file_push_offer arm @@ -1235,6 +1239,8 @@ await transferSessionManager.HandleCompleteAsync( case MessageTypes.FileMetadataResponse: case MessageTypes.FileThumbnailResponse: case MessageTypes.FileManageResponse: + case MessageTypes.FileReadRangeResponse: + case MessageTypes.FileHashResponse: if (phoneFileRelay?.TryDeliverReply(connectionClientId, identityProven, isLoopback, message) != true && logger.IsEnabled(LogLevel.Debug)) { diff --git a/remex.agent/Services/FileTransfer/FileTransferService.cs b/remex.agent/Services/FileTransfer/FileTransferService.cs index 934e8e29..aa307f8e 100644 --- a/remex.agent/Services/FileTransfer/FileTransferService.cs +++ b/remex.agent/Services/FileTransfer/FileTransferService.cs @@ -1531,6 +1531,52 @@ private static Task GetMetadataCore(string resolved, string rootDi throw new FileNotFoundException($"'{relativePath}' not found in root '{rootDisplay}'."); } + public Task ReadRangeAsync(string rootId, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) + => ReadRangeCore(ResolvePath(rootId, relativePath), GetConfiguredRoot(rootId).DisplayName, relativePath, offset, length, fromEnd, ct); + + /// Volume-mode counterpart of . See . + public Task ReadVolumeRangeAsync(string volumeAbsolutePath, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) + { + var resolved = FilePathValidation.ResolveWithinRoot(volumeAbsolutePath, relativePath, volumeAbsolutePath); + return ReadRangeCore(resolved, volumeAbsolutePath, relativePath, offset, length, fromEnd, ct); + } + + private static async Task ReadRangeCore( + string resolved, string rootDisplay, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) + { + if (offset < 0 || length is < 1 or > FileTransferLimits.ReadRangeMaxBytes) + throw new ArgumentOutOfRangeException(nameof(length), "The read range is out of bounds."); + if (!File.Exists(resolved)) + throw new FileNotFoundException($"File not found in shared root '{rootDisplay}': {relativePath}"); + + // ReadWrite | Delete, NOT the Read that the transfer paths use: a preview must be able to open a log + // another program still has open for writing, which is the whole point of live tail. Random access, + // not SequentialScan, because a tail reads the end of the file and nothing before it. + await using var stream = new FileStream( + resolved, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 4096, + FileOptions.Asynchronous | FileOptions.RandomAccess); + var size = stream.Length; + var start = FileReadRangeValidation.StartOffset(offset, length, fromEnd, size); + var toRead = (int)Math.Clamp(size - start, 0, length); + var data = new byte[toRead]; + var read = 0; + if (toRead > 0) + { + stream.Position = start; + // ReadAtLeast stops early only at end of stream, which here means the file shrank since Length + // was read. Trim to what actually arrived rather than return zeros as if they were content. + read = await stream.ReadAtLeastAsync(data, toRead, throwOnEndOfStream: false, ct); + } + + return new FileRangeRead + { + Offset = start, + Data = read == toRead ? data : data[..read], + FileSize = size, + ModifiedUtc = ToUnixMs(File.GetLastWriteTimeUtc(resolved)), + }; + } + public Task GetThumbnailBase64Async(string rootId, string relativePath, int maxDim, CancellationToken ct) { var root = GetConfiguredRoot(rootId); diff --git a/remex.agent/Services/FileTransfer/PhoneFileRelay.cs b/remex.agent/Services/FileTransfer/PhoneFileRelay.cs index 4242ff06..af078273 100644 --- a/remex.agent/Services/FileTransfer/PhoneFileRelay.cs +++ b/remex.agent/Services/FileTransfer/PhoneFileRelay.cs @@ -32,10 +32,11 @@ namespace Remex.Agent.Services.FileTransfer; /// /// The target must be paired AND connected right now (), or /// and every send throw . -/// Only the request types in are forwarded: the seven read-only -/// ones, and file_manage_request (RemEx-fgmne). Root management (file_root_manage_request: -/// what the phone shares stays the phone's decision), hashing and the legacy v2 transfer are refused -/// before they reach the wire, whatever the caller asks for. +/// Only the request types in are forwarded: the nine read-only +/// ones (range reads and hashing joined in the 2026-10-08 redesign, with their paths checked by +/// ), and file_manage_request (RemEx-fgmne). Root management +/// (file_root_manage_request: what the phone shares stays the phone's decision) and the legacy v2 +/// transfer are refused before they reach the wire, whatever the caller asks for. /// A file_manage_request is forwarded only when THIS phone's own roots reply said the /// person allows the PC to change files (), and only when its /// operation, names and paths pass FilePathValidation. The phone checks its switch and its @@ -69,10 +70,16 @@ public sealed class PhoneFileRelay : IPhoneFileAccess, IPhoneFileTransfers, IDis // by FileCapabilities.PcChanges and by the phone's own switch. file_root_manage_request is NOT // here and must not be: which folders a phone shares is only ever the phone's decision. [MessageTypes.FileManageResponse] = MessageTypes.FileManageRequest, + // 2026-10-08 redesign, a second deliberate widening (Connor's call in the planning Q&A): live + // preview and "Verify against…" read a phone's files. Both are READ-ONLY, pass the same + // shared-folder check on the phone as a browse (re-checked per request), never need the change + // switch, and have their paths checked by ReadRefusal before the wire. + [MessageTypes.FileReadRangeResponse] = MessageTypes.FileReadRangeRequest, + [MessageTypes.FileHashResponse] = MessageTypes.FileHashRequest, }.ToFrozenDictionary(StringComparer.Ordinal); /// - /// The ONLY request types the PC may send to a phone: seven read-only ones, plus + /// The ONLY request types the PC may send to a phone: nine read-only ones, plus /// file_manage_request, which additionally gates on the /// phone's own say-so. Root management is deliberately absent. /// @@ -103,6 +110,12 @@ public sealed class PhoneFileRelay : IPhoneFileAccess, IPhoneFileTransfers, IDis /// internal TimeSpan CopyMoveTimeout { get; init; } = TimeSpan.FromMinutes(30); + /// + /// How long a relayed hash may wait. A phone hashes the whole file before it answers, which for a long + /// video can outlast the 90-second backstop, so it gets the copy/move allowance. Test seam only. + /// + internal TimeSpan HashTimeout { get; init; } = TimeSpan.FromMinutes(30); + public PhoneFileRelay( ILogger logger, PairedClientRegistry paired, @@ -359,6 +372,8 @@ public void Dispose() MessageTypes.FileMetadataRequest => message.FileMetadataRequest?.RequestId, MessageTypes.FileThumbnailRequest => message.FileThumbnailRequest?.RequestId, MessageTypes.FileManageRequest => message.FileManageRequest?.RequestId, + MessageTypes.FileReadRangeRequest => message.FileReadRangeRequest?.RequestId, + MessageTypes.FileHashRequest => message.FileHashRequest?.RequestId, _ => null, }; @@ -372,6 +387,8 @@ public void Dispose() MessageTypes.FileMetadataResponse => message.FileMetadataResponse?.RequestId, MessageTypes.FileThumbnailResponse => message.FileThumbnailResponse?.RequestId, MessageTypes.FileManageResponse => message.FileManageResponse?.RequestId, + MessageTypes.FileReadRangeResponse => message.FileReadRangeResponse?.RequestId, + MessageTypes.FileHashResponse => message.FileHashResponse?.RequestId, _ => null, }; @@ -386,6 +403,8 @@ public void Dispose() MessageTypes.FileMetadataRequest => message.FileMetadataRequest is not null, MessageTypes.FileThumbnailRequest => message.FileThumbnailRequest is not null, MessageTypes.FileManageRequest => message.FileManageRequest is not null, + MessageTypes.FileReadRangeRequest => message.FileReadRangeRequest is not null, + MessageTypes.FileHashRequest => message.FileHashRequest is not null, _ => false, }; @@ -431,6 +450,16 @@ public void Dispose() Type = MessageTypes.FileManageResponse, FileManageResponse = new FileManageResponse { RequestId = requestId, Success = false, ErrorMessage = reason }, }, + MessageTypes.FileReadRangeRequest => new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse { RequestId = requestId, ErrorMessage = reason }, + }, + MessageTypes.FileHashRequest => new RemexMessage + { + Type = MessageTypes.FileHashResponse, + FileHashResponse = new FileHashResponse { RequestId = requestId, ErrorMessage = reason }, + }, _ => new RemexMessage { Type = MessageTypes.FileThumbnailResponse, @@ -438,6 +467,35 @@ public void Dispose() }, }; + /// + /// Why a file_read_range_request or file_hash_request may not be sent, or null when it may + /// (or when is neither). Pure, like : the root must be + /// named, the path must pass the same remote rules the phone applies and name a file rather than the shared + /// folder itself, and a range read must pass . + /// + internal static string? ReadRefusal(RemexMessage message) + { + (string? rootId, string? path) = message.Type switch + { + MessageTypes.FileReadRangeRequest => (message.FileReadRangeRequest?.RootId, message.FileReadRangeRequest?.RelativePath), + MessageTypes.FileHashRequest => (message.FileHashRequest?.RootId, message.FileHashRequest?.RelativePath), + _ => ((string?)null, (string?)null), + }; + if (message.Type is not (MessageTypes.FileReadRangeRequest or MessageTypes.FileHashRequest)) + return null; + + if (string.IsNullOrWhiteSpace(rootId)) + return "a root id is required"; + if (!FilePathValidation.IsValidRemoteRelativePath(path, out var pathError)) + return $"relativePath: {pathError}"; + if ((path ?? string.Empty).Trim('/').Length == 0) + return "a file must be named, not the shared folder itself"; + if (message.Type == MessageTypes.FileReadRangeRequest + && !FileReadRangeValidation.IsValid(message.FileReadRangeRequest, out var rangeError)) + return rangeError; + return null; + } + /// /// Why a file_manage_request may not be sent, or null when it may. Pure, so every refusal is /// testable without a socket. The operation must be one of the five, the root must be named, and @@ -552,11 +610,20 @@ public async Task SendAsync(RemexMessage message) isCopyOrMove = message.FileManageRequest!.Operation is FileManageOperations.Copy or FileManageOperations.Move; } + if (ReadRefusal(message) is { } readRefusal) + { + relay._logger.LogWarning("Refused to send {Type} to a phone: {Reason}.", message.Type, readRefusal); + throw new PhoneFileRequestRefusedException($"{message.Type} is not sent to a phone: {readRefusal}."); + } + if (!relay.IsReachable(ClientId)) throw new PhoneNotConnectedException(); var key = (message.Type, requestId ?? string.Empty); - var pending = new PendingRequest(isCopyOrMove ? relay.CopyMoveTimeout : relay.RequestTimeout); + var timeout = isCopyOrMove ? relay.CopyMoveTimeout + : message.Type == MessageTypes.FileHashRequest ? relay.HashTimeout + : relay.RequestTimeout; + var pending = new PendingRequest(timeout); if (_pending.TryRemove(key, out var superseded)) superseded.Dispose(); _pending[key] = pending; diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileHostHandler.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileHostHandler.kt index 71e4d601..26bc31e7 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileHostHandler.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileHostHandler.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.json.JSONArray import org.json.JSONObject @@ -109,6 +111,22 @@ internal const val PC_CHANGES_OFF_MESSAGE = internal const val SHARED_FOLDER_ITSELF_MESSAGE = "That is the shared folder itself, so it can't be changed from your PC. Remove it from Access from your PC on the phone instead." +/** What the PC is told when it asks to read or hash something that is not a readable file in a shared folder. */ +internal const val READ_NOT_A_FILE_MESSAGE = "That file isn't in a folder this phone shares, or it can't be read." + +/** What the PC is told when a read or hash fails part-way. Detail goes to the phone's own log only. */ +internal const val READ_FAILED_MESSAGE = "The phone couldn't read that file. Check it is not in use, then try again." + +/** + * At most this many range reads run at once on the phone, and at most [HASH_CONCURRENCY] hashes. A live tail + * polls every two seconds; against a slow, pipe-only provider an unbounded host would stack reads up on the IO + * pool faster than they finish. Requests past the cap wait their turn rather than being refused. + */ +internal const val READ_CONCURRENCY = 2 + +/** See [READ_CONCURRENCY]. One, because a second hash of a large file only slows the first. */ +internal const val HASH_CONCURRENCY = 1 + /** * What the PC is told when a change failed for a reason the phone keeps to itself. A SAF provider's * exception text can hold an absolute path or a content URI, so it is logged on the phone and never sent. @@ -286,6 +304,8 @@ class FileHostHandler( "file_manifest_request" -> { handleManifest(obj.optJSONObject("fileManifestRequest")); true } "file_metadata_request" -> { handleMetadata(obj.optJSONObject("fileMetadataRequest")); true } "file_thumbnail_request" -> { handleThumbnail(obj.optJSONObject("fileThumbnailRequest")); true } + "file_read_range_request" -> { handleReadRange(obj.optJSONObject("fileReadRangeRequest")); true } + "file_hash_request" -> { handleHash(obj.optJSONObject("fileHashRequest")); true } "file_transfer_offer" -> { handleOffer(obj.optJSONObject("fileTransferOffer")); true } "file_transfer_complete" -> { handleComplete(obj.optJSONObject("fileTransferComplete")); true } "file_transfer_control" -> { handleControl(obj.optJSONObject("fileTransferControl")); true } @@ -335,6 +355,10 @@ class FileHostHandler( // every roots request so a PC that reloads sees a change made a moment ago. Absent on a phone // that predates it, which the PC reads as "no". put("pcChanges", rootsProvider.isPcChangeAllowed()) + // Live preview and "Verify against…" (2026-10-08 redesign). Both read-only, both resolved through + // facade.resolve, so they reach exactly what a browse reaches and nothing else. + put("readRange", true) + put("hash", true) } private fun handleBrowse(req: JSONObject?) { @@ -1059,6 +1083,161 @@ class FileHostHandler( sender.send(envelope("file_thumbnail_response", "fileThumbnailResponse", payload, v3 = true)) } + /** + * Up to 1 MiB of one file, for the PC's live preview (2026-10-08 redesign). Read-only: it resolves through + * [FileSystemFacade.resolve], so the shared-folder check runs on every request exactly as a browse's does, + * and the "Let your PC change files" switch is not needed. Off the control-message collector, like a copy, + * so a slow provider never holds up the browse behind it. + */ + private val readPermits = Semaphore(READ_CONCURRENCY) + private val hashPermits = Semaphore(HASH_CONCURRENCY) + + private fun handleReadRange(req: JSONObject?) { + req ?: return + val requestId = req.optString("requestId") + val rootId = req.optString("rootId") + val relativePath = req.optString("relativePath") + val offset = req.optLong("offset", 0L) + val length = req.optInt("length", 0) + val fromEnd = req.optBoolean("fromEnd", false) + scope.launch { + val payload = JSONObject().apply { put("requestId", requestId) } + try { + readPermits.withPermit { readRangeInto(payload, rootId, relativePath, offset, length, fromEnd) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("FileHostHandler", "A range read requested by the PC failed.", e) + payload.remove("dataBase64") + payload.put("errorMessage", READ_FAILED_MESSAGE) + } + sender.send(envelope("file_read_range_response", "fileReadRangeResponse", payload, v3 = true)) + } + } + + private suspend fun readRangeInto( + payload: JSONObject, rootId: String, relativePath: String, offset: Long, length: Int, fromEnd: Boolean, + ) { + if (offset < 0 || length < 1 || length > FileTransferLimits.READ_RANGE_MAX_BYTES) { + payload.put("errorMessage", "A read must be between 1 and ${FileTransferLimits.READ_RANGE_MAX_BYTES} bytes.") + return + } + val node = facade.resolve(rootId, relativePath) + if (node == null || !node.canRead || node.isDirectory) { + payload.put("errorMessage", READ_NOT_A_FILE_MESSAGE) + return + } + val job = currentCoroutineContext()[Job] + val checkCancelled = { job?.ensureActive() ?: Unit } + val size = node.length + // A SIZE OF 0 MEANS "UNKNOWN" AS OFTEN AS "EMPTY": DocumentFile.length() is 0 whenever a provider omits + // COLUMN_SIZE (RemEx-xrb2v). Trusting it would answer eof on every read and serve the top of a log as its tail. + val sizeKnown = size > 0 + if (fromEnd && !sizeKnown) { + val tail = tailOfUnknownSize(node, length, checkCancelled) + if (tail == null) { + payload.put("errorMessage", READ_FAILED_MESSAGE) + } else { + putRead(payload, tail.first, tail.second, fileSize = tail.third, eof = true, node.lastModifiedMs) + } + return + } + val start = if (fromEnd) maxOf(0L, size - length) else offset + val data = + if (sizeKnown && start >= size) ByteArray(0) + else facade.readRange(node, start, length, checkCancelled) + if (data == null) { + payload.put("errorMessage", READ_FAILED_MESSAGE) + return + } + val reachedEnd = data.size < length || (sizeKnown && start + data.size >= size) + // With the size unknown, what was read is a lower bound on it, and exact once the end was reached. + val fileSize = if (sizeKnown) maxOf(size, start + data.size) else start + data.size + putRead(payload, start, data, fileSize, reachedEnd, node.lastModifiedMs) + } + + private fun putRead(payload: JSONObject, start: Long, data: ByteArray, fileSize: Long, eof: Boolean, modifiedMs: Long) { + payload.put("offset", start) + payload.put("dataBase64", JavaBase64.getEncoder().encodeToString(data)) + payload.put("fileSize", fileSize) + payload.put("modifiedUtc", modifiedMs) + payload.put("eof", eof) + } + + /** + * The last [length] bytes of a file whose provider will not say how big it is: streamed to the end through a + * ring buffer, checking for cancellation between pieces. Returns (start offset, bytes, total size), or null + * when the file cannot be opened. + */ + private fun tailOfUnknownSize(node: FileNode, length: Int, checkCancelled: () -> Unit): Triple? = + facade.openInput(node)?.use { input -> + val ring = ByteArray(length) + val piece = ByteArray(64 * 1024) + var total = 0L + while (true) { + checkCancelled() + val n = input.read(piece) + if (n < 0) break + for (i in 0 until n) ring[((total + i) % length).toInt()] = piece[i] + total += n + } + val kept = minOf(total, length.toLong()).toInt() + val out = ByteArray(kept) + val first = total - kept + for (i in 0 until kept) out[i] = ring[((first + i) % length).toInt()] + Triple(first, out, total) + } + + /** + * SHA-256 of one file, for "Compute SHA-256" and "Verify against…" on the PC (2026-10-08 redesign). The same + * read-only gate as [handleReadRange]. Streams through [MessageDigest] in 64 KiB pieces and checks for + * cancellation between them, so hashing a long video never blocks the collector and stops with the host. + */ + private fun handleHash(req: JSONObject?) { + req ?: return + val requestId = req.optString("requestId") + val rootId = req.optString("rootId") + val relativePath = req.optString("relativePath") + scope.launch { + val payload = JSONObject().apply { put("requestId", requestId) } + try { + hashPermits.withPermit { hashInto(payload, rootId, relativePath) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("FileHostHandler", "A hash requested by the PC failed.", e) + payload.remove("sha256") + payload.put("errorMessage", READ_FAILED_MESSAGE) + } + sender.send(envelope("file_hash_response", "fileHashResponse", payload, v3 = true)) + } + } + + private suspend fun hashInto(payload: JSONObject, rootId: String, relativePath: String) { + val node = facade.resolve(rootId, relativePath) + if (node == null || !node.canRead || node.isDirectory) { + payload.put("errorMessage", READ_NOT_A_FILE_MESSAGE) + } else { + val digest = MessageDigest.getInstance("SHA-256") + val opened = + facade.openInput(node)?.use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + currentCoroutineContext().ensureActive() + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + } + true + } ?: false + if (opened) { + payload.put("sha256", JavaBase64.getEncoder().encodeToString(digest.digest())) + } else { + payload.put("errorMessage", READ_FAILED_MESSAGE) + } + } + } + // ── v3 binary transfer (Android served: PC downloads-from / uploads-to Android) ── private fun handleOffer(req: JSONObject?) { diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileSystemFacade.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileSystemFacade.kt index 106ec0c8..31b65514 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileSystemFacade.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileSystemFacade.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.util.Size import androidx.documentfile.provider.DocumentFile import java.io.ByteArrayOutputStream +import java.io.FileInputStream import java.io.InputStream import java.io.OutputStream @@ -79,6 +80,30 @@ interface FileSystemFacade { /** Loads a JPEG thumbnail (images/videos), ≤ [maxBytes] encoded, or null when unavailable. */ fun loadThumbnailJpeg(node: FileNode, maxDim: Int, maxBytes: Int): ByteArray? + + /** + * Up to [length] bytes of [node] from [offset] (live preview), or null when it cannot be opened. Fewer + * bytes come back only at the end of the file. The default streams and skips, which every provider + * supports; [SafFileSystemFacade] seeks instead when the provider hands out a real file descriptor. + * [checkCancelled] is called between pieces of a skip, which on a pipe-only provider streams every byte + * before [offset]; it throws to stop a read nobody is waiting for any more. + */ + fun readRange(node: FileNode, offset: Long, length: Int, checkCancelled: () -> Unit = {}): ByteArray? = + openInput(node)?.use { input -> + var toSkip = offset + while (toSkip > 0) { + checkCancelled() + val skipped = input.skip(toSkip) + if (skipped <= 0) { + // skip() may legitimately return 0 before the end; one read tells the two apart. + if (input.read() < 0) return@use ByteArray(0) + toSkip-- + } else { + toSkip -= skipped + } + } + input.readNBytes(length) + } } /** Production [FileNode] backed by a SAF [DocumentFile]. */ @@ -152,6 +177,23 @@ class SafFileSystemFacade( return context.contentResolver.openOutputStream(doc.uri, "w") } + override fun readRange(node: FileNode, offset: Long, length: Int, checkCancelled: () -> Unit): ByteArray? { + val doc = (node as? DocumentFileNode)?.doc ?: return null + // A SEEK, NOT A SKIP, WHEN THE PROVIDER ALLOWS IT. Tailing a 2 GB log would otherwise stream two + // gigabytes through skip() every two seconds. Providers that only offer a pipe fall back to the + // interface default, which still answers correctly, just slower. + val seeked = + runCatching { + context.contentResolver.openFileDescriptor(doc.uri, "r")?.use { pfd -> + FileInputStream(pfd.fileDescriptor).use { stream -> + stream.channel.position(offset) + stream.readNBytes(length) + } + } + }.getOrNull() + return seeked ?: super.readRange(node, offset, length, checkCancelled) + } + override fun loadThumbnailJpeg(node: FileNode, maxDim: Int, maxBytes: Int): ByteArray? { val doc = (node as? DocumentFileNode)?.doc ?: return null val type = doc.type ?: return null diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferFrame.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferFrame.kt index e831f863..8fdec880 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferFrame.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferFrame.kt @@ -195,6 +195,16 @@ object FileTransferLimits { const val SEARCH_MAX_RESULTS = 200 const val THUMBNAIL_DEFAULT_MAX_DIM = 128 const val THUMBNAIL_MAX_BYTES = 96 * 1024 + /** Most bytes one file_read_range_request may ask for (1 MiB). Mirrors C# ReadRangeMaxBytes. */ + const val READ_RANGE_MAX_BYTES = 1024 * 1024 + /** Text preview reads at most this much from the start of a file (2 MiB). */ + const val PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024 + /** Live tail keeps the last this-many bytes of a growing text file (256 KiB). */ + const val PREVIEW_TEXT_TAIL_BYTES = 256 * 1024 + /** Images larger than this are not previewed at full size (40 MiB). */ + const val PREVIEW_IMAGE_MAX_BYTES = 40L * 1024 * 1024 + /** How often live tail asks for new bytes while the preview is visible. */ + const val PREVIEW_TAIL_POLL_MS = 2000L const val DATA_PAYLOAD_BYTES = 256 * 1024 const val ACK_INTERVAL_BYTES = 4 * 1024 * 1024 const val MAX_UNACKED_BYTES = 8 * 1024 * 1024 diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/HashFormat.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/HashFormat.kt new file mode 100644 index 00000000..5c6e674c --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/HashFormat.kt @@ -0,0 +1,76 @@ +package com.clindsay94.remex.service + +import java.security.MessageDigest +import java.util.Base64 + +/** + * How a SHA-256 is shown to a person and how a pasted one is read back (2026-10-08 redesign). + * + * The Kotlin twin of `Remex.Core.Helpers.HashFormat`. **The wire stays Base64; only the screen changes.** + * People compare hashes against `sha256sum`, `Get-FileHash` and download pages, which print hex, so the screen + * shows 64 lowercase hex characters. A pasted hash is accepted as hex in either case (separators ignored) or as + * the 44-character Base64 RemEx uses on the wire; anything else is "not a SHA-256", never a silent mismatch. + * Both platforms run the same vectors (`HashFormatTest` here, `HashFormatTests` in remex.core.tests). + */ +object HashFormat { + /** Bytes in a SHA-256 digest. */ + const val SHA256_BYTES = 32 + + /** The lowercase hex form of a Base64 SHA-256, or null when [base64] is not one. */ + fun toHex(base64: String?): String? = decodeBase64(base64)?.let(::hex) + + /** The lowercase hex form of raw digest bytes. */ + fun hex(digest: ByteArray): String { + val out = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xFF + out.append(HEX[v ushr 4]).append(HEX[v and 0x0F]) + } + return out.toString() + } + + /** + * Reads a hash someone typed or pasted: the 32 digest bytes when it is 64 hex digits (any case, with + * spaces, colons or dashes ignored) or a 44-character Base64 SHA-256; otherwise null. + */ + fun normalize(input: String?): ByteArray? { + if (input.isNullOrBlank()) return null + val trimmed = input.trim() + val digits = StringBuilder(SHA256_BYTES * 2) + var allHex = true + for (c in trimmed) { + if (c == ' ' || c == ':' || c == '-' || c == '\t') continue + if (!isHexDigit(c) || digits.length == SHA256_BYTES * 2) { + allHex = false + break + } + digits.append(c) + } + if (allHex && digits.length == SHA256_BYTES * 2) { + return ByteArray(SHA256_BYTES) { i -> + ((Character.digit(digits[i * 2], 16) shl 4) or Character.digit(digits[i * 2 + 1], 16)).toByte() + } + } + return decodeBase64(trimmed) + } + + /** + * True when two hashes, each in any form [normalize] accepts, name the same digest. False when they differ + * OR when either is not a SHA-256: call [normalize] first to tell those apart on screen. + */ + fun matches(a: String?, b: String?): Boolean { + val left = normalize(a) ?: return false + val right = normalize(b) ?: return false + return MessageDigest.isEqual(left, right) + } + + private fun decodeBase64(base64: String?): ByteArray? { + if (base64 == null || base64.length != 44) return null + val bytes = runCatching { Base64.getDecoder().decode(base64) }.getOrNull() ?: return null + return bytes.takeIf { it.size == SHA256_BYTES } + } + + private fun isHexDigit(c: Char): Boolean = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' + + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/service/FileHostHandlerTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/service/FileHostHandlerTest.kt index ed15959b..4c61be69 100644 --- a/remex.android/app/src/test/java/com/clindsay94/remex/service/FileHostHandlerTest.kt +++ b/remex.android/app/src/test/java/com/clindsay94/remex/service/FileHostHandlerTest.kt @@ -175,7 +175,9 @@ class FileHostHandlerTest { private class CapturingSender : ControlMessageSender { val sent = mutableListOf() - override fun send(json: String) { sent.add(json) } + // Synchronized: range reads and hashes answer from the host's IO scope, so a test on a real dispatcher + // sends from several threads at once. + override fun send(json: String) { synchronized(this) { sent.add(json) } } fun last() = JSONObject(sent.last()) } @@ -357,6 +359,253 @@ class FileHostHandlerTest { val ops = caps.getJSONArray("ops") val opSet = (0 until ops.length()).map { ops.getString(it) }.toSet() assertTrue(opSet.containsAll(listOf("copy", "move", "mkdir", "search"))) + // Live preview and hashing (2026-10-08 redesign): a PC hides both for a phone that does not say so. + assertTrue(caps.getBoolean("readRange")) + assertTrue(caps.getBoolean("hash")) + } + + // ── Live preview and hashing, from the PC (2026-10-08 redesign) ────────────── + + private fun readRange(path: String, offset: Long = 0, length: Int = 1024, fromEnd: Boolean = false, root: String = "root1") = + JSONObject().apply { + put("type", "file_read_range_request") + put( + "fileReadRangeRequest", + JSONObject().apply { + put("requestId", "rr") + put("rootId", root) + put("relativePath", path) + put("offset", offset) + put("length", length) + put("fromEnd", fromEnd) + }, + ) + }.toString() + + private fun hash(path: String, root: String = "root1") = + """{"type":"file_hash_request","fileHashRequest":{"requestId":"hh","rootId":"$root","relativePath":"$path"}}""" + + private fun logTree(text: String): FakeNode { + val root = sampleTree() + val logs = FakeNode("Logs", true, parent = root) + root.children["Logs"] = logs + logs.children["app.log"] = FakeNode("app.log", false, content = text.toByteArray(), parent = logs) + return root + } + + private fun readReply(sender: CapturingSender): JSONObject { + val msg = sender.last() + assertEquals("file_read_range_response", msg.getString("type")) + return msg.getJSONObject("fileReadRangeResponse") + } + + private fun bytesOf(reply: JSONObject) = String(java.util.Base64.getDecoder().decode(reply.getString("dataBase64"))) + + @Test + fun readRange_fromAnOffset_returnsThoseBytesAndTheSize() = runBlocking { + val (h, sender, _) = build(logTree("0123456789")) + h.handleControlMessage(readRange("Logs/app.log", offset = 3, length = 4)) + val r = readReply(sender) + assertEquals("rr", r.getString("requestId")) + assertEquals(3L, r.getLong("offset")) + assertEquals("3456", bytesOf(r)) + assertEquals(10L, r.getLong("fileSize")) + assertFalse(r.getBoolean("eof")) + assertFalse(r.has("errorMessage")) + } + + @Test + fun readRange_fromTheEnd_returnsTheTail() = runBlocking { + val (h, sender, _) = build(logTree("0123456789")) + h.handleControlMessage(readRange("Logs/app.log", length = 4, fromEnd = true)) + val r = readReply(sender) + assertEquals(6L, r.getLong("offset")) + assertEquals("6789", bytesOf(r)) + assertTrue(r.getBoolean("eof")) + } + + @Test + fun readRange_pastTheEnd_isNothingNew_notAnError() = runBlocking { + val (h, sender, _) = build(logTree("abc")) + h.handleControlMessage(readRange("Logs/app.log", offset = 50)) + val r = readReply(sender) + assertFalse(r.has("errorMessage")) + assertEquals("", bytesOf(r)) + assertTrue(r.getBoolean("eof")) + } + + @Test + fun readRange_whenTheProviderReportsNoSize_stillReportsWhatItRead() = runBlocking { + // DocumentFile.length() is 0 when a provider omits COLUMN_SIZE (RemEx-xrb2v). The preview must still + // get the bytes, and a size no smaller than what it was given. + val tree = logTree("hello") + (tree.children["Logs"]!!.children["app.log"]!!).declaredLength = 0L + val (h, sender, _) = build(tree) + h.handleControlMessage(readRange("Logs/app.log")) + val r = readReply(sender) + assertEquals("hello", bytesOf(r)) + assertEquals(5L, r.getLong("fileSize")) + } + + private fun unknownSizeLog(text: String): FakeNode { + val tree = logTree(text) + tree.children["Logs"]!!.children["app.log"]!!.declaredLength = 0L + return tree + } + + @Test + fun readRange_withTheSizeUnknown_doesNotClaimTheEndBeforeItGetsThere() = runBlocking { + // A provider that omits COLUMN_SIZE reports 0 (RemEx-xrb2v). A full read of the asked length proves nothing + // about the end, so eof must be false and the size a lower bound; a preview that trusted eof would stop here. + val (h, sender, _) = build(unknownSizeLog("0123456789")) + h.handleControlMessage(readRange("Logs/app.log", offset = 0, length = 4)) + val r = readReply(sender) + assertEquals("0123", bytesOf(r)) + assertFalse(r.getBoolean("eof")) + assertEquals(4L, r.getLong("fileSize")) + } + + @Test + fun readRange_tailWithTheSizeUnknown_returnsTheRealEnd_notTheTop() = runBlocking { + val (h, sender, _) = build(unknownSizeLog("0123456789")) + h.handleControlMessage(readRange("Logs/app.log", length = 4, fromEnd = true)) + val r = readReply(sender) + assertEquals("6789", bytesOf(r)) + assertEquals(6L, r.getLong("offset")) + assertEquals(10L, r.getLong("fileSize")) + assertTrue(r.getBoolean("eof")) + } + + @Test + fun readRange_pastTheEnd_throughAStreamWhoseSkipReturnsZero_isEmpty_notAHang() = runBlocking { + // skip() may return 0 before the end; the facade's default tells that apart from the end with one read(). + val (h, sender, _) = build(unknownSizeLog("abc")) + lastFacade!!.inputOverride = { + object : ByteArrayInputStream("abc".toByteArray()) { + override fun skip(n: Long): Long = 0 + } + } + h.handleControlMessage(readRange("Logs/app.log", offset = 50)) + val r = readReply(sender) + assertFalse(r.has("errorMessage")) + assertEquals("", bytesOf(r)) + assertTrue(r.getBoolean("eof")) + } + + @Test + fun readRange_thatFailsPartWay_saysSoWithoutTheProvidersWords() = runBlocking { + val (h, sender, _) = build(logTree("abc")) + lastFacade!!.inputOverride = { + object : InputStream() { + override fun read(): Int = throw java.io.IOException("/storage/emulated/0/secret/path exploded") + } + } + h.handleControlMessage(readRange("Logs/app.log")) + val r = readReply(sender) + assertEquals(READ_FAILED_MESSAGE, r.getString("errorMessage")) + assertFalse(r.has("dataBase64")) + } + + @Test + fun rangeReads_runAtMostTwoAtATime_andTheRestWaitTheirTurn() { + // A live tail polls every two seconds; against a slow provider an uncapped host stacks reads up on the IO + // pool. Three reads arrive while the provider is stuck: two may open, the third must wait, and all three + // are answered once the provider moves. + val ioScope = CoroutineScope(Dispatchers.IO) + try { + val (h, sender, _) = build(logTree("0123456789"), scope = ioScope) + val gate = java.util.concurrent.CountDownLatch(1) + val opened = java.util.concurrent.atomic.AtomicInteger(0) + lastFacade!!.inputOverride = { + opened.incrementAndGet() + gate.await(10, java.util.concurrent.TimeUnit.SECONDS) + ByteArrayInputStream("0123456789".toByteArray()) + } + runBlocking { repeat(3) { h.handleControlMessage(readRange("Logs/app.log")) } } + Thread.sleep(500) + assertEquals("only READ_CONCURRENCY reads may be in the provider at once", READ_CONCURRENCY, opened.get()) + gate.countDown() + val deadline = System.currentTimeMillis() + 10_000 + while (synchronized(sender) { sender.sent.size } < 3 && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertEquals(3, synchronized(sender) { sender.sent.size }) + assertEquals(3, opened.get()) + } finally { + ioScope.cancel() + } + } + + @Test + fun readRange_ofAFolder_aMissingFile_orAnUnsharedRoot_isRefusedWithAPlainReason() = runBlocking { + for (request in listOf(readRange("Logs"), readRange("Logs/nope.log"), readRange("Logs/app.log", root = "not-shared"))) { + val (h, sender, _) = build(logTree("secret")) + h.handleControlMessage(request) + val r = readReply(sender) + assertEquals(READ_NOT_A_FILE_MESSAGE, r.getString("errorMessage")) + assertFalse("no bytes may come back for $request", r.has("dataBase64")) + } + } + + @Test + fun readRange_outOfBounds_isRefused_beforeAnythingIsRead() = runBlocking { + for (request in listOf( + readRange("Logs/app.log", offset = -1), + readRange("Logs/app.log", length = 0), + readRange("Logs/app.log", length = FileTransferLimits.READ_RANGE_MAX_BYTES + 1), + )) { + val (h, sender, _) = build(logTree("secret")) + lastFacade!!.inputOverride = { throw AssertionError("nothing may be opened for an out-of-bounds read") } + h.handleControlMessage(request) + val r = readReply(sender) + assertTrue(r.getString("errorMessage").isNotBlank()) + assertFalse(r.has("dataBase64")) + } + } + + @Test + fun readRange_andHash_doNotNeedTheChangeSwitch() = runBlocking { + // Reading is not changing: the person's "Let your PC change files" switch is off here. + val (h, sender, _) = build(logTree("abc"), pcChangesAllowed = false) + h.handleControlMessage(readRange("Logs/app.log")) + assertEquals("abc", bytesOf(readReply(sender))) + h.handleControlMessage(hash("Logs/app.log")) + assertTrue(sender.last().getJSONObject("fileHashResponse").has("sha256")) + } + + @Test + fun hash_isTheSha256OfTheFile_inBase64() = runBlocking { + val (h, sender, _) = build(logTree("abc")) + h.handleControlMessage(hash("Logs/app.log")) + val msg = sender.last() + assertEquals("file_hash_response", msg.getString("type")) + val r = msg.getJSONObject("fileHashResponse") + assertEquals("hh", r.getString("requestId")) + // SHA-256("abc"), the FIPS 180-2 vector; HashFormatTest pins the same value. + assertEquals("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=", r.getString("sha256")) + } + + @Test + fun hash_ofAFolder_aMissingFile_orAnUnsharedRoot_isRefusedWithAPlainReason() = runBlocking { + for (request in listOf(hash("Logs"), hash("Logs/nope.log"), hash("Logs/app.log", root = "not-shared"))) { + val (h, sender, _) = build(logTree("abc")) + h.handleControlMessage(request) + val r = sender.last().getJSONObject("fileHashResponse") + assertEquals(READ_NOT_A_FILE_MESSAGE, r.getString("errorMessage")) + assertFalse(r.has("sha256")) + } + } + + @Test + fun hash_thatFailsPartWay_saysSoWithoutTheProvidersWords() = runBlocking { + val (h, sender, _) = build(logTree("abc")) + lastFacade!!.inputOverride = { + object : java.io.InputStream() { + override fun read(): Int = throw java.io.IOException("/storage/emulated/0/secret/path exploded") + } + } + h.handleControlMessage(hash("Logs/app.log")) + val r = sender.last().getJSONObject("fileHashResponse") + assertEquals(READ_FAILED_MESSAGE, r.getString("errorMessage")) + assertFalse(r.getString("errorMessage").contains("/storage")) } @Test @@ -506,6 +755,10 @@ class FileHostHandlerTest { """{"type":"file_metadata_request","fileMetadataRequest":{"requestId":"q","rootId":"root1","relativePath":"Docs/report.txt"}}"""), Triple("file_thumbnail_request", "file_thumbnail_response", """{"type":"file_thumbnail_request","fileThumbnailRequest":{"requestId":"q","rootId":"root1","relativePath":"Docs/report.txt","maxDim":128}}"""), + Triple("file_read_range_request", "file_read_range_response", + """{"type":"file_read_range_request","fileReadRangeRequest":{"requestId":"q","rootId":"root1","relativePath":"Docs/report.txt","offset":0,"length":10}}"""), + Triple("file_hash_request", "file_hash_response", + """{"type":"file_hash_request","fileHashRequest":{"requestId":"q","rootId":"root1","relativePath":"Docs/report.txt"}}"""), ) for ((request, response, json) in requests) { val (h, sender, _) = build(sampleTree(), roots = emptyList()) diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/service/HashFormatTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/service/HashFormatTest.kt new file mode 100644 index 00000000..b4100c62 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/service/HashFormatTest.kt @@ -0,0 +1,82 @@ +package com.clindsay94.remex.service + +import java.security.MessageDigest +import java.util.Base64 +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Kotlin twin's vectors. They are the same strings `FileReadRangeAndHashFormatTests` uses in + * remex.core.tests, so a hash shown on the phone and one shown on the PC can be compared by eye. + */ +class HashFormatTest { + private companion object { + // SHA-256("abc"), the FIPS 180-2 test vector. + const val ABC_HEX = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + const val ABC_BASE64 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=" + // SHA-256(""). + const val EMPTY_HEX = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + + @Test + fun theVectorsAreWhatTheyClaimToBe() { + val abc = MessageDigest.getInstance("SHA-256").digest("abc".toByteArray()) + assertEquals(ABC_HEX, HashFormat.hex(abc)) + assertEquals(ABC_BASE64, Base64.getEncoder().encodeToString(abc)) + assertEquals(EMPTY_HEX, HashFormat.hex(MessageDigest.getInstance("SHA-256").digest(ByteArray(0)))) + } + + @Test + fun toHex_turnsTheWireBase64IntoLowercaseHex() { + assertEquals(ABC_HEX, HashFormat.toHex(ABC_BASE64)) + } + + @Test + fun toHex_ofSomethingThatIsNotASha256_isNull() { + for (input in listOf(null, "", "not base64 at all", "aGVsbG8=")) { + assertNull("'$input'", HashFormat.toHex(input)) + } + } + + @Test + fun everyFormOfAHash_normalizesToTheSameDigest_andMatchesTheOthers() { + val forms = listOf( + ABC_HEX, + ABC_HEX.uppercase(), + " $ABC_HEX\t", + (0 until 8).joinToString(" ") { ABC_HEX.substring(it * 8, it * 8 + 8) }, + (0 until 32).joinToString(":") { ABC_HEX.substring(it * 2, it * 2 + 2) }, + ABC_BASE64, + ) + val expected = MessageDigest.getInstance("SHA-256").digest("abc".toByteArray()) + for (form in forms) { + assertArrayEquals("'$form'", expected, HashFormat.normalize(form)) + assertTrue("'$form'", HashFormat.matches(form, ABC_BASE64)) + assertTrue("'$form'", HashFormat.matches(ABC_HEX, form)) + } + } + + @Test + fun somethingThatIsNotASha256_doesNotNormalize_andNeverMatches() { + val notHashes = listOf( + "", + "ba7816bf", + ABC_HEX + "00", + "zz7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "md5:900150983cd24fb0d6963f7d28e17f72", + ) + for (input in notHashes) { + assertNull("'$input'", HashFormat.normalize(input)) + assertFalse("'$input'", HashFormat.matches(input, input)) + } + } + + @Test + fun twoDifferentHashes_doNotMatch() { + assertFalse(HashFormat.matches(ABC_HEX, EMPTY_HEX)) + } +} diff --git a/remex.core.tests/FileReadRangeAndHashFormatTests.cs b/remex.core.tests/FileReadRangeAndHashFormatTests.cs new file mode 100644 index 00000000..5c02f7d3 --- /dev/null +++ b/remex.core.tests/FileReadRangeAndHashFormatTests.cs @@ -0,0 +1,202 @@ +using System.Text; +using System.Text.Json; +using Remex.Core.Helpers; +using Remex.Core.Messages; +using Remex.Core.Models; +using Remex.Core.Validation; + +namespace Remex.Core.Tests; + +/// +/// The 2026-10-08 file-browser redesign's shared contracts: how a hash is shown and read back, which range +/// reads are valid, and the wire names of the new message pair. The hash vectors are repeated verbatim in +/// the Kotlin twin's HashFormatTest, so both platforms agree on every one. +/// +public class FileReadRangeAndHashFormatTests +{ + // SHA-256("abc"), the FIPS 180-2 test vector, in each form a person might paste. + private const string AbcHex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + private const string AbcBase64 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="; + // SHA-256("") — a different digest, for the mismatch side. + private const string EmptyHex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + [Fact] + public void ToHex_TurnsTheWireBase64IntoLowercaseHex() + { + Assert.Equal(AbcHex, HashFormat.ToHex(AbcBase64)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not base64 at all")] + [InlineData("aGVsbG8=")] // valid Base64, but 5 bytes, not 32 + public void ToHex_OfSomethingThatIsNotASha256_IsNull(string? input) + { + Assert.Null(HashFormat.ToHex(input)); + } + + public static TheoryData FormsOfTheSameHash() => + [ + AbcHex, + AbcHex.ToUpperInvariant(), // Get-FileHash prints upper case + " " + AbcHex + "\t", // copied with whitespace around it + string.Join(' ', Enumerable.Range(0, 8).Select(i => AbcHex.Substring(i * 8, 8))), // grouped + string.Join(':', Enumerable.Range(0, 32).Select(i => AbcHex.Substring(i * 2, 2))), // colon pairs + AbcBase64, // what RemEx itself used to show + ]; + + [Theory] + [MemberData(nameof(FormsOfTheSameHash))] + public void EveryFormOfAHash_NormalizesToTheSameDigest_AndMatchesTheOthers(string form) + { + Assert.True(HashFormat.TryNormalize(form, out var digest)); + Assert.Equal(Convert.FromHexString(AbcHex), digest); + Assert.True(HashFormat.Matches(form, AbcBase64)); + Assert.True(HashFormat.Matches(AbcHex, form)); + } + + [Theory] + [InlineData("")] + [InlineData("ba7816bf")] // too short + [InlineData(AbcHex + "00")] // too long + [InlineData("zz7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] // not hex, not base64 + [InlineData("md5:900150983cd24fb0d6963f7d28e17f72")] + public void SomethingThatIsNotASha256_DoesNotNormalize_AndNeverMatches(string input) + { + Assert.False(HashFormat.TryNormalize(input, out _)); + Assert.False(HashFormat.Matches(input, input)); + } + + [Fact] + public void TwoDifferentHashes_DoNotMatch() + { + Assert.False(HashFormat.Matches(AbcHex, EmptyHex)); + } + + [Fact] + public void TheVectorsAreWhatTheyClaimToBe() + { + // Anti-vacuity: every assertion above leans on these constants, so prove them. + var abc = System.Security.Cryptography.SHA256.HashData(Encoding.ASCII.GetBytes("abc")); + Assert.Equal(AbcHex, Convert.ToHexStringLower(abc)); + Assert.Equal(AbcBase64, Convert.ToBase64String(abc)); + Assert.Equal(EmptyHex, Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData([]))); + } + + // ── Range validation ── + + private static FileReadRangeRequest Range(long offset = 0, int length = 1024, string root = "docs", string path = "a.log") => + new() { RequestId = "r", RootId = root, RelativePath = path, Offset = offset, Length = length }; + + [Fact] + public void AnOrdinaryRead_IsValid_UpToTheOneMegabyteCap() + { + Assert.True(FileReadRangeValidation.IsValid(Range(), out var error)); + Assert.Null(error); + Assert.True(FileReadRangeValidation.IsValid(Range(length: FileTransferLimits.ReadRangeMaxBytes), out _)); + Assert.True(FileReadRangeValidation.IsValid(Range(offset: long.MaxValue / 2), out _)); + } + + public static TheoryData InvalidReads() => + [ + null, + Range(offset: -1), + Range(length: 0), + Range(length: -5), + Range(length: FileTransferLimits.ReadRangeMaxBytes + 1), + Range(root: ""), + Range(root: " "), + Range(path: ""), + Range(path: "/"), + ]; + + [Theory] + [MemberData(nameof(InvalidReads))] + public void AnInvalidRead_IsRefused_WithAReason(FileReadRangeRequest? request) + { + Assert.False(FileReadRangeValidation.IsValid(request, out var error)); + Assert.False(string.IsNullOrWhiteSpace(error)); + } + + [Theory] + [InlineData(100, 10, false, 1000, 100)] // from an offset: the offset + [InlineData(0, 256, true, 1000, 744)] // tail: the last 256 bytes + [InlineData(0, 4096, true, 1000, 0)] // tail longer than the file: the whole file + [InlineData(0, 10, true, 0, 0)] // tail of an empty file + public void StartOffset_IsTheOffset_OrTheTail(long offset, int length, bool fromEnd, long size, long expected) + { + Assert.Equal(expected, FileReadRangeValidation.StartOffset(offset, length, fromEnd, size)); + } + + [Fact] + public void AOneMegabyteReply_FitsInsideTheControlMessageLimit() + { + // The reason the cap is 1 MiB: base64 grows it by a third, and MessageSerializer refuses anything over + // 4 MB. If someone raises the cap, this says why it can't go past ~3 MB. + var reply = new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = Guid.NewGuid().ToString(), + DataBase64 = Convert.ToBase64String(new byte[FileTransferLimits.ReadRangeMaxBytes]), + FileSize = long.MaxValue, + }, + }; + Assert.True(MessageSerializer.Serialize(reply).Length < 4 * 1024 * 1024); + } + + // ── Wire shape ── + + [Fact] + public void TheReadRangePair_RoundTrips_UnderTheNamesTheKotlinHostReads() + { + var request = new RemexMessage + { + Type = MessageTypes.FileReadRangeRequest, + FileReadRangeRequest = new FileReadRangeRequest + { + RequestId = "r1", RootId = "content://tree/x", RelativePath = "Logs/app.log", + Offset = 12, Length = 34, FromEnd = true, + }, + }; + var json = Encoding.UTF8.GetString(MessageSerializer.Serialize(request)); + using (var doc = JsonDocument.Parse(json)) + { + var body = doc.RootElement.GetProperty("fileReadRangeRequest"); + Assert.Equal("file_read_range_request", doc.RootElement.GetProperty("type").GetString()); + Assert.Equal(12, body.GetProperty("offset").GetInt64()); + Assert.Equal(34, body.GetProperty("length").GetInt32()); + Assert.True(body.GetProperty("fromEnd").GetBoolean()); + } + + // The phone writes exactly these keys (FileHostHandler.handleReadRange). + const string phoneReply = """ + {"type":"file_read_range_response","protocolVersion":3, + "fileReadRangeResponse":{"requestId":"r1","offset":5,"dataBase64":"aGVsbG8=","fileSize":10,"modifiedUtc":77,"eof":true}} + """; + var back = MessageSerializer.Deserialize(Encoding.UTF8.GetBytes(phoneReply)); + var body2 = back!.FileReadRangeResponse!; + Assert.Equal("r1", body2.RequestId); + Assert.Equal(5, body2.Offset); + Assert.Equal("hello", Encoding.ASCII.GetString(Convert.FromBase64String(body2.DataBase64!))); + Assert.Equal(10, body2.FileSize); + Assert.Equal(77, body2.ModifiedUtc); + Assert.True(body2.Eof); + } + + [Fact] + public void TheNewCapabilities_AreAdditive_AndDefaultToNo() + { + const string olderPhone = """{"protocol":3,"binary":true,"resume":true,"ops":[],"pcChanges":false}"""; + var caps = JsonSerializer.Deserialize(olderPhone, Serialization.RemexJsonSerializerContext.Default.FileCapabilities)!; + Assert.False(caps.ReadRange); + Assert.False(caps.Hash); + + const string newerPhone = """{"protocol":3,"ops":[],"readRange":true,"hash":true}"""; + var newer = JsonSerializer.Deserialize(newerPhone, Serialization.RemexJsonSerializerContext.Default.FileCapabilities)!; + Assert.True(newer.ReadRange); + Assert.True(newer.Hash); + } +} diff --git a/remex.core/Helpers/HashFormat.cs b/remex.core/Helpers/HashFormat.cs new file mode 100644 index 00000000..4996cecc --- /dev/null +++ b/remex.core/Helpers/HashFormat.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; + +namespace Remex.Core.Helpers; + +/// +/// How a SHA-256 is shown to a person and how a pasted one is read back (2026-10-08 redesign). +/// +/// +/// +/// THE WIRE STAYS BASE64; ONLY THE SCREEN CHANGES. Every file_hash_response and transfer verdict +/// already carries Base64. People compare hashes against sha256sum, Get-FileHash and download +/// pages, which all print hex, so that is what the screen shows: 64 lowercase hex characters. +/// +/// +/// A PASTED HASH IS ACCEPTED IN ANY FORM SOMEONE IS LIKELY TO HAVE. Hex in either case (Get-FileHash prints +/// upper case), with spaces, colons or dashes between groups, or the Base64 RemEx itself used to show. +/// Anything else is "not a SHA-256", never a silent mismatch. +/// +/// +/// The Kotlin twin is com.clindsay94.remex.service.HashFormat; both run the vectors in +/// HashFormatTests / HashFormatTest, so a hash reads the same on the PC and the phone. +/// +/// +public static class HashFormat +{ + /// Bytes in a SHA-256 digest. + public const int Sha256Bytes = 32; + + /// + /// The lowercase hex form of a Base64 SHA-256, or null when is not one. + /// + public static string? ToHex(string? base64) => + TryDecodeBase64(base64, out var bytes) ? Convert.ToHexStringLower(bytes) : null; + + /// + /// Reads a hash someone typed or pasted. True with the 32 digest bytes when it is 64 hex digits (any case, + /// separators ignored) or a 44-character Base64 SHA-256; otherwise false. + /// + public static bool TryNormalize(string? input, out byte[] digest) + { + digest = []; + if (string.IsNullOrWhiteSpace(input)) + return false; + + var trimmed = input.Trim(); + Span hex = stackalloc char[Sha256Bytes * 2]; + var count = 0; + var allHex = true; + foreach (var c in trimmed) + { + if (c is ' ' or ':' or '-' or '\t') + continue; + if (!char.IsAsciiHexDigit(c)) + { + allHex = false; + break; + } + if (count == hex.Length) + { + allHex = false; + break; + } + hex[count++] = c; + } + + if (allHex && count == hex.Length) + { + digest = Convert.FromHexString(hex); + return true; + } + + return TryDecodeBase64(trimmed, out digest); + } + + /// + /// True when two hashes, each in any form accepts, name the same digest. + /// False when they differ OR when either is not a SHA-256: call first to tell + /// those apart on screen. + /// + public static bool Matches(string? a, string? b) => + TryNormalize(a, out var left) + && TryNormalize(b, out var right) + && CryptographicOperations.FixedTimeEquals(left, right); + + private static bool TryDecodeBase64(string? base64, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrWhiteSpace(base64) || base64.Length != 44) + return false; + + Span buffer = stackalloc byte[Sha256Bytes + 3]; + if (!Convert.TryFromBase64String(base64, buffer, out var written) || written != Sha256Bytes) + return false; + + bytes = buffer[..Sha256Bytes].ToArray(); + return true; + } +} diff --git a/remex.core/Messages/MessageAudience.cs b/remex.core/Messages/MessageAudience.cs index 1607bb22..dff203d7 100644 --- a/remex.core/Messages/MessageAudience.cs +++ b/remex.core/Messages/MessageAudience.cs @@ -91,6 +91,7 @@ public static class MessageAudience [MessageTypes.FileSearchResponse] = ClientSurface.AndroidControl | ClientSurface.PcUi, [MessageTypes.FileManifestResponse] = ClientSurface.AndroidControl | ClientSurface.PcUi, [MessageTypes.FileThumbnailResponse] = ClientSurface.AndroidControl | ClientSurface.PcUi, + [MessageTypes.FileReadRangeResponse] = ClientSurface.AndroidControl | ClientSurface.PcUi, [MessageTypes.FileTransferChunk] = ClientSurface.AndroidControl | ClientSurface.PcUi, [MessageTypes.FileTransferComplete] = ClientSurface.AndroidControl, // Host -> client only since RemEx-cc30z, which made the host able to say "let go of this" @@ -103,7 +104,7 @@ public static class MessageAudience [MessageTypes.FileTransferReady] = ClientSurface.AndroidControl, [MessageTypes.FileTransferResult] = ClientSurface.AndroidControl, [MessageTypes.FileVolumesResponse] = ClientSurface.AndroidControl | ClientSurface.PcUi, - // The PC browsing a paired phone (RemEx-xt0af). PhoneFileRelay forwards these REQUESTS (the seven + // The PC browsing a paired phone (RemEx-xt0af). PhoneFileRelay forwards these REQUESTS (the nine // read-only ones, plus file_manage_request since RemEx-fgmne), // built by the PC's own File Transfer screen, down a paired phone's existing session; the // phone's file host answers them as it always has. The phone only — the PC's UI is the asker @@ -116,6 +117,10 @@ public static class MessageAudience [MessageTypes.FileManifestRequest] = ClientSurface.AndroidControl, [MessageTypes.FileMetadataRequest] = ClientSurface.AndroidControl, [MessageTypes.FileThumbnailRequest] = ClientSurface.AndroidControl, + // Live preview and "Verify against…" on a phone's files, from the PC (2026-10-08 redesign). Both are + // read-only and pass the same shared-folder gate on the phone as a browse does, re-checked per request. + [MessageTypes.FileReadRangeRequest] = ClientSurface.AndroidControl, + [MessageTypes.FileHashRequest] = ClientSurface.AndroidControl, // Rename / delete / move / copy / new folder on the phone, from the PC (RemEx-fgmne). Relayed only to a // phone whose own roots reply said the person allows it, and the phone checks that switch again. [MessageTypes.FileManageRequest] = ClientSurface.AndroidControl, diff --git a/remex.core/Messages/RemexMessage.cs b/remex.core/Messages/RemexMessage.cs index 9f90d8f7..f5e0fdfa 100644 --- a/remex.core/Messages/RemexMessage.cs +++ b/remex.core/Messages/RemexMessage.cs @@ -302,6 +302,12 @@ public sealed record RemexMessage [JsonPropertyName("fileThumbnailResponse")] public FileThumbnailResponse? FileThumbnailResponse { get; init; } + [JsonPropertyName("fileReadRangeRequest")] + public FileReadRangeRequest? FileReadRangeRequest { get; init; } + + [JsonPropertyName("fileReadRangeResponse")] + public FileReadRangeResponse? FileReadRangeResponse { get; init; } + [JsonPropertyName("fileConsentRequest")] public FileConsentRequest? FileConsentRequest { get; init; } @@ -660,6 +666,9 @@ public static class MessageTypes public const string FileMetadataResponse = "file_metadata_response"; public const string FileThumbnailRequest = "file_thumbnail_request"; public const string FileThumbnailResponse = "file_thumbnail_response"; + // Live preview (2026-10-08 redesign): a bounded byte range of one file. + public const string FileReadRangeRequest = "file_read_range_request"; + public const string FileReadRangeResponse = "file_read_range_response"; // Consent / push. public const string FileConsentRequest = "file_consent_request"; public const string FileConsentResponse = "file_consent_response"; diff --git a/remex.core/Models/FileTransferMessages.cs b/remex.core/Models/FileTransferMessages.cs index 00b505ad..4fc810dc 100644 --- a/remex.core/Models/FileTransferMessages.cs +++ b/remex.core/Models/FileTransferMessages.cs @@ -277,6 +277,18 @@ public sealed record FileCapabilities /// /// [JsonPropertyName("pcChanges")] public bool PcChanges { get; init; } + + /// + /// True when the host answers file_read_range_request (live preview, 2026-10-08 redesign). + /// Additive and default-false: a peer that never sends it gets the thumbnail-only fallback. + /// + [JsonPropertyName("readRange")] public bool ReadRange { get; init; } + + /// + /// True when the host answers file_hash_request. The PC always has; a phone does from the + /// 2026-10-08 redesign on. Default-false, so an older phone simply hides "Compute SHA-256". + /// + [JsonPropertyName("hash")] public bool Hash { get; init; } } // ── v3 transfer negotiation (replaces base64 start/chunk/end for v3 peers) ── @@ -601,6 +613,61 @@ public sealed record FileThumbnailResponse [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; } } +/// +/// Requester → host: read up to bytes of one file, for +/// live preview (2026-10-08 redesign). Capability-flagged by . +/// +/// +/// The PATH resolves exactly as a download's source does (same root, consent and restricted-path checks), so it +/// can name nothing a download could not. The SHARE MODE is deliberately wider: the PC opens the file sharing +/// read, write and delete so a log another program still has open can be tailed, which means a preview can read +/// a file that a download of it would fail on with a sharing violation. Answered on the control channel (base64), +/// which is why one read is capped at 1 MiB: the encoded reply stays well under MessageSerializer's 4 MB +/// message limit. +/// +public sealed record FileReadRangeRequest +{ + [JsonPropertyName("requestId")] public required string RequestId { get; init; } + [JsonPropertyName("rootId")] public required string RootId { get; init; } + [JsonPropertyName("relativePath")] public required string RelativePath { get; init; } + /// Bytes from the start of the file. Ignored when is true. + [JsonPropertyName("offset")] public long Offset { get; init; } + /// How many bytes to read: 1 to . + [JsonPropertyName("length")] public int Length { get; init; } + /// True to read the LAST bytes (tailing a growing log). + [JsonPropertyName("fromEnd")] public bool FromEnd { get; init; } +} + +/// Host → requester: the bytes read, or null data with an errorMessage. +public sealed record FileReadRangeResponse +{ + [JsonPropertyName("requestId")] public required string RequestId { get; init; } + /// Where the returned bytes actually start in the file. + [JsonPropertyName("offset")] public long Offset { get; init; } + /// The bytes, base64. Empty (not null) when the read started at or past the end. + [JsonPropertyName("dataBase64")] public string? DataBase64 { get; init; } + /// The file's size when it was read, so a tailing reader sees growth or truncation. + [JsonPropertyName("fileSize")] public long FileSize { get; init; } + /// Last-modified time as Unix milliseconds UTC. + [JsonPropertyName("modifiedUtc")] public long ModifiedUtc { get; init; } + /// True when the returned bytes reach the end of the file. + [JsonPropertyName("eof")] public bool Eof { get; init; } + [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; } +} + +/// +/// One range read, as the host's file service returns it. The handler wraps it into a +/// . Server-side only and intentionally NOT wire-serialized. +/// +public sealed record FileRangeRead +{ + public required long Offset { get; init; } + public required byte[] Data { get; init; } + public required long FileSize { get; init; } + public required long ModifiedUtc { get; init; } + public bool Eof => Offset + Data.Length >= FileSize; +} + // ── Consent / push ── /// Serving device → requester: a consent prompt for sensitive access. @@ -815,6 +882,19 @@ public static class FileTransferLimits public const int ThumbnailDefaultMaxDim = 128; /// Maximum encoded thumbnail size in bytes (≈96 KB). public const int ThumbnailMaxBytes = 96 * 1024; + /// + /// Most bytes one file_read_range_request may ask for (1 MiB). Base64 makes the reply ≈1.37 MB, + /// comfortably inside the 4 MB control-message limit on both ends. + /// + public const int ReadRangeMaxBytes = 1024 * 1024; + /// Text preview reads at most this much from the start of a file (2 MiB). + public const int PreviewTextMaxBytes = 2 * 1024 * 1024; + /// Live tail keeps the last this-many bytes of a growing text file (256 KiB). + public const int PreviewTextTailBytes = 256 * 1024; + /// Images larger than this are not previewed at full size (40 MiB). + public const long PreviewImageMaxBytes = 40L * 1024 * 1024; + /// How often live tail asks for new bytes while the preview is visible. + public const int PreviewTailPollMilliseconds = 2000; /// Raw payload bytes per binary data frame (plan §1.1). public const int DataPayloadBytes = 256 * 1024; /// The receiver acks committedOffset at least every this many bytes and on final. diff --git a/remex.core/Serialization/RemexJsonSerializerContext.cs b/remex.core/Serialization/RemexJsonSerializerContext.cs index 992aedcb..f96a3c1b 100644 --- a/remex.core/Serialization/RemexJsonSerializerContext.cs +++ b/remex.core/Serialization/RemexJsonSerializerContext.cs @@ -147,6 +147,8 @@ namespace Remex.Core.Serialization; [JsonSerializable(typeof(FileMetadataResponse))] [JsonSerializable(typeof(FileThumbnailRequest))] [JsonSerializable(typeof(FileThumbnailResponse))] +[JsonSerializable(typeof(FileReadRangeRequest))] +[JsonSerializable(typeof(FileReadRangeResponse))] [JsonSerializable(typeof(ClientCapabilities))] [JsonSerializable(typeof(FileConsentRequest))] [JsonSerializable(typeof(FileConsentResponse))] diff --git a/remex.core/Services/FileTransfer/IFileTransferService.cs b/remex.core/Services/FileTransfer/IFileTransferService.cs index 0bbd4e0b..cca6cbb8 100644 --- a/remex.core/Services/FileTransfer/IFileTransferService.cs +++ b/remex.core/Services/FileTransfer/IFileTransferService.cs @@ -126,4 +126,16 @@ public interface IFileTransferService /// (SkiaSharp) — this interface stays NativeAOT-safe by returning only a string. /// Task GetThumbnailBase64Async(string rootId, string relativePath, int maxDim, CancellationToken ct); + + /// + /// Reads up to bytes of one file for live preview: from + /// , or the last bytes when + /// is true. Resolved exactly as a download's source is. Opens the file sharing read, write and delete, + /// so a log another program is still writing can be tailed. A directory throws; an offset at or past + /// the end returns no bytes rather than an error. + /// + Task ReadRangeAsync(string rootId, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct); + + /// Volume-mode counterpart of . See . + Task ReadVolumeRangeAsync(string volumeAbsolutePath, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct); } diff --git a/remex.core/Validation/FileReadRangeValidation.cs b/remex.core/Validation/FileReadRangeValidation.cs new file mode 100644 index 00000000..5e69554b --- /dev/null +++ b/remex.core/Validation/FileReadRangeValidation.cs @@ -0,0 +1,65 @@ +using Remex.Core.Models; + +namespace Remex.Core.Validation; + +/// +/// Validates a before a host reads anything (2026-10-08 redesign). +/// +/// +/// The path itself is checked where every other file request's is: the PC resolves it with +/// , and the PC's relay checks it with +/// before it goes down to a phone. This covers +/// what is new about a range read, which is the numbers. +/// +public static class FileReadRangeValidation +{ + /// + /// True when names a root, a non-empty path, a non-negative offset and a + /// length from 1 to . Otherwise false, with a plain + /// . + /// + public static bool IsValid(FileReadRangeRequest? request, out string? error) + { + error = null; + if (request is null) + { + error = "The read request carried no body."; + return false; + } + + if (string.IsNullOrWhiteSpace(request.RootId)) + { + error = "No shared folder was named."; + return false; + } + + if (string.IsNullOrWhiteSpace(request.RelativePath?.Trim('/'))) + { + error = "No file was named."; + return false; + } + + if (request.Offset < 0) + { + error = "The read offset can't be negative."; + return false; + } + + if (request.Length is < 1 or > FileTransferLimits.ReadRangeMaxBytes) + { + error = $"A read must be between 1 and {FileTransferLimits.ReadRangeMaxBytes} bytes."; + return false; + } + + return true; + } + + /// + /// Where a read of bytes starts in a file of bytes: + /// itself, or, when is true, the last + /// bytes (the whole file when it is shorter). Shared by both hosts' callers so + /// a tail means the same thing on the PC and the phone. + /// + public static long StartOffset(long offset, int length, bool fromEnd, long fileSize) => + fromEnd ? Math.Max(0, fileSize - length) : offset; +} diff --git a/remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs b/remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs new file mode 100644 index 00000000..d199ad31 --- /dev/null +++ b/remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Remex.Core.Messages; +using Remex.Core.Models; +using Remex.Desktop.Services.FileTransfer; +using Xunit; + +namespace Remex.Desktop.Tests.Services.FileTransfer; + +/// +/// , the PC's side of live preview (2026-10-08 redesign): +/// what goes on the wire, which reply it takes, and that a host's refusal reaches the screen as the host's words. +/// +public class ReadRangeClientTests +{ + private sealed class ScriptedConnection : IFileTransferConnection + { + private readonly Func _reply; + + public ScriptedConnection(Func reply) => _reply = reply; + + public List Sent { get; } = []; + + public event Action? FileTransferMessageReceived; + + public Task SendAsync(RemexMessage message) + { + Sent.Add(message); + if (_reply(message) is { } answer) + FileTransferMessageReceived?.Invoke(answer); + return Task.CompletedTask; + } + } + + private static RemexMessage Answer(string requestId, string? data = null, string? error = null) => new() + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = requestId, Offset = 6, DataBase64 = data, FileSize = 10, Eof = true, ErrorMessage = error, + }, + }; + + [Fact] + public async Task ARead_SendsTheRangeAsked_AndReturnsTheMatchingReply() + { + var connection = new ScriptedConnection(m => + Answer(m.FileReadRangeRequest!.RequestId, data: Convert.ToBase64String("6789"u8.ToArray()))); + using var client = new FileTransferClient(connection); + + var read = await client.ReadRangeRemoteAsync("root", "Logs/app.log", 0, 4, fromEnd: true, CancellationToken.None); + + var sent = connection.Sent.Should().ContainSingle().Subject; + sent.Type.Should().Be(MessageTypes.FileReadRangeRequest); + sent.FileReadRangeRequest!.RootId.Should().Be("root"); + sent.FileReadRangeRequest.RelativePath.Should().Be("Logs/app.log"); + sent.FileReadRangeRequest.Length.Should().Be(4); + sent.FileReadRangeRequest.FromEnd.Should().BeTrue(); + read.Offset.Should().Be(6); + Convert.FromBase64String(read.DataBase64!).Should().Equal("6789"u8.ToArray()); + read.Eof.Should().BeTrue(); + } + + [Fact] + public async Task AReplyToADifferentRequest_IsNotTakenForThisOne() + { + // The host answers, but with someone else's request id. A client that matched on type alone would + // hand this preview another file's bytes; it must keep waiting instead. + var connection = new ScriptedConnection(_ => Answer("someone-elses-request", data: "AAAA")); + using var client = new FileTransferClient(connection); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + var act = async () => await client.ReadRangeRemoteAsync("root", "a.txt", 0, 4, fromEnd: false, cts.Token); + + await act.Should().ThrowAsync( + "a reply carrying another request's id must never complete this read"); + } + + [Fact] + public async Task AHostRefusal_ArrivesAsTheHostsOwnWords() + { + const string PhoneSaid = "That file isn't in a folder this phone shares, or it can't be read."; + var connection = new ScriptedConnection(m => Answer(m.FileReadRangeRequest!.RequestId, error: PhoneSaid)); + using var client = new FileTransferClient(connection); + + var act = async () => await client.ReadRangeRemoteAsync("root", "a.txt", 0, 4, fromEnd: false, CancellationToken.None); + + (await act.Should().ThrowAsync()).Which.HostMessage.Should().Be(PhoneSaid); + } +} diff --git a/remex.desktop/Services/FileTransfer/FileTransferClient.cs b/remex.desktop/Services/FileTransfer/FileTransferClient.cs index 5d611805..53e4684d 100644 --- a/remex.desktop/Services/FileTransfer/FileTransferClient.cs +++ b/remex.desktop/Services/FileTransfer/FileTransferClient.cs @@ -91,6 +91,7 @@ public sealed class FileTransferClient : IDisposable private readonly ConcurrentDictionary> _manifestWaiters = new(); private readonly ConcurrentDictionary> _metadataWaiters = new(); private readonly ConcurrentDictionary> _thumbnailWaiters = new(); + private readonly ConcurrentDictionary> _readRangeWaiters = new(); // ── Uploads over the binary /ws/files channel (perf audit P1-5) ── private readonly IFileChannelConnector? _fileChannelConnector; @@ -373,6 +374,14 @@ public async Task RenameRemoteAsync(string rootId, string relativePath, string n /// private const int HashRequestTimeoutSeconds = 30; + /// + /// How long to wait for a PHONE to hash a file (2026-10-08 redesign): the same allowance the PC's relay gives + /// it (PhoneFileRelay.HashTimeout). A phone hashes the whole file before it answers, which for a long + /// video outlasts ; waiting less than the relay does would fail a + /// healthy hash on screen and then throw away the phone's real answer when it arrived. + /// + public static readonly TimeSpan PhoneHashTimeout = TimeSpan.FromMinutes(30); + /// /// Bounds for awaiting a peer reply, in seconds, split by operation class rather than /// shared. A single value would be wrong at both ends: too short turns a legitimate @@ -394,7 +403,11 @@ public async Task RenameRemoteAsync(string rootId, string relativePath, string n /// private const int ManifestRequestTimeoutSeconds = 180; - public async Task VerifyRemoteHashAsync(string rootId, string relativePath, CancellationToken ct) + /// + /// How long to wait for the answer. Defaults to ; pass + /// when the host is a phone. + /// + public async Task VerifyRemoteHashAsync(string rootId, string relativePath, CancellationToken ct, TimeSpan? timeout = null) { var requestId = Guid.NewGuid().ToString("N"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -414,7 +427,7 @@ public async Task VerifyRemoteHashAsync(string rootId, string relativePa }, tcs, () => _hashWaiters.TryRemove(requestId, out _), - TimeSpan.FromSeconds(HashRequestTimeoutSeconds), + timeout ?? TimeSpan.FromSeconds(HashRequestTimeoutSeconds), ct); if (response.FileHashResponse?.ErrorMessage is string err) @@ -725,6 +738,43 @@ public async Task GetMetadataRemoteAsync(string rootId, st return response.FileThumbnailResponse?.JpegBase64; } + /// + /// Reads up to of one file for live preview (2026-10-08 + /// redesign): from , or the last bytes when + /// is true. Callers gate on ; a host that + /// predates it never answers and this times out like any control request. + /// + /// The host refused or could not read the file; its reason is shown as-is. + public async Task ReadRangeRemoteAsync( + string rootId, string relativePath, long offset, int length, bool fromEnd, CancellationToken ct) + { + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _readRangeWaiters[requestId] = tcs; + using var reg = ct.Register(() => tcs.TrySetCanceled(ct)); + + var response = await SendAndAwaitReplyAsync( + new RemexMessage + { + Type = MessageTypes.FileReadRangeRequest, + FileReadRangeRequest = new FileReadRangeRequest + { + RequestId = requestId, RootId = rootId, RelativePath = relativePath, + Offset = offset, Length = length, FromEnd = fromEnd, + } + }, + tcs, + () => _readRangeWaiters.TryRemove(requestId, out _), + TimeSpan.FromSeconds(ControlRequestTimeoutSeconds), + ct); + + var read = response.FileReadRangeResponse + ?? throw new IOException("No read response received."); + if (read.ErrorMessage is string err && !string.IsNullOrWhiteSpace(err)) + throw FileTransferHostException.ForHostError(err, $"Read error: {err}"); + return read; + } + /// Enumerates the host's mounted volumes/drives once full-browse consent is granted (plan §1.2). public async Task<(IReadOnlyList Volumes, bool FullBrowseGranted, string? DenyReason)> ListVolumesAsync(CancellationToken ct) @@ -1708,6 +1758,11 @@ private void OnFileTransferMessage(RemexMessage message) if (_thumbnailWaiters.TryGetValue(thumbnail.RequestId, out var thumbnailTcs)) thumbnailTcs.TrySetResult(message); break; + + case MessageTypes.FileReadRangeResponse when message.FileReadRangeResponse is { } readRange: + if (_readRangeWaiters.TryGetValue(readRange.RequestId, out var readRangeTcs)) + readRangeTcs.TrySetResult(message); + break; } } From 41b253fc20d79519168896c4e3bda0f184a7cf8c Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:46:13 +0000 Subject: [PATCH 04/10] feat(files): "Verified" transfers and the PC preview pipeline (redesign phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A transfer that really compared SHA-256 hashes now finishes "Verified" and keeps the hash (hex for display); one whose host sent no hash stays "Done". Rows say "Verifying…" while the ends compare. Every path reports it: v3/v2 uploads, downloads (only when a hash was compared), and both PC-started phone transfers through PhoneFileRelay. Delivered through a small ITransferVerificationSink on the IProgress the queue already passes, so no work delegate changed. New string in all 9 locales. - remex.desktop/Services/FilePreview: preview classifier, text decoder (BOMs, binary sniff, no half characters at a read's edge), per-line syntax colouring (logs incl. .NET fail:/warn:, JSON, XML, code), a full-size image loader in 1 MiB reads refusing >40 MiB after one read, and a live TextTail that reads only what was added, restarts on rotation and bounds an endless line. Bound into the screen in phase 3. Defect-injected and seen red: download only-when-compared, IsVerified needs a hash, tail carry bound, image size limit, UTF-8 cut handling. Gate (verify.ps1 -Scope dotnet, Linux): core, desktop (3903) and render suites pass; the receipt says FAIL only for the same 10 remex.agent.tests failures that fail identically on the pre-change base in this container (headless capture x7, root-only revoker injection, HostIdentityUnification path case). Android untouched since phase 1's passing Android run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- docs/CHANGELOG.md | 10 + .../Services/FileTransfer/PhoneFileRelay.cs | 4 + .../Services/FilePreview/FilePreviewTests.cs | 325 ++++++++++++++++++ .../DownloadVerifiedReportTests.cs | 88 +++++ .../FileTransferQueueVerifiedTests.cs | 98 ++++++ remex.desktop/Localization/Strings.es.resx | 3 + remex.desktop/Localization/Strings.fr.resx | 3 + remex.desktop/Localization/Strings.hi.resx | 3 + remex.desktop/Localization/Strings.id.resx | 3 + remex.desktop/Localization/Strings.pl.resx | 3 + remex.desktop/Localization/Strings.pt-BR.resx | 3 + remex.desktop/Localization/Strings.resx | 3 + remex.desktop/Localization/Strings.tr.resx | 3 + remex.desktop/Localization/Strings.uk.resx | 3 + .../Services/FilePreview/PreviewKind.cs | 68 ++++ .../Services/FilePreview/PreviewLoaders.cs | 179 ++++++++++ .../Services/FilePreview/SyntaxTokenizer.cs | 242 +++++++++++++ .../FilePreview/TextPreviewDecoder.cs | 88 +++++ .../FileTransfer/FileTransferClient.cs | 11 + .../FileTransfer/TransferVerification.cs | 45 +++ remex.desktop/ViewModels/FileTransferQueue.cs | 43 ++- 21 files changed, 1224 insertions(+), 4 deletions(-) create mode 100644 remex.desktop.tests/Services/FilePreview/FilePreviewTests.cs create mode 100644 remex.desktop.tests/Services/FileTransfer/DownloadVerifiedReportTests.cs create mode 100644 remex.desktop.tests/ViewModels/FileTransferQueueVerifiedTests.cs create mode 100644 remex.desktop/Services/FilePreview/PreviewKind.cs create mode 100644 remex.desktop/Services/FilePreview/PreviewLoaders.cs create mode 100644 remex.desktop/Services/FilePreview/SyntaxTokenizer.cs create mode 100644 remex.desktop/Services/FilePreview/TextPreviewDecoder.cs create mode 100644 remex.desktop/Services/FileTransfer/TransferVerification.cs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index be0838cd..b4239058 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,11 @@ Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250- instead of opening Google Play in a browser on the PC. Point your phone's camera at it to go straight to RemEx on Google Play. If you'd rather use the PC, **Open in browser instead** under the code still opens the listing there. (RemEx-2p7um) +- **Transfers on the PC now say "Verified".** RemEx has always checked every file it moves with a SHA-256 + fingerprint, but a finished transfer only ever said **Done**. Now, when the check really ran and matched, the + transfer says **Verified** and keeps the fingerprint, so the new File Transfer screen can show and copy it. A + transfer whose other end sent no fingerprint to compare still says **Done**: nothing was checked, so nothing is + claimed. The row also says **Verifying…** while the two ends compare. ### Internal @@ -32,6 +37,11 @@ Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250- - A hash is shown as lowercase hex everywhere (the format `sha256sum` and `Get-FileHash` print), and a pasted hash is accepted in hex or Base64. The C# and Kotlin versions share one set of test vectors. See `docs/specs/2026-10-08-file-browser-redesign.md` and `docs/API_CONTRACTS.md` §3. +- **The preview pipeline for the new File Transfer screen** (`remex.desktop/Services/FilePreview/`): which files + get an image, text or no preview; text decoding that honours byte-order marks and never shows half a character + cut by a read; light per-line colouring for logs (including the .NET logger's `fail:`/`warn:`), JSON, XML and + code; a full-size image fetch in 1 MiB reads that refuses a file over 40 MiB after one read; and a live tail + that reads only what was added, restarts when a log rotates, and keeps an endless line bounded. - **`scripts/verify.ps1` runs on Linux again.** It died at its first step on every Linux run: PowerShell 7.6 adds its own newline to text piped into `git check-ignore`, so the script's extra one (needed on Windows, where PowerShell adds CRLF) became an empty line that git refuses. The extra newline is now added on Windows diff --git a/remex.agent/Services/FileTransfer/PhoneFileRelay.cs b/remex.agent/Services/FileTransfer/PhoneFileRelay.cs index af078273..1f1af873 100644 --- a/remex.agent/Services/FileTransfer/PhoneFileRelay.cs +++ b/remex.agent/Services/FileTransfer/PhoneFileRelay.cs @@ -226,6 +226,8 @@ public async Task DownloadAsync( var result = await _transfers.PullFileAsync( clientId, rootId, remoteRelativePath, localPath, controlWs, ToByteProgress(progress), ct); ThrowIfFailed(clientId, result); + // This PC hashed what landed and it matched the phone's: that is the hash the row shows. + progress.ReportVerified(result.Sha256Base64); } /// @@ -247,6 +249,8 @@ public async Task UploadAsync( var result = await _transfers.UploadFileToPeerAsync( clientId, localPath, rootId, remoteDirectory, controlWs, ToByteProgress(progress, total), ct); ThrowIfFailed(clientId, result); + // The phone hashed what landed and said it matched; it echoes that hash with its verdict. + progress.ReportVerified(result.Sha256Base64); } private System.Net.WebSockets.WebSocket ControlSocketOrThrow(string clientId) diff --git a/remex.desktop.tests/Services/FilePreview/FilePreviewTests.cs b/remex.desktop.tests/Services/FilePreview/FilePreviewTests.cs new file mode 100644 index 00000000..91a8d61c --- /dev/null +++ b/remex.desktop.tests/Services/FilePreview/FilePreviewTests.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Remex.Core.Models; +using Remex.Desktop.Services.FilePreview; +using Xunit; + +namespace Remex.Desktop.Tests.Services.FilePreview; + +/// +/// The preview pipeline under the new File Transfer screen (2026-10-08 redesign): which files get which preview, +/// how bytes become text, how lines are coloured, and how images and live tails are fetched in range reads. +/// +public class FilePreviewTests +{ + /// An in-memory file a range reader serves, which a test can grow, shrink or replace. + private sealed class FakeFile + { + public byte[] Bytes = []; + public List<(long Offset, int Length, bool FromEnd)> Reads { get; } = []; + public int? StallAfterReads; + + public Task Read(long offset, int length, bool fromEnd, CancellationToken ct) + { + Reads.Add((offset, length, fromEnd)); + var size = Bytes.LongLength; + var start = fromEnd ? Math.Max(0, size - length) : offset; + var count = (int)Math.Clamp(size - start, 0, length); + if (StallAfterReads is { } n && Reads.Count > n) count = 0; // a host that stops sending + var data = count > 0 ? Bytes[(int)start..(int)(start + count)] : []; + return Task.FromResult(new FileReadRangeResponse + { + RequestId = "r", Offset = start, DataBase64 = Convert.ToBase64String(data), + FileSize = size, Eof = count > 0 ? start + count >= size : start >= size && StallAfterReads is null, + }); + } + } + + // ── Classifier ── + + [Theory] + [InlineData("photo.JPG", PreviewKind.Image)] + [InlineData("shot.webp", PreviewKind.Image)] + [InlineData("IMG_0001.HEIC", PreviewKind.ImageThumbnailOnly)] + [InlineData("app.log", PreviewKind.Text)] + [InlineData("settings.json", PreviewKind.Text)] + [InlineData("Program.cs", PreviewKind.Text)] + [InlineData("movie.mp4", PreviewKind.None)] + [InlineData("setup.exe", PreviewKind.None)] + [InlineData("README", PreviewKind.Sniff)] + [InlineData("data.unknownext", PreviewKind.Sniff)] + public void AFileGetsThePreviewItsNameCallsFor(string name, PreviewKind expected) + { + PreviewClassifier.Classify(name, isDirectory: false).Should().Be(expected); + } + + [Fact] + public void AFolderIsNeverPreviewed_WhateverItIsCalled() + { + PreviewClassifier.Classify("photos.jpg", isDirectory: true).Should().Be(PreviewKind.None); + } + + // ── Decoder ── + + [Fact] + public void ANulInTheFirstBytes_LooksBinary_ButUtf16TextDoesNot() + { + TextPreviewDecoder.LooksBinary("hello\0world"u8).Should().BeTrue(); + TextPreviewDecoder.LooksBinary("plain text"u8).Should().BeFalse(); + TextPreviewDecoder.LooksBinary([0xFF, 0xFE, (byte)'h', 0, (byte)'i', 0]).Should().BeFalse( + "UTF-16 has NUL bytes everywhere, and its BOM says so"); + } + + [Fact] + public void ByteOrderMarks_AreHonouredAndNotShown() + { + TextPreviewDecoder.Decode([0xEF, 0xBB, 0xBF, (byte)'h', (byte)'i']).Should().Be("hi"); + TextPreviewDecoder.Decode([0xFF, 0xFE, (byte)'h', 0, (byte)'i', 0]).Should().Be("hi"); + TextPreviewDecoder.Decode([0xFE, 0xFF, 0, (byte)'h', 0, (byte)'i']).Should().Be("hi"); + } + + [Fact] + public void ACharacterCutByTheEndOfARead_IsDropped_NotShownAsAReplacementMark() + { + var euro = "a€"u8.ToArray(); // € is three bytes + var cut = euro[..^1]; + TextPreviewDecoder.Decode(cut, endsMidFile: true).Should().Be("a"); + TextPreviewDecoder.Decode(cut, endsMidFile: false).Should().Be("a�", + "at the real end of a file a broken character is broken, and saying so is honest"); + } + + [Theory] + [InlineData(new byte[] { 0x61 }, 1)] + [InlineData(new byte[] { 0x61, 0xC3 }, 1)] // 2-byte lead, cut + [InlineData(new byte[] { 0x61, 0xC3, 0xA9 }, 3)] // é, whole + [InlineData(new byte[] { 0x61, 0xE2, 0x82 }, 1)] // € cut after two + [InlineData(new byte[] { 0xF0, 0x9F, 0x98 }, 0)] // 4-byte emoji cut after three + [InlineData(new byte[] { 0xF0, 0x9F, 0x98, 0x80 }, 4)] // whole emoji + public void CompleteUtf8Length_TrimsOnlyAnUnfinishedSequence(byte[] bytes, int expected) + { + TextPreviewDecoder.CompleteUtf8Length(bytes).Should().Be(expected); + } + + [Fact] + public void ATailThatStartsMidFile_DropsItsFirstPartialLine() + { + TextPreviewDecoder.Decode("ial line\nwhole line\n"u8, startsMidFile: true).Should().Be("whole line\n"); + } + + // ── Tokenizer ── + + [Theory] + [InlineData("2026-10-08 14:03:22.123 ERROR Something broke", SyntaxKind.LogError)] + [InlineData("[14:03:22] WARN low disk", SyntaxKind.LogWarning)] + [InlineData("fail: Remex.Agent.Handlers[0] boom", SyntaxKind.LogError)] + [InlineData("dbug: Remex.Agent[0] noise", SyntaxKind.LogDebug)] + [InlineData("All good, NoErrors and failover handled", SyntaxKind.Plain)] + public void ALogLine_IsColouredByItsLevelWord_AndOnlyByAWholeOne(string line, SyntaxKind expected) + { + SyntaxTokenizer.LevelOf(line).Should().Be(expected); + } + + [Fact] + public void ALeadingTimestamp_IsColouredSeparately_AndNotMistakenForOne() + { + var spans = SyntaxTokenizer.Tokenize("2026-10-08 14:03:22.123 INFO started", SyntaxLanguage.Log); + spans.Should().ContainSingle(s => s.Kind == SyntaxKind.Timestamp && s.Start == 0 && s.Length == 23); + SyntaxTokenizer.LeadingTimestampLength("12 apples").Should().Be(0); + SyntaxTokenizer.LeadingTimestampLength("1. Introduction").Should().Be(0); + } + + [Fact] + public void Json_KeysStringsNumbersAndLiterals_AreTold_Apart() + { + const string line = " \"name\": \"Pixel 9\", \"port\": 5005, \"tls\": true"; + var spans = SyntaxTokenizer.Tokenize(line, SyntaxLanguage.Json); + string Text(SyntaxSpan s) => line.Substring(s.Start, s.Length); + spans.Where(s => s.Kind == SyntaxKind.Key).Select(Text).Should().Equal("\"name\"", "\"port\"", "\"tls\""); + spans.Where(s => s.Kind == SyntaxKind.String).Select(Text).Should().Equal("\"Pixel 9\""); + spans.Where(s => s.Kind == SyntaxKind.Number).Select(Text).Should().Equal("5005"); + spans.Where(s => s.Kind == SyntaxKind.Keyword).Select(Text).Should().Equal("true"); + } + + [Fact] + public void Code_ACommentMarkerInsideAString_IsNotAComment() + { + const string line = "var url = \"http://example\"; // real comment"; + var spans = SyntaxTokenizer.Tokenize(line, SyntaxLanguage.CLike); + spans.Single(s => s.Kind == SyntaxKind.String).Start.Should().Be(10); + line[spans.Single(s => s.Kind == SyntaxKind.Comment).Start..].Should().Be("// real comment"); + } + + [Fact] + public void Xml_TagsAttributesAndComments_AreColoured() + { + const string line = " - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + - - - - - + - - - - + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + diff --git a/remex.desktop/Views/FileTransferView.axaml.cs b/remex.desktop/Views/FileTransferView.axaml.cs index 7dac0a0b..8f630d69 100644 --- a/remex.desktop/Views/FileTransferView.axaml.cs +++ b/remex.desktop/Views/FileTransferView.axaml.cs @@ -1,17 +1,45 @@ +using System.Collections.Specialized; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; +// Avalonia 12 moved SetTextAsync off IClipboard onto ClipboardExtensions in this namespace (RemEx-jcma3). +using Avalonia.Input.Platform; +using Avalonia.Interactivity; using Avalonia.Platform.Storage; -using Remex.Desktop.ViewModels; +using Avalonia.VisualTree; +using Material.Icons; +using Material.Icons.Avalonia; using Remex.Core.Models; +using Remex.Desktop.Services; +using Remex.Desktop.ViewModels; namespace Remex.Desktop.Views; +/// +/// The File Transfer screen's view (2026-10-08 redesign). Everything here is input plumbing the view model cannot +/// do itself: selection from both views, keyboard shortcuts, right-click and More menus, dragging rows out (to the +/// tree, or to File Explorer for This PC's files) and dropping onto the tree, and the clipboard. +/// public partial class FileTransferView : UserControl { + /// Rows dragged out of the list. In-process only: another app sees the plain files, never this. + private static readonly DataFormat EntriesFormat = + DataFormat.CreateInProcessFormat("remex-file-entries"); + + /// How far the pointer must travel before a press on a row becomes a drag, so clicks stay clicks. + private const double DragThreshold = 6; + + private Point? _pressPoint; + private ListBox? _pressList; + // Avalonia starts a drag from the PRESS that began it, so it is kept until the pointer has moved far enough. + private PointerPressedEventArgs? _pressArgs; + private FileTransferViewModel? _boundVm; + public FileTransferView() { InitializeComponent(); RemoteFileList.SelectionChanged += OnRemoteFileListSelectionChanged; + RemoteIconGrid.SelectionChanged += OnRemoteFileListSelectionChanged; DataContextChanged += (_, _) => ConfigureViewModel(); // Double-click opens a folder, in both the details list and the icon grid (live-check C5). @@ -22,15 +50,36 @@ public FileTransferView() RemoteFileList.AddHandler(DoubleTappedEvent, OnRemoteEntryDoubleTapped, handledEventsToo: true); RemoteIconGrid.AddHandler(DoubleTappedEvent, OnRemoteEntryDoubleTapped, handledEventsToo: true); + // Dragging rows out: the same reason for handledEventsToo as above. + foreach (var list in new[] { RemoteFileList, RemoteIconGrid }) + { + list.AddHandler(PointerPressedEvent, OnListPointerPressed, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, handledEventsToo: true); + list.AddHandler(PointerMovedEvent, OnListPointerMoved, handledEventsToo: true); + list.AddHandler(PointerReleasedEvent, (_, _) => _pressPoint = null, handledEventsToo: true); + list.ContextMenu = new ContextMenu(); + list.ContextMenu.Opening += (sender, _) => FillMenu(((ContextMenu)sender!).Items, forMoreButton: false); + } + + if (MoreButton.Flyout is MenuFlyout more) + more.Opening += (_, _) => FillMenu(more.Items, forMoreButton: true); + // Accept files dragged from the OS file manager and enqueue them as uploads (plan §WP10). AddHandler(DragDrop.DragOverEvent, OnDragOver); AddHandler(DragDrop.DropEvent, OnDrop); + + // Rows and OS files dropped onto a folder in the tree. + FolderTree.AddHandler(DragDrop.DragOverEvent, OnTreeDragOver); + FolderTree.AddHandler(DragDrop.DropEvent, OnTreeDrop); + + AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble); } private void OnRemoteFileListSelectionChanged(object? sender, SelectionChangedEventArgs e) { - if (DataContext is not FileTransferViewModel vm) return; - var selected = RemoteFileList.SelectedItems?.OfType() ?? []; + if (DataContext is not FileTransferViewModel vm || sender is not ListBox list) return; + // BOTH views forward their selection now. Before the redesign only the details list did, so a + // multi-selection made in the icon grid never reached Copy, Cut or Delete. + var selected = list.SelectedItems?.OfType() ?? []; vm.SetSelectedEntries(selected); } @@ -47,15 +96,25 @@ private void OnRemoteEntryDoubleTapped(object? sender, TappedEventArgs e) } } - private void OnSearchResultTapped(object? sender, Avalonia.Input.TappedEventArgs e) + private void OnSearchResultTapped(object? sender, TappedEventArgs e) { if (DataContext is not FileTransferViewModel vm) return; if (sender is Control { DataContext: FileSearchEntry entry }) vm.OpenSearchResultCommand.Execute(entry); } + // ── OS files dropped onto the list ── + private void OnDragOver(object? sender, DragEventArgs e) { + // Our own rows dragged back onto the list are not an upload of themselves. + if (e.DataTransfer.Contains(EntriesFormat)) + { + e.DragEffects = DragDropEffects.None; + e.Handled = true; + return; + } + // Only allow the drop when files are present and a writable folder is in view. var canDrop = e.DataTransfer.Contains(DataFormat.File) && DataContext is FileTransferViewModel { SelectedRemoteRoot.IsWritable: true }; @@ -65,37 +124,250 @@ private void OnDragOver(object? sender, DragEventArgs e) private void OnDrop(object? sender, DragEventArgs e) { - if (DataContext is not FileTransferViewModel vm) + if (DataContext is not FileTransferViewModel vm || e.DataTransfer.Contains(EntriesFormat)) return; - var files = e.DataTransfer.TryGetFiles(); - if (files is null) - return; + var localPaths = LocalPathsIn(e); + if (localPaths.Count > 0) + vm.EnqueueUploads(localPaths, FileTransferQueueKind.Upload); + + e.Handled = true; + } - var localPaths = files + private static List LocalPathsIn(DragEventArgs e) => + (e.DataTransfer.TryGetFiles() ?? []) .OfType() .Select(f => f.TryGetLocalPath()) .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p!) .ToList(); - if (localPaths.Count > 0) - vm.EnqueueUploads(localPaths, FileTransferQueueKind.Upload); + // ── Dragging rows out ── + private void OnListPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is ListBox list && e.GetCurrentPoint(list).Properties.IsLeftButtonPressed + && (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) is not null) + { + _pressPoint = e.GetPosition(list); + _pressList = list; + _pressArgs = e; + } + } + + private async void OnListPointerMoved(object? sender, PointerEventArgs e) + { + if (_pressPoint is not { } start || _pressList is not { } list || _pressArgs is not { } press || !ReferenceEquals(sender, list) + || DataContext is not FileTransferViewModel vm || vm.SelectedRemoteRoot is not { } root) + return; + var now = e.GetPosition(list); + if (Math.Abs(now.X - start.X) < DragThreshold && Math.Abs(now.Y - start.Y) < DragThreshold) + return; + _pressPoint = null; + + var entries = (list.SelectedItems?.OfType() ?? []).Where(x => x.Name != "..").ToList(); + if (entries.Count == 0) + return; + + var source = vm.SelectedSource ?? FileSourceOption.ThisPc; + var folder = FileTreeViewModel.Normalize(vm.RemotePath); + var data = new DataTransfer(); + data.Add(DataTransferItem.Create(EntriesFormat, new FileDragPayload(source, root.RootId, folder, entries))); + + // This PC's files are real files on this disk, so they can also be dropped into File Explorer. A phone's + // cannot: Avalonia has no deferred (virtual-file) drag source, so phone files use Download to… instead. + if (source.IsThisPc && TopLevel.GetTopLevel(this)?.StorageProvider is { } storage) + { + foreach (var entry in entries.Where(x => !x.IsDirectory)) + { + if (vm.TryLocalPath(root.RootId, FileTreeViewModel.Combine(folder, entry.Name)) is { } path + && await storage.TryGetFileFromPathAsync(path) is { } file) + { + data.Add(DataTransferItem.CreateFile(file)); + } + } + } + + _pressArgs = null; + await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Copy | DragDropEffects.Move); + } + + // ── Drops onto the tree ── + + private static FileTreeNode? NodeUnder(DragEventArgs e) => + (e.Source as Visual)?.FindAncestorOfType(includeSelf: true)?.DataContext as FileTreeNode; + + private void OnTreeDragOver(object? sender, DragEventArgs e) + { + var node = NodeUnder(e); + var ok = node is { IsDevice: false, IsPlaceholder: false }; + if (ok && e.DataTransfer.TryGetValue(EntriesFormat) is { } payload) + { + // Same device: a move, or a copy with Ctrl. Another device: always a copy. + e.DragEffects = payload.Source.SameSourceAs(node!.Source) && !e.KeyModifiers.HasFlag(KeyModifiers.Control) + ? DragDropEffects.Move + : DragDropEffects.Copy; + } + else + { + e.DragEffects = ok && e.DataTransfer.Contains(DataFormat.File) && node!.IsWritable + ? DragDropEffects.Copy + : DragDropEffects.None; + } e.Handled = true; } - private void ConfigureViewModel() + private async void OnTreeDrop(object? sender, DragEventArgs e) { + if (DataContext is not FileTransferViewModel vm || NodeUnder(e) is not { } node) + return; + e.Handled = true; + if (e.DataTransfer.TryGetValue(EntriesFormat) is { } payload) + await vm.DropOntoNodeAsync(payload, node, copy: e.KeyModifiers.HasFlag(KeyModifiers.Control)); + else if (LocalPathsIn(e) is { Count: > 0 } paths) + await vm.DropLocalFilesOntoNodeAsync(paths, node); + } + + // ── Keyboard ── + + /// + /// Explorer's shortcuts. Ignored while typing in a box, so Space, Delete and Backspace type rather than act. + /// Escape still leaves an inline rename or new-folder box. + /// + private void OnKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not FileTransferViewModel vm) + return; + + if (e.Key == Key.Escape) + { + if (vm.IsRenaming) { vm.CancelRenameCommand.Execute(null); e.Handled = true; } + else if (vm.IsCreatingFolder) { vm.CancelNewFolderCommand.Execute(null); e.Handled = true; } + return; + } + + if (e.Source is TextBox) + return; + + var ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control); + var alt = e.KeyModifiers.HasFlag(KeyModifiers.Alt); + bool Run(System.Windows.Input.ICommand command) + { + if (!command.CanExecute(null)) return false; + command.Execute(null); + return true; + } + + e.Handled = e.Key switch + { + Key.Space when !ctrl && !alt => Run(vm.TogglePreviewPaneCommand), + Key.Enter when vm.SelectedRemoteEntry is { IsDirectory: true } => Run(vm.OpenSelectedRemoteCommand), + Key.Enter => Run(vm.TogglePreviewPaneCommand), + Key.Back => Run(vm.NavigateRemoteUpCommand), + Key.Up when alt => Run(vm.NavigateRemoteUpCommand), + Key.F2 => Run(vm.StartRenameCommand), + Key.Delete => Run(vm.DeleteRemoteCommand), + Key.F5 => Run(vm.BrowseRemoteCommand), + Key.C when ctrl => Run(vm.CopySelectionCommand), + Key.X when ctrl => Run(vm.CutSelectionCommand), + Key.V when ctrl => Run(vm.PasteCommand), + Key.A when ctrl => Run(vm.SelectAllCommand), + Key.F when ctrl => SearchBox.IsVisible && SearchBox.Focus(), + _ => false, + }; + } + + // ── Menus ── + + /// + /// Builds the right-click and More menus fresh on every opening, from what the selection and device allow + /// right now: items that cannot apply are left out, not greyed, and every label follows the current language. + /// + private void FillMenu(ItemCollection items, bool forMoreButton) + { + items.Clear(); if (DataContext is not FileTransferViewModel vm) return; + var loc = LocalizationService.Instance; + + void Add(string key, System.Windows.Input.ICommand command, MaterialIconKind icon, bool show = true, object? parameter = null) + { + if (!show) return; + items.Add(new MenuItem + { + Header = loc[key], + Command = command, + CommandParameter = parameter, + Icon = new MaterialIcon { Kind = icon }, + }); + } + + var hasSelection = vm.SelectedRemoteEntry is { Name: not ".." }; + var isFile = vm.SelectedRemoteEntry is { IsDirectory: false }; + + Add("FileTransfer_PreviewMenu", vm.TogglePreviewPaneCommand, MaterialIconKind.DockRight, hasSelection && !vm.IsPreviewPaneOpen); + Add("FileTransfer_DownloadBtn", vm.DownloadCommand, MaterialIconKind.Download, isFile); + Add("FileTransfer_DownloadFolderBtn", vm.DownloadFolderCommand, MaterialIconKind.FolderDownloadOutline, vm.SupportsFolderTransfer && !isFile && hasSelection); + + var others = vm.OtherDevices; + if (hasSelection && others.Count > 0) + { + var sendTo = new MenuItem { Header = loc["FileTransfer_SendTo"], Icon = new MaterialIcon { Kind = MaterialIconKind.SendOutline } }; + foreach (var device in others) + sendTo.Items.Add(new MenuItem { Header = FileTreeViewModel.DeviceLabel(device), Command = vm.SendSelectionToCommand, CommandParameter = device }); + items.Add(sendTo); + } + + if (forMoreButton) + { + Add("FileTransfer_UploadFolderBtn", vm.UploadFolderCommand, MaterialIconKind.FolderUploadOutline, vm.CanChangeFiles); + Add("FileTransfer_SelectAllBtn", vm.SelectAllCommand, MaterialIconKind.SelectAll); + } + + items.Add(new Separator()); + Add("FileTransfer_RenameBtn", vm.StartRenameCommand, MaterialIconKind.RenameOutline, vm.CanChangeFiles && hasSelection); + Add("FileTransfer_CopyBtn", vm.CopySelectionCommand, MaterialIconKind.ContentCopy, vm.SupportsCopyMove && hasSelection); + Add("FileTransfer_CutBtn", vm.CutSelectionCommand, MaterialIconKind.ContentCut, vm.SupportsCopyMove && hasSelection); + Add("FileTransfer_PasteBtn", vm.PasteCommand, MaterialIconKind.ContentPaste, vm.SupportsCopyMove && vm.HasClipboard); + Add("FileTransfer_DeleteBtn", vm.DeleteRemoteCommand, MaterialIconKind.DeleteOutline, vm.CanChangeFiles && hasSelection); + items.Add(new Separator()); + Add("FileTransfer_CopyPath", vm.CopyPathCommand, MaterialIconKind.LinkVariant); + Add("FileTransfer_ComputeHashMenu", vm.ComputeSelectionHashCommand, MaterialIconKind.Fingerprint, isFile); + + if (forMoreButton) + { + Add("FileTransfer_PinFolderTooltip", vm.PinCurrentFolderCommand, MaterialIconKind.Pin, vm.CanManageFiles); + Add("FileTransfer_VolumesTooltip", vm.LoadVolumesCommand, MaterialIconKind.Harddisk, vm.ShowVolumesButton); + } + + // No separator left dangling at either end. + while (items.Count > 0 && items[^1] is Separator) items.RemoveAt(items.Count - 1); + while (items.Count > 0 && items[0] is Separator) items.RemoveAt(0); + } + + // ── View model wiring ── + + private void ConfigureViewModel() + { + if (_boundVm is not null) + _boundVm.Preview.Lines.CollectionChanged -= OnPreviewLinesChanged; + _boundVm = DataContext as FileTransferViewModel; + if (_boundVm is not { } vm) + return; - vm.SelectAllEntries = () => RemoteFileList.SelectAll(); + vm.Preview.Lines.CollectionChanged += OnPreviewLinesChanged; + vm.SelectAllEntries = () => (vm.IsIconView ? RemoteIconGrid : RemoteFileList).SelectAll(); // Was an inline lambda; moved to ConfirmationDialogHost when RemEx-6p1f needed the same one // in four more views. Behaviour is identical, including returning false with no parent window. vm.OnConfirmationRequested = ConfirmationDialogHost.For(this); + vm.CopyText = async text => + { + if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) + await clipboard.SetTextAsync(text); + }; + vm.PickUploadFileAsync = async options => { var topLevel = TopLevel.GetTopLevel(this); @@ -122,5 +394,15 @@ private void ConfigureViewModel() return await topLevel.StorageProvider.SaveFilePickerAsync(options); }; + + // Drag-out needs This PC's shared folders on disk without awaiting mid-gesture; read them once now. + vm.LocalPathForAsync(string.Empty, string.Empty).FireAndForget("reading this PC's shared folders"); + } + + /// A live tail keeps the newest line in view. + private void OnPreviewLinesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_boundVm?.Preview.IsLive == true && e.Action == NotifyCollectionChangedAction.Add) + PreviewTextScroll.ScrollToEnd(); } } From aa4bb0cc4900a33e7b6e8cbfda1ae29e3926b03d Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:58:38 +0000 Subject: [PATCH 06/10] feat(files): phone plumbing for the new File Transfer screen, and "Verified" on phone transfers (redesign phase 4) - Kotlin twins of the PC preview pipeline (classifier, decoder, tokenizer, image/text/tail loaders) under ui/files/preview, held to the C# rules by one shared vector file kept byte-identical in remex.desktop.tests/Fixtures and the Android test resources. - PcFileRequests: range-read and SHA-256 requests to the PC, matched by request id, routed from the file view model's message feed. - LocalDocuments: DocumentsContract listing (one query per folder), path walking through SharedPathPolicy, range reads and on-phone SHA-256. - PhoneBookmarksRepository: phone-only bookmarks in their own DataStore key; "Share with PC" writes the existing Access-from-PC list; grants are released only when no list (or whole-device browse) holds them. - QueuedTransfer.verified: set only when a hash was compared; queue rows say Verified with a copy-hash button, and the finish notification says "Transfer verified". 3 strings x 9 locales + 1. Every new guard was defect-injected (15 Kotlin + 1 C#); two blind tests were fixed before landing. Gate (verify.ps1 -Scope all, Linux): Android unit 1974/1974 and lintRelease pass; .NET as phase 3, with the same 10 environment-only agent failures as the base commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- docs/CHANGELOG.md | 11 + .../remex/data/PhoneBookmarksRepository.kt | 104 +++ .../clindsay94/remex/data/SettingsManager.kt | 16 + .../remex/service/FileTransferEngine.kt | 8 +- .../FileTransferNotificationManager.kt | 20 +- .../clindsay94/remex/service/TransferQueue.kt | 14 + .../ui/components/FileManagerQueuePanel.kt | 34 +- .../remex/ui/files/FileClipboard.kt | 17 + .../remex/ui/files/LocalDocuments.kt | 258 +++++++ .../remex/ui/files/PcFileRequests.kt | 148 ++++ .../ui/files/preview/PreviewClassifier.kt | 74 ++ .../remex/ui/files/preview/PreviewLoaders.kt | 155 ++++ .../remex/ui/files/preview/SyntaxTokenizer.kt | 211 +++++ .../ui/files/preview/TextPreviewDecoder.kt | 98 +++ .../remex/ui/screens/FileTransferViewModel.kt | 16 + .../app/src/main/res/values-es/strings.xml | 4 + .../app/src/main/res/values-fr/strings.xml | 4 + .../app/src/main/res/values-hi/strings.xml | 4 + .../app/src/main/res/values-in/strings.xml | 4 + .../app/src/main/res/values-pl/strings.xml | 4 + .../src/main/res/values-pt-rBR/strings.xml | 4 + .../app/src/main/res/values-tr/strings.xml | 4 + .../app/src/main/res/values-uk/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + .../data/PhoneBookmarksRepositoryTest.kt | 89 +++ .../remex/service/TransferVerifiedTest.kt | 61 ++ .../remex/ui/files/FilePreviewVectorsTest.kt | 81 ++ .../remex/ui/files/LocalDocumentLogicTest.kt | 126 +++ .../remex/ui/files/PcFileRequestsTest.kt | 121 +++ .../remex/ui/files/PreviewLoadersTest.kt | 175 +++++ .../test/resources/file-preview-vectors.json | 724 ++++++++++++++++++ .../Fixtures/file-preview-vectors.json | 724 ++++++++++++++++++ .../Services/FilePreviewVectorTests.cs | 82 ++ 33 files changed, 3388 insertions(+), 15 deletions(-) create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileClipboard.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/LocalDocuments.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PcFileRequests.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewClassifier.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewLoaders.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/SyntaxTokenizer.kt create mode 100644 remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/TextPreviewDecoder.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/service/TransferVerifiedTest.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewVectorsTest.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/ui/files/LocalDocumentLogicTest.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt create mode 100644 remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PreviewLoadersTest.kt create mode 100644 remex.android/app/src/test/resources/file-preview-vectors.json create mode 100644 remex.desktop.tests/Fixtures/file-preview-vectors.json create mode 100644 remex.desktop.tests/Services/FilePreviewVectorTests.cs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index da7e5a8e..7a7f45c2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,10 @@ Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250- transfer says **Verified** and keeps the fingerprint, so the new File Transfer screen can show and copy it. A transfer whose other end sent no fingerprint to compare still says **Done**: nothing was checked, so nothing is claimed. The row also says **Verifying…** while the two ends compare. +- **Transfers on the phone say "Verified" too.** A finished transfer in the phone's queue now says **Verified** + (with a check-mark icon) when its SHA-256 fingerprint was really compared and matched, and a button copies the + fingerprint as lowercase hex. The "Transfer complete" notification becomes **Transfer verified** in the same + case. A download from a PC that sent no fingerprint still says **Done**. ### Changed @@ -62,6 +66,13 @@ Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250- cut by a read; light per-line colouring for logs (including the .NET logger's `fail:`/`warn:`), JSON, XML and code; a full-size image fetch in 1 MiB reads that refuses a file over 40 MiB after one read; and a live tail that reads only what was added, restarts when a log rotates, and keeps an endless line bounded. +- **The phone's plumbing for the new File Transfer screen** (`remex.android/.../ui/files/`): Kotlin twins of the + PC's preview classifier, text decoder, tokenizer and loaders (image, text head, live tail), held to the C# + rules by one shared vector file (`file-preview-vectors.json`, kept byte-identical on both sides); + `PcFileRequests` for range reads and SHA-256 requests to the PC, matched by request id; `LocalDocuments`, which + lists phone folders with one `DocumentsContract` query per folder and reads ranges and hashes on the phone; and + `PhoneBookmarksRepository`, phone-only folder bookmarks in their own DataStore key that the PC never sees until + the person turns on "Share with PC", which adds them to the existing "Access from your PC" list. - **`scripts/verify.ps1` runs on Linux again.** It died at its first step on every Linux run: PowerShell 7.6 adds its own newline to text piped into `git check-ignore`, so the script's extra one (needed on Windows, where PowerShell adds CRLF) became an empty line that git refuses. The extra newline is now added on Windows diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt b/remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt new file mode 100644 index 00000000..0546b3a2 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt @@ -0,0 +1,104 @@ +package com.clindsay94.remex.data + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * A folder under "This phone" in the File Transfer tree. + * + * @property isBookmark the person added it on the File Transfer screen (phone-only unless shared). + * @property isShared it is on the "Access from your PC" list, so the PC can see it. + */ +data class PhoneFolder(val uri: String, val isBookmark: Boolean, val isShared: Boolean) + +/** The two folder lists the repository joins. The app's is [SettingsManager]; tests use a map. */ +interface PhoneFolderStore { + val sharedUris: Flow> + val bookmarkUris: Flow> + + /** Grants something else still relies on (the whole-device browse root), which are never released here. */ + val otherHeldUris: Flow> + suspend fun setShared(uri: String, shared: Boolean) + suspend fun setBookmarked(uri: String, bookmarked: Boolean) +} + +/** + * The phone folders the File Transfer screen can browse (file browser redesign, 2026-10-08): the folders shared + * with the PC, plus phone-only bookmarks. + * + * There is still ONE consent surface. A bookmark lives in its own list, which the PC-facing host never reads; + * "Share with PC" on a bookmark adds it to the same "Access from your PC" list Settings edits, and turning it + * off takes it back out. The app keeps its persisted folder grant while either list holds the folder, and + * releases it once neither does (and whole-device browsing doesn't use it), so removing a bookmark doesn't leave a + * grant behind. + * + * @param releaseGrant gives up the persisted URI grant for a folder no list holds any more. + */ +class PhoneBookmarksRepository( + private val store: PhoneFolderStore, + private val releaseGrant: (String) -> Unit, +) { + /** Every folder to list under "This phone": shared ones first, then phone-only bookmarks, each by URI. */ + val folders: Flow> = combine(store.sharedUris, store.bookmarkUris, ::merge) + + /** Adds a folder the person just picked (and granted) as a phone-only bookmark. */ + suspend fun addBookmark(uri: String) = store.setBookmarked(uri, true) + + /** + * Shares a folder with the PC, or stops sharing it. Stopping keeps a bookmarked folder on the phone and + * releases the grant of one that was only shared. + */ + suspend fun setShared(uri: String, shared: Boolean) { + store.setShared(uri, shared) + if (!shared) releaseIfUnused(uri) + } + + /** Removes a bookmark. A folder that is still shared stays shared, and keeps its grant. */ + suspend fun removeBookmark(uri: String) { + store.setBookmarked(uri, false) + releaseIfUnused(uri) + } + + private suspend fun releaseIfUnused(uri: String) { + val held = store.sharedUris.first() + store.bookmarkUris.first() + store.otherHeldUris.first() + if (uri !in held) releaseGrant(uri) + } + + companion object { + /** The joined list, pure so the order and flags are unit-tested. */ + fun merge(shared: Set, bookmarks: Set): List = + shared.sorted().map { PhoneFolder(it, isBookmark = it in bookmarks, isShared = true) } + + (bookmarks - shared).sorted().map { PhoneFolder(it, isBookmark = true, isShared = false) } + + /** The app's repository: [SettingsManager]'s two lists, releasing grants through [resolver]. */ + fun create(settings: SettingsManager, resolver: ContentResolver): PhoneBookmarksRepository = + PhoneBookmarksRepository( + object : PhoneFolderStore { + override val sharedUris = settings.sharedFolderUrisFlow + override val bookmarkUris = settings.phoneBookmarkUrisFlow + override val otherHeldUris = settings.fullBrowseRootUriFlow.map { setOfNotNull(it) } + override suspend fun setShared(uri: String, shared: Boolean) = + if (shared) settings.addSharedFolderUri(uri) else settings.removeSharedFolderUri(uri) + override suspend fun setBookmarked(uri: String, bookmarked: Boolean) = + settings.setPhoneBookmark(uri, bookmarked) + }, + releaseGrant = { uri -> + try { + resolver.releasePersistableUriPermission( + Uri.parse(uri), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } catch (e: SecurityException) { + // Not held (already released, or granted read-only): nothing to give back. + Log.i("PhoneBookmarks", "No grant to release for a removed folder", e) + } + }, + ) + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/data/SettingsManager.kt b/remex.android/app/src/main/java/com/clindsay94/remex/data/SettingsManager.kt index 96ecd0b0..82443fde 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/data/SettingsManager.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/data/SettingsManager.kt @@ -911,6 +911,22 @@ class SettingsManager(val context: Context) { } } + // Phone-only bookmarks for the File Transfer screen (file browser redesign, 2026-10-08). Folders the + // person added to browse ON THE PHONE. Deliberately its own key: AndroidFileTransferHost and + // SharedPathPolicy read only SHARED_FOLDER_URIS_KEY, so a bookmark is never visible to the PC until the + // person turns on "Share with PC", which adds it to that list. Not exported (SettingsExport allowlists). + private val phoneBookmarkUrisKey = stringSetPreferencesKey("phone_bookmark_uris") + + val phoneBookmarkUrisFlow: Flow> = + context.dataStore.data.map { prefs -> prefs[phoneBookmarkUrisKey] ?: emptySet() } + + suspend fun setPhoneBookmark(uri: String, bookmarked: Boolean) { + context.dataStore.edit { prefs -> + val current = prefs[phoneBookmarkUrisKey] ?: emptySet() + prefs[phoneBookmarkUrisKey] = if (bookmarked) current + uri else current - uri + } + } + // "Let your PC change files" (RemEx-fgmne). OFF BY DEFAULT, and it is the person's call alone: with // it off the phone refuses every rename, move, copy, delete and new-folder request from the PC, // however writable the shared folder is, and tells the PC it is off. It does not widen WHAT is diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt index f9e8b83f..e38ce80a 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt @@ -403,12 +403,12 @@ object FileTransferEngine { sendComplete(t.id, sha) val result = awaitResult(t.id) if (result != null && result.verified) { - updateState(t.id) { it.copy(state = TransferState.Done) } + updateState(t.id) { it.copy(state = TransferState.Done, verified = true) } // A Share-to-PC push reads from a private staged copy; it has landed, so drop it // (live-check C6). A content:// source the user picked is left alone. discardLocalFiles(t) // Confirm the send (parity with runDownload's completion notification). - FileTransferNotificationManager.showTransferComplete(appContext, t.fileName, isDownload = false) + FileTransferNotificationManager.showTransferComplete(appContext, t.fileName, isDownload = false, verified = true) } else { updateState(t.id) { it.copy(state = TransferState.Failed, error = result?.error ?: "Verification failed.") @@ -595,9 +595,9 @@ object FileTransferEngine { if (verified && commitDownload(partial, t.localUri)) { partial.delete() sendResult(t.id, true, actualSha, null) - updateState(t.id) { it.copy(state = TransferState.Done, sha256 = actualSha) } + updateState(t.id) { it.copy(state = TransferState.Done, sha256 = actualSha, verified = expectedSha != null) } // Open-after-download (WP8): surface an "Open" completion notification for the file. - FileTransferNotificationManager.showDownloadComplete(appContext, t.fileName, t.localUri) + FileTransferNotificationManager.showDownloadComplete(appContext, t.fileName, t.localUri, verified = expectedSha != null) } else { partial.delete() // A skipped wait with no complete from the host is unconfirmed, not a hash mismatch. diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferNotificationManager.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferNotificationManager.kt index 1a51a58b..40643919 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferNotificationManager.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferNotificationManager.kt @@ -147,6 +147,10 @@ object FileTransferNotificationManager { ) } + private fun completeTitle(verified: Boolean): Int = + if (verified) R.string.file_transfer_notification_verified_title + else R.string.file_transfer_notification_complete_title + private fun formatBytes(bytes: Long): String = when { bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0) @@ -155,10 +159,14 @@ object FileTransferNotificationManager { else -> "$bytes B" } - fun showTransferComplete(context: Context, fileName: String, isDownload: Boolean) { + /** + * [verified] is true only when a SHA-256 was compared and matched (QueuedTransfer.verified); the title then + * says so. The legacy v2 path, which compares nothing, leaves it false. + */ + fun showTransferComplete(context: Context, fileName: String, isDownload: Boolean, verified: Boolean = false) { notify( context = context, - title = context.getString(R.string.file_transfer_notification_complete_title), + title = context.getString(completeTitle(verified)), text = context.getString( if (isDownload) @@ -178,7 +186,7 @@ object FileTransferNotificationManager { * (a SAF `content://` document or a `file://` path); MIME is inferred from [fileName]. Falls back * to a plain completion notification when no viewer can be resolved. */ - fun showDownloadComplete(context: Context, fileName: String, localUri: String) { + fun showDownloadComplete(context: Context, fileName: String, localUri: String, verified: Boolean = false) { if (!canPostNotifications(context)) return ensureTransferChannel(context) @@ -188,11 +196,7 @@ object FileTransferNotificationManager { val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle( - context.getString( - R.string.file_transfer_notification_complete_title - ) - ) + .setContentTitle(context.getString(completeTitle(verified))) .setContentText( context.getString( R.string.file_transfer_notification_download_complete, diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/service/TransferQueue.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/TransferQueue.kt index adadb989..f9c1355f 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/service/TransferQueue.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/service/TransferQueue.kt @@ -49,7 +49,18 @@ data class QueuedTransfer( * Paused, so a cancel must still reach the host (live-check C4 review). */ val hostKnows: Boolean = false, + /** + * True only when a SHA-256 was really compared and matched: the host confirmed an upload's hash, or a + * download's bytes matched the hash the host sent. A download from a host that sent no hash still + * finishes (and keeps the hash it computed in [sha256]), but nothing was checked, so it stays false + * and the row says "Done", not "Verified" (file browser redesign, 2026-10-08). + */ + val verified: Boolean = false, ) { + /** The checked hash as lowercase hex, for "Verified" rows only; null for anything else. */ + val verifiedSha256Hex: String? + get() = if (state == TransferState.Done && verified) HashFormat.toHex(sha256) else null + fun toJson(): JSONObject = JSONObject().apply { put("id", id) @@ -67,6 +78,7 @@ data class QueuedTransfer( if (error != null) put("error", error) put("createdAtMs", createdAtMs) put("hostKnows", hostKnows) + if (verified) put("verified", true) } companion object { @@ -91,6 +103,8 @@ data class QueuedTransfer( createdAtMs = obj.optLong("createdAtMs", System.currentTimeMillis()), // A row persisted before the flag existed: anything past Queued was offered. hostKnows = obj.optBoolean("hostKnows", state != TransferState.Queued), + // A row persisted before the flag existed says "Done": nothing claims a check it can't prove. + verified = obj.optBoolean("verified", false), ) } } diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQueuePanel.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQueuePanel.kt index 3c6c0bed..650030ef 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQueuePanel.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQueuePanel.kt @@ -18,10 +18,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.Verified import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -48,11 +50,13 @@ import com.clindsay94.remex.service.QueuedTransfer import com.clindsay94.remex.service.TransferProgressFormat import com.clindsay94.remex.service.TransferProgressText import com.clindsay94.remex.service.TransferState +import com.clindsay94.remex.ui.files.FileClipboard import com.clindsay94.remex.ui.screens.FileManagerLogic import com.clindsay94.remex.ui.screens.RemexLinearWavyProgress import com.clindsay94.remex.ui.theme.rememberRemexButtonShapes import com.clindsay94.remex.ui.theme.rememberRemexIconButtonShapes import androidx.compose.material3.ButtonDefaults +import android.widget.Toast /** * Persistent transfer-queue panel (plan WP7): one row per [QueuedTransfer] with live progress and @@ -161,10 +165,15 @@ private fun TransferRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + val verifiedHex = transfer.verifiedSha256Hex Icon( - imageVector = if (isDownload) Icons.Default.Download else Icons.Default.Upload, + imageVector = when { + verifiedHex != null -> Icons.Default.Verified + isDownload -> Icons.Default.Download + else -> Icons.Default.Upload + }, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = if (verifiedHex != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) Column(modifier = Modifier.weight(1f)) { @@ -213,6 +222,22 @@ private fun TransferRow( state == TransferState.Cancelled || state == TransferState.Failed Row(verticalAlignment = Alignment.CenterVertically) { + // A verified row's fingerprint, as lowercase hex, to paste next to sha256sum or Get-FileHash. + if (state == TransferState.Done && verifiedHex != null) { + val context = LocalContext.current + val label = stringResource(R.string.file_manager_copy_sha256) + val copied = stringResource(R.string.file_manager_sha256_copied) + IconButton( + onClick = { + if (FileClipboard.copy(context, label, verifiedHex)) { + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + }, + shapes = rememberRemexIconButtonShapes(), + ) { + Icon(Icons.Default.ContentCopy, contentDescription = label, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } if (state == TransferState.Paused || state == TransferState.Failed) { IconButton(onClick = { onResume(transfer.id) }, shapes = rememberRemexIconButtonShapes()) { Icon(Icons.Default.PlayArrow, contentDescription = stringResource(R.string.file_manager_resume)) @@ -250,7 +275,10 @@ private fun transferStatusLabel(transfer: QueuedTransfer): String { withRateAndEta(stringResource(R.string.file_manager_transfer_active, progress), transfer) TransferState.Paused -> stringResource(R.string.file_manager_transfer_paused, progress) TransferState.Verifying -> stringResource(R.string.file_manager_transfer_verifying) - TransferState.Done -> stringResource(R.string.file_manager_transfer_done) + // "Verified" only when a SHA-256 was really compared and matched (QueuedTransfer.verified). + TransferState.Done -> + if (transfer.verifiedSha256Hex != null) stringResource(R.string.file_manager_transfer_verified) + else stringResource(R.string.file_manager_transfer_done) TransferState.Failed -> transfer.error ?: stringResource(R.string.file_manager_transfer_failed) TransferState.Cancelled -> stringResource(R.string.file_manager_transfer_cancelled) } diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileClipboard.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileClipboard.kt new file mode 100644 index 00000000..b57cc078 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileClipboard.kt @@ -0,0 +1,17 @@ +package com.clindsay94.remex.ui.files + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context + +/** + * Puts plain text (a path, a SHA-256 fingerprint) on the clipboard for the File Transfer screen. Guarded because a + * clip is a binder transaction that can fail; false means nothing was copied, so the caller says so instead of + * claiming it was. + */ +object FileClipboard { + fun copy(context: Context, label: String, text: String): Boolean { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return false + return runCatching { clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) }.isSuccess + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/LocalDocuments.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/LocalDocuments.kt new file mode 100644 index 00000000..9bb310d0 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/LocalDocuments.kt @@ -0,0 +1,258 @@ +package com.clindsay94.remex.ui.files + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import com.clindsay94.remex.service.SharedPathPolicy +import com.clindsay94.remex.ui.files.preview.RangeChunk +import com.clindsay94.remex.ui.files.preview.RangeReader +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +/** One item in a phone folder, as `DocumentsContract` lists it. [sizeBytes] is -1 when the provider doesn't say. */ +data class LocalEntry( + val documentId: String, + val name: String, + val isDirectory: Boolean, + val sizeBytes: Long, + val modifiedMs: Long, + val mimeType: String?, +) + +/** + * Pure helpers behind [LocalDocuments], unit-tested on the JVM: how a row becomes an entry, how a path is walked + * to a document, and how a range or a hash is read from a stream. + */ +object LocalDocumentLogic { + /** One child-document row as an entry; null for a row with no id or name, which can't be shown or opened. */ + fun entryOf(documentId: String?, name: String?, mimeType: String?, size: Long?, modified: Long?): LocalEntry? { + if (documentId.isNullOrEmpty() || name.isNullOrEmpty()) return null + val isDirectory = mimeType == Document.MIME_TYPE_DIR + return LocalEntry( + documentId = documentId, + name = name, + isDirectory = isDirectory, + sizeBytes = if (isDirectory) -1L else size ?: -1L, + modifiedMs = modified ?: 0L, + mimeType = mimeType, + ) + } + + /** + * The document id at [relativePath] under the tree whose top document is [rootDocumentId], walking one + * listing per level with [childrenOf]. Null when a name is unsafe ([SharedPathPolicy.segments]), missing, or + * a file sits where a folder is needed. [cache] remembers paths already walked, so opening a folder costs + * one listing, not one per level. + */ + suspend fun resolve( + rootDocumentId: String, + relativePath: String, + childrenOf: suspend (String) -> List, + cache: MutableMap? = null, + ): String? { + val segments = SharedPathPolicy.segments(relativePath) ?: return null + var documentId = rootDocumentId + var walked = "" + for ((index, name) in segments.withIndex()) { + walked = if (walked.isEmpty()) name else "$walked/$name" + val known = cache?.get(walked) + if (known != null) { + documentId = known + continue + } + val child = childrenOf(documentId).firstOrNull { it.name == name } ?: return null + if (!child.isDirectory && index < segments.lastIndex) return null + documentId = child.documentId + cache?.put(walked, documentId) + } + return documentId + } + + /** + * Reads one range from [input], positioned at its start: up to [length] bytes from [offset], or the last + * [length] when [fromEnd]. [size] is the file's size, or -1 when the provider doesn't know it; a tail of a + * file of unknown size is then read to the end through a ring buffer, the way the phone's host does it. + */ + suspend fun readRange(input: InputStream, size: Long, offset: Long, length: Int, fromEnd: Boolean): RangeChunk { + require(length > 0) { "length must be positive" } + if (fromEnd && size < 0) return tailOfUnknownSize(input, length) + + val start = when { + fromEnd -> maxOf(0L, size - length) + size >= 0 -> minOf(offset, size) + else -> offset + } + skipTo(input, start) + val buffer = ByteArray(if (size >= 0) minOf(length.toLong(), size - start).toInt() else length) + var filled = 0 + while (filled < buffer.size) { + currentCoroutineContext().ensureActive() + val n = input.read(buffer, filled, buffer.size - filled) + if (n < 0) break + filled += n + } + val data = if (filled == buffer.size) buffer else buffer.copyOf(filled) + val end = start + filled + val eof = if (size >= 0) end >= size else filled < length || input.read() < 0 + // With no size from the provider, what has been seen is the best size there is: never less than the end + // of this read, so a live tail never mistakes a long unknown-size file for one that shrank. + return RangeChunk(start, data, if (size >= 0) size else end, eof) + } + + /** The SHA-256 of everything [input] holds, reporting bytes read so far to [onProgress]. */ + suspend fun sha256(input: InputStream, onProgress: ((Long) -> Unit)? = null): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(HASH_BUFFER_BYTES) + var total = 0L + while (true) { + currentCoroutineContext().ensureActive() + val n = input.read(buffer) + if (n < 0) break + digest.update(buffer, 0, n) + total += n + onProgress?.invoke(total) + } + return digest.digest() + } + + private suspend fun skipTo(input: InputStream, start: Long) { + if (start <= 0) return + if (input is FileInputStream) { + input.channel.position(start) + return + } + var remaining = start + val scratch = ByteArray(HASH_BUFFER_BYTES) + while (remaining > 0) { + currentCoroutineContext().ensureActive() + val skipped = input.skip(remaining) + if (skipped > 0) { + remaining -= skipped + continue + } + // skip() may return 0 without being at the end; a read tells the two apart. + val n = input.read(scratch, 0, minOf(scratch.size.toLong(), remaining).toInt()) + if (n < 0) return + remaining -= n + } + } + + private suspend fun tailOfUnknownSize(input: InputStream, length: Int): RangeChunk { + val ring = ByteArray(length) + var total = 0L + val scratch = ByteArray(HASH_BUFFER_BYTES) + while (true) { + currentCoroutineContext().ensureActive() + val n = input.read(scratch) + if (n < 0) break + for (i in 0 until n) ring[((total + i) % length).toInt()] = scratch[i] + total += n + } + val count = minOf(total, length.toLong()).toInt() + val start = total - count + val data = ByteArray(count) { ring[((start + it) % length).toInt()] } + return RangeChunk(start, data, total, eof = true) + } + + private const val HASH_BUFFER_BYTES = 64 * 1024 +} + +/** + * Lists, reads and hashes files in a phone folder the person granted (a shared folder or a phone-only bookmark), + * by tree URI and '/'-separated path, the same addressing the PC side uses. Folders are listed with one + * `DocumentsContract` child query each, not `DocumentFile.listFiles()`, which costs one IPC per child. + */ +class LocalDocuments(private val resolver: ContentResolver) { + // tree URI -> (relative path -> document id). Cleared per tree when a listing shows a name is gone. + private val ids = ConcurrentHashMap>() + + /** The items in [relativePath] under [treeUri]. Throws [FileNotFoundException] when the folder is gone. */ + suspend fun list(treeUri: Uri, relativePath: String): List = withContext(Dispatchers.IO) { + listChildren(treeUri, documentIdOf(treeUri, relativePath)) + } + + /** A content URI for the document at [relativePath], for opening, sharing or as a transfer's source. */ + suspend fun documentUri(treeUri: Uri, relativePath: String): Uri = withContext(Dispatchers.IO) { + DocumentsContract.buildDocumentUriUsingTree(treeUri, documentIdOf(treeUri, relativePath)) + } + + /** One range of the file at [relativePath]. */ + suspend fun readRange(treeUri: Uri, relativePath: String, offset: Long, length: Int, fromEnd: Boolean): RangeChunk = + withContext(Dispatchers.IO) { + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentIdOf(treeUri, relativePath)) + val size = sizeOf(uri) + val input = resolver.openInputStream(uri) ?: throw FileNotFoundException(relativePath) + input.use { LocalDocumentLogic.readRange(it, size, offset, length, fromEnd) } + } + + /** A [RangeReader] bound to one phone file, for the preview loaders. */ + fun reader(treeUri: Uri, relativePath: String): RangeReader = + RangeReader { offset, length, fromEnd -> readRange(treeUri, relativePath, offset, length, fromEnd) } + + /** The SHA-256 of the file at [relativePath], computed here on the phone. */ + suspend fun sha256(treeUri: Uri, relativePath: String, onProgress: ((Long) -> Unit)? = null): ByteArray = + withContext(Dispatchers.IO) { + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentIdOf(treeUri, relativePath)) + val input = resolver.openInputStream(uri) ?: throw FileNotFoundException(relativePath) + input.use { LocalDocumentLogic.sha256(it, onProgress) } + } + + /** Forgets the paths walked under [treeUri], after the person changed files there. */ + fun invalidate(treeUri: Uri) { + ids.remove(treeUri.toString()) + } + + private suspend fun documentIdOf(treeUri: Uri, relativePath: String): String { + val root = DocumentsContract.getTreeDocumentId(treeUri) + val cache = ids.getOrPut(treeUri.toString()) { ConcurrentHashMap() } + return LocalDocumentLogic.resolve(root, relativePath, { listChildren(treeUri, it) }, cache) + ?: run { + // A stale cached id (moved or deleted) would keep failing; walk fresh next time. + ids.remove(treeUri.toString()) + throw FileNotFoundException(relativePath) + } + } + + private fun listChildren(treeUri: Uri, parentDocumentId: String): List { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId) + val cursor = resolver.query(childrenUri, PROJECTION, null, null, null) + ?: throw IOException("This folder could not be listed.") + return cursor.use { c -> + buildList { + while (c.moveToNext()) { + LocalDocumentLogic.entryOf( + documentId = c.getString(0), + name = c.getString(1), + mimeType = c.getString(2), + size = if (c.isNull(3)) null else c.getLong(3), + modified = if (c.isNull(4)) null else c.getLong(4), + )?.let(::add) + } + } + } + } + + private fun sizeOf(documentUri: Uri): Long = + resolver.query(documentUri, arrayOf(Document.COLUMN_SIZE), null, null, null)?.use { c -> + if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else -1L + } ?: -1L + + private companion object { + val PROJECTION = arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + ) + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PcFileRequests.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PcFileRequests.kt new file mode 100644 index 00000000..ae088ee4 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PcFileRequests.kt @@ -0,0 +1,148 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.service.FileTransferLimits +import com.clindsay94.remex.ui.files.preview.RangeChunk +import com.clindsay94.remex.ui.files.preview.RangeReader +import java.io.IOException +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import org.json.JSONObject + +/** The PC refused or failed a request; [hostMessage] is its own (English) reason, for the log. */ +class PcRequestFailedException(val hostMessage: String) : IOException(hostMessage) + +/** The PC did not answer in time: the connection dropped, or the PC is too old to know the request. */ +class PcRequestTimeoutException(what: String) : IOException("The PC did not answer the $what request in time.") + +/** + * The phone's side of the two read-only requests the new File Transfer screen sends to the PC (file browser + * redesign, 2026-10-08): `file_read_range_request` for previews and live tails, and `file_hash_request` for + * the Integrity card. Each request carries a fresh id and is answered by the reply with the same id, so any + * number can be in flight and a reply meant for someone else is left alone. + * + * Android-free on purpose (the sender and the message feed are passed in), so the correlation, the timeouts + * and the reply checks are unit-tested on the JVM. + * + * @param send sends one envelope to the PC; false when it could not be sent at all. + */ +class PcFileRequests(private val send: (JSONObject) -> Boolean) { + private val rangeWaiters = ConcurrentHashMap>() + private val hashWaiters = ConcurrentHashMap>() + + /** + * Offers one incoming file message. Returns true when it was a reply to a request made here (and so was + * consumed), false for anything else, which the caller handles as before. + */ + fun onMessage(obj: JSONObject): Boolean { + val (waiters, key) = when (obj.optString("type")) { + "file_read_range_response" -> rangeWaiters to "fileReadRangeResponse" + "file_hash_response" -> hashWaiters to "fileHashResponse" + else -> return false + } + val body = obj.optJSONObject(key) ?: return false + val waiter = waiters.remove(body.optString("requestId")) ?: return false + waiter.complete(body) + return true + } + + /** Fails every request still waiting, e.g. when the connection to the PC closes. */ + fun failAll(reason: String) { + for (waiters in listOf(rangeWaiters, hashWaiters)) { + for (id in waiters.keys.toList()) waiters.remove(id)?.completeExceptionally(IOException(reason)) + } + } + + /** + * Reads up to [length] bytes of a PC file: from [offset], or the last [length] when [fromEnd]. Throws + * [PcRequestFailedException] with the PC's reason, or [PcRequestTimeoutException]. + */ + suspend fun readRange( + rootId: String, + relativePath: String, + offset: Long, + length: Int, + fromEnd: Boolean, + timeoutMs: Long = RANGE_TIMEOUT_MS, + ): RangeChunk { + val payload = JSONObject().apply { + put("rootId", rootId) + put("relativePath", relativePath) + put("offset", if (fromEnd) 0L else offset) + put("length", length.coerceIn(1, FileTransferLimits.READ_RANGE_MAX_BYTES)) + put("fromEnd", fromEnd) + } + val body = request("file_read_range_request", "fileReadRangeRequest", payload, rangeWaiters, timeoutMs, "preview") + body.meaningful("errorMessage")?.let { throw PcRequestFailedException(it) } + val data = body.meaningful("dataBase64")?.let { encoded -> + try { + Base64.getDecoder().decode(encoded) + } catch (e: IllegalArgumentException) { + throw IOException("The PC sent bytes that could not be read.", e) + } + } ?: ByteArray(0) + val start = body.optLong("offset", 0L) + if (!fromEnd && start != offset) throw IOException("The PC answered for a different part of the file.") + if (data.size > FileTransferLimits.READ_RANGE_MAX_BYTES) throw IOException("The PC sent more than was asked for.") + return RangeChunk(start, data, body.optLong("fileSize", 0L), body.optBoolean("eof", false)) + } + + /** A [RangeReader] bound to one PC file, for the preview loaders. */ + fun reader(rootId: String, relativePath: String): RangeReader = + RangeReader { offset, length, fromEnd -> readRange(rootId, relativePath, offset, length, fromEnd) } + + /** + * The PC's SHA-256 of one of its files, Base64 as on the wire. The PC reads the whole file to answer, so + * the wait is long; [HASH_TIMEOUT_MS] matches the PC's own wait for a phone's hash. + */ + suspend fun hash(rootId: String, relativePath: String, timeoutMs: Long = HASH_TIMEOUT_MS): String { + val payload = JSONObject().apply { + put("rootId", rootId) + put("relativePath", relativePath) + } + val body = request("file_hash_request", "fileHashRequest", payload, hashWaiters, timeoutMs, "fingerprint") + body.meaningful("errorMessage")?.let { throw PcRequestFailedException(it) } + return body.meaningful("sha256") ?: throw PcRequestFailedException("The PC sent no fingerprint.") + } + + private suspend fun request( + type: String, + payloadKey: String, + payload: JSONObject, + waiters: ConcurrentHashMap>, + timeoutMs: Long, + what: String, + ): JSONObject { + val requestId = UUID.randomUUID().toString().replace("-", "") + payload.put("requestId", requestId) + val waiter = CompletableDeferred() + waiters[requestId] = waiter + try { + val envelope = JSONObject().apply { + put("type", type) + put("protocolVersion", 3) + put(payloadKey, payload) + } + if (!send(envelope)) throw IOException("The request could not be sent to the PC.") + return withTimeout(timeoutMs) { waiter.await() } + } catch (_: TimeoutCancellationException) { + throw PcRequestTimeoutException(what) + } finally { + waiters.remove(requestId) + } + } + + private fun JSONObject.meaningful(key: String): String? = + if (has(key) && !isNull(key)) optString(key).takeIf { it.isNotBlank() } else null + + companion object { + /** One range is at most 1 MiB; a PC that has not answered in this long is not going to. */ + const val RANGE_TIMEOUT_MS = 30_000L + + /** Hashing reads the whole file on the PC: up to 30 minutes, the same wait the PC gives a phone. */ + const val HASH_TIMEOUT_MS = 30L * 60 * 1000 + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewClassifier.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewClassifier.kt new file mode 100644 index 00000000..40df88f5 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewClassifier.kt @@ -0,0 +1,74 @@ +package com.clindsay94.remex.ui.files.preview + +/** What the preview pane shows for a file (file browser redesign, 2026-10-08). Twin of the PC's `PreviewKind`. */ +enum class PreviewKind { + /** A folder, or a file with nothing to preview: a large icon and the details. */ + None, + + /** An image decoded at full size, with pinch-zoom and pan. */ + Image, + + /** An image this device can't decode: its thumbnail only. Never chosen on the phone, which decodes HEIC itself. */ + ImageThumbnailOnly, + + /** Text, code or a log, in the monospace viewer (and the only kind that can live-tail). */ + Text, + + /** Extension unknown: the first bytes are sniffed, and it is shown as text unless they look binary. */ + Sniff, +} + +/** + * Chooses a [PreviewKind] from a file name. Pure, and the same rules as the PC's `PreviewClassifier`; the + * shared vectors in `file-preview-vectors.json` hold the two to each other. + */ +object PreviewClassifier { + private val decodableImages = setOf(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp") + + // The PC needs the host's thumbnail for these. Android's ImageDecoder reads them directly. + private val heifImages = setOf(".heic", ".heif") + + // Formats that are text but would otherwise be sniffed: listing them skips the extra read. + private val textExtensions = setOf( + ".txt", ".log", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonc", ".xml", ".axaml", ".xaml", + ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cs", ".csproj", ".props", ".targets", + ".sln", ".kt", ".kts", ".java", ".gradle", ".py", ".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", + ".sh", ".bash", ".zsh", ".ps1", ".psm1", ".psd1", ".bat", ".cmd", ".ini", ".cfg", ".conf", ".toml", + ".yaml", ".yml", ".properties", ".env", ".gitignore", ".editorconfig", ".sql", ".srt", ".vtt", ".resx", + ) + + // Formats that are never worth sniffing: archives, media, documents and executables. + private val knownBinary = setOf( + ".zip", ".7z", ".rar", ".gz", ".tgz", ".xz", ".bz2", ".tar", ".iso", ".img", ".dmg", ".apk", ".aab", + ".exe", ".dll", ".so", ".dylib", ".msi", ".bin", ".dat", ".db", ".sqlite", ".pdf", ".doc", ".docx", + ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".mp3", ".m4a", ".aac", ".flac", ".wav", ".ogg", ".opus", + ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".3gp", ".tif", ".tiff", ".raw", ".dng", ".cr2", + ".nef", ".psd", ".ico", ".ttf", ".otf", ".woff", ".woff2", ".class", ".jar", ".pdb", ".pfx", ".p12", + ) + + /** + * The kind for [fileName]; a folder is always [PreviewKind.None]. [decodesHeif] is true on the phone, where + * HEIC/HEIF previews at full size; false gives the PC's answer, which the shared vectors check. + */ + fun classify(fileName: String, isDirectory: Boolean, decodesHeif: Boolean = true): PreviewKind { + if (isDirectory || fileName.isBlank()) return PreviewKind.None + val ext = extensionOf(fileName) + return when { + ext in decodableImages -> PreviewKind.Image + ext in heifImages -> if (decodesHeif) PreviewKind.Image else PreviewKind.ImageThumbnailOnly + ext in textExtensions -> PreviewKind.Text + ext in knownBinary -> PreviewKind.None + else -> PreviewKind.Sniff + } + } + + /** + * The extension with its dot, lower-cased, as .NET's `Path.GetExtension` finds it: from the last '.' of the + * last path segment, "" when there is none. ".gitignore" is its own extension, which is what both lists expect. + */ + internal fun extensionOf(fileName: String): String { + val name = fileName.substringAfterLast('/').substringAfterLast('\\') + val dot = name.lastIndexOf('.') + return if (dot < 0 || dot == name.length - 1) "" else name.substring(dot).lowercase() + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewLoaders.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewLoaders.kt new file mode 100644 index 00000000..95529834 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/PreviewLoaders.kt @@ -0,0 +1,155 @@ +package com.clindsay94.remex.ui.files.preview + +import com.clindsay94.remex.service.FileTransferLimits +import java.io.ByteArrayOutputStream +import java.io.IOException + +/** + * One range of a previewed file. [offset] is where [data] starts in the file (for a read from the end, where + * the tail began), [fileSize] the file's size when it was read, and [eof] true when [data] reaches the end. + */ +class RangeChunk(val offset: Long, val data: ByteArray, val fileSize: Long, val eof: Boolean) + +/** + * Reads one range of the previewed file: from [offset], or the last [length] bytes when fromEnd is true. + * A PC file goes through `PcFileRequests.readRange`, a phone file through `LocalDocuments.readRange`, and tests + * use an in-memory file. A failure is thrown as an [IOException] carrying the reason to show. + */ +fun interface RangeReader { + suspend fun read(offset: Long, length: Int, fromEnd: Boolean): RangeChunk +} + +/** The file is bigger than a preview will fetch; the screen offers "Save to phone" instead. */ +class PreviewTooLargeException(val fileSize: Long, val limit: Long) : + IOException("The file is $fileSize bytes; previews stop at $limit.") + +/** Fetches a whole image for the full-size preview, in 1 MiB reads. Twin of the PC's `ImagePreviewLoader`. */ +object ImagePreviewLoader { + /** + * All of the file's bytes, read in order. Refuses after the first read when the file is over [maxBytes], and + * refuses a source that stops making progress rather than looping. + * + * @param progress 0..1 of the bytes fetched so far. + */ + suspend fun load( + read: RangeReader, + progress: ((Float) -> Unit)? = null, + maxBytes: Long = FileTransferLimits.PREVIEW_IMAGE_MAX_BYTES, + ): ByteArray { + var chunk = read.read(0, FileTransferLimits.READ_RANGE_MAX_BYTES, false) + if (chunk.fileSize > maxBytes) throw PreviewTooLargeException(chunk.fileSize, maxBytes) + + val buffer = ByteArrayOutputStream(chunk.fileSize.coerceIn(0, maxBytes).toInt()) + while (true) { + if (chunk.offset != buffer.size().toLong()) throw IOException("The bytes arrived out of order.") + buffer.write(chunk.data) + if (buffer.size() > maxBytes) throw PreviewTooLargeException(buffer.size().toLong(), maxBytes) + if (chunk.fileSize > 0) progress?.invoke((buffer.size().toFloat() / chunk.fileSize).coerceIn(0f, 1f)) + if (chunk.eof) break + if (chunk.data.isEmpty()) throw IOException("The file stopped before its end.") + chunk = read.read(buffer.size().toLong(), FileTransferLimits.READ_RANGE_MAX_BYTES, false) + } + progress?.invoke(1f) + return buffer.toByteArray() + } +} + +/** The start of a text file, decoded for the preview. */ +data class TextPreview(val text: String, val isBinary: Boolean, val truncated: Boolean, val fileSize: Long) + +/** Fetches and decodes the start of a text file, up to 2 MiB. Twin of the PC's `TextPreviewLoader`. */ +object TextPreviewLoader { + /** + * The first [maxBytes] of the file, decoded. Stops after the first read when its leading bytes look binary, + * so a mislabelled 2 GB file costs one read, not two megabytes. + */ + suspend fun loadHead(read: RangeReader, maxBytes: Int = FileTransferLimits.PREVIEW_TEXT_MAX_BYTES): TextPreview { + val bytes = ByteArrayOutputStream() + var offset = 0L + var chunk: RangeChunk + do { + val want = minOf(FileTransferLimits.READ_RANGE_MAX_BYTES, maxBytes - bytes.size()) + chunk = read.read(offset, want, false) + if (offset == 0L && TextPreviewDecoder.looksBinary(chunk.data)) { + return TextPreview("", isBinary = true, truncated = false, fileSize = chunk.fileSize) + } + bytes.write(chunk.data) + offset += chunk.data.size + if (chunk.data.isEmpty()) break + } while (!chunk.eof && bytes.size() < maxBytes) + + val truncated = !chunk.eof + val text = TextPreviewDecoder.decode(bytes.toByteArray(), startsMidFile = false, endsMidFile = truncated) + return TextPreview(text, isBinary = false, truncated = truncated, fileSize = chunk.fileSize) + } +} + +/** + * What one live-tail step changed. + * + * @property reset the file shrank or was replaced: throw away what is shown and use [lines]. + * @property lines complete lines to append (or, on a reset, the whole visible tail). + * @property pendingLine the last line so far, still being written (no newline yet); replaces the previous one. + */ +data class TailUpdate(val reset: Boolean, val lines: List, val pendingLine: String, val fileSize: Long) + +/** + * Live tail for a growing text file. Twin of the PC's `TextTail`: starts at the last 256 KiB, then on every poll + * reads only what was added since. A file that shrinks restarts from its new tail; a line still being written is + * pending until its newline arrives; a character split across two polls is never shown as U+FFFD; and a file + * that never writes a newline keeps only the last [tailBytes] of its unfinished line. + */ +class TextTail( + private val read: RangeReader, + private val tailBytes: Int = FileTransferLimits.PREVIEW_TEXT_TAIL_BYTES, +) { + private var nextOffset = 0L + private var carry = ByteArray(0) + private var started = false + + /** Reads the tail and returns it as a reset. Safe to call again to start over. */ + suspend fun start(): TailUpdate { + val chunk = read.read(0, tailBytes, true) + var data = chunk.data + started = true + carry = ByteArray(0) + nextOffset = chunk.offset + data.size + // A tail that starts mid-file drops its first, partial line. + if (chunk.offset > 0) { + val newline = TextPreviewDecoder.indexOf(data, '\n'.code.toByte(), 0, data.size) + data = if (newline >= 0) data.copyOfRange(newline + 1, data.size) else ByteArray(0) + } + val (lines, pending) = split(data) + return TailUpdate(reset = true, lines = lines, pendingLine = pending, fileSize = chunk.fileSize) + } + + /** Reads what was added since the last call (or restarts when the file shrank). */ + suspend fun poll(): TailUpdate { + if (!started) return start() + val chunk = read.read(nextOffset, FileTransferLimits.READ_RANGE_MAX_BYTES, false) + if (chunk.fileSize < nextOffset) return start() + nextOffset += chunk.data.size + val (lines, pending) = split(carry + chunk.data) + return TailUpdate(reset = false, lines = lines, pendingLine = pending, fileSize = chunk.fileSize) + } + + private fun split(bytes: ByteArray): Pair, String> { + var lastNewline = -1 + for (i in bytes.indices.reversed()) if (bytes[i] == '\n'.code.toByte()) { lastNewline = i; break } + val complete = if (lastNewline >= 0) bytes.copyOfRange(0, lastNewline + 1) else ByteArray(0) + carry = if (lastNewline >= 0) bytes.copyOfRange(lastNewline + 1, bytes.size) else bytes + if (carry.size > tailBytes) { + var start = carry.size - tailBytes + while (start < carry.size && (carry[start].toInt() and 0b1100_0000) == 0b1000_0000) start++ + carry = carry.copyOfRange(start, carry.size) + } + + val lines = if (complete.isEmpty()) { + emptyList() + } else { + TextPreviewDecoder.utf8(complete, 0, complete.size).split('\n').dropLast(1).map { it.trimEnd('\r') } + } + val pending = TextPreviewDecoder.utf8(carry, 0, TextPreviewDecoder.completeUtf8Length(carry)).trimEnd('\r') + return lines to pending + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/SyntaxTokenizer.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/SyntaxTokenizer.kt new file mode 100644 index 00000000..d04b716f --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/SyntaxTokenizer.kt @@ -0,0 +1,211 @@ +package com.clindsay94.remex.ui.files.preview + +/** What a coloured stretch of a previewed line is (file browser redesign, 2026-10-08). */ +enum class SyntaxKind { + Plain, + Comment, + String, + Number, + Keyword, + /** A JSON object key, or an XML tag or attribute name. */ + Key, + Timestamp, + LogError, + LogWarning, + LogDebug, +} + +/** One coloured stretch of a line: characters `[start, start + length)`. */ +data class SyntaxSpan(val start: Int, val length: Int, val kind: SyntaxKind) + +/** Which rules colour a previewed file. */ +enum class SyntaxLanguage { + Plain, + Log, + Json, + Xml, + /** C-like: `//` comments, quoted strings, numbers. */ + CLike, + /** Scripts and config: `#` comments, quoted strings, numbers. */ + Hash, +} + +/** + * Light, per-line colouring for the text preview: log levels and timestamps, JSON keys and strings, XML tags, + * comments, strings and numbers. Twin of the PC's `SyntaxTokenizer`, rule for rule; the shared vectors in + * `file-preview-vectors.json` hold the two to each other. Deliberately not a parser: each line is coloured on + * its own, so a live tail can colour each new line as it arrives. + */ +object SyntaxTokenizer { + /** The rules for [fileName], by extension. */ + fun languageFor(fileName: String): SyntaxLanguage = when (PreviewClassifier.extensionOf(fileName)) { + ".log", ".txt" -> SyntaxLanguage.Log + ".json", ".jsonc" -> SyntaxLanguage.Json + ".xml", ".axaml", ".xaml", ".html", ".htm", ".csproj", ".props", ".targets", ".resx" -> SyntaxLanguage.Xml + ".cs", ".kt", ".kts", ".java", ".gradle", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".c", ".h", + ".cpp", ".hpp", ".go", ".rs", ".css" -> SyntaxLanguage.CLike + ".py", ".rb", ".sh", ".bash", ".zsh", ".ps1", ".psm1", ".psd1", ".yaml", ".yml", ".toml", + ".ini", ".cfg", ".conf", ".properties", ".env", ".gitignore", ".editorconfig" -> SyntaxLanguage.Hash + else -> SyntaxLanguage.Plain + } + + /** The coloured stretches of [line], in order and non-overlapping. Plain gaps are omitted. */ + fun tokenize(line: String, language: SyntaxLanguage): List = when (language) { + SyntaxLanguage.Log -> tokenizeLog(line) + SyntaxLanguage.Json -> tokenizeCode(line, lineComment = null, jsonKeys = true) + SyntaxLanguage.Xml -> tokenizeXml(line) + SyntaxLanguage.CLike -> tokenizeCode(line, lineComment = "//", jsonKeys = false) + SyntaxLanguage.Hash -> tokenizeCode(line, lineComment = "#", jsonKeys = false) + SyntaxLanguage.Plain -> emptyList() + } + + // Upper case as most loggers write them, plus the four-letter lower-case forms the .NET console logger + // (and so the PC's own logs) writes. Whole words only, so "NoErrors" or "failover" never colour a line. + private val errorWords = setOf("ERROR", "ERR", "FATAL", "CRITICAL", "CRIT", "FAIL", "FAILED", "EXCEPTION", "fail", "crit") + private val warningWords = setOf("WARN", "WARNING", "WRN", "warn") + private val debugWords = setOf("DEBUG", "DBG", "TRACE", "TRC", "VERBOSE", "VRB", "dbug", "trce") + + private fun tokenizeLog(line: String): List { + val spans = mutableListOf() + val timestampEnd = leadingTimestampLength(line) + if (timestampEnd > 0) spans += SyntaxSpan(0, timestampEnd, SyntaxKind.Timestamp) + val level = levelOf(line) + if (level != SyntaxKind.Plain && timestampEnd < line.length) { + spans += SyntaxSpan(timestampEnd, line.length - timestampEnd, level) + } + return spans + } + + /** The level a log line is at, judged by its first whole level word (ERROR, WARN, DEBUG…), case-sensitive. */ + internal fun levelOf(line: String): SyntaxKind { + var i = 0 + while (i < line.length) { + while (i < line.length && !isAsciiLetter(line[i])) i++ + val start = i + while (i < line.length && isAsciiLetter(line[i])) i++ + if (i > start) { + val word = line.substring(start, i) + if (word in errorWords) return SyntaxKind.LogError + if (word in warningWords) return SyntaxKind.LogWarning + if (word in debugWords) return SyntaxKind.LogDebug + } + } + return SyntaxKind.Plain + } + + /** + * The length of a leading timestamp: an optional '[', then digits and the separators of a date or time, + * at least one ':' or '-', and an optional ']'. Zero when the line does not start with one. + */ + internal fun leadingTimestampLength(line: String): Int { + var i = 0 + if (i < line.length && line[i] == '[') i++ + val digitsStart = i + var sawSeparator = false + var sawDigit = false + while (i < line.length) { + val c = line[i] + when { + isAsciiDigit(c) -> { sawDigit = true; i++ } + c == ':' || c == '-' || c == '/' -> { sawSeparator = true; i++ } + (c == '.' || c == ',' || c == 'T' || c == 'Z' || c == '+') && sawDigit -> i++ + c == ' ' && i + 1 < line.length && isAsciiDigit(line[i + 1]) && sawSeparator -> i++ + else -> break + } + } + if (!sawDigit || !sawSeparator || i - digitsStart < 5) return 0 + if (i < line.length && line[i] == ']') i++ + return i + } + + private fun tokenizeCode(line: String, lineComment: String?, jsonKeys: Boolean): List { + val spans = mutableListOf() + var i = 0 + while (i < line.length) { + val c = line[i] + if (lineComment != null && line.startsWith(lineComment, i)) { + spans += SyntaxSpan(i, line.length - i, SyntaxKind.Comment) + break + } + if (c == '"' || c == '\'' || (c == '`' && !jsonKeys)) { + val end = stringEnd(line, i) + val kind = if (jsonKeys && c == '"' && nextNonSpace(line, end) == ':') SyntaxKind.Key else SyntaxKind.String + spans += SyntaxSpan(i, end - i, kind) + i = end + continue + } + if (isAsciiDigit(c) && (i == 0 || !isWordChar(line[i - 1]))) { + var end = i + while (end < line.length && (isAsciiHexDigit(line[end]) || line[end] in ".xX_")) end++ + if (end == line.length || !isWordChar(line[end])) { + spans += SyntaxSpan(i, end - i, SyntaxKind.Number) + i = end + continue + } + } + if (jsonKeys && isAsciiLetter(c) && (i == 0 || !isWordChar(line[i - 1]))) { + var end = i + while (end < line.length && isAsciiLetter(line[end])) end++ + val word = line.substring(i, end) + if (word == "true" || word == "false" || word == "null") spans += SyntaxSpan(i, end - i, SyntaxKind.Keyword) + i = end + continue + } + i++ + } + return spans + } + + private fun tokenizeXml(line: String): List { + val spans = mutableListOf() + var i = 0 + while (i < line.length) { + if (line.startsWith("", i + 4) + val end = if (close < 0) line.length else close + 3 + spans += SyntaxSpan(i, end - i, SyntaxKind.Comment) + i = end + continue + } + if (line[i] == '<') { + var start = i + 1 + if (start < line.length && line[start] in "/?!") start++ + var end = start + while (end < line.length && (isWordChar(line[end]) || line[end] in ":-.")) end++ + if (end > start) spans += SyntaxSpan(start, end - start, SyntaxKind.Key) + i = end // never stalls: start is already past the '<' + continue + } + if (line[i] == '"' || line[i] == '\'') { + val end = stringEnd(line, i) + spans += SyntaxSpan(i, end - i, SyntaxKind.String) + i = end + continue + } + i++ + } + return spans + } + + /** One past the closing quote of the string opening at [open], honouring backslash escapes; the line's end if unclosed. */ + private fun stringEnd(line: String, open: Int): Int { + val quote = line[open] + var i = open + 1 + while (i < line.length) { + if (line[i] == '\\') { i += 2; continue } + if (line[i] == quote) return i + 1 + i++ + } + return line.length + } + + private fun nextNonSpace(line: String, from: Int): Char { + for (i in from until line.length) if (!line[i].isWhitespace()) return line[i] + return '\u0000' + } + + private fun isAsciiLetter(c: Char) = c in 'a'..'z' || c in 'A'..'Z' + private fun isAsciiDigit(c: Char) = c in '0'..'9' + private fun isAsciiHexDigit(c: Char) = isAsciiDigit(c) || c in 'a'..'f' || c in 'A'..'F' + private fun isWordChar(c: Char) = isAsciiLetter(c) || isAsciiDigit(c) || c == '_' +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/TextPreviewDecoder.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/TextPreviewDecoder.kt new file mode 100644 index 00000000..52aab993 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/preview/TextPreviewDecoder.kt @@ -0,0 +1,98 @@ +package com.clindsay94.remex.ui.files.preview + +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +/** + * Turns the raw bytes of a text preview into a string (file browser redesign, 2026-10-08). Twin of the PC's + * `TextPreviewDecoder`, with the same edge cases: a byte-order mark, a read that stopped in the middle of a + * multi-byte character, and a tail that started in the middle of a line. + */ +object TextPreviewDecoder { + /** How many leading bytes are checked for NUL when deciding whether a file is binary. */ + const val SNIFF_BYTES = 8 * 1024 + + /** + * True when [bytes] look binary: a NUL in the first [SNIFF_BYTES]. UTF-16 text has NULs too, so a UTF-16 + * byte-order mark is checked first and wins. + */ + fun looksBinary(bytes: ByteArray, length: Int = bytes.size): Boolean { + if (startsWithUtf16Bom(bytes, length)) return false + val head = minOf(length, SNIFF_BYTES) + for (i in 0 until head) if (bytes[i] == 0.toByte()) return true + return false + } + + /** + * Decodes [bytes]: UTF-8 (with or without a BOM) or UTF-16 by its BOM, invalid sequences shown as U+FFFD + * rather than refused. + * + * @param startsMidFile the bytes are a tail, not the start of the file: the first, probably partial, line is + * dropped, along with any continuation bytes of a character that began before the read. + * @param endsMidFile the file goes on past these bytes: a multi-byte character cut by the end of the read is + * dropped instead of becoming U+FFFD (the next read starts with it whole). + */ + fun decode(bytes: ByteArray, startsMidFile: Boolean = false, endsMidFile: Boolean = false): String { + var start = 0 + var end = bytes.size + if (!startsMidFile) { + if (startsWith(bytes, 0xFF, 0xFE)) return String(bytes, 2, bytes.size - 2, StandardCharsets.UTF_16LE) + if (startsWith(bytes, 0xFE, 0xFF)) return String(bytes, 2, bytes.size - 2, StandardCharsets.UTF_16BE) + if (startsWith(bytes, 0xEF, 0xBB, 0xBF)) start = 3 + } + if (endsMidFile) end = start + completeUtf8Length(bytes, start, end) + if (startsMidFile) { + val newline = indexOf(bytes, '\n'.code.toByte(), start, end) + start = if (newline >= 0) newline + 1 else end + } + return utf8(bytes, start, end) + } + + /** + * The length of `bytes[from, to)` without a trailing, incomplete UTF-8 sequence. Looks back at most three + * bytes, which is as far as a sequence can be cut. + */ + fun completeUtf8Length(bytes: ByteArray, from: Int = 0, to: Int = bytes.size): Int { + val n = to - from + var back = 1 + while (back <= 3 && back <= n) { + val b = bytes[to - back].toInt() and 0xFF + if (b and 0b1100_0000 == 0b1000_0000) { + back++ + continue // a continuation byte: keep looking for the lead + } + val need = when { + b and 0b1000_0000 == 0 -> 1 + b and 0b1110_0000 == 0b1100_0000 -> 2 + b and 0b1111_0000 == 0b1110_0000 -> 3 + b and 0b1111_1000 == 0b1111_0000 -> 4 + else -> 1 // not a valid lead: leave it for the decoder to replace + } + return if (back < need) n - back else n + } + return n + } + + /** + * UTF-8 with every invalid sequence replaced by U+FFFD, like .NET's `Encoding.UTF8`. Java's `String(bytes, + * UTF_8)` already replaces, but says nothing about how; the explicit decoder makes the rule visible. + */ + internal fun utf8(bytes: ByteArray, from: Int, to: Int): String = + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .decode(ByteBuffer.wrap(bytes, from, to - from)) + .toString() + + private fun startsWithUtf16Bom(bytes: ByteArray, length: Int): Boolean = + length >= 2 && (startsWith(bytes, 0xFF, 0xFE) || startsWith(bytes, 0xFE, 0xFF)) + + private fun startsWith(bytes: ByteArray, vararg prefix: Int): Boolean = + bytes.size >= prefix.size && prefix.indices.all { (bytes[it].toInt() and 0xFF) == prefix[it] } + + internal fun indexOf(bytes: ByteArray, value: Byte, from: Int, to: Int): Int { + for (i in from until to) if (bytes[i] == value) return i + return -1 + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferViewModel.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferViewModel.kt index 09362cde..f2041133 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferViewModel.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferViewModel.kt @@ -24,6 +24,7 @@ import com.clindsay94.remex.service.FileTransferJobService import com.clindsay94.remex.service.FileTransferLimits import com.clindsay94.remex.service.FileTransferNotificationManager import com.clindsay94.remex.ui.components.FileConflictPrompt +import com.clindsay94.remex.ui.files.PcFileRequests import com.clindsay94.remex.service.TransferProgressFormat import com.clindsay94.remex.service.TransferProgressText import com.clindsay94.remex.service.TransferRateEstimator @@ -269,6 +270,20 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica */ private val transferRate = TransferRateEstimator() + /** + * Range reads (previews, live tails) and SHA-256 requests to the PC (file browser redesign, 2026-10-08). + * Replies come in on the same file-message feed as everything else and are handed over in + * [handleFileTransferMessage], matched by request id. + */ + internal val pcFileRequests = PcFileRequests { msg -> + try { + RemexCoreClient.SendMessage(msg.toString()).isSuccess + } catch (e: Exception) { + Log.e(TAG, "SendMessage failed", e) + false + } + } + init { // Start the (idempotent) engine so its queue is readable and its control-message collector is // live before the user enqueues anything; the foreground service holds the process open. @@ -1727,6 +1742,7 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica private fun handleFileTransferMessage(json: String) { try { val obj = JSONObject(json) + if (pcFileRequests.onMessage(obj)) return when (obj.optString("type")) { "file_roots_response" -> handleRootsResponse(obj) "file_browse_response" -> handleBrowseResponse(obj) diff --git a/remex.android/app/src/main/res/values-es/strings.xml b/remex.android/app/src/main/res/values-es/strings.xml index 4341de55..f9906f7d 100644 --- a/remex.android/app/src/main/res/values-es/strings.xml +++ b/remex.android/app/src/main/res/values-es/strings.xml @@ -397,6 +397,7 @@ quedan %1$d horas Transferencia completada + Transferencia verificada %1$s subido No se pudo enviar %1$s No se pudo descargar %1$s @@ -707,6 +708,9 @@ En pausa • %1$s Verificando… Completada + Verificado + Copiar huella SHA-256 + Huella SHA-256 copiada Error Cancelada Explorar este PC diff --git a/remex.android/app/src/main/res/values-fr/strings.xml b/remex.android/app/src/main/res/values-fr/strings.xml index e772aa1d..7423122f 100644 --- a/remex.android/app/src/main/res/values-fr/strings.xml +++ b/remex.android/app/src/main/res/values-fr/strings.xml @@ -397,6 +397,7 @@ %1$d heures restantes Transfert terminé + Transfert vérifié %1$s envoyé Envoi de %1$s échoué Impossible de télécharger %1$s @@ -707,6 +708,9 @@ En pause • %1$s Vérification… Terminé + Vérifié + Copier l’empreinte SHA-256 + Empreinte SHA-256 copiée Échec Annulé Parcourir ce PC diff --git a/remex.android/app/src/main/res/values-hi/strings.xml b/remex.android/app/src/main/res/values-hi/strings.xml index 01dbd536..523de7bf 100644 --- a/remex.android/app/src/main/res/values-hi/strings.xml +++ b/remex.android/app/src/main/res/values-hi/strings.xml @@ -397,6 +397,7 @@ %1$d घंटे बाकी ट्रांसफर पूरा हुआ + ट्रांसफ़र सत्यापित %1$s अपलोड हो गया %1$s भेजने में विफल %1$s डाउनलोड नहीं हो सका @@ -707,6 +708,9 @@ रोका गया • %1$s सत्यापित हो रहा है… पूर्ण + सत्यापित + SHA-256 फ़िंगरप्रिंट कॉपी करें + SHA-256 फ़िंगरप्रिंट कॉपी हो गया विफल रद्द इस PC को ब्राउज़ करें diff --git a/remex.android/app/src/main/res/values-in/strings.xml b/remex.android/app/src/main/res/values-in/strings.xml index 9fd45ff3..d85d135a 100644 --- a/remex.android/app/src/main/res/values-in/strings.xml +++ b/remex.android/app/src/main/res/values-in/strings.xml @@ -390,6 +390,7 @@ %1$d jam lagi Transfer selesai + Transfer terverifikasi Berhasil mengunggah %1$s Gagal mengirim %1$s Tidak dapat mengunduh %1$s @@ -700,6 +701,9 @@ Dijeda • %1$s Memverifikasi… Selesai + Terverifikasi + Salin sidik jari SHA-256 + Sidik jari SHA-256 disalin Gagal Dibatalkan Jelajahi PC ini diff --git a/remex.android/app/src/main/res/values-pl/strings.xml b/remex.android/app/src/main/res/values-pl/strings.xml index 5de99917..a17513c8 100644 --- a/remex.android/app/src/main/res/values-pl/strings.xml +++ b/remex.android/app/src/main/res/values-pl/strings.xml @@ -395,6 +395,7 @@ pozostało %1$d godziny Transfer zakończony + Przesyłanie zweryfikowane Wysłano %1$s Nie udało się wysłać %1$s Nie udało się pobrać %1$s @@ -700,6 +701,9 @@ Wstrzymano • %1$s Weryfikowanie… Gotowe + Zweryfikowano + Kopiuj odcisk SHA-256 + Skopiowano odcisk SHA-256 Niepowodzenie Anulowano Przeglądaj ten komputer diff --git a/remex.android/app/src/main/res/values-pt-rBR/strings.xml b/remex.android/app/src/main/res/values-pt-rBR/strings.xml index caeb32b2..0a7d475e 100644 --- a/remex.android/app/src/main/res/values-pt-rBR/strings.xml +++ b/remex.android/app/src/main/res/values-pt-rBR/strings.xml @@ -397,6 +397,7 @@ restam %1$d horas Transferência concluída + Transferência verificada %1$s enviado com sucesso Falha ao enviar %1$s Não foi possível baixar %1$s @@ -707,6 +708,9 @@ Pausado • %1$s Verificando… Concluído + Verificado + Copiar impressão digital SHA-256 + Impressão digital SHA-256 copiada Falhou Cancelado Explorar este PC diff --git a/remex.android/app/src/main/res/values-tr/strings.xml b/remex.android/app/src/main/res/values-tr/strings.xml index 7ea8d0ee..7764ffa9 100644 --- a/remex.android/app/src/main/res/values-tr/strings.xml +++ b/remex.android/app/src/main/res/values-tr/strings.xml @@ -397,6 +397,7 @@ %1$d saat kaldı Aktarım tamamlandı + Aktarım doğrulandı %1$s yüklendi %1$s gönderilemedi %1$s indirilemedi @@ -707,6 +708,9 @@ Duraklatıldı • %1$s Doğrulanıyor… Tamamlandı + Doğrulandı + SHA-256 parmak izini kopyala + SHA-256 parmak izi kopyalandı Başarısız İptal edildi Bu bilgisayara göz at diff --git a/remex.android/app/src/main/res/values-uk/strings.xml b/remex.android/app/src/main/res/values-uk/strings.xml index 625c3c80..aa812b84 100644 --- a/remex.android/app/src/main/res/values-uk/strings.xml +++ b/remex.android/app/src/main/res/values-uk/strings.xml @@ -411,6 +411,7 @@ залишилося %1$d години Передавання завершено + Передавання перевірено %1$s вивантажено Не вдалося надіслати %1$s Не вдалося завантажити %1$s @@ -721,6 +722,9 @@ Призупинено • %1$s Перевірка… Готово + Перевірено + Копіювати відбиток SHA-256 + Відбиток SHA-256 скопійовано Помилка Скасовано Огляд цього ПК diff --git a/remex.android/app/src/main/res/values/strings.xml b/remex.android/app/src/main/res/values/strings.xml index 26b311a6..02233618 100644 --- a/remex.android/app/src/main/res/values/strings.xml +++ b/remex.android/app/src/main/res/values/strings.xml @@ -596,6 +596,7 @@ %1$d hours left Transfer complete + Transfer verified Uploaded %1$s Could not send %1$s Could not download %1$s @@ -887,6 +888,9 @@ Paused • %1$s Verifying… Done + Verified + Copy SHA-256 fingerprint + SHA-256 fingerprint copied Failed Cancelled Browse this PC diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt new file mode 100644 index 00000000..d8e0755c --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt @@ -0,0 +1,89 @@ +package com.clindsay94.remex.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Phone-only bookmarks and the one "Access from your PC" list (file browser redesign, 2026-10-08). */ +class PhoneBookmarksRepositoryTest { + private class MapStore(shared: Set = emptySet(), fullBrowse: String? = null) : PhoneFolderStore { + override val sharedUris = MutableStateFlow(shared) + override val bookmarkUris = MutableStateFlow(emptySet()) + override val otherHeldUris = MutableStateFlow(setOfNotNull(fullBrowse)) + override suspend fun setShared(uri: String, shared: Boolean) { + sharedUris.value = if (shared) sharedUris.value + uri else sharedUris.value - uri + } + override suspend fun setBookmarked(uri: String, bookmarked: Boolean) { + bookmarkUris.value = if (bookmarked) bookmarkUris.value + uri else bookmarkUris.value - uri + } + } + + private val released = mutableListOf() + + @Test + fun aBookmark_IsNotSharedUntilThePersonSharesIt() = runBlocking { + val store = MapStore() + val repo = PhoneBookmarksRepository(store) { released += it } + + repo.addBookmark("content://tree/Music") + + assertTrue("the PC-facing list is untouched", store.sharedUris.value.isEmpty()) + assertEquals(listOf(PhoneFolder("content://tree/Music", isBookmark = true, isShared = false)), repo.folders.first()) + + repo.setShared("content://tree/Music", true) + assertEquals(setOf("content://tree/Music"), store.sharedUris.value) + assertEquals(listOf(PhoneFolder("content://tree/Music", isBookmark = true, isShared = true)), repo.folders.first()) + } + + @Test + fun unsharingABookmark_KeepsItOnThePhone_AndKeepsItsGrant() = runBlocking { + val store = MapStore() + val repo = PhoneBookmarksRepository(store) { released += it } + repo.addBookmark("u") + repo.setShared("u", true) + + repo.setShared("u", false) + + assertEquals(listOf(PhoneFolder("u", isBookmark = true, isShared = false)), repo.folders.first()) + assertTrue(released.isEmpty()) + } + + @Test + fun removingABookmark_ReleasesItsGrant_UnlessItIsStillShared() = runBlocking { + val store = MapStore(shared = setOf("shared")) + val repo = PhoneBookmarksRepository(store) { released += it } + repo.addBookmark("shared") + repo.addBookmark("phone-only") + + repo.removeBookmark("shared") + repo.removeBookmark("phone-only") + + assertEquals(listOf("phone-only"), released) + assertEquals(listOf(PhoneFolder("shared", isBookmark = false, isShared = true)), repo.folders.first()) + } + + @Test + fun theWholeDeviceBrowseGrant_IsNeverReleasedHere() = runBlocking { + val store = MapStore(shared = setOf("root"), fullBrowse = "root") + val repo = PhoneBookmarksRepository(store) { released += it } + + repo.setShared("root", false) + + assertTrue(released.isEmpty()) + } + + @Test + fun sharedFoldersComeFirst_ThenPhoneOnlyBookmarks() { + assertEquals( + listOf( + PhoneFolder("a", isBookmark = false, isShared = true), + PhoneFolder("c", isBookmark = true, isShared = true), + PhoneFolder("b", isBookmark = true, isShared = false), + ), + PhoneBookmarksRepository.merge(shared = setOf("c", "a"), bookmarks = setOf("b", "c")), + ) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/service/TransferVerifiedTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/service/TransferVerifiedTest.kt new file mode 100644 index 00000000..fc83f8a5 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/service/TransferVerifiedTest.kt @@ -0,0 +1,61 @@ +package com.clindsay94.remex.service + +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Verified" on the phone's transfer rows (file browser redesign, 2026-10-08): claimed only when a SHA-256 was + * really compared and matched, the same rule the PC's queue follows. + */ +class TransferVerifiedTest { + private val abcBase64 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=" + private val abcHex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + + private fun row(state: TransferState, verified: Boolean, sha: String? = abcBase64) = + QueuedTransfer("t", "upload", "a.txt", 3, "content://x", state = state, sha256 = sha, verified = verified) + + @Test + fun aVerifiedFinishedRow_ShowsItsHashAsHex() { + assertEquals(abcHex, row(TransferState.Done, verified = true).verifiedSha256Hex) + } + + @Test + fun aRowThatOnlyComputedAHash_OrIsNotFinished_ClaimsNothing() { + assertNull("nothing compared", row(TransferState.Done, verified = false).verifiedSha256Hex) + assertNull("still comparing", row(TransferState.Verifying, verified = false).verifiedSha256Hex) + assertNull("no hash", row(TransferState.Done, verified = true, sha = null).verifiedSha256Hex) + } + + @Test + fun theFlagSurvivesARestart_AndAnOlderRowIsNotVerified() { + val saved = row(TransferState.Done, verified = true).toJson() + assertTrue(QueuedTransfer.fromJson(JSONObject(saved.toString())).verified) + + val older = JSONObject(saved.toString()).apply { remove("verified") } + assertFalse(QueuedTransfer.fromJson(older).verified) + } + + /** + * The engine is the only writer of the flag and needs a live channel to drive, so its two writes are pinned + * here by shape: an upload is verified when the host confirmed the hash, a download only when the host sent + * one to compare against. + */ + @Test + fun theEngine_SetsTheFlagOnlyWhereAHashWasCompared() { + val root = System.getProperty("remex.repoRoot")?.let(::File) + ?: generateSequence(File(".").absoluteFile) { it.parentFile }.first { File(it, "remex.android").isDirectory } + val engine = File(root, "remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt").readText() + + assertTrue(engine.contains("if (result != null && result.verified) {\n updateState(t.id) { it.copy(state = TransferState.Done, verified = true) }")) + assertTrue(engine.contains("it.copy(state = TransferState.Done, sha256 = actualSha, verified = expectedSha != null)")) + assertEquals("no other row write may claim verified", 2, Regex("""copy\([^)]*verified = """).findAll(engine).count()) + // The finish notification follows the same two facts. + assertTrue(engine.contains("showTransferComplete(appContext, t.fileName, isDownload = false, verified = true)")) + assertTrue(engine.contains("showDownloadComplete(appContext, t.fileName, t.localUri, verified = expectedSha != null)")) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewVectorsTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewVectorsTest.kt new file mode 100644 index 00000000..f5b07bc2 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewVectorsTest.kt @@ -0,0 +1,81 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.ui.files.preview.PreviewClassifier +import com.clindsay94.remex.ui.files.preview.PreviewKind +import com.clindsay94.remex.ui.files.preview.SyntaxKind +import com.clindsay94.remex.ui.files.preview.SyntaxLanguage +import com.clindsay94.remex.ui.files.preview.SyntaxSpan +import com.clindsay94.remex.ui.files.preview.SyntaxTokenizer +import com.clindsay94.remex.ui.files.preview.TextPreviewDecoder +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The phone half of the preview parity check (file browser redesign, 2026-10-08). The vectors were generated from + * the PC's C# classifier, decoder and tokenizer; `FilePreviewVectorTests` in remex.desktop.tests asserts the PC + * against the same file and keeps this copy byte-identical. A rule changed on one platform only fails one side. + */ +class FilePreviewVectorsTest { + private val doc = JSONObject( + javaClass.classLoader!!.getResourceAsStream("file-preview-vectors.json")!!.readBytes().toString(Charsets.UTF_8), + ) + + private fun section(name: String): List { + val array = doc.getJSONArray(name) + assertTrue("an empty '$name' section would pass vacuously", array.length() > 3) + return (0 until array.length()).map { array.getJSONObject(it) } + } + + @Test + fun theClassifierMatchesThePc() { + for (v in section("classify")) { + val name = v.getString("name") + // decodesHeif = false asks for the PC's answer; the phone's own HEIF rule is checked below. + assertEquals(name, v.getString("kind"), PreviewClassifier.classify(name, v.getBoolean("isDirectory"), decodesHeif = false).name) + } + } + + @Test + fun onThePhone_HeifIsAFullImage_AndEverythingElseIsAsOnThePc() { + for (v in section("classify")) { + val name = v.getString("name") + val pc = PreviewKind.valueOf(v.getString("kind")) + val expected = if (pc == PreviewKind.ImageThumbnailOnly) PreviewKind.Image else pc + assertEquals(name, expected, PreviewClassifier.classify(name, v.getBoolean("isDirectory"))) + } + } + + @Test + fun theLanguagesMatchThePc() { + for (v in section("languages")) { + assertEquals(v.getString("name"), v.getString("language"), SyntaxTokenizer.languageFor(v.getString("name")).name) + } + } + + @Test + fun theTokenizerMatchesThePc() { + for (v in section("tokenize")) { + val line = v.getString("line") + val spans: JSONArray = v.getJSONArray("spans") + val expected = (0 until spans.length()).map { + val s = spans.getJSONArray(it) + SyntaxSpan(s.getInt(0), s.getInt(1), SyntaxKind.valueOf(s.getString(2))) + } + assertEquals(line, expected, SyntaxTokenizer.tokenize(line, SyntaxLanguage.valueOf(v.getString("language")))) + } + } + + @Test + fun theDecoderMatchesThePc() { + for (v in section("decode")) { + val hex = v.getString("hex") + val bytes = ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + assertEquals(hex, v.getString("text"), TextPreviewDecoder.decode(bytes, v.getBoolean("startsMidFile"), v.getBoolean("endsMidFile"))) + assertEquals(hex, v.getInt("completeUtf8Length"), TextPreviewDecoder.completeUtf8Length(bytes)) + assertEquals(hex, v.getBoolean("looksBinary"), TextPreviewDecoder.looksBinary(bytes)) + } + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/LocalDocumentLogicTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/LocalDocumentLogicTest.kt new file mode 100644 index 00000000..858deb82 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/LocalDocumentLogicTest.kt @@ -0,0 +1,126 @@ +package com.clindsay94.remex.ui.files + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.MessageDigest +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Listing, walking, range reads and hashing of phone folders (file browser redesign, 2026-10-08). */ +class LocalDocumentLogicTest { + private val dir = "vnd.android.document/directory" + + // A tiny tree: root -> DCIM/ -> Camera/ -> IMG_1.jpg, and root -> notes.txt + private val tree = mapOf( + "root" to listOf( + LocalEntry("d1", "DCIM", true, -1, 0, dir), + LocalEntry("f1", "notes.txt", false, 5, 0, "text/plain"), + ), + "d1" to listOf(LocalEntry("d2", "Camera", true, -1, 0, dir)), + "d2" to listOf(LocalEntry("f2", "IMG_1.jpg", false, 3, 0, "image/jpeg")), + ) + private val listed = mutableListOf() + // Like a real provider, listing the children of a FILE is an error, not an empty folder. + private val childrenOf: suspend (String) -> List = { id -> + listed += id + tree[id] ?: throw IllegalArgumentException("$id is not a directory") + } + + @Test + fun aRow_BecomesAnEntry_AndARowWithNoNameIsSkipped() { + val folder = LocalDocumentLogic.entryOf("d", "DCIM", dir, 4096, 7)!! + assertTrue(folder.isDirectory) + assertEquals("a folder's size is not a file size", -1L, folder.sizeBytes) + + val file = LocalDocumentLogic.entryOf("f", "a.txt", "text/plain", null, null)!! + assertFalse(file.isDirectory) + assertEquals("no size from the provider is unknown, not zero", -1L, file.sizeBytes) + + assertNull(LocalDocumentLogic.entryOf("x", null, "text/plain", 1, 1)) + assertNull(LocalDocumentLogic.entryOf(null, "a", "text/plain", 1, 1)) + } + + @Test + fun aPath_IsWalkedOneListingPerLevel_AndRememberedForNextTime() = runBlocking { + val cache = HashMap() + + assertEquals("f2", LocalDocumentLogic.resolve("root", "DCIM/Camera/IMG_1.jpg", childrenOf, cache)) + assertEquals(listOf("root", "d1", "d2"), listed) + + listed.clear() + assertEquals("d2", LocalDocumentLogic.resolve("root", "/DCIM/Camera/", childrenOf, cache)) + assertTrue("a walked path costs no listing the second time", listed.isEmpty()) + assertEquals("root", LocalDocumentLogic.resolve("root", "", childrenOf, cache)) + } + + @Test + fun aMissingName_AnUnsafePath_OrAFileUsedAsAFolder_ResolvesToNothing() = runBlocking { + assertNull(LocalDocumentLogic.resolve("root", "DCIM/Screenshots", childrenOf)) + assertNull(LocalDocumentLogic.resolve("root", "DCIM/../notes.txt", childrenOf)) + assertNull(LocalDocumentLogic.resolve("root", "DCIM//Camera", childrenOf)) + assertNull(LocalDocumentLogic.resolve("root", "notes.txt/inside", childrenOf)) + } + + @Test + fun aRange_IsReadFromItsOffset_OrFromTheEnd() = runBlocking { + val bytes = ByteArray(100) { it.toByte() } + + val middle = LocalDocumentLogic.readRange(ByteArrayInputStream(bytes), 100, 10, 5, fromEnd = false) + assertEquals(10L, middle.offset) + assertArrayEquals(bytes.copyOfRange(10, 15), middle.data) + assertFalse(middle.eof) + + val tail = LocalDocumentLogic.readRange(ByteArrayInputStream(bytes), 100, 0, 30, fromEnd = true) + assertEquals(70L, tail.offset) + assertArrayEquals(bytes.copyOfRange(70, 100), tail.data) + assertTrue(tail.eof) + + val past = LocalDocumentLogic.readRange(ByteArrayInputStream(bytes), 100, 500, 5, fromEnd = false) + assertEquals(0, past.data.size) + assertTrue(past.eof) + } + + @Test + fun aFileOfUnknownSize_StillGivesARightTail_AndAnHonestSize() = runBlocking { + val bytes = ByteArray(1000) { (it % 251).toByte() } + + val tail = LocalDocumentLogic.readRange(ByteArrayInputStream(bytes), -1, 0, 64, fromEnd = true) + assertEquals(936L, tail.offset) + assertArrayEquals(bytes.copyOfRange(936, 1000), tail.data) + assertEquals(1000L, tail.fileSize) + + val head = LocalDocumentLogic.readRange(ByteArrayInputStream(bytes), -1, 0, 100, fromEnd = false) + assertFalse(head.eof) + assertTrue("never smaller than what was read, so a tail never thinks it shrank", head.fileSize >= 100) + } + + @Test + fun aStreamThatSkipsNothing_IsStillPositionedRight() = runBlocking { + val bytes = ByteArray(50) { it.toByte() } + val stubborn = object : InputStream() { + private val inner = ByteArrayInputStream(bytes) + override fun read(): Int = inner.read() + override fun read(b: ByteArray, off: Int, len: Int): Int = inner.read(b, off, len) + override fun skip(n: Long): Long = 0 + } + + val chunk = LocalDocumentLogic.readRange(stubborn, 50, 20, 5, fromEnd = false) + assertArrayEquals(bytes.copyOfRange(20, 25), chunk.data) + } + + @Test + fun theHash_IsTheSha256OfEveryByte() = runBlocking { + val bytes = ByteArray(200_000) { (it * 7).toByte() } + var progress = 0L + + val hash = LocalDocumentLogic.sha256(ByteArrayInputStream(bytes)) { progress = it } + + assertArrayEquals(MessageDigest.getInstance("SHA-256").digest(bytes), hash) + assertEquals(200_000L, progress) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt new file mode 100644 index 00000000..ef8d0f74 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt @@ -0,0 +1,121 @@ +package com.clindsay94.remex.ui.files + +import java.io.IOException +import java.util.Base64 +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** The phone's range-read and hash requests to the PC (file browser redesign, 2026-10-08). */ +class PcFileRequestsTest { + private val sent = CopyOnWriteArrayList() + private val requests = PcFileRequests { sent += it; true } + + private suspend fun awaitSent(count: Int): JSONObject { + while (sent.size < count) yield() + return sent[count - 1] + } + + private fun reply(type: String, key: String, body: JSONObject) = + requests.onMessage(JSONObject().put("type", type).put(key, body)) + + @Test + fun aRangeRead_SendsAV3RequestForThatFile_AndReturnsTheBytesOfItsReply() = runBlocking { + val pending = async { requests.readRange("docs", "Logs/agent.log", 1024, 4096, fromEnd = false) } + val envelope = awaitSent(1) + + assertEquals("file_read_range_request", envelope.getString("type")) + assertEquals(3, envelope.getInt("protocolVersion")) + val body = envelope.getJSONObject("fileReadRangeRequest") + assertEquals("docs", body.getString("rootId")) + assertEquals("Logs/agent.log", body.getString("relativePath")) + assertEquals(1024L, body.getLong("offset")) + assertEquals(4096, body.getInt("length")) + assertFalse(body.getBoolean("fromEnd")) + + val consumed = reply( + "file_read_range_response", "fileReadRangeResponse", + JSONObject().put("requestId", body.getString("requestId")).put("offset", 1024) + .put("dataBase64", Base64.getEncoder().encodeToString("hello".toByteArray())) + .put("fileSize", 1029).put("eof", true), + ) + val chunk = pending.await() + + assertTrue(consumed) + assertArrayEquals("hello".toByteArray(), chunk.data) + assertEquals(1024L, chunk.offset) + assertEquals(1029L, chunk.fileSize) + assertTrue(chunk.eof) + } + + @Test + fun aReplyForSomeoneElse_IsLeftAlone_AndTheRightOneStillArrives() = runBlocking { + val pending = async { requests.hash("docs", "a.bin") } + val id = awaitSent(1).getJSONObject("fileHashRequest").getString("requestId") + + assertFalse(reply("file_hash_response", "fileHashResponse", JSONObject().put("requestId", "not-mine").put("sha256", "x"))) + assertFalse(requests.onMessage(JSONObject().put("type", "file_browse_response"))) + assertTrue(reply("file_hash_response", "fileHashResponse", JSONObject().put("requestId", id).put("sha256", "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="))) + + assertEquals("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=", pending.await()) + } + + @Test + fun aRefusal_CarriesThePcsReason() = runBlocking { + val pending = async { runCatching { requests.readRange("docs", "gone.txt", 0, 10, fromEnd = false) } } + val id = awaitSent(1).getJSONObject("fileReadRangeRequest").getString("requestId") + reply("file_read_range_response", "fileReadRangeResponse", JSONObject().put("requestId", id).put("errorMessage", "File not found.")) + + val error = pending.await().exceptionOrNull() + assertTrue(error is PcRequestFailedException) + assertEquals("File not found.", (error as PcRequestFailedException).hostMessage) + } + + @Test + fun anAnswerForADifferentPartOfTheFile_IsRefused() = runBlocking { + val pending = async { runCatching { requests.readRange("docs", "a.txt", 100, 10, fromEnd = false) } } + val id = awaitSent(1).getJSONObject("fileReadRangeRequest").getString("requestId") + reply("file_read_range_response", "fileReadRangeResponse", JSONObject().put("requestId", id).put("offset", 0).put("dataBase64", "AAAA").put("fileSize", 3)) + + assertTrue(pending.await().exceptionOrNull() is IOException) + } + + @Test + fun noAnswer_TimesOut_AndForgetsTheRequest() = runBlocking { + try { + requests.readRange("docs", "a.txt", 0, 10, fromEnd = false, timeoutMs = 50) + fail("an unanswered request must time out") + } catch (_: PcRequestTimeoutException) { + } + val id = sent.single().getJSONObject("fileReadRangeRequest").getString("requestId") + assertFalse("a late reply finds no waiter", reply("file_read_range_response", "fileReadRangeResponse", JSONObject().put("requestId", id))) + } + + @Test + fun aRequestThatCannotBeSent_FailsAtOnce() = runBlocking { + val offline = PcFileRequests { false } + try { + offline.hash("docs", "a.bin", timeoutMs = 60_000) + fail("an unsendable request must fail, not wait") + } catch (e: IOException) { + assertFalse(e is PcRequestTimeoutException) + } + } + + @Test + fun failAll_ReleasesEveryWaiter() = runBlocking { + val pending = async { runCatching { requests.hash("docs", "a.bin") } } + awaitSent(1) + requests.failAll("Disconnected") + + assertEquals("Disconnected", pending.await().exceptionOrNull()?.message) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PreviewLoadersTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PreviewLoadersTest.kt new file mode 100644 index 00000000..a25922a6 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PreviewLoadersTest.kt @@ -0,0 +1,175 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.service.FileTransferLimits +import com.clindsay94.remex.ui.files.preview.ImagePreviewLoader +import com.clindsay94.remex.ui.files.preview.PreviewTooLargeException +import com.clindsay94.remex.ui.files.preview.RangeChunk +import com.clindsay94.remex.ui.files.preview.RangeReader +import com.clindsay94.remex.ui.files.preview.TextPreviewLoader +import com.clindsay94.remex.ui.files.preview.TextTail +import java.io.IOException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** The Kotlin loaders, case for case with the PC's `FilePreviewTests` (file browser redesign, 2026-10-08). */ +class PreviewLoadersTest { + /** An in-memory file a reader serves, which a test can grow, shrink or replace. */ + private class FakeFile(var bytes: ByteArray = ByteArray(0), val stallAfterReads: Int? = null) { + val reads = mutableListOf>() + + val reader = RangeReader { offset, length, fromEnd -> + reads += Triple(offset, length, fromEnd) + val size = bytes.size.toLong() + val start = if (fromEnd) maxOf(0L, size - length) else offset + var count = (size - start).coerceIn(0L, length.toLong()).toInt() + if (stallAfterReads != null && reads.size > stallAfterReads) count = 0 + val data = if (count > 0) bytes.copyOfRange(start.toInt(), start.toInt() + count) else ByteArray(0) + val eof = if (count > 0) start + count >= size else start >= size && stallAfterReads == null + RangeChunk(start, data, size, eof) + } + } + + // ── Image loader ── + + @Test + fun anImage_IsFetchedWhole_InOneMegabyteReads_WithProgress() = runBlocking { + val file = FakeFile(ByteArray(2_500_000) { it.toByte() }) + val reported = mutableListOf() + + val bytes = ImagePreviewLoader.load(file.reader, { reported += it }) + + assertArrayEquals(file.bytes, bytes) + assertEquals(listOf(0L, 1_048_576L, 2_097_152L), file.reads.map { it.first }) + assertEquals(1f, reported.last()) + } + + @Test + fun anImageOverTheLimit_IsRefusedAfterOneRead() = runBlocking { + val file = FakeFile(ByteArray(3_000_000)) + try { + ImagePreviewLoader.load(file.reader, maxBytes = 2_000_000) + fail("an over-limit image must be refused") + } catch (e: PreviewTooLargeException) { + assertEquals(3_000_000L, e.fileSize) + } + assertEquals("nothing past the first read for a file that will be refused", 1, file.reads.size) + } + + @Test + fun aSourceThatStopsSending_FailsTheImage_RatherThanLoopingForever() = runBlocking { + val file = FakeFile(ByteArray(2_500_000), stallAfterReads = 1) + try { + ImagePreviewLoader.load(file.reader) + fail("a stalled source must fail the image") + } catch (_: IOException) { + } + assertTrue(file.reads.size < 5) + } + + // ── Text head ── + + @Test + fun textHead_ReadsUpToTheLimit_AndSaysWhenItStopped() = runBlocking { + val file = FakeFile("x".repeat(3_000_000).toByteArray()) + + val preview = TextPreviewLoader.loadHead(file.reader) + + assertFalse(preview.isBinary) + assertTrue(preview.truncated) + assertEquals(FileTransferLimits.PREVIEW_TEXT_MAX_BYTES, preview.text.length) + assertEquals(3_000_000L, preview.fileSize) + } + + @Test + fun aBinaryFileWithATextName_CostsOneRead_AndIsSaidToBeBinary() = runBlocking { + val file = FakeFile(ByteArray(3_000_000)) + + val preview = TextPreviewLoader.loadHead(file.reader) + + assertTrue(preview.isBinary) + assertEquals(1, file.reads.size) + } + + @Test + fun aShortFile_IsReadWhole_AndNotTruncated() = runBlocking { + val file = FakeFile("one\ntwo\n".toByteArray()) + + val preview = TextPreviewLoader.loadHead(file.reader) + + assertEquals("one\ntwo\n", preview.text) + assertFalse(preview.truncated) + } + + // ── Live tail ── + + @Test + fun liveTail_StartsAtTheEnd_ThenAppendsOnlyWhatWasAdded() = runBlocking { + val file = FakeFile((1..100).joinToString("") { "line $it\n" }.toByteArray()) + val tail = TextTail(file.reader, tailBytes = 40) + + val start = tail.start() + assertTrue(start.reset) + assertEquals("line 100", start.lines.last()) + assertTrue("the partial first line of a mid-file tail is dropped", start.lines.first().startsWith("line ")) + + file.bytes += "line 101\nline 1".toByteArray() + val update = tail.poll() + assertFalse(update.reset) + assertEquals(listOf("line 101"), update.lines) + assertEquals("a line still being written is shown, not held back", "line 1", update.pendingLine) + + file.bytes += "02\n".toByteArray() + assertEquals(listOf("line 102"), tail.poll().lines) + } + + @Test + fun liveTail_RestartsWhenTheFileShrinks() = runBlocking { + val file = FakeFile("old one\nold two\n".toByteArray()) + val tail = TextTail(file.reader) + tail.start() + + file.bytes = "new\n".toByteArray() // rotated + val update = tail.poll() + + assertTrue(update.reset) + assertEquals(listOf("new"), update.lines) + } + + @Test + fun liveTail_ACharacterSplitAcrossTwoPolls_JoinsUp() = runBlocking { + val file = FakeFile("a\n".toByteArray()) + val tail = TextTail(file.reader) + tail.start() + val euro = "€\n".toByteArray() + + file.bytes += byteArrayOf(euro[0], euro[1]) + assertEquals("half a character is not shown", "", tail.poll().pendingLine) + + file.bytes += byteArrayOf(euro[2], euro[3]) + assertEquals(listOf("€"), tail.poll().lines) + } + + @Test + fun liveTail_ALineThatNeverEnds_DoesNotGrowWithoutBound() = runBlocking { + val file = FakeFile("x\n".toByteArray()) + val tail = TextTail(file.reader, tailBytes = 64) + tail.start() + + repeat(20) { + file.bytes += "y".repeat(50).toByteArray() + assertTrue(tail.poll().pendingLine.length <= 64) + } + } + + @Test + fun liveTail_WindowsLineEndings_AreNotShown() = runBlocking { + val file = FakeFile("a\r\nb\r\n".toByteArray()) + + assertEquals(listOf("a", "b"), TextTail(file.reader).start().lines) + } +} diff --git a/remex.android/app/src/test/resources/file-preview-vectors.json b/remex.android/app/src/test/resources/file-preview-vectors.json new file mode 100644 index 00000000..ec2e6708 --- /dev/null +++ b/remex.android/app/src/test/resources/file-preview-vectors.json @@ -0,0 +1,724 @@ +{ + "_comment": "Shared vectors for the PC (remex.desktop/Services/FilePreview) and phone (ui/files/preview) preview twins. Generated from the C# implementation; both suites assert against this file, and FilePreviewVectorTests keeps the Android copy byte-identical.", + "classify": [ + { + "name": "photo.JPG", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "a.jpeg", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "x.png", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "anim.gif", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "b.bmp", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "c.webp", + "isDirectory": false, + "kind": "Image" + }, + { + "name": "IMG_0142.HEIC", + "isDirectory": false, + "kind": "ImageThumbnailOnly" + }, + { + "name": "d.heif", + "isDirectory": false, + "kind": "ImageThumbnailOnly" + }, + { + "name": "agent.log", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "notes.md", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "data.json", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "Main.kt", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "build.gradle.kts", + "isDirectory": false, + "kind": "Text" + }, + { + "name": ".gitignore", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "run.ps1", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "setup.exe", + "isDirectory": false, + "kind": "None" + }, + { + "name": "song.mp3", + "isDirectory": false, + "kind": "None" + }, + { + "name": "archive.tar.gz", + "isDirectory": false, + "kind": "None" + }, + { + "name": "README", + "isDirectory": false, + "kind": "Sniff" + }, + { + "name": "data.blob", + "isDirectory": false, + "kind": "Sniff" + }, + { + "name": "trailing.", + "isDirectory": false, + "kind": "Sniff" + }, + { + "name": "Pictures", + "isDirectory": true, + "kind": "None" + }, + { + "name": "folder.png", + "isDirectory": true, + "kind": "None" + }, + { + "name": "", + "isDirectory": false, + "kind": "None" + }, + { + "name": "dir/sub/file.TXT", + "isDirectory": false, + "kind": "Text" + }, + { + "name": "weird.Json", + "isDirectory": false, + "kind": "Text" + } + ], + "languages": [ + { + "name": "a.log", + "language": "Log" + }, + { + "name": "b.TXT", + "language": "Log" + }, + { + "name": "c.json", + "language": "Json" + }, + { + "name": "d.jsonc", + "language": "Json" + }, + { + "name": "e.xml", + "language": "Xml" + }, + { + "name": "f.axaml", + "language": "Xml" + }, + { + "name": "g.resx", + "language": "Xml" + }, + { + "name": "h.cs", + "language": "CLike" + }, + { + "name": "i.kt", + "language": "CLike" + }, + { + "name": "j.kts", + "language": "CLike" + }, + { + "name": "k.css", + "language": "CLike" + }, + { + "name": "l.py", + "language": "Hash" + }, + { + "name": "m.yml", + "language": "Hash" + }, + { + "name": "n.toml", + "language": "Hash" + }, + { + "name": ".gitignore", + "language": "Hash" + }, + { + "name": ".editorconfig", + "language": "Hash" + }, + { + "name": "o.md", + "language": "Plain" + }, + { + "name": "p", + "language": "Plain" + }, + { + "name": "q.csv", + "language": "Plain" + }, + { + "name": "r.HTML", + "language": "Xml" + } + ], + "tokenize": [ + { + "line": "2026-10-08 14:03:21.004 INFO RemEx agent starting", + "language": "Log", + "spans": [ + [ + 0, + 23, + "Timestamp" + ] + ] + }, + { + "line": "2026-10-08 14:03:22.517 WARN Pairing PIN expires in 30 seconds", + "language": "Log", + "spans": [ + [ + 0, + 23, + "Timestamp" + ], + [ + 23, + 40, + "LogWarning" + ] + ] + }, + { + "line": "[2026-10-08T14:03:24.900Z] ERROR Thumbnail failed", + "language": "Log", + "spans": [ + [ + 0, + 26, + "Timestamp" + ], + [ + 26, + 23, + "LogError" + ] + ] + }, + { + "line": "fail: Remex.Agent.Handlers.FileTransferHandler[0]", + "language": "Log", + "spans": [ + [ + 0, + 49, + "LogError" + ] + ] + }, + { + "line": "dbug: something verbose", + "language": "Log", + "spans": [ + [ + 0, + 23, + "LogDebug" + ] + ] + }, + { + "line": "14:03:22 DEBUG tick", + "language": "Log", + "spans": [ + [ + 0, + 8, + "Timestamp" + ], + [ + 8, + 11, + "LogDebug" + ] + ] + }, + { + "line": "NoErrors here, failover ok", + "language": "Log", + "spans": [] + }, + { + "line": "12 apples", + "language": "Log", + "spans": [] + }, + { + "line": "", + "language": "Log", + "spans": [] + }, + { + "line": " \"rootId\": \"docs\", \"bytes\": 1048576, \"ok\": false, \"x\": null", + "language": "Json", + "spans": [ + [ + 2, + 8, + "Key" + ], + [ + 12, + 6, + "String" + ], + [ + 20, + 7, + "Key" + ], + [ + 29, + 7, + "Number" + ], + [ + 38, + 4, + "Key" + ], + [ + 44, + 5, + "Keyword" + ], + [ + 51, + 3, + "Key" + ], + [ + 56, + 4, + "Keyword" + ] + ] + }, + { + "line": "{\"a\":true,\"esc\":\"q\\\"uote\"}", + "language": "Json", + "spans": [ + [ + 1, + 3, + "Key" + ], + [ + 5, + 4, + "Keyword" + ], + [ + 10, + 5, + "Key" + ], + [ + 16, + 9, + "String" + ] + ] + }, + { + "line": "[1.5e3, 0x1F, -2, abc1]", + "language": "Json", + "spans": [ + [ + 1, + 5, + "Number" + ], + [ + 8, + 4, + "Number" + ], + [ + 15, + 1, + "Number" + ] + ] + }, + { + "line": " ", + "language": "Xml", + "spans": [ + [ + 1, + 4, + "Key" + ], + [ + 13, + 6, + "String" + ], + [ + 27, + 3, + "String" + ], + [ + 32, + 13, + "Comment" + ], + [ + 48, + 4, + "Key" + ] + ] + }, + { + "line": "", + "language": "Xml", + "spans": [ + [ + 2, + 3, + "Key" + ], + [ + 14, + 5, + "String" + ] + ] + }, + { + "line": "a < b ", + "language": "Xml", + "spans": [ + [ + 1, + 4, + "Key" + ], + [ + 13, + 6, + "String" + ], + [ + 27, + 3, + "String" + ], + [ + 32, + 13, + "Comment" + ], + [ + 48, + 4, + "Key" + ] + ] + }, + { + "line": "", + "language": "Xml", + "spans": [ + [ + 2, + 3, + "Key" + ], + [ + 14, + 5, + "String" + ] + ] + }, + { + "line": "a < b - Acceso rápido - Este PC - %1$s • %2$s libres Buscar Buscar en esta carpeta… Cerrar búsqueda @@ -711,6 +708,64 @@ Verificado Copiar huella SHA-256 Huella SHA-256 copiada + Este teléfono + PC + Carpetas + Mostrar carpetas + Añadir carpeta + RemEx no pudo conservar el acceso a esa carpeta. Vuelve a elegirla. + Añade una carpeta de este teléfono para ver sus archivos aquí. Se queda en el teléfono hasta que la compartas con tu PC. + Compartir con el PC + Quitar de esta lista + Opciones de %1$s + Carpeta + Mostrar carpetas internas + Ocultar carpetas internas + No se pudieron mostrar las carpetas. Toca para reintentar. + Sin conexión. Conéctate a tu PC para ver sus carpetas. + Elige una carpeta de tu PC. + Elige una carpeta de este teléfono. + No se pudo abrir esta carpeta. Puede que se haya movido o eliminado, o que RemEx haya perdido el acceso. + Vista previa + Volver a la carpeta + Elige un archivo para verlo aquí. + En vivo + Este archivo no es texto, así que no hay nada que mostrar. + Este archivo es demasiado grande para la vista previa (%1$s). Guárdalo para abrirlo. + No se pudo mostrar la vista previa de este archivo. + Reintentar + Solo se muestra el principio del archivo. Guárdalo para leer el resto. + Actualiza RemEx en tu PC para ver aquí sus archivos. + Actualiza RemEx en tu PC para calcular las huellas de sus archivos. + Abrir con… + Ninguna app de este teléfono puede abrir este archivo. + Enviar al PC… + Guardar en el teléfono… + Descargar en… + Enviar aquí + Guardar aquí + Subir un nivel + Aún no hay carpetas para elegir aquí. + No hay nada que elegir en esta carpeta. + Enviando a tu PC. El progreso aparece abajo, en las transferencias. + Guardando en este teléfono. El progreso aparece abajo, en las transferencias. + Algunos archivos no se pudieron poner en cola. Comprueba que sigan ahí y vuelve a intentarlo. + Las carpetas no se envían así. Abre la carpeta y elige sus archivos. + Actualiza RemEx en tu PC para enviar archivos así. + No se pudo crear el archivo en esa carpeta del teléfono. Elige otra carpeta e inténtalo de nuevo. + Huella (SHA-256) + Calcular huella + Leyendo todo el archivo para calcular su huella… + No se pudo calcular la huella. Comprueba que el archivo siga ahí y vuelve a intentarlo. + Pega una huella para comparar + Coincide. Es el mismo archivo. + No coincide. El archivo es distinto o se modificó. + Eso no es una huella SHA-256. Debe tener 64 letras y números. + Comprobar con una copia en %1$s + Comparando las dos copias… + Idénticos. Las dos copias tienen la misma huella. + Distintos. Estas dos copias no son el mismo archivo. + No se pudieron comparar las dos copias. Comprueba que sigan ahí y vuelve a intentarlo. Error Cancelada Explorar este PC diff --git a/remex.android/app/src/main/res/values-fr/strings.xml b/remex.android/app/src/main/res/values-fr/strings.xml index 7423122f..b070b0e7 100644 --- a/remex.android/app/src/main/res/values-fr/strings.xml +++ b/remex.android/app/src/main/res/values-fr/strings.xml @@ -641,9 +641,6 @@ Transferts de fichiers RemEx Synchronisation des fichiers avec votre PC… - Accès rapide - Ce PC - %1$s • %2$s libres Rechercher Rechercher dans ce dossier… Fermer la recherche @@ -711,6 +708,64 @@ Vérifié Copier l’empreinte SHA-256 Empreinte SHA-256 copiée + Ce téléphone + PC + Dossiers + Afficher les dossiers + Ajouter un dossier + RemEx n’a pas pu garder l’accès à ce dossier. Choisissez-le à nouveau. + Ajoutez un dossier de ce téléphone pour parcourir et prévisualiser ses fichiers ici. Il reste sur le téléphone tant que vous ne le partagez pas avec votre PC. + Partager avec le PC + Retirer de cette liste + Options pour %1$s + Dossier + Afficher les sous-dossiers + Masquer les sous-dossiers + Impossible d’afficher les dossiers ici. Touchez pour réessayer. + Non connecté. Connectez-vous à votre PC pour voir ses dossiers. + Choisissez un dossier sur votre PC. + Choisissez un dossier sur ce téléphone. + Impossible d’ouvrir ce dossier. Il a peut-être été déplacé ou supprimé, ou RemEx n’y a plus accès. + Aperçu + Retour au dossier + Choisissez un fichier pour l’afficher ici. + En direct + Ce fichier n’est pas du texte : rien à afficher. + Ce fichier est trop volumineux pour l’aperçu (%1$s). Enregistrez-le pour l’ouvrir. + Impossible d’afficher l’aperçu de ce fichier. + Réessayer + Seul le début du fichier est affiché. Enregistrez-le pour lire la suite. + Mettez à jour RemEx sur votre PC pour prévisualiser ses fichiers ici. + Mettez à jour RemEx sur votre PC pour calculer les empreintes de ses fichiers. + Ouvrir avec… + Aucune appli de ce téléphone ne peut ouvrir ce fichier. + Envoyer au PC… + Enregistrer sur le téléphone… + Télécharger vers… + Envoyer ici + Enregistrer ici + Remonter d’un niveau + Aucun dossier à choisir ici pour l’instant. + Rien à choisir dans ce dossier. + Envoi vers votre PC. La progression s’affiche dans les transferts ci-dessous. + Enregistrement sur ce téléphone. La progression s’affiche dans les transferts ci-dessous. + Certains fichiers n’ont pas pu être mis en file. Vérifiez qu’ils sont toujours là et réessayez. + Les dossiers ne s’envoient pas ainsi. Ouvrez le dossier et choisissez ses fichiers. + Mettez à jour RemEx sur votre PC pour envoyer des fichiers ainsi. + Impossible de créer le fichier dans ce dossier du téléphone. Choisissez-en un autre et réessayez. + Empreinte (SHA-256) + Calculer l’empreinte + Lecture de tout le fichier pour calculer son empreinte… + Impossible de calculer l’empreinte. Vérifiez que le fichier est toujours là et réessayez. + Collez une empreinte à comparer + Elle correspond : c’est le même fichier. + Elle ne correspond pas : le fichier est différent ou a été modifié. + Ce n’est pas une empreinte SHA-256. Elle doit compter 64 lettres et chiffres. + Comparer avec une copie sur %1$s + Comparaison des deux copies… + Identiques : les deux copies ont la même empreinte. + Différents : ces deux copies ne sont pas le même fichier. + Impossible de comparer les deux copies. Vérifiez qu’elles sont toujours là et réessayez. Échec Annulé Parcourir ce PC diff --git a/remex.android/app/src/main/res/values-hi/strings.xml b/remex.android/app/src/main/res/values-hi/strings.xml index 523de7bf..6b4f1eec 100644 --- a/remex.android/app/src/main/res/values-hi/strings.xml +++ b/remex.android/app/src/main/res/values-hi/strings.xml @@ -641,9 +641,6 @@ RemEx फ़ाइल स्थानांतरण आपके पीसी के साथ फ़ाइलें सिंक हो रही हैं… - त्वरित पहुँच - यह PC - %1$s • %2$s खाली खोजें इस फ़ोल्डर में खोजें… खोज बंद करें @@ -711,6 +708,64 @@ सत्यापित SHA-256 फ़िंगरप्रिंट कॉपी करें SHA-256 फ़िंगरप्रिंट कॉपी हो गया + यह फ़ोन + पीसी + फ़ोल्डर + फ़ोल्डर दिखाएँ + फ़ोल्डर जोड़ें + RemEx उस फ़ोल्डर तक पहुँच नहीं रख सका। उसे फिर से चुनें। + इस फ़ोन का कोई फ़ोल्डर जोड़ें ताकि उसकी फ़ाइलें यहाँ देख सकें। जब तक आप उसे पीसी से साझा नहीं करते, वह फ़ोन पर ही रहता है। + पीसी से साझा करें + इस सूची से हटाएँ + %1$s के विकल्प + फ़ोल्डर + अंदर के फ़ोल्डर दिखाएँ + अंदर के फ़ोल्डर छिपाएँ + यहाँ के फ़ोल्डर नहीं दिखाए जा सके। फिर से कोशिश करने के लिए टैप करें। + कनेक्ट नहीं है। फ़ोल्डर देखने के लिए अपने पीसी से कनेक्ट करें। + अपने पीसी का कोई फ़ोल्डर चुनें। + इस फ़ोन का कोई फ़ोल्डर चुनें। + यह फ़ोल्डर नहीं खुल सका। हो सकता है वह हटाया या स्थानांतरित किया गया हो, या RemEx की उस तक पहुँच न रही हो। + पूर्वावलोकन + फ़ोल्डर पर वापस जाएँ + यहाँ देखने के लिए कोई फ़ाइल चुनें। + लाइव + यह फ़ाइल टेक्स्ट नहीं है, इसलिए दिखाने को कुछ नहीं है। + यह फ़ाइल पूर्वावलोकन के लिए बहुत बड़ी है (%1$s)। खोलने के लिए इसे सहेजें। + इस फ़ाइल का पूर्वावलोकन नहीं हो सका। + फिर से कोशिश करें + इस फ़ाइल का केवल शुरुआती हिस्सा दिख रहा है। बाकी पढ़ने के लिए इसे सहेजें। + यहाँ पीसी की फ़ाइलों का पूर्वावलोकन देखने के लिए अपने पीसी पर RemEx अपडेट करें। + पीसी की फ़ाइलों के फ़िंगरप्रिंट निकालने के लिए अपने पीसी पर RemEx अपडेट करें। + इससे खोलें… + इस फ़ोन पर कोई ऐप यह फ़ाइल नहीं खोल सकता। + पीसी पर भेजें… + फ़ोन पर सहेजें… + यहाँ डाउनलोड करें… + यहाँ भेजें + यहाँ सहेजें + एक स्तर ऊपर + यहाँ अभी चुनने के लिए कोई फ़ोल्डर नहीं है। + इस फ़ोल्डर में चुनने को कुछ नहीं है। + आपके पीसी पर भेजा जा रहा है। प्रगति नीचे ट्रांसफ़र में दिखेगी। + इस फ़ोन पर सहेजा जा रहा है। प्रगति नीचे ट्रांसफ़र में दिखेगी। + कुछ फ़ाइलें कतार में नहीं जुड़ सकीं। जाँचें कि वे अभी भी मौजूद हैं, फिर कोशिश करें। + फ़ोल्डर इस तरह नहीं भेजे जाते। फ़ोल्डर खोलें और उसकी फ़ाइलें चुनें। + इस तरह फ़ाइलें भेजने के लिए अपने पीसी पर RemEx अपडेट करें। + फ़ोन के उस फ़ोल्डर में फ़ाइल नहीं बन सकी। कोई दूसरा फ़ोल्डर चुनकर फिर कोशिश करें। + फ़िंगरप्रिंट (SHA-256) + फ़िंगरप्रिंट निकालें + फ़िंगरप्रिंट निकालने के लिए पूरी फ़ाइल पढ़ी जा रही है… + फ़िंगरप्रिंट नहीं निकल सका। जाँचें कि फ़ाइल अभी भी मौजूद है, फिर कोशिश करें। + तुलना के लिए फ़िंगरप्रिंट चिपकाएँ + मेल खाता है। यह वही फ़ाइल है। + मेल नहीं खाता। फ़ाइल अलग है, या बदली गई है। + यह SHA-256 फ़िंगरप्रिंट नहीं है। इसमें 64 अक्षर और अंक होने चाहिए। + %1$s पर मौजूद कॉपी से जाँचें + दोनों कॉपियों की तुलना हो रही है… + एक जैसी। दोनों कॉपियों का फ़िंगरप्रिंट समान है। + अलग। ये दोनों कॉपियाँ एक ही फ़ाइल नहीं हैं। + दोनों कॉपियों की तुलना नहीं हो सकी। जाँचें कि दोनों मौजूद हैं, फिर कोशिश करें। विफल रद्द इस PC को ब्राउज़ करें diff --git a/remex.android/app/src/main/res/values-in/strings.xml b/remex.android/app/src/main/res/values-in/strings.xml index d85d135a..385855ab 100644 --- a/remex.android/app/src/main/res/values-in/strings.xml +++ b/remex.android/app/src/main/res/values-in/strings.xml @@ -634,9 +634,6 @@ Transfer file RemEx Menyinkronkan file dengan PC Anda… - Akses cepat - PC Ini - %1$s • %2$s tersisa Cari Cari di folder ini… Tutup pencarian @@ -704,6 +701,64 @@ Terverifikasi Salin sidik jari SHA-256 Sidik jari SHA-256 disalin + Ponsel ini + PC + Folder + Tampilkan folder + Tambah folder + RemEx tidak bisa mempertahankan akses ke folder itu. Coba pilih lagi. + Tambahkan folder dari ponsel ini untuk menjelajahi dan melihat pratinjau filenya di sini. Folder tetap di ponsel sampai Anda membagikannya ke PC. + Bagikan ke PC + Hapus dari daftar ini + Opsi untuk %1$s + Folder + Tampilkan folder di dalamnya + Sembunyikan folder di dalamnya + Folder di sini tidak bisa ditampilkan. Ketuk untuk mencoba lagi. + Tidak terhubung. Hubungkan ke PC untuk melihat foldernya. + Pilih folder di PC Anda. + Pilih folder di ponsel ini. + Folder ini tidak bisa dibuka. Mungkin sudah dipindahkan atau dihapus, atau RemEx kehilangan akses. + Pratinjau + Kembali ke folder + Pilih file untuk melihat pratinjaunya di sini. + Langsung + File ini bukan teks, jadi tidak ada yang bisa ditampilkan. + File ini terlalu besar untuk pratinjau (%1$s). Simpan untuk membukanya. + Pratinjau file ini tidak bisa ditampilkan. + Coba lagi + Hanya awal file yang ditampilkan. Simpan untuk membaca sisanya. + Perbarui RemEx di PC untuk melihat pratinjau filenya di sini. + Perbarui RemEx di PC untuk menghitung sidik jari filenya. + Buka dengan… + Tidak ada aplikasi di ponsel ini yang bisa membuka file ini. + Kirim ke PC… + Simpan ke ponsel… + Unduh ke… + Kirim ke sini + Simpan di sini + Naik satu tingkat + Belum ada folder untuk dipilih di sini. + Tidak ada yang bisa dipilih di folder ini. + Mengirim ke PC. Progresnya ada di daftar transfer di bawah. + Menyimpan ke ponsel ini. Progresnya ada di daftar transfer di bawah. + Beberapa file tidak bisa dimasukkan ke antrean. Pastikan file masih ada, lalu coba lagi. + Folder tidak dikirim dengan cara ini. Buka foldernya dan pilih filenya. + Perbarui RemEx di PC untuk mengirim file dengan cara ini. + File tidak bisa dibuat di folder ponsel itu. Pilih folder lain lalu coba lagi. + Sidik jari (SHA-256) + Hitung sidik jari + Membaca seluruh file untuk menghitung sidik jarinya… + Sidik jari tidak bisa dihitung. Pastikan file masih ada, lalu coba lagi. + Tempel sidik jari untuk dibandingkan + Cocok. Ini file yang sama. + Tidak cocok. File-nya berbeda atau sudah diubah. + Itu bukan sidik jari SHA-256. Seharusnya 64 huruf dan angka. + Periksa dengan salinan di %1$s + Membandingkan kedua salinan… + Identik. Kedua salinan punya sidik jari yang sama. + Berbeda. Kedua salinan ini bukan file yang sama. + Kedua salinan tidak bisa dibandingkan. Pastikan keduanya masih ada, lalu coba lagi. Gagal Dibatalkan Jelajahi PC ini diff --git a/remex.android/app/src/main/res/values-pl/strings.xml b/remex.android/app/src/main/res/values-pl/strings.xml index a17513c8..27807c13 100644 --- a/remex.android/app/src/main/res/values-pl/strings.xml +++ b/remex.android/app/src/main/res/values-pl/strings.xml @@ -634,9 +634,6 @@ Transfery plików RemEx Synchronizowanie plików z komputerem… - Szybki dostęp - Ten komputer - %1$s • %2$s wolne Szukaj Szukaj w tym folderze… Zamknij wyszukiwanie @@ -704,6 +701,64 @@ Zweryfikowano Kopiuj odcisk SHA-256 Skopiowano odcisk SHA-256 + Ten telefon + Komputer + Foldery + Pokaż foldery + Dodaj folder + RemEx nie mógł zachować dostępu do tego folderu. Wybierz go ponownie. + Dodaj folder z tego telefonu, aby przeglądać tu jego pliki. Zostaje na telefonie, dopóki nie udostępnisz go komputerowi. + Udostępnij komputerowi + Usuń z tej listy + Opcje dla %1$s + Folder + Pokaż foldery wewnątrz + Ukryj foldery wewnątrz + Nie udało się wyświetlić folderów. Dotknij, aby spróbować ponownie. + Brak połączenia. Połącz się z komputerem, aby zobaczyć jego foldery. + Wybierz folder na komputerze. + Wybierz folder na tym telefonie. + Nie udało się otworzyć tego folderu. Mógł zostać przeniesiony lub usunięty albo RemEx stracił do niego dostęp. + Podgląd + Wróć do folderu + Wybierz plik, aby zobaczyć go tutaj. + Na żywo + Ten plik nie jest tekstem, więc nie ma czego pokazać. + Ten plik jest za duży na podgląd (%1$s). Zapisz go, aby otworzyć. + Nie udało się wyświetlić podglądu tego pliku. + Spróbuj ponownie + Widać tylko początek pliku. Zapisz go, aby przeczytać resztę. + Zaktualizuj RemEx na komputerze, aby oglądać tu podgląd jego plików. + Zaktualizuj RemEx na komputerze, aby obliczać odciski jego plików. + Otwórz za pomocą… + Żadna aplikacja na tym telefonie nie otworzy tego pliku. + Wyślij na komputer… + Zapisz na telefonie… + Pobierz do… + Wyślij tutaj + Zapisz tutaj + Poziom wyżej + Nie ma tu jeszcze folderów do wyboru. + W tym folderze nie ma nic do wyboru. + Wysyłanie na komputer. Postęp widać w przesyłaniu poniżej. + Zapisywanie na tym telefonie. Postęp widać w przesyłaniu poniżej. + Nie udało się dodać części plików do kolejki. Sprawdź, czy nadal istnieją, i spróbuj ponownie. + Folderów nie wysyła się w ten sposób. Otwórz folder i wybierz jego pliki. + Zaktualizuj RemEx na komputerze, aby wysyłać pliki w ten sposób. + Nie udało się utworzyć pliku w tym folderze telefonu. Wybierz inny folder i spróbuj ponownie. + Odcisk (SHA-256) + Oblicz odcisk + Odczytywanie całego pliku, aby obliczyć odcisk… + Nie udało się obliczyć odcisku. Sprawdź, czy plik nadal istnieje, i spróbuj ponownie. + Wklej odcisk do porównania + Pasuje. To ten sam plik. + Nie pasuje. Plik jest inny albo został zmieniony. + To nie jest odcisk SHA-256. Powinien mieć 64 litery i cyfry. + Porównaj z kopią na: %1$s + Porównywanie obu kopii… + Identyczne. Obie kopie mają ten sam odcisk. + Różne. Te dwie kopie to nie ten sam plik. + Nie udało się porównać obu kopii. Sprawdź, czy nadal istnieją, i spróbuj ponownie. Niepowodzenie Anulowano Przeglądaj ten komputer diff --git a/remex.android/app/src/main/res/values-pt-rBR/strings.xml b/remex.android/app/src/main/res/values-pt-rBR/strings.xml index 0a7d475e..2500bf3f 100644 --- a/remex.android/app/src/main/res/values-pt-rBR/strings.xml +++ b/remex.android/app/src/main/res/values-pt-rBR/strings.xml @@ -641,9 +641,6 @@ Transferências de arquivos do RemEx Sincronizando arquivos com seu PC… - Acesso rápido - Este PC - %1$s • %2$s livres Pesquisar Pesquisar nesta pasta… Fechar pesquisa @@ -711,6 +708,64 @@ Verificado Copiar impressão digital SHA-256 Impressão digital SHA-256 copiada + Este celular + PC + Pastas + Mostrar pastas + Adicionar pasta + O RemEx não conseguiu manter o acesso a essa pasta. Escolha-a de novo. + Adicione uma pasta deste celular para navegar e ver os arquivos aqui. Ela fica só no celular até você compartilhá-la com o PC. + Compartilhar com o PC + Remover desta lista + Opções de %1$s + Pasta + Mostrar pastas internas + Ocultar pastas internas + Não foi possível listar as pastas. Toque para tentar de novo. + Sem conexão. Conecte-se ao PC para ver as pastas dele. + Escolha uma pasta do seu PC. + Escolha uma pasta deste celular. + Não foi possível abrir esta pasta. Ela pode ter sido movida ou excluída, ou o RemEx perdeu o acesso. + Visualizar + Voltar à pasta + Escolha um arquivo para visualizá-lo aqui. + Ao vivo + Este arquivo não é texto, então não há nada para mostrar. + Este arquivo é grande demais para visualizar (%1$s). Salve-o para abrir. + Não foi possível visualizar este arquivo. + Tentar de novo + Só o início do arquivo é mostrado. Salve-o para ler o resto. + Atualize o RemEx no PC para visualizar os arquivos dele aqui. + Atualize o RemEx no PC para calcular as impressões digitais dos arquivos dele. + Abrir com… + Nenhum app deste celular consegue abrir este arquivo. + Enviar ao PC… + Salvar no celular… + Baixar para… + Enviar para cá + Salvar aqui + Subir um nível + Ainda não há pastas para escolher aqui. + Não há nada para escolher nesta pasta. + Enviando ao PC. O progresso aparece nas transferências abaixo. + Salvando neste celular. O progresso aparece nas transferências abaixo. + Alguns arquivos não entraram na fila. Verifique se ainda existem e tente de novo. + Pastas não são enviadas assim. Abra a pasta e escolha os arquivos. + Atualize o RemEx no PC para enviar arquivos assim. + Não foi possível criar o arquivo nessa pasta do celular. Escolha outra pasta e tente de novo. + Impressão digital (SHA-256) + Calcular impressão digital + Lendo o arquivo inteiro para calcular a impressão digital… + Não foi possível calcular a impressão digital. Verifique se o arquivo ainda existe e tente de novo. + Cole uma impressão digital para comparar + Confere. É o mesmo arquivo. + Não confere. O arquivo é diferente ou foi alterado. + Isso não é uma impressão digital SHA-256. Ela deve ter 64 letras e números. + Comparar com uma cópia em %1$s + Comparando as duas cópias… + Idênticos. As duas cópias têm a mesma impressão digital. + Diferentes. Estas duas cópias não são o mesmo arquivo. + Não foi possível comparar as duas cópias. Verifique se ainda existem e tente de novo. Falhou Cancelado Explorar este PC diff --git a/remex.android/app/src/main/res/values-tr/strings.xml b/remex.android/app/src/main/res/values-tr/strings.xml index 7764ffa9..778403a4 100644 --- a/remex.android/app/src/main/res/values-tr/strings.xml +++ b/remex.android/app/src/main/res/values-tr/strings.xml @@ -641,9 +641,6 @@ RemEx dosya aktarımları Dosyalar bilgisayarınızla eşitleniyor… - Hızlı erişim - Bu Bilgisayar - %1$s • %2$s boş Ara Bu klasörde ara… Aramayı kapat @@ -711,6 +708,64 @@ Doğrulandı SHA-256 parmak izini kopyala SHA-256 parmak izi kopyalandı + Bu telefon + PC + Klasörler + Klasörleri göster + Klasör ekle + RemEx bu klasöre erişimi koruyamadı. Klasörü yeniden seçin. + Dosyalarına buradan göz atmak için bu telefondan bir klasör ekleyin. Bilgisayarınızla paylaşana kadar telefonda kalır. + Bilgisayarla paylaş + Bu listeden kaldır + %1$s seçenekleri + Klasör + İçindeki klasörleri göster + İçindeki klasörleri gizle + Buradaki klasörler listelenemedi. Yeniden denemek için dokunun. + Bağlı değil. Klasörlerini görmek için bilgisayarınıza bağlanın. + Bilgisayarınızdan bir klasör seçin. + Bu telefondan bir klasör seçin. + Bu klasör açılamadı. Taşınmış ya da silinmiş olabilir veya RemEx erişimini kaybetmiş olabilir. + Önizleme + Klasöre dön + Burada önizlemek için bir dosya seçin. + Canlı + Bu dosya metin değil, gösterilecek bir şey yok. + Bu dosya önizleme için çok büyük (%1$s). Açmak için kaydedin. + Bu dosya önizlenemedi. + Yeniden dene + Dosyanın yalnızca başı gösteriliyor. Kalanını okumak için kaydedin. + Dosyalarını burada önizlemek için bilgisayarınızdaki RemEx\'i güncelleyin. + Dosyalarının parmak izlerini hesaplamak için bilgisayarınızdaki RemEx\'i güncelleyin. + Birlikte aç… + Bu telefonda bu dosyayı açabilecek bir uygulama yok. + Bilgisayara gönder… + Telefona kaydet… + Şuraya indir… + Buraya gönder + Buraya kaydet + Bir üst düzey + Burada henüz seçilecek klasör yok. + Bu klasörde seçilecek bir şey yok. + Bilgisayarınıza gönderiliyor. İlerleme aşağıdaki aktarımlarda. + Bu telefona kaydediliyor. İlerleme aşağıdaki aktarımlarda. + Bazı dosyalar sıraya eklenemedi. Hâlâ yerinde olduklarını kontrol edip yeniden deneyin. + Klasörler bu şekilde gönderilmez. Klasörü açıp dosyalarını seçin. + Dosyaları bu şekilde göndermek için bilgisayarınızdaki RemEx\'i güncelleyin. + Dosya o telefon klasöründe oluşturulamadı. Başka bir klasör seçip yeniden deneyin. + Parmak izi (SHA-256) + Parmak izini hesapla + Parmak izini hesaplamak için dosyanın tamamı okunuyor… + Parmak izi hesaplanamadı. Dosyanın hâlâ yerinde olduğunu kontrol edip yeniden deneyin. + Karşılaştırmak için parmak izi yapıştırın + Eşleşiyor. Bu aynı dosya. + Eşleşmiyor. Dosya farklı ya da değiştirilmiş. + Bu bir SHA-256 parmak izi değil. 64 harf ve rakamdan oluşmalı. + %1$s üzerindeki kopyayla karşılaştır + İki kopya karşılaştırılıyor… + Aynı. İki kopyanın parmak izi de aynı. + Farklı. Bu iki kopya aynı dosya değil. + İki kopya karşılaştırılamadı. İkisinin de yerinde olduğunu kontrol edip yeniden deneyin. Başarısız İptal edildi Bu bilgisayara göz at diff --git a/remex.android/app/src/main/res/values-uk/strings.xml b/remex.android/app/src/main/res/values-uk/strings.xml index aa812b84..873bdbee 100644 --- a/remex.android/app/src/main/res/values-uk/strings.xml +++ b/remex.android/app/src/main/res/values-uk/strings.xml @@ -655,9 +655,6 @@ Передавання файлів RemEx Синхронізація файлів із ПК… - Швидкий доступ - Цей ПК - %1$s • %2$s вільно Пошук Пошук у цій папці… Закрити пошук @@ -725,6 +722,64 @@ Перевірено Копіювати відбиток SHA-256 Відбиток SHA-256 скопійовано + Цей телефон + ПК + Папки + Показати папки + Додати папку + RemEx не зміг зберегти доступ до цієї папки. Виберіть її ще раз. + Додайте папку з цього телефона, щоб переглядати її файли тут. Вона залишається на телефоні, доки ви не поділитеся нею з ПК. + Поділитися з ПК + Прибрати з цього списку + Параметри для %1$s + Папка + Показати вкладені папки + Сховати вкладені папки + Не вдалося показати папки. Торкніться, щоб спробувати ще раз. + Немає з’єднання. Під’єднайтеся до ПК, щоб побачити його папки. + Виберіть папку на ПК. + Виберіть папку на цьому телефоні. + Не вдалося відкрити цю папку. Можливо, її переміщено чи видалено або RemEx втратив доступ. + Перегляд + Назад до папки + Виберіть файл, щоб переглянути його тут. + Наживо + Цей файл не текстовий, тож показати нічого. + Цей файл завеликий для перегляду (%1$s). Збережіть його, щоб відкрити. + Не вдалося переглянути цей файл. + Спробувати ще раз + Показано лише початок файлу. Збережіть його, щоб прочитати решту. + Оновіть RemEx на ПК, щоб переглядати тут його файли. + Оновіть RemEx на ПК, щоб обчислювати відбитки його файлів. + Відкрити за допомогою… + Жоден застосунок на цьому телефоні не може відкрити цей файл. + Надіслати на ПК… + Зберегти на телефоні… + Завантажити до… + Надіслати сюди + Зберегти сюди + На рівень вище + Тут поки немає папок для вибору. + У цій папці нічого вибрати. + Надсилання на ПК. Поступ видно в передаваннях нижче. + Збереження на цьому телефоні. Поступ видно в передаваннях нижче. + Деякі файли не вдалося додати в чергу. Перевірте, чи вони ще є, і спробуйте знову. + Папки так не надсилаються. Відкрийте папку й виберіть її файли. + Оновіть RemEx на ПК, щоб надсилати файли так. + Не вдалося створити файл у цій папці телефона. Виберіть іншу папку й спробуйте знову. + Відбиток (SHA-256) + Обчислити відбиток + Читання всього файлу для обчислення відбитка… + Не вдалося обчислити відбиток. Перевірте, чи файл ще є, і спробуйте знову. + Вставте відбиток для порівняння + Збігається. Це той самий файл. + Не збігається. Файл інший або його змінено. + Це не відбиток SHA-256. Він має містити 64 літери й цифри. + Порівняти з копією на: %1$s + Порівняння двох копій… + Однакові. Обидві копії мають той самий відбиток. + Різні. Ці дві копії — не той самий файл. + Не вдалося порівняти дві копії. Перевірте, чи обидві ще є, і спробуйте знову. Помилка Скасовано Огляд цього ПК diff --git a/remex.android/app/src/main/res/values/strings.xml b/remex.android/app/src/main/res/values/strings.xml index 02233618..5f2613c5 100644 --- a/remex.android/app/src/main/res/values/strings.xml +++ b/remex.android/app/src/main/res/values/strings.xml @@ -820,9 +820,6 @@ so it is intentionally not translated. --> %1$d%% · %2$d/%3$d - Quick access - This PC - %1$s • %2$s free Search Search this folder… Close search @@ -891,6 +888,64 @@ Verified Copy SHA-256 fingerprint SHA-256 fingerprint copied + This phone + PC + Folders + Show folders + Add folder + RemEx couldn\'t keep access to that folder. Try picking it again. + Add a folder from this phone to browse and preview its files here. It stays on the phone until you share it with your PC. + Share with PC + Remove from this list + Options for %1$s + Folder + Show folders inside + Hide folders inside + Couldn\'t list the folders here. Tap to try again. + Not connected. Connect to your PC to see its folders. + Pick a folder on your PC. + Pick a folder on this phone. + This folder couldn\'t be opened. It may have been moved or deleted, or RemEx lost access to it. + Preview + Back to the folder + Pick a file to preview it here. + Live + This file isn\'t text, so there\'s nothing to show. + This file is too big to preview (%1$s). Save it to open it. + This file couldn\'t be previewed. + Try again + Only the start of this file is shown. Save it to read the rest. + Update RemEx on your PC to preview its files here. + Update RemEx on your PC to work out fingerprints of its files. + Open with… + No app on this phone can open this file. + Send to PC… + Save to phone… + Download to… + Send here + Save here + Up one level + There are no folders to pick here yet. + Nothing to pick in this folder. + Sending to your PC. Progress is in the transfers below. + Saving to this phone. Progress is in the transfers below. + Some files couldn\'t be queued. Check they\'re still there and try again. + Folders aren\'t sent this way. Open the folder and pick its files instead. + Update RemEx on your PC to send files this way. + The file couldn\'t be created in that phone folder. Pick another folder and try again. + Fingerprint (SHA-256) + Work out fingerprint + Reading the whole file to work out its fingerprint… + The fingerprint couldn\'t be worked out. Check the file is still there and try again. + Paste a fingerprint to compare + It matches. This is the same file. + It doesn\'t match. The file is different, or was changed. + That isn\'t a SHA-256 fingerprint. It should be 64 letters and numbers. + Check against a copy on %1$s + Comparing the two copies… + Identical. Both copies have the same fingerprint. + Different. These two copies aren\'t the same file. + The two copies couldn\'t be compared. Check both are still there and try again. Failed Cancelled Browse this PC diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt index d8e0755c..b63afab0 100644 --- a/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt +++ b/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt @@ -1,5 +1,6 @@ package com.clindsay94.remex.data +import java.io.File import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -75,6 +76,41 @@ class PhoneBookmarksRepositoryTest { assertTrue(released.isEmpty()) } + /** + * THE CONSENT BOUNDARY. A phone-only bookmark must never reach the PC: everything that answers the PC + * (the file host, its resolvers and the consent manager) reads the shared list, never the bookmark key. A + * future "convenience" that let the host see bookmarks would silently share every folder the person only + * meant to browse on the phone. + */ + @Test + fun nothingThatAnswersThePc_ReadsTheBookmarks() { + val root = System.getProperty("remex.repoRoot")?.let(::File) + ?: generateSequence(File(".").absoluteFile) { it.parentFile }.first { File(it, "remex.android").isDirectory } + val service = File(root, "remex.android/app/src/main/java/com/clindsay94/remex/service") + val files = service.listFiles { f -> f.extension == "kt" }.orEmpty() + assertTrue("the scan found nothing to check", files.size > 10) + assertTrue(files.any { it.name == "AndroidFileTransferHost.kt" }) + for (file in files) { + val text = file.readText() + assertTrue("${file.name} reads the phone-only bookmarks", "phoneBookmark" !in text && "phone_bookmark_uris" !in text) + } + } + + @Test + fun aFolderAnUnfinishedTransferUses_KeepsItsGrant() { + fun row(uri: String, state: com.clindsay94.remex.service.TransferState) = + com.clindsay94.remex.service.QueuedTransfer("t$uri$state", "upload", "a", 1, uri, state = state) + val tree = "content://com.android.externalstorage.documents/tree/primary%3AMusic" + val queue = listOf( + row("$tree/document/primary%3AMusic%2Fa.mp3", com.clindsay94.remex.service.TransferState.Paused), + row("content://x/tree/done/document/d", com.clindsay94.remex.service.TransferState.Done), + row("content://x/tree/gone/document/d", com.clindsay94.remex.service.TransferState.Cancelled), + row("content://media/external/images/1", com.clindsay94.remex.service.TransferState.Active), + ) + + assertEquals(setOf(tree), PhoneBookmarksRepository.treesInUse(queue)) + } + @Test fun sharedFoldersComeFirst_ThenPhoneOnlyBookmarks() { assertEquals( diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewModelTest.kt new file mode 100644 index 00000000..fa860246 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewModelTest.kt @@ -0,0 +1,301 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.service.FileTransferLimits +import com.clindsay94.remex.ui.files.preview.RangeChunk +import com.clindsay94.remex.ui.files.preview.RangeReader +import com.clindsay94.remex.ui.files.preview.SyntaxKind +import java.io.IOException +import java.security.MessageDigest +import java.util.Base64 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The phone's preview pane state, case for case with the PC's preview tests (file browser redesign, 2026-10-08). */ +@OptIn(ExperimentalCoroutinesApi::class) +class FilePreviewModelTest { + private val files = mutableMapOf() + private val reads = mutableListOf() + private val hashFailures = mutableSetOf() + private var hashGate: kotlinx.coroutines.CompletableDeferred? = null + private var hashesFinished = 0 + private var pcReadable = true + private var pcHashable = true + + private val sources = object : PreviewSources { + override fun reader(target: PreviewTarget) = RangeReader { offset, length, fromEnd -> + reads += target.path + val bytes = files[target.path] ?: throw IOException("gone") + val start = if (fromEnd) maxOf(0L, bytes.size - length.toLong()) else offset + val count = (bytes.size - start).coerceIn(0L, length.toLong()).toInt() + RangeChunk(start, if (count > 0) bytes.copyOfRange(start.toInt(), start.toInt() + count) else ByteArray(0), bytes.size.toLong(), start + count >= bytes.size) + } + + override suspend fun hashBase64(target: PreviewTarget): String { + hashGate?.await() + hashesFinished++ + if (target.path in hashFailures) throw IOException("refused") + val bytes = files[target.path] ?: throw IOException("gone") + return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(bytes)) + } + + override fun canRead(side: FileSide) = side == FileSide.Phone || pcReadable + override fun canHash(side: FileSide) = side == FileSide.Phone || pcHashable + + override suspend fun decodeImage(bytes: ByteArray): String? = if (bytes.firstOrNull() == 0x89.toByte()) "image:${bytes.size}" else null + } + + private fun target(path: String, size: Long = files[path]?.size?.toLong() ?: 0, side: FileSide = FileSide.Pc) = + PreviewTarget(side, "docs", path, path.substringAfterLast('/'), isDirectory = false, sizeBytes = size, modifiedMs = 0) + + private fun TestScope.model() = FilePreviewModel(backgroundScope, sources) + + // advanceUntilIdle() skips backgroundScope work; moving the clock past the 150 ms pause runs it. + private fun TestScope.settle() { + advanceTimeBy(1_000) + runCurrent() + } + + @Test + fun aLog_IsShownAsNumberedColouredLines_WithNoPhantomLastLine() = runTest(StandardTestDispatcher()) { + files["agent.log"] = "2026-10-08 14:03:21 INFO start\n2026-10-08 14:03:24 ERROR boom\n".toByteArray() + val preview = model() + + preview.show(target("agent.log")) + settle() + + val ui = preview.ui.value + assertEquals(PreviewState.Text, ui.state) + assertEquals(listOf(1, 2), ui.lines.map { it.number }) + assertTrue(ui.lines[1].spans.any { it.kind == SyntaxKind.LogError }) + assertTrue(ui.canTail) + } + + @Test + fun aFileThatIsNotText_IsNotShownAsText() = runTest(StandardTestDispatcher()) { + files["data.txt"] = byteArrayOf(0x41, 0, 0x42) + files["data.blob"] = byteArrayOf(0x41, 0, 0x42) + val preview = model() + + preview.show(target("data.txt")); settle() + assertEquals("a text name with bytes inside says so", PreviewState.Binary, preview.ui.value.state) + + preview.show(target("data.blob")); settle() + assertEquals("an unknown name just shows its details", PreviewState.Details, preview.ui.value.state) + + reads.clear() + preview.show(target("setup.exe", size = 1000)); settle() + assertEquals(PreviewState.Details, preview.ui.value.state) + assertTrue("a known binary is never read", reads.isEmpty()) + } + + @Test + fun images_AreDecodedWhole_OrRefusedBeforeAnyReadWhenTooBig() = runTest(StandardTestDispatcher()) { + files["a.png"] = ByteArray(1000) { if (it == 0) 0x89.toByte() else 1 } + files["fake.jpg"] = ByteArray(10) { 1 } + val preview = model() + + preview.show(target("a.png")); settle() + assertEquals(PreviewState.Image, preview.ui.value.state) + assertEquals("image:1000", preview.ui.value.image) + + preview.show(target("fake.jpg")); settle() + assertEquals("not decodable: details, not a broken image", PreviewState.Details, preview.ui.value.state) + + reads.clear() + preview.show(target("huge.jpg", size = FileTransferLimits.PREVIEW_IMAGE_MAX_BYTES + 1)); settle() + assertEquals(PreviewState.TooLarge, preview.ui.value.state) + assertTrue(reads.isEmpty()) + } + + @Test + fun flickingThroughFiles_OnlyLoadsTheOneThatStays() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "a".toByteArray() + files["b.txt"] = "b".toByteArray() + val preview = model() + + preview.show(target("a.txt")) + advanceTimeBy(50) + preview.show(target("b.txt")) + settle() + + assertEquals(listOf("b.txt"), reads) + assertEquals("b", preview.ui.value.lines.single().text) + } + + @Test + fun aFileThatCannotBeRead_SaysSo_AndCanBeTriedAgain() = runTest(StandardTestDispatcher()) { + val preview = model() + + preview.show(target("missing.txt", size = 5)); settle() + assertEquals(PreviewState.Failed, preview.ui.value.state) + + files["missing.txt"] = "back!".toByteArray() + preview.show(target("missing.txt", size = 5)); settle() + assertEquals(PreviewState.Text, preview.ui.value.state) + } + + @Test + fun live_StartsAtTheTail_ThenAddsWhatIsWritten_EveryTwoSeconds() = runTest(StandardTestDispatcher()) { + files["app.log"] = "one\ntwo\n".toByteArray() + val preview = model() + preview.show(target("app.log")); settle() + + preview.setLive(true) + runCurrent() + assertTrue(preview.ui.value.live) + assertEquals(listOf("one", "two"), preview.ui.value.lines.map { it.text }) + assertNull("a tail has no line numbers", preview.ui.value.lines.first().number) + + files["app.log"] = "one\ntwo\nthree\nfou".toByteArray() + advanceTimeBy(FileTransferLimits.PREVIEW_TAIL_POLL_MS + 1) + assertEquals(listOf("one", "two", "three"), preview.ui.value.lines.map { it.text }) + assertEquals("fou", preview.ui.value.pendingLine) + + preview.setLive(false) + files["app.log"] = "one\ntwo\nthree\nfour\nfive\n".toByteArray() + advanceTimeBy(10_000) + assertEquals("stopped means stopped", 3, preview.ui.value.lines.size) + } + + @Test + fun aLongLiveTail_KeepsOnlyTheNewestLines() = runTest(StandardTestDispatcher()) { + files["app.log"] = "x\n".toByteArray() + val preview = FilePreviewModel(backgroundScope, sources, maxTailLines = 3) + val t = target("app.log") + preview.show(t); settle() + + preview.apply(t, com.clindsay94.remex.ui.files.preview.TailUpdate(false, listOf("a", "b", "c", "d"), "", 10)) + + assertEquals(listOf("b", "c", "d"), preview.ui.value.lines.map { it.text }) + } + + @Test + fun theFingerprint_IsShownAsHex_AndAPastedOneIsCompared() = runTest(StandardTestDispatcher()) { + files["abc.txt"] = "abc".toByteArray() + val preview = model() + preview.show(target("abc.txt")); settle() + + preview.computeHash(); settle() + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", preview.ui.value.hashHex) + + preview.compare("BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD") + assertEquals(HashCompareOutcome.Match, preview.ui.value.compare) + preview.compare("ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=") + assertEquals("Base64 works too", HashCompareOutcome.Match, preview.ui.value.compare) + preview.compare("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + assertEquals(HashCompareOutcome.Mismatch, preview.ui.value.compare) + preview.compare("not a hash") + assertEquals(HashCompareOutcome.NotAHash, preview.ui.value.compare) + } + + @Test + fun aFingerprintThatFails_SaysSo() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "a".toByteArray() + hashFailures += "a.txt" + val preview = model() + preview.show(target("a.txt")); settle() + + preview.computeHash(); settle() + + assertTrue(preview.ui.value.hashFailed) + assertFalse(preview.ui.value.hashing) + assertNull(preview.ui.value.hashHex) + } + + @Test + fun verifyAgainst_SaysIdenticalDifferentOrFailed() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "same".toByteArray() + files["copy/a.txt"] = "same".toByteArray() + files["other/a.txt"] = "changed".toByteArray() + val preview = model() + val phoneCopy = target("a.txt", side = FileSide.Phone) + preview.show(phoneCopy); settle() + + preview.verifyAgainst(target("copy/a.txt")); settle() + assertEquals(CounterpartOutcome.Identical, preview.ui.value.counterpart) + assertTrue("the file's own fingerprint is shown on the way", preview.ui.value.hashHex != null) + + preview.verifyAgainst(target("other/a.txt")); settle() + assertEquals(CounterpartOutcome.Different, preview.ui.value.counterpart) + + preview.verifyAgainst(target("gone.txt", size = 3)); settle() + assertEquals(CounterpartOutcome.Failed, preview.ui.value.counterpart) + assertFalse(preview.ui.value.verifying) + } + + @Test + fun aSlowAnswerForTheLastFile_NeverLandsOnTheNextOne() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "a".toByteArray() + files["b.txt"] = "b".toByteArray() + files["copy.txt"] = "a".toByteArray() + val preview = model() + preview.show(target("a.txt")); settle() + val gate = kotlinx.coroutines.CompletableDeferred() + hashGate = gate + preview.verifyAgainst(target("copy.txt")); settle() + + preview.show(target("b.txt")); settle() + gate.complete(Unit); settle() + + assertEquals("b.txt", preview.ui.value.target?.path) + assertNull("a's fingerprint is not b's", preview.ui.value.hashHex) + assertEquals("a's cross-check is not b's", CounterpartOutcome.None, preview.ui.value.counterpart) + } + + @Test + fun anOlderPc_IsNeverAskedForAPreview_AndTheScreenSaysToUpdate() = runTest(StandardTestDispatcher()) { + files["agent.log"] = "x\n".toByteArray() + pcReadable = false + pcHashable = false + val preview = model() + + preview.show(target("agent.log")); settle() + + assertEquals(PreviewState.NeedsUpdate, preview.ui.value.state) + assertTrue("nothing an old PC can't answer is sent", reads.isEmpty()) + assertFalse(preview.ui.value.canHash) + assertFalse(preview.ui.value.canVerifyAcross) + preview.computeHash(); settle() + assertEquals(0, hashesFinished) + } + + @Test + fun aPhoneFile_CanBeHashed_ButNotCheckedAgainstAnOlderPc() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "a".toByteArray() + pcHashable = false + val preview = model() + + preview.show(target("a.txt", side = FileSide.Phone)); settle() + + assertTrue(preview.ui.value.canHash) + assertFalse(preview.ui.value.canVerifyAcross) + preview.verifyAgainst(target("a.txt")); settle() + assertEquals(CounterpartOutcome.None, preview.ui.value.counterpart) + } + + @Test + fun movingToAnotherFile_StopsACheckStillRunning() = runTest(StandardTestDispatcher()) { + files["a.txt"] = "a".toByteArray() + files["b.txt"] = "b".toByteArray() + files["copy.txt"] = "a".toByteArray() + val preview = model() + preview.show(target("a.txt")); settle() + val gate = kotlinx.coroutines.CompletableDeferred() + hashGate = gate + preview.verifyAgainst(target("copy.txt")); settle() + + preview.show(target("b.txt")); settle() + gate.complete(Unit); settle() + + assertEquals("the cross-check was cancelled, not left hashing", 0, hashesFinished) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FileTreeModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FileTreeModelTest.kt new file mode 100644 index 00000000..ba6301b5 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FileTreeModelTest.kt @@ -0,0 +1,151 @@ +package com.clindsay94.remex.ui.files + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The File Transfer folder tree (file browser redesign, 2026-10-08). */ +@OptIn(ExperimentalCoroutinesApi::class) +class FileTreeModelTest { + // Folders per (side, root, path). + private val folders = mutableMapOf( + Triple(FileSide.Pc, "docs", "") to listOf("Taxes", "Logs"), + Triple(FileSide.Pc, "docs", "Logs") to listOf("2025"), + Triple(FileSide.Pc, "docs", "Logs/2025") to emptyList(), + Triple(FileSide.Phone, "content://tree/DCIM", "") to listOf("Camera"), + ) + private val calls = mutableListOf>() + private var failNext = false + + private fun TestScope.model() = FileTreeModel(backgroundScope) { side, root, path -> + calls += Triple(side, root, path) + if (failNext) { failNext = false; throw IllegalStateException("offline") } + folders[Triple(side, root, path)] ?: error("no such folder") + }.also { + it.setRoots(FileSide.Pc, listOf(TreeRootInfo("docs", "Documents"), TreeRootInfo("pics", "Pictures", isWritable = false))) + it.setRoots(FileSide.Phone, listOf(TreeRootInfo("content://tree/DCIM", "DCIM", isShared = true))) + } + + private fun FileTreeModel.row(label: String) = rows.value.single { it.label == label && it.kind != TreeRow.Kind.Device } + + @Test + fun bothDevicesStartOpen_WithTheirTopLevelFolders() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + + val rows = tree.rows.value + assertEquals(listOf(TreeRow.Kind.Device, TreeRow.Kind.Root, TreeRow.Kind.Device, TreeRow.Kind.Root, TreeRow.Kind.Root), rows.map { it.kind }) + assertEquals(listOf(FileSide.Phone, FileSide.Phone, FileSide.Pc, FileSide.Pc, FileSide.Pc), rows.map { it.side }) + assertEquals(listOf("", "DCIM", "", "Documents", "Pictures"), rows.map { it.label }) + assertTrue("nothing is listed until a row is opened", calls.isEmpty()) + } + + @Test + fun openingAFolder_ListsItsFoldersOnce_SortedByName() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + + tree.toggle(tree.row("Documents")) + assertEquals(listOf("Documents", "Logs", "Taxes"), tree.rows.value.filter { it.side == FileSide.Pc && it.kind != TreeRow.Kind.Device }.map { it.label }.take(3)) + assertEquals(2, tree.row("Logs").depth) + + tree.toggle(tree.row("Documents")) // close + tree.toggle(tree.row("Documents")) // and open again: no second listing + assertEquals(1, calls.size) + } + + @Test + fun aListingThatFails_SaysSo_AndOpeningAgainRetries() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + failNext = true + + tree.toggle(tree.row("Documents")) + assertTrue(tree.row("Documents").failed) + assertFalse(tree.row("Documents").loading) + + tree.toggle(tree.row("Documents")) // close + tree.toggle(tree.row("Documents")) // open: tries again + assertFalse(tree.row("Documents").failed) + assertEquals(2, calls.size) + assertTrue(tree.rows.value.any { it.label == "Taxes" }) + } + + @Test + fun aRowStaysLoading_UntilItsListingArrives() = runTest(UnconfinedTestDispatcher()) { + val gate = CompletableDeferred>() + val tree = FileTreeModel(backgroundScope) { _, _, _ -> gate.await() } + tree.setRoots(FileSide.Pc, listOf(TreeRootInfo("docs", "Documents"))) + + tree.toggle(tree.row("Documents")) + assertTrue(tree.row("Documents").loading) + + gate.complete(listOf("A")) + assertFalse(tree.row("Documents").loading) + assertTrue(tree.rows.value.any { it.label == "A" }) + } + + @Test + fun aFolderWithNoFoldersInside_HasNoChevron() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + tree.toggle(tree.row("Documents")) + tree.toggle(tree.row("Logs")) + tree.toggle(tree.row("2025")) + + assertFalse(tree.row("2025").expandable) + assertTrue("not opened yet, so it might have some", tree.row("Taxes").expandable) + } + + @Test + fun reveal_OpensEveryLevelDownToTheFolderBeingBrowsed() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + + tree.reveal(FileSide.Pc, "docs", "/Logs/2025/") + + assertEquals(listOf(Triple(FileSide.Pc, "docs", ""), Triple(FileSide.Pc, "docs", "Logs")), calls) + assertTrue(tree.row("Documents").expanded) + assertTrue(tree.row("Logs").expanded) + assertEquals(FileTreeModel.folderKey(FileSide.Pc, "docs", "Logs/2025"), tree.row("2025").key) + } + + @Test + fun aDisconnectedPc_HidesItsFolders_AndGetsThemBackAsTheyWere() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + tree.toggle(tree.row("Documents")) + + tree.setAvailable(FileSide.Pc, false) + assertTrue(tree.rows.value.none { it.side == FileSide.Pc && it.kind != TreeRow.Kind.Device }) + + tree.setAvailable(FileSide.Pc, true) + assertTrue(tree.row("Documents").expanded) + assertTrue(tree.rows.value.any { it.label == "Logs" }) + } + + @Test + fun aFolderNoLongerShared_ForgetsWhatWasOpenInside() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + tree.toggle(tree.row("Documents")) + + tree.setRoots(FileSide.Pc, listOf(TreeRootInfo("pics", "Pictures"))) + tree.setRoots(FileSide.Pc, listOf(TreeRootInfo("docs", "Documents"), TreeRootInfo("pics", "Pictures"))) + + assertFalse(tree.row("Documents").expanded) + } + + @Test + fun aListingFromTheContentsPane_UpdatesTheTreeWithNoRequestOfItsOwn() = runTest(UnconfinedTestDispatcher()) { + val tree = model() + tree.toggle(tree.row("Documents")) + calls.clear() + + tree.setChildren(FileSide.Pc, "docs", "", listOf("Logs", "New", "New")) + + assertTrue(tree.rows.value.any { it.label == "New" }) + assertFalse("a deleted folder goes", tree.rows.value.any { it.label == "Taxes" }) + assertEquals("one row per name, even if a provider lists two", 1, tree.rows.value.count { it.label == "New" }) + assertTrue(calls.isEmpty()) + } +} diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt index ef8d0f74..7f6ae97d 100644 --- a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt @@ -110,6 +110,34 @@ class PcFileRequestsTest { } } + @Test + fun aFolderListing_UsesTheEnvelopeEveryPcKnows_AndNeverTakesTheScreensOwnReply() = runBlocking { + val pending = async { requests.browse("docs", "Logs") } + val envelope = awaitSent(1) + assertEquals("file_browse_request", envelope.getString("type")) + assertFalse("v2 envelope: older PCs answer it too", envelope.has("protocolVersion")) + val id = envelope.getJSONObject("fileBrowseRequest").getString("requestId") + + assertFalse( + "the screen's own listing has another id and is left for it", + reply("file_browse_response", "fileBrowseResponse", JSONObject().put("requestId", "screen").put("entries", org.json.JSONArray())), + ) + reply( + "file_browse_response", "fileBrowseResponse", + JSONObject().put("requestId", id).put( + "entries", + org.json.JSONArray() + .put(JSONObject().put("name", "2025").put("isDirectory", true)) + .put(JSONObject().put("name", "agent.log").put("isDirectory", false).put("sizeBytes", 12)), + ), + ) + + val entries = pending.await() + assertEquals(listOf("2025", "agent.log"), entries.map { it.name }) + assertTrue(entries[0].isDirectory) + assertEquals(12L, entries[1].sizeBytes) + } + @Test fun failAll_ReleasesEveryWaiter() = runBlocking { val pending = async { runCatching { requests.hash("docs", "a.bin") } } diff --git a/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PhoneBrowserModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PhoneBrowserModelTest.kt new file mode 100644 index 00000000..e901937b --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PhoneBrowserModelTest.kt @@ -0,0 +1,79 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.ui.files.preview.RangeReader +import java.io.FileNotFoundException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The "This phone" side's open folder (file browser redesign, 2026-10-08). */ +@OptIn(ExperimentalCoroutinesApi::class) +class PhoneBrowserModelTest { + private val gates = mutableMapOf>>() + + private val source = object : PhoneFileSource { + override suspend fun list(rootUri: String, path: String): List = + gates[path]?.await() ?: when (path) { + "" -> listOf(LocalEntry("1", "Camera", true, -1, 0, null), LocalEntry("2", "a.jpg", false, 3, 7, "image/jpeg")) + else -> throw FileNotFoundException(path) + } + override fun reader(rootUri: String, path: String) = RangeReader { _, _, _ -> error("unused") } + override suspend fun sha256(rootUri: String, path: String, onProgress: ((Long) -> Unit)?) = ByteArray(32) + override suspend fun documentUri(rootUri: String, path: String) = "$rootUri/$path" + } + + @Test + fun aFolder_IsListedAsTheSameRowsThePcSideUses() = runTest(UnconfinedTestDispatcher()) { + val browser = PhoneBrowserModel(backgroundScope, source) + + browser.open("content://tree/DCIM", "/") + + val state = browser.state.value + assertEquals("", state.path) + assertFalse(state.loading) + assertEquals(listOf("Camera", "a.jpg"), state.entries.map { it.name }) + assertEquals("an unknown folder size is shown as nothing, not -1", 0L, state.entries[0].sizeBytes) + assertEquals(7L, state.entries[1].modifiedUnixMs) + assertEquals("rows carry their document id: names need not be unique", listOf("1", "2"), state.entries.map { it.id }) + } + + @Test + fun aFolderThatIsGone_SaysSo() = runTest(UnconfinedTestDispatcher()) { + val browser = PhoneBrowserModel(backgroundScope, source) + + browser.open("content://tree/DCIM", "Deleted") + + assertTrue(browser.state.value.failed) + assertTrue(browser.state.value.entries.isEmpty()) + } + + @Test + fun aNewerFolder_WinsOverASlowerOlderOne() = runTest(UnconfinedTestDispatcher()) { + val slow = CompletableDeferred>() + gates["Slow"] = slow + val browser = PhoneBrowserModel(backgroundScope, source) + + browser.open("content://tree/DCIM", "Slow") + browser.open("content://tree/DCIM", "") + slow.complete(listOf(LocalEntry("9", "late.txt", false, 1, 0, null))) + + assertEquals("", browser.state.value.path) + assertEquals(listOf("Camera", "a.jpg"), browser.state.value.entries.map { it.name }) + } + + @Test + fun clear_LeavesNothingOpen() = runTest(UnconfinedTestDispatcher()) { + val browser = PhoneBrowserModel(backgroundScope, source) + browser.open("content://tree/DCIM", "") + + browser.clear() + + assertNull(browser.state.value.rootId) + } +} From 436675e5c379a183f36d83688faf43f2698065e8 Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:48:27 +0000 Subject: [PATCH 08/10] docs(files): the new Files screens in FILE_SHARING.md, and what the redesign delivered (redesign phase 6) - FILE_SHARING.md: a "Files screen" section in plain English: the tree, live preview, phone-only folders and the Share with PC switch (still one consent list), Verified vs Done, and the fingerprint tools. - Spec section 7: what landed per phase, and where it differs from the plan (medium-width Android is one pane at a time, files not folders across devices, no phone-side rename/delete, the older-PC fallback, same-named phone files), plus what still needs Connor's machines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- docs/FILE_SHARING.md | 48 ++++++++++++++++++- .../specs/2026-10-08-file-browser-redesign.md | 26 ++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/FILE_SHARING.md b/docs/FILE_SHARING.md index 70db9a19..056c9f7d 100644 --- a/docs/FILE_SHARING.md +++ b/docs/FILE_SHARING.md @@ -1,12 +1,14 @@ # File Sharing RemEx lets you move files between your phone and your PC, both ways. This page -explains, in plain English, the three things people ask about most: +explains, in plain English, the things people ask about most: 1. **Consent** — when RemEx asks before one device touches the other's files. 2. **Full‑device browse** — the opt‑in setting that lets your phone see the PC's whole file system instead of just a few shared folders. 3. **Share to PC** — how to send a file to your PC straight from any app on your phone. +4. **The Files screen** — both devices in one folder tree, with a live preview and + SHA‑256 fingerprints. You do **not** need to read this to use file sharing. The app guides you through everything. This is here if you want to understand exactly what's happening and @@ -128,6 +130,47 @@ kind of file — a photo in your gallery, a PDF in your reader, and so on. --- +## 4. The Files screen: both devices in one place + +On the PC (**File Transfer**) and on the phone (**Files**), one screen shows both devices: + +- **A folder tree** on the left: **This PC** (or **This phone**) and each connected + device, with their shared folders. Tap a folder to open it; use the arrow next to it to + see the folders inside without leaving the one you're in. On a phone, the tree opens + from the menu button at the top. +- **The folder you're in**, with thumbnails, a right‑click (PC) or long‑press (phone) + menu, and drag and drop on the PC. +- **A live preview** of the file you pick: photos at full quality with zoom, and text, + code and logs with colour. Turn on **Live** to follow a log as it's written, like + `tail -f`. + +### Phone folders you add on the Files screen stay on the phone + +On the phone, **Add folder** puts a folder in the tree so you can browse and preview it. +**Your PC can't see it.** It only becomes visible to the PC when you open its menu and +turn on **Share with PC**, which adds it to the same **"Access from your PC"** list as +Settings — that list is still the one place that decides what the PC sees. Turning +**Share with PC** off takes it away from the PC again but keeps it on the phone's list. + +### Fingerprints (SHA‑256) + +Every transfer is checked with a SHA‑256 fingerprint. When the check really ran and +matched, the transfer says **Verified** (on both devices), and you can copy its +fingerprint. If the other side sent nothing to compare against, it says **Done** +instead — RemEx doesn't claim a check it didn't do. + +In a file's preview, the **Fingerprint (SHA‑256)** section can: + +- work out the file's fingerprint (shown as 64 letters and numbers, the same format + `sha256sum` on Linux and `Get-FileHash` on Windows print) and copy it; +- compare it with a fingerprint you paste in, for example one published next to a download; +- check it against a copy on the other device and tell you **Identical** or **Different**. + +A phone's file is fingerprinted on the phone and a PC's file on the PC; only the +64‑character result crosses the connection. + +--- + ## Taking access back You stay in control. You can change your mind at any time: @@ -137,7 +180,8 @@ You stay in control. You can change your mind at any time: make the folder read‑only in **Settings → Shared folders**. - **On the phone**, open **Settings → "Access from your PC"** to remove a shared folder, turn off full‑device browsing, or turn off auto‑accept for files the PC - sends. The PC stops seeing anything you take away there straight away. + sends. On the **Files** screen, a folder's **Share with PC** switch does the same + for that folder. The PC stops seeing anything you take away straight away. - **Unpairing a device removes all of its file‑sharing permissions automatically.** --- diff --git a/docs/specs/2026-10-08-file-browser-redesign.md b/docs/specs/2026-10-08-file-browser-redesign.md index 61bed2de..da3c3cb2 100644 --- a/docs/specs/2026-10-08-file-browser-redesign.md +++ b/docs/specs/2026-10-08-file-browser-redesign.md @@ -231,3 +231,29 @@ requested for this work. need later. - **Linux parity.** Everything here is cross-platform Avalonia, with no Windows-only APIs. The Windows-specific check is the palette sweep above. + +## 7. As delivered (2026-10-08) + +Phases 1–6 landed on `claude/clever-cerf-lkvh1u`, one commit each. Where the build differs from the plan above: + +- **Medium-width Android windows** use the list-detail scaffold's own default, one pane at a time (the preview opens + over the list), rather than contents and preview side by side. With the tree also on screen, a 600–840 dp window + left each pane too narrow to read. Expanded windows get all three panes as planned. +- **"Verify against…"** on the PC takes the other device, folder and path in fields (defaulting to the same name in + the last folder looked at there); on Android it opens the other device's folder picker. +- **"Send to PC…" and "Save to phone…" move files, not folders.** A folder in the selection is skipped and the status + line says so; whole folders still go through *Upload folder* and *Download folder*. +- **Phone-side management** (rename, delete, new folder on the phone's own folders) was not part of this round; the + phone's own file manager does that. The PC still manages its own folders, and the phone's when the person allows it. +- The toolbar's write actions became one primary button plus **More** on both platforms, as planned. +- **Mixed versions (§2.4)** on the phone: a PC without `readRange` gets details plus "Update RemEx on your PC to preview + its files here" instead of the thumbnail, and nothing is sent to it; without `hash`, the fingerprint and cross-check + buttons give way to a line saying the same. +- **Phone files are addressed by name.** A provider that lists two siblings with one name (some cloud providers can) + shows both rows, keyed by document id so the list never crashes, but opening, previewing or sending either one acts + on the first. Built-in storage never does this. + +Verification in the cloud session: `verify.ps1 -Scope all` on Linux (Android unit tests and `lintRelease` included), +headless render tests with PNG snapshots of the PC screen, and defect injection for every new guard. Still to do on +Connor's machines: the 13-cell `ui-palette-sweep.ps1` run on Windows, and the Android screen on the AVD across +seed/dynamic/static colour, light/dark and contrast. From 51f9a07eb3bb1ec80f8f4391048124f4e4bdd1f4 Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:09:01 +0000 Subject: [PATCH 09/10] chore(release): RemEx 3.1.0 (Android versionCode 39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version set with build-remex.ps1 -Version 3.1.0 -VersionCode 39, which writes version.properties and Directory.Build.props together (VersionSourceOfTruthTests). CHANGELOG: Unreleased becomes 3.1.0 — the redesigned file screens on PC and phone. Gate (verify.ps1 -Scope all, Linux): all suites green apart from the same 10 environment-only agent failures as the base commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- Directory.Build.props | 2 +- docs/CHANGELOG.md | 7 ++++++- remex.android/app/version.properties | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 947f7df1..795bc008 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.0.0 + 3.1.0 Connor Lindsay CLindsay94 $(NoWarn);CA1416;CS0067;CS1591;CS1573 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aff70cb4..00346efb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,12 +5,17 @@ All notable changes to RemEx will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Jump to: [Unreleased](#unreleased) · [3.0.0](#300--2026-10-04) · [2.5.0](#250--2026-09-10) · [2.4.0](#240--2026-07-19) · [2.3.2](#232--2026-07-18) · older versions below. +Jump to: [Unreleased](#unreleased) · [3.1.0](#310--2026-10-08) · [3.0.0](#300--2026-10-04) · [2.5.0](#250--2026-09-10) · [2.4.0](#240--2026-07-19) · [2.3.2](#232--2026-07-18) · older versions below. --- ## [Unreleased] +## [3.1.0] — 2026-10-08 + +Android versionCode 39. The headline is the redesigned file screens on both the PC and the phone: one folder tree +for both devices, a live preview, and SHA-256 fingerprints you can see, compare and cross-check. + ### Added - **Scan to get the phone app.** On the PC's Home screen, the **Get the phone app** card now shows a QR code diff --git a/remex.android/app/version.properties b/remex.android/app/version.properties index 3f0f3f2b..68f971d4 100644 --- a/remex.android/app/version.properties +++ b/remex.android/app/version.properties @@ -1,2 +1,2 @@ -versionCode=38 -versionName=3.0.0 +versionCode=39 +versionName=3.1.0 From ba4b4054ce100e239a9f7e684d87d69fc8007ad2 Mon Sep 17 00:00:00 2001 From: Connor <115328188+clindsay94@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:34:04 +0000 Subject: [PATCH 10/10] chore(release): 3.1.0 release boilerplate, PC versions and installers - What's New on both apps lists the seven 3.1 highlights in all nine languages (WhatsNewCount 9 -> 7; AboutScreen.kt follows). - PC version strings: app.manifest assemblyIdentity 1.13.0.0 -> 3.1.0.0, RemEx.iss fallback 2.2.0 -> 3.1.0. build-remex.ps1 now rewrites both on every version change (Sync-PinnedVersionStrings), and VersionSourceOfTruthTests fails if they drift. - build-remex.ps1 named RemEx.sln; the file is Remex.sln, so every PC build on Linux died at dotnet restore. Fixed, with BuildScriptPathCaseTests checking every repo path the script names, case included (defect-injected red). - Release docs: RELEASE-NOTES-3.1.0.md, PLAY-WHATS-NEW-3.1.0.md (9 locales, each under 500 characters), README "What's new in 3.1" and 3.1.0 downloads, install docs and version examples, CHANGELOG Fixed. Verified: Windows publish stamps Remex.Agent.exe 3.1.0.0 (file and manifest); Linux package builds as remex-agent-v3.1.0-linux-x64.tar.gz (70.1 MB); APK is 3.1.0 (39). Gate (verify.ps1 -Scope all, Linux): all green apart from the same 10 environment-only agent failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E9cDSGibUBVRtkDqkLMk7V --- README.md | 60 +++++++++++++-- build-remex.ps1 | 41 +++++++++- docs/CHANGELOG.md | 12 +++ docs/CONTRIBUTING.md | 4 +- docs/INSTALL-ANDROID.md | 2 +- docs/LINUX_INSTALL.md | 12 +-- docs/PLAY-WHATS-NEW-3.1.0.md | 75 +++++++++++++++++++ docs/RELEASE-NOTES-3.1.0.md | 66 ++++++++++++++++ installer/RemEx.iss | 4 +- remex.agent/app.manifest | 2 +- remex.android/app/build.gradle.kts | 2 +- .../remex/ui/screens/AboutScreen.kt | 2 - .../app/src/main/res/values-es/strings.xml | 32 ++++---- .../app/src/main/res/values-fr/strings.xml | 32 ++++---- .../app/src/main/res/values-hi/strings.xml | 32 ++++---- .../app/src/main/res/values-in/strings.xml | 32 ++++---- .../app/src/main/res/values-pl/strings.xml | 32 ++++---- .../src/main/res/values-pt-rBR/strings.xml | 32 ++++---- .../app/src/main/res/values-tr/strings.xml | 32 ++++---- .../app/src/main/res/values-uk/strings.xml | 32 ++++---- .../app/src/main/res/values/strings.xml | 32 ++++---- .../Localization/WhatsNewParityTests.cs | 3 - .../Services/BuildScriptPathCaseTests.cs | 52 +++++++++++++ .../Services/VersionSourceOfTruthTests.cs | 26 +++++++ remex.desktop/Localization/Strings.es.resx | 40 ++++------ remex.desktop/Localization/Strings.fr.resx | 40 ++++------ remex.desktop/Localization/Strings.hi.resx | 40 ++++------ remex.desktop/Localization/Strings.id.resx | 40 ++++------ remex.desktop/Localization/Strings.pl.resx | 40 ++++------ remex.desktop/Localization/Strings.pt-BR.resx | 40 ++++------ remex.desktop/Localization/Strings.resx | 40 ++++------ remex.desktop/Localization/Strings.tr.resx | 40 ++++------ remex.desktop/Localization/Strings.uk.resx | 40 ++++------ remex.desktop/ViewModels/AboutViewModel.cs | 2 +- 34 files changed, 591 insertions(+), 422 deletions(-) create mode 100644 docs/PLAY-WHATS-NEW-3.1.0.md create mode 100644 docs/RELEASE-NOTES-3.1.0.md create mode 100644 remex.desktop.tests/Services/BuildScriptPathCaseTests.cs diff --git a/README.md b/README.md index 76f709d7..b8f7d635 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-RemEx 3.0: remote control for your PC, from your phone +RemEx 3.1: remote control for your PC, from your phone
@@ -14,7 +14,7 @@ [![Languages](https://img.shields.io/badge/languages-9-8fd694?style=flat-square)](#everything-it-does) [![License](https://img.shields.io/github/license/clindsay94/RemEx?style=flat-square)](LICENSE) -**[Get started](#get-started-in-five-minutes)**  ·  **[What's new in 3.0](#whats-new-in-30)**  ·  **[How it works](#how-it-talks-to-your-pc)**  ·  **[Build it](#building-from-source)**  ·  **[Docs](#docs-index)** +**[Get started](#get-started-in-five-minutes)**  ·  **[What's new in 3.1](#whats-new-in-31)**  ·  **[How it works](#how-it-talks-to-your-pc)**  ·  **[Build it](#building-from-source)**  ·  **[Docs](#docs-index)**
@@ -24,6 +24,55 @@ RemEx runs your PC from your phone over plain LAN: no relay server, no account, > **3.0 is the biggest release yet.** 296 commits and 1,436 files changed since 2.5.0, a new look shared across both apps, routines that act on their own, and a startup splash that actually shows RemEx finding your PC. It's also the first release in production on Google Play. +## What's new in 3.1 + +**3.1 redesigns file sharing on both apps.** The PC and the phone now show both devices in one folder tree, preview the file you pick, and show the SHA-256 fingerprint that proves a file arrived exactly as it was sent. + + + + + + + + + + + + +
+ +### 🗂️ One tree, both devices +The PC's File Transfer page and the phone's Files screen list **This PC** (or **This phone**) and the other device side by side. Open folders level by level; the tree follows you as you browse. + + + +### 👁️ Live preview +Pick a file to see it: photos at full quality with zoom, and text, code and logs in colour with line numbers. Turn on **Live** to follow a log as it grows. + + + +### 🔐 SHA-256 you can see +Transfers say **Verified** when the fingerprint really matched. Copy a file's fingerprint, compare it with one you paste, or check it against the copy on the other device. + +
+ +### ↔️ Send between devices +**Send to PC** and **Save to phone** into any folder you pick, and on the PC drag files from one device to the other. Everything goes through the same verified transfer queue. + + + +### 📁 Phone folders you choose +Add folders from your phone to browse them in RemEx. Your PC only sees one when you turn on **Share with PC**. + + + +### 📷 Scan to get the phone app +The PC's Home page shows a QR code that opens RemEx on Google Play. + +
+ +The full 3.1 write-up is in the [release notes](docs/RELEASE-NOTES-3.1.0.md). + ## What's new in 3.0 @@ -114,9 +163,9 @@ All four share one shader on Android and the PC, and Original Scan, Cosmic Zoom | Platform | Download | Size | |---|---|---| -| **Windows** | `RemEx-v3.0.0-Setup.exe` (Inno Setup, installs to run elevated for input and power control) | 55.6 MB | -| **Android** | `RemEx-V3.0.0-release.apk` (sideload) | 38.2 MB | -| **Linux x64** | `remex-agent-v3.0.0-linux-x64.tar.gz`, see [Linux install](docs/LINUX_INSTALL.md) | 69.7 MB | +| **Windows** | `RemEx-v3.1.0-Setup.exe` (Inno Setup, installs to run elevated for input and power control) | — | +| **Android** | `RemEx-V3.1.0-release.apk` (sideload) | 38.6 MB | +| **Linux x64** | `remex-agent-v3.1.0-linux-x64.tar.gz`, see [Linux install](docs/LINUX_INSTALL.md) | 70.1 MB | **Requires:** Android 14 or newer on the phone, a Windows or Linux PC, and both on the same network. To reach your PC from elsewhere, run [Tailscale](https://tailscale.com) on both ends, because RemEx has no relay of its own. @@ -227,6 +276,7 @@ Please don't open a public issue for a vulnerability. See [docs/SECURITY.md](doc | Doc | What's in it | |---|---| +| [docs/RELEASE-NOTES-3.1.0.md](docs/RELEASE-NOTES-3.1.0.md) | Readable summary of everything in 3.1 | | [docs/RELEASE-NOTES-3.0.0.md](docs/RELEASE-NOTES-3.0.0.md) | Readable summary of everything in 3.0 | | [docs/CHANGELOG.md](docs/CHANGELOG.md) | Full changelog, all versions | | [docs/INSTALL-ANDROID.md](docs/INSTALL-ANDROID.md) | Google Play, sideloading, Auto Blocker, first pairing | diff --git a/build-remex.ps1 b/build-remex.ps1 index 6f8fee17..7ec08a44 100644 --- a/build-remex.ps1 +++ b/build-remex.ps1 @@ -78,7 +78,7 @@ .EXAMPLE ./build-remex.ps1 -c release -t all .EXAMPLE - ./build-remex.ps1 -v 3.0.0 -t all -c release + ./build-remex.ps1 -v 3.1.0 -t all -c release .EXAMPLE ./build-remex.ps1 -t windows -NoClean .EXAMPLE @@ -265,6 +265,43 @@ function Sync-DirectoryBuildPropsVersion { } else { Write-Host "Directory.Build.props is already synced at version $DesiredVersion" -ForegroundColor DarkGray } + + Sync-PinnedVersionStrings -RepoRoot (Split-Path -Parent $BuildPropsPath) -DesiredVersion $DesiredVersion +} + +function Sync-PinnedVersionStrings { + # The two places that hold the PC version as text rather than reading Directory.Build.props: the Windows + # manifest's assemblyIdentity (four parts) and the Inno Setup script's fallback for a bare `iscc` run. Both had + # drifted for releases (1.13.0.0 and 2.2.0 at 3.0.0), so every version change now rewrites them too, and + # VersionSourceOfTruthTests fails the build if they disagree with Directory.Build.props. + param( + [Parameter(Mandatory=$true)][string]$RepoRoot, + [Parameter(Mandatory=$true)][string]$DesiredVersion + ) + + $edits = @( + @{ Path = Join-Path $RepoRoot "remex.agent/app.manifest" + Pattern = '( +• A new Files screen: your phone and your PC in one folder tree +• Tap a file to preview it: photos with zoom, and text, code and logs in color, with Live to follow a log +• SHA-256 fingerprints: Verified transfers, copy, compare and check a file against the copy on your PC +• Send to PC and Save to phone, into any folder you pick +• Add phone folders to browse them; your PC only sees them if you share them + + + +• Nueva pantalla Archivos: el teléfono y el PC en un mismo árbol de carpetas +• Toca un archivo para verlo: fotos con zoom, y texto, código y registros en color, con En vivo para seguir un registro +• Huellas SHA-256: transferencias verificadas; copia, compara y comprueba un archivo con la copia del PC +• Enviar al PC y Guardar en el teléfono, en la carpeta que elijas +• Añade carpetas del teléfono para verlas; el PC solo las ve si las compartes + + + +• Nouvel écran Fichiers : le téléphone et le PC dans une même arborescence +• Touchez un fichier pour l'afficher : photos avec zoom, texte, code et journaux en couleur, avec En direct pour suivre un journal +• Empreintes SHA-256 : transferts vérifiés, copie, comparaison et contrôle d'un fichier avec la copie du PC +• Envoyer au PC et Enregistrer sur le téléphone, dans le dossier de votre choix +• Ajoutez des dossiers du téléphone ; le PC ne les voit que si vous les partagez + + + +• नई फ़ाइलें स्क्रीन: फ़ोन और पीसी एक ही फ़ोल्डर ट्री में +• फ़ाइल पर टैप करके देखें: ज़ूम वाली फ़ोटो, रंगों में टेक्स्ट, कोड और लॉग, और लॉग देखने के लिए लाइव +• SHA-256 फ़िंगरप्रिंट: सत्यापित ट्रांसफ़र; कॉपी करें, तुलना करें और पीसी वाली कॉपी से जाँचें +• पीसी पर भेजें और फ़ोन पर सहेजें, अपने चुने फ़ोल्डर में +• फ़ोन के फ़ोल्डर जोड़ें; पीसी उन्हें तभी देखता है जब आप साझा करें + + + +• Layar File baru: ponsel dan PC dalam satu pohon folder +• Ketuk file untuk pratinjau: foto dengan zoom, serta teks, kode, dan log berwarna, dengan Langsung untuk mengikuti log +• Sidik jari SHA-256: transfer terverifikasi; salin, bandingkan, dan periksa file dengan salinan di PC +• Kirim ke PC dan Simpan ke ponsel, ke folder pilihan Anda +• Tambahkan folder ponsel; PC hanya melihatnya jika Anda membagikannya + + + +• Nowy ekran Pliki: telefon i komputer w jednym drzewie folderów +• Dotknij pliku, aby go zobaczyć: zdjęcia z powiększeniem, tekst, kod i logi w kolorze, a Na żywo śledzi log +• Odciski SHA-256: zweryfikowane przesyłanie; kopiuj, porównuj i sprawdzaj plik z kopią na komputerze +• Wyślij na komputer i Zapisz na telefonie, w wybranym folderze +• Dodaj foldery z telefonu; komputer widzi je tylko po udostępnieniu + + + +• Nova tela Arquivos: o celular e o PC em uma única árvore de pastas +• Toque em um arquivo para visualizar: fotos com zoom, e texto, código e logs em cores, com Ao vivo para acompanhar um log +• Impressões digitais SHA-256: transferências verificadas; copie, compare e confira um arquivo com a cópia no PC +• Enviar ao PC e Salvar no celular, na pasta que você escolher +• Adicione pastas do celular; o PC só as vê se você compartilhar + + + +• Yeni Dosyalar ekranı: telefon ve bilgisayar tek bir klasör ağacında +• Önizlemek için dosyaya dokunun: yakınlaştırılabilir fotoğraflar, renkli metin, kod ve günlükler; günlüğü izlemek için Canlı +• SHA-256 parmak izleri: doğrulanmış aktarımlar; dosyayı kopyalayın, karşılaştırın, bilgisayardaki kopyayla denetleyin +• Bilgisayara gönder ve Telefona kaydet, seçtiğiniz klasöre +• Telefon klasörleri ekleyin; bilgisayar onları yalnızca paylaşırsanız görür + + + +• Новий екран «Файли»: телефон і ПК в одному дереві папок +• Торкніться файлу для перегляду: фото з масштабуванням, текст, код і журнали в кольорі, а «Наживо» стежить за журналом +• Відбитки SHA-256: перевірені передавання; копіюйте, порівнюйте й перевіряйте файл із копією на ПК +• Надіслати на ПК і Зберегти на телефоні, у вибрану папку +• Додавайте папки телефона; ПК бачить їх, лише якщо ви поділитеся + diff --git a/docs/RELEASE-NOTES-3.1.0.md b/docs/RELEASE-NOTES-3.1.0.md new file mode 100644 index 00000000..63c23b5a --- /dev/null +++ b/docs/RELEASE-NOTES-3.1.0.md @@ -0,0 +1,66 @@ +**RemEx 3.1: Files, redesigned on the PC and the phone** + +This release is about one thing: moving and checking files between your phone and your PC. Both apps now show the two devices in one folder tree, preview the file you pick, and show the SHA-256 fingerprint that proves a file arrived exactly as it was sent. + +This release covers everything since 3.0.0. The complete list is in [`docs/CHANGELOG.md`](https://github.com/clindsay94/RemEx/blob/main/docs/CHANGELOG.md). + +--- + +## Both apps + +- **One folder tree for both devices.** The PC's File Transfer page lists This PC and each connected phone; the phone's Files screen lists This phone and your PC. Open a folder level by level with the arrow next to it, and the tree follows you as you browse. +- **Live preview.** Pick a file to see it straight away: photos at full quality with zoom and pan, and text, code and logs in color, with line numbers. Turn on **Live** to follow a log as new lines are written. Files that can't be previewed show their details. +- **SHA-256 fingerprints you can see.** A finished transfer says **Verified** when its fingerprint was really compared and matched, and **Done** when the other side sent nothing to compare. The preview's fingerprint section works out a file's fingerprint (shown as 64 letters and numbers, the format `sha256sum` and `Get-FileHash` print), copies it, compares it with one you paste in, and checks it against a copy on the other device. +- **A simpler toolbar.** Each file screen has one main button (Upload on the PC side, Add folder on the phone side), with New folder and Upload folder under More options. + +## On the phone + +- **A new Files screen.** On a tablet or an unfolded phone, the folder tree stays on the left with the folder and the preview beside it. On a phone, the tree opens from the menu button, a switch at the top flips between This phone and PC, and a file opens its preview full screen; back returns to the list. +- **Tapping a file previews it.** In list view a tap used to do nothing. +- **Send to PC and Save to phone.** Pick a folder on the other device and the files go there through the usual transfer queue, with pause, resume and verification. Long-press to select several files. +- **Phone folders you choose.** **Add folder** puts a folder from the phone in the tree so you can browse and preview it. Your PC can't see it unless you turn on **Share with PC** in its menu, which adds it to the same "Access from your PC" list as Settings. +- **Verified transfers.** Finished transfers show Verified with a check mark, a button copies the fingerprint, and the notification says Transfer verified. + +## On the PC + +- **A new File Transfer page,** laid out like Explorer: folders on the left, the folder you're in with thumbnails in the middle, and the preview on the right. +- **Right-click menus and keyboard shortcuts** (Enter, Backspace, F2, Delete, F5, Ctrl+C/X/V, Ctrl+A, Ctrl+F, Space for the preview). +- **Drag and drop.** Drag files from one device to another in the tree to send them, or from Windows Explorer onto a folder to upload them. Dragging inside one shared folder moves (or, with Ctrl, copies). +- **A transfer strip** along the bottom counts what's running, verified and failed, and opens into the full list. +- **Scan to get the phone app.** On Home, the Get the phone app card shows a QR code for RemEx on Google Play instead of opening a browser. + +## Security + +- A folder you add on the phone's Files screen is never visible to the PC until you turn on Share with PC. Everything the phone serves to the PC still comes only from the "Access from your PC" list. +- Previews and fingerprints from a phone use the same checks as browsing it, and the PC checks every path and range before a request leaves for the phone. "Let your PC change files" is not needed for either, because both only read. +- A phone file is fingerprinted on the phone and a PC file on the PC; only the 64-character result crosses the connection. + +--- + +## Install and upgrade + +- Install the new PC app over the old one. Your settings, paired phones and layout stay as they are. +- You do not need to pair again. Nothing in this release changes pairing or certificates. +- Update the PC and the phone together. A PC older than 3.1 still works with the new phone app, but the phone says "Update RemEx on your PC to preview its files here" instead of previewing PC files, and can't work out their fingerprints. Transfers keep working. +- The Google Play build is distributed separately through the Play Store. + +## Known issues + +- Send to PC and Save to phone move files, not folders. Use Upload folder and Download folder for a whole folder. +- On the phone, renaming or deleting the phone's own files is still done in the phone's own file manager. +- If a phone app lists two files with the same name in one folder (some cloud storage apps can), opening either one opens the first. The phone's own storage never does this. +- On the PC, HEIC photos from a phone preview as a thumbnail, not at full size. +- Phone files can't be dragged out of RemEx into Windows Explorer; use Download to… instead. +- The PC window does not remember its size and position. It opens at the default size each time. + +--- + +## Downloads + +| Platform | File | Size | +|---|---|---| +| **Windows** | `RemEx-v3.1.0-Setup.exe` | — | +| **Android** | `RemEx-V3.1.0-release.apk` (sideload) | 38.6 MB | +| **Linux (x64)** | `remex-agent-v3.1.0-linux-x64.tar.gz` | 70.1 MB | + +Full detail is in [`docs/CHANGELOG.md`](https://github.com/clindsay94/RemEx/blob/main/docs/CHANGELOG.md). diff --git a/installer/RemEx.iss b/installer/RemEx.iss index f377ea58..f1576554 100644 --- a/installer/RemEx.iss +++ b/installer/RemEx.iss @@ -1,11 +1,11 @@ ; ============================================================ ; RemEx Installer -; Build with: iscc /DAppVersion=2.2.0 installer\RemEx.iss +; Build with: iscc /DAppVersion=3.1.0 installer\RemEx.iss ; Or use: installer\build-installer.ps1 ; ============================================================ #ifndef AppVersion - #define AppVersion "2.2.0" + #define AppVersion "3.1.0" #endif #define AppName "RemEx" diff --git a/remex.agent/app.manifest b/remex.agent/app.manifest index 38dd41e2..a328c947 100644 --- a/remex.agent/app.manifest +++ b/remex.agent/app.manifest @@ -4,7 +4,7 @@ - + diff --git a/remex.android/app/build.gradle.kts b/remex.android/app/build.gradle.kts index e40c8535..1a75ee22 100644 --- a/remex.android/app/build.gradle.kts +++ b/remex.android/app/build.gradle.kts @@ -25,7 +25,7 @@ val androidLocalProperties = Properties().apply { // ── Version management ────────────────────────────────────────────────────── // Source of truth: app/version.properties (tracked in git). // - remexFreshAssembleRelease → builds with current version, no changes. -// - remexPublishRelease → bumps versionCode+1, PATCH+1 (3.0.0 → 3.0.1), +// - remexPublishRelease → bumps versionCode+1, PATCH+1 (3.1.0 → 3.1.1), // writes back to version.properties, then builds. val versionPropsFile = file("version.properties") val versionProps = Properties().apply { diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/AboutScreen.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/AboutScreen.kt index 5e699597..a305dc30 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/AboutScreen.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/AboutScreen.kt @@ -356,8 +356,6 @@ private val whatsNewEntries: List> = listOf( R.string.about_whats_new_5_label to R.string.about_whats_new_5_body, R.string.about_whats_new_6_label to R.string.about_whats_new_6_body, R.string.about_whats_new_7_label to R.string.about_whats_new_7_body, - R.string.about_whats_new_8_label to R.string.about_whats_new_8_body, - R.string.about_whats_new_9_label to R.string.about_whats_new_9_body, ) @Composable diff --git a/remex.android/app/src/main/res/values-es/strings.xml b/remex.android/app/src/main/res/values-es/strings.xml index a7a16fe6..47c39e3c 100644 --- a/remex.android/app/src/main/res/values-es/strings.xml +++ b/remex.android/app/src/main/res/values-es/strings.xml @@ -476,24 +476,20 @@ Tu PC Desconocido Ver código fuente en GitHub - Rutinas - Tu teléfono ya puede actuar solo cuando llegas a casa o te vas, tocas una etiqueta NFC o un sensor del PC pasa un límite. - Rutinas en tu PC - Las rutinas también pueden ejecutarse en el propio PC, por ejemplo para suspenderlo si está inactivo. Una cuenta atrás te deja cancelar un apagado o un reinicio. - Un nuevo diseño en el teléfono - La aplicación ahora tiene cinco pestañas. Inicio muestra tu PC y los mismos sensores fijados que el PC, y una pulsación larga edita la cuadrícula de Sensores. - El mismo aspecto en las dos aplicaciones - El teléfono y el PC ahora usan las mismas palabras, fuentes, tarjetas y colores. Una instalación nueva empieza con los colores de RemEx. - Arreglos en la transferencia de archivos - Descargar una carpeta, en cualquier sentido, ya no omite ni repite archivos. Archivos no muestra los elementos ocultos ni los del sistema hasta que activas Mostrar elementos ocultos. El PC ahora también puede explorar las carpetas compartidas de tu teléfono. - Una nueva pantalla de inicio - Al abrir RemEx, la pantalla de inicio muestra cómo arranca y se conecta de verdad, en lugar de una animación fija. - Alertas de sensores en el teléfono - Las alertas que creas en el PC ahora llegan al teléfono como notificaciones. Puedes añadirlas, cambiarlas o quitarlas desde cualquiera de los dos. - Registros y diagnósticos del PC en el teléfono - En Más hay una página nueva con lo que ha registrado tu PC y las mismas comprobaciones de estado que muestra el PC. Es de solo lectura. - Más formas de personalizar el teléfono - Elige un fondo, más formas de tarjeta y siete Nerd Fonts, y decide el ancho de la cuadrícula de Sensores. + Archivos, rediseñado + Tu teléfono y tu PC aparecen ahora en un mismo árbol de carpetas en las dos apps. Abre las carpetas nivel a nivel; el árbol te sigue mientras navegas. + Vista previa en vivo + Elige un archivo para verlo al instante: fotos a calidad completa con zoom, y texto, código y registros en color. Activa En vivo para seguir un registro mientras crece. + Huellas SHA-256 + Una transferencia ahora indica Verificado cuando se comprobó su huella. Copia la huella de un archivo, compárala con una que pegues o compruébala con una copia en el otro dispositivo. + Envía archivos entre dispositivos + Envía archivos del teléfono a una carpeta de tu PC o guarda archivos del PC en una carpeta del teléfono. En el PC también puedes arrastrar archivos de un dispositivo a otro. + Carpetas del teléfono que tú eliges + Añade carpetas de tu teléfono para verlas y previsualizarlas en RemEx. Tu PC solo ve una si activas Compartir con el PC. + Una barra de herramientas más sencilla + Cada pantalla de archivos tiene ahora un botón principal y el resto en Más opciones; además, tocar un archivo de la lista abre su vista previa. + Escanea para obtener la app del teléfono + En la página de inicio del PC, la tarjeta Obtener la aplicación de teléfono muestra ahora un código QR. Apunta la cámara del teléfono para abrir RemEx en Google Play. Novedades de la versión %1$s Recientes Cerrar configuración diff --git a/remex.android/app/src/main/res/values-fr/strings.xml b/remex.android/app/src/main/res/values-fr/strings.xml index b070b0e7..2ff4ab26 100644 --- a/remex.android/app/src/main/res/values-fr/strings.xml +++ b/remex.android/app/src/main/res/values-fr/strings.xml @@ -476,24 +476,20 @@ Votre PC Inconnu Voir le code source sur GitHub - Routines - Votre téléphone peut désormais agir seul quand vous rentrez ou partez, approchez un tag NFC, ou quand un capteur du PC dépasse un seuil. - Routines sur votre PC - Certaines routines s\'exécutent sur le PC lui-même, par exemple pour le mettre en veille s\'il est inactif. Un compte à rebours vous laisse annuler un arrêt ou un redémarrage. - Une nouvelle disposition sur le téléphone - L\'application a maintenant cinq onglets. Accueil affiche votre PC et les mêmes capteurs épinglés que le PC, et un appui long modifie la grille Capteurs. - Le même style sur les deux applications - Le téléphone et le PC utilisent maintenant les mêmes mots, polices, cartes et couleurs. Une nouvelle installation démarre avec les couleurs RemEx. - Corrections du transfert de fichiers - Télécharger un dossier, dans un sens comme dans l\'autre, ne saute ni ne répète plus aucun fichier. Fichiers masque les éléments cachés et système tant que vous n\'activez pas Afficher les éléments masqués. Le PC peut aussi parcourir les dossiers partagés de votre téléphone. - Un nouvel écran de démarrage - À l\'ouverture, l\'écran de démarrage montre RemEx qui démarre et se connecte vraiment, au lieu d\'une animation figée. - Alertes de capteurs sur le téléphone - Les alertes que vous créez sur le PC arrivent maintenant sur le téléphone sous forme de notifications. Vous pouvez les ajouter, les modifier ou les supprimer depuis l\'un ou l\'autre. - Journaux et diagnostics du PC sur le téléphone - Dans Plus, une nouvelle page montre ce que votre PC a consigné et les mêmes vérifications d\'état que sur le PC. Elle est en lecture seule. - Plus de façons de personnaliser le téléphone - Choisissez un arrière-plan, d\'autres formes de cartes et sept Nerd Fonts, ainsi que la largeur de la grille de Capteurs. + Fichiers, repensé + Votre téléphone et votre PC apparaissent désormais dans une même arborescence, sur les deux applis. Ouvrez les dossiers niveau par niveau : l’arborescence vous suit pendant que vous naviguez. + Aperçu en direct + Choisissez un fichier pour le voir tout de suite : photos en pleine qualité avec zoom, texte, code et journaux en couleur. Activez En direct pour suivre un journal à mesure qu’il s’allonge. + Empreintes SHA-256 + Un transfert indique désormais Vérifié quand son empreinte a été contrôlée. Copiez l’empreinte d’un fichier, comparez-la avec une empreinte collée ou vérifiez-la avec une copie sur l’autre appareil. + Envoyez des fichiers entre appareils + Envoyez des fichiers du téléphone vers un dossier du PC, ou enregistrez des fichiers du PC dans un dossier du téléphone. Sur le PC, vous pouvez aussi glisser des fichiers d’un appareil à l’autre. + Les dossiers du téléphone de votre choix + Ajoutez des dossiers de votre téléphone pour les parcourir et les prévisualiser dans RemEx. Votre PC n’en voit un que si vous activez Partager avec le PC. + Une barre d’outils plus simple + Chaque écran de fichiers a désormais un bouton principal, le reste se trouve sous Plus d’options, et toucher un fichier dans la liste ouvre son aperçu. + Scannez pour obtenir l’appli mobile + Sur la page d’accueil du PC, la carte Obtenir l’application téléphone affiche désormais un code QR. Visez-le avec l’appareil photo du téléphone pour ouvrir RemEx sur Google Play. Nouveautés de la version %1$s Récents Fermer les paramètres diff --git a/remex.android/app/src/main/res/values-hi/strings.xml b/remex.android/app/src/main/res/values-hi/strings.xml index 6b4f1eec..213c877c 100644 --- a/remex.android/app/src/main/res/values-hi/strings.xml +++ b/remex.android/app/src/main/res/values-hi/strings.xml @@ -476,24 +476,20 @@ आपका पीसी अज्ञान GitHub पर स्रोत कोड देखें - रूटीन - अब आपका फ़ोन अपने आप काम कर सकता है, जब आप घर पहुँचें या निकलें, कोई NFC टैग टैप करें, या PC का कोई सेंसर सीमा पार करे। - आपके PC पर रूटीन - रूटीन अब PC पर भी चल सकते हैं, जैसे PC खाली हो तो उसे स्लीप में भेजना। शटडाउन या रीस्टार्ट से पहले उलटी गिनती होती है, ताकि आप उसे रोक सकें। - फ़ोन का नया लेआउट - ऐप में अब पाँच टैब हैं। होम पर आपका PC और वही पिन किए गए सेंसर दिखते हैं जो PC पर हैं, और देर तक दबाकर आप सेंसर ग्रिड बदल सकते हैं। - दोनों ऐप्स का एक जैसा रूप - फ़ोन और PC अब एक जैसे शब्द, फ़ॉन्ट, कार्ड और रंग इस्तेमाल करते हैं। नया इंस्टॉल RemEx के रंगों से शुरू होता है। - फ़ाइल ट्रांसफ़र में सुधार - किसी भी तरफ़ पूरा फ़ोल्डर डाउनलोड करने पर अब फ़ाइलें छूटती या दोहराई नहीं जातीं। फ़ाइलें स्क्रीन छिपे हुए और सिस्टम आइटम तब तक नहीं दिखाती, जब तक आप छिपे हुए आइटम दिखाएँ चालू न करें। अब PC आपके फ़ोन के साझा फ़ोल्डर भी ब्राउज़ कर सकता है। - नई स्प्लैश स्क्रीन - RemEx खुलते समय स्प्लैश अब कोई तय एनीमेशन नहीं चलाती, बल्कि दिखाती है कि ऐप सच में कैसे शुरू होकर कनेक्ट होता है। - फ़ोन पर सेंसर चेतावनियाँ - PC पर बनाई गई चेतावनियाँ अब आपके फ़ोन पर नोटिफ़िकेशन के रूप में आती हैं। आप उन्हें दोनों में से किसी से भी जोड़, बदल या हटा सकते हैं। - फ़ोन पर PC के लॉग और जाँच - अधिक में एक नया पेज है, जो दिखाता है कि आपके PC ने क्या दर्ज किया और PC जैसी ही स्वास्थ्य जाँचें भी। यह केवल पढ़ने के लिए है। - फ़ोन को अपने ढंग से सजाने के और तरीके - पृष्ठभूमि, कार्ड के और आकार और सात Nerd Fonts चुनें, और तय करें कि सेंसर ग्रिड कितना चौड़ा हो। + फ़ाइलें, नए रूप में + अब दोनों ऐप में आपका फ़ोन और पीसी एक ही फ़ोल्डर ट्री में दिखते हैं। फ़ोल्डर एक-एक स्तर खोलें, और ब्राउज़ करते समय ट्री आपके साथ चलता है। + लाइव पूर्वावलोकन + कोई फ़ाइल चुनें और उसे तुरंत देखें: ज़ूम के साथ पूरी गुणवत्ता वाली फ़ोटो, और रंगों में टेक्स्ट, कोड और लॉग। बढ़ते लॉग को देखने के लिए लाइव चालू करें। + SHA-256 फ़िंगरप्रिंट + अब फ़िंगरप्रिंट जाँचे जाने पर ट्रांसफ़र सत्यापित दिखाता है। किसी फ़ाइल का फ़िंगरप्रिंट कॉपी करें, चिपकाए गए फ़िंगरप्रिंट से तुलना करें, या दूसरे डिवाइस पर मौजूद कॉपी से जाँचें। + डिवाइसों के बीच फ़ाइलें भेजें + फ़ोन की फ़ाइलें पीसी के किसी फ़ोल्डर में भेजें, या पीसी की फ़ाइलें फ़ोन के किसी फ़ोल्डर में सहेजें। पीसी पर आप फ़ाइलें एक डिवाइस से दूसरे पर खींच भी सकते हैं। + आपके चुने हुए फ़ोन फ़ोल्डर + फ़ोन के फ़ोल्डर जोड़ें ताकि RemEx में उन्हें ब्राउज़ कर सकें और उनका पूर्वावलोकन देख सकें। आपका पीसी उसे तभी देखता है जब आप पीसी से साझा करें चालू करते हैं। + सरल टूलबार + अब हर फ़ाइल स्क्रीन पर एक मुख्य बटन है, बाकी अधिक विकल्प में हैं, और सूची में किसी फ़ाइल पर टैप करने से उसका पूर्वावलोकन खुलता है। + फ़ोन ऐप पाने के लिए स्कैन करें + पीसी के होम पेज पर, फ़ोन ऐप प्राप्त करें कार्ड अब एक QR कोड दिखाता है। Google Play पर RemEx खोलने के लिए फ़ोन का कैमरा उस पर रखें। %1$s में नया क्या है हाल का सेटिंग्स बंद करें diff --git a/remex.android/app/src/main/res/values-in/strings.xml b/remex.android/app/src/main/res/values-in/strings.xml index 385855ab..a8e11311 100644 --- a/remex.android/app/src/main/res/values-in/strings.xml +++ b/remex.android/app/src/main/res/values-in/strings.xml @@ -469,24 +469,20 @@ PC Anda Tidak Diketahui Lihat kode sumber di GitHub - Rutinitas - Ponsel Anda kini bisa bertindak sendiri saat Anda tiba di rumah atau pergi, mengetuk tag NFC, atau saat sensor PC melewati batas. - Rutinitas di PC Anda - Rutinitas juga bisa berjalan di PC itu sendiri, misalnya menidurkan PC saat sedang tidak dipakai. Sebelum PC dimatikan atau dimulai ulang, ada hitung mundur agar Anda bisa membatalkannya. - Tata letak baru di ponsel - Aplikasi kini punya lima tab. Beranda menampilkan PC Anda dan sensor yang disematkan sama seperti di PC, dan tekan lama untuk mengedit kisi Sensor. - Satu tampilan di kedua aplikasi - Ponsel dan PC kini memakai kata, font, kartu, dan warna yang sama. Instalasi baru dimulai dengan warna RemEx. - Perbaikan transfer file - Mengunduh folder, ke arah mana pun, tidak lagi melewatkan atau mengulang file. File menyembunyikan item tersembunyi dan item sistem sampai Anda menyalakan Tampilkan item tersembunyi. PC kini juga bisa menjelajahi folder bersama di ponsel Anda. - Layar pembuka baru - Saat RemEx dibuka, layar pembuka kini menunjukkan aplikasi benar-benar memulai dan terhubung, bukan memutar animasi tetap. - Peringatan sensor di ponsel - Peringatan yang Anda atur di PC kini sampai ke ponsel sebagai notifikasi. Anda bisa menambah, mengubah, atau menghapusnya dari salah satunya. - Log dan diagnostik PC di ponsel - Di Lainnya, ada halaman baru yang menampilkan apa yang dicatat PC Anda beserta pemeriksaan kondisi yang sama seperti di PC. Halaman ini hanya untuk dibaca. - Lebih banyak cara mempersonalisasi ponsel - Pilih latar belakang, lebih banyak bentuk kartu, dan tujuh Nerd Font, lalu atur lebar kisi Sensor. + File, didesain ulang + Ponsel dan PC Anda kini ada dalam satu pohon folder di kedua aplikasi. Buka folder tingkat demi tingkat, dan pohonnya mengikuti saat Anda menjelajah. + Pratinjau langsung + Pilih file untuk langsung melihatnya: foto berkualitas penuh dengan zoom, serta teks, kode, dan log berwarna. Nyalakan Langsung untuk mengikuti log saat bertambah. + Sidik jari SHA-256 + Transfer kini menampilkan Terverifikasi saat sidik jarinya sudah diperiksa. Salin sidik jari file, bandingkan dengan yang Anda tempel, atau periksa dengan salinan di perangkat lain. + Kirim file antarperangkat + Kirim file ponsel ke folder di PC, atau simpan file PC ke folder di ponsel. Di PC, Anda juga bisa menyeret file dari satu perangkat ke perangkat lain. + Folder ponsel pilihan Anda + Tambahkan folder dari ponsel untuk menjelajahi dan melihat pratinjaunya di RemEx. PC hanya melihatnya jika Anda menyalakan Bagikan ke PC. + Toolbar yang lebih sederhana + Setiap layar file kini punya satu tombol utama, sisanya ada di Opsi lainnya, dan mengetuk file di daftar kini membuka pratinjaunya. + Pindai untuk mendapatkan aplikasi ponsel + Di halaman Beranda PC, kartu Dapatkan aplikasi ponsel kini menampilkan kode QR. Arahkan kamera ponsel ke kode itu untuk membuka RemEx di Google Play. Yang baru di %1$s Terbaru Tutup pengaturan diff --git a/remex.android/app/src/main/res/values-pl/strings.xml b/remex.android/app/src/main/res/values-pl/strings.xml index 27807c13..5cf2b25a 100644 --- a/remex.android/app/src/main/res/values-pl/strings.xml +++ b/remex.android/app/src/main/res/values-pl/strings.xml @@ -469,24 +469,20 @@ Kompilacja %1$s Nieznany Zobacz kod źródłowy na GitHubie - Rutyny - Telefon może teraz działać sam, gdy wracasz do domu lub wychodzisz, zbliżasz go do tagu NFC albo czujnik komputera przekracza limit. - Rutyny na komputerze - Rutyny mogą też działać na samym komputerze, na przykład usypiać go, gdy jest bezczynny. Przed wyłączeniem lub ponownym uruchomieniem odliczanie daje ci czas, żeby to przerwać. - Nowy układ telefonu - Aplikacja ma teraz pięć kart. Start pokazuje twój komputer i te same przypięte czujniki co na komputerze, a długie naciśnięcie pozwala edytować siatkę Czujniki. - Jeden wygląd w obu aplikacjach - Telefon i komputer używają teraz tych samych słów, czcionek, kart i kolorów. Nowa instalacja startuje w kolorach RemEx. - Poprawki przesyłania plików - Pobieranie folderu, w obie strony, nie pomija już ani nie powtarza plików. Pliki nie pokazują ukrytych i systemowych elementów, dopóki nie włączysz opcji Pokaż ukryte elementy. Komputer może teraz przeglądać też udostępnione foldery telefonu. - Nowy ekran powitalny - Po otwarciu RemEx ekran powitalny pokazuje, jak aplikacja naprawdę się uruchamia i łączy, zamiast odtwarzać stałą animację. - Alerty czujników w telefonie - Alerty ustawione na komputerze trafiają teraz do telefonu jako powiadomienia. Możesz je dodawać, zmieniać i usuwać na każdym z nich. - Logi i diagnostyka komputera w telefonie - W Więcej nowa strona pokazuje, co zapisał twój komputer, oraz te same kontrole stanu co na komputerze. Jest tylko do odczytu. - Więcej sposobów personalizacji telefonu - Wybierz tło, więcej kształtów kart i siedem czcionek Nerd Fonts oraz ustaw szerokość siatki Czujników. + Pliki w nowej odsłonie + Telefon i komputer są teraz w jednym drzewie folderów w obu aplikacjach. Otwieraj foldery poziom po poziomie, a drzewo podąża za tobą. + Podgląd na żywo + Wybierz plik, aby od razu go zobaczyć: zdjęcia w pełnej jakości z powiększeniem oraz tekst, kod i logi w kolorze. Włącz Na żywo, aby śledzić rosnący log. + Odciski SHA-256 + Przesyłanie pokazuje teraz Zweryfikowano, gdy sprawdzono jego odcisk. Skopiuj odcisk pliku, porównaj go z wklejonym albo sprawdź z kopią na drugim urządzeniu. + Przesyłaj pliki między urządzeniami + Wysyłaj pliki z telefonu do folderu na komputerze albo zapisuj pliki z komputera w folderze na telefonie. Na komputerze możesz też przeciągać pliki między urządzeniami. + Wybrane przez ciebie foldery telefonu + Dodaj foldery z telefonu, aby przeglądać je w RemEx. Komputer widzi folder tylko wtedy, gdy włączysz Udostępnij komputerowi. + Prostszy pasek narzędzi + Każdy ekran plików ma teraz jeden główny przycisk, a resztę w menu Więcej opcji. Dotknięcie pliku na liście otwiera jego podgląd. + Zeskanuj, aby pobrać aplikację na telefon + Na stronie głównej komputera karta Pobierz aplikację na telefon pokazuje teraz kod QR. Skieruj na niego aparat telefonu, aby otworzyć RemEx w Google Play. Nowości w wersji %1$s Ostatnie Zamknij ustawienia diff --git a/remex.android/app/src/main/res/values-pt-rBR/strings.xml b/remex.android/app/src/main/res/values-pt-rBR/strings.xml index 2500bf3f..ccfc0d7a 100644 --- a/remex.android/app/src/main/res/values-pt-rBR/strings.xml +++ b/remex.android/app/src/main/res/values-pt-rBR/strings.xml @@ -476,24 +476,20 @@ Seu PC Desconhecido Ver código-fonte no GitHub - Rotinas - Seu celular agora pode agir sozinho quando você chega em casa ou sai, toca uma tag NFC ou um sensor do PC passa de um limite. - Rotinas no seu PC - As rotinas também podem rodar no próprio PC, como suspendê-lo quando ele fica ocioso. Uma contagem regressiva dá tempo de cancelar um desligamento ou reinício. - Um novo layout no celular - O aplicativo agora tem cinco abas. Início mostra seu PC e os mesmos sensores fixados do PC, e um toque longo edita a grade de Sensores. - Um só visual nos dois aplicativos - O celular e o PC agora usam as mesmas palavras, fontes, cartões e cores. Uma instalação nova começa com as cores do RemEx. - Correções na transferência de arquivos - Baixar uma pasta, em qualquer direção, não pula nem repete mais arquivos. Arquivos esconde itens ocultos e do sistema até você ativar Mostrar itens ocultos. O PC agora também pode explorar as pastas compartilhadas do seu celular. - Uma nova tela de inicialização - Ao abrir o RemEx, a tela de inicialização mostra o aplicativo iniciando e se conectando de verdade, em vez de uma animação fixa. - Alertas de sensores no celular - Os alertas que você cria no PC agora chegam ao celular como notificações. Você pode adicioná-los, alterá-los ou removê-los em qualquer um dos dois. - Logs e diagnóstico do PC no celular - Em Mais, uma página nova mostra o que o seu PC registrou e as mesmas verificações de integridade que o PC mostra. Ela é somente leitura. - Mais formas de personalizar o celular - Escolha um plano de fundo, mais formas de cartão e sete Nerd Fonts, e defina a largura da grade de Sensores. + Arquivos, redesenhado + Seu celular e seu PC agora ficam em uma única árvore de pastas nos dois apps. Abra as pastas nível por nível, e a árvore acompanha você enquanto navega. + Visualização ao vivo + Escolha um arquivo para vê-lo na hora: fotos em qualidade total com zoom, e texto, código e logs em cores. Ative Ao vivo para acompanhar um log enquanto ele cresce. + Impressões digitais SHA-256 + Uma transferência agora mostra Verificado quando a impressão digital foi conferida. Copie a impressão digital de um arquivo, compare com uma colada ou confira com uma cópia no outro aparelho. + Envie arquivos entre aparelhos + Envie arquivos do celular para uma pasta no PC ou salve arquivos do PC em uma pasta no celular. No PC, você também pode arrastar arquivos de um aparelho para o outro. + Pastas do celular que você escolhe + Adicione pastas do celular para navegar e visualizar no RemEx. O PC só vê uma pasta se você ativar Compartilhar com o PC. + Uma barra de ferramentas mais simples + Cada tela de arquivos agora tem um botão principal, com o resto em Mais opções, e tocar em um arquivo da lista abre a visualização. + Escaneie para baixar o app do celular + Na página Início do PC, o cartão Obter o aplicativo do telefone agora mostra um QR code. Aponte a câmera do celular para abrir o RemEx no Google Play. Novidades da versão %1$s Recentes Fechar configurações diff --git a/remex.android/app/src/main/res/values-tr/strings.xml b/remex.android/app/src/main/res/values-tr/strings.xml index 778403a4..49215237 100644 --- a/remex.android/app/src/main/res/values-tr/strings.xml +++ b/remex.android/app/src/main/res/values-tr/strings.xml @@ -476,24 +476,20 @@ PC\'niz Bilinmeyen GitHub\'da kaynağı görüntüle - Rutinler - Telefonunuz artık eve gelip gittiğinizde, bir NFC etiketine dokunduğunuzda veya bilgisayarın bir sensörü sınırı aştığında kendi başına harekete geçebilir. - Bilgisayarınızda rutinler - Rutinler bilgisayarın kendisinde de çalışabilir, örneğin boşta kalınca onu uyku moduna alabilir. Kapatma veya yeniden başlatmadan önce bir geri sayım, durdurmanız için size zaman tanır. - Telefonda yeni düzen - Uygulamada artık beş sekme var. Ana Sayfa bilgisayarınızı ve bilgisayardakiyle aynı sabitlenmiş sensörleri gösterir; Sensörler ızgarasını uzun basarak düzenlersiniz. - İki uygulamada tek görünüm - Telefon ve bilgisayar artık aynı sözcükleri, yazı tiplerini, kartları ve renkleri kullanıyor. Yeni bir kurulum RemEx renkleriyle başlar. - Dosya aktarımı düzeltmeleri - Bir klasörü iki yönde de indirmek artık dosya atlamıyor veya tekrarlamıyor. Dosyalar, siz Gizli öğeleri göster seçeneğini açana kadar gizli ve sistem öğelerini göstermez. Bilgisayar artık telefonunuzun paylaşılan klasörlerine de göz atabilir. - Yeni başlangıç ekranı - RemEx açılırken başlangıç ekranı artık sabit bir animasyon oynatmak yerine uygulamanın gerçekten başlayıp bağlandığını gösteriyor. - Telefonda sensör uyarıları - Bilgisayarda oluşturduğunuz uyarılar artık telefonunuza bildirim olarak geliyor. Bunları ikisinden de ekleyebilir, değiştirebilir veya kaldırabilirsiniz. - Telefonda bilgisayar günlükleri ve tanılama - Diğer\'de yeni bir sayfa, bilgisayarınızın neleri kaydettiğini ve bilgisayarda görünen aynı durum denetimlerini gösterir. Yalnızca okunabilir. - Telefonu kişiselleştirmenin daha fazla yolu - Bir arka plan, daha fazla kart şekli ve yedi Nerd Font seçin; Sensörler ızgarasının genişliğini belirleyin. + Dosyalar yeniden tasarlandı + Telefonunuz ve bilgisayarınız artık iki uygulamada da tek bir klasör ağacında. Klasörleri düzey düzey açın; ağaç siz gezindikçe sizi izler. + Canlı önizleme + Bir dosyayı hemen görmek için seçin: yakınlaştırılabilir tam kaliteli fotoğraflar, renkli metin, kod ve günlükler. Büyüyen bir günlüğü izlemek için Canlı\'yı açın. + SHA-256 parmak izleri + Bir aktarım, parmak izi denetlendiğinde artık Doğrulandı yazar. Bir dosyanın parmak izini kopyalayın, yapıştırdığınızla karşılaştırın ya da diğer cihazdaki kopyayla denetleyin. + Cihazlar arasında dosya gönderin + Telefon dosyalarını bilgisayarınızdaki bir klasöre gönderin ya da bilgisayar dosyalarını telefonunuzdaki bir klasöre kaydedin. Bilgisayarda dosyaları bir cihazdan diğerine sürükleyebilirsiniz de. + Seçtiğiniz telefon klasörleri + RemEx\'te göz atmak ve önizlemek için telefonunuzdan klasörler ekleyin. Bilgisayarınız bir klasörü yalnızca Bilgisayarla paylaş\'ı açarsanız görür. + Daha sade bir araç çubuğu + Her dosya ekranında artık tek bir ana düğme var, gerisi Diğer seçenekler altında. Listedeki bir dosyaya dokunmak artık önizlemesini açar. + Telefon uygulamasını almak için tarayın + Bilgisayarın Ana sayfasındaki Telefon uygulamasını al kartı artık bir QR kodu gösterir. RemEx\'i Google Play\'de açmak için telefonunuzun kamerasını ona tutun. %1$s sürümündeki yenilikler Son kullanılanlar Ayarları kapat diff --git a/remex.android/app/src/main/res/values-uk/strings.xml b/remex.android/app/src/main/res/values-uk/strings.xml index 873bdbee..dc80dd50 100644 --- a/remex.android/app/src/main/res/values-uk/strings.xml +++ b/remex.android/app/src/main/res/values-uk/strings.xml @@ -490,24 +490,20 @@ Ваш ПК Невідомо Переглянути вихідний код на GitHub - Рутини - Тепер телефон може діяти сам, коли ви приходите додому чи йдете, торкаєтеся NFC-мітки або датчик ПК перетинає поріг. - Рутини на вашому ПК - Рутини можуть працювати й на самому ПК, наприклад присипляти його, коли він простоює. Перед вимкненням чи перезапуском іде відлік, щоб ви встигли його скасувати. - Новий макет на телефоні - Тепер у додатку п’ять вкладок. Головна показує ваш ПК і ті самі закріплені датчики, що й на ПК, а довге натискання дає змогу змінити сітку Датчиків. - Однаковий вигляд в обох застосунках - Телефон і ПК тепер використовують ті самі слова, шрифти, картки й кольори. Нове встановлення починається з кольорів RemEx. - Виправлення передавання файлів - Завантаження теки, в будь-який бік, більше не пропускає й не повторює файли. Файли не показують прихованих і системних елементів, доки ви не ввімкнете «Показувати приховані елементи». ПК тепер також може переглядати спільні теки вашого телефону. - Нова заставка - Під час відкриття заставка тепер показує, як RemEx справді запускається й підключається, а не відтворює фіксовану анімацію. - Оповіщення датчиків на телефоні - Оповіщення, які ви створюєте на ПК, тепер приходять на телефон як сповіщення. Додавати, змінювати й вилучати їх можна з будь-якого з двох пристроїв. - Журнали та діагностика ПК на телефоні - У розділі «Більше» нова сторінка показує, що записав ваш ПК, і ті самі перевірки стану, що й на ПК. Вона лише для читання. - Більше способів персоналізувати телефон - Виберіть тло, більше форм карток і сім шрифтів Nerd Fonts та задайте ширину сітки Датчиків. + Файли в новому вигляді + Телефон і ПК тепер в одному дереві папок в обох застосунках. Відкривайте папки рівень за рівнем, а дерево стежить за вами під час перегляду. + Живий перегляд + Виберіть файл, щоб одразу його побачити: фото в повній якості з масштабуванням, а текст, код і журнали в кольорі. Увімкніть «Наживо», щоб стежити за журналом, поки він росте. + Відбитки SHA-256 + Тепер передавання показує «Перевірено», коли його відбиток перевірено. Скопіюйте відбиток файлу, порівняйте його зі вставленим або перевірте з копією на іншому пристрої. + Надсилайте файли між пристроями + Надсилайте файли з телефона в папку на ПК або зберігайте файли з ПК у папку на телефоні. На ПК також можна перетягувати файли з одного пристрою на інший. + Папки телефона на ваш вибір + Додайте папки з телефона, щоб переглядати їх у RemEx. ПК бачить папку, лише якщо ви ввімкнете «Поділитися з ПК». + Простіша панель інструментів + Кожен екран файлів тепер має одну головну кнопку, а решта в меню «Більше параметрів». Торкання файлу в списку відкриває його перегляд. + Скануйте, щоб отримати застосунок для телефона + На головній сторінці ПК картка «Отримати застосунок для телефону» тепер показує QR-код. Наведіть на нього камеру телефона, щоб відкрити RemEx у Google Play. Що нового у версії %1$s Нещодавні Закрити налаштування diff --git a/remex.android/app/src/main/res/values/strings.xml b/remex.android/app/src/main/res/values/strings.xml index 5f2613c5..bc95fe0c 100644 --- a/remex.android/app/src/main/res/values/strings.xml +++ b/remex.android/app/src/main/res/values/strings.xml @@ -401,24 +401,20 @@ Browse releases, report issues, and star the project if RemEx helps you. Made with ♥️ in Denver, Colorado What\'s new in %1$s - Routines - Your phone can now act on its own when you get home or leave, tap an NFC tag, or a PC sensor passes a limit. - Routines on your PC - Routines can also run on the PC itself, like sleeping it when it\'s idle. A countdown gives you time to stop a shutdown or restart. - A new phone layout - The app now has five tabs. Home shows your PC and the same pinned sensors as the PC, and a long press edits the Sensors grid. - One look on both apps - The phone and the PC now use the same words, fonts, cards and colors. A fresh install starts on the RemEx colors. - File transfer fixes - Folder downloads, either way, no longer skip or repeat files. Files hides hidden and system items until you turn on Show hidden items. The PC can now browse your phone\'s shared folders too. - A new startup splash - When RemEx opens, the splash now shows it really starting up and connecting, instead of playing a fixed animation. - Sensor alerts on your phone - The alerts you set on the PC now reach your phone as notifications. You can add, change or remove them from either one. - PC logs and diagnostics on your phone - In More, a new page shows what your PC has logged and the same health checks the PC shows. It is read-only. - More ways to personalize the phone - Pick a background, more card shapes and seven Nerd Fonts, and choose how wide the Sensors grid is. + Files, redesigned + Your phone and your PC now sit in one folder tree on both apps. Open folders level by level, and the tree follows you as you browse. + Live preview + Pick a file to see it straight away: photos at full quality with zoom, and text, code and logs in color. Turn on Live to follow a log as it grows. + SHA-256 fingerprints + A transfer now says Verified when its fingerprint was checked. Copy a file\'s fingerprint, compare it with one you paste, or check it against a copy on the other device. + Send files between devices + Send phone files to a folder on your PC, or save PC files into a folder on your phone. On the PC you can also drag files from one device to the other. + Phone folders you choose + Add folders from your phone to browse and preview them in RemEx. Your PC only sees one if you turn on Share with PC. + A simpler toolbar + Each file screen now has one main button, with the rest under More options, and tapping a file in the list now opens its preview. + Scan to get the phone app + On the PC\'s Home page, the Get the phone app card now shows a QR code. Point your phone\'s camera at it to open RemEx on Google Play. Frequently asked questions Collapse Expand diff --git a/remex.desktop.tests/Localization/WhatsNewParityTests.cs b/remex.desktop.tests/Localization/WhatsNewParityTests.cs index 8a3097bb..1a231704 100644 --- a/remex.desktop.tests/Localization/WhatsNewParityTests.cs +++ b/remex.desktop.tests/Localization/WhatsNewParityTests.cs @@ -187,9 +187,6 @@ public void TheCopyIsPlain() } } - // Sensor alerts used to be PC-only, so the phone's list could not mention them. Since 3.0 the - // PC's alert rules reach the phone (RemEx-pp4cm.12) and item 7 says so. - offenders.Should().BeEmpty(); } } diff --git a/remex.desktop.tests/Services/BuildScriptPathCaseTests.cs b/remex.desktop.tests/Services/BuildScriptPathCaseTests.cs new file mode 100644 index 00000000..488d5790 --- /dev/null +++ b/remex.desktop.tests/Services/BuildScriptPathCaseTests.cs @@ -0,0 +1,52 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FluentAssertions; +using Xunit; + +namespace Remex.Desktop.Tests.Services; + +/// +/// Every source file build-remex.ps1 names under the repo root exists with exactly that case (hard rule 4). The +/// script asked for RemEx.sln while the file is Remex.sln: Windows found it anyway, and every PC +/// build on Linux died at dotnet restore ("Project file does not exist"), found while cutting 3.1.0. +/// +public sealed class BuildScriptPathCaseTests +{ + // Folders and files the build creates, or that are machine-local; they need not exist in a clean checkout. + private static readonly string[] Generated = ["artifacts", "build_output", "installer/Output", "remex.android/local.properties"]; + + private static string RepoRoot([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "..")); + + [Fact] + public void EveryRepoPathTheBuildScriptNames_ExistsWithThatExactCase() + { + var script = File.ReadAllText(Path.Combine(RepoRoot(), "build-remex.ps1")); + var paths = Regex.Matches(script, @"Join-Paths \$RepoRoot((?:\s+""[^""$]*"")+)(?!\s+""[^""]*\$)") + .Select(m => string.Join("/", Regex.Matches(m.Groups[1].Value, @"""([^""]*)""").Select(g => g.Groups[1].Value))) + .Distinct() + .Where(p => !Generated.Any(g => p == g || p.StartsWith(g + "/", StringComparison.Ordinal))) + .ToList(); + + paths.Should().Contain(["Remex.sln", "Directory.Build.props", "installer/RemEx.iss"], + "the scan must still see the paths it exists to check"); + + var wrong = paths.Where(p => !ExistsWithExactCase(RepoRoot(), p)).ToList(); + wrong.Should().BeEmpty("a path whose case differs from the file works on Windows and fails on Linux"); + } + + /// Walks each segment and compares names ordinally, so the answer is the same on Windows and Linux. + private static bool ExistsWithExactCase(string root, string relative) + { + var current = root; + foreach (var segment in relative.Split('/')) + { + if (!Directory.Exists(current)) return false; + var match = Directory.EnumerateFileSystemEntries(current) + .FirstOrDefault(e => string.Equals(Path.GetFileName(e), segment, StringComparison.Ordinal)); + if (match is null) return false; + current = match; + } + return true; + } +} diff --git a/remex.desktop.tests/Services/VersionSourceOfTruthTests.cs b/remex.desktop.tests/Services/VersionSourceOfTruthTests.cs index e9d6f59f..9e10c3aa 100644 --- a/remex.desktop.tests/Services/VersionSourceOfTruthTests.cs +++ b/remex.desktop.tests/Services/VersionSourceOfTruthTests.cs @@ -106,6 +106,32 @@ public void TheBuildScriptStillSynchronisesTheDesktopFromAndroid() "and $Version itself has to be READ from Android's file for it to be the source of truth"); } + /// + /// The two files that hold the PC version as text: the Windows manifest's assemblyIdentity and the Inno + /// Setup script's fallback for a bare iscc run. At 3.0.0 they still said 1.13.0.0 and 2.2.0, because + /// nothing rewrote them; Sync-PinnedVersionStrings in build-remex.ps1 now does, and this pins that. + /// + [Fact] + public void TheManifestAndTheInstallerFallbackCarryTheSameVersion() + { + var desktop = DesktopVersion(); + desktop.Should().MatchRegex(@"^\d+\.\d+\.\d+$"); + + var manifest = Regex.Match( + File.ReadAllText(Path.Combine(RepoRoot(), "remex.agent", "app.manifest")), + @" plus .0"); + + var iss = File.ReadAllText(Path.Combine(RepoRoot(), "installer", "RemEx.iss")); + var fallback = Regex.Match(iss, @"#define AppVersion ""([^""]*)"""); + fallback.Success.Should().BeTrue("RemEx.iss must still define a fallback AppVersion"); + fallback.Groups[1].Value.Should().Be(desktop, "a bare `iscc RemEx.iss` names the installer with this"); + + File.ReadAllText(Path.Combine(RepoRoot(), "build-remex.ps1")).Should().Contain("Sync-PinnedVersionStrings", + "the build script is what keeps these two in step on every version change"); + } + /// Reads <Version> from Directory.Build.props, or null. private static string? DesktopVersion() { diff --git a/remex.desktop/Localization/Strings.es.resx b/remex.desktop/Localization/Strings.es.resx index 548d47dd..775407fa 100644 --- a/remex.desktop/Localization/Strings.es.resx +++ b/remex.desktop/Localization/Strings.es.resx @@ -1391,58 +1391,46 @@ En la página Rutinas puedes verlas, desactivarlas y consultar su historial.¿Qué hay de nuevo? - Rutinas + Archivos, rediseñado - Tu teléfono ya puede actuar solo cuando llegas a casa o te vas, tocas una etiqueta NFC o un sensor del PC pasa un límite. + Tu teléfono y tu PC aparecen ahora en un mismo árbol de carpetas en las dos apps. Abre las carpetas nivel a nivel; el árbol te sigue mientras navegas. - Rutinas en tu PC + Vista previa en vivo - Las rutinas también pueden ejecutarse en el propio PC, por ejemplo para suspenderlo si está inactivo. Una cuenta atrás te deja cancelar un apagado o un reinicio. + Elige un archivo para verlo al instante: fotos a calidad completa con zoom, y texto, código y registros en color. Activa En vivo para seguir un registro mientras crece. - Un nuevo diseño en el teléfono + Huellas SHA-256 - La aplicación del teléfono ahora tiene cinco pestañas. Su Inicio muestra este PC y los mismos sensores fijados que aquí, y una pulsación larga edita su cuadrícula de Sensores. + Una transferencia ahora indica Verificado cuando se comprobó su huella. Copia la huella de un archivo, compárala con una que pegues o compruébala con una copia en el otro dispositivo. - El mismo aspecto en las dos aplicaciones + Envía archivos entre dispositivos - El PC y el teléfono ahora usan las mismas palabras, fuentes, tarjetas y colores. Una instalación nueva empieza con los colores de RemEx. + Envía archivos del teléfono a una carpeta de tu PC o guarda archivos del PC en una carpeta del teléfono. En el PC también puedes arrastrar archivos de un dispositivo a otro. - Arreglos en la transferencia de archivos + Carpetas del teléfono que tú eliges - Descargar una carpeta, en cualquier sentido, ya no omite ni repite archivos. Archivos, en el teléfono, no muestra los elementos ocultos ni los del sistema salvo que lo pidas. El PC ahora también puede explorar las carpetas compartidas de tu teléfono. + Añade carpetas de tu teléfono para verlas y previsualizarlas en RemEx. Tu PC solo ve una si activas Compartir con el PC. - Una nueva pantalla de inicio + Una barra de herramientas más sencilla - Al abrir RemEx, la pantalla de inicio muestra cómo arranca y se conecta de verdad, en lugar de una animación fija. + Cada pantalla de archivos tiene ahora un botón principal y el resto en Más opciones; además, tocar un archivo de la lista abre su vista previa. - Alertas de sensores en el teléfono + Escanea para obtener la app del teléfono - Las alertas que creas en el PC ahora llegan al teléfono como notificaciones. Puedes añadirlas, cambiarlas o quitarlas desde cualquiera de los dos. - - - Registros y diagnósticos del PC en el teléfono - - - En Más hay una página nueva con lo que ha registrado tu PC y las mismas comprobaciones de estado que muestra el PC. Es de solo lectura. - - - Más formas de personalizar el teléfono - - - Elige un fondo, más formas de tarjeta y siete Nerd Fonts, y decide el ancho de la cuadrícula de Sensores. + En la página de inicio del PC, la tarjeta Obtener la aplicación de teléfono muestra ahora un código QR. Apunta la cámara del teléfono para abrir RemEx en Google Play. diff --git a/remex.desktop/Localization/Strings.fr.resx b/remex.desktop/Localization/Strings.fr.resx index 36d8ce22..0c2a8874 100644 --- a/remex.desktop/Localization/Strings.fr.resx +++ b/remex.desktop/Localization/Strings.fr.resx @@ -1387,58 +1387,46 @@ Sur la page Routines, vous pouvez les voir, les désactiver et consulter leur hi Quoi de neuf ? - Routines + Fichiers, repensé - Votre téléphone peut désormais agir seul quand vous rentrez ou partez, approchez un tag NFC, ou quand un capteur du PC dépasse un seuil. + Votre téléphone et votre PC apparaissent désormais dans une même arborescence, sur les deux applis. Ouvrez les dossiers niveau par niveau : l’arborescence vous suit pendant que vous naviguez. - Routines sur votre PC + Aperçu en direct - Certaines routines s'exécutent sur le PC lui-même, par exemple pour le mettre en veille s'il est inactif. Un compte à rebours vous laisse annuler un arrêt ou un redémarrage. + Choisissez un fichier pour le voir tout de suite : photos en pleine qualité avec zoom, texte, code et journaux en couleur. Activez En direct pour suivre un journal à mesure qu’il s’allonge. - Une nouvelle disposition sur le téléphone + Empreintes SHA-256 - L'application du téléphone a maintenant cinq onglets. Son Accueil affiche ce PC et les mêmes capteurs épinglés qu'ici, et un appui long modifie sa grille Capteurs. + Un transfert indique désormais Vérifié quand son empreinte a été contrôlée. Copiez l’empreinte d’un fichier, comparez-la avec une empreinte collée ou vérifiez-la avec une copie sur l’autre appareil. - Le même style sur les deux applications + Envoyez des fichiers entre appareils - Le PC et le téléphone utilisent maintenant les mêmes mots, polices, cartes et couleurs. Une nouvelle installation démarre avec les couleurs RemEx. + Envoyez des fichiers du téléphone vers un dossier du PC, ou enregistrez des fichiers du PC dans un dossier du téléphone. Sur le PC, vous pouvez aussi glisser des fichiers d’un appareil à l’autre. - Corrections du transfert de fichiers + Les dossiers du téléphone de votre choix - Télécharger un dossier, dans un sens comme dans l'autre, ne saute ni ne répète plus aucun fichier. Fichiers, sur le téléphone, masque les éléments cachés et système sauf si vous le demandez. Le PC peut aussi parcourir les dossiers partagés de votre téléphone. + Ajoutez des dossiers de votre téléphone pour les parcourir et les prévisualiser dans RemEx. Votre PC n’en voit un que si vous activez Partager avec le PC. - Un nouvel écran de démarrage + Une barre d’outils plus simple - À l'ouverture, l'écran de démarrage montre RemEx qui démarre et se connecte vraiment, au lieu d'une animation figée. + Chaque écran de fichiers a désormais un bouton principal, le reste se trouve sous Plus d’options, et toucher un fichier dans la liste ouvre son aperçu. - Alertes de capteurs sur le téléphone + Scannez pour obtenir l’appli mobile - Les alertes que vous créez sur le PC arrivent maintenant sur le téléphone sous forme de notifications. Vous pouvez les ajouter, les modifier ou les supprimer depuis l'un ou l'autre. - - - Journaux et diagnostics du PC sur le téléphone - - - Dans Plus, une nouvelle page montre ce que votre PC a consigné et les mêmes vérifications d'état que sur le PC. Elle est en lecture seule. - - - Plus de façons de personnaliser le téléphone - - - Choisissez un arrière-plan, d'autres formes de cartes et sept Nerd Fonts, ainsi que la largeur de la grille de Capteurs. + Sur la page d’accueil du PC, la carte Obtenir l’application téléphone affiche désormais un code QR. Visez-le avec l’appareil photo du téléphone pour ouvrir RemEx sur Google Play. diff --git a/remex.desktop/Localization/Strings.hi.resx b/remex.desktop/Localization/Strings.hi.resx index 05756dbe..1c17331d 100644 --- a/remex.desktop/Localization/Strings.hi.resx +++ b/remex.desktop/Localization/Strings.hi.resx @@ -1387,58 +1387,46 @@ Linux: ip addr या hostname -I नया क्या है? - रूटीन + फ़ाइलें, नए रूप में - अब आपका फ़ोन अपने आप काम कर सकता है, जब आप घर पहुँचें या निकलें, कोई NFC टैग टैप करें, या PC का कोई सेंसर सीमा पार करे। + अब दोनों ऐप में आपका फ़ोन और पीसी एक ही फ़ोल्डर ट्री में दिखते हैं। फ़ोल्डर एक-एक स्तर खोलें, और ब्राउज़ करते समय ट्री आपके साथ चलता है। - आपके PC पर रूटीन + लाइव पूर्वावलोकन - रूटीन अब PC पर भी चल सकते हैं, जैसे PC खाली हो तो उसे स्लीप में भेजना। शट डाउन या रीस्टार्ट से पहले उलटी गिनती होती है, ताकि आप उसे रोक सकें। + कोई फ़ाइल चुनें और उसे तुरंत देखें: ज़ूम के साथ पूरी गुणवत्ता वाली फ़ोटो, और रंगों में टेक्स्ट, कोड और लॉग। बढ़ते लॉग को देखने के लिए लाइव चालू करें। - फ़ोन का नया लेआउट + SHA-256 फ़िंगरप्रिंट - फ़ोन ऐप में अब पाँच टैब हैं। उसके होम पर यह PC और वही पिन किए गए सेंसर दिखते हैं जो यहाँ हैं, और देर तक दबाकर उसकी सेंसर ग्रिड बदली जा सकती है। + अब फ़िंगरप्रिंट जाँचे जाने पर ट्रांसफ़र सत्यापित दिखाता है। किसी फ़ाइल का फ़िंगरप्रिंट कॉपी करें, चिपकाए गए फ़िंगरप्रिंट से तुलना करें, या दूसरे डिवाइस पर मौजूद कॉपी से जाँचें। - दोनों ऐप्स का एक जैसा रूप + डिवाइसों के बीच फ़ाइलें भेजें - PC और फ़ोन अब एक जैसे शब्द, फ़ॉन्ट, कार्ड और रंग इस्तेमाल करते हैं। नया इंस्टॉल RemEx के रंगों से शुरू होता है। + फ़ोन की फ़ाइलें पीसी के किसी फ़ोल्डर में भेजें, या पीसी की फ़ाइलें फ़ोन के किसी फ़ोल्डर में सहेजें। पीसी पर आप फ़ाइलें एक डिवाइस से दूसरे पर खींच भी सकते हैं। - फ़ाइल ट्रांसफ़र में सुधार + आपके चुने हुए फ़ोन फ़ोल्डर - किसी भी तरफ़ पूरा फ़ोल्डर डाउनलोड करने पर अब फ़ाइलें छूटती या दोहराई नहीं जातीं। फ़ोन की फ़ाइलें स्क्रीन छिपे हुए और सिस्टम आइटम तब तक नहीं दिखाती, जब तक आप न कहें। अब PC आपके फ़ोन के साझा फ़ोल्डर भी ब्राउज़ कर सकता है। + फ़ोन के फ़ोल्डर जोड़ें ताकि RemEx में उन्हें ब्राउज़ कर सकें और उनका पूर्वावलोकन देख सकें। आपका पीसी उसे तभी देखता है जब आप पीसी से साझा करें चालू करते हैं। - नई स्प्लैश स्क्रीन + सरल टूलबार - RemEx खुलते समय स्प्लैश अब कोई तय एनीमेशन नहीं चलाती, बल्कि दिखाती है कि ऐप सच में कैसे शुरू होकर कनेक्ट होता है। + अब हर फ़ाइल स्क्रीन पर एक मुख्य बटन है, बाकी अधिक विकल्प में हैं, और सूची में किसी फ़ाइल पर टैप करने से उसका पूर्वावलोकन खुलता है। - फ़ोन पर सेंसर चेतावनियाँ + फ़ोन ऐप पाने के लिए स्कैन करें - PC पर बनाई गई चेतावनियाँ अब आपके फ़ोन पर नोटिफ़िकेशन के रूप में आती हैं। आप उन्हें दोनों में से किसी से भी जोड़, बदल या हटा सकते हैं। - - - फ़ोन पर PC के लॉग और जाँच - - - अधिक में एक नया पेज है, जो दिखाता है कि आपके PC ने क्या दर्ज किया और PC जैसी ही स्वास्थ्य जाँचें भी। यह केवल पढ़ने के लिए है। - - - फ़ोन को अपने ढंग से सजाने के और तरीके - - - पृष्ठभूमि, कार्ड के और आकार और सात Nerd Fonts चुनें, और तय करें कि सेंसर ग्रिड कितना चौड़ा हो। + पीसी के होम पेज पर, फ़ोन ऐप प्राप्त करें कार्ड अब एक QR कोड दिखाता है। Google Play पर RemEx खोलने के लिए फ़ोन का कैमरा उस पर रखें। diff --git a/remex.desktop/Localization/Strings.id.resx b/remex.desktop/Localization/Strings.id.resx index b94ac22d..c51595ce 100644 --- a/remex.desktop/Localization/Strings.id.resx +++ b/remex.desktop/Localization/Strings.id.resx @@ -1387,58 +1387,46 @@ Di halaman Rutinitas, Anda dapat melihatnya, mematikannya, dan memeriksa riwayat Apa yang baru? - Rutinitas + File, didesain ulang - Ponsel Anda kini bisa bertindak sendiri saat Anda tiba di rumah atau pergi, mengetuk tag NFC, atau saat sensor PC melewati batas. + Ponsel dan PC Anda kini ada dalam satu pohon folder di kedua aplikasi. Buka folder tingkat demi tingkat, dan pohonnya mengikuti saat Anda menjelajah. - Rutinitas di PC Anda + Pratinjau langsung - Rutinitas juga bisa berjalan di PC itu sendiri, misalnya menidurkan PC saat sedang tidak dipakai. Sebelum PC dimatikan atau dimulai ulang, ada hitung mundur agar Anda bisa membatalkannya. + Pilih file untuk langsung melihatnya: foto berkualitas penuh dengan zoom, serta teks, kode, dan log berwarna. Nyalakan Langsung untuk mengikuti log saat bertambah. - Tata letak baru di ponsel + Sidik jari SHA-256 - Aplikasi ponsel kini punya lima tab. Berandanya menampilkan PC ini dan sensor yang disematkan sama seperti di sini, dan tekan lama untuk mengedit kisi Sensor-nya. + Transfer kini menampilkan Terverifikasi saat sidik jarinya sudah diperiksa. Salin sidik jari file, bandingkan dengan yang Anda tempel, atau periksa dengan salinan di perangkat lain. - Satu tampilan di kedua aplikasi + Kirim file antarperangkat - PC dan ponsel kini memakai kata, font, kartu, dan warna yang sama. Instalasi baru dimulai dengan warna RemEx. + Kirim file ponsel ke folder di PC, atau simpan file PC ke folder di ponsel. Di PC, Anda juga bisa menyeret file dari satu perangkat ke perangkat lain. - Perbaikan transfer file + Folder ponsel pilihan Anda - Mengunduh folder, ke arah mana pun, tidak lagi melewatkan atau mengulang file. File di ponsel menyembunyikan item tersembunyi dan item sistem sampai Anda memintanya. PC kini juga bisa menjelajahi folder bersama di ponsel Anda. + Tambahkan folder dari ponsel untuk menjelajahi dan melihat pratinjaunya di RemEx. PC hanya melihatnya jika Anda menyalakan Bagikan ke PC. - Layar pembuka baru + Toolbar yang lebih sederhana - Saat RemEx dibuka, layar pembuka kini menunjukkan aplikasi benar-benar memulai dan terhubung, bukan memutar animasi tetap. + Setiap layar file kini punya satu tombol utama, sisanya ada di Opsi lainnya, dan mengetuk file di daftar kini membuka pratinjaunya. - Peringatan sensor di ponsel + Pindai untuk mendapatkan aplikasi ponsel - Peringatan yang Anda atur di PC kini sampai ke ponsel sebagai notifikasi. Anda bisa menambah, mengubah, atau menghapusnya dari salah satunya. - - - Log dan diagnostik PC di ponsel - - - Di Lainnya, ada halaman baru yang menampilkan apa yang dicatat PC Anda beserta pemeriksaan kondisi yang sama seperti di PC. Halaman ini hanya untuk dibaca. - - - Lebih banyak cara mempersonalisasi ponsel - - - Pilih latar belakang, lebih banyak bentuk kartu, dan tujuh Nerd Font, lalu atur lebar kisi Sensor. + Di halaman Beranda PC, kartu Dapatkan aplikasi ponsel kini menampilkan kode QR. Arahkan kamera ponsel ke kode itu untuk membuka RemEx di Google Play. diff --git a/remex.desktop/Localization/Strings.pl.resx b/remex.desktop/Localization/Strings.pl.resx index 2ceeafbb..34501e7f 100644 --- a/remex.desktop/Localization/Strings.pl.resx +++ b/remex.desktop/Localization/Strings.pl.resx @@ -1180,58 +1180,46 @@ Linux: ip addr lub hostname -I - Rutyny + Pliki w nowej odsłonie - Telefon może teraz działać sam, gdy wracasz do domu lub wychodzisz, zbliżasz go do tagu NFC albo czujnik komputera przekracza limit. + Telefon i komputer są teraz w jednym drzewie folderów w obu aplikacjach. Otwieraj foldery poziom po poziomie, a drzewo podąża za tobą. - Rutyny na komputerze + Podgląd na żywo - Rutyny mogą też działać na samym komputerze, na przykład usypiać go, gdy jest bezczynny. Przed wyłączeniem lub ponownym uruchomieniem odliczanie daje ci czas, żeby to przerwać. + Wybierz plik, aby od razu go zobaczyć: zdjęcia w pełnej jakości z powiększeniem oraz tekst, kod i logi w kolorze. Włącz Na żywo, aby śledzić rosnący log. - Nowy układ telefonu + Odciski SHA-256 - Aplikacja na telefonie ma teraz pięć kart. Jej Start pokazuje ten komputer i te same przypięte czujniki co tutaj, a długie naciśnięcie pozwala edytować jej siatkę Czujniki. + Przesyłanie pokazuje teraz Zweryfikowano, gdy sprawdzono jego odcisk. Skopiuj odcisk pliku, porównaj go z wklejonym albo sprawdź z kopią na drugim urządzeniu. - Jeden wygląd w obu aplikacjach + Przesyłaj pliki między urządzeniami - Komputer i telefon używają teraz tych samych słów, czcionek, kart i kolorów. Nowa instalacja startuje w kolorach RemEx. + Wysyłaj pliki z telefonu do folderu na komputerze albo zapisuj pliki z komputera w folderze na telefonie. Na komputerze możesz też przeciągać pliki między urządzeniami. - Poprawki przesyłania plików + Wybrane przez ciebie foldery telefonu - Pobieranie folderu, w obie strony, nie pomija już ani nie powtarza plików. Pliki na telefonie nie pokazują ukrytych i systemowych elementów, dopóki o to nie poprosisz. Komputer może teraz przeglądać też udostępnione foldery telefonu. + Dodaj foldery z telefonu, aby przeglądać je w RemEx. Komputer widzi folder tylko wtedy, gdy włączysz Udostępnij komputerowi. - Nowy ekran powitalny + Prostszy pasek narzędzi - Po otwarciu RemEx ekran powitalny pokazuje, jak aplikacja naprawdę się uruchamia i łączy, zamiast odtwarzać stałą animację. + Każdy ekran plików ma teraz jeden główny przycisk, a resztę w menu Więcej opcji. Dotknięcie pliku na liście otwiera jego podgląd. - Alerty czujników w telefonie + Zeskanuj, aby pobrać aplikację na telefon - Alerty ustawione na komputerze trafiają teraz do telefonu jako powiadomienia. Możesz je dodawać, zmieniać i usuwać na każdym z nich. - - - Logi i diagnostyka komputera w telefonie - - - W Więcej nowa strona pokazuje, co zapisał twój komputer, oraz te same kontrole stanu co na komputerze. Jest tylko do odczytu. - - - Więcej sposobów personalizacji telefonu - - - Wybierz tło, więcej kształtów kart i siedem czcionek Nerd Fonts oraz ustaw szerokość siatki Czujników. + Na stronie głównej komputera karta Pobierz aplikację na telefon pokazuje teraz kod QR. Skieruj na niego aparat telefonu, aby otworzyć RemEx w Google Play. diff --git a/remex.desktop/Localization/Strings.pt-BR.resx b/remex.desktop/Localization/Strings.pt-BR.resx index 47ae4d3b..04153888 100644 --- a/remex.desktop/Localization/Strings.pt-BR.resx +++ b/remex.desktop/Localization/Strings.pt-BR.resx @@ -1387,58 +1387,46 @@ Na página Rotinas você pode vê-las, desativá-las e conferir o histórico del O que há de novo? - Rotinas + Arquivos, redesenhado - Seu celular agora pode agir sozinho quando você chega em casa ou sai, toca uma tag NFC ou um sensor do PC passa de um limite. + Seu celular e seu PC agora ficam em uma única árvore de pastas nos dois apps. Abra as pastas nível por nível, e a árvore acompanha você enquanto navega. - Rotinas no seu PC + Visualização ao vivo - As rotinas também podem rodar no próprio PC, como suspendê-lo quando ele fica ocioso. Uma contagem regressiva dá tempo de cancelar um desligamento ou reinício. + Escolha um arquivo para vê-lo na hora: fotos em qualidade total com zoom, e texto, código e logs em cores. Ative Ao vivo para acompanhar um log enquanto ele cresce. - Um novo layout no celular + Impressões digitais SHA-256 - O aplicativo do celular agora tem cinco abas. O Início dele mostra este PC e os mesmos sensores fixados daqui, e um toque longo edita a grade de Sensores dele. + Uma transferência agora mostra Verificado quando a impressão digital foi conferida. Copie a impressão digital de um arquivo, compare com uma colada ou confira com uma cópia no outro aparelho. - Um só visual nos dois aplicativos + Envie arquivos entre aparelhos - O PC e o celular agora usam as mesmas palavras, fontes, cartões e cores. Uma instalação nova começa com as cores do RemEx. + Envie arquivos do celular para uma pasta no PC ou salve arquivos do PC em uma pasta no celular. No PC, você também pode arrastar arquivos de um aparelho para o outro. - Correções na transferência de arquivos + Pastas do celular que você escolhe - Baixar uma pasta, em qualquer direção, não pula nem repete mais arquivos. Arquivos, no celular, esconde itens ocultos e do sistema até você pedir. O PC agora também pode explorar as pastas compartilhadas do seu celular. + Adicione pastas do celular para navegar e visualizar no RemEx. O PC só vê uma pasta se você ativar Compartilhar com o PC. - Uma nova tela de inicialização + Uma barra de ferramentas mais simples - Ao abrir o RemEx, a tela de inicialização mostra o aplicativo iniciando e se conectando de verdade, em vez de uma animação fixa. + Cada tela de arquivos agora tem um botão principal, com o resto em Mais opções, e tocar em um arquivo da lista abre a visualização. - Alertas de sensores no celular + Escaneie para baixar o app do celular - Os alertas que você cria no PC agora chegam ao celular como notificações. Você pode adicioná-los, alterá-los ou removê-los em qualquer um dos dois. - - - Logs e diagnóstico do PC no celular - - - Em Mais, uma página nova mostra o que o seu PC registrou e as mesmas verificações de integridade que o PC mostra. Ela é somente leitura. - - - Mais formas de personalizar o celular - - - Escolha um plano de fundo, mais formas de cartão e sete Nerd Fonts, e defina a largura da grade de Sensores. + Na página Início do PC, o cartão Obter o aplicativo do telefone agora mostra um QR code. Aponte a câmera do celular para abrir o RemEx no Google Play. diff --git a/remex.desktop/Localization/Strings.resx b/remex.desktop/Localization/Strings.resx index 0dc418d8..e0710e50 100644 --- a/remex.desktop/Localization/Strings.resx +++ b/remex.desktop/Localization/Strings.resx @@ -1429,58 +1429,46 @@ You can see them, switch them off and check their history on the Routines page.< What's new? - Routines + Files, redesigned - Your phone can now act on its own when you get home or leave, tap an NFC tag, or a PC sensor passes a limit. + Your phone and your PC now sit in one folder tree on both apps. Open folders level by level, and the tree follows you as you browse. - Routines on your PC + Live preview - Routines can also run on the PC itself, like sleeping it when it's idle. A countdown gives you time to stop a shut down or restart. + Pick a file to see it straight away: photos at full quality with zoom, and text, code and logs in color. Turn on Live to follow a log as it grows. - A new phone layout + SHA-256 fingerprints - The phone app now has five tabs. Its Home shows this PC and the same pinned sensors as here, and a long press edits its Sensors grid. + A transfer now says Verified when its fingerprint was checked. Copy a file's fingerprint, compare it with one you paste, or check it against a copy on the other device. - One look on both apps + Send files between devices - The PC and the phone now use the same words, fonts, cards and colours. A fresh install starts on the RemEx colours. + Send phone files to a folder on your PC, or save PC files into a folder on your phone. On the PC you can also drag files from one device to the other. - File transfer fixes + Phone folders you choose - Folder downloads, either way, no longer skip or repeat files. The phone's Files hides hidden and system items until you ask. The PC can now browse your phone's shared folders too. + Add folders from your phone to browse and preview them in RemEx. Your PC only sees one if you turn on Share with PC. - A new startup splash + A simpler toolbar - When RemEx opens, the splash now shows it really starting up and connecting, instead of playing a fixed animation. + Each file screen now has one main button, with the rest under More options, and tapping a file in the list now opens its preview. - Sensor alerts on your phone + Scan to get the phone app - The alerts you set on the PC now reach your phone as notifications. You can add, change or remove them from either one. - - - PC logs and diagnostics on your phone - - - In More, a new page shows what your PC has logged and the same health checks the PC shows. It is read-only. - - - More ways to personalize the phone - - - Pick a background, more card shapes and seven Nerd Fonts, and choose how wide the Sensors grid is. + On the PC's Home page, the Get the phone app card now shows a QR code. Point your phone's camera at it to open RemEx on Google Play. diff --git a/remex.desktop/Localization/Strings.tr.resx b/remex.desktop/Localization/Strings.tr.resx index 3db92de6..6808515e 100644 --- a/remex.desktop/Localization/Strings.tr.resx +++ b/remex.desktop/Localization/Strings.tr.resx @@ -1387,58 +1387,46 @@ Rutinler sayfasında onları görebilir, kapatabilir ve geçmişlerini inceleyeb Neler yeni? - Rutinler + Dosyalar yeniden tasarlandı - Telefonunuz artık eve gelip gittiğinizde, bir NFC etiketine dokunduğunuzda veya bilgisayarın bir sensörü sınırı aştığında kendi başına harekete geçebilir. + Telefonunuz ve bilgisayarınız artık iki uygulamada da tek bir klasör ağacında. Klasörleri düzey düzey açın; ağaç siz gezindikçe sizi izler. - Bilgisayarınızda rutinler + Canlı önizleme - Rutinler bilgisayarın kendisinde de çalışabilir, örneğin boşta kalınca onu uyku moduna alabilir. Kapatma veya yeniden başlatmadan önce bir geri sayım, durdurmanız için size zaman tanır. + Bir dosyayı hemen görmek için seçin: yakınlaştırılabilir tam kaliteli fotoğraflar, renkli metin, kod ve günlükler. Büyüyen bir günlüğü izlemek için Canlı'yı açın. - Telefonda yeni düzen + SHA-256 parmak izleri - Telefon uygulamasında artık beş sekme var. Ana Sayfası bu bilgisayarı ve buradakiyle aynı sabitlenmiş sensörleri gösterir; Sensörler ızgarasını uzun basarak düzenlersiniz. + Bir aktarım, parmak izi denetlendiğinde artık Doğrulandı yazar. Bir dosyanın parmak izini kopyalayın, yapıştırdığınızla karşılaştırın ya da diğer cihazdaki kopyayla denetleyin. - İki uygulamada tek görünüm + Cihazlar arasında dosya gönderin - Bilgisayar ve telefon artık aynı sözcükleri, yazı tiplerini, kartları ve renkleri kullanıyor. Yeni bir kurulum RemEx renkleriyle başlar. + Telefon dosyalarını bilgisayarınızdaki bir klasöre gönderin ya da bilgisayar dosyalarını telefonunuzdaki bir klasöre kaydedin. Bilgisayarda dosyaları bir cihazdan diğerine sürükleyebilirsiniz de. - Dosya aktarımı düzeltmeleri + Seçtiğiniz telefon klasörleri - Bir klasörü iki yönde de indirmek artık dosya atlamıyor veya tekrarlamıyor. Telefondaki Dosyalar, siz isteyene kadar gizli ve sistem öğelerini göstermez. Bilgisayar artık telefonunuzun paylaşılan klasörlerine de göz atabilir. + RemEx'te göz atmak ve önizlemek için telefonunuzdan klasörler ekleyin. Bilgisayarınız bir klasörü yalnızca Bilgisayarla paylaş'ı açarsanız görür. - Yeni başlangıç ekranı + Daha sade bir araç çubuğu - RemEx açılırken başlangıç ekranı artık sabit bir animasyon oynatmak yerine uygulamanın gerçekten başlayıp bağlandığını gösteriyor. + Her dosya ekranında artık tek bir ana düğme var, gerisi Diğer seçenekler altında. Listedeki bir dosyaya dokunmak artık önizlemesini açar. - Telefonda sensör uyarıları + Telefon uygulamasını almak için tarayın - Bilgisayarda oluşturduğunuz uyarılar artık telefonunuza bildirim olarak geliyor. Bunları ikisinden de ekleyebilir, değiştirebilir veya kaldırabilirsiniz. - - - Telefonda bilgisayar günlükleri ve tanılama - - - Diğer'de yeni bir sayfa, bilgisayarınızın neleri kaydettiğini ve bilgisayarda görünen aynı durum denetimlerini gösterir. Yalnızca okunabilir. - - - Telefonu kişiselleştirmenin daha fazla yolu - - - Bir arka plan, daha fazla kart şekli ve yedi Nerd Font seçin; Sensörler ızgarasının genişliğini belirleyin. + Bilgisayarın Ana sayfasındaki Telefon uygulamasını al kartı artık bir QR kodu gösterir. RemEx'i Google Play'de açmak için telefonunuzun kamerasını ona tutun. diff --git a/remex.desktop/Localization/Strings.uk.resx b/remex.desktop/Localization/Strings.uk.resx index c22d8843..d4cb521f 100644 --- a/remex.desktop/Localization/Strings.uk.resx +++ b/remex.desktop/Localization/Strings.uk.resx @@ -1387,58 +1387,46 @@ Linux: ip addr або hostname -I Що нового? - Рутини + Файли в новому вигляді - Тепер телефон може діяти сам, коли ви приходите додому чи йдете, торкаєтеся NFC-мітки або датчик ПК перетинає поріг. + Телефон і ПК тепер в одному дереві папок в обох застосунках. Відкривайте папки рівень за рівнем, а дерево стежить за вами під час перегляду. - Рутини на вашому ПК + Живий перегляд - Рутини можуть працювати й на самому ПК, наприклад присипляти його, коли він простоює. Перед вимкненням чи перезапуском іде відлік, щоб ви встигли його скасувати. + Виберіть файл, щоб одразу його побачити: фото в повній якості з масштабуванням, а текст, код і журнали в кольорі. Увімкніть «Наживо», щоб стежити за журналом, поки він росте. - Новий макет на телефоні + Відбитки SHA-256 - Тепер у застосунку на телефоні п’ять вкладок. Його Головна показує цей ПК і ті самі закріплені датчики, що й тут, а довге натискання дає змогу змінити його сітку Датчиків. + Тепер передавання показує «Перевірено», коли його відбиток перевірено. Скопіюйте відбиток файлу, порівняйте його зі вставленим або перевірте з копією на іншому пристрої. - Однаковий вигляд в обох застосунках + Надсилайте файли між пристроями - ПК і телефон тепер використовують ті самі слова, шрифти, картки й кольори. Нове встановлення починається з кольорів RemEx. + Надсилайте файли з телефона в папку на ПК або зберігайте файли з ПК у папку на телефоні. На ПК також можна перетягувати файли з одного пристрою на інший. - Виправлення передавання файлів + Папки телефона на ваш вибір - Завантаження теки, в будь-який бік, більше не пропускає й не повторює файли. Файли на телефоні не показують прихованих і системних елементів, доки ви про це не попросите. ПК тепер також може переглядати спільні теки вашого телефону. + Додайте папки з телефона, щоб переглядати їх у RemEx. ПК бачить папку, лише якщо ви ввімкнете «Поділитися з ПК». - Нова заставка + Простіша панель інструментів - Під час відкриття заставка тепер показує, як RemEx справді запускається й підключається, а не відтворює фіксовану анімацію. + Кожен екран файлів тепер має одну головну кнопку, а решта в меню «Більше параметрів». Торкання файлу в списку відкриває його перегляд. - Оповіщення датчиків на телефоні + Скануйте, щоб отримати застосунок для телефона - Оповіщення, які ви створюєте на ПК, тепер приходять на телефон як сповіщення. Додавати, змінювати й вилучати їх можна з будь-якого з двох пристроїв. - - - Журнали та діагностика ПК на телефоні - - - У розділі «Більше» нова сторінка показує, що записав ваш ПК, і ті самі перевірки стану, що й на ПК. Вона лише для читання. - - - Більше способів персоналізувати телефон - - - Виберіть тло, більше форм карток і сім шрифтів Nerd Fonts та задайте ширину сітки Датчиків. + На головній сторінці ПК картка «Отримати застосунок для телефону» тепер показує QR-код. Наведіть на нього камеру телефона, щоб відкрити RemEx у Google Play. diff --git a/remex.desktop/ViewModels/AboutViewModel.cs b/remex.desktop/ViewModels/AboutViewModel.cs index 2da136c7..2b9a26a8 100644 --- a/remex.desktop/ViewModels/AboutViewModel.cs +++ b/remex.desktop/ViewModels/AboutViewModel.cs @@ -198,7 +198,7 @@ private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e) /// lines each, and never matched what the phone said. The highlights are now written for users, /// translated like every other string, and rebuilt on a language switch. /// - internal const int WhatsNewCount = 9; + internal const int WhatsNewCount = 7; private void LoadWhatsNew() {