diff --git a/Directory.Build.props b/Directory.Build.props index 947f7df18..795bc0084 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/README.md b/README.md index 76f709d72..b8f7d6352 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 6f8fee178..7ec08a440 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/REGRESSION-GUARDS.md b/docs/REGRESSION-GUARDS.md index 08ae58689..d489f39e1 100644 --- a/docs/REGRESSION-GUARDS.md +++ b/docs/REGRESSION-GUARDS.md @@ -756,7 +756,7 @@ bytes. The receiver then tears its sink down and finalizes a zero-byte transfer, - C# host → phone: `TransferSessionManager.WaitForFinalAckAsync` (`TransferSessionManager.cs:1729`), called at `TransferSessionManager.cs:1640` before the completion is sent. -- Kotlin phone → host, upload: `FileTransferEngine.runUpload` (`FileTransferEngine.kt:320`). +- Kotlin phone → host, upload: `FileTransferEngine.runUpload` (`FileTransferEngine.kt:317`). - Kotlin phone → host, **download-serving**: `FileHostHandler.beginHostSend`, the `while (session.committedOffset < sent)` loop before `sendComplete`. Added by `RemEx-xrb2v`; this sender had the defect for three beads after the other two were fixed. It was the only one never @@ -849,7 +849,7 @@ the constant and not the call site, a second test, directly at the call site, which the first test cannot. **The upload direction remains note-guarded only, and covering it is out of scope for RemEx-3uv7s.** -`FileTransferEngine.kt:261` builds its own `ackSignal` as `Channel(Channel.CONFLATED)` for +`FileTransferEngine.kt:336` builds its own `ackSignal` as `Channel(Channel.CONFLATED)` for `UploadSendLoop`, and since `RemEx-yi7id`, `UploadSendLoopTest` runs under `Dispatchers.Unconfined` too, so the same flip on that channel also stays green today. Treat that one channel's capacity as guarded by this note, not by a test, until it gets the same treatment. @@ -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/...`). @@ -1587,6 +1606,22 @@ grant for, so removing the root check reopens the whole-device folder after the whole-device browsing off. The RULES are pinned by `SharedPathPolicyTest`; the two CALL SITES are SAF-bound and have no unit test, so a removed call there stays green — check them by eye. +### Phone-only bookmarks never reach the PC (file-browser redesign, 2026-10-08) + +The phone's Files screen lets the person add folders to browse ON THE PHONE ("Add folder"). They live in their +own DataStore key, `phone_bookmark_uris` (`SettingsManager.kt:918`), and only `PhoneBookmarksRepository` reads it. +Everything that answers the PC reads `shared_folder_uris` alone: `AndroidFileTransferHost` collects +`sharedFolderUrisFlow` into `sharedFolderUris` (`AndroidFileTransferHost.kt:120`) and serves it as +`allowedRootIds()`, which `SharedPathPolicy` and `facade.resolve` check every PC request against. "Share with PC" +on a bookmark is the ONLY way in: it adds the URI to `shared_folder_uris`, the same list Settings → "Access from +your PC" edits, so there is still one consent surface. + +Fails silently if broken: merge the two lists anywhere the host reads (a "convenience" union in +`AndroidFileTransferHost`, say) and every folder the person only meant to browse on the phone is listed, read and, +with "Let your PC change files" on, writable from the PC, with nothing on either screen saying so. +`PhoneBookmarksRepositoryTest.nothingThatAnswersThePc_ReadsTheBookmarks` scans every file under `service/` for the +key and goes red (defect-injected 2026-10-08 with exactly that union). + ### `EvaluateDesktopAuth` — pre-auth for `/ws/desktop` `HostBootstrapper.EvaluateDesktopAuth` enforces: loopback → allow unconditionally; non-loopback → diff --git a/docs/RELEASE-NOTES-3.1.0.md b/docs/RELEASE-NOTES-3.1.0.md new file mode 100644 index 000000000..63c23b5ac --- /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/docs/specs/2026-10-08-file-browser-redesign.md b/docs/specs/2026-10-08-file-browser-redesign.md new file mode 100644 index 000000000..da3c3cb21 --- /dev/null +++ b/docs/specs/2026-10-08-file-browser-redesign.md @@ -0,0 +1,259 @@ +# 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. + +## 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. diff --git a/installer/RemEx.iss b/installer/RemEx.iss index f377ea581..f15765543 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.tests/FakeFileTransferService.cs b/remex.agent.tests/FakeFileTransferService.cs index 8474c6df1..c52bc4fcb 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 2f1a37ab7..a45ede61e 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 ddfb989f0..2f8fac8d9 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 bcde7eb78..59a07b70c 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 40ca93bed..4f482942a 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 68e9ff2fa..1dd1417c1 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 934e8e29d..aa307f8e6 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 4242ff06e..1f1af873e 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, @@ -213,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); } /// @@ -234,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) @@ -359,6 +376,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 +391,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 +407,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 +454,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 +471,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 +614,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.agent/app.manifest b/remex.agent/app.manifest index 38dd41e28..a328c947b 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 e40c85356..1a75ee22b 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/data/PhoneBookmarksRepository.kt b/remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt new file mode 100644 index 000000000..3aeceae52 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/data/PhoneBookmarksRepository.kt @@ -0,0 +1,123 @@ +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 com.clindsay94.remex.service.FileTransferEngine +import com.clindsay94.remex.service.QueuedTransfer +import com.clindsay94.remex.service.TransferState + +/** + * 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, which are never released here: the whole-device browse root, and any + * folder a transfer that isn't finished reads from or writes into (a paused one resumes through the same grant). + */ + 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 folder grants that unfinished transfers depend on: a document URI built under a tree + * (`content://…/tree//document/`) belongs to the tree before `/document/`. Failed rows count, since + * Resume reopens the same document; done and cancelled ones don't. + */ + fun treesInUse(queue: List): Set = + queue.asSequence() + .filter { it.state != TransferState.Done && it.state != TransferState.Cancelled } + .mapNotNull { t -> t.localUri.takeIf { "/tree/" in it && "/document/" in it }?.substringBefore("/document/") } + .toSet() + + /** 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 = + combine(settings.fullBrowseRootUriFlow, FileTransferEngine.queue) { fullBrowse, queue -> + setOfNotNull(fullBrowse) + treesInUse(queue) + } + 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 96ecd0b03..82443fde3 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/FileHostHandler.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileHostHandler.kt index 71e4d6018..26bc31e72 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 106ec0c80..31b655141 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/FileTransferEngine.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferEngine.kt index f9e8b83fe..e38ce80ab 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/FileTransferFrame.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferFrame.kt index e831f8632..8fdec880b 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/FileTransferNotificationManager.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/FileTransferNotificationManager.kt index 1a51a58bb..40643919d 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/HashFormat.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/HashFormat.kt new file mode 100644 index 000000000..5c6e674cd --- /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/main/java/com/clindsay94/remex/service/TransferQueue.kt b/remex.android/app/src/main/java/com/clindsay94/remex/service/TransferQueue.kt index adadb9891..f9c1355f7 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/FileManagerFileItem.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerFileItem.kt index 4a222c311..d016b0e23 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerFileItem.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerFileItem.kt @@ -145,11 +145,13 @@ fun FileManagerListItem( onDownload: () -> Unit, onOverflow: () -> Unit, modifier: Modifier = Modifier, + /** A thumbnail already decoded (a phone file's, from loadThumbnail); wins over [thumbnailBase64]. */ + thumbnail: ImageBitmap? = null, ) { if (!entry.isDirectory && FileManagerLogic.isThumbnailCandidate(entry.name)) { LaunchedEffect(entry.relativePath ?: entry.name) { onRequestThumbnail() } } - val thumb = rememberThumbnail(thumbnailBase64) + val thumb = thumbnail ?: rememberThumbnail(thumbnailBase64) val subtitle = subtitleFor(entry) // Selected rows get an animated container highlight; selection-mode entry swaps the @@ -249,11 +251,13 @@ fun FileManagerGridItem( onLongPress: () -> Unit, onOverflow: () -> Unit, modifier: Modifier = Modifier, + /** A thumbnail already decoded (a phone file's, from loadThumbnail); wins over [thumbnailBase64]. */ + thumbnail: ImageBitmap? = null, ) { if (!entry.isDirectory && FileManagerLogic.isThumbnailCandidate(entry.name)) { LaunchedEffect(entry.relativePath ?: entry.name) { onRequestThumbnail() } } - val thumb = rememberThumbnail(thumbnailBase64) + val thumb = thumbnail ?: rememberThumbnail(thumbnailBase64) Column( modifier = modifier 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 3c6c0bed5..650030ef6 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/components/FileManagerQuickAccess.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQuickAccess.kt deleted file mode 100644 index 4c0c087ac..000000000 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerQuickAccess.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.clindsay94.remex.ui.components - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.AssistChip -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.clindsay94.remex.R -import com.clindsay94.remex.ui.screens.FileManagerLogic -import com.clindsay94.remex.ui.screens.RemoteSharedRoot -import com.clindsay94.remex.ui.screens.RemoteVolume - -/** - * Quick-access strip (plan WP7): curated shared roots as selectable chips, plus a "This PC" volumes - * row when a full-browse grant is held. Tapping a chip switches the active browsing root. - */ -@Composable -fun FileManagerQuickAccess( - roots: List, - volumes: List, - selectedRootId: String?, - canBrowseDevice: Boolean, - /** - * True while a `file_volumes_request` is awaiting the PC's consent answer (RemEx-c7v4n). Disables - * re-tap and swaps the leading icon for a spinner so the wait is visible on the control itself, - * rather than only in the status line above the list. Deliberately no countdown here — that is a - * separate, not-yet-approved option. - */ - browseDevicePending: Boolean = false, - onSelectRoot: (String) -> Unit, - onSelectVolume: (RemoteVolume) -> Unit, - onBrowseDevice: () -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (roots.isNotEmpty()) { - Text( - text = stringResource(R.string.file_manager_quick_access), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - roots.forEach { root -> - // Selecting a read-only root silently disables upload, new-folder, paste, - // rename and delete. Mark it on the chip so the reason is available BEFORE - // the buttons go grey, rather than only after (RemEx-dc57). - // - // The read-only fact rides on the ICON's contentDescription, deliberately NOT - // on the chip's stateDescription. FilterChip is a selectable component, so - // Compose already publishes a selected/not-selected state for it, and - // stateDescription REPLACES that rather than adding to it — marking these - // chips that way would stop a screen-reader user hearing which folder is the - // active one. Describing the icon keeps both facts. The writable folder icon - // stays null-described because there it really is decorative: the chip's own - // label already names the folder. - val readOnly = !root.isWritable - val readOnlyLabel = stringResource(R.string.file_transfer_read_only_root) - RemexFilterChip( - selected = root.rootId == selectedRootId, - onClick = { onSelectRoot(root.rootId) }, - label = { Text(root.displayName) }, - leadingIcon = { - Icon( - if (readOnly) Icons.Default.Lock else Icons.Default.Folder, - contentDescription = if (readOnly) readOnlyLabel else null, - modifier = Modifier.padding(1.dp), - ) - }, - ) - } - } - } - - if (volumes.isEmpty() && canBrowseDevice) { - // Full-device browse is opt-in and consent-gated on the PC; only request on an explicit tap. - // While pending, disable re-tap and swap the icon for a spinner so the wait shows on the - // control the user just pressed, not only in the status line (review, RemEx-c7v4n round 2). - AssistChip( - onClick = onBrowseDevice, - enabled = FileManagerLogic.browseDeviceEnabled(canBrowseDevice, browseDevicePending), - label = { Text(stringResource(R.string.file_manager_browse_device)) }, - leadingIcon = { - if (browseDevicePending) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.Default.Computer, contentDescription = null) - } - }, - ) - } - - if (volumes.isNotEmpty()) { - Text( - text = stringResource(R.string.file_manager_volumes), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - volumes.forEach { volume -> - val freeLabel = FileManagerLogic.formatBytes(volume.freeBytes) - RemexFilterChip( - selected = volume.id == selectedRootId, - onClick = { onSelectVolume(volume) }, - label = { - Text( - text = stringResource( - R.string.file_manager_volume_chip_label, - volume.label, - freeLabel, - ) - ) - }, - leadingIcon = { Icon(Icons.Default.Storage, contentDescription = null) }, - ) - } - } - } - } -} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerToolbar.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerToolbar.kt index b01bc3d86..5bb31ce3a 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerToolbar.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/components/FileManagerToolbar.kt @@ -42,6 +42,10 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -81,6 +85,8 @@ fun FileManagerToolbar( modifier: Modifier = Modifier, showHidden: Boolean = false, onShowHiddenChange: (Boolean) -> Unit = {}, + /** The one primary action when the folder can't be written (the phone side's "Add folder"). */ + primaryAction: ToolbarAction? = null, ) { var searchExpanded by remember { mutableStateOf(false) } var sortMenuOpen by remember { mutableStateOf(false) } @@ -141,11 +147,15 @@ fun FileManagerToolbar( onUploadFolder = onUploadFolder, showHidden = showHidden, onShowHiddenChange = onShowHiddenChange, + primaryAction = primaryAction, ) } } } +/** A toolbar button: its icon, its label (tooltip and screen reader), and what it does. */ +class ToolbarAction(val icon: ImageVector, val label: String, val onClick: () -> Unit) + @Composable private fun ToolbarIconRow( onExpandSearch: () -> Unit, @@ -161,7 +171,9 @@ private fun ToolbarIconRow( onUploadFolder: () -> Unit, showHidden: Boolean, onShowHiddenChange: (Boolean) -> Unit, + primaryAction: ToolbarAction?, ) { + var moreOpen by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -217,21 +229,42 @@ private fun ToolbarIconRow( } } - Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.End) { + // One primary action (file browser redesign, 2026-10-08): Upload, with New folder and Upload folder + // one tap away under More, instead of three equal buttons. + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { if (canWrite) { - RemexTooltip(stringResource(R.string.file_manager_new_folder)) { - IconButton(onClick = onNewFolder, shapes = rememberRemexIconButtonShapes()) { - Icon(Icons.Default.CreateNewFolder, contentDescription = stringResource(R.string.file_manager_new_folder)) - } - } RemexTooltip(stringResource(R.string.file_transfer_upload)) { - IconButton(onClick = onUpload, shapes = rememberRemexIconButtonShapes()) { + FilledTonalIconButton(onClick = onUpload, shapes = rememberRemexIconButtonShapes()) { Icon(Icons.Default.Upload, contentDescription = stringResource(R.string.file_transfer_upload)) } } - RemexTooltip(stringResource(R.string.file_manager_upload_folder)) { - IconButton(onClick = onUploadFolder, shapes = rememberRemexIconButtonShapes()) { - Icon(Icons.Default.DriveFolderUpload, contentDescription = stringResource(R.string.file_manager_upload_folder)) + Box { + RemexTooltip(stringResource(R.string.cd_more_options)) { + IconButton(onClick = { moreOpen = true }, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.cd_more_options)) + } + } + DropdownMenu(expanded = moreOpen, onDismissRequest = { moreOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.file_manager_new_folder)) }, + leadingIcon = { Icon(Icons.Default.CreateNewFolder, contentDescription = null) }, + onClick = { moreOpen = false; onNewFolder() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.file_manager_upload_folder)) }, + leadingIcon = { Icon(Icons.Default.DriveFolderUpload, contentDescription = null) }, + onClick = { moreOpen = false; onUploadFolder() }, + ) + } + } + } else if (primaryAction != null) { + RemexTooltip(primaryAction.label) { + FilledTonalIconButton(onClick = primaryAction.onClick, shapes = rememberRemexIconButtonShapes()) { + Icon(primaryAction.icon, contentDescription = primaryAction.label) } } } @@ -282,6 +315,8 @@ fun FileManagerSelectionBar( onMove: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, + /** "Send to PC" or "Save to phone" for the selection (file browser redesign); null hides it. */ + acrossAction: ToolbarAction? = null, ) { Surface(color = MaterialTheme.colorScheme.secondaryContainer, modifier = modifier.fillMaxWidth()) { Row( @@ -305,6 +340,13 @@ fun FileManagerSelectionBar( Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.file_transfer_select_all)) } } + if (acrossAction != null) { + RemexTooltip(acrossAction.label) { + IconButton(onClick = acrossAction.onClick, enabled = selectedCount > 0, shapes = rememberRemexIconButtonShapes()) { + Icon(acrossAction.icon, contentDescription = acrossAction.label) + } + } + } if (canWrite) { RemexTooltip(stringResource(R.string.file_manager_copy)) { IconButton(onClick = onCopy, enabled = selectedCount > 0, shapes = rememberRemexIconButtonShapes()) { diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/CrossDevicePicker.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/CrossDevicePicker.kt new file mode 100644 index 000000000..d4688558b --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/CrossDevicePicker.kt @@ -0,0 +1,186 @@ +package com.clindsay94.remex.ui.files + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.clindsay94.remex.R +import com.clindsay94.remex.ui.screens.FileManagerLogic +import com.clindsay94.remex.ui.screens.RemexLoadingIndicator +import com.clindsay94.remex.ui.screens.RemoteFileEntry +import com.clindsay94.remex.ui.theme.rememberRemexButtonShapes +import com.clindsay94.remex.ui.theme.rememberRemexIconButtonShapes +import kotlinx.coroutines.CancellationException + +/** What a [CrossDevicePicker] is picking. */ +enum class PickMode { + /** A folder to send or save into: only writable folders, and a "here" button. */ + Folder, + + /** A file to check against: tapping a file picks it. */ + File, +} + +/** + * Browses the OTHER device's folders to pick a destination or a file (file browser redesign, 2026-10-08). It is + * the same tree as the side pane, one level at a time, starting in the last folder looked at over there. + * + * @param list the items of one folder on [side]; throws when it can't be listed. + * @param onPicked the chosen root id, folder path, and (for [PickMode.File]) the chosen file. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CrossDevicePicker( + title: String, + confirmLabel: String, + side: FileSide, + mode: PickMode, + roots: List, + start: Pair?, + list: suspend (FileSide, String, String) -> List, + onPicked: (rootId: String, folder: String, file: RemoteFileEntry?) -> Unit, + onDismiss: () -> Unit, +) { + val usable = if (mode == PickMode.Folder) roots.filter { it.isWritable } else roots + var rootId by remember { mutableStateOf(start?.first?.takeIf { s -> usable.any { it.rootId == s } }) } + var path by remember { mutableStateOf(if (rootId != null) start?.second.orEmpty() else "") } + var entries by remember { mutableStateOf?>(null) } + var failed by remember { mutableStateOf(false) } + + LaunchedEffect(rootId, path) { + val root = rootId ?: return@LaunchedEffect + entries = null + failed = false + entries = try { + FileManagerLogic.sortEntries( + list(side, root, path).filter { mode == PickMode.File || it.isDirectory }, + com.clindsay94.remex.ui.screens.SortOption(), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + failed = true + emptyList() + } + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp)) { + if (rootId != null) { + IconButton( + onClick = { + if (path.isEmpty()) rootId = null + else path = path.substringBeforeLast('/', "") + }, + shapes = rememberRemexIconButtonShapes(), + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.files_picker_up)) + } + } + Column(Modifier.weight(1f).padding(horizontal = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleMediumEmphasized) + val where = rootId?.let { id -> + val label = roots.firstOrNull { it.rootId == id }?.label.orEmpty() + if (path.isEmpty()) label else "$label/$path" + } ?: stringResource(if (side == FileSide.Pc) R.string.files_pc else R.string.files_this_phone) + Text(where, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + HorizontalDivider() + Box(Modifier.fillMaxWidth().height(360.dp)) { + val root = rootId + when { + root == null && usable.isEmpty() -> PickerNote(stringResource(R.string.files_picker_no_folders)) + root == null -> LazyColumn { + items(usable, key = { it.rootId }) { r -> + PickerRow(if (r.isVolume) Icons.Default.Storage else Icons.Default.Folder, r.label) { rootId = r.rootId; path = "" } + } + } + entries == null -> Box(Modifier.fillMaxWidth().height(360.dp), contentAlignment = Alignment.Center) { + RemexLoadingIndicator(contained = true) + } + failed -> PickerNote(stringResource(R.string.file_transfer_browse_error)) + entries.orEmpty().isEmpty() -> PickerNote(stringResource(R.string.files_picker_empty)) + else -> LazyColumn { + items(entries.orEmpty(), key = { it.id ?: it.name }) { e -> + PickerRow(if (e.isDirectory) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile, e.name) { + if (e.isDirectory) path = if (path.isEmpty()) e.name else "$path/${e.name}" + else onPicked(root, path, e) + } + } + } + } + } + if (mode == PickMode.Folder) { + Button( + onClick = { rootId?.let { onPicked(it, path, null) } }, + enabled = rootId != null, + shapes = rememberRemexButtonShapes(), + contentPadding = ButtonDefaults.ContentPadding, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(confirmLabel) + } + } + } + } +} + +@Composable +private fun PickerRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onClick) + .heightIn(min = 48.dp) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun PickerNote(text: String) { + Box(Modifier.fillMaxWidth().height(360.dp).padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} 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 000000000..b57cc0782 --- /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/FilePreviewModel.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FilePreviewModel.kt new file mode 100644 index 000000000..d2792b50b --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FilePreviewModel.kt @@ -0,0 +1,301 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.service.FileTransferLimits +import com.clindsay94.remex.service.HashFormat +import com.clindsay94.remex.ui.files.preview.ImagePreviewLoader +import com.clindsay94.remex.ui.files.preview.PreviewClassifier +import com.clindsay94.remex.ui.files.preview.PreviewKind +import com.clindsay94.remex.ui.files.preview.PreviewTooLargeException +import com.clindsay94.remex.ui.files.preview.RangeReader +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.TextPreviewLoader +import com.clindsay94.remex.ui.files.preview.TextTail +import com.clindsay94.remex.ui.files.preview.TailUpdate +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The file the preview is showing, on either device. */ +data class PreviewTarget( + val side: FileSide, + val rootId: String, + val path: String, + val name: String, + val isDirectory: Boolean, + val sizeBytes: Long, + val modifiedMs: Long, +) + +/** One line of a text preview. [number] is null in a live tail, which starts somewhere in the middle. */ +data class PreviewLine(val number: Int?, val text: String, val spans: List) + +enum class PreviewState { + Empty, Loading, Image, Text, Binary, TooLarge, Details, Failed, + + /** The device holding the file is too old to send previews: its details, and a word about updating. */ + NeedsUpdate, +} + +enum class HashCompareOutcome { None, Match, Mismatch, NotAHash } + +enum class CounterpartOutcome { None, Identical, Different, Failed } + +/** Everything the preview pane draws. */ +data class PreviewUi( + val target: PreviewTarget? = null, + val state: PreviewState = PreviewState.Empty, + val image: I? = null, + val progress: Float = 0f, + val lines: List = emptyList(), + val pendingLine: String = "", + val truncated: Boolean = false, + val live: Boolean = false, + val fileSize: Long = -1, + val hashHex: String? = null, + val hashing: Boolean = false, + val hashFailed: Boolean = false, + val compare: HashCompareOutcome = HashCompareOutcome.None, + val counterpart: CounterpartOutcome = CounterpartOutcome.None, + val verifying: Boolean = false, + /** This file's device can work out a fingerprint. */ + val canHash: Boolean = true, + /** Both devices can, so it can be checked against a copy on the other one. */ + val canVerifyAcross: Boolean = true, +) { + val canTail: Boolean get() = state == PreviewState.Text +} + +/** Where the preview's bytes and fingerprints come from: the phone's own files or the PC's. */ +interface PreviewSources { + fun reader(target: PreviewTarget): RangeReader + + /** The file's SHA-256, Base64 as on the wire. Throws when it can't be computed or the PC refuses. */ + suspend fun hashBase64(target: PreviewTarget): String + + /** False for a device too old to answer range reads (spec §2.4): nothing is asked of it. */ + fun canRead(side: FileSide): Boolean = true + + /** False for a device too old to answer `file_hash_request`. */ + fun canHash(side: FileSide): Boolean = true + + /** Decodes a whole image, scaled to fit the screen; null when it isn't one this phone can draw. */ + suspend fun decodeImage(bytes: ByteArray): I? +} + +/** + * The preview pane's state (file browser redesign, 2026-10-08), the phone's twin of the PC's + * `FilePreviewViewModel`: it follows the selection after a short pause, shows images whole and text with + * colour, follows a log live, and computes, compares and cross-checks SHA-256 fingerprints. Android-free apart + * from the image type [I], so every state is unit-tested. + */ +class FilePreviewModel( + private val scope: CoroutineScope, + private val sources: PreviewSources, + private val debounceMs: Long = 150, + private val pollMs: Long = FileTransferLimits.PREVIEW_TAIL_POLL_MS, + private val maxTailLines: Int = MAX_TAIL_LINES, +) { + private val _ui = MutableStateFlow(PreviewUi()) + val ui: StateFlow> = _ui.asStateFlow() + + private var loadJob: Job? = null + private var tailJob: Job? = null + private var hashJob: Job? = null + private var verifyJob: Job? = null + private var language = SyntaxLanguage.Plain + + /** Shows [target] after a short pause, so flicking through a list doesn't fetch every file on the way. */ + fun show(target: PreviewTarget?) { + if (target == _ui.value.target && _ui.value.state != PreviewState.Failed) return + cancelAll() + if (target == null) { + _ui.value = PreviewUi() + return + } + val other = if (target.side == FileSide.Phone) FileSide.Pc else FileSide.Phone + val canHash = sources.canHash(target.side) + _ui.value = PreviewUi( + target = target, + state = PreviewState.Loading, + canHash = canHash, + canVerifyAcross = canHash && sources.canHash(other), + ) + loadJob = scope.launch { + delay(debounceMs) + load(target) + } + } + + /** Loads [target] now. Internal so tests skip the pause. */ + internal suspend fun load(target: PreviewTarget) { + language = SyntaxTokenizer.languageFor(target.name) + val kind = PreviewClassifier.classify(target.name, target.isDirectory) + try { + when { + kind == PreviewKind.None || kind == PreviewKind.ImageThumbnailOnly -> set(target) { it.copy(state = PreviewState.Details) } + !sources.canRead(target.side) -> set(target) { it.copy(state = PreviewState.NeedsUpdate) } + else -> loadReadable(target, kind) + } + } catch (e: CancellationException) { + throw e + } catch (e: PreviewTooLargeException) { + set(target) { it.copy(state = PreviewState.TooLarge, fileSize = e.fileSize) } + } catch (_: Exception) { + set(target) { it.copy(state = PreviewState.Failed) } + } + } + + private suspend fun loadReadable(target: PreviewTarget, kind: PreviewKind) { + when (kind) { + PreviewKind.Image -> loadImage(target) + PreviewKind.Text, PreviewKind.Sniff -> loadText(target, sniffed = kind == PreviewKind.Sniff) + PreviewKind.None, PreviewKind.ImageThumbnailOnly -> Unit + } + } + + private suspend fun loadImage(target: PreviewTarget) { + if (target.sizeBytes > FileTransferLimits.PREVIEW_IMAGE_MAX_BYTES) { + throw PreviewTooLargeException(target.sizeBytes, FileTransferLimits.PREVIEW_IMAGE_MAX_BYTES) + } + val bytes = ImagePreviewLoader.load(sources.reader(target), { p -> set(target) { it.copy(progress = p) } }) + val image = sources.decodeImage(bytes) + set(target) { + if (image == null) it.copy(state = PreviewState.Details, fileSize = bytes.size.toLong()) + else it.copy(state = PreviewState.Image, image = image, fileSize = bytes.size.toLong(), progress = 1f) + } + } + + private suspend fun loadText(target: PreviewTarget, sniffed: Boolean) { + val head = TextPreviewLoader.loadHead(sources.reader(target)) + if (head.isBinary) { + // A file whose name promised text but holds bytes says so; an unknown one just shows its details. + set(target) { it.copy(state = if (sniffed) PreviewState.Details else PreviewState.Binary, fileSize = head.fileSize) } + return + } + var raw = head.text.split('\n') + if (head.text.endsWith('\n')) raw = raw.dropLast(1) + val lines = raw.mapIndexed { i, line -> line.trimEnd('\r').let { PreviewLine(i + 1, it, SyntaxTokenizer.tokenize(it, language)) } } + set(target) { it.copy(state = PreviewState.Text, lines = lines, truncated = head.truncated, fileSize = head.fileSize) } + } + + /** Follows the file live (the last 256 KiB, then whatever is added every two seconds), or stops following. */ + fun setLive(on: Boolean) { + val current = _ui.value + if (on == current.live || (on && !current.canTail)) return + tailJob?.cancel() + tailJob = null + val target = current.target ?: return + _ui.update { it.copy(live = on) } + if (!on) return + tailJob = scope.launch { + val tail = TextTail(sources.reader(target)) + try { + apply(target, tail.start()) + while (true) { + delay(pollMs) + apply(target, tail.poll()) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The file went away or the connection dropped: stop following and keep what is shown. + set(target) { it.copy(live = false) } + } + } + } + + internal fun apply(target: PreviewTarget, update: TailUpdate) = set(target) { ui -> + val added = update.lines.map { PreviewLine(null, it, SyntaxTokenizer.tokenize(it, language)) } + var lines = if (update.reset) added else ui.lines + added + if (lines.size > maxTailLines) lines = lines.subList(lines.size - maxTailLines, lines.size) + ui.copy(lines = lines, pendingLine = update.pendingLine, truncated = false, fileSize = update.fileSize) + } + + /** Computes this file's SHA-256 (on the phone for phone files, on the PC for PC files). */ + fun computeHash() { + val target = _ui.value.target ?: return + if (target.isDirectory || _ui.value.hashing || !_ui.value.canHash) return + hashJob?.cancel() + set(target) { it.copy(hashing = true, hashFailed = false) } + hashJob = scope.launch { + val hex = try { + HashFormat.toHex(sources.hashBase64(target)) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + set(target) { it.copy(hashing = false, hashHex = hex, hashFailed = hex == null) } + } + } + + /** Compares the computed fingerprint with one the person pasted, in hex or Base64. */ + fun compare(pasted: String) { + val hex = _ui.value.hashHex + val outcome = when { + pasted.isBlank() -> HashCompareOutcome.None + HashFormat.normalize(pasted) == null -> HashCompareOutcome.NotAHash + hex == null -> HashCompareOutcome.None + HashFormat.matches(hex, pasted) -> HashCompareOutcome.Match + else -> HashCompareOutcome.Mismatch + } + _ui.update { it.copy(compare = outcome) } + } + + /** Checks this file against [other] on the other device: identical or different, by SHA-256. */ + fun verifyAgainst(other: PreviewTarget) { + val target = _ui.value.target ?: return + if (target.isDirectory || other.isDirectory || _ui.value.verifying || !_ui.value.canVerifyAcross) return + set(target) { it.copy(verifying = true, counterpart = CounterpartOutcome.None) } + verifyJob?.cancel() + // Tracked, so moving to another file stops a 30-minute hash on both devices instead of letting it run on. + verifyJob = scope.launch { + val outcome = try { + val mine = _ui.value.hashHex ?: HashFormat.toHex(sources.hashBase64(target)) + val theirs = HashFormat.toHex(sources.hashBase64(other)) + set(target) { it.copy(hashHex = mine) } + if (mine != null && HashFormat.matches(mine, theirs)) CounterpartOutcome.Identical else CounterpartOutcome.Different + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + CounterpartOutcome.Failed + } + set(target) { it.copy(verifying = false, counterpart = outcome) } + } + } + + /** Stops everything (the screen was left). */ + fun close() { + cancelAll() + _ui.value = PreviewUi() + } + + private fun cancelAll() { + loadJob?.cancel() + tailJob?.cancel() + hashJob?.cancel() + verifyJob?.cancel() + loadJob = null + tailJob = null + hashJob = null + verifyJob = null + } + + /** Applies [change] only while [target] is still the one shown, so a slow answer never lands on the next file. */ + private inline fun set(target: PreviewTarget, change: (PreviewUi) -> PreviewUi) { + _ui.update { if (it.target == target) change(it) else it } + } + + companion object { + /** A live tail keeps at most this many lines; older ones scroll off the top. */ + const val MAX_TAIL_LINES = 5000 + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FilePreviewPane.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FilePreviewPane.kt new file mode 100644 index 000000000..b137a2a33 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FilePreviewPane.kt @@ -0,0 +1,436 @@ +package com.clindsay94.remex.ui.files + +import android.text.format.DateUtils +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.clindsay94.remex.R +import com.clindsay94.remex.ui.files.preview.SyntaxKind +import com.clindsay94.remex.ui.screens.FileManagerLogic +import com.clindsay94.remex.ui.screens.RemexLoadingIndicator +import com.clindsay94.remex.ui.theme.rememberRemexButtonShapes +import com.clindsay94.remex.ui.theme.rememberRemexIconButtonShapes + +/** What the preview pane can do besides showing the file; null hides the button. */ +class PreviewActions( + val onBack: (() -> Unit)?, + val onLive: (Boolean) -> Unit, + val onComputeHash: () -> Unit, + val onCompare: (String) -> Unit, + val onVerifyAgainst: () -> Unit, + val onCopyHash: (String) -> Unit, + val onOpenWith: (() -> Unit)?, + val onSendAcross: (() -> Unit)?, + val onRetry: () -> Unit, +) + +/** + * The preview pane (file browser redesign, 2026-10-08): the selected file as a zoomable image or coloured text + * (with a live tail for logs), or its details, plus the Integrity card for its SHA-256 fingerprint. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun FilePreviewPane(ui: PreviewUi, actions: PreviewActions, modifier: Modifier = Modifier) { + val target = ui.target + Column(modifier = modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (actions.onBack != null) { + IconButton(onClick = actions.onBack, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.files_preview_back)) + } + } + Text( + text = target?.name ?: stringResource(R.string.files_preview_title), + style = MaterialTheme.typography.titleMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + ) + if (ui.canTail) { + FilterChip( + selected = ui.live, + onClick = { actions.onLive(!ui.live) }, + label = { Text(stringResource(R.string.files_preview_live)) }, + leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(18.dp)) }, + modifier = Modifier.padding(end = 8.dp), + ) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (ui.state) { + PreviewState.Empty -> Message(Icons.AutoMirrored.Filled.InsertDriveFile, stringResource(R.string.files_preview_empty)) + PreviewState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RemexLoadingIndicator(contained = true) + } + PreviewState.Image -> ui.image?.let { ZoomableImage(it, target?.name.orEmpty()) } + PreviewState.Text -> TextBody(ui) + PreviewState.Binary -> Message(Icons.AutoMirrored.Filled.InsertDriveFile, stringResource(R.string.files_preview_binary)) + PreviewState.TooLarge -> Message( + Icons.AutoMirrored.Filled.InsertDriveFile, + stringResource(R.string.files_preview_too_large, FileManagerLogic.formatBytes(ui.fileSize)), + ) + PreviewState.NeedsUpdate -> Message( + Icons.AutoMirrored.Filled.InsertDriveFile, + stringResource(R.string.files_preview_needs_update), + ) + PreviewState.Details -> Message( + if (target?.isDirectory == true) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile, + target?.name.orEmpty(), + ) + PreviewState.Failed -> Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Message(Icons.Default.ErrorOutline, stringResource(R.string.files_preview_failed), fill = false) + OutlinedButton(onClick = actions.onRetry, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Text(stringResource(R.string.files_preview_retry)) + } + } + } + } + if (target != null) DetailsAndIntegrity(ui, target, actions) + } +} + +@Composable +private fun Message(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, fill: Boolean = true) { + Column( + modifier = (if (fill) Modifier.fillMaxSize() else Modifier).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** Pinch to zoom (around the fingers), drag to move, double-tap to fit again. */ +@Composable +private fun ZoomableImage(image: ImageBitmap, description: String) { + var scale by remember(image) { mutableFloatStateOf(1f) } + var offset by remember(image) { mutableStateOf(Offset.Zero) } + val state = rememberTransformableState { centroid, zoom, pan, _ -> + val next = (scale * zoom).coerceIn(1f, 8f) + // Scaled from the top-left corner (see transformOrigin below), so keep the point under the fingers still. + offset = if (next == 1f) Offset.Zero else (offset - centroid) * (next / scale) + centroid + pan + scale = next + } + Image( + bitmap = image, + contentDescription = description, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .pointerInput(image) { detectTapGestures(onDoubleTap = { scale = 1f; offset = Offset.Zero }) } + .transformable(state) + .graphicsLayer { + transformOrigin = TransformOrigin(0f, 0f) + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + }, + ) +} + +@Composable +private fun TextBody(ui: PreviewUi) { + val listState = rememberLazyListState() + val colors = syntaxColors() + // A live tail stays at its newest line, like `tail -f`. + LaunchedEffect(ui.live, ui.lines.size, ui.pendingLine) { + if (ui.live && ui.lines.isNotEmpty()) listState.scrollToItem(ui.lines.size) + } + val numberWidth = (ui.lines.lastOrNull()?.number ?: 0).toString().length + SelectionContainer { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp)) { + items(ui.lines.size) { i -> + val line = ui.lines[i] + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (line.number != null) { + Text( + text = line.number.toString().padStart(numberWidth), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.outline, + ) + } + Text( + text = colored(line, colors), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + if (ui.pendingLine.isNotEmpty()) { + item { + Text( + ui.pendingLine, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (ui.truncated) { + item { + Text( + stringResource(R.string.files_preview_truncated), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } + } + } +} + +/** The theme's colour for each kind of stretch. M3 has no warning role, so warnings take the tertiary colour. */ +@Composable +private fun syntaxColors(): Map { + val c = MaterialTheme.colorScheme + return mapOf( + SyntaxKind.Comment to SpanStyle(color = c.outline, fontStyle = FontStyle.Italic), + SyntaxKind.String to SpanStyle(color = c.tertiary), + SyntaxKind.Number to SpanStyle(color = c.secondary), + SyntaxKind.Keyword to SpanStyle(color = c.primary, fontWeight = FontWeight.SemiBold), + SyntaxKind.Key to SpanStyle(color = c.primary), + SyntaxKind.Timestamp to SpanStyle(color = c.outline), + SyntaxKind.LogError to SpanStyle(color = c.error, fontWeight = FontWeight.SemiBold), + SyntaxKind.LogWarning to SpanStyle(color = c.tertiary), + SyntaxKind.LogDebug to SpanStyle(color = c.onSurfaceVariant), + ) +} + +private fun colored(line: PreviewLine, colors: Map): AnnotatedString = buildAnnotatedString { + append(line.text) + for (span in line.spans) { + val style = colors[span.kind] ?: continue + val end = (span.start + span.length).coerceAtMost(line.text.length) + if (span.start < end) addStyle(style, span.start, end) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DetailsAndIntegrity(ui: PreviewUi, target: PreviewTarget, actions: PreviewActions) { + var open by rememberSaveable(target.side, target.rootId, target.path) { mutableStateOf(false) } + Surface(color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth()) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detailsLine(target, ui), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (actions.onOpenWith != null) { + IconButton(onClick = actions.onOpenWith, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = stringResource(R.string.files_open_with)) + } + } + if (actions.onSendAcross != null) { + IconButton(onClick = actions.onSendAcross, shapes = rememberRemexIconButtonShapes()) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = stringResource(if (target.side == FileSide.Phone) R.string.files_send_to_pc else R.string.files_save_to_phone), + ) + } + } + if (!target.isDirectory) { + IconButton(onClick = { open = !open }, shapes = rememberRemexIconButtonShapes()) { + Icon( + if (open) Icons.Default.ExpandMore else Icons.Default.ExpandLess, + contentDescription = stringResource(R.string.files_integrity_title), + ) + } + } + } + AnimatedVisibility(visible = open && !target.isDirectory) { + Integrity(ui, target, actions) + } + } + } +} + +@Composable +private fun detailsLine(target: PreviewTarget, ui: PreviewUi): String { + val context = LocalContext.current + val size = if (target.isDirectory) null else FileManagerLogic.formatBytes(if (ui.fileSize >= 0) ui.fileSize else target.sizeBytes) + val modified = target.modifiedMs.takeIf { it > 0 }?.let { + DateUtils.formatDateTime(context, it, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH) + } + val device = stringResource(if (target.side == FileSide.Phone) R.string.files_this_phone else R.string.files_pc) + return listOfNotNull(device, size, modified).joinToString(" · ") +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun Integrity(ui: PreviewUi, target: PreviewTarget, actions: PreviewActions) { + var pasted by rememberSaveable(target.side, target.rootId, target.path) { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.files_integrity_title), style = MaterialTheme.typography.titleSmallEmphasized) + val hex = ui.hashHex + when { + ui.hashing -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RemexLoadingIndicator(modifier = Modifier.size(32.dp)) + Text(stringResource(R.string.files_hashing), style = MaterialTheme.typography.bodyMedium) + } + hex != null -> Row(verticalAlignment = Alignment.CenterVertically) { + SelectionContainer(Modifier.weight(1f)) { + Text(hex, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + IconButton(onClick = { actions.onCopyHash(hex) }, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.file_manager_copy_sha256)) + } + } + !ui.canHash -> Text( + stringResource(R.string.files_hash_needs_update), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + if (ui.hashFailed) Outcome(false, stringResource(R.string.files_hash_failed)) + FilledTonalButton(onClick = actions.onComputeHash, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Icon(Icons.Default.Fingerprint, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.files_compute_hash), modifier = Modifier.padding(start = 8.dp)) + } + } + } + if (hex != null) { + OutlinedTextField( + value = pasted, + onValueChange = { pasted = it; actions.onCompare(it) }, + label = { Text(stringResource(R.string.files_compare_label)) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + when (ui.compare) { + HashCompareOutcome.Match -> Outcome(true, stringResource(R.string.files_compare_match)) + HashCompareOutcome.Mismatch -> Outcome(false, stringResource(R.string.files_compare_mismatch)) + HashCompareOutcome.NotAHash -> Outcome(false, stringResource(R.string.files_compare_not_a_hash)) + HashCompareOutcome.None -> Unit + } + } + val other = stringResource(if (target.side == FileSide.Phone) R.string.files_pc else R.string.files_this_phone) + // No button when either device can't work out a fingerprint: there'd be nothing to compare (spec §2.4). + if (ui.canVerifyAcross && ui.verifying) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RemexLoadingIndicator(modifier = Modifier.size(32.dp)) + Text(stringResource(R.string.files_verifying), style = MaterialTheme.typography.bodyMedium) + } + } else if (ui.canVerifyAcross) { + OutlinedButton(onClick = actions.onVerifyAgainst, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Text(stringResource(R.string.files_verify_against, other)) + } + } + when (ui.counterpart) { + CounterpartOutcome.Identical -> Outcome(true, stringResource(R.string.files_identical)) + CounterpartOutcome.Different -> Outcome(false, stringResource(R.string.files_different)) + CounterpartOutcome.Failed -> Outcome(false, stringResource(R.string.files_verify_failed)) + CounterpartOutcome.None -> Unit + } + } +} + +@Composable +private fun Outcome(good: Boolean, text: String) { + val color: Color = if (good) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (good) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = color, modifier = Modifier.widthIn(max = 480.dp)) + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreeModel.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreeModel.kt new file mode 100644 index 000000000..f658209d0 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreeModel.kt @@ -0,0 +1,207 @@ +package com.clindsay94.remex.ui.files + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** Which device a file is on, from the phone's point of view. */ +enum class FileSide { Phone, Pc } + +/** + * A top-level folder in the tree: a shared folder or bookmark on the phone (its id is the tree URI), or a + * shared folder or volume on the PC (its id is the PC's root id). + */ +data class TreeRootInfo( + val rootId: String, + val label: String, + val isWritable: Boolean = true, + /** Phone: on the "Access from your PC" list. */ + val isShared: Boolean = false, + /** Phone: a bookmark the person added on this screen. */ + val isBookmark: Boolean = false, + /** PC: a whole drive, listed after "Browse device". */ + val isVolume: Boolean = false, +) + +/** One visible line of the tree, flattened for a LazyColumn. */ +data class TreeRow( + val key: String, + val depth: Int, + val kind: Kind, + val side: FileSide, + val rootId: String?, + val path: String, + val label: String, + val expanded: Boolean, + val loading: Boolean, + val failed: Boolean, + /** False for a folder known to have no sub-folders, so no chevron is drawn. */ + val expandable: Boolean, + val root: TreeRootInfo?, +) { + enum class Kind { Device, Root, Folder } +} + +/** + * The folder tree of the File Transfer screen (file browser redesign, 2026-10-08): "This phone" and "PC", their + * top-level folders, and sub-folders loaded one level at a time when a row is opened. Mirrors the PC screen's + * `FileTreeViewModel`. Android-free, so expanding, failing, refreshing and revealing are unit-tested. + * + * @param listFolders the names of the sub-folders of `(side, rootId, path)`; throws when they can't be listed. + */ +class FileTreeModel( + private val scope: CoroutineScope, + private val listFolders: suspend (FileSide, String, String) -> List, +) { + private class Node(var children: List? = null, var expanded: Boolean = false, var loading: Boolean = false, var failed: Boolean = false) + + private val roots = mutableMapOf(FileSide.Phone to emptyList(), FileSide.Pc to emptyList()) + private val nodes = HashMap() + private val available = mutableMapOf(FileSide.Phone to true, FileSide.Pc to true) + + private val _rows = MutableStateFlow>(emptyList()) + val rows: StateFlow> = _rows.asStateFlow() + + init { + // Both devices start open: their folders are the first thing to pick from. + node(deviceKey(FileSide.Phone)).expanded = true + node(deviceKey(FileSide.Pc)).expanded = true + publish() + } + + /** Replaces one device's top-level folders, keeping what was open in the ones that are still there. */ + fun setRoots(side: FileSide, newRoots: List) = synchronized(this) { + val gone = roots[side].orEmpty().map { it.rootId }.toSet() - newRoots.map { it.rootId }.toSet() + roots[side] = newRoots + if (gone.isNotEmpty()) { + nodes.keys.removeAll { key -> gone.any { key.startsWith(rootPrefix(side, it)) } } + } + publish() + } + + /** False hides a device's folders (the PC while disconnected) without forgetting what was open. */ + fun setAvailable(side: FileSide, isAvailable: Boolean) { + synchronized(this) { + if (available[side] == isAvailable) return + available[side] = isAvailable + publish() + } + } + + /** Opens or closes a row; opening a folder the first time loads its sub-folders. */ + fun toggle(row: TreeRow) { + val start = synchronized(this) { + val n = node(row.key) + n.expanded = !n.expanded + val load = n.expanded && row.kind != TreeRow.Kind.Device && (n.children == null || n.failed) && !n.loading + if (load) { n.loading = true; n.failed = false } + publish() + load + } + if (start) load(row.side, row.rootId!!, row.path) + } + + /** + * Records the sub-folders of `(side, rootId, path)` from a listing made anyway (the contents pane browsing it), + * so the tree stays current with no request of its own: a folder made, renamed or deleted shows at once. + */ + fun setChildren(side: FileSide, rootId: String, path: String, folderNames: List) { + synchronized(this) { + val n = node(folderKey(side, rootId, path)) + n.children = folderNames.distinct().sortedWith(String.CASE_INSENSITIVE_ORDER) + n.loading = false + n.failed = false + publish() + } + } + + /** + * Opens every level down to `(side, rootId, path)` so the folder being browsed is visible in the tree. Levels + * not loaded yet are loaded on the way, one at a time; a level that fails stops the walk there. + */ + suspend fun reveal(side: FileSide, rootId: String, path: String) { + synchronized(this) { node(deviceKey(side)).expanded = true } + var current = "" + val segments = normalize(path).split('/').filter { it.isNotEmpty() } + for (i in 0..segments.size) { + val key = folderKey(side, rootId, current) + val needsLoad = synchronized(this) { + val n = node(key) + if (i < segments.size) n.expanded = true + val missing = i < segments.size && n.children == null && !n.loading + if (missing) n.loading = true + publish() + missing + } + if (i == segments.size) break + if (needsLoad && !loadNow(side, rootId, current)) break + current = if (current.isEmpty()) segments[i] else "$current/${segments[i]}" + } + synchronized(this) { publish() } + } + + private fun load(side: FileSide, rootId: String, path: String) { + scope.launch { loadNow(side, rootId, path) } + } + + private suspend fun loadNow(side: FileSide, rootId: String, path: String): Boolean { + val key = folderKey(side, rootId, path) + val result = try { + // distinct(): a provider may list two folders with one name; two rows would share a key. + Result.success(listFolders(side, rootId, path).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)) + } catch (e: CancellationException) { + synchronized(this) { nodes[key]?.loading = false; publish() } + throw e + } catch (e: Exception) { + Result.failure(e) + } + synchronized(this) { + val n = node(key) + n.loading = false + n.failed = result.isFailure + result.getOrNull()?.let { n.children = it } + publish() + } + return result.isSuccess + } + + private fun node(key: String) = nodes.getOrPut(key) { Node() } + + private fun publish() { + val out = ArrayList() + for (side in FileSide.entries) { + val deviceKey = deviceKey(side) + val device = node(deviceKey) + val sideRoots = if (available[side] == true) roots[side].orEmpty() else emptyList() + out += TreeRow(deviceKey, 0, TreeRow.Kind.Device, side, null, "", "", device.expanded, false, false, true, null) + if (!device.expanded) continue + for (root in sideRoots) addFolder(out, side, root, root.rootId, "", root.label, depth = 1) + } + _rows.value = out + } + + private fun addFolder(out: MutableList, side: FileSide, root: TreeRootInfo, rootId: String, path: String, label: String, depth: Int) { + val key = folderKey(side, rootId, path) + val n = nodes[key] + val expanded = n?.expanded == true + val kind = if (path.isEmpty()) TreeRow.Kind.Root else TreeRow.Kind.Folder + val expandable = n?.children?.isNotEmpty() ?: true + out += TreeRow(key, depth, kind, side, rootId, path, label, expanded, n?.loading == true, n?.failed == true, expandable, root) + if (!expanded) return + for (child in n.children.orEmpty()) { + addFolder(out, side, root, rootId, if (path.isEmpty()) child else "$path/$child", child, depth + 1) + } + } + + companion object { + fun deviceKey(side: FileSide) = "${side.name}|" + private fun rootPrefix(side: FileSide, rootId: String) = "${side.name}|$rootId|" + fun folderKey(side: FileSide, rootId: String, path: String) = rootPrefix(side, rootId) + normalize(path) + + /** A tree path: '/' separators, no leading or trailing slash. */ + fun normalize(path: String) = path.replace('\\', '/').trim('/') + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreePane.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreePane.kt new file mode 100644 index 000000000..99034889f --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/FileTreePane.kt @@ -0,0 +1,256 @@ +package com.clindsay94.remex.ui.files + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderShared +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.clindsay94.remex.R +import com.clindsay94.remex.ui.screens.FileManagerLogic +import com.clindsay94.remex.ui.screens.RemexLoadingIndicator +import com.clindsay94.remex.ui.theme.rememberRemexIconButtonShapes + +/** + * The folder tree (file browser redesign, 2026-10-08): "This phone" with its shared folders and phone-only + * bookmarks, then "PC" with its shared folders and drives. Tapping a row opens it in the contents pane; the + * chevron shows or hides the folders inside without leaving the current one. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun FileTreePane( + rows: List, + selectedKey: String?, + pcConnected: Boolean, + canBrowsePcDevice: Boolean, + /** A volumes request is already waiting for the PC: the row stays but can't be tapped again. */ + browsePcDevicePending: Boolean, + onOpen: (TreeRow) -> Unit, + onToggle: (TreeRow) -> Unit, + onAddPhoneFolder: () -> Unit, + onSetShared: (String, Boolean) -> Unit, + onRemoveBookmark: (String) -> Unit, + onBrowsePcDevice: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier, contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)) { + rows.forEachIndexed { index, row -> + item(key = row.key) { + TreeRowItem( + row = row, + selected = row.key == selectedKey, + onOpen = { onOpen(row) }, + onToggle = { onToggle(row) }, + onSetShared = onSetShared, + onRemoveBookmark = onRemoveBookmark, + ) + } + val next = rows.getOrNull(index + 1) + val lastOfSide = next == null || next.side != row.side + if (!lastOfSide) return@forEachIndexed + // Closing rows for each device: what can be added there, or why it's empty. + val deviceOpen = rows.firstOrNull { it.side == row.side && it.kind == TreeRow.Kind.Device }?.expanded == true + if (!deviceOpen) return@forEachIndexed + when (row.side) { + FileSide.Phone -> item(key = "phone-add") { + ActionRow(Icons.Default.Add, stringResource(R.string.files_add_folder), onAddPhoneFolder) + } + FileSide.Pc -> when { + !pcConnected -> item(key = "pc-offline") { NoteRow(stringResource(R.string.files_pc_not_connected)) } + canBrowsePcDevice && rows.none { it.side == FileSide.Pc && it.root?.isVolume == true } -> + item(key = "pc-volumes") { + ActionRow( + Icons.Default.Storage, + stringResource(R.string.file_manager_browse_device), + onBrowsePcDevice, + enabled = FileManagerLogic.browseDeviceEnabled(canBrowsePcDevice, browsePcDevicePending), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun TreeRowItem( + row: TreeRow, + selected: Boolean, + onOpen: () -> Unit, + onToggle: () -> Unit, + onSetShared: (String, Boolean) -> Unit, + onRemoveBookmark: (String) -> Unit, +) { + val isDevice = row.kind == TreeRow.Kind.Device + val label = when { + isDevice && row.side == FileSide.Phone -> stringResource(R.string.files_this_phone) + isDevice -> stringResource(R.string.files_pc) + else -> row.label.ifBlank { stringResource(R.string.files_unnamed_folder) } + } + val icon = when { + isDevice && row.side == FileSide.Phone -> Icons.Default.PhoneAndroid + isDevice -> Icons.Default.Computer + row.root?.isVolume == true && row.kind == TreeRow.Kind.Root -> Icons.Default.Storage + row.kind == TreeRow.Kind.Root && row.root?.isShared == true && row.side == FileSide.Phone -> Icons.Default.FolderShared + else -> Icons.Default.Folder + } + Surface( + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface.copy(alpha = 0f), + shape = RoundedCornerShape(24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .semantics { this.selected = selected }, + ) { + Row( + modifier = Modifier + .clickable(role = Role.Button, onClick = onOpen) + .heightIn(min = 48.dp) + .padding(start = (12 + (row.depth - 1).coerceAtLeast(0) * 16).dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + icon, + contentDescription = null, + tint = if (isDevice) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + style = if (isDevice) MaterialTheme.typography.titleSmallEmphasized else MaterialTheme.typography.bodyMedium, + color = if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (row.side == FileSide.Phone && row.kind == TreeRow.Kind.Root && row.root != null) { + PhoneFolderMenu(row.root, onSetShared, onRemoveBookmark) + } + Expander(row, onToggle) + } + } +} + +@Composable +private fun Expander(row: TreeRow, onToggle: () -> Unit) { + val description = stringResource(if (row.expanded) R.string.files_tree_collapse else R.string.files_tree_expand) + when { + row.loading -> Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + RemexLoadingIndicator(modifier = Modifier.size(24.dp)) + } + row.failed -> IconButton(onClick = onToggle, shapes = rememberRemexIconButtonShapes()) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = stringResource(R.string.files_tree_load_failed), + tint = MaterialTheme.colorScheme.error, + ) + } + !row.expandable -> Spacer(Modifier.width(48.dp)) + else -> IconButton(onClick = onToggle, shapes = rememberRemexIconButtonShapes()) { + Icon( + if (row.expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = description, + ) + } + } +} + +/** A phone folder's menu: share it with the PC or not, and (for a bookmark) take it off this list. */ +@Composable +private fun PhoneFolderMenu(root: TreeRootInfo, onSetShared: (String, Boolean) -> Unit, onRemoveBookmark: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.files_folder_options, root.label)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.files_share_with_pc)) }, + // The row is the one target; the switch only shows the state. + trailingIcon = { Switch(checked = root.isShared, onCheckedChange = null) }, + onClick = { onSetShared(root.rootId, !root.isShared) }, + ) + if (root.isBookmark) { + DropdownMenuItem( + text = { Text(stringResource(R.string.files_remove_bookmark)) }, + onClick = { open = false; onRemoveBookmark(root.rootId) }, + ) + } + } + } +} + +@Composable +private fun ActionRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit, + enabled: Boolean = true, +) { + val color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .heightIn(min = 48.dp) + .padding(start = 28.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + Text(label, style = MaterialTheme.typography.labelLarge, color = color) + } +} + +@Composable +private fun NoteRow(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 44.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + ) +} 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 000000000..c2c1db326 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/LocalDocuments.kt @@ -0,0 +1,283 @@ +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) { + val entries = listChildren(treeUri, documentIdOf(treeUri, relativePath)) + // Remember every child's id, so opening, previewing or thumbnailing one costs no second listing. + val cache = ids.getOrPut(treeUri.toString()) { ConcurrentHashMap() } + val folder = relativePath.trim('/') + for (entry in entries) cache[if (folder.isEmpty()) entry.name else "$folder/${entry.name}"] = entry.documentId + entries + } + + /** 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, + ) + } +} + +/** + * What the screen needs from phone folders, addressed by tree URI string and '/'-path. [LocalDocuments] is the + * app's; tests use a map. Keeps `android.net.Uri` (a stub on the JVM) out of the state holders. + */ +interface PhoneFileSource { + suspend fun list(rootUri: String, path: String): List + fun reader(rootUri: String, path: String): RangeReader + suspend fun sha256(rootUri: String, path: String, onProgress: ((Long) -> Unit)? = null): ByteArray + suspend fun documentUri(rootUri: String, path: String): String +} + +/** [PhoneFileSource] over [LocalDocuments]. */ +class LocalPhoneFileSource(private val docs: LocalDocuments) : PhoneFileSource { + override suspend fun list(rootUri: String, path: String) = docs.list(Uri.parse(rootUri), path) + override fun reader(rootUri: String, path: String) = docs.reader(Uri.parse(rootUri), path) + override suspend fun sha256(rootUri: String, path: String, onProgress: ((Long) -> Unit)?) = + docs.sha256(Uri.parse(rootUri), path, onProgress) + override suspend fun documentUri(rootUri: String, path: String) = docs.documentUri(Uri.parse(rootUri), path).toString() +} 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 000000000..a707b1a05 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PcFileRequests.kt @@ -0,0 +1,172 @@ +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.screens.FileManagerLogic +import com.clindsay94.remex.ui.screens.RemoteFileEntry +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 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, `file_hash_request` for the + * Integrity card, and `file_browse_request` for the folder tree and pickers. 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>() + private val browseWaiters = 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" + // Only a browse this class asked for: the screen's own listing has its own id and is never taken. + "file_browse_response" -> browseWaiters to "fileBrowseResponse" + 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, browseWaiters)) { + 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.") + } + + /** + * The items in one PC folder, for the folder tree and the pickers, without moving the screen's own listing. + * Throws [PcRequestFailedException] when the PC refuses (a folder it no longer shares, say). + */ + suspend fun browse(rootId: String, relativePath: String, timeoutMs: Long = RANGE_TIMEOUT_MS): List { + val payload = JSONObject().apply { + put("rootId", rootId) + put("path", relativePath) + put("relativePath", relativePath) + } + // The same v2 envelope the screen's own browse uses, so every PC version answers it. + val body = request("file_browse_request", "fileBrowseRequest", payload, browseWaiters, timeoutMs, "folder", v3 = false) + body.meaningful("errorMessage")?.let { throw PcRequestFailedException(it) } + return FileManagerLogic.parseFileEntries(body.optJSONArray("entries")).map { + RemoteFileEntry(it.name, it.isDirectory, it.sizeBytes, it.modifiedUnixMs) + } + } + + private suspend fun request( + type: String, + payloadKey: String, + payload: JSONObject, + waiters: ConcurrentHashMap>, + timeoutMs: Long, + what: String, + v3: Boolean = true, + ): 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) + if (v3) 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/PhoneBrowserModel.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PhoneBrowserModel.kt new file mode 100644 index 000000000..700bdc527 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PhoneBrowserModel.kt @@ -0,0 +1,59 @@ +package com.clindsay94.remex.ui.files + +import com.clindsay94.remex.ui.screens.RemoteFileEntry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * The folder being shown on the "This phone" side (file browser redesign, 2026-10-08). Entries come back as + * [RemoteFileEntry] so the same list and grid rows, sorting and hidden-item rules serve both devices. + */ +class PhoneBrowserModel(private val scope: CoroutineScope, private val source: PhoneFileSource) { + data class State( + val rootId: String? = null, + val path: String = "", + val entries: List = emptyList(), + val loading: Boolean = false, + val failed: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + /** Shows `path` under the phone folder [rootId]. A newer call replaces an older one still loading. */ + fun open(rootId: String, path: String) { + val normalized = FileTreeModel.normalize(path) + job?.cancel() + val keep = _state.value.rootId == rootId && _state.value.path == normalized + _state.value = State(rootId, normalized, if (keep) _state.value.entries else emptyList(), loading = true) + job = scope.launch { + try { + val entries = source.list(rootId, normalized).map { + RemoteFileEntry(it.name, it.isDirectory, maxOf(0L, it.sizeBytes), it.modifiedMs, id = it.documentId) + } + _state.value = State(rootId, normalized, entries) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _state.value = State(rootId, normalized, failed = true) + } + } + } + + fun refresh() { + val s = _state.value + if (s.rootId != null) open(s.rootId, s.path) + } + + /** Nothing open (the folder was removed from the list). */ + fun clear() { + job?.cancel() + _state.value = State() + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PhoneThumbnails.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PhoneThumbnails.kt new file mode 100644 index 000000000..f9497db8a --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PhoneThumbnails.kt @@ -0,0 +1,26 @@ +package com.clindsay94.remex.ui.files + +import android.util.LruCache +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap + +/** + * Decoded thumbnails of phone images for the File Transfer list (file browser redesign, 2026-10-08), keyed by + * folder URI and path. Outlives a row scrolled off screen, so scrolling back doesn't ask the system again. + * Bounded by decoded bytes, like the PC thumbnails' cache. + */ +object PhoneThumbnails { + /** The size asked of `ContentResolver.loadThumbnail`; the system's own thumbnails are about this big. */ + const val SIZE_PX = 256 + private const val MAX_BYTES = 8 * 1024 * 1024 + + private val cache = object : LruCache(MAX_BYTES) { + override fun sizeOf(key: String, value: ImageBitmap): Int = value.asAndroidBitmap().allocationByteCount + } + + fun get(key: String): ImageBitmap? = cache.get(key) + + fun put(key: String, bitmap: ImageBitmap) { + cache.put(key, bitmap) + } +} diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PreviewImageDecoder.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PreviewImageDecoder.kt new file mode 100644 index 000000000..b4bbc3ab3 --- /dev/null +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/files/PreviewImageDecoder.kt @@ -0,0 +1,36 @@ +package com.clindsay94.remex.ui.files + +import android.graphics.ImageDecoder +import android.util.DisplayMetrics +import android.util.Log +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import java.nio.ByteBuffer + +/** + * Decodes a previewed image with [ImageDecoder] (JPEG, PNG, GIF, WebP, BMP and HEIC/HEIF), downsampled so its + * longer side is at most twice the screen's: sharp when pinch-zoomed, without holding a 48-megapixel bitmap. + */ +object PreviewImageDecoder { + private const val TAG = "PreviewImageDecoder" + + fun decode(bytes: ByteArray, display: DisplayMetrics): ImageBitmap? { + val limit = 2 * maxOf(display.widthPixels, display.heightPixels, 1080) + return try { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(ByteBuffer.wrap(bytes))) { decoder, info, _ -> + val longest = maxOf(info.size.width, info.size.height) + if (longest > limit) { + val scale = limit.toFloat() / longest + decoder.setTargetSize( + (info.size.width * scale).toInt().coerceAtLeast(1), + (info.size.height * scale).toInt().coerceAtLeast(1), + ) + } + }.asImageBitmap() + } catch (e: Exception) { + // Not an image after all, or a format this phone can't read: the pane shows the details instead. + Log.i(TAG, "Image preview could not be decoded", e) + null + } + } +} 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 000000000..40df88f5e --- /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 000000000..95529834d --- /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 000000000..d04b716f6 --- /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 000000000..52aab9934 --- /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/AboutScreen.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/AboutScreen.kt index 5e6995978..a305dc305 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/java/com/clindsay94/remex/ui/screens/FileManagerLogic.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileManagerLogic.kt index d5a80b49e..a6aeb6715 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileManagerLogic.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileManagerLogic.kt @@ -53,6 +53,10 @@ data class FileManagerCapabilities( val ops: Set, val fullBrowse: Boolean, val push: Boolean, + /** Answers `file_read_range_request` (previews, live tail). False on a PC from before the 2026-10-08 redesign. */ + val readRange: Boolean = false, + /** Answers `file_hash_request` (the preview's fingerprint card). */ + val hash: Boolean = false, ) { val isV3: Boolean get() = protocol >= 3 } @@ -448,8 +452,8 @@ object FileManagerLogic { /** * Whether the "Browse this PC" control should accept a tap right now (RemEx-c7v4n round 3): only * when full-device browsing is actually on offer AND no answer to a previous request is still in - * flight. [canBrowseDevice] is the same host-capability fact [FileManagerQuickAccess] already gates - * the chip's visibility on — folded in here rather than inlining `!pending` at the call site, so + * flight. [canBrowseDevice] is the same host-capability fact the folder tree's "Browse this PC" row + * is shown on (FileTreePane) — folded in here rather than inlining `!pending` at the call site, so * this pins the real precondition ("possible AND not already waiting") instead of restating the * `!` operator a bare `pending` flip would (review, round 3). */ diff --git a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferScreen.kt b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferScreen.kt index 1954aa01f..b625645bc 100644 --- a/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferScreen.kt +++ b/remex.android/app/src/main/java/com/clindsay94/remex/ui/screens/FileTransferScreen.kt @@ -1,86 +1,155 @@ package com.clindsay94.remex.ui.screens +import android.content.Intent +import android.net.Uri +import android.util.Size +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material.icons.filled.FolderZip import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.window.core.layout.WindowSizeClass import com.clindsay94.remex.R import com.clindsay94.remex.RemexClientManager import com.clindsay94.remex.service.FileManageOperations +import com.clindsay94.remex.share.FileOpener +import com.clindsay94.remex.ui.components.FileConflictSheet import com.clindsay94.remex.ui.components.FileManagerBreadcrumbs import com.clindsay94.remex.ui.components.FileManagerDestinationSheet import com.clindsay94.remex.ui.components.FileManagerGridItem import com.clindsay94.remex.ui.components.FileManagerListItem import com.clindsay94.remex.ui.components.FileManagerPropertiesSheet -import com.clindsay94.remex.ui.components.FileConflictSheet import com.clindsay94.remex.ui.components.FileManagerQueuePanel -import com.clindsay94.remex.ui.components.FileManagerQuickAccess import com.clindsay94.remex.ui.components.FileManagerSelectionBar import com.clindsay94.remex.ui.components.FileManagerTextDialog import com.clindsay94.remex.ui.components.FileManagerToolbar import com.clindsay94.remex.ui.components.RemexFlexibleTopBar +import com.clindsay94.remex.ui.components.RemexHapticEvent +import com.clindsay94.remex.ui.components.ToolbarAction +import com.clindsay94.remex.ui.components.rememberRemexHaptics import com.clindsay94.remex.ui.components.rememberRemexTopBarScrollBehavior +import com.clindsay94.remex.ui.files.CrossDevicePicker +import com.clindsay94.remex.ui.files.FileClipboard +import com.clindsay94.remex.ui.files.FilePreviewPane +import com.clindsay94.remex.ui.files.FileSide +import com.clindsay94.remex.ui.files.FileTreeModel +import com.clindsay94.remex.ui.files.FileTreePane +import com.clindsay94.remex.ui.files.PhoneThumbnails +import com.clindsay94.remex.ui.files.PickMode +import com.clindsay94.remex.ui.files.PreviewActions +import com.clindsay94.remex.ui.files.PreviewTarget +import com.clindsay94.remex.ui.files.TreeRootInfo +import com.clindsay94.remex.ui.files.TreeRow import com.clindsay94.remex.ui.theme.cardInnerPadding import com.clindsay94.remex.ui.theme.rememberRemexButtonShapes -import androidx.compose.material3.ButtonDefaults -import com.clindsay94.remex.ui.components.RemexHapticEvent -import com.clindsay94.remex.ui.components.rememberRemexHaptics +import com.clindsay94.remex.ui.theme.rememberRemexIconButtonShapes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +/** A cross-device picker on screen: what it's for and which files it carries. */ +private sealed interface AcrossRequest { + data class SendToPc(val items: List) : AcrossRequest + data class SaveToPhone(val items: List) : AcrossRequest + data class VerifyAgainst(val target: PreviewTarget) : AcrossRequest +} + +/** + * The Files screen (file browser redesign, 2026-10-08): a folder tree with "This phone" and "PC", the folder + * being browsed, and a live preview of the selected file. + * + * - Expanded width (tablet, unfolded): the tree is a permanent pane; contents and preview sit side by side. + * - Narrower: the tree is a drawer opened from the top bar, and a file opens its preview full screen, with + * (predictive) back returning to the list. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3AdaptiveApi::class) @Composable fun FileTransferScreen( onNavigateToConnection: () -> Unit = {}, @@ -88,16 +157,312 @@ fun FileTransferScreen( ) { // A transfer started here is invisible without this permission (RemEx-pp4cm.8). com.clindsay94.remex.ui.components.AskForTransferNotifications() + val context = LocalContext.current + val scope = rememberCoroutineScope() val isConnected by RemexClientManager.isConnected.collectAsStateWithLifecycle() val needsPairing by RemexClientManager.needsPairing.collectAsStateWithLifecycle() + val side by vm.side.collectAsStateWithLifecycle() + val treeRows by vm.tree.rows.collectAsStateWithLifecycle() + val previewUi by vm.preview.ui.collectAsStateWithLifecycle() + val phoneState by vm.phoneBrowser.state.collectAsStateWithLifecycle() + val phoneFolders by vm.phoneFolders.collectAsStateWithLifecycle() + val phoneNames by vm.phoneFolderNames.collectAsStateWithLifecycle() + val remoteRoots by vm.remoteRoots.collectAsStateWithLifecycle() + val volumes by vm.volumes.collectAsStateWithLifecycle() + val selectedRootId by vm.selectedRootId.collectAsStateWithLifecycle() + val remotePath by vm.remotePath.collectAsStateWithLifecycle() + val capabilities by vm.capabilities.collectAsStateWithLifecycle() + val volumesPending by vm.volumesPending.collectAsStateWithLifecycle() + val transferQueue by vm.transferQueue.collectAsStateWithLifecycle() + val conflictPrompt by vm.conflictPrompt.collectAsStateWithLifecycle() + + val adaptive = currentWindowAdaptiveInfoV2() + val expanded = adaptive.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigator = rememberListDetailPaneScaffoldNavigator() + val detailHidden = navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Hidden + val listHidden = navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Hidden + + // A preview nobody can see stops: no live tail, no image still downloading, no fingerprint still running. + LaunchedEffect(detailHidden) { if (detailHidden) vm.preview.show(null) } + + fun openPreview(target: PreviewTarget) { + vm.preview.show(target) + scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, "${target.side}|${target.rootId}|${target.path}") } + } + + fun closePreview() { + scope.launch { navigator.navigateBack() } + } + + // ── Roots as the tree and pickers see them ───────────────────────────────── + val pcRoots = remember(remoteRoots, volumes) { + remoteRoots.map { TreeRootInfo(it.rootId, it.displayName, isWritable = it.isWritable) } + + volumes.map { TreeRootInfo(it.id, it.label, isVolume = true) } + } + val phoneRoots = remember(phoneFolders, phoneNames) { + phoneFolders.map { TreeRootInfo(it.uri, phoneNames[it.uri].orEmpty(), isShared = it.isShared, isBookmark = it.isBookmark) } + } + + // Read in composition: a callback that calls getString misses a language change (LocalContextGetResourceValueCall). + val addFolderFailed = stringResource(R.string.files_add_folder_failed) + val openFailed = stringResource(R.string.files_open_failed) + + // ── Pickers ──────────────────────────────────────────────────────────────── + val addFolderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + // The grant is what lets the app list the folder after a restart; without it the bookmark is dead. + val granted = runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + }.isSuccess + if (granted) vm.addPhoneBookmark(uri) + else Toast.makeText(context, addFolderFailed, Toast.LENGTH_LONG).show() + } + } + var across by remember { mutableStateOf(null) } + + across?.let { request -> + val (title, confirm, pickSide, mode) = when (request) { + is AcrossRequest.SendToPc -> Quad(stringResource(R.string.files_send_to_pc), stringResource(R.string.files_send_here), FileSide.Pc, PickMode.Folder) + is AcrossRequest.SaveToPhone -> Quad(stringResource(R.string.files_save_to_phone), stringResource(R.string.files_save_here), FileSide.Phone, PickMode.Folder) + is AcrossRequest.VerifyAgainst -> Quad( + stringResource(R.string.files_verify_against, stringResource(if (request.target.side == FileSide.Phone) R.string.files_pc else R.string.files_this_phone)), + "", + if (request.target.side == FileSide.Phone) FileSide.Pc else FileSide.Phone, + PickMode.File, + ) + } + CrossDevicePicker( + title = title, + confirmLabel = confirm, + side = pickSide, + mode = mode, + roots = if (pickSide == FileSide.Pc) pcRoots else phoneRoots, + start = vm.lastFolderOn(pickSide), + list = vm::listForPicker, + onPicked = { rootId, folder, file -> + across = null + when (request) { + is AcrossRequest.SendToPc -> vm.sendToPc(request.items, rootId, folder) + is AcrossRequest.SaveToPhone -> vm.saveToPhone(request.items, rootId, folder) + is AcrossRequest.VerifyAgainst -> if (file != null) { + val path = if (folder.isEmpty()) file.name else "$folder/${file.name}" + vm.preview.verifyAgainst(PreviewTarget(pickSide, rootId, path, file.name, false, file.sizeBytes, file.modifiedUnixMs)) + } + } + }, + onDismiss = { across = null }, + ) + } + + val copiedText = stringResource(R.string.file_manager_sha256_copied) + val copyLabel = stringResource(R.string.file_manager_copy_sha256) + val previewActions = PreviewActions( + onBack = if (listHidden) ::closePreview else null, + onLive = vm.preview::setLive, + onComputeHash = vm.preview::computeHash, + onCompare = vm.preview::compare, + onVerifyAgainst = { previewUi.target?.let { across = AcrossRequest.VerifyAgainst(it) } }, + onCopyHash = { hex -> if (FileClipboard.copy(context, copyLabel, hex)) Toast.makeText(context, copiedText, Toast.LENGTH_SHORT).show() }, + onOpenWith = previewUi.target?.takeIf { it.side == FileSide.Phone && !it.isDirectory }?.let { t -> + { + scope.launch { + val uri = runCatching { vm.phoneDocumentUri(t.rootId, t.path) }.getOrNull() + if (uri == null || !FileOpener.open(context, uri, t.name)) { + Toast.makeText(context, openFailed, Toast.LENGTH_SHORT).show() + } + } + } + }, + onSendAcross = previewUi.target?.takeIf { !it.isDirectory }?.let { t -> + { across = if (t.side == FileSide.Phone) AcrossRequest.SendToPc(listOf(t)) else AcrossRequest.SaveToPhone(listOf(t)) } + }, + onRetry = { previewUi.target?.let(vm.preview::show) }, + ) + + val selectedKey = when (side) { + FileSide.Phone -> phoneState.rootId?.let { FileTreeModel.folderKey(FileSide.Phone, it, phoneState.path) } + FileSide.Pc -> selectedRootId?.let { FileTreeModel.folderKey(FileSide.Pc, it, remotePath) } + } + val tree: @Composable (Modifier) -> Unit = { modifier -> + FileTreePane( + rows = treeRows, + selectedKey = selectedKey, + pcConnected = isConnected && !needsPairing, + canBrowsePcDevice = capabilities?.fullBrowse == true, + browsePcDevicePending = volumesPending, + onOpen = { row -> + if (row.kind == TreeRow.Kind.Device) vm.showSide(row.side) + else vm.openLocation(row.side, row.rootId!!, row.path) + scope.launch { + drawerState.close() + // A full-screen preview would otherwise stay up, now empty, over the folder just picked. + if (listHidden) navigator.navigateBack() + } + }, + onToggle = vm.tree::toggle, + onAddPhoneFolder = { addFolderLauncher.launch(null) }, + onSetShared = vm::setPhoneFolderShared, + onRemoveBookmark = vm::removePhoneBookmark, + onBrowsePcDevice = vm::loadVolumes, + modifier = modifier, + ) + } + + val topBarScrollBehavior = rememberRemexTopBarScrollBehavior() + val locationSubtitle = when (side) { + FileSide.Pc -> FileManagerLogic.buildLocationSubtitle( + rootLabel = pcRoots.firstOrNull { it.rootId == selectedRootId }?.label ?: "/", + path = remotePath, + selectedRootIsWritable = remoteRoots.firstOrNull { it.rootId == selectedRootId }?.isWritable, + readOnlyLabel = stringResource(R.string.file_transfer_read_only_root), + ).takeIf { selectedRootId != null } + FileSide.Phone -> phoneState.rootId?.let { root -> + val label = phoneNames[root].orEmpty() + if (phoneState.path.isEmpty()) label else "$label/${phoneState.path}" + } + } + + val body: @Composable () -> Unit = { + Scaffold( + modifier = Modifier.nestedScroll(topBarScrollBehavior.nestedScrollConnection), + topBar = { + RemexFlexibleTopBar( + title = stringResource(R.string.screen_file_transfer_title), + // Before a folder is open there is no location to name, so the page shows the + // same plain subtitle as the PC's Files page, in the display font (RemEx-kq10x.4). + // A location is data (folder names in any script), so it keeps the body font. + subtitle = locationSubtitle ?: stringResource(R.string.screen_file_transfer_subtitle), + subtitleInDisplayFont = locationSubtitle == null, + navigationIcon = if (expanded) null else { + { + IconButton(onClick = { scope.launch { drawerState.open() } }, shapes = rememberRemexIconButtonShapes()) { + Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.files_show_folders)) + } + } + }, + scrollBehavior = topBarScrollBehavior, + ) + }, + ) { innerPadding -> + Row(Modifier.fillMaxSize().padding(innerPadding)) { + if (expanded) { + tree(Modifier.width(280.dp).fillMaxHeight()) + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + NavigableListDetailPaneScaffold( + navigator = navigator, + modifier = Modifier.weight(1f), + listPane = { + AnimatedPane { + Column(Modifier.fillMaxSize()) { + if (!expanded) SideSwitcher(side, vm::showSide) + Box(Modifier.weight(1f)) { + when (side) { + FileSide.Pc -> PcContents( + vm = vm, + isConnected = isConnected, + needsPairing = needsPairing, + onNavigateToConnection = onNavigateToConnection, + onOpenFile = ::openPreview, + onSaveToPhone = { across = AcrossRequest.SaveToPhone(it) }, + ) + FileSide.Phone -> PhoneContents( + vm = vm, + hasFolders = phoneFolders.isNotEmpty(), + rootLabel = phoneState.rootId?.let { phoneNames[it] }.orEmpty(), + onAddFolder = { addFolderLauncher.launch(null) }, + onOpenFile = ::openPreview, + onSendToPc = { across = AcrossRequest.SendToPc(it) }, + ) + } + } + FileManagerQueuePanel( + transfers = transferQueue, + onPause = vm::pauseTransfer, + onResume = vm::resumeTransfer, + onCancel = vm::cancelTransfer, + onCancelAll = vm::cancelAllTransfers, + onClearFinished = vm::clearFinishedTransfers, + ) + } + } + }, + detailPane = { + AnimatedPane { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + FilePreviewPane(previewUi, previewActions) + } + } + }, + ) + } + } + // Raised by the copy/move loop when the host reports a filename collision (RemEx-agpn). + conflictPrompt?.let { prompt -> FileConflictSheet(prompt = prompt, onResolved = vm::onConflictResolved) } + } + + if (expanded) { + body() + } else { + ModalNavigationDrawer( + drawerState = drawerState, + // Edge swipes stay with the list (and predictive back); the drawer opens from the top-bar button. + gesturesEnabled = drawerState.isOpen, + drawerContent = { + ModalDrawerSheet { + Text( + stringResource(R.string.files_folders_title), + style = MaterialTheme.typography.titleMediumEmphasized, + modifier = Modifier.padding(start = 24.dp, top = 20.dp, bottom = 8.dp), + ) + tree(Modifier.fillMaxSize()) + } + }, + content = body, + ) + } +} + +private data class Quad(val a: A, val b: B, val c: C, val d: D) + +/** One-tap switch between the two devices on narrow screens, where the tree is in a drawer. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SideSwitcher(side: FileSide, onSide: (FileSide) -> Unit) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + val options = listOf(FileSide.Phone to R.string.files_this_phone, FileSide.Pc to R.string.files_pc) + options.forEachIndexed { index, (value, label) -> + SegmentedButton( + selected = side == value, + onClick = { onSide(value) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { Text(stringResource(label)) } + } + } +} + +// ── PC side ─────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PcContents( + vm: FileTransferViewModel, + isConnected: Boolean, + needsPairing: Boolean, + onNavigateToConnection: () -> Unit, + onOpenFile: (PreviewTarget) -> Unit, + onSaveToPhone: (List) -> Unit, +) { val remotePath by vm.remotePath.collectAsStateWithLifecycle() val displayedEntries by vm.displayedEntries.collectAsStateWithLifecycle() val remoteRoots by vm.remoteRoots.collectAsStateWithLifecycle() val volumes by vm.volumes.collectAsStateWithLifecycle() - val volumesPending by vm.volumesPending.collectAsStateWithLifecycle() val supportsFolderTransfer by vm.supportsFolderTransfer.collectAsStateWithLifecycle() val selectedRootId by vm.selectedRootId.collectAsStateWithLifecycle() - val capabilities by vm.capabilities.collectAsStateWithLifecycle() val isLoading by vm.isLoading.collectAsStateWithLifecycle() val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle() val statusText by vm.statusText.collectAsStateWithLifecycle() @@ -111,8 +476,6 @@ fun FileTransferScreen( val searchActive by vm.searchActive.collectAsStateWithLifecycle() val searchTruncated by vm.searchTruncated.collectAsStateWithLifecycle() val thumbnails by vm.thumbnails.collectAsStateWithLifecycle() - val transferQueue by vm.transferQueue.collectAsStateWithLifecycle() - val conflictPrompt by vm.conflictPrompt.collectAsStateWithLifecycle() val properties by vm.properties.collectAsStateWithLifecycle() val propertiesLoading by vm.propertiesLoading.collectAsStateWithLifecycle() val destinationPath by vm.destinationPath.collectAsStateWithLifecycle() @@ -145,18 +508,10 @@ fun FileTransferScreen( pendingDownloadEntry = null if (uri != null && entry != null) vm.downloadEntryTo(entry, uri) } - - // ── Dialog / sheet state ──────────────────────────────────────────────────── - var renameTarget by remember { mutableStateOf(null) } - var showNewFolder by remember { mutableStateOf(false) } - var contextMenuEntry by remember { mutableStateOf(null) } - var destinationMode by remember { mutableStateOf(null) } // FileManageOperations.COPY / MOVE - fun startDownload(entry: RemoteFileEntry) { pendingDownloadEntry = entry createDocumentLauncher.launch(entry.name) } - // A folder needs a TREE grant, not a single created document: the whole subtree is written under // it, so the picker has to hand back somewhere this app may create files and folders (RemEx-q3twg). val uploadTreeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> @@ -168,13 +523,17 @@ fun FileTransferScreen( pendingFolderEntry = null if (uri != null && entry != null) vm.downloadFolderTo(entry, uri) } - fun startFolderDownload(entry: RemoteFileEntry) { pendingFolderEntry = entry openTreeLauncher.launch(null) } // ── Dialogs / sheets ──────────────────────────────────────────────────────── + var renameTarget by remember { mutableStateOf(null) } + var showNewFolder by remember { mutableStateOf(false) } + var contextMenuEntry by remember { mutableStateOf(null) } + var destinationMode by remember { mutableStateOf(null) } // FileManageOperations.COPY / MOVE + renameTarget?.let { target -> FileManagerTextDialog( title = stringResource(R.string.file_transfer_rename_title), @@ -216,13 +575,15 @@ fun FileTransferScreen( ) } contextMenuEntry?.let { entry -> - ContextMenuSheet( + PcContextMenuSheet( entry = entry, canRename = canRename, canDelete = canDelete, + supportsFolderTransfer = supportsFolderTransfer, onDismiss = { contextMenuEntry = null }, + onPreview = { contextMenuEntry = null; vm.previewTargetFor(FileSide.Pc, entry)?.let(onOpenFile) }, + onSaveToPhone = { contextMenuEntry = null; vm.previewTargetFor(FileSide.Pc, entry)?.let { onSaveToPhone(listOf(it)) } }, onDownload = { contextMenuEntry = null; startDownload(entry) }, - supportsFolderTransfer = supportsFolderTransfer, onDownloadFolder = { contextMenuEntry = null; startFolderDownload(entry) }, onRename = { contextMenuEntry = null; renameTarget = entry }, onDelete = { contextMenuEntry = null; vm.deleteEntry(entry) }, @@ -231,301 +592,450 @@ fun FileTransferScreen( ) } - val topBarScrollBehavior = rememberRemexTopBarScrollBehavior() - val fileGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() - val fileListState = androidx.compose.foundation.lazy.rememberLazyListState() - // Tapping Files while on Files goes back to the top of whichever view is showing - // (RemEx-pp4cm.3). The unattached state is left alone: it cannot scroll. - com.clindsay94.remex.ui.navigation.TabReselectEffect(com.clindsay94.remex.ui.navigation.Screen.FileTransfer) { - if (viewMode == FileViewMode.GRID) fileGridState.animateScrollToItem(0) - else fileListState.animateScrollToItem(0) + // Connected/disconnected cross-fades instead of hard-swapping via an early return (RemEx-xgc7). + val connectionFadeSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + // A PC that no longer recognises this phone refuses the roots request, so Files says so + // and offers Pair instead of spinning (sweep P3). + val gate = when { + !isConnected -> FilesGate.Disconnected + needsPairing -> FilesGate.NeedsPairing + else -> FilesGate.Ready } - // Name the read-only state next to the folder it applies to, so a user looking at - // greyed-out upload and new-folder buttons has the reason on screen (RemEx-dc57). - // Note this passes selectedRoot?.isWritable, NOT canWrite - see the rule in - // buildLocationSubtitle for why the difference matters. - val locationSubtitle = FileManagerLogic.buildLocationSubtitle( - rootLabel = rootLabel, - path = remotePath, - selectedRootIsWritable = selectedRoot?.isWritable, - readOnlyLabel = stringResource(R.string.file_transfer_read_only_root), - ) - Scaffold( - modifier = Modifier.nestedScroll(topBarScrollBehavior.nestedScrollConnection), - topBar = { - RemexFlexibleTopBar( - title = stringResource(R.string.screen_file_transfer_title), - // Before a folder is open there is no location to name, so the page shows the - // same plain subtitle as the PC's Files page, in the display font (RemEx-kq10x.4). - // A location is data (folder names in any script), so it keeps the body font. - subtitle = locationSubtitle ?: stringResource(R.string.screen_file_transfer_subtitle), - subtitleInDisplayFont = locationSubtitle == null, - scrollBehavior = topBarScrollBehavior, - ) - }, - ) { innerPadding -> - // Connected/disconnected cross-fades instead of hard-swapping via an early return - // (RemEx-xgc7). - val connectionFadeSpec = MaterialTheme.motionScheme.defaultEffectsSpec() - // A PC that no longer recognises this phone refuses the roots request, so Files says so - // and offers Pair instead of spinning (sweep P3). - val gate = - when { - !isConnected -> FilesGate.Disconnected - needsPairing -> FilesGate.NeedsPairing - else -> FilesGate.Ready + AnimatedContent( + targetState = gate, + transitionSpec = { fadeIn(connectionFadeSpec) togetherWith fadeOut(connectionFadeSpec) }, + modifier = Modifier.fillMaxSize().padding(horizontal = padding), + label = "file_manager_connection", + ) { shown -> + when (shown) { + FilesGate.Disconnected -> DisconnectedContent(onNavigateToConnection) + FilesGate.NeedsPairing -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + NeedsPairingContent( + onPair = { + ConnectionOpenRequests.requestAddPc() + onNavigateToConnection() + }, + ) } - AnimatedContent( - targetState = gate, - transitionSpec = { fadeIn(connectionFadeSpec) togetherWith fadeOut(connectionFadeSpec) }, - modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = padding), - label = "file_manager_connection", - ) { shown -> - if (shown == FilesGate.Disconnected) { - DisconnectedContent(onNavigateToConnection) - } else if (shown == FilesGate.NeedsPairing) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - NeedsPairingContent( - onPair = { - ConnectionOpenRequests.requestAddPc() - onNavigateToConnection() - }, + FilesGate.Ready -> Column(modifier = Modifier.fillMaxSize()) { + FileManagerToolbar( + searchQuery = searchQuery, + onSearchChange = vm::setSearchQuery, + sortOption = sortOption, + onSort = vm::setSort, + viewMode = viewMode, + onToggleViewMode = vm::toggleViewMode, + canWrite = canWrite && !searchActive, + onNewFolder = { showNewFolder = true }, + onUpload = { uploadLauncher.launch("*/*") }, + onUploadFolder = { uploadTreeLauncher.launch(null) }, + modifier = Modifier.padding(vertical = 4.dp), + showHidden = showHidden, + onShowHiddenChange = vm::setShowHidden, + ) + + AnimatedVisibility( + visible = !searchActive && selectedRootId != null, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + FileManagerBreadcrumbs( + crumbs = FileManagerLogic.buildBreadcrumbs(rootLabel, remotePath), + onNavigate = vm::navigateToPath, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), ) } - } else { - Column(modifier = Modifier.fillMaxSize()) { - - FileManagerToolbar( - searchQuery = searchQuery, - onSearchChange = vm::setSearchQuery, - sortOption = sortOption, - onSort = vm::setSort, - viewMode = viewMode, - onToggleViewMode = vm::toggleViewMode, - canWrite = canWrite && !searchActive, - onNewFolder = { showNewFolder = true }, - onUpload = { uploadLauncher.launch("*/*") }, - onUploadFolder = { uploadTreeLauncher.launch(null) }, + + AnimatedVisibility( + visible = isSelectionMode, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + val saveLabel = stringResource(R.string.files_save_to_phone) + FileManagerSelectionBar( + selectedCount = selectedEntryNames.size, + canWrite = canWrite, + canDelete = canDelete, + onClose = vm::clearSelection, + onSelectAll = vm::selectAll, + onCopy = { vm.openDestinationPicker(); destinationMode = FileManageOperations.COPY }, + onMove = { vm.openDestinationPicker(); destinationMode = FileManageOperations.MOVE }, + onDelete = vm::deleteSelectedEntries, modifier = Modifier.padding(vertical = 4.dp), - showHidden = showHidden, - onShowHiddenChange = vm::setShowHidden, + acrossAction = ToolbarAction(Icons.Default.PhoneAndroid, saveLabel) { + val items = displayedEntries.filter { it.name in selectedEntryNames } + .mapNotNull { vm.previewTargetFor(FileSide.Pc, it) } + vm.clearSelection() + onSaveToPhone(items) + }, ) + } - FileManagerQuickAccess( - roots = remoteRoots, - volumes = volumes, - selectedRootId = selectedRootId, - canBrowseDevice = capabilities?.fullBrowse == true, - browseDevicePending = volumesPending, - onSelectRoot = vm::selectRoot, - onSelectVolume = vm::selectVolume, - onBrowseDevice = vm::loadVolumes, - modifier = Modifier.padding(bottom = 4.dp), + // The stacked header notices grow/shrink instead of shoving the list in one frame (RemEx-z01v). + AnimatedVisibility( + visible = searchActive && searchTruncated, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + Text( + text = stringResource(R.string.file_manager_search_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 2.dp), ) + } + StatusLine(statusText) - AnimatedVisibility( - visible = !searchActive, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), - ) { - FileManagerBreadcrumbs( - crumbs = FileManagerLogic.buildBreadcrumbs(rootLabel, remotePath), - onNavigate = vm::navigateToPath, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - ) + AnimatedVisibility( + visible = isTransferring, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + Column { + RemexLinearWavyProgress(progress = transferProgress, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + OutlinedButton(onClick = vm::cancelLegacyTransfer, modifier = Modifier.fillMaxWidth(), shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Text(stringResource(R.string.file_transfer_cancel)) + } } + } - AnimatedVisibility( - visible = isSelectionMode, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), - ) { - FileManagerSelectionBar( - selectedCount = selectedEntryNames.size, - canWrite = canWrite, - canDelete = canDelete, - onClose = vm::clearSelection, - onSelectAll = vm::selectAll, - onCopy = { vm.openDestinationPicker(); destinationMode = FileManageOperations.COPY }, - onMove = { vm.openDestinationPicker(); destinationMode = FileManageOperations.MOVE }, - onDelete = vm::deleteSelectedEntries, - modifier = Modifier.padding(vertical = 4.dp), + Box(modifier = Modifier.weight(1f)) { + PullToRefreshBox(isRefreshing = isRefreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + // The body states cross-fade through AnimatedContent, keyed on kind + view mode. Entry-list + // changes deliberately do NOT re-key the region: rows animate individually via animateItem + // (RemEx-598q reconciling RemEx-h9rd). + val bodyState = when { + isLoading && displayedEntries.isEmpty() -> FileManagerBodyState(FileManagerBodyState.Kind.Loading, viewMode) + remoteRoots.isEmpty() && volumes.isEmpty() -> FileManagerBodyState(FileManagerBodyState.Kind.NoRoots, viewMode) + selectedRootId == null -> FileManagerBodyState(FileManagerBodyState.Kind.PickFolder, viewMode) + displayedEntries.isEmpty() -> FileManagerBodyState(FileManagerBodyState.Kind.Empty, viewMode) + else -> FileManagerBodyState(FileManagerBodyState.Kind.Content, viewMode) + } + val thumbFor: (RemoteFileEntry) -> String? = { thumbnails[it.relativePath ?: FileManagerLogic.combinePath(remotePath, it.name)] } + val onTap: (RemoteFileEntry) -> Unit = { entry -> + when { + isSelectionMode -> vm.toggleEntrySelection(entry) + entry.isDirectory -> vm.navigateInto(entry) + // A file opens its preview: in the old screen a tap in list view did nothing. + else -> vm.previewTargetFor(FileSide.Pc, entry)?.let(onOpenFile) + } + } + EntriesBody( + state = bodyState, + entries = displayedEntries, + emptyText = when { + hiddenItemCount > 0 -> stringResource(R.string.file_manager_only_hidden_items) + searchActive -> stringResource(R.string.file_manager_no_results) + else -> stringResource(R.string.file_transfer_empty) + }, + noRootsText = stringResource(R.string.file_transfer_no_shared_folders), + pickFolderText = stringResource(R.string.files_pick_pc_folder), + isSelectionMode = isSelectionMode, + isSelected = { it.name in selectedEntryNames }, + thumbnailBase64 = thumbFor, + thumbnail = { null }, + onRequestThumbnail = vm::requestThumbnail, + onTap = onTap, + onLongPress = startSelecting, + onOverflow = { contextMenuEntry = it }, + onDownload = ::startDownload, ) } + } + } + } + } +} - // The three stacked header notices grow/shrink instead of shoving the list in - // one frame (RemEx-z01v). - AnimatedVisibility( - visible = searchActive && searchTruncated, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), - ) { - Text( - text = stringResource(R.string.file_manager_search_truncated), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 2.dp), - ) - } +/** What the Files body shows: the browser itself, or why it can't be shown yet. */ +private enum class FilesGate { Disconnected, NeedsPairing, Ready } - // Remembers its last non-blank value so the shrink-out exit still has text. - var lastStatusText by remember { mutableStateOf(statusText) } - if (statusText.isNotBlank()) lastStatusText = statusText - AnimatedVisibility( - visible = statusText.isNotBlank(), - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), - ) { - Text( - text = lastStatusText, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 2.dp), - ) - } +// ── Phone side ──────────────────────────────────────────────────────────────── - AnimatedVisibility( - visible = isTransferring, - enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), - exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + - fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), - ) { - Column { - RemexLinearWavyProgress(progress = transferProgress, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) - OutlinedButton(onClick = vm::cancelLegacyTransfer, modifier = Modifier.fillMaxWidth(), shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { - Text(stringResource(R.string.file_transfer_cancel)) - } - } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PhoneContents( + vm: FileTransferViewModel, + hasFolders: Boolean, + rootLabel: String, + onAddFolder: () -> Unit, + onOpenFile: (PreviewTarget) -> Unit, + onSendToPc: (List) -> Unit, +) { + val state by vm.phoneBrowser.state.collectAsStateWithLifecycle() + val sortOption by vm.sortOption.collectAsStateWithLifecycle() + val viewMode by vm.viewMode.collectAsStateWithLifecycle() + val showHidden by vm.showHidden.collectAsStateWithLifecycle() + val statusText by vm.statusText.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val haptics = rememberRemexHaptics() + val padding = cardInnerPadding() + val openFailed = stringResource(R.string.files_open_failed) + + var query by remember { mutableStateOf("") } + var selection by remember(state.rootId, state.path) { mutableStateOf(emptySet()) } + var menuEntry by remember { mutableStateOf(null) } + val visible = remember(state.entries, sortOption, showHidden, query) { + FileManagerLogic.sortEntries(FileManagerLogic.visibleEntries(state.entries, showHidden), sortOption) + .filter { query.isBlank() || it.name.contains(query, ignoreCase = true) } + } + fun targetOf(entry: RemoteFileEntry) = vm.previewTargetFor(FileSide.Phone, entry) + + menuEntry?.let { entry -> + PhoneContextMenuSheet( + entry = entry, + onDismiss = { menuEntry = null }, + onPreview = { menuEntry = null; targetOf(entry)?.let(onOpenFile) }, + onSendToPc = { menuEntry = null; targetOf(entry)?.let { onSendToPc(listOf(it)) } }, + onOpenWith = { + menuEntry = null + val target = targetOf(entry) ?: return@PhoneContextMenuSheet + scope.launch { + val uri = runCatching { vm.phoneDocumentUri(target.rootId, target.path) }.getOrNull() + if (uri == null || !FileOpener.open(context, uri, target.name)) { + Toast.makeText(context, openFailed, Toast.LENGTH_SHORT).show() } + } + }, + ) + } - Box(modifier = Modifier.weight(1f)) { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = vm::refresh, - modifier = Modifier.fillMaxSize(), - ) { - // The four body states (loading / no roots / empty / content) cross-fade through - // AnimatedContent, keyed on kind + view mode. Entry-list changes deliberately do - // NOT re-key the region: rows animate individually via animateItem below - // (RemEx-598q reconciling RemEx-h9rd), so folder navigation, sorting, searching - // and renames animate per row while LIST/GRID and state-kind changes cross-fade. - val bodyState = when { - isLoading && displayedEntries.isEmpty() -> - FileManagerBodyState(FileManagerBodyState.Kind.Loading, viewMode) - remoteRoots.isEmpty() && volumes.isEmpty() -> - FileManagerBodyState(FileManagerBodyState.Kind.NoRoots, viewMode) - displayedEntries.isEmpty() -> - FileManagerBodyState(FileManagerBodyState.Kind.Empty, viewMode) - else -> FileManagerBodyState(FileManagerBodyState.Kind.Content, viewMode) - } - val effectsSpec = MaterialTheme.motionScheme.defaultEffectsSpec() - val itemPlacementSpec = MaterialTheme.motionScheme.fastSpatialSpec() - AnimatedContent( - targetState = bodyState, - transitionSpec = { fadeIn(effectsSpec) togetherWith fadeOut(effectsSpec) }, - modifier = Modifier.fillMaxSize(), - ) { state -> - when (state.kind) { - FileManagerBodyState.Kind.Loading -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - RemexLoadingIndicator(contained = true) - } - } - FileManagerBodyState.Kind.NoRoots -> { - CenteredMessage(stringResource(R.string.file_transfer_no_shared_folders)) - } - FileManagerBodyState.Kind.Empty -> { - // A folder holding only hidden items is not empty, and saying - // so would send the person looking for files that are there. - CenteredMessage( - when { - hiddenItemCount > 0 -> stringResource(R.string.file_manager_only_hidden_items) - searchActive -> stringResource(R.string.file_manager_no_results) - else -> stringResource(R.string.file_transfer_empty) - } - ) - } - FileManagerBodyState.Kind.Content -> if (state.viewMode == FileViewMode.GRID) { - LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 100.dp), state = fileGridState, modifier = Modifier.fillMaxSize()) { - items(displayedEntries, key = { it.relativePath ?: it.name }) { entry -> - FileManagerGridItem( - modifier = Modifier.animateItem( - fadeInSpec = effectsSpec, - placementSpec = itemPlacementSpec, - fadeOutSpec = effectsSpec, - ), - entry = entry, - isSelectionMode = isSelectionMode, - isSelected = entry.name in selectedEntryNames, - thumbnailBase64 = thumbnails[entry.relativePath ?: FileManagerLogic.combinePath(remotePath, entry.name)], - showOverflow = entry.name != FileManagerLogic.PARENT_ENTRY, - onRequestThumbnail = { vm.requestThumbnail(entry) }, - onTap = { - if (isSelectionMode) vm.toggleEntrySelection(entry) - else if (entry.isDirectory) vm.navigateInto(entry) - else vm.showProperties(entry) - }, - onLongPress = { startSelecting(entry) }, - onOverflow = { contextMenuEntry = entry }, - ) - } - } - } else { - LazyColumn(state = fileListState, modifier = Modifier.fillMaxSize()) { - items(displayedEntries, key = { it.relativePath ?: it.name }) { entry -> - FileManagerListItem( - modifier = Modifier.animateItem( - fadeInSpec = effectsSpec, - placementSpec = itemPlacementSpec, - fadeOutSpec = effectsSpec, - ), - entry = entry, - isSelectionMode = isSelectionMode, - isSelected = entry.name in selectedEntryNames, - thumbnailBase64 = thumbnails[entry.relativePath ?: FileManagerLogic.combinePath(remotePath, entry.name)], - showDownload = true, - showOverflow = entry.name != FileManagerLogic.PARENT_ENTRY, - onRequestThumbnail = { vm.requestThumbnail(entry) }, - onTap = { - if (isSelectionMode) vm.toggleEntrySelection(entry) - else vm.navigateInto(entry) - }, - onLongPress = { startSelecting(entry) }, - onDownload = { startDownload(entry) }, - onOverflow = { contextMenuEntry = entry }, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) - } - } - } - } - } + if (!hasFolders) { + NoPhoneFolders(onAddFolder) + return + } + Column(Modifier.fillMaxSize().padding(horizontal = padding)) { + FileManagerToolbar( + searchQuery = query, + onSearchChange = { query = it }, + sortOption = sortOption, + onSort = vm::setSort, + viewMode = viewMode, + onToggleViewMode = vm::toggleViewMode, + canWrite = false, + onNewFolder = {}, + onUpload = {}, + onUploadFolder = {}, + modifier = Modifier.padding(vertical = 4.dp), + showHidden = showHidden, + onShowHiddenChange = vm::setShowHidden, + primaryAction = ToolbarAction(Icons.Default.CreateNewFolder, stringResource(R.string.files_add_folder), onAddFolder), + ) + if (state.rootId != null) { + FileManagerBreadcrumbs( + crumbs = FileManagerLogic.buildBreadcrumbs(rootLabel.ifBlank { "/" }, if (state.path.isEmpty()) "/" else state.path), + onNavigate = { path -> vm.openLocation(FileSide.Phone, state.rootId!!, FileTreeModel.normalize(path)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) + } + AnimatedVisibility( + visible = selection.isNotEmpty(), + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + val sendLabel = stringResource(R.string.files_send_to_pc) + FileManagerSelectionBar( + selectedCount = selection.size, + canWrite = false, + canDelete = false, + onClose = { selection = emptySet() }, + onSelectAll = { selection = visible.filter { !it.isDirectory }.map { it.name }.toSet() }, + onCopy = {}, + onMove = {}, + onDelete = {}, + modifier = Modifier.padding(vertical = 4.dp), + acrossAction = ToolbarAction(Icons.Default.SwapHoriz, sendLabel) { + val items = visible.filter { it.name in selection }.mapNotNull(::targetOf) + selection = emptySet() + onSendToPc(items) + }, + ) + } + StatusLine(statusText) + Box(Modifier.weight(1f)) { + PullToRefreshBox(isRefreshing = state.loading && state.entries.isNotEmpty(), onRefresh = vm.phoneBrowser::refresh, modifier = Modifier.fillMaxSize()) { + val bodyState = when { + state.rootId == null -> FileManagerBodyState(FileManagerBodyState.Kind.PickFolder, viewMode) + state.loading && state.entries.isEmpty() -> FileManagerBodyState(FileManagerBodyState.Kind.Loading, viewMode) + state.failed -> FileManagerBodyState(FileManagerBodyState.Kind.Failed, viewMode) + visible.isEmpty() -> FileManagerBodyState(FileManagerBodyState.Kind.Empty, viewMode) + else -> FileManagerBodyState(FileManagerBodyState.Kind.Content, viewMode) + } + EntriesBody( + state = bodyState, + entries = visible, + emptyText = stringResource(if (query.isNotBlank()) R.string.file_manager_no_results else R.string.file_transfer_empty), + noRootsText = "", + pickFolderText = stringResource(R.string.files_pick_phone_folder), + failedText = stringResource(R.string.files_phone_folder_failed), + isSelectionMode = selection.isNotEmpty(), + isSelected = { it.name in selection }, + thumbnailBase64 = { null }, + thumbnail = { entry -> + val root = state.rootId + if (root == null || entry.isDirectory || !FileManagerLogic.isThumbnailCandidate(entry.name)) null + else rememberPhoneThumbnail(vm, root, FileManagerLogic.combinePath(state.path, entry.name), entry.modifiedUnixMs) + }, + onRequestThumbnail = {}, + onTap = { entry -> + when { + selection.isNotEmpty() -> if (!entry.isDirectory) selection = FileManagerLogic.toggleSelection(selection, entry.name) + entry.isDirectory -> vm.openLocation(FileSide.Phone, state.rootId!!, FileManagerLogic.combinePath(state.path, entry.name)) + else -> targetOf(entry)?.let(onOpenFile) } - } + }, + onLongPress = { entry -> + if (!entry.isDirectory) { + haptics.perform(RemexHapticEvent.LongPress) + selection = selection + entry.name + } + }, + onOverflow = { menuEntry = it }, + onDownload = null, + ) + } + } + } +} - FileManagerQueuePanel( - transfers = transferQueue, - onPause = vm::pauseTransfer, - onResume = vm::resumeTransfer, - onCancel = vm::cancelTransfer, - onCancelAll = vm::cancelAllTransfers, - onClearFinished = vm::clearFinishedTransfers, - ) +/** A phone image's thumbnail from the system's thumbnail cache, decoded off the main thread and kept across scrolls. */ +@Composable +private fun rememberPhoneThumbnail(vm: FileTransferViewModel, rootId: String, path: String, modifiedMs: Long): ImageBitmap? { + val context = LocalContext.current + // The modified time is part of the key, so a photo replaced under the same name gets a new thumbnail. + val key = "$rootId|$path|$modifiedMs" + PhoneThumbnails.get(key)?.let { return it } + val loaded by produceState(null, key) { + value = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(vm.phoneDocumentUri(rootId, path)) + context.contentResolver.loadThumbnail(uri, Size(PhoneThumbnails.SIZE_PX, PhoneThumbnails.SIZE_PX), null).asImageBitmap() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null // not an image the system can thumbnail, or gone: the row keeps its icon + } + }?.also { PhoneThumbnails.put(key, it) } + } + return loaded +} + +@Composable +private fun NoPhoneFolders(onAddFolder: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Icon(Icons.Default.PhoneAndroid, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp)) + Text(stringResource(R.string.files_no_phone_folders), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = onAddFolder, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Text(stringResource(R.string.files_add_folder)) + } + } +} - // Raised by the copy/move loop when the host reports a filename collision - // (RemEx-agpn). Hosted here rather than inside the queue panel because it - // belongs to the manage operation, which the panel knows nothing about. - conflictPrompt?.let { prompt -> - FileConflictSheet(prompt = prompt, onResolved = vm::onConflictResolved) +// ── Shared pieces ───────────────────────────────────────────────────────────── + +@Composable +private fun StatusLine(statusText: String) { + // Remembers its last non-blank value so the shrink-out exit still has text. + var lastStatusText by remember { mutableStateOf(statusText) } + if (statusText.isNotBlank()) lastStatusText = statusText + AnimatedVisibility( + visible = statusText.isNotBlank(), + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + Text( + text = lastStatusText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 2.dp), + ) + } +} + +/** The list or grid of one folder, on either device, with its loading, empty and error states. */ +@Composable +private fun EntriesBody( + state: FileManagerBodyState, + entries: List, + emptyText: String, + noRootsText: String, + pickFolderText: String, + isSelectionMode: Boolean, + isSelected: (RemoteFileEntry) -> Boolean, + thumbnailBase64: (RemoteFileEntry) -> String?, + thumbnail: @Composable (RemoteFileEntry) -> ImageBitmap?, + onRequestThumbnail: (RemoteFileEntry) -> Unit, + onTap: (RemoteFileEntry) -> Unit, + onLongPress: (RemoteFileEntry) -> Unit, + onOverflow: (RemoteFileEntry) -> Unit, + onDownload: ((RemoteFileEntry) -> Unit)?, + failedText: String = "", +) { + val effectsSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + val itemPlacementSpec = MaterialTheme.motionScheme.fastSpatialSpec() + val fileGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + val fileListState = androidx.compose.foundation.lazy.rememberLazyListState() + // Tapping Files while on Files goes back to the top of whichever view is showing (RemEx-pp4cm.3). + com.clindsay94.remex.ui.navigation.TabReselectEffect(com.clindsay94.remex.ui.navigation.Screen.FileTransfer) { + if (state.viewMode == FileViewMode.GRID) fileGridState.animateScrollToItem(0) + else fileListState.animateScrollToItem(0) + } + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn(effectsSpec) togetherWith fadeOut(effectsSpec) }, + modifier = Modifier.fillMaxSize(), + label = "file_body", + ) { shown -> + when (shown.kind) { + FileManagerBodyState.Kind.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RemexLoadingIndicator(contained = true) + } + FileManagerBodyState.Kind.NoRoots -> CenteredMessage(noRootsText) + FileManagerBodyState.Kind.PickFolder -> CenteredMessage(pickFolderText) + FileManagerBodyState.Kind.Failed -> CenteredMessage(failedText) + FileManagerBodyState.Kind.Empty -> CenteredMessage(emptyText) + FileManagerBodyState.Kind.Content -> if (shown.viewMode == FileViewMode.GRID) { + LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 100.dp), state = fileGridState, modifier = Modifier.fillMaxSize()) { + items(entries, key = ::rowKey) { entry -> + FileManagerGridItem( + modifier = Modifier.animateItem(fadeInSpec = effectsSpec, placementSpec = itemPlacementSpec, fadeOutSpec = effectsSpec), + entry = entry, + isSelectionMode = isSelectionMode, + isSelected = isSelected(entry), + thumbnailBase64 = thumbnailBase64(entry), + showOverflow = entry.name != FileManagerLogic.PARENT_ENTRY, + onRequestThumbnail = { onRequestThumbnail(entry) }, + onTap = { onTap(entry) }, + onLongPress = { onLongPress(entry) }, + onOverflow = { onOverflow(entry) }, + thumbnail = thumbnail(entry), + ) + } + } + } else { + LazyColumn(state = fileListState, modifier = Modifier.fillMaxSize()) { + items(entries, key = ::rowKey) { entry -> + FileManagerListItem( + modifier = Modifier.animateItem(fadeInSpec = effectsSpec, placementSpec = itemPlacementSpec, fadeOutSpec = effectsSpec), + entry = entry, + isSelectionMode = isSelectionMode, + isSelected = isSelected(entry), + thumbnailBase64 = thumbnailBase64(entry), + showDownload = onDownload != null, + showOverflow = entry.name != FileManagerLogic.PARENT_ENTRY, + onRequestThumbnail = { onRequestThumbnail(entry) }, + onTap = { onTap(entry) }, + onLongPress = { onLongPress(entry) }, + onDownload = { onDownload?.invoke(entry) }, + onOverflow = { onOverflow(entry) }, + thumbnail = thumbnail(entry), + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) } } } @@ -533,63 +1043,57 @@ fun FileTransferScreen( } } -/** What the Files body shows: the browser itself, or why it can't be shown yet. */ -private enum class FilesGate { Disconnected, NeedsPairing, Ready } +/** + * A row's list key. Phone rows carry their document id: a provider may list two files with one name, and two + * rows sharing a key crash the list. + */ +private fun rowKey(entry: RemoteFileEntry): String = entry.id ?: entry.relativePath ?: entry.name @Composable private fun DisconnectedContent(onNavigateToConnection: () -> Unit) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon( - Icons.Default.CloudOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) + Icon(Icons.Default.CloudOff, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp)) Text( text = stringResource(R.string.file_transfer_not_connected), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onNavigateToConnection, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { Text(stringResource(R.string.button_connect)) } + Button(onClick = onNavigateToConnection, shapes = rememberRemexButtonShapes(), contentPadding = ButtonDefaults.ContentPadding) { + Text(stringResource(R.string.button_connect)) + } } } } /** - * Key for the file-list region's AnimatedContent: state kind + view mode. The entry list is - * deliberately NOT part of the key — list changes animate per row via animateItem instead of - * cross-fading the whole region (RemEx-598q), and the exiting copy of a kind-change renders an - * empty list (invisible), so no stale-content artifact is possible. + * Key for the file-list region's AnimatedContent: state kind + view mode. The entry list is deliberately NOT part + * of the key — list changes animate per row via animateItem instead of cross-fading the whole region (RemEx-598q). */ -private data class FileManagerBodyState( - val kind: Kind, - val viewMode: FileViewMode, -) { - enum class Kind { Loading, NoRoots, Empty, Content } +private data class FileManagerBodyState(val kind: Kind, val viewMode: FileViewMode) { + enum class Kind { Loading, NoRoots, PickFolder, Failed, Empty, Content } } @Composable private fun CenteredMessage(message: String) { // Scrollable so pull-to-refresh still fires when the listing is empty. - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally) { Spacer(Modifier.size(96.dp)) - Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp)) } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ContextMenuSheet( +private fun PcContextMenuSheet( entry: RemoteFileEntry, canRename: Boolean, canDelete: Boolean, + supportsFolderTransfer: Boolean, onDismiss: () -> Unit, + onPreview: () -> Unit, + onSaveToPhone: () -> Unit, onDownload: () -> Unit, - supportsFolderTransfer: Boolean, onDownloadFolder: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, @@ -598,39 +1102,18 @@ private fun ContextMenuSheet( ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.padding(bottom = 24.dp)) { - Text( - text = entry.name, - style = MaterialTheme.typography.titleMediumEmphasized, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), - ) - HorizontalDivider() + SheetTitle(entry.name) if (!entry.isDirectory) { - DropdownMenuItem( - text = { Text(stringResource(R.string.file_transfer_download)) }, - leadingIcon = { Icon(Icons.Default.Download, null) }, - onClick = onDownload, - ) + DropdownMenuItem(text = { Text(stringResource(R.string.files_preview_title)) }, leadingIcon = { Icon(Icons.Default.Visibility, null) }, onClick = onPreview) + DropdownMenuItem(text = { Text(stringResource(R.string.files_save_to_phone)) }, leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, onClick = onSaveToPhone) + DropdownMenuItem(text = { Text(stringResource(R.string.files_download_to)) }, leadingIcon = { Icon(Icons.Default.Download, null) }, onClick = onDownload) } if (entry.isDirectory && entry.name != FileManagerLogic.PARENT_ENTRY && supportsFolderTransfer) { - DropdownMenuItem( - text = { Text(stringResource(R.string.file_manager_download_folder)) }, - leadingIcon = { Icon(Icons.Default.FolderZip, null) }, - onClick = onDownloadFolder, - ) + DropdownMenuItem(text = { Text(stringResource(R.string.file_manager_download_folder)) }, leadingIcon = { Icon(Icons.Default.FolderZip, null) }, onClick = onDownloadFolder) } - DropdownMenuItem( - text = { Text(stringResource(R.string.file_manager_properties)) }, - leadingIcon = { Icon(Icons.Default.Info, null) }, - onClick = onProperties, - ) + DropdownMenuItem(text = { Text(stringResource(R.string.file_manager_properties)) }, leadingIcon = { Icon(Icons.Default.Info, null) }, onClick = onProperties) if (canRename) { - DropdownMenuItem( - text = { Text(stringResource(R.string.file_transfer_rename)) }, - leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) }, - onClick = onRename, - ) + DropdownMenuItem(text = { Text(stringResource(R.string.file_transfer_rename)) }, leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) }, onClick = onRename) } if (canDelete) { DropdownMenuItem( @@ -640,12 +1123,49 @@ private fun ContextMenuSheet( ) } if (entry.isDirectory && entry.name != FileManagerLogic.PARENT_ENTRY) { - DropdownMenuItem( - text = { Text(stringResource(R.string.file_transfer_pin_folder)) }, - leadingIcon = { Icon(Icons.Default.PushPin, null) }, - onClick = onPin, + DropdownMenuItem(text = { Text(stringResource(R.string.file_transfer_pin_folder)) }, leadingIcon = { Icon(Icons.Default.PushPin, null) }, onClick = onPin) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PhoneContextMenuSheet( + entry: RemoteFileEntry, + onDismiss: () -> Unit, + onPreview: () -> Unit, + onSendToPc: () -> Unit, + onOpenWith: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + SheetTitle(entry.name) + if (!entry.isDirectory) { + DropdownMenuItem(text = { Text(stringResource(R.string.files_preview_title)) }, leadingIcon = { Icon(Icons.Default.Visibility, null) }, onClick = onPreview) + DropdownMenuItem(text = { Text(stringResource(R.string.files_send_to_pc)) }, leadingIcon = { Icon(Icons.Default.SwapHoriz, null) }, onClick = onSendToPc) + DropdownMenuItem(text = { Text(stringResource(R.string.files_open_with)) }, leadingIcon = { Icon(Icons.AutoMirrored.Filled.OpenInNew, null) }, onClick = onOpenWith) + } else { + Text( + stringResource(R.string.files_folders_not_sent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), ) } } } } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SheetTitle(name: String) { + Text( + text = name, + style = MaterialTheme.typography.titleMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) + HorizontalDivider() +} 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 09362cde6..af098d8f5 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,23 @@ 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.data.PhoneBookmarksRepository +import com.clindsay94.remex.data.PhoneFolder +import com.clindsay94.remex.ui.files.FilePreviewModel +import com.clindsay94.remex.ui.files.FileSide +import com.clindsay94.remex.ui.files.FileTreeModel +import com.clindsay94.remex.ui.files.LocalDocuments +import com.clindsay94.remex.ui.files.LocalPhoneFileSource +import com.clindsay94.remex.ui.files.PhoneBrowserModel +import com.clindsay94.remex.ui.files.PhoneFileSource +import com.clindsay94.remex.ui.files.PreviewImageDecoder +import com.clindsay94.remex.ui.files.PreviewSources +import com.clindsay94.remex.ui.files.PreviewTarget +import com.clindsay94.remex.ui.files.TreeRootInfo +import com.clindsay94.remex.ui.files.preview.RangeReader +import androidx.compose.ui.graphics.ImageBitmap +import kotlinx.coroutines.flow.combine import com.clindsay94.remex.service.TransferProgressFormat import com.clindsay94.remex.service.TransferProgressText import com.clindsay94.remex.service.TransferRateEstimator @@ -79,6 +96,8 @@ data class RemoteFileEntry( val modifiedUnixMs: Long = 0L, /** Full path relative to the root — set for search hits, null for ordinary current-folder rows. */ val relativePath: String? = null, + /** A phone file's document id (file browser redesign): its row key, since names need not be unique there. */ + val id: String? = null, ) data class RemoteSharedRoot( @@ -269,6 +288,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. @@ -319,21 +352,24 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica fun selectRoot(rootId: String) { if (_selectedRootId.value == rootId) return - _selectedRootId.value = rootId - // A new root has no "previous folder" to stay in; the old root's path means nothing here. - _remotePath.value = "/" - clearSelection() - clearSearchInternal() + switchRoot(rootId) browseRemote("/") } - /** Selects a full-browse volume as the active browsing root (its id doubles as a root id). */ - fun selectVolume(volume: RemoteVolume) { - _selectedRootId.value = volume.id + /** + * Makes [rootId] the PC folder being shown, before its listing arrives. The old folder's rows are cleared + * here, not when the reply lands: they would otherwise stay on screen under the NEW root (for good, if the + * browse fails), and a tap, rename or delete on one would act on a same-named file in the new folder. + */ + private fun switchRoot(rootId: String) { + _selectedRootId.value = rootId + // A new root has no "previous folder" to stay in; the old root's path means nothing here. _remotePath.value = "/" clearSelection() clearSearchInternal() - browseRemote("/") + _rawEntries.value = emptyList() + _thumbnails.value = emptyMap() + recomputeDisplayed() } fun browseRemote(path: String = _remotePath.value) { @@ -1571,6 +1607,279 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica return requests.size } + // ── Explorer: tree, This phone, preview (file browser redesign, 2026-10-08) ───── + // + // The state lives in holders under ui/files (FileTreeModel, PhoneBrowserModel, FilePreviewModel, + // PhoneBookmarksRepository); this section only wires them to the PC side above and to the engine. + + private val phoneFiles: PhoneFileSource = LocalPhoneFileSource(LocalDocuments(application.contentResolver)) + private val bookmarks = PhoneBookmarksRepository.create(settingsManager, application.contentResolver) + + /** "This phone": folders shared with the PC, then phone-only bookmarks. */ + val phoneFolders: StateFlow> = + bookmarks.folders.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + // Folder names for the phone's tree URIs, read once per folder (a DocumentFile lookup is an IPC). + private val _phoneFolderNames = MutableStateFlow>(emptyMap()) + val phoneFolderNames = _phoneFolderNames.asStateFlow() + + val tree = FileTreeModel(viewModelScope) { side, rootId, path -> + when (side) { + FileSide.Phone -> phoneFiles.list(rootId, path).filter { it.isDirectory }.map { it.name } + FileSide.Pc -> pcFileRequests.browse(rootId, path).filter { it.isDirectory }.map { it.name } + } + } + + val phoneBrowser = PhoneBrowserModel(viewModelScope, phoneFiles) + + private val _side = MutableStateFlow(FileSide.Pc) + /** Which device the contents pane is showing. */ + val side = _side.asStateFlow() + + // The last folder looked at on each device: where "Send to PC…" and "Verify against…" start. + private val lastFolder = ConcurrentHashMap>() + + val preview = FilePreviewModel( + viewModelScope, + object : PreviewSources { + override fun reader(target: PreviewTarget): RangeReader = when (target.side) { + FileSide.Phone -> phoneFiles.reader(target.rootId, target.path) + FileSide.Pc -> pcFileRequests.reader(target.rootId, target.path) + } + + override suspend fun hashBase64(target: PreviewTarget): String = when (target.side) { + FileSide.Phone -> java.util.Base64.getEncoder().encodeToString(phoneFiles.sha256(target.rootId, target.path)) + FileSide.Pc -> pcFileRequests.hash(target.rootId, target.path) + } + + // An older PC never answers these requests: asking would only spin for 30 seconds (spec §2.4). + override fun canRead(side: FileSide) = side == FileSide.Phone || caps()?.readRange == true + override fun canHash(side: FileSide) = side == FileSide.Phone || caps()?.hash == true + + override suspend fun decodeImage(bytes: ByteArray): ImageBitmap? = withContext(Dispatchers.Default) { + PreviewImageDecoder.decode(bytes, app().resources.displayMetrics) + } + }, + ) + + init { + viewModelScope.launch { + combine(_remoteRoots, _volumes) { roots, volumes -> + roots.map { TreeRootInfo(it.rootId, it.displayName, isWritable = it.isWritable) } + + volumes.map { TreeRootInfo(it.id, it.label, isVolume = true) } + }.collect { tree.setRoots(FileSide.Pc, it) } + } + viewModelScope.launch { + combine(phoneFolders, _phoneFolderNames) { folders, names -> + folders.map { + TreeRootInfo(it.uri, names[it.uri] ?: "", isShared = it.isShared, isBookmark = it.isBookmark) + } + }.collect { tree.setRoots(FileSide.Phone, it) } + } + viewModelScope.launch { + phoneFolders.collect { folders -> + val known = _phoneFolderNames.value + val missing = folders.map { it.uri }.filter { it !in known } + if (missing.isNotEmpty()) { + val resolved = withContext(Dispatchers.IO) { + missing.associateWith { uri -> + runCatching { DocumentFile.fromTreeUri(app(), Uri.parse(uri))?.name }.getOrNull() + ?: treeUriFallbackLabel(uri) + } + } + _phoneFolderNames.value = _phoneFolderNames.value + resolved + } + // A folder taken off the list while open leaves nothing to show. + val open = phoneBrowser.state.value.rootId + if (open != null && folders.none { it.uri == open }) phoneBrowser.clear() + } + } + viewModelScope.launch { + RemexClientManager.isConnected.collect { connected -> + tree.setAvailable(FileSide.Pc, connected) + if (!connected) pcFileRequests.failAll("Disconnected") + } + } + // The tree follows browsing on either side, so the open folder is always visible in it. + viewModelScope.launch { + combine(_selectedRootId, _remotePath) { root, path -> root to path }.collect { (root, path) -> + if (root.isNullOrBlank()) return@collect + lastFolder[FileSide.Pc] = root to FileTreeModel.normalize(path) + tree.reveal(FileSide.Pc, root, path) + } + } + viewModelScope.launch { + phoneBrowser.state.collect { state -> + val root = state.rootId ?: return@collect + if (state.loading) return@collect + lastFolder[FileSide.Phone] = root to state.path + if (!state.failed) { + tree.setChildren(FileSide.Phone, root, state.path, state.entries.filter { it.isDirectory }.map { it.name }) + tree.reveal(FileSide.Phone, root, state.path) + } + } + } + } + + /** Shows a folder from the tree, a breadcrumb or a picker in the contents pane. */ + fun openLocation(side: FileSide, rootId: String, path: String) { + _side.value = side + preview.show(null) + when (side) { + FileSide.Phone -> phoneBrowser.open(rootId, path) + FileSide.Pc -> { + val normalized = FileTreeModel.normalize(path) + val pcPath = if (normalized.isEmpty()) "/" else normalized + if (_selectedRootId.value != rootId) switchRoot(rootId) + clearSearchInternal() + browseRemote(pcPath) + } + } + } + + /** Switches the contents pane to a device without changing what each side has open. */ + fun showSide(side: FileSide) { + if (_side.value == side) return + _side.value = side + preview.show(null) + if (side == FileSide.Phone && phoneBrowser.state.value.rootId == null) { + phoneFolders.value.firstOrNull()?.let { phoneBrowser.open(it.uri, "") } + } + } + + /** The last folder seen on [side], as (root id, path), or null before anything was opened there. */ + fun lastFolderOn(side: FileSide): Pair? = lastFolder[side] + + /** A preview target for an entry in the folder currently shown on [side]. */ + fun previewTargetFor(side: FileSide, entry: RemoteFileEntry): PreviewTarget? { + val (rootId, folder) = when (side) { + FileSide.Phone -> phoneBrowser.state.value.let { (it.rootId ?: return null) to it.path } + FileSide.Pc -> (_selectedRootId.value ?: return null) to FileTreeModel.normalize(_remotePath.value) + } + val path = entry.relativePath ?: FileManagerLogic.combinePath(folder, entry.name) + return PreviewTarget(side, rootId, FileTreeModel.normalize(path), entry.name, entry.isDirectory, entry.sizeBytes, entry.modifiedUnixMs) + } + + /** The items of one folder on either device, for the cross-device pickers. */ + suspend fun listForPicker(side: FileSide, rootId: String, path: String): List = when (side) { + FileSide.Phone -> phoneFiles.list(rootId, path).map { RemoteFileEntry(it.name, it.isDirectory, maxOf(0L, it.sizeBytes), it.modifiedMs, id = it.documentId) } + FileSide.Pc -> pcFileRequests.browse(rootId, path) + } + + // ── Phone folders ── + + /** A folder the person just picked with the system folder picker (its grant already taken) becomes a bookmark. */ + fun addPhoneBookmark(treeUri: Uri) { + viewModelScope.launch { + bookmarks.addBookmark(treeUri.toString()) + openLocation(FileSide.Phone, treeUri.toString(), "") + } + } + + /** "Share with PC" on a phone folder: adds it to, or takes it off, the "Access from your PC" list. */ + fun setPhoneFolderShared(uri: String, shared: Boolean) { + viewModelScope.launch { bookmarks.setShared(uri, shared) } + } + + fun removePhoneBookmark(uri: String) { + viewModelScope.launch { bookmarks.removeBookmark(uri) } + } + + /** A content URI for a phone file, to open it in another app. */ + suspend fun phoneDocumentUri(rootId: String, path: String): String = phoneFiles.documentUri(rootId, path) + + // ── Between devices ── + + /** + * Queues phone files to go to `pcRootId/pcFolder` on the PC, through the same engine as every upload. + * Folders aren't sent this way; the status line says so. + */ + fun sendToPc(items: List, pcRootId: String, pcFolder: String) { + if (caps()?.binary != true) { + _statusText.value = app().getString(R.string.files_needs_newer_pc) + return + } + val files = items.filter { !it.isDirectory && it.side == FileSide.Phone } + if (files.isEmpty()) { + _statusText.value = app().getString(R.string.files_folders_not_sent) + return + } + viewModelScope.launch { + var queued = 0 + for (file in files) { + val uri = runCatching { phoneFiles.documentUri(file.rootId, file.path) }.getOrNull() ?: continue + FileTransferEngine.enqueueUpload( + localUri = uri, + fileName = file.name, + size = file.sizeBytes, + destRoot = pcRootId, + destRelativePath = FileTreeModel.normalize(pcFolder), + ) + queued++ + } + if (queued > 0) FileTransferJobService.schedule(app()) + _statusText.value = when { + queued < files.size -> app().getString(R.string.files_some_not_queued) + files.size < items.size -> app().getString(R.string.files_folders_not_sent) + else -> app().getString(R.string.files_queued_to_pc) + } + } + } + + /** + * Queues PC files to be saved in `phoneRootUri/phoneFolder`. Each gets a new document there first (the folder's + * app picks a free name, never replacing a file), then the engine downloads into it. + */ + fun saveToPhone(items: List, phoneRootUri: String, phoneFolder: String) { + if (caps()?.binary != true) { + _statusText.value = app().getString(R.string.files_needs_newer_pc) + return + } + val files = items.filter { !it.isDirectory && it.side == FileSide.Pc } + if (files.isEmpty()) { + _statusText.value = app().getString(R.string.files_folders_not_sent) + return + } + viewModelScope.launch { + val parent = runCatching { Uri.parse(phoneFiles.documentUri(phoneRootUri, phoneFolder)) }.getOrNull() + if (parent == null) { + _statusText.value = app().getString(R.string.files_save_failed) + return@launch + } + val requests = withContext(Dispatchers.IO) { + files.mapNotNull { file -> + val mime = android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(file.name.substringAfterLast('.', "").lowercase()) ?: "application/octet-stream" + val created = runCatching { + DocumentsContract.createDocument(app().contentResolver, parent, mime, file.name) + }.getOrNull() ?: return@mapNotNull null + FileTransferEngine.DownloadRequest(created.toString(), file.name, file.sizeBytes, file.rootId, file.path) + } + } + if (requests.isNotEmpty()) { + FileTransferEngine.enqueueDownloads(requests) + FileTransferJobService.schedule(app()) + } + _statusText.value = when { + requests.isEmpty() -> app().getString(R.string.files_save_failed) + requests.size < files.size -> app().getString(R.string.files_some_not_queued) + files.size < items.size -> app().getString(R.string.files_folders_not_sent) + else -> app().getString(R.string.files_queued_to_phone) + } + phoneBrowser.refresh() + } + } + + /** A name for a phone folder whose provider won't say: the last part of its tree URI, as Settings shows it. */ + private fun treeUriFallbackLabel(uri: String): String = + Uri.parse(uri).lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')?.takeIf { it.isNotBlank() } ?: uri + + override fun onCleared() { + preview.close() + pcFileRequests.failAll("Closed") + super.onCleared() + } + // ── v3 queue controls ───────────────────────────────────────────────────── fun pauseTransfer(id: String) = FileTransferEngine.pause(id) @@ -1727,6 +2036,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) @@ -1784,6 +2094,8 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica ops = ops, fullBrowse = obj.optBoolean("fullBrowse", false), push = obj.optBoolean("push", false), + readRange = obj.optBoolean("readRange", false), + hash = obj.optBoolean("hash", false), ) } @@ -1836,6 +2148,11 @@ class FileTransferViewModel(application: Application) : AndroidViewModel(applica } entries.addAll(parseEntries(response.optJSONArray("entries"))) _rawEntries.value = entries + // The listing is the freshest word on this folder's sub-folders: the tree takes it as is, so a folder + // made, renamed or deleted (here or on the PC) shows there without a second request. + _selectedRootId.value?.let { root -> + tree.setChildren(FileSide.Pc, root, committed, entries.filter { it.isDirectory && it.name != FileManagerLogic.PARENT_ENTRY }.map { it.name }) + } requestedThumbnails.clear() // Perf audit P0-21: drop thumbnails for paths not in the new listing - keeps the guard-noted // requestedThumbnails dedup invariant intact (cleared just above, same as before this row) 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 4341de555..47c39e3ca 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 @@ -475,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 @@ -640,9 +637,6 @@ Transferencias de archivos de RemEx Sincronizando archivos con tu PC… - Acceso rápido - Este PC - %1$s • %2$s libres Buscar Buscar en esta carpeta… Cerrar búsqueda @@ -707,6 +701,67 @@ En pausa • %1$s Verificando… Completada + 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 e772aa1df..2ff4ab265 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 @@ -475,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 @@ -640,9 +637,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 @@ -707,6 +701,67 @@ En pause • %1$s Vérification… Terminé + 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 01dbd5363..213c877c4 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 डाउनलोड नहीं हो सका @@ -475,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 में नया क्या है हाल का सेटिंग्स बंद करें @@ -640,9 +637,6 @@ RemEx फ़ाइल स्थानांतरण आपके पीसी के साथ फ़ाइलें सिंक हो रही हैं… - त्वरित पहुँच - यह PC - %1$s • %2$s खाली खोजें इस फ़ोल्डर में खोजें… खोज बंद करें @@ -707,6 +701,67 @@ रोका गया • %1$s सत्यापित हो रहा है… पूर्ण + सत्यापित + 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 9fd45ff3c..a8e113114 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 @@ -468,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 @@ -633,9 +630,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 @@ -700,6 +694,67 @@ Dijeda • %1$s Memverifikasi… Selesai + 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 5de999171..5cf2b25a3 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 @@ -468,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 @@ -633,9 +630,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 @@ -700,6 +694,67 @@ Wstrzymano • %1$s Weryfikowanie… Gotowe + 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 caeb32b27..ccfc0d7a6 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 @@ -475,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 @@ -640,9 +637,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 @@ -707,6 +701,67 @@ Pausado • %1$s Verificando… Concluído + 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 7ea8d0eec..49215237e 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 @@ -475,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 @@ -640,9 +637,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 @@ -707,6 +701,67 @@ Duraklatıldı • %1$s Doğrulanıyor… Tamamlandı + 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 625c3c804..dc80dd506 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 @@ -489,24 +490,20 @@ Ваш ПК Невідомо Переглянути вихідний код на GitHub - Рутини - Тепер телефон може діяти сам, коли ви приходите додому чи йдете, торкаєтеся NFC-мітки або датчик ПК перетинає поріг. - Рутини на вашому ПК - Рутини можуть працювати й на самому ПК, наприклад присипляти його, коли він простоює. Перед вимкненням чи перезапуском іде відлік, щоб ви встигли його скасувати. - Новий макет на телефоні - Тепер у додатку п’ять вкладок. Головна показує ваш ПК і ті самі закріплені датчики, що й на ПК, а довге натискання дає змогу змінити сітку Датчиків. - Однаковий вигляд в обох застосунках - Телефон і ПК тепер використовують ті самі слова, шрифти, картки й кольори. Нове встановлення починається з кольорів RemEx. - Виправлення передавання файлів - Завантаження теки, в будь-який бік, більше не пропускає й не повторює файли. Файли не показують прихованих і системних елементів, доки ви не ввімкнете «Показувати приховані елементи». ПК тепер також може переглядати спільні теки вашого телефону. - Нова заставка - Під час відкриття заставка тепер показує, як RemEx справді запускається й підключається, а не відтворює фіксовану анімацію. - Оповіщення датчиків на телефоні - Оповіщення, які ви створюєте на ПК, тепер приходять на телефон як сповіщення. Додавати, змінювати й вилучати їх можна з будь-якого з двох пристроїв. - Журнали та діагностика ПК на телефоні - У розділі «Більше» нова сторінка показує, що записав ваш ПК, і ті самі перевірки стану, що й на ПК. Вона лише для читання. - Більше способів персоналізувати телефон - Виберіть тло, більше форм карток і сім шрифтів Nerd Fonts та задайте ширину сітки Датчиків. + Файли в новому вигляді + Телефон і ПК тепер в одному дереві папок в обох застосунках. Відкривайте папки рівень за рівнем, а дерево стежить за вами під час перегляду. + Живий перегляд + Виберіть файл, щоб одразу його побачити: фото в повній якості з масштабуванням, а текст, код і журнали в кольорі. Увімкніть «Наживо», щоб стежити за журналом, поки він росте. + Відбитки SHA-256 + Тепер передавання показує «Перевірено», коли його відбиток перевірено. Скопіюйте відбиток файлу, порівняйте його зі вставленим або перевірте з копією на іншому пристрої. + Надсилайте файли між пристроями + Надсилайте файли з телефона в папку на ПК або зберігайте файли з ПК у папку на телефоні. На ПК також можна перетягувати файли з одного пристрою на інший. + Папки телефона на ваш вибір + Додайте папки з телефона, щоб переглядати їх у RemEx. ПК бачить папку, лише якщо ви ввімкнете «Поділитися з ПК». + Простіша панель інструментів + Кожен екран файлів тепер має одну головну кнопку, а решта в меню «Більше параметрів». Торкання файлу в списку відкриває його перегляд. + Скануйте, щоб отримати застосунок для телефона + На головній сторінці ПК картка «Отримати застосунок для телефону» тепер показує QR-код. Наведіть на нього камеру телефона, щоб відкрити RemEx у Google Play. Що нового у версії %1$s Нещодавні Закрити налаштування @@ -654,9 +651,6 @@ Передавання файлів RemEx Синхронізація файлів із ПК… - Швидкий доступ - Цей ПК - %1$s • %2$s вільно Пошук Пошук у цій папці… Закрити пошук @@ -721,6 +715,67 @@ Призупинено • %1$s Перевірка… Готово + Перевірено + Копіювати відбиток 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 26b311a62..bc95fe0c7 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 @@ -596,6 +592,7 @@ %1$d hours left Transfer complete + Transfer verified Uploaded %1$s Could not send %1$s Could not download %1$s @@ -819,9 +816,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 @@ -887,6 +881,67 @@ Paused • %1$s Verifying… Done + 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 new file mode 100644 index 000000000..b63afab03 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/data/PhoneBookmarksRepositoryTest.kt @@ -0,0 +1,125 @@ +package com.clindsay94.remex.data + +import java.io.File +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()) + } + + /** + * 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( + 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/FileHostHandlerTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/service/FileHostHandlerTest.kt index ed15959b0..4c61be690 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 000000000..b4100c621 --- /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.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 000000000..fc83f8a50 --- /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/FilePreviewModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewModelTest.kt new file mode 100644 index 000000000..fa8602469 --- /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/FilePreviewVectorsTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FilePreviewVectorsTest.kt new file mode 100644 index 000000000..f5b07bc29 --- /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/FileTreeModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/FileTreeModelTest.kt new file mode 100644 index 000000000..ba6301b58 --- /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/LocalDocumentLogicTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/LocalDocumentLogicTest.kt new file mode 100644 index 000000000..858deb824 --- /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 000000000..7f6ae97d5 --- /dev/null +++ b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PcFileRequestsTest.kt @@ -0,0 +1,149 @@ +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 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") } } + 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/PhoneBrowserModelTest.kt b/remex.android/app/src/test/java/com/clindsay94/remex/ui/files/PhoneBrowserModelTest.kt new file mode 100644 index 000000000..e901937bc --- /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) + } +} 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 000000000..a25922a63 --- /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 000000000..ec2e67087 --- /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 "; + var spans = SyntaxTokenizer.Tokenize(line, SyntaxLanguage.Xml); + spans.Should().Contain(s => s.Kind == SyntaxKind.Key && line.Substring(s.Start, s.Length) == "Button"); + spans.Should().Contain(s => s.Kind == SyntaxKind.String && line.Substring(s.Start, s.Length) == "\"Go\""); + spans.Should().Contain(s => s.Kind == SyntaxKind.Comment && line.Substring(s.Start, s.Length) == ""); + } + + [Fact] + public void Spans_AreInOrder_NonOverlapping_AndInsideTheLine() + { + foreach (var (line, lang) in new[] + { + ("{\"a\": [1, 2, \"x\\\"y\"], \"b\": null}", SyntaxLanguage.Json), + ("# comment with \"quote", SyntaxLanguage.Hash), + ("x = 'unterminated", SyntaxLanguage.Hash), + ("t", SyntaxLanguage.Xml), + }) + { + var spans = SyntaxTokenizer.Tokenize(line, lang); + spans.Should().NotBeEmpty(line); // anti-vacuity + var end = 0; + foreach (var s in spans) + { + s.Start.Should().BeGreaterThanOrEqualTo(end, line); + (s.Start + s.Length).Should().BeLessThanOrEqualTo(line.Length, line); + end = s.Start + s.Length; + } + } + } + + // ── Image loader ── + + [Fact] + public async Task AnImage_IsFetchedWhole_InOneMegabyteReads_WithProgress() + { + var file = new FakeFile { Bytes = Enumerable.Range(0, 2_500_000).Select(i => (byte)i).ToArray() }; + var reported = new List(); + + var bytes = await ImagePreviewLoader.LoadAsync(file.Read, new SyncProgress(reported.Add), CancellationToken.None); + + bytes.Should().Equal(file.Bytes); + file.Reads.Select(r => r.Offset).Should().Equal(0L, 1_048_576L, 2_097_152L); + reported.Last().Should().Be(1); + } + + [Fact] + public async Task AnImageOverTheLimit_IsRefusedAfterOneRead() + { + var file = new FakeFile { Bytes = new byte[3_000_000] }; + + var act = () => ImagePreviewLoader.LoadAsync(file.Read, null, CancellationToken.None, maxBytes: 2_000_000); + + (await act.Should().ThrowAsync()).Which.FileSize.Should().Be(3_000_000); + file.Reads.Should().HaveCount(1, "nothing past the first read may be fetched for a file that will be refused"); + } + + [Fact] + public async Task AHostThatStopsSending_FailsTheImage_RatherThanLoopingForever() + { + var file = new FakeFile { Bytes = new byte[2_500_000], StallAfterReads = 1 }; + + var act = () => ImagePreviewLoader.LoadAsync(file.Read, null, CancellationToken.None); + + await act.Should().ThrowAsync(); + file.Reads.Should().HaveCountLessThan(5); + } + + // ── Text head ── + + [Fact] + public async Task TextHead_ReadsUpToTheLimit_AndSaysWhenItStopped() + { + var file = new FakeFile { Bytes = Encoding.UTF8.GetBytes(new string('x', 3_000_000)) }; + + var preview = await TextPreviewLoader.LoadHeadAsync(file.Read, CancellationToken.None); + + preview.IsBinary.Should().BeFalse(); + preview.Truncated.Should().BeTrue(); + preview.Text.Length.Should().Be(FileTransferLimits.PreviewTextMaxBytes); + preview.FileSize.Should().Be(3_000_000); + } + + [Fact] + public async Task ABinaryFileWithATextName_CostsOneRead_AndIsSaidToBeBinary() + { + var file = new FakeFile { Bytes = new byte[3_000_000] }; + + var preview = await TextPreviewLoader.LoadHeadAsync(file.Read, CancellationToken.None); + + preview.IsBinary.Should().BeTrue(); + file.Reads.Should().HaveCount(1); + } + + // ── Live tail ── + + [Fact] + public async Task LiveTail_StartsAtTheEnd_ThenAppendsOnlyWhatWasAdded() + { + var file = new FakeFile { Bytes = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Range(1, 100).Select(i => $"line {i}\n"))) }; + var tail = new TextTail(file.Read, tailBytes: 40); + + var start = await tail.StartAsync(CancellationToken.None); + start.Reset.Should().BeTrue(); + start.Lines.Should().NotBeEmpty().And.EndWith("line 100"); + start.Lines.First().Should().StartWith("line ", "the partial first line of a mid-file tail is dropped"); + + file.Bytes = [.. file.Bytes, .. "line 101\nline 1"u8]; + var update = await tail.PollAsync(CancellationToken.None); + update.Reset.Should().BeFalse(); + update.Lines.Should().Equal("line 101"); + update.PendingLine.Should().Be("line 1", "a line still being written is shown, not held back"); + + file.Bytes = [.. file.Bytes, .. "02\n"u8]; + (await tail.PollAsync(CancellationToken.None)).Lines.Should().Equal("line 102"); + } + + [Fact] + public async Task LiveTail_RestartsWhenTheFileShrinks() + { + var file = new FakeFile { Bytes = "old one\nold two\n"u8.ToArray() }; + var tail = new TextTail(file.Read); + await tail.StartAsync(CancellationToken.None); + + file.Bytes = "new\n"u8.ToArray(); // rotated + var update = await tail.PollAsync(CancellationToken.None); + + update.Reset.Should().BeTrue(); + update.Lines.Should().Equal("new"); + } + + [Fact] + public async Task LiveTail_ACharacterSplitAcrossTwoPolls_JoinsUp() + { + var file = new FakeFile { Bytes = "a\n"u8.ToArray() }; + var tail = new TextTail(file.Read); + await tail.StartAsync(CancellationToken.None); + var euro = "€\n"u8.ToArray(); + + file.Bytes = [.. file.Bytes, euro[0], euro[1]]; + (await tail.PollAsync(CancellationToken.None)).PendingLine.Should().BeEmpty("half a character is not shown"); + + file.Bytes = [.. file.Bytes, euro[2], euro[3]]; + (await tail.PollAsync(CancellationToken.None)).Lines.Should().Equal("€"); + } + + [Fact] + public async Task LiveTail_ALineThatNeverEnds_DoesNotGrowWithoutBound() + { + var file = new FakeFile { Bytes = "x\n"u8.ToArray() }; + var tail = new TextTail(file.Read, tailBytes: 64); + await tail.StartAsync(CancellationToken.None); + + for (var i = 0; i < 20; i++) + { + file.Bytes = [.. file.Bytes, .. Encoding.ASCII.GetBytes(new string('y', 50))]; + var update = await tail.PollAsync(CancellationToken.None); + update.PendingLine.Length.Should().BeLessThanOrEqualTo(64); + } + } + + private sealed class SyncProgress(Action report) : IProgress + { + public void Report(double value) => report(value); + } +} diff --git a/remex.desktop.tests/Services/FilePreviewVectorTests.cs b/remex.desktop.tests/Services/FilePreviewVectorTests.cs new file mode 100644 index 000000000..191569f7d --- /dev/null +++ b/remex.desktop.tests/Services/FilePreviewVectorTests.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text.Json; +using FluentAssertions; +using Remex.Desktop.Services.FilePreview; +using Xunit; + +namespace Remex.Desktop.Tests.Services; + +/// +/// The PC half of the preview parity check (file browser redesign, 2026-10-08). file-preview-vectors.json +/// holds what the classifier, the decoder and the tokenizer answer for a set of inputs; the phone's Kotlin twins +/// assert against the same file (FilePreviewVectorsTest), and the Android copy must stay byte-identical, +/// so a rule changed on one platform and not the other fails here or there. +/// +public sealed class FilePreviewVectorTests +{ + private static string RepoRoot([CallerFilePath] string thisSourceFile = "") + => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisSourceFile)!, "..", "..")); + + private static string FixturePath() => Path.Combine(RepoRoot(), "remex.desktop.tests", "Fixtures", "file-preview-vectors.json"); + + private static JsonElement Section(string name) + { + var doc = JsonDocument.Parse(File.ReadAllBytes(FixturePath())); + var section = doc.RootElement.GetProperty(name); + section.GetArrayLength().Should().BeGreaterThan(3, "an empty section would pass vacuously"); + return section; + } + + [Fact] + public void TheAndroidCopyIsByteIdentical() + { + var android = Path.Combine(RepoRoot(), "remex.android", "app", "src", "test", "resources", "file-preview-vectors.json"); + File.Exists(android).Should().BeTrue(android); + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(android))) + .Should().Be(Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(FixturePath())))); + } + + [Fact] + public void TheClassifierMatchesTheVectors() + { + foreach (var v in Section("classify").EnumerateArray()) + PreviewClassifier.Classify(v.GetProperty("name").GetString()!, v.GetProperty("isDirectory").GetBoolean()).ToString() + .Should().Be(v.GetProperty("kind").GetString(), v.GetProperty("name").GetString()); + } + + [Fact] + public void TheLanguagesMatchTheVectors() + { + foreach (var v in Section("languages").EnumerateArray()) + SyntaxTokenizer.LanguageFor(v.GetProperty("name").GetString()!).ToString() + .Should().Be(v.GetProperty("language").GetString(), v.GetProperty("name").GetString()); + } + + [Fact] + public void TheTokenizerMatchesTheVectors() + { + foreach (var v in Section("tokenize").EnumerateArray()) + { + var line = v.GetProperty("line").GetString()!; + var language = Enum.Parse(v.GetProperty("language").GetString()!); + var expected = v.GetProperty("spans").EnumerateArray() + .Select(s => new SyntaxSpan(s[0].GetInt32(), s[1].GetInt32(), Enum.Parse(s[2].GetString()!))); + SyntaxTokenizer.Tokenize(line, language).Should().Equal(expected, line); + } + } + + [Fact] + public void TheDecoderMatchesTheVectors() + { + foreach (var v in Section("decode").EnumerateArray()) + { + var bytes = Convert.FromHexString(v.GetProperty("hex").GetString()!); + var because = v.GetProperty("hex").GetString(); + TextPreviewDecoder.Decode(bytes, v.GetProperty("startsMidFile").GetBoolean(), v.GetProperty("endsMidFile").GetBoolean()) + .Should().Be(v.GetProperty("text").GetString(), because); + TextPreviewDecoder.CompleteUtf8Length(bytes).Should().Be(v.GetProperty("completeUtf8Length").GetInt32(), because); + TextPreviewDecoder.LooksBinary(bytes).Should().Be(v.GetProperty("looksBinary").GetBoolean(), because); + } + } +} diff --git a/remex.desktop.tests/Services/FileTransfer/DownloadVerifiedReportTests.cs b/remex.desktop.tests/Services/FileTransfer/DownloadVerifiedReportTests.cs new file mode 100644 index 000000000..8cf53d543 --- /dev/null +++ b/remex.desktop.tests/Services/FileTransfer/DownloadVerifiedReportTests.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Remex.Core.Messages; +using Remex.Core.Models; +using Remex.Desktop.Services.FileTransfer; +using Remex.Desktop.ViewModels; +using Xunit; + +namespace Remex.Desktop.Tests.Services.FileTransfer; + +/// +/// The download path tells its row "Verified" only when the host sent a hash and it matched (2026-10-08 redesign). +/// A host that sends no hash proves nothing, and the row must stay "Done". +/// +public sealed class DownloadVerifiedReportTests : IDisposable +{ + private readonly string _dir = Directory.CreateTempSubdirectory("remex-verified-").FullName; + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + } + + private sealed class ScriptedHost(Func, Task> onSend) : IFileTransferConnection + { + public event Action? FileTransferMessageReceived; + + public Task SendAsync(RemexMessage message) => onSend(message, m => FileTransferMessageReceived?.Invoke(m)); + } + + private sealed class RecordingSink : IProgress, ITransferVerificationSink + { + public string? Verified; + public void Report(TransferProgress value) { } + public void ReportVerifying() { } + public void ReportVerified(string sha256Base64) => Verified = sha256Base64; + } + + private static ScriptedHost HostSending(byte[] content, string? sha256Base64) => new((message, deliver) => + { + if (message.Type == MessageTypes.FileTransferStart) + { + var id = message.FileTransferStart!.TransferId; + _ = Task.Run(() => + { + deliver(new RemexMessage + { + Type = MessageTypes.FileTransferChunk, + FileTransferChunk = new FileTransferChunk { TransferId = id, Offset = 0, DataBase64 = Convert.ToBase64String(content) }, + }); + deliver(new RemexMessage + { + Type = MessageTypes.FileTransferEnd, + FileTransferEnd = new FileTransferEnd { TransferId = id, Success = true, Sha256Base64 = sha256Base64 }, + }); + }); + } + return Task.CompletedTask; + }); + + [Fact] + public async Task AHostThatSentAMatchingHash_MakesTheRowVerified_WithThatHash() + { + var content = "hello"u8.ToArray(); + var hash = Convert.ToBase64String(SHA256.HashData(content)); + using var client = new FileTransferClient(HostSending(content, hash)); + var sink = new RecordingSink(); + + await client.DownloadAsync("root", "a.txt", Path.Combine(_dir, "a.txt"), sink, CancellationToken.None); + + sink.Verified.Should().Be(hash); + } + + [Fact] + public async Task AHostThatSentNoHash_LeavesTheRowUnverified() + { + using var client = new FileTransferClient(HostSending("hello"u8.ToArray(), sha256Base64: null)); + var sink = new RecordingSink(); + + await client.DownloadAsync("root", "a.txt", Path.Combine(_dir, "b.txt"), sink, CancellationToken.None); + + sink.Verified.Should().BeNull("nothing was compared, so nothing was verified"); + } +} diff --git a/remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs b/remex.desktop.tests/Services/FileTransfer/ReadRangeClientTests.cs new file mode 100644 index 000000000..d199ad314 --- /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.tests/Services/VersionSourceOfTruthTests.cs b/remex.desktop.tests/Services/VersionSourceOfTruthTests.cs index e9d6f59fa..9e10c3aa8 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.tests/ViewModels/FileExplorerTests.cs b/remex.desktop.tests/ViewModels/FileExplorerTests.cs new file mode 100644 index 000000000..e6f353db0 --- /dev/null +++ b/remex.desktop.tests/ViewModels/FileExplorerTests.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Remex.Core.Messages; +using Remex.Core.Models; +using Remex.Desktop.Services; +using Remex.Desktop.Services.FilePreview; +using Remex.Desktop.Services.FileTransfer; +using Remex.Desktop.ViewModels; +using Xunit; + +namespace Remex.Desktop.Tests.ViewModels; + +/// +/// The explorer half of the File Transfer screen (2026-10-08 redesign), driven through scripted devices: the folder +/// tree, navigation between devices and folders, the preview pane, and moving files between devices. +/// +public sealed class FileExplorerTests : IDisposable +{ + private const string PhoneId = "phone-1"; + private readonly string _disk = Directory.CreateTempSubdirectory("remex-explorer-").FullName; + + public void Dispose() + { + try { Directory.Delete(_disk, recursive: true); } catch (IOException) { } + } + + /// A device that answers file requests from an in-memory tree. Folders end with '/'. + private sealed class ScriptedDevice(string clientId) : IPhoneFileConnection + { + public Dictionary Roots { get; } = new(); + public Dictionary Files { get; } = new(); // "root:path/to/file" + public HashSet Folders { get; } = new(); // "root:path/to/folder" + public List Volumes { get; } = new(); + public bool ReadRange { get; set; } = true; + public bool Hash { get; set; } = true; + public bool FailBrowse { get; set; } + public List Sent { get; } = new(); + + public string ClientId => clientId; + public bool IsConnected => true; + public event Action? Disconnected { add { } remove { } } + public event Action? FileTransferMessageReceived; + public void Dispose() { } + + public void AddFile(string root, string path, string text) => AddFile(root, path, Encoding.UTF8.GetBytes(text)); + + public void AddFile(string root, string path, byte[] bytes) + { + Files[$"{root}:{path}"] = bytes; + var parts = path.Split('/'); + for (var i = 1; i < parts.Length; i++) + Folders.Add($"{root}:{string.Join('/', parts[..i])}"); + } + + public Task SendAsync(RemexMessage m) + { + Sent.Add(m); + var reply = Answer(m); + if (reply is not null) + FileTransferMessageReceived?.Invoke(reply); + return Task.CompletedTask; + } + + private RemexMessage? Answer(RemexMessage m) => m.Type switch + { + MessageTypes.FileRootsRequest => new RemexMessage + { + Type = MessageTypes.FileRootsResponse, + FileRootsResponse = new FileRootsResponse + { + Roots = Roots.Select(r => new FileSharedRoot { RootId = r.Key, DisplayName = r.Value.Name, IsWritable = r.Value.Writable }).ToArray(), + FileCapabilities = new FileCapabilities + { + Protocol = 3, Binary = true, Resume = true, ReadRange = ReadRange, Hash = Hash, + FullBrowse = Volumes.Count > 0, Ops = ["delete", "rename", "copy", "move", "mkdir", "search", "manifest"], + }, + }, + }, + MessageTypes.FileBrowseRequest => Browse(m.FileBrowseRequest!), + MessageTypes.FileVolumesRequest => new RemexMessage + { + Type = MessageTypes.FileVolumesResponse, + FileVolumesResponse = new FileVolumesResponse { RequestId = m.FileVolumesRequest!.RequestId, Volumes = [.. Volumes], FullBrowseGranted = true }, + }, + MessageTypes.FileReadRangeRequest => Read(m.FileReadRangeRequest!), + MessageTypes.FileMetadataRequest => new RemexMessage + { + Type = MessageTypes.FileMetadataResponse, + FileMetadataResponse = new FileMetadataResponse { RequestId = m.FileMetadataRequest!.RequestId, Size = 1 }, + }, + MessageTypes.FileThumbnailRequest => new RemexMessage + { + Type = MessageTypes.FileThumbnailResponse, + FileThumbnailResponse = new FileThumbnailResponse { RequestId = m.FileThumbnailRequest!.RequestId }, + }, + MessageTypes.FileHashRequest => new RemexMessage + { + Type = MessageTypes.FileHashResponse, + FileHashResponse = Files.TryGetValue($"{m.FileHashRequest!.RootId}:{m.FileHashRequest.RelativePath}", out var b) + ? new FileHashResponse { RequestId = m.FileHashRequest.RequestId, Sha256Base64 = Convert.ToBase64String(SHA256.HashData(b)) } + : new FileHashResponse { RequestId = m.FileHashRequest.RequestId, ErrorMessage = "Not found." }, + }, + _ => null, + }; + + private RemexMessage Browse(FileBrowseRequest r) + { + var folder = (r.RelativePath ?? string.Empty).Trim('/'); + var prefix = folder.Length == 0 ? $"{r.RootId}:" : $"{r.RootId}:{folder}/"; + static string Child(string rest) => rest.Split('/')[0]; + var dirs = Folders.Where(f => f.StartsWith(prefix, StringComparison.Ordinal)) + .Select(f => Child(f[prefix.Length..])).Distinct() + .Select(n => new FileEntry { Name = n, IsDirectory = true }); + var files = Files.Keys.Where(f => f.StartsWith(prefix, StringComparison.Ordinal) && !f[prefix.Length..].Contains('/')) + .Select(f => new FileEntry { Name = f[prefix.Length..], IsDirectory = false, SizeBytes = Files[f].Length }); + return new RemexMessage + { + Type = MessageTypes.FileBrowseResponse, + FileBrowseResponse = FailBrowse + ? new FileBrowseResponse { RequestId = r.RequestId, Entries = [], ErrorMessage = "Phone said no." } + : new FileBrowseResponse { RequestId = r.RequestId, Entries = [.. dirs, .. files] }, + }; + } + + private RemexMessage Read(FileReadRangeRequest r) + { + var bytes = Files[$"{r.RootId}:{r.RelativePath}"]; + var start = r.FromEnd ? Math.Max(0, bytes.Length - r.Length) : r.Offset; + var count = (int)Math.Clamp(bytes.Length - start, 0, r.Length); + return new RemexMessage + { + Type = MessageTypes.FileReadRangeResponse, + FileReadRangeResponse = new FileReadRangeResponse + { + RequestId = r.RequestId, Offset = start, FileSize = bytes.Length, Eof = start + count >= bytes.Length, + DataBase64 = Convert.ToBase64String(bytes, (int)start, count), + }, + }; + } + } + + private sealed class FakeAccess(ScriptedDevice phone) : IPhoneFileAccess + { + public event Action? AvailabilityChanged; + public List Phones { get; } = [new(PhoneId, "Pixel 9")]; + public IReadOnlyList AvailablePhones() => [.. Phones]; + public IPhoneFileConnection Open(string clientId) => + Phones.Any(p => p.ClientId == clientId) ? phone : throw new PhoneNotConnectedException(); + public void Raise() => AvailabilityChanged?.Invoke(); + } + + private sealed class FakeTransfers : IPhoneFileTransfers + { + public List<(string ClientId, string RootId, string Remote, string Local)> Downloads { get; } = new(); + public List<(string ClientId, string Local, string RootId, string RemoteDir)> Uploads { get; } = new(); + + public Task DownloadAsync(string clientId, string rootId, string remoteRelativePath, string localPath, IProgress? progress, CancellationToken ct) + { + lock (Downloads) Downloads.Add((clientId, rootId, remoteRelativePath, localPath)); + return Task.CompletedTask; + } + + public Task UploadAsync(string clientId, string localPath, string rootId, string remoteDirectory, IProgress? progress, CancellationToken ct) + { + lock (Uploads) Uploads.Add((clientId, localPath, rootId, remoteDirectory)); + return Task.CompletedTask; + } + } + + private static Action UiThreadStandIn() + { + var gate = new object(); + return action => { lock (gate) action(); }; + } + + private sealed record Kit(FileTransferViewModel Vm, ScriptedDevice Pc, ScriptedDevice Phone, FakeAccess Access, FakeTransfers Transfers); + + private async Task OpenAsync(Action? pc = null, Action? phone = null) + { + var pcDevice = new ScriptedDevice("pc"); + pcDevice.Roots["docs"] = ("Documents", true); + pcDevice.Roots["pics"] = ("Pictures", false); + pcDevice.AddFile("docs", "Logs/agent.log", "one\ntwo\nERROR three\n"); + pcDevice.AddFile("docs", "notes.txt", "hello"); + pc?.Invoke(pcDevice); + + var phoneDevice = new ScriptedDevice(PhoneId); + phoneDevice.Roots["dcim"] = ("DCIM", true); + phoneDevice.AddFile("dcim", "Camera/IMG_1.jpg", [1, 2, 3]); + phoneDevice.AddFile("dcim", "notes.txt", "hello"); + phone?.Invoke(phoneDevice); + + var access = new FakeAccess(phoneDevice); + var transfers = new FakeTransfers(); + var vm = new FileTransferViewModel( + new ConnectionViewModel { IsConnected = true }, + transferQueue: new FileTransferQueue(UiThreadStandIn()), + client: new FileTransferClient(pcDevice), + phoneAccess: () => access, + phoneTransfers: () => transfers); + vm.PostToUi = work => work(); + vm.LoadPcRoots = () => Task.FromResult>( + [ + new FileTransferRootConfiguration { RootId = "docs", DisplayName = "Documents", AbsolutePath = _disk, IsWritable = true }, + ]); + await WaitForAsync(() => vm.SelectedRemoteRoot is not null && !vm.IsLoading && vm.Tree.Devices.Count == 2, "the screen should open on This PC"); + return new Kit(vm, pcDevice, phoneDevice, access, transfers); + } + + private static async Task WaitForAsync(Func condition, string because) + { + for (var i = 0; i < 400 && !condition(); i++) + await Task.Delay(10); + condition().Should().BeTrue(because); + } + + // ── Tree ── + + [Fact] + public async Task TheTree_ListsThisPcAndEachPhone_AndRevealsTheFolderOnShow_WithoutNavigatingAgain() + { + var kit = await OpenAsync(); + var navigations = 0; + kit.Vm.Tree.NavigateRequested += _ => navigations++; + + kit.Vm.Tree.Devices.Select(d => d.IsThisPc).Should().Equal(true, false); + var thisPc = kit.Vm.Tree.Devices[0]; + await WaitForAsync(() => thisPc.IsExpanded && thisPc.Children.Any(c => c.IsRoot), "This PC opens to its shared folders"); + thisPc.Children.Select(c => c.Name).Should().Equal("Documents", "Pictures"); + thisPc.Children.Single(c => c.Name == "Pictures").IsReadOnlyRoot.Should().BeTrue(); + kit.Vm.Tree.SelectedNode.Should().BeSameAs(thisPc.Children.Single(c => c.RootId == kit.Vm.SelectedRemoteRoot!.RootId)); + navigations.Should().Be(0, "revealing where the screen already is must not ask it to go there again"); + } + + [Fact] + public async Task ExpandingASharedFolder_ListsOnlyItsFolders() + { + var kit = await OpenAsync(); + var docs = kit.Vm.Tree.Devices[0].Children.Single(c => c.RootId == "docs"); + + await kit.Vm.Tree.EnsureChildrenAsync(docs); + + docs.Children.Select(c => c.Name).Should().Equal("Logs"); + docs.Children.Single().Path.Should().Be("Logs"); + } + + [Fact] + public async Task ARowThatCannotBeListed_SaysSo_AndTriesAgainWhenOpenedAgain() + { + var kit = await OpenAsync(); + var phone = kit.Vm.Tree.Devices[1]; + await kit.Vm.Tree.EnsureChildrenAsync(phone); + var dcim = phone.Children.Single(c => c.RootId == "dcim"); + kit.Phone.FailBrowse = true; + + await kit.Vm.Tree.EnsureChildrenAsync(dcim); + dcim.Children.Should().ContainSingle(c => c.IsPlaceholder && c.LoadFailed); + + kit.Phone.FailBrowse = false; + await kit.Vm.Tree.EnsureChildrenAsync(dcim); + dcim.Children.Select(c => c.Name).Should().Equal("Camera"); + } + + [Fact] + public async Task APhoneThatAllowsWholeDeviceBrowsing_ListsItsVolumes_ReadOnly() + { + var kit = await OpenAsync(phone: p => p.Volumes.Add(new FileVolumeInfo { Id = "vol-1", Label = "Internal storage", Path = "/", Kind = "internal" })); + var phone = kit.Vm.Tree.Devices[1]; + + await kit.Vm.Tree.EnsureChildrenAsync(phone); + + var volume = phone.Children.Single(c => c.RootId == "vol-1"); + volume.IsVolume.Should().BeTrue(); + volume.IsWritable.Should().BeFalse(); + } + + [Fact] + public async Task TheDeviceRows_FollowPhonesComingAndGoing_AndKeepWhatWasOpen() + { + var kit = await OpenAsync(); + var thisPc = kit.Vm.Tree.Devices[0]; + + kit.Access.Phones.Clear(); + kit.Access.Raise(); + kit.Vm.Tree.Devices.Should().ContainSingle().Which.Should().BeSameAs(thisPc); + + kit.Access.Phones.Add(new PhoneFileSource(PhoneId, "Pixel 9")); + kit.Access.Raise(); + kit.Vm.Tree.Devices.Should().HaveCount(2); + kit.Vm.Tree.Devices[0].Should().BeSameAs(thisPc, "an open row is not rebuilt when another device arrives"); + } + + // ── Navigation ── + + [Fact] + public async Task PickingAFolderOnAnotherSharedFolder_LandsInThatFolder() + { + var kit = await OpenAsync(pc: d => d.AddFile("pics", "2026/a.jpg", [1])); + var pics = kit.Vm.Tree.Devices[0].Children.Single(c => c.RootId == "pics"); + await kit.Vm.Tree.EnsureChildrenAsync(pics); + + kit.Vm.Tree.SelectedNode = pics.Children.Single(c => c.Name == "2026"); + + await WaitForAsync(() => kit.Vm.SelectedRemoteRoot?.RootId == "pics" && kit.Vm.RemoteEntries.Any(e => e.Name == "a.jpg"), + "the screen shows the folder picked in the tree, not the top of its shared folder"); + } + + [Fact] + public async Task PickingAFolderOnThePhone_SwitchesDevice_AndLandsInThatFolder() + { + var kit = await OpenAsync(); + + await kit.Vm.NavigateToAsync(kit.Vm.Sources.Single(s => s.ClientId == PhoneId), "dcim", "Camera"); + + await WaitForAsync(() => kit.Vm.IsPhoneSource && kit.Vm.RemoteEntries.Any(e => e.Name == "IMG_1.jpg"), + "the phone's Camera folder is shown"); + kit.Vm.CurrentDeviceLabel.Should().Be("Pixel 9"); + } + + // ── Preview ── + + private static PreviewTarget Target(Kit kit, FileTransferClient client, string root, string path, bool phone = false) => + new(client, phone ? new FileSourceOption(PhoneId, "Pixel 9") : FileSourceOption.ThisPc, root, path, + new FileEntry { Name = Path.GetFileName(path), IsDirectory = false }); + + [Fact] + public async Task ALog_IsPreviewedAsColouredLines_AndOffersLiveTail() + { + var kit = await OpenAsync(); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(client)); + + await preview.LoadAsync(Target(kit, client, "docs", "Logs/agent.log"), CancellationToken.None); + + preview.State.Should().Be(PreviewState.Text); + preview.Lines.Select(l => l.Text).Should().Equal("one", "two", "ERROR three"); + preview.Lines[2].Spans.Should().Contain(s => s.Kind == SyntaxKind.LogError); + preview.CanTail.Should().BeTrue(); + preview.CanComputeHash.Should().BeTrue(); + } + + [Theory] + [InlineData("data.txt", PreviewState.Binary)] // named as text, is not + [InlineData("data.blob", PreviewState.Details)] // unknown name, sniffed binary: details only + [InlineData("setup.exe", PreviewState.Details)] // never previewed + public async Task AFileThatIsNotText_IsNotShownAsText(string name, PreviewState expected) + { + var kit = await OpenAsync(pc: d => d.AddFile("docs", name, [0, 1, 2, 0, 3])); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(client)); + + await preview.LoadAsync(Target(kit, client, "docs", name), CancellationToken.None); + + preview.State.Should().Be(expected); + preview.Lines.Should().BeEmpty(); + } + + [Fact] + public async Task ADeviceThatPredatesPreviews_IsAskedToUpdate_RatherThanReadFrom() + { + var kit = await OpenAsync(pc: d => d.ReadRange = false); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(client)); + + await preview.LoadAsync(Target(kit, client, "docs", "notes.txt"), CancellationToken.None); + + preview.State.Should().Be(PreviewState.NeedsUpdate); + kit.Pc.Sent.Should().NotContain(m => m.Type == MessageTypes.FileReadRangeRequest); + } + + [Fact] + public async Task AnImageOverTheLimit_SaysTooLarge() + { + var kit = await OpenAsync(pc: d => d.AddFile("docs", "huge.png", new byte[100])); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + // A host that claims a size over the limit: the loader refuses after its first read. + kit.Pc.Files["docs:huge.png"] = new byte[FileTransferLimits.ReadRangeMaxBytes]; + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(client)); + var decoded = 0; + preview.DecodeImage = _ => { decoded++; return null; }; + + // Within the limit an image is decoded… + await preview.LoadAsync(Target(kit, client, "docs", "huge.png"), CancellationToken.None); + decoded.Should().Be(1); + } + + [Fact] + public async Task APastedHash_IsComparedInAnyForm_AndANonHashSaysSo() + { + var kit = await OpenAsync(); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(client)); + await preview.LoadAsync(Target(kit, client, "docs", "notes.txt"), CancellationToken.None); + + await preview.ComputeHashCommand.ExecuteAsync(null); + var hex = Convert.ToHexStringLower(SHA256.HashData("hello"u8)); + preview.HashHex.Should().Be(hex); + + preview.CompareInput = hex.ToUpperInvariant(); + preview.CompareOutcome.Should().Be(HashCompareOutcome.Match); + preview.CompareInput = new string('0', 64); + preview.CompareOutcome.Should().Be(HashCompareOutcome.Mismatch); + preview.CompareInput = "abc"; + preview.CompareOutcome.Should().Be(HashCompareOutcome.NotAHash); + } + + [Fact] + public async Task VerifyingAgainstThePhone_SaysIdentical_ThenDifferentWhenTheCopiesDiffer() + { + var kit = await OpenAsync(); + var client = new FileTransferClient(kit.Pc); + await client.ListRemoteRootsAsync(CancellationToken.None); + var phoneClient = new FileTransferClient(kit.Phone); + await phoneClient.ListRemoteRootsAsync(CancellationToken.None); + var preview = new FilePreviewViewModel((s, _) => Task.FromResult(s.IsThisPc ? client : phoneClient)) + { + OtherDevices = () => [new FileSourceOption(PhoneId, "Pixel 9")], + LastFolderOn = _ => ("dcim", string.Empty), + }; + + await preview.LoadAsync(Target(kit, client, "docs", "notes.txt"), CancellationToken.None); + await WaitForAsync(() => preview.CounterpartRoot is not null, "the phone's shared folders are listed"); + preview.CounterpartPath.Should().Be("notes.txt", "the same name, in the folder last opened on the phone"); + + await preview.VerifyAgainstCommand.ExecuteAsync(null); + preview.CounterpartOutcome.Should().Be(CounterpartOutcome.Identical); + + kit.Phone.Files["dcim:notes.txt"] = "hello!"u8.ToArray(); + await preview.VerifyAgainstCommand.ExecuteAsync(null); + preview.CounterpartOutcome.Should().Be(CounterpartOutcome.Different); + } + + [Fact] + public void ALiveTail_ResetsAppendsAndKeepsAtMostItsLimit() + { + var preview = new FilePreviewViewModel((_, _) => Task.FromResult(null)); + + preview.ApplyTail(new TailUpdate(true, ["a", "b"], "c", 3)); + preview.Lines.Select(l => l.Text).Should().Equal("a", "b"); + preview.Lines.Should().OnlyContain(l => l.Number == null, "a tail does not know its line numbers"); + preview.PendingLine.Should().Be("c"); + + preview.ApplyTail(new TailUpdate(false, ["c"], string.Empty, 4)); + preview.Lines.Select(l => l.Text).Should().Equal("a", "b", "c"); + + preview.ApplyTail(new TailUpdate(false, Enumerable.Range(0, FilePreviewViewModel.MaxTailLines).Select(i => $"x{i}").ToList(), "", 9)); + preview.Lines.Should().HaveCount(FilePreviewViewModel.MaxTailLines); + preview.Lines[^1].Text.Should().Be($"x{FilePreviewViewModel.MaxTailLines - 1}"); + } + + // ── Moving files between devices ── + + [Fact] + public async Task PcFilesDroppedOnAPhoneFolder_AreUploadedFromWhereTheyAreOnDisk() + { + var kit = await OpenAsync(); + var phone = kit.Vm.Tree.Devices[1]; + await kit.Vm.Tree.EnsureChildrenAsync(phone); + var dcim = phone.Children.Single(c => c.RootId == "dcim"); + var payload = new FileDragPayload(FileSourceOption.ThisPc, "docs", "Logs", + [new FileEntry { Name = "agent.log", IsDirectory = false }, new FileEntry { Name = "Old", IsDirectory = true }]); + + await kit.Vm.DropOntoNodeAsync(payload, dcim, copy: false); + await Task.WhenAll(kit.Vm.TransferQueue.Items.Select(i => i.Completion.Task)); + + kit.Transfers.Uploads.Should().ContainSingle() + .Which.Should().Be((PhoneId, Path.Combine(_disk, "Logs", "agent.log"), "dcim", string.Empty)); + kit.Vm.TransferQueue.Items.Should().OnlyContain(i => i.Kind == FileTransferQueueKind.Upload); + } + + [Fact] + public async Task PhoneFilesDroppedOnAPcFolder_AreDownloadedThere_WithoutReplacingWhatIsThere() + { + var kit = await OpenAsync(); + File.WriteAllText(Path.Combine(_disk, "notes.txt"), "already here"); + var docs = kit.Vm.Tree.Devices[0].Children.Single(c => c.RootId == "docs"); + var payload = new FileDragPayload(new FileSourceOption(PhoneId, "Pixel 9"), "dcim", string.Empty, + [new FileEntry { Name = "notes.txt", IsDirectory = false }]); + + await kit.Vm.DropOntoNodeAsync(payload, docs, copy: false); + await Task.WhenAll(kit.Vm.TransferQueue.Items.Select(i => i.Completion.Task)); + + kit.Transfers.Downloads.Should().ContainSingle() + .Which.Should().Be((PhoneId, "dcim", "notes.txt", Path.Combine(_disk, "notes (2).txt"))); + } + + [Fact] + public async Task PhoneToPhone_AndFolders_AreRefusedWithAReason_AndQueueNothing() + { + var kit = await OpenAsync(); + var phoneB = new FileSourceOption("phone-2", "Tablet"); + var node = kit.Vm.Tree.Devices[1]; + await kit.Vm.Tree.EnsureChildrenAsync(node); + var dcim = node.Children.Single(c => c.RootId == "dcim"); + + await kit.Vm.DropOntoNodeAsync(new FileDragPayload(phoneB, "x", "", [new FileEntry { Name = "a.jpg", IsDirectory = false }]), dcim, copy: false); + kit.Vm.StatusText.Should().Be(LocalizationService.Instance["FilePreview_PhoneToPhoneUnsupported"], + "a refusal names its real reason, not whatever later check happens to fail"); + await kit.Vm.DropOntoNodeAsync(new FileDragPayload(FileSourceOption.ThisPc, "docs", "", [new FileEntry { Name = "Logs", IsDirectory = true }]), dcim, copy: false); + kit.Vm.StatusText.Should().Be(LocalizationService.Instance["FilePreview_FoldersNotSent"]); + + kit.Vm.TransferQueue.Items.Should().BeEmpty(); + kit.Transfers.Uploads.Should().BeEmpty(); + } + + [Fact] + public async Task APcPath_ThatWouldLeaveItsSharedFolder_IsNeverResolved() + { + var kit = await OpenAsync(); + + (await kit.Vm.LocalPathForAsync("docs", "Logs/agent.log")).Should().Be(Path.Combine(_disk, "Logs", "agent.log")); + (await kit.Vm.LocalPathForAsync("docs", "../outside.txt")).Should().BeNull(); + (await kit.Vm.LocalPathForAsync("not-a-root", "a.txt")).Should().BeNull(); + } + + [Fact] + public void UniqueLocalPath_KeepsBothRatherThanReplacing() + { + var file = Path.Combine(_disk, "a.txt"); + FileTransferViewModel.UniqueLocalPath(file).Should().Be(file); + File.WriteAllText(file, "x"); + File.WriteAllText(Path.Combine(_disk, "a (2).txt"), "x"); + FileTransferViewModel.UniqueLocalPath(file).Should().Be(Path.Combine(_disk, "a (3).txt")); + } +} diff --git a/remex.desktop.tests/ViewModels/FileTransferQueueVerifiedTests.cs b/remex.desktop.tests/ViewModels/FileTransferQueueVerifiedTests.cs new file mode 100644 index 000000000..45e601e83 --- /dev/null +++ b/remex.desktop.tests/ViewModels/FileTransferQueueVerifiedTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Remex.Core.Models; +using Remex.Desktop.Services; +using Remex.Desktop.Services.FileTransfer; +using Remex.Desktop.ViewModels; +using Xunit; + +namespace Remex.Desktop.Tests.ViewModels; + +/// +/// A finished transfer says "Verified" and carries its hash only when the hash was really compared +/// (2026-10-08 redesign): before this, every success said "Done" and the hash the client had just checked +/// was thrown away. +/// +public class FileTransferQueueVerifiedTests +{ + private const string AbcBase64 = "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0="; + private const string AbcHex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + private static Action UiThreadStandIn() + { + var uiThread = new object(); + return action => + { + lock (uiThread) + action(); + }; + } + + [Fact] + public async Task ATransferThatReportsItsHash_FinishesVerified_WithTheHashInHex() + { + using var queue = new FileTransferQueue(UiThreadStandIn()); + var sawVerifying = false; + + var item = queue.Enqueue(FileTransferQueueKind.Download, "a.bin", (progress, _) => + { + progress.ReportVerifying(); + sawVerifying = queue.Items[0].State == TransferState.Verifying; + progress.ReportVerified(AbcBase64); + return Task.CompletedTask; + }); + await item.Completion.Task; + + sawVerifying.Should().BeTrue("the row says Verifying while the hashes are compared"); + item.State.Should().Be(TransferState.Done); + item.IsVerified.Should().BeTrue(); + item.VerifiedSha256Hex.Should().Be(AbcHex); + item.StateLabel.Should().NotBe(LocalizationService.Instance["FileTransfer_QueueStateDone"]); + } + + [Fact] + public async Task ATransferThatComparedNoHash_StaysDone_NotVerified() + { + using var queue = new FileTransferQueue(UiThreadStandIn()); + + var item = queue.Enqueue(FileTransferQueueKind.Upload, "b.bin", (_, _) => Task.CompletedTask); + await item.Completion.Task; + + item.State.Should().Be(TransferState.Done); + item.IsVerified.Should().BeFalse(); + item.VerifiedSha256Hex.Should().BeNull(); + item.StateLabel.Should().Be(LocalizationService.Instance["FileTransfer_QueueStateDone"]); + } + + [Fact] + public async Task AMalformedHash_NeverEarnsVerified() + { + using var queue = new FileTransferQueue(UiThreadStandIn()); + + var item = queue.Enqueue(FileTransferQueueKind.Download, "c.bin", (progress, _) => + { + progress.ReportVerified("not a hash"); + return Task.CompletedTask; + }); + await item.Completion.Task; + + item.IsVerified.Should().BeFalse(); + } + + [Fact] + public async Task ATransferThatFailsAfterVerifying_IsFailed_NotVerified() + { + using var queue = new FileTransferQueue(UiThreadStandIn()); + + var item = queue.Enqueue(FileTransferQueueKind.Download, "d.bin", (progress, _) => + { + progress.ReportVerifying(); + throw new FileTransferIntegrityException(); + }); + await item.Completion.Task; + + item.State.Should().Be(TransferState.Failed); + item.IsVerified.Should().BeFalse(); + } +} diff --git a/remex.desktop.tests/Views/TextInputTests.cs b/remex.desktop.tests/Views/TextInputTests.cs index 1b2928390..06b5c4d25 100644 --- a/remex.desktop.tests/Views/TextInputTests.cs +++ b/remex.desktop.tests/Views/TextInputTests.cs @@ -57,8 +57,6 @@ public class TextInputTests // Dense single-row toolbars: a floating label needs vertical headroom above the field that // a fixed-height horizontal control bar does not have. Each of these keeps the caption // TextBlock beside it instead (unlike the vertical settings-row fields this bead converted). - ["Binding RemoteRoots"] = "FileTransferView - toolbar row (Row 0: root selector + search + volumes)", - ["Binding Sources"] = "FileTransferView - toolbar row (Row 0: This PC / phone source picker, captioned beside it like the root selector)", ["Binding AvailableDisplayTargets"] = "RemoteDesktopView - toolbar row (display picker)", ["Binding SelectedScaleIndex"] = "RemoteDesktopView - toolbar row (scale picker)", ["Binding Connection.HostAddress"] = "RemoteDesktopView - toolbar row (connection panel host field)", diff --git a/remex.desktop/Controls/RemoteThumbnailImage.cs b/remex.desktop/Controls/RemoteThumbnailImage.cs new file mode 100644 index 000000000..bc28814eb --- /dev/null +++ b/remex.desktop/Controls/RemoteThumbnailImage.cs @@ -0,0 +1,85 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; +using Remex.Core.Models; +using Remex.Desktop.Services; +using Remex.Desktop.ViewModels; + +namespace Remex.Desktop.Controls; + +/// +/// A row's thumbnail in the File Transfer list or grid (2026-10-08 redesign). It asks for its picture only once it is +/// on screen, and gives up the request when the row scrolls away or is reused for another file, so a folder of a +/// thousand photos fetches what is visible and no more. Shows nothing for a file without one, leaving the icon +/// beneath it in view. +/// +public sealed class RemoteThumbnailImage : Image +{ + public static readonly StyledProperty EntryProperty = + AvaloniaProperty.Register(nameof(Entry)); + + private CancellationTokenSource? _request; + + public FileEntry? Entry + { + get => GetValue(EntryProperty); + set => SetValue(EntryProperty, value); + } + + protected override Type StyleKeyOverride => typeof(Image); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == EntryProperty && this.IsAttachedToVisualTree()) + RequestAsync().FireAndForget("file list thumbnail"); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + RequestAsync().FireAndForget("file list thumbnail"); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + Cancel(); + base.OnDetachedFromVisualTree(e); + } + + private void Cancel() + { + _request?.Cancel(); + _request?.Dispose(); + _request = null; + } + + private async Task RequestAsync() + { + Cancel(); + Source = null; + IsVisible = false; + if (Entry is not { IsDirectory: false } entry) + return; + + var screen = this.GetVisualAncestors().OfType() + .Select(c => c.DataContext).OfType().FirstOrDefault(); + if (screen is null) + return; + + _request = new CancellationTokenSource(); + var ct = _request.Token; + try + { + var image = await screen.GetThumbnailAsync(entry, ct); + if (ct.IsCancellationRequested || !ReferenceEquals(entry, Entry)) + return; + Source = image; + IsVisible = image is not null; + } + catch (OperationCanceledException) + { + // Scrolled away or reused; the next request owns this control. + } + } +} diff --git a/remex.desktop/Controls/SyntaxLineBlock.cs b/remex.desktop/Controls/SyntaxLineBlock.cs new file mode 100644 index 000000000..d8a840b49 --- /dev/null +++ b/remex.desktop/Controls/SyntaxLineBlock.cs @@ -0,0 +1,91 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using Remex.Desktop.Services.FilePreview; +using Remex.Desktop.ViewModels; + +namespace Remex.Desktop.Controls; + +/// +/// One line of the File Transfer text preview, coloured by 's spans (2026-10-08 redesign). +/// A plain cannot bind its inlines, so this builds them from . +/// +/// +/// COLOURS COME FROM THE PALETTE'S ROLE KEYS, never hex literals, so every preset, seed and contrast level colours the +/// preview the same way it colours the rest of the screen. They are looked up again when the theme changes. +/// +public sealed class SyntaxLineBlock : TextBlock +{ + public static readonly StyledProperty LineProperty = + AvaloniaProperty.Register(nameof(Line)); + + public PreviewLine? Line + { + get => GetValue(LineProperty); + set => SetValue(LineProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == LineProperty) + Rebuild(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ActualThemeVariantChanged += OnThemeChanged; + Rebuild(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + ActualThemeVariantChanged -= OnThemeChanged; + base.OnDetachedFromVisualTree(e); + } + + private void OnThemeChanged(object? sender, EventArgs e) => Rebuild(); + + private void Rebuild() + { + var inlines = Inlines ??= new InlineCollection(); + inlines.Clear(); + if (Line is not { } line) + return; + + var at = 0; + foreach (var span in line.Spans) + { + if (span.Start > at) + inlines.Add(new Run(line.Text[at..span.Start])); + var run = new Run(line.Text.Substring(span.Start, span.Length)); + if (BrushFor(span.Kind) is { } brush) + run.Foreground = brush; + if (span.Kind is SyntaxKind.LogError) + run.FontWeight = FontWeight.SemiBold; + inlines.Add(run); + at = span.Start + span.Length; + } + if (at < line.Text.Length) + inlines.Add(new Run(line.Text[at..])); + } + + private IBrush? BrushFor(SyntaxKind kind) + { + var key = kind switch + { + SyntaxKind.Comment or SyntaxKind.LogDebug or SyntaxKind.Timestamp => "TextMutedBrush", + SyntaxKind.String => "SystemSuccessBrush", + SyntaxKind.Number or SyntaxKind.Keyword => "PaletteTertiaryBrush", + SyntaxKind.Key => "AccentPrimaryBrush", + SyntaxKind.LogError => "SystemErrorBrush", + SyntaxKind.LogWarning => "SystemWarningBrush", + _ => null, + }; + return key is not null && this.TryFindResource(key, ActualThemeVariant, out var found) ? found as IBrush : null; + } +} diff --git a/remex.desktop/Controls/ZoomableImage.cs b/remex.desktop/Controls/ZoomableImage.cs new file mode 100644 index 000000000..1e3a150d1 --- /dev/null +++ b/remex.desktop/Controls/ZoomableImage.cs @@ -0,0 +1,131 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace Remex.Desktop.Controls; + +/// +/// The full-size image in the File Transfer preview (2026-10-08 redesign): fitted to start with; the wheel zooms +/// around the pointer, dragging pans, and a double-click fits it again. Clips to its own bounds. +/// +public sealed class ZoomableImage : Border +{ + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + private const double MinZoom = 1.0; + private const double MaxZoom = 16.0; + private const double WheelStep = 1.2; + + private readonly Image _image = new() { Stretch = Stretch.Uniform }; + private double _zoom = 1; + private Vector _offset; + private Point? _dragFrom; + + public ZoomableImage() + { + ClipToBounds = true; + Background = Brushes.Transparent; // hit-testable everywhere, so the whole area pans + Child = _image; + _image.RenderTransformOrigin = RelativePoint.TopLeft; + } + + public IImage? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// The current zoom, 1 = fitted. + public double Zoom => _zoom; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SourceProperty) + { + _image.Source = Source; + Fit(); + } + } + + /// Back to fitted, centred. + public void Fit() + { + _zoom = 1; + _offset = default; + Apply(); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + var factor = e.Delta.Y > 0 ? WheelStep : 1 / WheelStep; + ZoomAround(e.GetPosition(this), factor); + e.Handled = true; + } + + /// Zooms by keeping the image point under still. + public void ZoomAround(Point anchor, double factor) + { + var next = Math.Clamp(_zoom * factor, MinZoom, MaxZoom); + if (Math.Abs(next - _zoom) < 1e-9) + return; + var applied = next / _zoom; + _offset = new Vector(anchor.X - (anchor.X - _offset.X) * applied, anchor.Y - (anchor.Y - _offset.Y) * applied); + _zoom = next; + if (_zoom <= MinZoom) + _offset = default; + Apply(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.ClickCount >= 2) + { + Fit(); + e.Handled = true; + return; + } + if (_zoom > MinZoom && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _dragFrom = e.GetPosition(this); + e.Pointer.Capture(this); + e.Handled = true; + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_dragFrom is not { } from) + return; + var now = e.GetPosition(this); + _offset += now - from; + _dragFrom = now; + Apply(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_dragFrom is null) + return; + _dragFrom = null; + e.Pointer.Capture(null); + } + + private void Apply() + { + _image.RenderTransform = new TransformGroup + { + Children = + { + new ScaleTransform(_zoom, _zoom), + new TranslateTransform(_offset.X, _offset.Y), + }, + }; + } +} diff --git a/remex.desktop/Localization/Strings.es.resx b/remex.desktop/Localization/Strings.es.resx index ee777aaca..775407fa3 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. @@ -4280,4 +4268,151 @@ Para que las rutinas no empiecen solas, haz clic en Pausar todo en la página Ru Esperando un teléfono + + Verificado + + + Carpetas + + + Más acciones + + + Mostrar u ocultar la vista previa (Espacio) + + + Vista previa + + + Enviar a + + + Copiar ruta + + + Calcular SHA-256 + + + Copiar SHA-256 + + + No se pudo abrir. Vuelve a expandir para reintentar. + + + Actualizar + + + Mostrar u ocultar la lista de transferencias + + + en curso + + + verificados + + + fallidos + + + Vista previa + + + Cerrar la vista previa + + + Selecciona un archivo para verlo. + + + Cargando la vista previa… + + + En vivo + + + Seguir el final del archivo mientras crece + + + Se muestran los primeros 2 MB. Descarga el archivo para verlo entero. + + + Este archivo es demasiado grande para la vista previa. Descárgalo para abrirlo. + + + Este archivo no es texto, así que no hay nada que mostrar. + + + Actualiza RemEx en este dispositivo para ver sus archivos. + + + No se pudo cargar la vista previa. + + + Desplaza para hacer zoom, arrastra para mover y haz doble clic para ajustar + + + Detalles + + + Huella SHA-256 + + + Calcular SHA-256 + + + Calculando la huella… + + + Copiar + + + Pega un SHA-256 para comparar + + + Coincide + + + No coincide + + + Eso no es un SHA-256. Debe tener 64 caracteres hexadecimales. + + + Este dispositivo aún no puede calcular huellas. Actualiza RemEx en él. + + + No se pudo calcular la huella. Comprueba que el archivo no esté en uso y vuelve a intentarlo. + + + Comparar con una copia en otro dispositivo + + + Dispositivo + + + Carpeta compartida + + + Archivo en ese dispositivo + + + Comparar + + + Comparando las dos copias… + + + Idénticos: las dos copias tienen la misma huella. + + + Diferentes: las dos copias no coinciden. + + + No se pudo comparar. Comprueba el nombre del archivo y que ambos dispositivos estén conectados. + + + Las carpetas no se envían así. Usa Subir carpeta o Descargar carpeta. + + + Los archivos no pueden pasar directamente de un teléfono a otro. Cópialos primero a este PC. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.fr.resx b/remex.desktop/Localization/Strings.fr.resx index 319c4a437..0c2a8874c 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. @@ -4268,4 +4256,151 @@ Pour empêcher les routines de démarrer seules, cliquez sur Tout mettre en paus En attente d'un téléphone + + Vérifié + + + Dossiers + + + Plus d'actions + + + Afficher ou masquer l'aperçu (Espace) + + + Aperçu + + + Envoyer vers + + + Copier le chemin + + + Calculer le SHA-256 + + + Copier le SHA-256 + + + Ouverture impossible. Développez à nouveau pour réessayer. + + + Actualiser + + + Afficher ou masquer la liste des transferts + + + en cours + + + vérifiés + + + échoués + + + Aperçu + + + Fermer l'aperçu + + + Sélectionnez un fichier pour l'afficher. + + + Chargement de l'aperçu… + + + En direct + + + Suivre la fin du fichier à mesure qu'il grandit + + + Affichage des 2 premiers Mo. Téléchargez le fichier pour tout voir. + + + Ce fichier est trop volumineux pour l'aperçu. Téléchargez-le pour l'ouvrir. + + + Ce fichier n'est pas du texte : il n'y a rien à afficher. + + + Mettez RemEx à jour sur cet appareil pour afficher ses fichiers. + + + Impossible de charger l'aperçu. + + + Faites défiler pour zoomer, faites glisser pour déplacer, double-cliquez pour ajuster + + + Détails + + + Empreinte SHA-256 + + + Calculer le SHA-256 + + + Calcul de l'empreinte… + + + Copier + + + Collez un SHA-256 pour comparer + + + Correspond + + + Ne correspond pas + + + Ce n'est pas un SHA-256. Il doit compter 64 caractères hexadécimaux. + + + Cet appareil ne sait pas encore calculer d'empreintes. Mettez RemEx à jour dessus. + + + Impossible de calculer l'empreinte. Vérifiez que le fichier n'est pas utilisé, puis réessayez. + + + Comparer avec une copie sur un autre appareil + + + Appareil + + + Dossier partagé + + + Fichier sur cet appareil + + + Comparer + + + Comparaison des deux copies… + + + Identiques : les deux copies ont la même empreinte. + + + Différentes : les deux copies ne correspondent pas. + + + Comparaison impossible. Vérifiez le nom du fichier et que les deux appareils sont connectés. + + + Les dossiers ne s'envoient pas ainsi. Utilisez Envoyer un dossier ou Télécharger le dossier. + + + Les fichiers ne peuvent pas passer directement d'un téléphone à un autre. Copiez-les d'abord sur ce PC. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.hi.resx b/remex.desktop/Localization/Strings.hi.resx index 14084e51d..1c17331d1 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 खोलने के लिए फ़ोन का कैमरा उस पर रखें। @@ -4268,4 +4256,151 @@ Windows: RemEx को व्यवस्थापक (administrator) अधि फ़ोन का इंतज़ार है + + सत्यापित + + + फ़ोल्डर + + + और कार्रवाइयाँ + + + पूर्वावलोकन दिखाएँ या छिपाएँ (Space) + + + पूर्वावलोकन + + + यहाँ भेजें + + + पथ कॉपी करें + + + SHA-256 निकालें + + + SHA-256 कॉपी करें + + + खुल नहीं सका। फिर से कोशिश करने के लिए दोबारा खोलें। + + + रीफ़्रेश करें + + + ट्रांसफ़र सूची दिखाएँ या छिपाएँ + + + जारी + + + सत्यापित + + + विफल + + + पूर्वावलोकन + + + पूर्वावलोकन बंद करें + + + पूर्वावलोकन के लिए कोई फ़ाइल चुनें। + + + पूर्वावलोकन लोड हो रहा है… + + + लाइव + + + फ़ाइल बढ़ने पर उसके अंत को देखते रहें + + + पहले 2 MB दिखाए जा रहे हैं। पूरी फ़ाइल देखने के लिए उसे डाउनलोड करें। + + + यह फ़ाइल पूर्वावलोकन के लिए बहुत बड़ी है। खोलने के लिए इसे डाउनलोड करें। + + + यह फ़ाइल टेक्स्ट नहीं है, इसलिए यहाँ दिखाने को कुछ नहीं है। + + + इस डिवाइस की फ़ाइलों का पूर्वावलोकन करने के लिए उस पर RemEx अपडेट करें। + + + पूर्वावलोकन लोड नहीं हो सका। + + + ज़ूम के लिए स्क्रॉल करें, हिलाने के लिए खींचें, फ़िट करने के लिए डबल-क्लिक करें + + + विवरण + + + SHA-256 फ़िंगरप्रिंट + + + SHA-256 निकालें + + + फ़िंगरप्रिंट निकाला जा रहा है… + + + कॉपी करें + + + तुलना के लिए SHA-256 चिपकाएँ + + + मेल खाता है + + + मेल नहीं खाता + + + यह SHA-256 नहीं है। इसमें 64 हेक्साडेसिमल वर्ण होने चाहिए। + + + यह डिवाइस अभी फ़िंगरप्रिंट नहीं निकाल सकता। उस पर RemEx अपडेट करें। + + + फ़िंगरप्रिंट नहीं निकाला जा सका। जाँचें कि फ़ाइल उपयोग में नहीं है, फिर दोबारा कोशिश करें। + + + किसी दूसरे डिवाइस की कॉपी से तुलना करें + + + डिवाइस + + + साझा फ़ोल्डर + + + उस डिवाइस पर फ़ाइल + + + तुलना करें + + + दोनों कॉपियों की तुलना हो रही है… + + + एक जैसी: दोनों कॉपियों का फ़िंगरप्रिंट एक ही है। + + + अलग: दोनों कॉपियाँ मेल नहीं खातीं। + + + तुलना नहीं हो सकी। फ़ाइल का नाम जाँचें और देखें कि दोनों डिवाइस कनेक्ट हैं। + + + फ़ोल्डर इस तरह नहीं भेजे जाते। इसके बजाय फ़ोल्डर अपलोड करें या फ़ोल्डर डाउनलोड करें का उपयोग करें। + + + फ़ाइलें सीधे एक फ़ोन से दूसरे फ़ोन पर नहीं जा सकतीं। पहले उन्हें इस PC पर कॉपी करें। + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.id.resx b/remex.desktop/Localization/Strings.id.resx index bb26bccdd..c51595cef 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. @@ -4271,4 +4259,151 @@ Agar rutinitas tidak mulai sendiri, klik Jeda semua di halaman Rutinitas. Tidak Menunggu ponsel + + Terverifikasi + + + Folder + + + Tindakan lainnya + + + Tampilkan atau sembunyikan pratinjau (Spasi) + + + Pratinjau + + + Kirim ke + + + Salin jalur + + + Hitung SHA-256 + + + Salin SHA-256 + + + Tidak dapat dibuka. Bentangkan lagi untuk mencoba ulang. + + + Muat ulang + + + Tampilkan atau sembunyikan daftar transfer + + + berlangsung + + + terverifikasi + + + gagal + + + Pratinjau + + + Tutup pratinjau + + + Pilih file untuk melihat pratinjaunya. + + + Memuat pratinjau… + + + Langsung + + + Ikuti akhir file saat file bertambah + + + Menampilkan 2 MB pertama. Unduh file untuk melihat semuanya. + + + File ini terlalu besar untuk pratinjau. Unduh untuk membukanya. + + + File ini bukan teks, jadi tidak ada yang bisa ditampilkan. + + + Perbarui RemEx di perangkat ini untuk melihat pratinjau filenya. + + + Pratinjau tidak dapat dimuat. + + + Gulir untuk zoom, seret untuk menggeser, klik dua kali untuk menyesuaikan + + + Detail + + + Sidik SHA-256 + + + Hitung SHA-256 + + + Menghitung sidik… + + + Salin + + + Tempel SHA-256 untuk membandingkan + + + Cocok + + + Tidak cocok + + + Itu bukan SHA-256. Seharusnya 64 karakter heksadesimal. + + + Perangkat ini belum bisa menghitung sidik. Perbarui RemEx di perangkat itu. + + + Tidak dapat menghitung sidik. Pastikan file tidak sedang digunakan, lalu coba lagi. + + + Bandingkan dengan salinan di perangkat lain + + + Perangkat + + + Folder bersama + + + File di perangkat itu + + + Bandingkan + + + Membandingkan kedua salinan… + + + Identik: kedua salinan memiliki sidik yang sama. + + + Berbeda: kedua salinan tidak cocok. + + + Tidak dapat membandingkan. Periksa nama file dan pastikan kedua perangkat terhubung. + + + Folder tidak dikirim dengan cara ini. Gunakan Unggah folder atau Unduh folder. + + + File tidak bisa langsung dari satu ponsel ke ponsel lain. Salin dulu ke PC ini. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.pl.resx b/remex.desktop/Localization/Strings.pl.resx index 19194203b..34501e7f3 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. @@ -4269,4 +4257,151 @@ Aby rutyny nie startowały same, kliknij Wstrzymaj wszystko na stronie Rutyny. N Czekam na telefon + + Zweryfikowano + + + Foldery + + + Więcej działań + + + Pokaż lub ukryj podgląd (Spacja) + + + Podgląd + + + Wyślij do + + + Kopiuj ścieżkę + + + Oblicz SHA-256 + + + Kopiuj SHA-256 + + + Nie udało się otworzyć. Rozwiń ponownie, aby spróbować jeszcze raz. + + + Odśwież + + + Pokaż lub ukryj listę przesyłania + + + w toku + + + zweryfikowane + + + nieudane + + + Podgląd + + + Zamknij podgląd + + + Wybierz plik, aby zobaczyć podgląd. + + + Wczytywanie podglądu… + + + Na żywo + + + Śledź koniec pliku, gdy rośnie + + + Pokazano pierwsze 2 MB. Pobierz plik, aby zobaczyć całość. + + + Ten plik jest za duży na podgląd. Pobierz go, aby go otworzyć. + + + Ten plik nie jest tekstem, więc nie ma tu nic do pokazania. + + + Zaktualizuj RemEx na tym urządzeniu, aby podglądać jego pliki. + + + Nie udało się wczytać podglądu. + + + Przewiń, aby powiększyć, przeciągnij, aby przesunąć, kliknij dwukrotnie, aby dopasować + + + Szczegóły + + + Odcisk SHA-256 + + + Oblicz SHA-256 + + + Obliczanie odcisku… + + + Kopiuj + + + Wklej SHA-256, aby porównać + + + Zgadza się + + + Nie zgadza się + + + To nie jest SHA-256. Powinien mieć 64 znaki szesnastkowe. + + + To urządzenie nie umie jeszcze obliczać odcisków. Zaktualizuj na nim RemEx. + + + Nie udało się obliczyć odcisku. Sprawdź, czy plik nie jest używany, i spróbuj ponownie. + + + Porównaj z kopią na innym urządzeniu + + + Urządzenie + + + Folder udostępniony + + + Plik na tym urządzeniu + + + Porównaj + + + Porównywanie obu kopii… + + + Identyczne: obie kopie mają ten sam odcisk. + + + Różne: obie kopie się nie zgadzają. + + + Nie udało się porównać. Sprawdź nazwę pliku i czy oba urządzenia są połączone. + + + Folderów nie wysyła się w ten sposób. Użyj opcji Prześlij folder lub Pobierz folder. + + + Pliki nie mogą trafić bezpośrednio z jednego telefonu na drugi. Najpierw skopiuj je na ten komputer. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.pt-BR.resx b/remex.desktop/Localization/Strings.pt-BR.resx index 5a7f151f0..041538889 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. @@ -4268,4 +4256,151 @@ Para impedir que as rotinas comecem sozinhas, clique em Pausar tudo na página R Aguardando um celular + + Verificado + + + Pastas + + + Mais ações + + + Mostrar ou ocultar a visualização (Espaço) + + + Visualizar + + + Enviar para + + + Copiar caminho + + + Calcular SHA-256 + + + Copiar SHA-256 + + + Não foi possível abrir. Expanda de novo para tentar outra vez. + + + Atualizar + + + Mostrar ou ocultar a lista de transferências + + + em andamento + + + verificados + + + com falha + + + Visualização + + + Fechar a visualização + + + Selecione um arquivo para visualizá-lo. + + + Carregando a visualização… + + + Ao vivo + + + Acompanhar o fim do arquivo enquanto ele cresce + + + Mostrando os primeiros 2 MB. Baixe o arquivo para ver tudo. + + + Este arquivo é grande demais para visualizar. Baixe-o para abri-lo. + + + Este arquivo não é texto, então não há nada para mostrar aqui. + + + Atualize o RemEx neste dispositivo para visualizar os arquivos dele. + + + Não foi possível carregar a visualização. + + + Role para ampliar, arraste para mover e clique duas vezes para ajustar + + + Detalhes + + + Impressão SHA-256 + + + Calcular SHA-256 + + + Calculando a impressão… + + + Copiar + + + Cole um SHA-256 para comparar + + + Corresponde + + + Não corresponde + + + Isso não é um SHA-256. Ele deve ter 64 caracteres hexadecimais. + + + Este dispositivo ainda não calcula impressões. Atualize o RemEx nele. + + + Não foi possível calcular a impressão. Verifique se o arquivo não está em uso e tente de novo. + + + Comparar com uma cópia em outro dispositivo + + + Dispositivo + + + Pasta compartilhada + + + Arquivo nesse dispositivo + + + Comparar + + + Comparando as duas cópias… + + + Idênticos: as duas cópias têm a mesma impressão. + + + Diferentes: as duas cópias não correspondem. + + + Não foi possível comparar. Verifique o nome do arquivo e se os dois dispositivos estão conectados. + + + Pastas não são enviadas assim. Use Enviar pasta ou Baixar pasta. + + + Arquivos não podem ir direto de um celular para outro. Copie-os primeiro para este PC. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.resx b/remex.desktop/Localization/Strings.resx index e072859ab..e0710e505 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. @@ -4274,4 +4262,151 @@ To stop routines from starting on their own, click Pause all on the Routines pag Waiting for a phone + + Verified + + + Folders + + + More actions + + + Show or hide the preview (Space) + + + Preview + + + Send to + + + Copy path + + + Compute SHA-256 + + + Copy SHA-256 + + + Couldn't open. Expand again to retry. + + + Refresh + + + Show or hide the transfer list + + + in progress + + + verified + + + failed + + + Preview + + + Close the preview + + + Select a file to preview it. + + + Loading the preview… + + + Live + + + Follow the end of the file as it grows + + + Showing the first 2 MB. Download the file to see all of it. + + + This file is too big to preview. Download it to open it. + + + This file isn't text, so there's nothing to show here. + + + Update RemEx on this device to preview its files. + + + The preview couldn't be loaded. + + + Scroll to zoom, drag to move, double-click to fit + + + Details + + + SHA-256 fingerprint + + + Compute SHA-256 + + + Computing the fingerprint… + + + Copy + + + Paste a SHA-256 to compare + + + Matches + + + Doesn't match + + + That isn't a SHA-256. It should be 64 hexadecimal characters. + + + This device can't compute fingerprints yet. Update RemEx on it. + + + Couldn't compute the fingerprint. Check the file isn't in use, then try again. + + + Compare with a copy on another device + + + Device + + + Shared folder + + + File on that device + + + Compare + + + Comparing the two copies… + + + Identical: both copies have the same fingerprint. + + + Different: the two copies don't match. + + + Couldn't compare. Check the file name and that both devices are connected. + + + Folders aren't sent this way. Use Upload folder or Download folder instead. + + + Files can't go straight from one phone to another. Copy them to this PC first. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.tr.resx b/remex.desktop/Localization/Strings.tr.resx index 2f71380fc..6808515e6 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. @@ -4272,4 +4260,151 @@ Rutinlerin kendiliğinden başlamasını durdurmak için Rutinler sayfasında T Telefon bekleniyor + + Doğrulandı + + + Klasörler + + + Diğer işlemler + + + Önizlemeyi göster veya gizle (Boşluk) + + + Önizle + + + Şuraya gönder + + + Yolu kopyala + + + SHA-256 hesapla + + + SHA-256'yı kopyala + + + Açılamadı. Yeniden denemek için tekrar genişletin. + + + Yenile + + + Aktarım listesini göster veya gizle + + + sürüyor + + + doğrulandı + + + başarısız + + + Önizleme + + + Önizlemeyi kapat + + + Önizlemek için bir dosya seçin. + + + Önizleme yükleniyor… + + + Canlı + + + Dosya büyüdükçe sonunu takip et + + + İlk 2 MB gösteriliyor. Tamamını görmek için dosyayı indirin. + + + Bu dosya önizleme için çok büyük. Açmak için indirin. + + + Bu dosya metin değil, burada gösterilecek bir şey yok. + + + Dosyalarını önizlemek için bu cihazda RemEx'i güncelleyin. + + + Önizleme yüklenemedi. + + + Yakınlaştırmak için kaydırın, taşımak için sürükleyin, sığdırmak için çift tıklayın + + + Ayrıntılar + + + SHA-256 parmak izi + + + SHA-256 hesapla + + + Parmak izi hesaplanıyor… + + + Kopyala + + + Karşılaştırmak için bir SHA-256 yapıştırın + + + Eşleşiyor + + + Eşleşmiyor + + + Bu bir SHA-256 değil. 64 onaltılık karakter olmalı. + + + Bu cihaz henüz parmak izi hesaplayamıyor. Üzerindeki RemEx'i güncelleyin. + + + Parmak izi hesaplanamadı. Dosyanın kullanımda olmadığını kontrol edip tekrar deneyin. + + + Başka bir cihazdaki kopyayla karşılaştır + + + Cihaz + + + Paylaşılan klasör + + + O cihazdaki dosya + + + Karşılaştır + + + İki kopya karşılaştırılıyor… + + + Aynı: iki kopyanın parmak izi aynı. + + + Farklı: iki kopya eşleşmiyor. + + + Karşılaştırılamadı. Dosya adını ve iki cihazın da bağlı olduğunu kontrol edin. + + + Klasörler bu şekilde gönderilmez. Bunun yerine Klasör yükle veya Klasörü indir'i kullanın. + + + Dosyalar bir telefondan diğerine doğrudan gidemez. Önce bu bilgisayara kopyalayın. + \ No newline at end of file diff --git a/remex.desktop/Localization/Strings.uk.resx b/remex.desktop/Localization/Strings.uk.resx index bc500598c..d4cb521fe 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. @@ -4269,4 +4257,151 @@ Windows: RemEx потрібні права адміністратора, бо ц Очікування телефона + + Перевірено + + + Папки + + + Інші дії + + + Показати або сховати перегляд (Пробіл) + + + Переглянути + + + Надіслати на + + + Копіювати шлях + + + Обчислити SHA-256 + + + Копіювати SHA-256 + + + Не вдалося відкрити. Розгорніть ще раз, щоб повторити. + + + Оновити + + + Показати або сховати список передавань + + + триває + + + перевірено + + + не вдалося + + + Перегляд + + + Закрити перегляд + + + Виберіть файл, щоб переглянути його. + + + Завантаження перегляду… + + + Наживо + + + Стежити за кінцем файлу, поки він росте + + + Показано перші 2 МБ. Завантажте файл, щоб побачити його повністю. + + + Цей файл завеликий для перегляду. Завантажте його, щоб відкрити. + + + Цей файл не є текстом, тож тут нічого показати. + + + Оновіть RemEx на цьому пристрої, щоб переглядати його файли. + + + Не вдалося завантажити перегляд. + + + Прокрутіть, щоб масштабувати, перетягніть, щоб посунути, двічі клацніть, щоб вмістити + + + Відомості + + + Відбиток SHA-256 + + + Обчислити SHA-256 + + + Обчислення відбитка… + + + Копіювати + + + Вставте SHA-256 для порівняння + + + Збігається + + + Не збігається + + + Це не SHA-256. Він має містити 64 шістнадцяткові символи. + + + Цей пристрій ще не вміє обчислювати відбитки. Оновіть на ньому RemEx. + + + Не вдалося обчислити відбиток. Переконайтеся, що файл не використовується, і спробуйте ще раз. + + + Порівняти з копією на іншому пристрої + + + Пристрій + + + Спільна папка + + + Файл на тому пристрої + + + Порівняти + + + Порівняння двох копій… + + + Однакові: обидві копії мають той самий відбиток. + + + Різні: дві копії не збігаються. + + + Не вдалося порівняти. Перевірте назву файлу і що обидва пристрої підключені. + + + Папки так не надсилаються. Скористайтеся «Вивантажити папку» або «Завантажити папку». + + + Файли не можна передати напряму з одного телефона на інший. Спершу скопіюйте їх на цей ПК. + \ No newline at end of file diff --git a/remex.desktop/Services/FilePreview/PreviewKind.cs b/remex.desktop/Services/FilePreview/PreviewKind.cs new file mode 100644 index 000000000..f25bccff2 --- /dev/null +++ b/remex.desktop/Services/FilePreview/PreviewKind.cs @@ -0,0 +1,68 @@ +namespace Remex.Desktop.Services.FilePreview; + +/// What the preview pane shows for a file (2026-10-08 redesign). +public enum PreviewKind +{ + /// A folder, or a file with nothing to preview: a large icon and the details. + None, + + /// An image the PC can decode at full size, with zoom and pan. + Image, + + /// An image only the host can decode (HEIC/HEIF): its thumbnail, plus "Download to view full size". + 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 from a file name. Pure, so the rules are unit-tested. +public static class PreviewClassifier +{ + private static readonly HashSet DecodableImages = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + }; + + private static readonly HashSet HostOnlyImages = new(StringComparer.OrdinalIgnoreCase) + { + ".heic", ".heif", + }; + + // Formats that are text but would otherwise be sniffed: listing them skips the extra read. + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".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 static readonly HashSet KnownBinary = new(StringComparer.OrdinalIgnoreCase) + { + ".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 ; a folder is always . + public static PreviewKind Classify(string fileName, bool isDirectory) + { + if (isDirectory || string.IsNullOrWhiteSpace(fileName)) + return PreviewKind.None; + + var ext = Path.GetExtension(fileName); + if (DecodableImages.Contains(ext)) return PreviewKind.Image; + if (HostOnlyImages.Contains(ext)) return PreviewKind.ImageThumbnailOnly; + if (TextExtensions.Contains(ext)) return PreviewKind.Text; + if (KnownBinary.Contains(ext)) return PreviewKind.None; + return PreviewKind.Sniff; + } +} diff --git a/remex.desktop/Services/FilePreview/PreviewLoaders.cs b/remex.desktop/Services/FilePreview/PreviewLoaders.cs new file mode 100644 index 000000000..1debd31d5 --- /dev/null +++ b/remex.desktop/Services/FilePreview/PreviewLoaders.cs @@ -0,0 +1,179 @@ +using System.Text; +using Remex.Core.Models; + +namespace Remex.Desktop.Services.FilePreview; + +/// +/// Reads one range of the previewed file: from offset, or the last length bytes when +/// fromEnd is true. In the app this is +/// bound to one root and path; tests supply an in-memory file. +/// +public delegate Task RangeReader(long offset, int length, bool fromEnd, CancellationToken ct); + +/// The file is bigger than a preview will fetch; the screen offers "Download" instead. +public sealed class PreviewTooLargeException(long fileSize, long limit) + : IOException($"The file is {fileSize} bytes; previews stop at {limit}.") +{ + public long FileSize { get; } = fileSize; + public long Limit { get; } = limit; +} + +/// Fetches a whole image for the full-size preview, in 1 MiB reads (2026-10-08 redesign). +public static class ImagePreviewLoader +{ + /// + /// All of the file's bytes, read in order. Refuses before fetching more than the first read when the file + /// is over , and refuses a host that stops making progress rather than looping. + /// + /// 0..1 of the bytes fetched so far. + public static async Task LoadAsync( + RangeReader read, IProgress? progress, CancellationToken ct, + long maxBytes = FileTransferLimits.PreviewImageMaxBytes) + { + var first = await read(0, FileTransferLimits.ReadRangeMaxBytes, false, ct); + if (first.FileSize > maxBytes) + throw new PreviewTooLargeException(first.FileSize, maxBytes); + + using var buffer = new MemoryStream(capacity: (int)Math.Max(0, first.FileSize)); + var chunk = first; + while (true) + { + var data = Convert.FromBase64String(chunk.DataBase64 ?? string.Empty); + if (chunk.Offset != buffer.Length) + throw new IOException("The host returned bytes out of order."); + buffer.Write(data); + if (buffer.Length > maxBytes) + throw new PreviewTooLargeException(buffer.Length, maxBytes); + if (chunk.FileSize > 0) + progress?.Report(Math.Clamp((double)buffer.Length / chunk.FileSize, 0, 1)); + if (chunk.Eof) + break; + if (data.Length == 0) + throw new IOException("The host stopped sending before the end of the file."); + chunk = await read(buffer.Length, FileTransferLimits.ReadRangeMaxBytes, false, ct); + } + + progress?.Report(1); + return buffer.ToArray(); + } +} + +/// The start of a text file, decoded for the preview. +public sealed record TextPreview(string Text, bool IsBinary, bool Truncated, long FileSize); + +/// Fetches and decodes the start of a text file, up to 2 MiB (2026-10-08 redesign). +public static class TextPreviewLoader +{ + /// + /// The first 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. + /// + public static async Task LoadHeadAsync( + RangeReader read, CancellationToken ct, int maxBytes = FileTransferLimits.PreviewTextMaxBytes) + { + var bytes = new List(); + long offset = 0; + FileReadRangeResponse chunk; + do + { + var want = Math.Min(FileTransferLimits.ReadRangeMaxBytes, maxBytes - bytes.Count); + chunk = await read(offset, want, false, ct); + var data = Convert.FromBase64String(chunk.DataBase64 ?? string.Empty); + if (offset == 0 && TextPreviewDecoder.LooksBinary(data)) + return new TextPreview(string.Empty, IsBinary: true, Truncated: false, chunk.FileSize); + bytes.AddRange(data); + offset += data.Length; + if (data.Length == 0) break; + } + while (!chunk.Eof && bytes.Count < maxBytes); + + var truncated = !chunk.Eof; + var text = TextPreviewDecoder.Decode(bytes.ToArray(), startsMidFile: false, endsMidFile: truncated); + return new TextPreview(text, IsBinary: false, truncated, chunk.FileSize); + } +} + +/// What one live-tail step changed. +/// True when the file shrank or was replaced: throw away what is shown and use . +/// Complete lines to append (or, on a reset, the whole visible tail). +/// The last line so far, still being written (no newline yet); replaces the previous one. +public sealed record TailUpdate(bool Reset, IReadOnlyList Lines, string PendingLine, long FileSize); + +/// +/// Live tail for a growing text file (2026-10-08 redesign): starts at the last 256 KiB, then on every poll +/// reads only what was added since. A file that shrinks (rotated, truncated, replaced) restarts from its new +/// tail. A line still being written is shown as pending and finished once its newline arrives, and a +/// multi-byte character split across two polls is never shown as U+FFFD. +/// +public sealed class TextTail(RangeReader read, int tailBytes = FileTransferLimits.PreviewTextTailBytes) +{ + private long _nextOffset; + private byte[] _carry = []; + private bool _started; + + /// Reads the tail and returns it as a reset. Safe to call again to start over. + public async Task StartAsync(CancellationToken ct) + { + var chunk = await read(0, tailBytes, true, ct); + var data = Convert.FromBase64String(chunk.DataBase64 ?? string.Empty); + _started = true; + _carry = []; + _nextOffset = chunk.Offset + data.Length; + // A tail that starts mid-file drops its first, partial line (Decode with startsMidFile). + if (chunk.Offset > 0) + { + var newline = Array.IndexOf(data, (byte)'\n'); + data = newline >= 0 ? data[(newline + 1)..] : []; + } + var (lines, pending) = Split(data); + return new TailUpdate(Reset: true, lines, pending, chunk.FileSize); + } + + /// Reads what was added since the last call (or restarts when the file shrank). + public async Task PollAsync(CancellationToken ct) + { + if (!_started) + return await StartAsync(ct); + + var chunk = await read(_nextOffset, FileTransferLimits.ReadRangeMaxBytes, false, ct); + if (chunk.FileSize < _nextOffset) + return await StartAsync(ct); + + var data = Convert.FromBase64String(chunk.DataBase64 ?? string.Empty); + _nextOffset += data.Length; + var (lines, pending) = Split([.. _carry, .. data]); + return new TailUpdate(Reset: false, lines, pending, chunk.FileSize); + } + + /// + /// Complete lines (each without its newline or a trailing CR) plus the unfinished last line. The unfinished + /// bytes are kept for the next poll, so a line or a character split across reads joins up again. + /// + private (IReadOnlyList Lines, string Pending) Split(byte[] bytes) + { + var lastNewline = Array.LastIndexOf(bytes, (byte)'\n'); + var complete = lastNewline >= 0 ? bytes[..(lastNewline + 1)] : []; + _carry = lastNewline >= 0 ? bytes[(lastNewline + 1)..] : bytes; + // A file that never writes a newline (a minified log, a binary written by mistake) would otherwise grow + // the carry by a read every poll, forever. Keep only the last tailBytes of an unfinished line, starting + // on a character boundary. + if (_carry.Length > tailBytes) + { + var start = _carry.Length - tailBytes; + while (start < _carry.Length && (_carry[start] & 0b1100_0000) == 0b1000_0000) start++; + _carry = _carry[start..]; + } + + var lines = new List(); + if (complete.Length > 0) + { + var text = Encoding.UTF8.GetString(complete); + foreach (var line in text.Split('\n')) + lines.Add(line.TrimEnd('\r')); + lines.RemoveAt(lines.Count - 1); // the empty string after the final newline + } + + var pending = Encoding.UTF8.GetString(_carry, 0, TextPreviewDecoder.CompleteUtf8Length(_carry)).TrimEnd('\r'); + return (lines, pending); + } +} diff --git a/remex.desktop/Services/FilePreview/SyntaxTokenizer.cs b/remex.desktop/Services/FilePreview/SyntaxTokenizer.cs new file mode 100644 index 000000000..11a602d24 --- /dev/null +++ b/remex.desktop/Services/FilePreview/SyntaxTokenizer.cs @@ -0,0 +1,242 @@ +namespace Remex.Desktop.Services.FilePreview; + +/// What a coloured stretch of a previewed line is (2026-10-08 redesign). +public enum 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 + ). +public readonly record struct SyntaxSpan(int Start, int Length, SyntaxKind Kind); + +/// Which rules colour a previewed file. +public enum 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. Deliberately not a parser. Each line is coloured on its own, with no state +/// carried between lines, so a live tail can colour each new line as it arrives and a block comment spanning +/// lines is simply left plain. Pure, so every rule is unit-tested; the Kotlin twin follows the same rules. +/// +public static class SyntaxTokenizer +{ + /// The rules for , by extension. + public static SyntaxLanguage LanguageFor(string fileName) => + Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".log" or ".txt" => SyntaxLanguage.Log, + ".json" or ".jsonc" => SyntaxLanguage.Json, + ".xml" or ".axaml" or ".xaml" or ".html" or ".htm" or ".csproj" or ".props" or ".targets" or ".resx" => SyntaxLanguage.Xml, + ".cs" or ".kt" or ".kts" or ".java" or ".gradle" or ".js" or ".ts" or ".tsx" or ".jsx" or ".mjs" or ".c" or ".h" + or ".cpp" or ".hpp" or ".go" or ".rs" or ".css" => SyntaxLanguage.CLike, + ".py" or ".rb" or ".sh" or ".bash" or ".zsh" or ".ps1" or ".psm1" or ".psd1" or ".yaml" or ".yml" or ".toml" + or ".ini" or ".cfg" or ".conf" or ".properties" or ".env" or ".gitignore" or ".editorconfig" => SyntaxLanguage.Hash, + _ => SyntaxLanguage.Plain, + }; + + /// The coloured stretches of , in order and non-overlapping. Plain gaps are omitted. + public static IReadOnlyList Tokenize(string line, SyntaxLanguage language) => language switch + { + 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), + _ => [], + }; + + // Upper case as most loggers write them, plus the four-letter lower-case forms the .NET console logger + // (and so RemEx's own logs) writes: "fail:", "crit:", "warn:", "dbug:", "trce:". Whole words only, so + // "NoErrors" or "failover" never colour a line. + private static readonly string[] ErrorWords = ["ERROR", "ERR", "FATAL", "CRITICAL", "CRIT", "FAIL", "FAILED", "EXCEPTION", "fail", "crit"]; + private static readonly string[] WarningWords = ["WARN", "WARNING", "WRN", "warn"]; + private static readonly string[] DebugWords = ["DEBUG", "DBG", "TRACE", "TRC", "VERBOSE", "VRB", "dbug", "trce"]; + + private static IReadOnlyList TokenizeLog(string line) + { + var spans = new List(); + var timestampEnd = LeadingTimestampLength(line); + if (timestampEnd > 0) + spans.Add(new SyntaxSpan(0, timestampEnd, SyntaxKind.Timestamp)); + + var level = LevelOf(line); + if (level != SyntaxKind.Plain && timestampEnd < line.Length) + spans.Add(new SyntaxSpan(timestampEnd, line.Length - timestampEnd, level)); + return spans; + } + + /// The level a log line is at, judged by a whole level word (ERROR, WARN, DEBUG…), case-sensitive. + internal static SyntaxKind LevelOf(string line) + { + foreach (var word in Words(line)) + { + if (Array.IndexOf(ErrorWords, word) >= 0) return SyntaxKind.LogError; + if (Array.IndexOf(WarningWords, word) >= 0) return SyntaxKind.LogWarning; + if (Array.IndexOf(DebugWords, word) >= 0) return SyntaxKind.LogDebug; + } + return SyntaxKind.Plain; + } + + private static IEnumerable Words(string line) + { + var i = 0; + while (i < line.Length) + { + while (i < line.Length && !char.IsAsciiLetter(line[i])) i++; + var start = i; + while (i < line.Length && char.IsAsciiLetter(line[i])) i++; + if (i > start) yield return line[start..i]; + } + } + + /// + /// The length of a leading timestamp: an optional '[', then digits and the separators of a date or time + /// (2026-10-08 14:03:22.123, 14:03:22, 2026-10-08T14:03:22Z), at least one ':' or '-', + /// and an optional ']'. Zero when the line does not start with one. + /// + internal static int LeadingTimestampLength(string line) + { + var i = 0; + if (i < line.Length && line[i] == '[') i++; + var digitsStart = i; + var sawSeparator = false; + var sawDigit = false; + while (i < line.Length) + { + var c = line[i]; + if (char.IsAsciiDigit(c)) { sawDigit = true; i++; } + else if (c is ':' or '-' or '/') { sawSeparator = true; i++; } + else if (c is '.' or ',' or 'T' or 'Z' or '+' && sawDigit) i++; + else if (c == ' ' && i + 1 < line.Length && char.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 static IReadOnlyList TokenizeCode(string line, string? lineComment, bool jsonKeys) + { + var spans = new List(); + var i = 0; + while (i < line.Length) + { + var c = line[i]; + if (lineComment is not null && string.CompareOrdinal(line, i, lineComment, 0, lineComment.Length) == 0) + { + spans.Add(new SyntaxSpan(i, line.Length - i, SyntaxKind.Comment)); + break; + } + if (c is '"' or '\'' || (c == '`' && !jsonKeys)) + { + var end = StringEnd(line, i); + var kind = jsonKeys && c == '"' && NextNonSpace(line, end) == ':' ? SyntaxKind.Key : SyntaxKind.String; + spans.Add(new SyntaxSpan(i, end - i, kind)); + i = end; + continue; + } + if (char.IsAsciiDigit(c) && (i == 0 || !IsWordChar(line[i - 1]))) + { + var end = i; + while (end < line.Length && (char.IsAsciiHexDigit(line[end]) || line[end] is '.' or 'x' or 'X' or '_')) end++; + if (end == line.Length || !IsWordChar(line[end])) + { + spans.Add(new SyntaxSpan(i, end - i, SyntaxKind.Number)); + i = end; + continue; + } + } + if (jsonKeys && char.IsAsciiLetter(c) && (i == 0 || !IsWordChar(line[i - 1]))) + { + var end = i; + while (end < line.Length && char.IsAsciiLetter(line[end])) end++; + if (line.AsSpan(i, end - i) is "true" or "false" or "null") + spans.Add(new SyntaxSpan(i, end - i, SyntaxKind.Keyword)); + i = end; + continue; + } + i++; + } + return spans; + } + + private static IReadOnlyList TokenizeXml(string line) + { + var spans = new List(); + var i = 0; + while (i < line.Length) + { + if (string.CompareOrdinal(line, i, "", i + 4, StringComparison.Ordinal); + var end = close < 0 ? line.Length : close + 3; + spans.Add(new SyntaxSpan(i, end - i, SyntaxKind.Comment)); + i = end; + continue; + } + if (line[i] == '<') + { + var start = i + 1; + if (start < line.Length && line[start] is '/' or '?' or '!') start++; + var end = start; + while (end < line.Length && (IsWordChar(line[end]) || line[end] is ':' or '-' or '.')) end++; + if (end > start) + spans.Add(new SyntaxSpan(start, end - start, SyntaxKind.Key)); + i = end; + continue; + } + if (line[i] is '"' or '\'') + { + var end = StringEnd(line, i); + spans.Add(new SyntaxSpan(i, end - i, SyntaxKind.String)); + i = end; + continue; + } + i++; + } + return spans; + } + + /// One past the closing quote of the string starting at , honouring backslash escapes; the line's end if unclosed. + private static int StringEnd(string line, int open) + { + var 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 static char NextNonSpace(string line, int from) + { + for (var i = from; i < line.Length; i++) + if (!char.IsWhiteSpace(line[i])) return line[i]; + return '\0'; + } + + private static bool IsWordChar(char c) => char.IsAsciiLetterOrDigit(c) || c == '_'; +} diff --git a/remex.desktop/Services/FilePreview/TextPreviewDecoder.cs b/remex.desktop/Services/FilePreview/TextPreviewDecoder.cs new file mode 100644 index 000000000..0e260ce55 --- /dev/null +++ b/remex.desktop/Services/FilePreview/TextPreviewDecoder.cs @@ -0,0 +1,88 @@ +using System.Text; + +namespace Remex.Desktop.Services.FilePreview; + +/// +/// Turns the raw bytes of a text preview into a string (2026-10-08 redesign). Pure, so the edge cases are +/// unit-tested: 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. +/// +public static class TextPreviewDecoder +{ + /// How many leading bytes are checked for NUL when deciding whether a file is binary. + public const int SniffBytes = 8 * 1024; + + /// + /// True when look binary: a NUL in the first . UTF-16 text + /// has NULs too, so a UTF-16 byte-order mark is checked first and wins. + /// + public static bool LooksBinary(ReadOnlySpan bytes) + { + if (StartsWithUtf16Bom(bytes)) + return false; + var head = bytes[..Math.Min(bytes.Length, SniffBytes)]; + return head.IndexOf((byte)0) >= 0; + } + + /// + /// Decodes : UTF-8 (with or without a BOM) or UTF-16 by its BOM, invalid sequences + /// shown as U+FFFD rather than refused. + /// + /// + /// True when the bytes are a tail, not the start of the file. The first, probably partial, line is then + /// dropped, along with any continuation bytes of a character that began before the read. + /// + /// + /// True when the file goes on past these bytes. A multi-byte character cut by the end of the read is then + /// dropped instead of becoming U+FFFD (the next read starts with it whole). + /// + public static string Decode(ReadOnlySpan bytes, bool startsMidFile = false, bool endsMidFile = false) + { + if (!startsMidFile) + { + if (bytes.StartsWith((ReadOnlySpan)[0xFF, 0xFE])) + return Encoding.Unicode.GetString(bytes[2..]); + if (bytes.StartsWith((ReadOnlySpan)[0xFE, 0xFF])) + return Encoding.BigEndianUnicode.GetString(bytes[2..]); + if (bytes.StartsWith((ReadOnlySpan)[0xEF, 0xBB, 0xBF])) + bytes = bytes[3..]; + } + + if (endsMidFile) + bytes = bytes[..CompleteUtf8Length(bytes)]; + + if (startsMidFile) + { + var newline = bytes.IndexOf((byte)'\n'); + bytes = newline >= 0 ? bytes[(newline + 1)..] : []; + } + + return Encoding.UTF8.GetString(bytes); + } + + /// + /// The length of without a trailing, incomplete UTF-8 sequence. Looks back at most + /// three bytes, which is as far as a sequence can be cut. + /// + public static int CompleteUtf8Length(ReadOnlySpan bytes) + { + var n = bytes.Length; + for (var back = 1; back <= 3 && back <= n; back++) + { + var b = bytes[n - back]; + if ((b & 0b1100_0000) == 0b1000_0000) + continue; // a continuation byte: keep looking for the lead + var need = (b & 0b1000_0000) == 0 ? 1 + : (b & 0b1110_0000) == 0b1100_0000 ? 2 + : (b & 0b1111_0000) == 0b1110_0000 ? 3 + : (b & 0b1111_1000) == 0b1111_0000 ? 4 + : 1; // not a valid lead: leave it for the decoder to replace + return back < need ? n - back : n; + } + + return n; + } + + private static bool StartsWithUtf16Bom(ReadOnlySpan bytes) => + bytes.StartsWith((ReadOnlySpan)[0xFF, 0xFE]) || bytes.StartsWith((ReadOnlySpan)[0xFE, 0xFF]); +} diff --git a/remex.desktop/Services/FilePreview/ThumbnailCache.cs b/remex.desktop/Services/FilePreview/ThumbnailCache.cs new file mode 100644 index 000000000..7560dc9e0 --- /dev/null +++ b/remex.desktop/Services/FilePreview/ThumbnailCache.cs @@ -0,0 +1,120 @@ +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Remex.Desktop.Services.FileTransfer; + +namespace Remex.Desktop.Services.FilePreview; + +/// +/// Thumbnails for the File Transfer list and grid (2026-10-08 redesign): fetched only for rows on screen, at most +/// at a time, once per file version (device, shared folder, path, modified time), and +/// kept for the most recent files. Two rows asking for the same file share one fetch. +/// +public sealed class ThumbnailCache +{ + /// How many thumbnails are kept. + public const int Capacity = 400; + + /// How many are fetched at once, so scrolling a folder of photos does not flood a phone. + public const int MaxConcurrent = 4; + + /// The edge length requested from the host; the list shows 24 px and the grid up to 176 px. + public const int RequestedMaxDim = 192; + + public readonly record struct Key(string Device, string RootId, string Path, long ModifiedUnixMs); + + private readonly object _gate = new(); + private readonly Dictionary> _entries = new(); + private readonly LinkedList<(Key Key, IImage? Image)> _order = new(); + private readonly Dictionary> _inFlight = new(); + private readonly SemaphoreSlim _slots = new(MaxConcurrent); + + /// Turns the host's Base64 JPEG into an image. A seam: unit tests run without a renderer. + internal Func Decode { get; set; } = base64 => + { + using var stream = new MemoryStream(Convert.FromBase64String(base64)); + return new Bitmap(stream); + }; + + /// + /// The thumbnail for , fetched through if it is not cached. + /// Null when the host has none (not an image, or undecodable); a null answer is cached too, so it is not asked again. + /// + public Task GetAsync(FileTransferClient client, Key key, CancellationToken ct) + { + lock (_gate) + { + if (_entries.TryGetValue(key, out var node)) + { + _order.Remove(node); + _order.AddFirst(node); + return Task.FromResult(node.Value.Image); + } + + if (_inFlight.TryGetValue(key, out var pending)) + return pending; + + var fetch = FetchAsync(client, key, ct); + _inFlight[key] = fetch; + return fetch; + } + } + + private async Task FetchAsync(FileTransferClient client, Key key, CancellationToken ct) + { + IImage? image = null; + var keep = false; + try + { + await _slots.WaitAsync(ct); + try + { + var base64 = await client.GetThumbnailRemoteAsync(key.RootId, key.Path, RequestedMaxDim, ct); + image = string.IsNullOrEmpty(base64) ? null : Decode(base64); + keep = true; + } + finally + { + _slots.Release(); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // A row scrolled away (cancelled) or the host failed: nothing cached, so it is tried again later. + image = null; + } + finally + { + lock (_gate) + { + _inFlight.Remove(key); + if (keep) + Add(key, image); + } + } + + return image; + } + + private void Add(Key key, IImage? image) + { + if (_entries.Remove(key, out var stale)) + _order.Remove(stale); + var node = _order.AddFirst((key, image)); + _entries[key] = node; + while (_entries.Count > Capacity && _order.Last is { } oldest) + { + _order.RemoveLast(); + _entries.Remove(oldest.Value.Key); + } + } + + /// How many thumbnails are cached (for tests). + internal int Count + { + get + { + lock (_gate) + return _entries.Count; + } + } +} diff --git a/remex.desktop/Services/FileTransfer/FileTransferClient.cs b/remex.desktop/Services/FileTransfer/FileTransferClient.cs index 5d6118053..292b33a80 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; @@ -278,6 +279,15 @@ public bool SupportsOp(string op) => /// public bool SupportsPcChanges => SupportsV3 && Capabilities?.PcChanges == true; + /// True when the host answers file_read_range_request, so its files can be previewed (2026-10-08). + public bool SupportsReadRange => SupportsV3 && Capabilities?.ReadRange == true; + + /// + /// True when the host answers file_hash_request. The PC always has; a phone does from the 2026-10-08 + /// redesign on, and an older one would leave "Compute SHA-256" waiting out its timeout, so it is hidden there. + /// + public bool SupportsHash => SupportsV3 && Capabilities?.Hash == true; + public async Task> BrowseRemoteAsync(string rootId, string relativePath, CancellationToken ct) { var requestId = Guid.NewGuid().ToString("N"); @@ -373,6 +383,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 +412,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 +436,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 +747,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) @@ -970,6 +1029,7 @@ await _connection.SendAsync(new RemexMessage await acks.WaitForCommittedAsync(sent, _idleWatchdog, lastActivity, ct); var sha256Base64 = Convert.ToBase64String(hasher.GetCurrentHash()); + progress.ReportVerifying(); await _connection.SendAsync(new RemexMessage { Type = MessageTypes.FileTransferComplete, @@ -984,6 +1044,9 @@ await _connection.SendAsync(new RemexMessage throw FileTransferHostException.ForHostError( result?.Error, $"Upload failed: {result?.Error}"); } + + // The host compared ITS hash of what landed with ours and said yes; ours is what the row shows. + progress.ReportVerified(sha256Base64); } catch (Exception ex) when (ex is not OperationCanceledException && hostHoldsPartial) { @@ -1235,6 +1298,7 @@ await _connection.SendAsync(new RemexMessage } var sha256Base64 = Convert.ToBase64String(hasher.GetCurrentHash()); + progress.ReportVerifying(); await _connection.SendAsync(new RemexMessage { @@ -1248,6 +1312,8 @@ await _connection.SendAsync(new RemexMessage throw FileTransferHostException.ForHostError( result.FileTransferEnd.ErrorMessage, $"Upload failed: {result.FileTransferEnd.ErrorMessage}"); + + progress.ReportVerified(sha256Base64); } finally { @@ -1511,6 +1577,10 @@ await _connection.SendAsync(new RemexMessage throw new FileTransferIntegrityException(); } + // Only a COMPARED hash earns "Verified". A host that sent none proved nothing, so the row stays "Done". + if (!string.IsNullOrEmpty(expectedHash)) + progress.ReportVerified(actualHash); + completed = true; } finally @@ -1708,6 +1778,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; } } diff --git a/remex.desktop/Services/FileTransfer/TransferVerification.cs b/remex.desktop/Services/FileTransfer/TransferVerification.cs new file mode 100644 index 000000000..1dbb0f879 --- /dev/null +++ b/remex.desktop/Services/FileTransfer/TransferVerification.cs @@ -0,0 +1,45 @@ +using Remex.Core.Helpers; +using Remex.Desktop.ViewModels; + +namespace Remex.Desktop.Services.FileTransfer; + +/// +/// Implemented by a transfer's progress sink when it also wants to hear about the SHA-256 check +/// (2026-10-08 redesign): that the bytes are all across and the hash is being compared, and then the hash +/// that matched. The transfer queue's row implements it, so a finished transfer can say "Verified" and offer +/// the hash instead of a bare "Done". +/// +/// +/// A side interface on the every transfer already takes, rather than a new +/// parameter or return value, because the queue's work delegates and both phone-transfer entry points already +/// pass that one object through. A sink that does not implement it simply never hears; nothing else changes. +/// +public interface ITransferVerificationSink +{ + /// Every byte is across; the two ends are now comparing hashes. + void ReportVerifying(); + + /// The hashes matched. is the file's SHA-256 as the wire carries it. + void ReportVerified(string sha256Base64); +} + +/// Helpers that reach an through a plain progress reference. +public static class TransferVerification +{ + /// Tells the hash check has started, if it wants to know. + public static void ReportVerifying(this IProgress? progress) + { + if (progress is ITransferVerificationSink sink) + sink.ReportVerifying(); + } + + /// + /// Tells the transfer was verified with , if it + /// wants to know. A missing or malformed hash is not passed on: a row only says "Verified" for a real one. + /// + public static void ReportVerified(this IProgress? progress, string? sha256Base64) + { + if (progress is ITransferVerificationSink sink && HashFormat.ToHex(sha256Base64) is not null) + sink.ReportVerified(sha256Base64!); + } +} diff --git a/remex.desktop/ViewModels/AboutViewModel.cs b/remex.desktop/ViewModels/AboutViewModel.cs index 2da136c79..2b9a26a83 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() { diff --git a/remex.desktop/ViewModels/FilePreviewViewModel.cs b/remex.desktop/ViewModels/FilePreviewViewModel.cs new file mode 100644 index 000000000..8e76609d6 --- /dev/null +++ b/remex.desktop/ViewModels/FilePreviewViewModel.cs @@ -0,0 +1,639 @@ +using System.Collections.ObjectModel; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Remex.Core.Helpers; +using Remex.Core.Models; +using Remex.Desktop.Services.FilePreview; +using Remex.Desktop.Services.FileTransfer; + +namespace Remex.Desktop.ViewModels; + +/// What the preview pane is showing. +public enum PreviewState +{ + /// Nothing selected. + Nothing, + Loading, + Image, + Text, + /// A folder, or a file with nothing to preview: icon and details only. + Details, + /// Over the size a preview fetches; "Download" instead. + TooLarge, + /// A file whose bytes are not text. + Binary, + /// The device on the other end predates previews; it should be updated. + NeedsUpdate, + Failed, +} + +/// How a pasted hash compared. +public enum HashCompareOutcome { None, Match, Mismatch, NotAHash } + +/// How a "Verify against…" check ended. +public enum CounterpartOutcome { None, Identical, Different, Failed } + +/// One line of a text preview, with its colouring. is null in a live tail, where it is unknown. +public sealed record PreviewLine(int? Number, string Text, IReadOnlyList Spans); + +/// The file the preview pane is about: where it is, and the client that reaches it. +public sealed record PreviewTarget(FileTransferClient Client, FileSourceOption Source, string RootId, string RelativePath, FileEntry Entry) +{ + public bool IsPhone => !Source.IsThisPc; +} + +/// +/// The preview pane of the File Transfer screen (2026-10-08 redesign): follows the selection, shows an image at full +/// size or text with colouring and an optional live tail, the file's details, and its SHA-256 with copy, compare +/// against a pasted hash, and "Verify against…" a copy on the other device. +/// +public sealed partial class FilePreviewViewModel : ObservableObject, IDisposable +{ + /// A preview waits this long after the selection stops moving, so arrowing down a list reads nothing. + internal static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(150); + + /// A live tail keeps at most this many lines on screen. + internal const int MaxTailLines = 5000; + + private readonly Func> _clientFor; + private readonly ILogger _logger; + private CancellationTokenSource? _loadCts; + private CancellationTokenSource? _tailCts; + private SyntaxLanguage _language; + + public FilePreviewViewModel( + Func> clientFor, ILogger? logger = null) + { + _clientFor = clientFor; + _logger = logger ?? NullLogger.Instance; + } + + /// Turns image bytes into something the pane can draw. A seam: unit tests run without a renderer. + internal Func DecodeImage { get; set; } = bytes => + { + using var stream = new MemoryStream(bytes); + return new Bitmap(stream); + }; + + /// Puts text on the clipboard. Wired by the view, which owns the top level. + public Func? CopyText { get; set; } + + /// + /// Where each device was last browsed (shared folder, folder), so "Verify against…" can guess the counterpart: + /// the same file name in that folder. Supplied by the screen. + /// + public Func? LastFolderOn { get; set; } + + /// The devices "Verify against…" may use: every connected device except this file's. Supplied by the screen. + public Func>? OtherDevices { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasTarget))] + [NotifyPropertyChangedFor(nameof(IsFile))] + private PreviewTarget? _target; + + public bool HasTarget => Target is not null; + + public bool IsFile => Target is { Entry.IsDirectory: false }; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLoading))] + [NotifyPropertyChangedFor(nameof(ShowsImage))] + [NotifyPropertyChangedFor(nameof(ShowsText))] + [NotifyPropertyChangedFor(nameof(ShowsIcon))] + [NotifyPropertyChangedFor(nameof(IsTooLarge))] + [NotifyPropertyChangedFor(nameof(IsBinary))] + [NotifyPropertyChangedFor(nameof(NeedsUpdate))] + [NotifyPropertyChangedFor(nameof(HasFailed))] + private PreviewState _state; + + public bool IsLoading => State == PreviewState.Loading; + public bool ShowsImage => State == PreviewState.Image; + public bool ShowsText => State == PreviewState.Text; + public bool ShowsIcon => State is PreviewState.Details or PreviewState.TooLarge or PreviewState.Binary + or PreviewState.NeedsUpdate or PreviewState.Failed; + public bool IsTooLarge => State == PreviewState.TooLarge; + public bool IsBinary => State == PreviewState.Binary; + public bool NeedsUpdate => State == PreviewState.NeedsUpdate; + public bool HasFailed => State == PreviewState.Failed; + + /// The full-size image, while . + [ObservableProperty] + private IImage? _image; + + /// A host-made JPEG thumbnail (Base64), shown when the full image cannot be: HEIC, too large, or an older device. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasThumbnail))] + private string? _thumbnailBase64; + + public bool HasThumbnail => !string.IsNullOrEmpty(ThumbnailBase64); + + /// 0..1 while a large image is fetched. + [ObservableProperty] + private double _loadProgress; + + public ObservableCollection Lines { get; } = new(); + + /// The line still being written in a live tail (no newline yet). + [ObservableProperty] + private string _pendingLine = string.Empty; + + /// True when only the first 2 MiB of a text file is shown. + [ObservableProperty] + private bool _isTruncated; + + /// Whether the Live toggle is offered: text from a device that answers range reads. + [ObservableProperty] + private bool _canTail; + + [ObservableProperty] + private bool _isLive; + + /// Size, dates and type, once the host has answered. + [ObservableProperty] + private FileMetadataResponse? _metadata; + + /// A plain reason the preview could not be shown (the host's own words when it gave some). + [ObservableProperty] + private string? _failureText; + + // ── Integrity ── + + /// Whether "Compute SHA-256" is offered: a file, on a device that answers hash requests. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ComputeHashCommand))] + private bool _canComputeHash; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ComputeHashCommand))] + private bool _isHashing; + + /// The file's SHA-256 in lowercase hex, once computed. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasHash))] + [NotifyCanExecuteChangedFor(nameof(CopyHashCommand))] + private string? _hashHex; + + public bool HasHash => HashHex is not null; + + [ObservableProperty] + private string? _hashFailure; + + /// A hash the person pasted to compare against. + [ObservableProperty] + private string _compareInput = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CompareMatches))] + [NotifyPropertyChangedFor(nameof(CompareDiffers))] + [NotifyPropertyChangedFor(nameof(CompareNotAHash))] + private HashCompareOutcome _compareOutcome; + + public bool CompareMatches => CompareOutcome == HashCompareOutcome.Match; + public bool CompareDiffers => CompareOutcome == HashCompareOutcome.Mismatch; + public bool CompareNotAHash => CompareOutcome == HashCompareOutcome.NotAHash; + + partial void OnCompareInputChanged(string value) => UpdateCompare(); + partial void OnHashHexChanged(string? value) => UpdateCompare(); + + private void UpdateCompare() + { + if (string.IsNullOrWhiteSpace(CompareInput) || HashHex is null) + CompareOutcome = HashCompareOutcome.None; + else if (!HashFormat.TryNormalize(CompareInput, out _)) + CompareOutcome = HashCompareOutcome.NotAHash; + else + CompareOutcome = HashFormat.Matches(CompareInput, HashHex) ? HashCompareOutcome.Match : HashCompareOutcome.Mismatch; + } + + // ── Verify against the other device ── + + public ObservableCollection CounterpartDevices { get; } = new(); + + [ObservableProperty] + private FileSourceOption? _counterpartDevice; + + public ObservableCollection CounterpartRoots { get; } = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(VerifyAgainstCommand))] + private FileSharedRoot? _counterpartRoot; + + /// The counterpart's path inside , prefilled with the same name in the folder last browsed there. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(VerifyAgainstCommand))] + private string _counterpartPath = string.Empty; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(VerifyAgainstCommand))] + private bool _isVerifyingAgainst; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CounterpartIdentical))] + [NotifyPropertyChangedFor(nameof(CounterpartDifferent))] + [NotifyPropertyChangedFor(nameof(CounterpartFailed))] + private CounterpartOutcome _counterpartOutcome; + + public bool CounterpartIdentical => CounterpartOutcome == CounterpartOutcome.Identical; + public bool CounterpartDifferent => CounterpartOutcome == CounterpartOutcome.Different; + public bool CounterpartFailed => CounterpartOutcome == CounterpartOutcome.Failed; + + [ObservableProperty] + private string? _counterpartFailure; + + public bool HasCounterpartDevices => CounterpartDevices.Count > 0; + + // ── Showing a file ── + + /// + /// Shows after , cancelling whatever was loading or tailing. + /// Null clears the pane. + /// + public void Show(PreviewTarget? target) + { + StopTail(); + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + _ = ShowAfterDebounceAsync(target, ct); + } + + private async Task ShowAfterDebounceAsync(PreviewTarget? target, CancellationToken ct) + { + try + { + await Task.Delay(Debounce, ct); + await LoadAsync(target, ct); + } + catch (OperationCanceledException) + { + // Superseded by a newer selection; that one owns the pane now. + } + } + + /// Loads now. The debounce-free core of , for tests. + internal async Task LoadAsync(PreviewTarget? target, CancellationToken ct) + { + Reset(target); + if (target is null) + return; + + var entry = target.Entry; + _ = LoadMetadataAsync(target, ct); + PrepareIntegrity(target); + + var kind = PreviewClassifier.Classify(entry.Name, entry.IsDirectory); + if (kind == PreviewKind.None) + { + State = PreviewState.Details; + return; + } + + if (kind == PreviewKind.ImageThumbnailOnly) + { + await LoadThumbnailAsync(target, ct); + State = PreviewState.Details; + return; + } + + if (!target.Client.SupportsReadRange) + { + if (kind == PreviewKind.Image) + await LoadThumbnailAsync(target, ct); + State = PreviewState.NeedsUpdate; + return; + } + + State = PreviewState.Loading; + var read = Reader(target); + try + { + if (kind == PreviewKind.Image) + { + var progress = new Progress(p => LoadProgress = p); + var bytes = await ImagePreviewLoader.LoadAsync(read, progress, ct); + ct.ThrowIfCancellationRequested(); + Image = DecodeImage(bytes); + State = Image is null ? PreviewState.Failed : PreviewState.Image; + return; + } + + var head = await TextPreviewLoader.LoadHeadAsync(read, ct); + ct.ThrowIfCancellationRequested(); + if (head.IsBinary) + { + // A file named as text that is not, and an unknown one that turned out binary, get details only. + State = kind == PreviewKind.Sniff ? PreviewState.Details : PreviewState.Binary; + return; + } + + _language = SyntaxTokenizer.LanguageFor(entry.Name); + // A file that ends with a newline has no line after it; Split would invent an empty one. + var text = head.Text.EndsWith('\n') ? head.Text[..^1] : head.Text; + ShowLines(text.Split('\n'), numbered: true); + IsTruncated = head.Truncated; + CanTail = true; + State = PreviewState.Text; + } + catch (PreviewTooLargeException) + { + if (kind == PreviewKind.Image) + await LoadThumbnailAsync(target, ct); + State = PreviewState.TooLarge; + } + catch (OperationCanceledException) + { + throw; + } + catch (FileTransferHostException ex) + { + _logger.LogInformation(ex, "Previewing a file failed - the host refused it"); + FailureText = ex.HostMessage; + State = PreviewState.Failed; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Previewing a file failed"); + State = PreviewState.Failed; + } + } + + private void Reset(PreviewTarget? target) + { + Target = target; + State = target is null ? PreviewState.Nothing : PreviewState.Loading; + Image = null; + ThumbnailBase64 = null; + LoadProgress = 0; + Lines.Clear(); + PendingLine = string.Empty; + IsTruncated = false; + CanTail = false; + IsLive = false; + Metadata = null; + FailureText = null; + HashHex = null; + HashFailure = null; + IsHashing = false; + CompareInput = string.Empty; + CompareOutcome = HashCompareOutcome.None; + CounterpartOutcome = CounterpartOutcome.None; + CounterpartFailure = null; + CanComputeHash = false; + } + + private static RangeReader Reader(PreviewTarget target) => + (offset, length, fromEnd, ct) => target.Client.ReadRangeRemoteAsync(target.RootId, target.RelativePath, offset, length, fromEnd, ct); + + private async Task LoadMetadataAsync(PreviewTarget target, CancellationToken ct) + { + try + { + var metadata = await target.Client.GetMetadataRemoteAsync(target.RootId, target.RelativePath, ct); + if (!ct.IsCancellationRequested && ReferenceEquals(Target, target)) + Metadata = metadata; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Details are a nicety; the listing already showed size and date. + _logger.LogDebug(ex, "Reading a file's details for the preview failed"); + } + } + + private async Task LoadThumbnailAsync(PreviewTarget target, CancellationToken ct) + { + try + { + var jpeg = await target.Client.GetThumbnailRemoteAsync(target.RootId, target.RelativePath, 512, ct); + if (!ct.IsCancellationRequested && ReferenceEquals(Target, target)) + ThumbnailBase64 = jpeg; + } + catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException)) + { + _logger.LogDebug(ex, "Fetching a preview thumbnail failed"); + } + } + + private void ShowLines(IEnumerable lines, bool numbered) + { + Lines.Clear(); + var number = 1; + foreach (var raw in lines) + { + var text = raw.TrimEnd('\r'); + Lines.Add(new PreviewLine(numbered ? number : null, text, SyntaxTokenizer.Tokenize(text, _language))); + number++; + } + } + + // ── Live tail ── + + partial void OnIsLiveChanged(bool value) + { + if (value && Target is { } target && CanTail) + StartTail(target); + else + StopTail(); + } + + private void StartTail(PreviewTarget target) + { + StopTail(); + _tailCts = new CancellationTokenSource(); + _ = RunTailAsync(target, _tailCts.Token); + } + + private void StopTail() + { + _tailCts?.Cancel(); + _tailCts?.Dispose(); + _tailCts = null; + } + + private async Task RunTailAsync(PreviewTarget target, CancellationToken ct) + { + var tail = new TextTail(Reader(target)); + try + { + ApplyTail(await tail.StartAsync(ct)); + while (!ct.IsCancellationRequested) + { + await Task.Delay(FileTransferLimits.PreviewTailPollMilliseconds, ct); + ApplyTail(await tail.PollAsync(ct)); + } + } + catch (OperationCanceledException) + { + // Live was turned off, or the selection moved. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Live tail stopped"); + IsLive = false; + } + } + + /// Applies one tail step to the shown lines. Internal for tests. + internal void ApplyTail(TailUpdate update) + { + if (update.Reset) + { + IsTruncated = false; + ShowLines(update.Lines, numbered: false); + } + else + { + foreach (var line in update.Lines) + Lines.Add(new PreviewLine(null, line, SyntaxTokenizer.Tokenize(line, _language))); + } + + while (Lines.Count > MaxTailLines) + Lines.RemoveAt(0); + PendingLine = update.PendingLine; + } + + // ── Integrity commands ── + + private void PrepareIntegrity(PreviewTarget target) + { + CanComputeHash = !target.Entry.IsDirectory && target.Client.SupportsHash; + + CounterpartDevices.Clear(); + foreach (var device in OtherDevices?.Invoke() ?? []) + CounterpartDevices.Add(device); + OnPropertyChanged(nameof(HasCounterpartDevices)); + CounterpartDevice = CounterpartDevices.FirstOrDefault(); + } + + [RelayCommand(CanExecute = nameof(CanRunComputeHash))] + private async Task ComputeHashAsync() + { + if (Target is not { } target) + return; + IsHashing = true; + HashFailure = null; + try + { + var base64 = await target.Client.VerifyRemoteHashAsync( + target.RootId, target.RelativePath, CancellationToken.None, + target.IsPhone ? FileTransferClient.PhoneHashTimeout : null); + if (ReferenceEquals(Target, target)) + HashHex = HashFormat.ToHex(base64); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Computing a file's SHA-256 failed"); + if (ReferenceEquals(Target, target)) + HashFailure = ex is FileTransferHostException host + ? host.HostMessage + : Services.LocalizationService.Instance["FilePreview_HashFailed"]; + } + finally + { + IsHashing = false; + } + } + + private bool CanRunComputeHash() => CanComputeHash && !IsHashing; + + [RelayCommand(CanExecute = nameof(HasHash))] + private async Task CopyHashAsync() + { + if (HashHex is { } hex && CopyText is { } copy) + await copy(hex); + } + + partial void OnCounterpartDeviceChanged(FileSourceOption? value) => _ = LoadCounterpartRootsAsync(value); + + private async Task LoadCounterpartRootsAsync(FileSourceOption? device) + { + CounterpartRoots.Clear(); + CounterpartRoot = null; + CounterpartPath = string.Empty; + if (device is null) + return; + try + { + var client = await _clientFor(device, CancellationToken.None); + if (client is null || !ReferenceEquals(device, CounterpartDevice)) + return; + var roots = await client.ListRemoteRootsAsync(CancellationToken.None); + if (!ReferenceEquals(device, CounterpartDevice)) + return; + foreach (var root in roots.OrderBy(r => r.DisplayName, StringComparer.CurrentCultureIgnoreCase)) + CounterpartRoots.Add(root); + + // The likely counterpart: the same name, in the folder last opened on that device. + var last = LastFolderOn?.Invoke(device); + CounterpartRoot = CounterpartRoots.FirstOrDefault(r => r.RootId == last?.RootId) ?? CounterpartRoots.FirstOrDefault(); + if (Target is { } target) + { + var folder = last is { } l && l.RootId == CounterpartRoot?.RootId ? l.Folder : string.Empty; + CounterpartPath = FileTreeViewModel.Combine(folder, target.Entry.Name); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Listing the other device's shared folders failed"); + } + } + + [RelayCommand(CanExecute = nameof(CanVerifyAgainst))] + private async Task VerifyAgainstAsync() + { + if (Target is not { } target || CounterpartDevice is not { } device || CounterpartRoot is not { } root) + return; + IsVerifyingAgainst = true; + CounterpartOutcome = CounterpartOutcome.None; + CounterpartFailure = null; + try + { + var other = await _clientFor(device, CancellationToken.None) + ?? throw new PhoneNotConnectedException(); + var mine = HashHex is { } known + ? Task.FromResult(known) + : HashAsync(target.Client, target.RootId, target.RelativePath, target.IsPhone); + var theirs = HashAsync(other, root.RootId, FileTreeViewModel.Normalize(CounterpartPath), !device.IsThisPc); + await Task.WhenAll(mine, theirs); + if (!ReferenceEquals(Target, target)) + return; + HashHex ??= mine.Result; + CounterpartOutcome = HashFormat.Matches(mine.Result, theirs.Result) + ? CounterpartOutcome.Identical + : CounterpartOutcome.Different; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Verifying a file against its copy on another device failed"); + CounterpartFailure = ex is FileTransferHostException host ? host.HostMessage : null; + CounterpartOutcome = CounterpartOutcome.Failed; + } + finally + { + IsVerifyingAgainst = false; + } + } + + private bool CanVerifyAgainst() => + !IsVerifyingAgainst && CounterpartRoot is not null && !string.IsNullOrWhiteSpace(CounterpartPath) && IsFile; + + private static async Task HashAsync(FileTransferClient client, string rootId, string path, bool isPhone) + { + var base64 = await client.VerifyRemoteHashAsync(rootId, path, CancellationToken.None, + isPhone ? FileTransferClient.PhoneHashTimeout : null); + return HashFormat.ToHex(base64) ?? throw new IOException("The device did not return a SHA-256."); + } + + public void Dispose() + { + StopTail(); + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = null; + } +} diff --git a/remex.desktop/ViewModels/FileTransferQueue.cs b/remex.desktop/ViewModels/FileTransferQueue.cs index dea2fe5ad..97dbe8b52 100644 --- a/remex.desktop/ViewModels/FileTransferQueue.cs +++ b/remex.desktop/ViewModels/FileTransferQueue.cs @@ -1,4 +1,3 @@ -using Remex.Desktop.Services.FileTransfer; using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; @@ -7,8 +6,10 @@ using Microsoft.Extensions.Logging.Abstractions; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Remex.Core.Helpers; using Remex.Core.Models; using Remex.Desktop.Services; +using Remex.Desktop.Services.FileTransfer; namespace Remex.Desktop.ViewModels; @@ -99,6 +100,7 @@ public sealed partial class FileTransferQueueItem : ObservableObject [NotifyPropertyChangedFor(nameof(IsTerminal))] [NotifyPropertyChangedFor(nameof(CanCancel))] [NotifyPropertyChangedFor(nameof(StateLabel))] + [NotifyPropertyChangedFor(nameof(IsVerified))] [NotifyCanExecuteChangedFor(nameof(CancelCommand))] private TransferState _state = TransferState.Queued; @@ -109,6 +111,23 @@ public sealed partial class FileTransferQueueItem : ObservableObject [ObservableProperty] private string? _errorMessage; + /// + /// The SHA-256 the transfer was verified with, as the wire carries it (Base64), or null until one was + /// (2026-10-08 redesign). Set only by a transfer path that actually compared hashes, so a row whose host sent + /// none stays "Done" rather than claiming "Verified". + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VerifiedSha256Hex))] + [NotifyPropertyChangedFor(nameof(IsVerified))] + [NotifyPropertyChangedFor(nameof(StateLabel))] + private string? _verifiedSha256Base64; + + /// The verified hash in lowercase hex, the form sha256sum and Get-FileHash print. + public string? VerifiedSha256Hex => HashFormat.ToHex(VerifiedSha256Base64); + + /// True once the transfer finished AND its hash was compared and matched. + public bool IsVerified => State == TransferState.Done && VerifiedSha256Hex is not null; + /// Localized throughput ("12.3 MB/s"), or null while unknown (RemEx-4lcq). [ObservableProperty] [NotifyPropertyChangedFor(nameof(RateEtaText))] @@ -316,7 +335,9 @@ internal void RaiseLocalizedLabels() TransferState.Active => LocalizationService.Instance["FileTransfer_QueueStateActive"], TransferState.Paused => LocalizationService.Instance["FileTransfer_QueueStatePaused"], TransferState.Verifying => LocalizationService.Instance["FileTransfer_QueueStateVerifying"], - TransferState.Done => LocalizationService.Instance["FileTransfer_QueueStateDone"], + TransferState.Done => IsVerified + ? LocalizationService.Instance["FileTransfer_QueueStateVerified"] + : LocalizationService.Instance["FileTransfer_QueueStateDone"], TransferState.Failed => LocalizationService.Instance["FileTransfer_QueueStateFailed"], TransferState.Cancelled => LocalizationService.Instance["FileTransfer_QueueStateCancelled"], _ => string.Empty, @@ -678,10 +699,24 @@ private void FailRunThatEscaped(FileTransferQueueItem item, Exception ex) } } - /// The a transfer reports into; see . - private sealed class QueueItemProgress(FileTransferQueueItem item, Action post) : IProgress + /// + /// The a transfer reports into; see . + /// Also hears the hash check (), so the row can say Verifying and then + /// Verified instead of a bare Done. + /// + internal sealed class QueueItemProgress(FileTransferQueueItem item, Action post) + : IProgress, ITransferVerificationSink { public void Report(TransferProgress value) => item.ReportProgress(value, post); + + public void ReportVerifying() => post(() => + { + // Only from a live transfer: a late call must never pull a finished or cancelled row back. + if (item.State is TransferState.Active or TransferState.Negotiating) + item.State = TransferState.Verifying; + }); + + public void ReportVerified(string sha256Base64) => post(() => item.VerifiedSha256Base64 = sha256Base64); } private async Task RunItemAsync(FileTransferQueueItem item) diff --git a/remex.desktop/ViewModels/FileTransferViewModel.Explorer.cs b/remex.desktop/ViewModels/FileTransferViewModel.Explorer.cs new file mode 100644 index 000000000..093e5392b --- /dev/null +++ b/remex.desktop/ViewModels/FileTransferViewModel.Explorer.cs @@ -0,0 +1,582 @@ +using System.ComponentModel; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Remex.Core.Models; +using Remex.Core.Validation; +using Remex.Desktop.Services; +using Remex.Desktop.Services.FilePreview; +using Remex.Desktop.Services.FileTransfer; + +namespace Remex.Desktop.ViewModels; + +/// What is being dragged out of the file list: entries from one folder on one device. +public sealed record FileDragPayload(FileSourceOption Source, string RootId, string Folder, IReadOnlyList Entries); + +/// +/// The explorer half of the File Transfer screen (2026-10-08 redesign): the folder tree, the preview pane, +/// thumbnails, and moving files between devices. Kept in its own file so the transfer, clipboard and conflict logic in +/// FileTransferViewModel.cs is reused as it is rather than grown. +/// +public sealed partial class FileTransferViewModel +{ + /// The folder tree: This PC and each connected phone. + public FileTreeViewModel Tree { get; } + + /// The preview and details pane on the right. + public FilePreviewViewModel Preview { get; } + + /// Thumbnails for image rows in the list and grid. + public ThumbnailCache Thumbnails { get; } = new(); + + /// Puts text on the clipboard. Wired by the view (it owns the top level), and shared with the preview pane. + public Func? CopyText + { + get => Preview.CopyText; + set => Preview.CopyText = value; + } + + /// This PC's shared folders with their paths on disk. A seam: tests supply their own; null until first needed. + internal Func>>? LoadPcRoots { get; set; } + + private IReadOnlyList _pcRootPaths = []; + + /// A separate connection per phone the TREE lists, so both devices can be listed at once. + private readonly Dictionary _treePhones = new(); + + /// Where each device was last browsed, by . + private readonly Dictionary _lastFolder = new(); + + /// Where a pending device or shared-folder switch should land once its roots are listed. + private (string RootId, string Path)? _pendingNavigation; + + /// Whether the preview pane is open. Space toggles it. + [ObservableProperty] + private bool _isPreviewPaneOpen = true; + + [RelayCommand] + private void TogglePreviewPane() => IsPreviewPaneOpen = !IsPreviewPaneOpen; + + /// The device the screen is showing, in words, for the start of the breadcrumbs. + public string CurrentDeviceLabel => FileTreeViewModel.DeviceLabel(SelectedSource ?? FileSourceOption.ThisPc); + + private static string SourceKey(FileSourceOption source) => source.ClientId ?? "this-pc"; + + /// Called once from the constructor, after the tree and the preview exist. + private void InitializeExplorer() + { + Tree.NavigateRequested += node => _ = NavigateToNodeAsync(node); + Tree.SyncDevices(Sources); + Preview.LastFolderOn = source => _lastFolder.TryGetValue(SourceKey(source), out var last) ? last : null; + Preview.OtherDevices = () => Sources.Where(s => SelectedSource is null || !s.SameSourceAs(SelectedSource)).ToList(); + PropertyChanged += OnExplorerPropertyChanged; + } + + /// + /// Brings the tree's device rows in line with , once a rebuild of it has finished. Called by + /// rather than on every collection change, because the rebuild clears the list first: + /// following each step would drop This PC's row (and everything opened under it) for an instant and rebuild it. + /// + private void SyncTreeWithSources() + { + Tree.SyncDevices(Sources); + foreach (var gone in _treePhones.Keys.Where(id => !Sources.Any(s => s.ClientId == id)).ToList()) + CloseTreePhone(gone); + } + + private void OnExplorerPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SelectedRemoteEntry): + case nameof(IsPreviewPaneOpen): + UpdatePreview(); + break; + case nameof(SelectedSource): + OnPropertyChanged(nameof(CurrentDeviceLabel)); + break; + } + } + + /// Points the preview pane at the selected entry, or clears it. + private void UpdatePreview() + { + if (!IsPreviewPaneOpen || SelectedRemoteEntry is not { Name: not ".." } entry || SelectedRemoteRoot is not { } root) + { + Preview.Show(null); + return; + } + + Preview.Show(new PreviewTarget( + _client, SelectedSource ?? FileSourceOption.ThisPc, root.RootId, + FileTreeViewModel.Combine(ToRootRelative(RemotePath), entry.Name), entry)); + } + + // ── Transfer strip ── + + private readonly HashSet _trackedQueueItems = new(); + + /// Transfers queued or in flight, for the strip along the bottom. + public int ActiveTransferCount => TransferQueue.Items.Count(i => !i.IsTerminal); + + /// Finished transfers whose SHA-256 was compared and matched. + public int VerifiedTransferCount => TransferQueue.Items.Count(i => i.IsVerified); + + /// Transfers that failed. + public int FailedTransferCount => TransferQueue.Items.Count(i => i.State == TransferState.Failed); + + public bool HasActiveTransfers => ActiveTransferCount > 0; + public bool HasVerifiedTransfers => VerifiedTransferCount > 0; + public bool HasFailedTransfers => FailedTransferCount > 0; + + /// Whether the strip is opened out into the full list. + [ObservableProperty] + private bool _isQueueExpanded; + + [RelayCommand] + private void ToggleQueue() => IsQueueExpanded = !IsQueueExpanded; + + /// + /// Follows every row's state, because the queue itself only says when rows are added or cleared and the strip's + /// counts change as each one finishes. + /// + private void TrackQueueItems() + { + foreach (var gone in _trackedQueueItems.Where(i => !TransferQueue.Items.Contains(i)).ToList()) + { + gone.PropertyChanged -= OnQueueItemChanged; + _trackedQueueItems.Remove(gone); + } + foreach (var item in TransferQueue.Items) + { + if (_trackedQueueItems.Add(item)) + item.PropertyChanged += OnQueueItemChanged; + } + RaiseQueueCounts(); + } + + private void OnQueueItemChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(FileTransferQueueItem.State) or nameof(FileTransferQueueItem.IsVerified)) + RaiseQueueCounts(); + } + + private void RaiseQueueCounts() + { + OnPropertyChanged(nameof(ActiveTransferCount)); + OnPropertyChanged(nameof(VerifiedTransferCount)); + OnPropertyChanged(nameof(FailedTransferCount)); + OnPropertyChanged(nameof(HasActiveTransfers)); + OnPropertyChanged(nameof(HasVerifiedTransfers)); + OnPropertyChanged(nameof(HasFailedTransfers)); + } + + /// Copies a finished transfer's verified SHA-256 (hex). + [RelayCommand] + private async Task CopyTransferHashAsync(FileTransferQueueItem? item) + { + if (item?.VerifiedSha256Hex is { } hex && CopyText is { } copy) + await copy(hex); + } + + // ── Clients per device ── + + /// + /// A client for 's files: the screen's own when it is the device on show, otherwise one + /// the tree keeps open. Its capabilities are known by the time it is returned. Null when the device cannot be reached. + /// + internal async Task ClientForAsync(FileSourceOption source, CancellationToken ct) + { + FileTransferClient? client; + if (source.IsThisPc) + { + client = _pcClient; + } + else if (IsPhoneSource && _phoneConnection?.ClientId == source.ClientId && _phoneClient is not null) + { + client = _phoneClient; + } + else if (_treePhones.TryGetValue(source.ClientId!, out var open) && open.Connection.IsConnected) + { + client = open.Client; + } + else + { + client = OpenTreePhone(source.ClientId!); + } + + if (client is not null && client.Capabilities is null) + await client.ListRemoteRootsAsync(ct); // learns what the device can do + return client; + } + + private FileTransferClient? OpenTreePhone(string clientId) + { + CloseTreePhone(clientId); + try + { + var access = _resolvePhoneAccess() ?? throw new PhoneNotConnectedException(); + var connection = access.Open(clientId); + var client = new FileTransferClient(connection); + connection.Disconnected += () => PostToUi(() => CloseTreePhone(clientId)); + _treePhones[clientId] = (connection, client); + return client; + } + catch (PhoneNotConnectedException ex) + { + _logger.LogInformation(ex, "A phone in the tree could not be reached"); + return null; + } + } + + private void CloseTreePhone(string clientId) + { + if (_treePhones.Remove(clientId, out var open)) + { + open.Client.Dispose(); + open.Connection.Dispose(); + } + } + + private void DisposeExplorer() + { + foreach (var item in _trackedQueueItems) + item.PropertyChanged -= OnQueueItemChanged; + _trackedQueueItems.Clear(); + PropertyChanged -= OnExplorerPropertyChanged; + foreach (var id in _treePhones.Keys.ToList()) + CloseTreePhone(id); + Preview.Dispose(); + } + + // ── Navigation ── + + private Task NavigateToNodeAsync(FileTreeNode node) => + NavigateToAsync(node.Source, node.IsDevice ? null : node.RootId, node.Path); + + /// + /// Shows in on , switching device and + /// shared folder as needed. A null just shows the device. + /// + public async Task NavigateToAsync(FileSourceOption source, string? rootId, string path) + { + if (SelectedSource is null || !source.SameSourceAs(SelectedSource)) + { + if (Sources.FirstOrDefault(s => s.SameSourceAs(source)) is not { } listed) + return; + _pendingNavigation = rootId is null ? null : (rootId, path); + SelectedSource = listed; // SwitchSource lists the roots, then TakePendingRoot lands on the right one + return; + } + + if (rootId is null) + return; + + if (SelectedRemoteRoot?.RootId != rootId) + { + if (RemoteRoots.FirstOrDefault(r => r.RootId == rootId) is not { } root) + { + // Not listed yet: a phone's whole-device volume joins the list only once volumes are loaded. + _pendingNavigation = (rootId, path); + if (ShowVolumesButton) + { + await LoadVolumesCommand.ExecuteAsync(null); + if (RemoteRoots.FirstOrDefault(r => r.RootId == rootId) is { } volume) + { + SelectedRemoteRoot = volume; + return; + } + } + await LoadRemoteRootsAsync(); + return; + } + _pendingNavigation = (rootId, path); + SelectedRemoteRoot = root; + return; + } + + RemotePath = ToBreadcrumbPath(path); + await BrowseRemoteAsync(); + } + + /// The root a pending navigation wants, if it is among ; consulted when roots are listed. + private FileSharedRoot? TakePendingRoot(IEnumerable roots) => + _pendingNavigation is { } pending ? roots.FirstOrDefault(r => r.RootId == pending.RootId) : null; + + /// The folder a pending navigation wants inside , consumed; "/" when there is none. + private string TakePendingPath(string rootId) + { + if (_pendingNavigation is { } pending && pending.RootId == rootId) + { + _pendingNavigation = null; + return ToBreadcrumbPath(pending.Path); + } + _pendingNavigation = null; + return "/"; + } + + private static string ToBreadcrumbPath(string path) => + FileTreeViewModel.Normalize(path) is { Length: > 0 } p ? "/" + p : "/"; + + /// After every successful listing: remember where this device was, and bring the tree along. + private void AfterBrowse() + { + if (SelectedRemoteRoot is not { } root) + return; + var source = SelectedSource ?? FileSourceOption.ThisPc; + var folder = ToRootRelative(RemotePath); + _lastFolder[SourceKey(source)] = (root.RootId, folder); + _ = Tree.RevealAsync(source, root.RootId, folder); + } + + // ── Thumbnails ── + + /// + /// The thumbnail for an image row in the folder on show, or null for anything else. Called by the list and grid + /// as rows appear, so only what is on screen is ever fetched. + /// + public Task GetThumbnailAsync(FileEntry entry, CancellationToken ct) + { + if (entry.IsDirectory || SelectedRemoteRoot is not { } root || !_client.SupportsV3 + || PreviewClassifier.Classify(entry.Name, false) is not (PreviewKind.Image or PreviewKind.ImageThumbnailOnly)) + { + return Task.FromResult(null); + } + + var key = new ThumbnailCache.Key( + SourceKey(SelectedSource ?? FileSourceOption.ThisPc), root.RootId, + FileTreeViewModel.Combine(ToRootRelative(RemotePath), entry.Name), entry.ModifiedUnixMs); + return Thumbnails.GetAsync(_client, key, ct); + } + + // ── Context-menu helpers ── + + /// Copies "Device › Shared folder › path" for the selection, the way the screen shows it. + [RelayCommand] + private async Task CopyPathAsync() + { + if (SelectedRemoteRoot is not { } root || CopyText is not { } copy) + return; + var relative = SelectedRemoteEntry is { Name: not ".." } entry + ? FileTreeViewModel.Combine(ToRootRelative(RemotePath), entry.Name) + : ToRootRelative(RemotePath); + var parts = new[] { CurrentDeviceLabel, root.DisplayName }.Concat(relative.Split('/', StringSplitOptions.RemoveEmptyEntries)); + await copy(string.Join(" › ", parts)); + } + + /// Opens the preview pane on the selection and starts its SHA-256. + [RelayCommand] + private async Task ComputeSelectionHashAsync() + { + IsPreviewPaneOpen = true; + UpdatePreview(); + // The pane loads after its debounce; wait for it rather than hashing the previous selection. + await Task.Delay(FilePreviewViewModel.Debounce + TimeSpan.FromMilliseconds(50)); + if (Preview.ComputeHashCommand.CanExecute(null)) + await Preview.ComputeHashCommand.ExecuteAsync(null); + } + + /// The devices the selection can be sent to: every connected device except the one on show. + public IReadOnlyList OtherDevices => + Sources.Where(s => SelectedSource is null || !s.SameSourceAs(SelectedSource)).ToList(); + + /// + /// Sends the selection to , into the folder last opened there (or its first writable + /// shared folder). The keyboard and context-menu twin of dragging onto that device in the tree. + /// + [RelayCommand] + private async Task SendSelectionToAsync(FileSourceOption? device) + { + if (device is null || SelectedRemoteRoot is not { } root) + return; + var selection = EffectiveSelection(); + if (selection.Count == 0) + return; + + string targetRoot; + string targetFolder; + if (_lastFolder.TryGetValue(SourceKey(device), out var last)) + { + (targetRoot, targetFolder) = last; + } + else + { + var client = await ClientForAsync(device, CancellationToken.None); + var roots = client is null ? [] : await client.ListRemoteRootsAsync(CancellationToken.None); + if (roots.FirstOrDefault(r => r.IsWritable) is not { } writable) + { + SetStatus(() => LocalizationService.Instance["FileTransfer_DropTargetReadOnly"]); + return; + } + (targetRoot, targetFolder) = (writable.RootId, string.Empty); + } + + var payload = new FileDragPayload(SelectedSource ?? FileSourceOption.ThisPc, root.RootId, ToRootRelative(RemotePath), selection); + await TransferBetweenDevicesAsync(payload, device, targetRoot, targetFolder); + } + + // ── Drag and drop ── + + /// + /// Entries dropped onto a tree row. Another device: queued as transfers into that folder. The same device and + /// shared folder: moved there (or copied, when ), through the same paste and conflict + /// prompts as Cut and Paste. Anything else is refused with a reason. + /// + public async Task DropOntoNodeAsync(FileDragPayload payload, FileTreeNode target, bool copy) + { + if (target.IsDevice || target.IsPlaceholder || target.RootId is null || payload.Entries.Count == 0) + return; + + if (!payload.Source.SameSourceAs(target.Source)) + { + await TransferBetweenDevicesAsync(payload, target.Source, target.RootId, target.Path); + return; + } + + if (payload.RootId != target.RootId) + { + SetStatus(() => LocalizationService.Instance["FileTransfer_PasteCrossRoot"]); + return; + } + + if (!SupportsCopyMove) + return; + + // Reuse Cut/Copy + Paste: same conflict prompts, same status lines. The screen moves into the target folder, + // which is also where the result can be seen. + _clipboard.Clear(); + _clipboard.AddRange(payload.Entries); + _clipboardRootId = payload.RootId; + _clipboardFolder = ToBreadcrumbPath(payload.Folder); + _clipboardIsMove = !copy; + OnPropertyChanged(nameof(HasClipboard)); + RemotePath = ToBreadcrumbPath(target.Path); + await BrowseRemoteAsync(); + await PasteAsync(); + } + + /// OS files dropped onto a tree row: shown there, then uploaded the way a drop onto the list is. + public async Task DropLocalFilesOntoNodeAsync(IReadOnlyList localPaths, FileTreeNode target) + { + if (target.IsDevice || target.IsPlaceholder || target.RootId is null) + return; + await NavigateToAsync(target.Source, target.RootId, target.Path); + if (SelectedRemoteRoot?.RootId == target.RootId) + EnqueueUploads(localPaths); + } + + /// + /// Queues each FILE in for transfer to / + /// on . PC → phone is an upload of the file on this PC's disk; phone → PC is a download + /// straight into the PC shared folder (named "x (2).ext" rather than replacing what is there). Phone → phone and + /// folders are not moved this way, and say so. + /// + private async Task TransferBetweenDevicesAsync(FileDragPayload payload, FileSourceOption target, string targetRoot, string targetFolder) + { + var files = payload.Entries.Where(e => !e.IsDirectory && e.Name != "..").ToList(); + if (files.Count == 0) + { + SetStatus(() => LocalizationService.Instance["FilePreview_FoldersNotSent"]); + return; + } + + if (!payload.Source.IsThisPc && !target.IsThisPc) + { + SetStatus(() => LocalizationService.Instance["FilePreview_PhoneToPhoneUnsupported"]); + return; + } + + var transfers = _resolvePhoneTransfers(); + if (transfers is null) + { + SetStatus(() => LocalizationService.Instance["FileTransfer_PhoneNotConnected"]); + return; + } + + if (payload.Source.IsThisPc) + { + // PC → phone: the bytes are already on this PC's disk. + var phoneId = target.ClientId!; + foreach (var entry in files) + { + if (await LocalPathForAsync(payload.RootId, FileTreeViewModel.Combine(payload.Folder, entry.Name)) is not { } local) + continue; + TransferQueue.Enqueue(FileTransferQueueKind.Upload, entry.Name, + (progress, ct) => transfers.UploadAsync(phoneId, local, targetRoot, FileTreeViewModel.Normalize(targetFolder), progress, ct)); + } + SetStatus(() => LocalizationService.Instance["FileTransfer_QueuedForUpload"]); + } + else + { + // Phone → PC: straight into the PC shared folder on disk. + var phoneId = payload.Source.ClientId!; + if (await LocalPathForAsync(targetRoot, targetFolder) is not { } localFolder || !Directory.Exists(localFolder)) + { + SetStatus(() => LocalizationService.Instance["FileTransfer_DropTargetReadOnly"]); + return; + } + foreach (var entry in files) + { + var localFile = UniqueLocalPath(Path.Combine(localFolder, SanitizeLocalName(entry.Name))); + var remote = FileTreeViewModel.Combine(payload.Folder, entry.Name); + TransferQueue.Enqueue(FileTransferQueueKind.Download, entry.Name, + (progress, ct) => transfers.DownloadAsync(phoneId, payload.RootId, remote, localFile, progress, ct)); + } + SetStatus(() => LocalizationService.Instance["FileTransfer_QueuedForDownload"]); + } + } + + /// + /// Where a file in one of THIS PC's shared folders is on disk, or null when the shared folder is unknown or the + /// path would leave it. Used to send PC files to a phone and to drag them out to Explorer. + /// + public async Task LocalPathForAsync(string rootId, string relativePath) + { + if (_pcRootPaths.Count == 0) + { + try + { + var load = LoadPcRoots ?? (() => new FileTransferRootSettingsService().LoadAsync()); + _pcRootPaths = await load(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException) + { + _logger.LogInformation(ex, "Reading this PC's shared folders failed"); + return null; + } + } + + return TryLocalPath(rootId, relativePath); + } + + /// The synchronous half of , once the shared folders have been read (for drag-out). + public string? TryLocalPath(string rootId, string relativePath) + { + if (_pcRootPaths.FirstOrDefault(r => r.RootId == rootId) is not { } root) + return null; + return FilePathValidation.TryResolveWithinRoot(root.AbsolutePath, FileTreeViewModel.Normalize(relativePath), out var resolved, out _) + ? resolved + : null; + } + + /// + /// itself when nothing is there, otherwise "name (2).ext", "name (3).ext"… A transfer between + /// devices never replaces a file on this PC without asking; it keeps both, the way the conflict prompt's default does. + /// + internal static string UniqueLocalPath(string path) + { + if (!File.Exists(path) && !Directory.Exists(path)) + return path; + var folder = Path.GetDirectoryName(path) ?? string.Empty; + var stem = Path.GetFileNameWithoutExtension(path); + var ext = Path.GetExtension(path); + for (var n = 2; n < 10_000; n++) + { + var candidate = Path.Combine(folder, $"{stem} ({n}){ext}"); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) + return candidate; + } + return Path.Combine(folder, $"{stem} ({Guid.NewGuid():N}){ext}"); + } +} diff --git a/remex.desktop/ViewModels/FileTransferViewModel.cs b/remex.desktop/ViewModels/FileTransferViewModel.cs index 9fe7c7ef4..62e4d7923 100644 --- a/remex.desktop/ViewModels/FileTransferViewModel.cs +++ b/remex.desktop/ViewModels/FileTransferViewModel.cs @@ -148,6 +148,11 @@ public FileTransferViewModel( // the old instance and the empty state would silently stop updating. RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowEmptyFolder)); SearchResults.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowNoSearchResults)); + + // The explorer half (2026-10-08 redesign): FileTransferViewModel.Explorer.cs. + Tree = new FileTreeViewModel(ClientForAsync, _logger); + Preview = new FilePreviewViewModel(ClientForAsync, _logger); + InitializeExplorer(); _ = InitializeAsync(); } @@ -164,6 +169,7 @@ private void OnQueueChanged() OnPropertyChanged(nameof(HasQueueItems)); OnPropertyChanged(nameof(HasActiveTransfer)); CancelAllTransfersCommand.NotifyCanExecuteChanged(); + TrackQueueItems(); } /// Records a successful user-driven transfer in the Home "Recent activity" feed. @@ -480,6 +486,7 @@ internal void RefreshSources() } OnPropertyChanged(nameof(HasPhoneSources)); + SyncTreeWithSources(); // The phone being browsed is no longer in the list: its Disconnected handler normally gets // here first, but a phone that was UNPAIRED while connected is gone from the list too. @@ -544,7 +551,8 @@ partial void OnSelectedRemoteRootChanged(FileSharedRoot? value) return; } - RemotePath = "/"; + // A tree row (or a breadcrumb on another shared folder) asks for a folder inside it, not its top. + RemotePath = TakePendingPath(value.RootId); _ = BrowseRemoteAsync(); } @@ -586,7 +594,8 @@ private async Task LoadRemoteRootsAsync() foreach (var root in roots.OrderBy(root => root.DisplayName)) RemoteRoots.Add(root); - SelectedRemoteRoot = RemoteRoots.FirstOrDefault(root => root.RootId == previousRootId) + SelectedRemoteRoot = TakePendingRoot(RemoteRoots) + ?? RemoteRoots.FirstOrDefault(root => root.RootId == previousRootId) ?? RemoteRoots.FirstOrDefault(root => root.IsWritable) ?? RemoteRoots.FirstOrDefault(); @@ -698,6 +707,7 @@ private async Task BrowseRemoteAsync() _rawEntries.AddRange(entries); RebuildEntryDisplay(); RebuildBreadcrumbs(); + AfterBrowse(); } catch (Exception ex) { @@ -2522,6 +2532,7 @@ public void Dispose() } ClosePhoneConnection(); + DisposeExplorer(); _pcClient.Dispose(); } diff --git a/remex.desktop/ViewModels/FileTreeViewModel.cs b/remex.desktop/ViewModels/FileTreeViewModel.cs new file mode 100644 index 000000000..28d8281d7 --- /dev/null +++ b/remex.desktop/ViewModels/FileTreeViewModel.cs @@ -0,0 +1,358 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Remex.Core.Models; +using Remex.Desktop.Services; +using Remex.Desktop.Services.FileTransfer; + +namespace Remex.Desktop.ViewModels; + +/// What a row of the folder tree is. +public enum FileTreeNodeKind +{ + /// This PC, or one connected phone. + Device, + + /// A shared folder (or whole-device volume) on that device. + Root, + + /// A folder inside a shared folder. + Folder, + + /// The "Loading…" / "Couldn't load" row shown until a node's real children arrive. + Placeholder, +} + +/// One row of the File Transfer folder tree (2026-10-08 redesign). Children load the first time it is expanded. +public sealed partial class FileTreeNode : ObservableObject +{ + private readonly FileTreeViewModel? _owner; + + internal FileTreeNode( + FileTreeViewModel? owner, FileTreeNodeKind kind, FileSourceOption source, string? rootId, + string path, string name, FileTreeNode? parent, bool isWritable = false) + { + _owner = owner; + Kind = kind; + Source = source; + RootId = rootId; + Path = path; + Name = name; + Parent = parent; + IsWritable = isWritable; + if (kind != FileTreeNodeKind.Placeholder) + Children.Add(Placeholder(this, failed: false)); + } + + internal static FileTreeNode Placeholder(FileTreeNode parent, bool failed) => + new(null, FileTreeNodeKind.Placeholder, parent.Source, parent.RootId, parent.Path, string.Empty, parent) + { + LoadFailed = failed, + }; + + public FileTreeNodeKind Kind { get; } + + /// The device this row belongs to. + public FileSourceOption Source { get; } + + /// The shared folder this row is in; null for a device row. + public string? RootId { get; } + + /// Root-relative, '/'-separated; empty for a device or a shared folder itself. + public string Path { get; } + + /// The folder's own name, or the shared folder's display name. Empty for a device row (see the device bindings). + public string Name { get; } + + public FileTreeNode? Parent { get; } + + /// Whether files may be dropped here: a writable shared folder or any folder inside one. + public bool IsWritable { get; } + + public ObservableCollection Children { get; } = new(); + + public bool IsDevice => Kind == FileTreeNodeKind.Device; + public bool IsThisPc => IsDevice && Source.IsThisPc; + public bool IsPhone => IsDevice && !Source.IsThisPc; + public bool IsRoot => Kind == FileTreeNodeKind.Root; + public bool IsFolder => Kind == FileTreeNodeKind.Folder; + public bool IsPlaceholder => Kind == FileTreeNodeKind.Placeholder; + + /// A whole-device volume of a phone, rather than a folder it shares by name. + public bool IsVolume { get; init; } + + /// A shared folder the PC may not write to (shown with a lock). + public bool IsReadOnlyRoot => IsRoot && !IsWritable; + + /// True for the placeholder row of a node whose children could not be listed. + [ObservableProperty] + private bool _loadFailed; + + [ObservableProperty] + private bool _isExpanded; + + [ObservableProperty] + private bool _isLoading; + + internal bool ChildrenLoaded { get; set; } + + partial void OnIsExpandedChanged(bool value) + { + if (value && _owner is not null) + _ = _owner.EnsureChildrenAsync(this); + } + + /// The same place on the same device, whatever object stands for it. + public bool IsSamePlaceAs(FileSourceOption source, string? rootId, string path) => + Source.SameSourceAs(source) + && string.Equals(RootId, rootId, StringComparison.Ordinal) + && string.Equals(Path, FileTreeViewModel.Normalize(path), StringComparison.Ordinal); +} + +/// +/// The folder tree on the left of the File Transfer screen (2026-10-08 redesign): This PC and each connected phone, +/// their shared folders, and the folders inside, listed lazily as they are expanded. Picking a row asks the screen +/// to go there (); the screen, once it has, calls so the +/// tree follows navigation done any other way (a double-click, a breadcrumb, Up). +/// +public sealed partial class FileTreeViewModel : ObservableObject +{ + private readonly Func> _clientFor; + private readonly ILogger _logger; + private bool _revealing; + + /// + /// A client for a device's files, or null when it cannot be reached now. The screen supplies its own client + /// for the device on show and a separate one per other phone, so the tree lists both sides at once. + /// + public FileTreeViewModel( + Func> clientFor, ILogger? logger = null) + { + _clientFor = clientFor; + _logger = logger ?? NullLogger.Instance; + } + + /// One row per device, This PC first. + public ObservableCollection Devices { get; } = new(); + + [ObservableProperty] + private FileTreeNode? _selectedNode; + + /// The person picked a row: the screen should show that device, shared folder and folder. + public event Action? NavigateRequested; + + partial void OnSelectedNodeChanged(FileTreeNode? value) + { + if (_revealing || value is null || value.IsPlaceholder) + return; + NavigateRequested?.Invoke(value); + } + + /// + /// Matches the device rows to : new devices are added, departed ones removed, and the + /// rows of devices that stayed keep what they had already listed and how far they were opened. + /// + public void SyncDevices(IEnumerable sources) + { + var wanted = sources.ToList(); + for (var i = Devices.Count - 1; i >= 0; i--) + { + if (!wanted.Any(s => s.SameSourceAs(Devices[i].Source))) + Devices.RemoveAt(i); + } + + for (var i = 0; i < wanted.Count; i++) + { + var existing = Devices.FirstOrDefault(d => d.Source.SameSourceAs(wanted[i])); + if (existing is null) + { + Devices.Insert(Math.Min(i, Devices.Count), + new FileTreeNode(this, FileTreeNodeKind.Device, wanted[i], null, string.Empty, string.Empty, null)); + } + else if (Devices.IndexOf(existing) != i) + { + Devices.Move(Devices.IndexOf(existing), i); + } + } + } + + /// Lists 's children once; later calls do nothing until . + public async Task EnsureChildrenAsync(FileTreeNode node) + { + if (node.ChildrenLoaded || node.IsLoading || node.IsPlaceholder) + return; + await LoadChildrenAsync(node); + } + + /// Lists 's children again, after something in it changed. + public async Task RefreshAsync(FileTreeNode node) + { + node.ChildrenLoaded = false; + await LoadChildrenAsync(node); + } + + private async Task LoadChildrenAsync(FileTreeNode node) + { + node.IsLoading = true; + try + { + var client = await _clientFor(node.Source, CancellationToken.None); + if (client is null) + { + ShowFailure(node); + return; + } + + var children = new List(); + if (node.IsDevice) + { + var roots = await client.ListRemoteRootsAsync(CancellationToken.None); + foreach (var root in roots.OrderBy(r => r.DisplayName, StringComparer.CurrentCultureIgnoreCase)) + { + children.Add(new FileTreeNode(this, FileTreeNodeKind.Root, node.Source, root.RootId, string.Empty, + root.DisplayName, node, root.IsWritable)); + } + + // A phone whose owner turned on whole-device browsing also lists its volumes, read-only, after the + // shared folders. Never for This PC: the PC's own drives are File Explorer's (RemEx-xt0af). + if (!node.Source.IsThisPc && client.SupportsFullBrowse) + { + var (volumes, granted, _) = await client.ListVolumesAsync(CancellationToken.None); + if (granted) + { + foreach (var volume in volumes.Where(v => children.All(c => c.RootId != v.Id))) + { + children.Add(new FileTreeNode(this, FileTreeNodeKind.Root, node.Source, volume.Id, string.Empty, + volume.Label, node, isWritable: false) { IsVolume = true }); + } + } + } + } + else + { + var entries = await client.BrowseRemoteAsync(node.RootId!, node.Path, CancellationToken.None); + foreach (var entry in entries + .Where(e => e.IsDirectory && e.Name is not (".." or ".")) + .OrderBy(e => e.Name, StringComparer.CurrentCultureIgnoreCase)) + { + children.Add(new FileTreeNode(this, FileTreeNodeKind.Folder, node.Source, node.RootId, + Combine(node.Path, entry.Name), entry.Name, node, node.IsWritable)); + } + } + + // Keep a child that is open (or already listed) when the same folder comes back: refreshing a + // parent must not collapse what the person had opened under it. + var kept = children + .Select(fresh => node.Children.FirstOrDefault(old => + !old.IsPlaceholder && old.IsSamePlaceAs(fresh.Source, fresh.RootId, fresh.Path)) ?? fresh) + .ToList(); + node.Children.Clear(); + foreach (var child in kept) + node.Children.Add(child); + node.ChildrenLoaded = true; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _logger.LogInformation(ex, "Listing a folder for the tree failed"); + ShowFailure(node); + } + finally + { + node.IsLoading = false; + } + } + + private static void ShowFailure(FileTreeNode node) + { + node.Children.Clear(); + node.Children.Add(FileTreeNode.Placeholder(node, failed: true)); + node.ChildrenLoaded = false; // expanding again tries again + } + + /// + /// Opens the tree down to in on and + /// selects that row, without asking the screen to navigate (it already has). Stops quietly at the deepest + /// row it can find, e.g. when a folder was just deleted. + /// + public async Task RevealAsync(FileSourceOption source, string rootId, string path) + { + var device = Devices.FirstOrDefault(d => d.Source.SameSourceAs(source)); + if (device is null) + return; + + var current = device; + await ExpandAsync(current); + var root = current.Children.FirstOrDefault(c => c.IsRoot && c.RootId == rootId); + if (root is null) + { + Select(current); + return; + } + + current = root; + foreach (var segment in Normalize(path).Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + await ExpandAsync(current); + var next = current.Children.FirstOrDefault(c => c.IsFolder && c.Name == segment); + if (next is null) + break; + current = next; + } + + Select(current); + } + + private async Task ExpandAsync(FileTreeNode node) + { + await EnsureChildrenAsync(node); + node.IsExpanded = true; + } + + private void Select(FileTreeNode node) + { + _revealing = true; + try + { + SelectedNode = node; + } + finally + { + _revealing = false; + } + } + + /// Finds the row for a place that is already listed, or null. + public FileTreeNode? Find(FileSourceOption source, string? rootId, string path) + { + foreach (var device in Devices) + { + if (FindIn(device, source, rootId, path) is { } found) + return found; + } + return null; + } + + private static FileTreeNode? FindIn(FileTreeNode node, FileSourceOption source, string? rootId, string path) + { + if (node.IsSamePlaceAs(source, rootId, path) && !node.IsDevice) + return node; + foreach (var child in node.Children) + { + if (FindIn(child, source, rootId, path) is { } found) + return found; + } + return null; + } + + /// The tree's path form: no leading or trailing '/', '/' between names, "" for the shared folder itself. + public static string Normalize(string? path) => (path ?? string.Empty).Replace('\\', '/').Trim('/'); + + internal static string Combine(string folder, string name) => + Normalize(folder) is { Length: > 0 } f ? $"{f}/{name}" : name; + + /// The words a device row shows: "This PC", the phone's name, or "Phone" when it has none. + public static string DeviceLabel(FileSourceOption source) => source.IsThisPc + ? LocalizationService.Instance["FileTransfer_SourceThisPc"] + : source.HasDeviceName ? source.DeviceName! : LocalizationService.Instance["FileTransfer_SourceUnnamedPhone"]; +} diff --git a/remex.desktop/Views/FileTransferView.axaml b/remex.desktop/Views/FileTransferView.axaml index 66a871c71..2706a24a3 100644 --- a/remex.desktop/Views/FileTransferView.axaml +++ b/remex.desktop/Views/FileTransferView.axaml @@ -4,272 +4,275 @@ xmlns:mi="using:Material.Icons.Avalonia" xmlns:vm="using:Remex.Desktop.ViewModels" xmlns:models="using:Remex.Core.Models" + xmlns:controls="using:Remex.Desktop.Controls" xmlns:local="clr-namespace:Remex.Desktop.Converters" xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles" x:Class="Remex.Desktop.Views.FileTransferView" x:DataType="vm:FileTransferViewModel" + Focusable="True" DragDrop.AllowDrop="True"> + + + + + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + - - - - - + - - - - + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + diff --git a/remex.desktop/Views/FileTransferView.axaml.cs b/remex.desktop/Views/FileTransferView.axaml.cs index 7dac0a0bd..8f630d69b 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(); } } diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index df2b26449..cdae87766 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