From b6f5ab3f5ed761a152b2d24cb44ae1d23badf025 Mon Sep 17 00:00:00 2001 From: CodePandaaAI Date: Fri, 28 Aug 2026 11:06:23 +0530 Subject: [PATCH] feat: replace file approval with receive codes and prepare 0.4.0 File sharing previously used the same general approval idea that text sharing once used. The sender posted an offer, the HTTP request remained open, and the receiver had to accept or decline it before the transfer could continue. After separating text into its own direct-delivery flow, the file path was the only place that still needed this waiting system. Instead of trying to make the waiting machinery smaller, this change asks whether that machinery is still necessary. Replace the receiver-side Accept/Decline step with a temporary four-digit receive code. Generate one code for each fresh application session and keep it only in memory. Show the same code on both the Send and Receive screens so it is available from the default screen. Do not advertise it through discovery, persist it, or remember entered codes on sending devices. When sending files, ask for the target device's code before creating the transfer. Validate the input as exactly four ASCII digits in the UI, ViewModel, and outgoing controller. Include the entered code with the file metadata offer. Make the receiver answer the file offer immediately with an accepted, invalid-code, receiver-busy, or preparation-failed result. Under the existing operation mutex, atomically check that the receiver is idle, compare the code, prepare the platform TCP receiver, and publish the ReceivingFiles state before returning acceptance. Remove the file decision machinery that is no longer needed: - remove UserDecision and CompletableDeferred - remove IncomingFileOffer and WaitingForFiles states - remove the receiver Accept/Decline screen - remove the suspended decision request and its 50-second timeout - remove the old Accept/Cancel response race Keep the parts that still protect file-transfer correctness: - operation IDs and sender IDs - best-effort remote cancellation - platform-owned first-connection timeout - transfer progress and completed-file counts - raw TCP framing and streamed file bytes - file index and promised-size validation - incomplete-file cleanup - final batch result handling Update navigation so compact devices open Receive when file reception begins, and replace the old waiting-for-approval sender state with a clear Preparing Files state. Prepare preview version 0.4.0 across Android, Desktop, and iOS. Increment Android and iOS build numbers to 4 while preserving the permanent Windows MSI upgrade identity. Refresh the README, changelog, architecture, development, roadmap, privacy, security, store, screenshot, and project-context documentation. Explain that the receive code is a convenience against accidental or casual sends, not authentication, because it has a small keyspace, travels over cleartext HTTP, and currently has no attempt throttling. BREAKING CHANGE: the file-offer request and response format is incompatible with Sync360 0.3.0 and older. Both devices must use matching 0.4.0 builds. The advertised preview protocol version intentionally remains 1 for now, so discovery may still show an older incompatible device. No Gradle build, automated test, or runtime transfer test was performed. --- CHANGELOG.md | 16 +- PRIVACY.md | 8 +- README.md | 27 +-- SECURITY.md | 9 +- STORE_LISTING.md | 4 +- androidApp/build.gradle.kts | 4 +- context.md | 6 +- desktopApp/build.gradle.kts | 2 +- docs/ARCHITECTURE.md | 23 ++- docs/DEVELOPMENT.md | 10 +- docs/OPEN_SOURCE_NOTES.md | 4 +- docs/ROADMAP.md | 12 +- iosApp/Configuration/Config.xcconfig | 4 +- screenshots/README.md | 6 +- .../kotlin/com/liftley/sync360/Sync360Root.kt | 11 +- .../com/liftley/sync360/core/di/Koin.kt | 2 +- .../data/IncomingServerRequestsController.kt | 184 ++++-------------- .../data/OutgoingRequestsController.kt | 59 ++++-- .../network/http/client/FileOfferException.kt | 2 +- .../network/http/client/Sync360HttpClient.kt | 12 +- .../network/http/dto/file/FileOfferRequest.kt | 3 +- .../http/dto/file/FileOfferResponse.kt | 14 +- .../network/http/server/Sync360HttpServer.kt | 31 +-- .../sync360/domain/model/ClientServerState.kt | 12 -- .../sync360/domain/model/FileReceiveCode.kt | 20 ++ .../app/components/FileReceiveCodeCard.kt | 44 +++++ .../presentation/receive/ReceiveScreen.kt | 13 +- .../receive/ReceiveScreenViewModel.kt | 43 ++-- .../receive/components/FileOfferStateUi.kt | 145 -------------- .../receive/components/IdleReceiveStateUi.kt | 4 + .../receive/model/ReceiveScreenState.kt | 10 +- .../sync360/presentation/send/SendScreen.kt | 15 ++ .../presentation/send/SendScreenViewModel.kt | 77 +++++++- .../send/components/FileReceiveCodeDialog.kt | 61 ++++++ .../send/components/SendOperationStateUi.kt | 6 +- .../send/model/FileReceiveCodePrompt.kt | 7 + .../send/model/SendOperationState.kt | 2 +- .../send/model/SendScreenState.kt | 2 + 38 files changed, 441 insertions(+), 473 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/FileReceiveCode.kt create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt delete mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/FileOfferStateUi.kt create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileReceiveCodeDialog.kt create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/FileReceiveCodePrompt.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0395351..e2c1081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,24 @@ All notable changes to Sync360 will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Semantic versioning will begin when public releases begin. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions remain preview releases, so compatibility can change before `1.0.0`. ## [Unreleased] +## [0.4.0] - 2026-08-28 + +### Changed + +- Replaced the file Accept/Decline screen and suspended HTTP decision with a temporary four-digit receive code generated once for each fresh application session. +- Displayed the same session receive code on both the Send and Receive screens so it is visible from the default screen. +- File offers now include the entered code and receive an immediate accepted, invalid-code, busy, or preparation-failed response. +- Removed file `UserDecision`, `CompletableDeferred`, incoming-offer state, decision timeout, and Accept/Cancel race while retaining operation IDs, TCP preparation timeout, cancellation, progress, framing, and cleanup. +- Changed the file-offer wire format, so matching builds are required; preview protocol metadata intentionally remains version `1` for now. + +### Security + +- Documented the receive code as a short-lived convenience against accidental or casual unwanted sends, not authentication; it has no attempt throttling, and local HTTP and raw TCP remain cleartext. + ## [0.3.0] - 2026-08-27 ### Added diff --git a/PRIVACY.md b/PRIVACY.md index efd7906..9feee41 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Sync360 Privacy -Last updated: August 3, 2026 +Last updated: August 28, 2026 Sync360 sends text and files directly between nearby devices on the same reachable local network. It does not use a Sync360 account, cloud-storage service, analytics service, advertising service, or Sync360 transfer backend. @@ -11,14 +11,14 @@ Sync360 sends text and files directly between nearby devices on the same reachab - No analytics, advertising, tracking, or telemetry is included. - A random installation identifier is stored locally so devices can identify each other. - Nearby-device discovery information is exchanged only with devices on the reachable local network and is kept as runtime state. -- Text and selected files are sent directly to the receiver chosen by the user after the receiver approves the offer. +- Text is sent directly to the chosen receiver. Files are sent after the sender enters the receiver's temporary four-digit code. - Received files remain on the receiving device in its platform Downloads location. - Shared text and transfer state are temporary runtime state; Sync360 does not maintain chat or clipboard history. - Sync360 does not send shared content to the developer. ## Network Security -Sync360 currently uses cleartext local HTTP for offers and text and raw TCP for file bytes. Sender authentication, session validation, request signing, replay protection, encryption, and cryptographic integrity verification are not implemented yet. Receiver approval exists in the UI but is not a complete security boundary. +Sync360 currently uses cleartext local HTTP for offers and text and raw TCP for file bytes. Sender authentication, session validation, request signing, replay protection, encryption, and cryptographic integrity verification are not implemented yet. The temporary four-digit file receive code is a convenience check, not authentication, and is transmitted over cleartext HTTP. Do not treat the current code as production-secure file-transfer software. Use it only on private networks you control while testing. @@ -28,7 +28,7 @@ Sync360 uses network access for local discovery and direct transfer. Android use ## Retention -Sync360 stores a local installation identifier. Discovery, offer, text, and transfer state are runtime state. Files successfully received remain in Downloads until the user removes them through the operating system. Incomplete current files are removed after receive failure or cancellation where the platform implementation supports it. +Sync360 stores a local installation identifier. The file receive code, discovery, offer, text, and transfer state are runtime state. The receive code is not persisted and a fresh application session generates another one. Files successfully received remain in Downloads until the user removes them through the operating system. Incomplete current files are removed after receive failure or cancellation where the platform implementation supports it. ## Contact diff --git a/README.md b/README.md index e58d9a1..47f25be 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,15 @@ In an initial Windows 11 Ethernet test, the native Windows DNS-SD backend discov - Deliver text directly with one HTTP request when the receiver is idle. - Enforce a 100,000-character text limit and show the sender name with Copy and Clear actions. - Select images, videos, documents, and multiple files. -- Show file metadata to the receiver before any file bytes are sent. +- Generate a temporary four-digit file receive code for each fresh application session and show the same code on both Send and Receive. +- Check the receive code and file metadata before any file bytes are sent. - Stream file bytes directly over raw TCP without loading an entire file into memory. - Save received files into public Android Downloads through `MediaStore`, preserving the extension when duplicate names are resolved. - Delete the incomplete current file if its receive operation fails or is cancelled. - Stream each accepted file batch continuously, then confirm the batch with one final receiver result. - Cancel a pending send or active file transfer on a best-effort basis. - Show batch-wide byte percentage while files are being sent and received. -- Show clear offer, transfer, success, failure, and cancelled states on the sender, with incoming, receiving, and received states on the receiver. +- Show clear preparation, transfer, success, failure, and cancelled states on the sender, with receiving and received states on the receiver. - Run the shared Send/Receive UI on Desktop, with an adaptive 50/50 two-pane layout in wider windows. - Discover and advertise Windows devices through the operating system DNS-SD API, with JmDNS retained for macOS and Linux, using the same service as Android. - Select multiple Desktop files with the native file dialog and send them through the same offer and TCP protocol. @@ -92,14 +93,14 @@ The current progress UI tracks the exact bytes transferred across the accepted b Sync360 uses two small networking paths with different jobs: -- **Ktor HTTP handles direct text delivery and the file control plane.** It carries text payloads, file offers, receiver decisions, and file metadata. +- **Ktor HTTP handles direct text delivery and the file control plane.** It carries text payloads and immediate code-checked file offers with metadata. - **Raw TCP is the file data plane.** It streams the actual file bytes directly between devices. ```mermaid flowchart LR A["Sender device"] -->|"Android NSD or platform Desktop DNS-SD"| B["Receiver device"] A -->|"Ktor: direct text delivery"| B - A -->|"Ktor: file offer + decision"| B + A -->|"Ktor: code-checked file offer"| B A -->|"Raw TCP: streamed file bytes"| B B -->|"Platform Downloads writer"| D["Downloads"] ``` @@ -126,14 +127,19 @@ Text uses one request and has no offer, receiver decision, operation ID, waiting ```text Platform file picker -> SelectedFileReader reads name, size, MIME type, and platform location - -> POST /sync360/file/offer sends metadata - -> receiver Accept/Decline + -> sender enters the receiver's temporary four-digit code + -> POST /sync360/file/offer sends metadata and code + -> idle receiver checks the code and prepares its TCP receiver immediately -> platform FileTransferSender opens an InputStream -> one raw TCP connection streams the accepted file batch -> platform DownloadsWriter saves each file -> receiver returns final success and completed-file count ``` +The receive code is generated in memory when a fresh application session starts. The same code is shown on the Send and Receive screens, so it is available from the default screen without switching tabs. It is not persisted, advertised, or remembered by the sender. It is a convenience check, not authentication or encryption. + +This changes the file-offer request and response format. Builds containing this flow are not file-transfer compatible with `0.3.0` or older builds, even though the advertised preview protocol version intentionally remains `1` for now. Use matching builds on both devices. + One TCP socket is opened for the complete accepted batch. It begins with the operation ID as 16 raw UUID bytes; each file then begins with its index and promised byte count, followed by exactly that many bytes. The receiver checks the operation ID, index, and size before saving. The sender writes every file sequentially, flushes once after the complete batch, then reads one final success flag and completed-file count from the receiver. The count increases only after the platform Downloads writer successfully returns. The current shared payload buffer is 512 KiB; exact byte counts define file boundaries, so correctness does not depend on `flush()` calls or matching sender and receiver read chunks. Files are sent sequentially. If a later file fails, files that were already completed stay in Downloads; the incomplete current file is cleaned up. Android uses a pending `MediaStore` entry and resolves its MIME type from the filename extension so duplicate names remain in the form `file (1).ext`. Desktop writes a temporary `.part` file before moving a completed file into place without overwriting an existing name. @@ -237,8 +243,8 @@ macOS/Linux: 3. Keep Sync360 open on both devices during the current foreground-only test flow. 4. On the Send screen, wait for the other device to appear. 5. For text, enter the content and select the nearby device; idle receivers show it immediately. -6. For files, select the files and nearby device, then accept the offer on the receiver. -7. Accepted files will be written to the platform's Downloads folder. +6. For files, read the target device's four-digit code from its Send or Receive screen, select the files and nearby device, then enter that code on the sender. +7. Code-accepted files will be written to the platform's Downloads folder. Some routers enable client isolation and block local device-to-device traffic. If discovery or transfer does not work, try another trusted Wi-Fi network or a phone hotspot. @@ -250,7 +256,7 @@ Reload is available only after the current discovery window has stopped while se Sync360 is **not secure for untrusted networks yet**. -The current implementation uses cleartext local HTTP and raw TCP. File operation IDs correlate offers, cancellation, and file sockets for correctness, but they are not secret or authenticated. Direct text delivery has no receiver approval. Sync360 does not yet authenticate the sender, encrypt content, or verify file integrity with a cryptographic hash. File receiver approval exists in the UI, but it is not a complete security boundary. +The current implementation uses cleartext local HTTP and raw TCP. File operation IDs correlate offers, cancellation, and file sockets for correctness, but they are not secret or authenticated. Direct text delivery has no receiver approval. The four-digit file receive code reduces accidental or casual unwanted sends, but its small keyspace, cleartext transport, and current lack of attempt throttling do not make it authentication. Sync360 does not yet authenticate the sender, encrypt content, or verify file integrity with a cryptographic hash. Use the current app only for development and testing on private networks you control. Please report security-sensitive findings according to [SECURITY.md](SECURITY.md), not in a public issue. @@ -261,7 +267,6 @@ Use the current app only for development and testing on private networks you con - Improve active-transfer feedback around the current byte percentage. - Add integrity verification. - Test cancellation and failure reporting across more network-loss and transfer stages. -- Close the narrow Accept/Cancel timing gap so an offer cannot report acceptance after its receiver state has already been cancelled. - Strengthen lifecycle behavior and local-network reliability. - Add Android 17 local-network permission handling and serialize Android 13 legacy NSD resolves. - Validate Desktop discovery and transfer across more operating systems, network adapters, routers, and firewall configurations. @@ -277,7 +282,7 @@ Use the current app only for development and testing on private networks you con Sync360 is not trying to become a chat app, cloud-sync product, or permanent device manager. The product direction stays focused: ```text -find nearby -> send text or approve files -> transfer directly +find nearby -> send text or enter a file receive code -> transfer directly ``` ## Why the rebuild is intentionally small diff --git a/SECURITY.md b/SECURITY.md index 323868f..042a9fa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ Sync360 is an early local-network sharing app. It is not secure for untrusted networks yet. -The current rebuild implements local discovery, receiver-approved text sharing, and streamed file transfer before adding the final security model. Security work remains required before untrusted-network use. +The current rebuild implements local discovery, direct text delivery, and code-checked streamed file transfer before adding the final security model. Security work remains required before untrusted-network use. ## Supported versions @@ -11,6 +11,8 @@ There are no stable supported releases yet. | Version | Supported | | ------- | --------- | | Unreleased / main | Best effort | +| 0.4.x | Best effort | +| 0.3.x and older | No | ## Reporting a vulnerability @@ -40,10 +42,11 @@ General bugs, crashes, UI issues, documentation problems, and non-sensitive arch Current implementation: - Android NSD, Windows system DNS-SD, macOS/Linux JmDNS, and an initial iOS Bonjour implementation exist. -- Ktor carries text/file offers, receiver decisions, metadata, and accepted text. +- Ktor carries direct text and code-checked file offers with metadata. - Raw TCP streams accepted file batches to platform Downloads storage. -- File names and promised sizes are validated, but a file socket is not bound to its approved offer with a session token. +- File names and promised sizes are validated, but a file socket is not authenticated with a secret session token. - Sender authentication, encryption, replay protection, and cryptographic integrity verification are not implemented. +- The temporary four-digit file receive code is not authentication and can be guessed or observed on the cleartext local connection. Attempts are not currently rate-limited. Use current builds only on private local networks you control. Do not use the current code as a security model for production file transfer. diff --git a/STORE_LISTING.md b/STORE_LISTING.md index dd80e17..200b719 100644 --- a/STORE_LISTING.md +++ b/STORE_LISTING.md @@ -6,7 +6,7 @@ Share text and files directly between your Android and desktop devices over your ## Security Notice -Sync360 currently uses trusted-network mode. The receiver approves offers in the UI, but requests and file sockets are not authenticated and transferred content is not encrypted by Sync360. +Sync360 currently uses trusted-network mode. Files require the receiver's temporary four-digit code, but the code, requests, and file sockets are not authenticated and transferred content is not encrypted by Sync360. Use Sync360 only on a private home network or personal hotspot controlled by you. Do not use it on public or shared networks such as cafes, hotels, airports, schools, or offices. @@ -14,7 +14,7 @@ Use Sync360 only on a private home network or personal hotspot controlled by you - Direct local-network transfer; no transfer cloud. - No account, ads, analytics, tracking, or telemetry. -- Offer decisions, transfer state, and shared text are temporary runtime state. +- Receive codes, transfer state, and shared text are temporary runtime state. - Received files remain on the receiving device. ## Publishing Checklist diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 6dc591a..fa6cb91 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -36,8 +36,8 @@ android { applicationId = "com.liftley.sync360" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.4.0" } buildFeatures { diff --git a/context.md b/context.md index 7d39dc8..6eb5471 100644 --- a/context.md +++ b/context.md @@ -16,7 +16,7 @@ The old AI-generated sync implementation was removed. The current app is being r - Windows discovery/registration through the operating system `dnsapi.dll` DNS-SD API on all interfaces. - Current macOS/Linux discovery/registration through JmDNS on eligible IPv4 and IPv6 LAN addresses. - Application-lifetime network startup with separate discovery and registration lifecycle states. -- Ktor HTTP direct text delivery plus file offers, receiver decisions, and metadata. +- Ktor HTTP direct text delivery plus immediate code-checked file offers and metadata. - Raw TCP streaming for file bytes. - Multiple files sent sequentially over one accepted-batch connection. - Android file access through `ContentResolver` and Downloads writing through `MediaStore`. @@ -72,12 +72,12 @@ Current shared transfer constants use a 512 KiB payload buffer, 5-second connect - Foreground/background lifecycle support. - Broader Desktop adapter, firewall, router, and operating-system validation. - Android 17 local-network permission-aware startup and serialized Android 13 legacy NSD resolution. -- Closing the narrow shared Accept/Cancel response race. +- Validating the temporary file receive-code flow across supported platforms. - Session validation, authentication, encryption, and integrity verification. ## Important limitations -Sync360 currently uses cleartext local HTTP and raw TCP. Direct text has no receiver approval or operation ID. File operation IDs correlate protocol messages and sockets but do not authenticate a peer. File offers require receiver approval, but the app has no authentication, encryption, or checksum. The current target-SDK-37 Android build also lacks Android 17's required local-network runtime-permission flow. Windows receiving depends on Windows Firewall allowing the application. Use development builds only on private networks you control. +Sync360 currently uses cleartext local HTTP and raw TCP. Direct text has no receiver approval or operation ID. File operation IDs correlate protocol messages and sockets but do not authenticate a peer. A temporary four-digit receive code replaces file Accept/Decline, but it has no attempt throttling and is not authentication, encryption, or a checksum. The new file-offer format is incompatible with `0.3.0` and older builds while preview protocol metadata intentionally remains version `1`, so both devices must run matching builds. The current target-SDK-37 Android build also lacks Android 17's required local-network runtime-permission flow. Windows receiving depends on Windows Firewall allowing the application. Use development builds only on private networks you control. For detailed and current information, read: diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 956059e..67c7139 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -37,7 +37,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Sync360" - packageVersion = "0.3.0" + packageVersion = "0.4.0" appResourcesRootDir.set( project.layout.projectDirectory.dir("packaging/app-resources") ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11c2e4a..738a2cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,8 +18,8 @@ app starts -> FileTransferReceiver opens an OS-assigned TCP port -> NetworkServices advertises both ports through DNS-SD/mDNS -> nearby Sync360 devices are resolved into NearbyDevice - -> sender delivers text directly or posts a file offer through Ktor HTTP - -> idle receiver publishes the text, or the receiver accepts/declines the file offer + -> sender delivers text directly or posts file metadata with a receive code + -> idle receiver publishes the text, or checks the code and prepares file reception -> accepted file bytes stream through one raw TCP connection -> platform DownloadsWriter saves the files ``` @@ -65,7 +65,7 @@ Owns the single app `Scaffold`, compact bottom navigation, and one Navigation 3 ### ViewModels - `SendScreenViewModel` owns nearby-device state, selected files/text, send operations, results, and cancellation. -- `ReceiveScreenViewModel` maps incoming server state to Receive UI and handles Accept, Decline, Copy, Clear, and Open Downloads actions. +- `ReceiveScreenViewModel` maps incoming server state and the session receive code to Receive UI, and handles Copy, Clear, and Open Downloads actions. - `NavigationViewModel` keeps Send and Receive available as top-level entries and selects the active compact destination. ViewModels launch UI-facing work. They do not implement platform APIs or socket protocols. @@ -73,8 +73,8 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket ### Controllers - `NetworkServicesController` starts the HTTP server, file receiver, and discovery/registration once for the application lifetime. It also coordinates timed discovery stop, discovery restart, and full connection repair. -- `OutgoingRequestsController` validates and delivers text, creates file offers, and starts accepted file transfers. -- `IncomingServerRequestsController` uses one operation mutex to atomically admit direct text only while idle and to serialize file Accept/Decline/Cancel races. Text follows `Idle -> TextReceived -> Idle`; files follow `Idle -> IncomingFileOffer -> WaitingForFiles -> ReceivingFiles -> FilesReceived`. File states retain their request, so sender identity, operation ID, and acceptance phase remain derived from state. +- `OutgoingRequestsController` validates and delivers text, creates code-bearing file offers, and starts accepted file transfers. +- `IncomingServerRequestsController` generates one in-memory four-digit file receive code for its application session. Under one operation mutex it admits direct text only while idle, or atomically checks the file code, prepares the platform receiver, and publishes `ReceivingFiles`. Text follows `Idle -> TextReceived -> Idle`; files follow `Idle -> ReceivingFiles -> FilesReceived/Idle`. ### Discovery @@ -98,7 +98,7 @@ The macOS/Linux JmDNS fallback starts on eligible IPv4 and IPv6 addresses from e ## Control plane: Ktor HTTP -Ktor carries direct text plus file offers, decisions, and metadata: +Ktor carries direct text plus immediate code-checked file offers and metadata: ```text POST /sync360/text/deliver @@ -108,7 +108,13 @@ POST /sync360/operation/cancel Text is delivered in one request containing the sender device name and text. It has no offer, decision, operation ID, waiting state, or cancellation route. Text above 100,000 Kotlin `String.length` units is rejected, and the receiver atomically checks `Idle` and publishes `TextReceived` under the operation mutex. -A file offer waits up to 50 seconds for the receiver's decision. After acceptance, the controller derives a 30-second payload-preparation timeout from `WaitingForFiles`. A random operation ID correlates the file offer, explicit cancellation, and file connection. Cancellation succeeds only when both the operation ID and sender device ID match the active file state. The timeouts remain fallbacks for crashes and lost network communication. The shared flow uses `FileOfferRequest` directly for the accepted metadata; file contents remain in platform file readers and are not placed in the HTTP request. +A file offer contains the sender-entered four-digit receive code, operation identity, and file metadata. The receiver immediately reports accepted, invalid code, receiver busy, or preparation failed. A correct code is admitted only while `ClientServerState` is `Idle`; code checking, platform receiver preparation, and the `ReceivingFiles` state change happen under the operation mutex before acceptance is returned. + +The receive code is generated once when the singleton incoming controller is created for a fresh application session. The Send and Receive ViewModels both read that same controller-owned value and render the same shared code card. It remains only in memory, is not advertised, and is not remembered by the sender. It is a convenience check rather than authentication because it has only 9,000 possible values and is sent over cleartext HTTP. + +The added request field and structured response statuses change the file-offer wire format. This implementation is not file-transfer compatible with `0.3.0` or older builds. Protocol metadata intentionally remains version `1` during the current preview stage, so matching application builds are required even though discovery does not yet reject an older peer. + +A random operation ID still correlates the accepted file offer, explicit cancellation, and TCP connection. Cancellation succeeds only when both the operation ID and sender device ID match the active file state. Every platform receiver retains its 30-second timeout waiting for the first TCP connection, so an accepted offer cannot leave the receiver busy forever if the sender disappears. File contents remain in platform file readers and are not placed in the HTTP request. ## File data plane: raw TCP @@ -147,11 +153,10 @@ Previously completed files remain when a later file in the same batch fails. ## Current limitations -- No authentication, encryption, session token, or cryptographic integrity check. +- No authentication, encryption, session token, or cryptographic integrity check. The four-digit code has no attempt throttling and must not be treated as a security boundary. - No retry, pause/resume, or interrupted-transfer recovery. - Foreground/background and automatic network-change lifecycle handling are not complete. - Android 17 local-network permission handling is not implemented even though the app targets SDK 37; Android 13 legacy NSD resolves are not serialized or retried after an already-active failure. -- Accepting and cancelling in the narrow interval before the suspended offer handler is resumed can produce an accepted offer response after receiver state has already returned to idle. - Receiver failures do not yet provide rich error details. - HTTP and file-transfer senders retry distinct advertised addresses after connection failures; broader address preference and scoped IPv6 validation still need work. - Desktop interface selection and firewall behavior need broader validation. Windows inbound transfers require an application allow rule or user-approved firewall prompt. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b4310ba..6cccd6b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -56,7 +56,7 @@ Windows: ## Preparing public packages -The current package version is `0.3.0`. +The current package version is `0.4.0`. Android release APKs must use the maintainer's permanent private signing key. Copy `keystore.properties.example` to the ignored `keystore.properties` file and set: @@ -89,9 +89,11 @@ The Windows `upgradeUuid` must remain unchanged for the lifetime of Sync360, and 2. Open Sync360 on both devices and keep it in the foreground during current testing. 3. Wait for the other device to appear on the Send screen. 4. Test direct text delivery while idle and busy, the 100,000/100,001 boundaries, sender name, Copy, and Clear. -5. Test file Accept/Decline, one file, multiple files, and cancellation. -6. Confirm completed files appear in Downloads. -7. Resize the Desktop window and verify compact single-pane navigation and the wider 50/50 Send/Receive layout. +5. Test the file receive-code dialog with correct, incorrect, incomplete, and non-numeric input. +6. Confirm Send and Receive show the same code, and that a fresh application start creates a new code while navigation and recomposition do not change it. +7. Test one file, multiple files, receiver-busy behavior, the first-connection timeout, and cancellation. +8. Confirm completed files appear in Downloads. +9. Resize the Desktop window and verify compact single-pane navigation and the wider 50/50 Send/Receive layout. For Windows testing, check IPv4 and IPv6 with Ethernet, Wi-Fi, VPN, WSL, Docker, Hyper-V, or virtual-machine adapters. Windows DNS-SD browses and registers with interface index `0`, so Windows selects the applicable interfaces. Confirm discovery and resolution, live removal when a nearby app closes, removal of Windows from the other device after the Desktop app closes, Reload, and full connection repair. diff --git a/docs/OPEN_SOURCE_NOTES.md b/docs/OPEN_SOURCE_NOTES.md index 59a0fbb..eb3f7bf 100644 --- a/docs/OPEN_SOURCE_NOTES.md +++ b/docs/OPEN_SOURCE_NOTES.md @@ -47,7 +47,7 @@ Good moments to share publicly: - two devices discover each other - first local HTTP response -- receiver approval flow +- temporary file receive-code flow - first text send - first file send - progress UI @@ -59,7 +59,7 @@ Good moments to share publicly: Sync360 is still early, but the useful local flow is real: ```text -local discovery -> receiver approval -> direct text or file transfer +local discovery -> direct text or receive-code-checked file transfer ``` Android is the most-tested platform. Desktop/JVM implements the same shared flow and has initial Desktop-to-Android validation. An enabled iOS implementation exists in source and has opened in a cloud simulator, but same-LAN and physical-device transfer remain unverified. Broader operating-system, adapter, firewall, and router testing is still needed. That is the story to tell clearly without presenting the app as finished or secure. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 177e7b2..7798337 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Sync360 is an active Android-first rebuild. The current MVP can discover nearby Sync360 devices, deliver text directly to an idle receiver, and stream receiver-approved file batches over the local network. Android is the most-tested platform. Desktop-to-Android transfer has initial manual validation, and one Windows 11 Ethernet test confirmed prompt discovery and removal in both directions when the corresponding app opened or closed. +Sync360 is an active Android-first rebuild. The current MVP can discover nearby Sync360 devices, deliver text directly to an idle receiver, and admit file batches through a temporary four-digit receive code before streaming them over the local network. Android is the most-tested platform. Desktop-to-Android transfer has initial manual validation, and one Windows 11 Ethernet test confirmed prompt discovery and removal in both directions when the corresponding app opened or closed. ## Working now @@ -13,12 +13,14 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby - Dynamic HTTP and file-transfer ports advertised with device metadata. - One-request text delivery with sender name, a 100,000-character limit, Copy, and Clear. - Android and Desktop multiple-file selection. -- File metadata offer before any file bytes are sent. +- A temporary four-digit file receive code generated for each fresh application session. +- The same receive code shown on both Send and Receive from one application-session source of truth. +- Immediate code and metadata checking before any file bytes are sent. - One persistent raw TCP connection per accepted file batch. - Sequential file framing, index/size validation, and one final success/completed-count result per batch. - Android public Downloads writing with incomplete-entry cleanup. - Desktop Downloads writing through temporary `.part` files and collision-safe final names. -- Operation-scoped sender cancellation that explicitly clears the matching receiver offer or transfer, with timeout fallbacks for lost communication. +- Operation-scoped sender cancellation that explicitly clears the matching active receiver transfer, with timeout fallbacks for lost communication. - Batch-wide byte percentage on the sender and receiver. - Shared Compose UI with compact navigation and a wider 50/50 Send/Receive scene. - Enabled iOS device and Apple-silicon Simulator targets with initial Bonjour, selection, clipboard, storage, and TCP transfer implementations. @@ -29,7 +31,7 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby - Improve receiver-side failure details and per-file results. - Test cancellation and failure at more points in large multi-file batches. -- Close the narrow Accept/Cancel response race in the shared incoming-operation controller. +- Validate correct, incorrect, busy, cancelled, and missing-TCP-sender code flows. - Add focused protocol and storage tests. ### Discovery and lifecycle @@ -73,5 +75,5 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby The product direction remains focused: ```text -find nearby -> send text or approve files -> transfer directly +find nearby -> send text or enter a file receive code -> transfer directly ``` diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 4b5030b..d34870c 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Sync360 PRODUCT_BUNDLE_IDENTIFIER=com.liftley.sync360.Sync360$(TEAM_ID) -CURRENT_PROJECT_VERSION=3 -MARKETING_VERSION=0.3.0 +CURRENT_PROJECT_VERSION=4 +MARKETING_VERSION=0.4.0 diff --git a/screenshots/README.md b/screenshots/README.md index 0ec33e3..bb9377a 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -7,10 +7,10 @@ The README currently references these assets: | File | Purpose | Suggested size | | ---- | ------- | -------------- | | `shared/src/commonMain/composeResources/drawable/app_icon.png` | Active app icon near the README title | 1024x1024 PNG with a transparent background | -| `hero-demo.gif` | Main README demo showing the current Android discovery, approval, text, and file-transfer experience | 1080x1080 | -| `desktop-to-android-demo.gif` | README demo showing the current Desktop-to-Android discovery, approval, and file-transfer experience | 1920x1080 (16:9) | +| `hero-demo.gif` | Main README demo showing the Android discovery, text, and file-transfer experience | 1080x1080 | +| `desktop-to-android-demo.gif` | README demo showing the Desktop-to-Android discovery and file-transfer experience | 1920x1080 (16:9) | | `android-send.png` | Android Send screen | 1080x2400 or cropped portrait | -| `android-receive-request.png` | Receiver approval screen | 1080x2400 or cropped portrait | +| `android-receive-request.png` | Receiver code screen | 1080x2400 or cropped portrait | | `device-discovery.png` | Nearby device list close-up | 1080x1200 or readable crop | | `architecture-preview.png` | Visual architecture preview | 1600x900 | | `desktop-home.png` | Desktop screenshot later | 1600x1000 or 1920x1080 | diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt index 3f0913e..61f70e2 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt @@ -76,17 +76,16 @@ fun Sync360Root() { sendScreenState.registrationStatus == RegistrationStatus.Running val repairEnabled = sendScreenState.sendOperationState == SendOperationState.Idle && - receiveScreenState == ReceiveScreenState.Idle && + receiveScreenState is ReceiveScreenState.Idle && discoveryIsStable && registrationIsStable val shouldKeepScreenOn = sendScreenState.sendOperationState != SendOperationState.Idle || - receiveScreenState != ReceiveScreenState.Idle + receiveScreenState !is ReceiveScreenState.Idle val receiveTitle = when (receiveScreenState) { - ReceiveScreenState.Idle -> "Sync360" - is ReceiveScreenState.IncomingFileOffer -> "Incoming files" + is ReceiveScreenState.Idle -> "Sync360" is ReceiveScreenState.ReceivingFiles -> "Receiving files" is ReceiveScreenState.ReceivedText -> "Received text" is ReceiveScreenState.ReceivedFiles -> "Files received" @@ -96,7 +95,7 @@ fun Sync360Root() { SendOperationState.Idle -> "Sync360" SendOperationState.Cancelled -> "Sending Cancelled" is SendOperationState.SendingText -> "Sending Text" - is SendOperationState.SendingFileOffer -> "Sending File Offer" + is SendOperationState.PreparingFiles -> "Preparing Files" is SendOperationState.SendingFile -> "Sending Files" is SendOperationState.TextSent -> "Text Sent" is SendOperationState.FilesSent -> "Files Sent" @@ -242,7 +241,7 @@ fun Sync360Root() { LaunchedEffect(receiveScreenState) { if ( receiveScreenState is ReceiveScreenState.ReceivedText || - receiveScreenState is ReceiveScreenState.IncomingFileOffer + receiveScreenState is ReceiveScreenState.ReceivingFiles ) { navigationViewModel.navigateToReceive() } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt index 57b3d33..74eb5ac 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt @@ -17,7 +17,7 @@ import org.koin.dsl.module val appModule = module { single { ReceiveScreenViewModel(get(), get(), get()) } single { - SendScreenViewModel(get(), get(), get()) + SendScreenViewModel(get(), get(), get(), get()) } single { NavigationViewModel() diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt index a87233f..cf11893 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt @@ -1,54 +1,29 @@ package com.liftley.sync360.data import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest +import com.liftley.sync360.data.network.http.dto.file.FileOfferResponse +import com.liftley.sync360.data.network.http.dto.file.FileOfferStatus import com.liftley.sync360.data.network.http.dto.text.TextDeliveryRequest import com.liftley.sync360.data.network.http.dto.text.TextDeliveryResponse import com.liftley.sync360.data.network.http.dto.text.TextDeliveryStatus import com.liftley.sync360.domain.model.ClientServerState +import com.liftley.sync360.domain.model.FileReceiveCode import com.liftley.sync360.domain.model.FileTransferProgress import com.liftley.sync360.domain.model.TextDeliveryLimits -import com.liftley.sync360.domain.model.UserDecision -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.milliseconds import kotlin.uuid.Uuid class IncomingServerRequestsController { private val _clientServerState = MutableStateFlow(ClientServerState.Idle) val clientServerState: StateFlow = _clientServerState.asStateFlow() + val fileReceiveCode: String = FileReceiveCode.generate() private val operationMutex = Mutex() - private val operationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var pendingFileOfferDecision: CompletableDeferred? = null - - init { - operationScope.launch { - clientServerState.collectLatest { state -> - val operationId = when (state) { - is ClientServerState.WaitingForFiles -> state.fileOffer.operationId - else -> return@collectLatest - } - - delay(ACCEPTED_OPERATION_TIMEOUT_MILLIS.milliseconds) - expireOperation(operationId) - } - } - } internal suspend fun deliverIncomingText( request: TextDeliveryRequest @@ -77,117 +52,53 @@ class IncomingServerRequestsController { } } - internal suspend fun awaitFileOfferDecision( - fileOffer: FileOfferRequest - ): UserDecision? = registerFileOfferAndAwaitDecision( - operationId = fileOffer.operationId, - offerState = ClientServerState.IncomingFileOffer(fileOffer) - ) - - private suspend fun registerFileOfferAndAwaitDecision( - operationId: Uuid, - offerState: ClientServerState - ): UserDecision? { - val decision = operationMutex.withLock { - if (_clientServerState.value != ClientServerState.Idle) { - return@withLock null - } - - CompletableDeferred().also { decision -> - pendingFileOfferDecision = decision - _clientServerState.value = offerState - } - } ?: return null - - val result = try { - withTimeoutOrNull(OFFER_DECISION_TIMEOUT_MILLIS.milliseconds) { - decision.await() - } - } catch (exception: CancellationException) { - withContext(NonCancellable) { - expireOperation(operationId) - } - throw exception + internal suspend fun prepareIncomingFileTransfer( + fileOffer: FileOfferRequest, + prepare: (acceptedFileOffer: FileOfferRequest) -> Unit + ): FileOfferResponse = operationMutex.withLock { + if (_clientServerState.value != ClientServerState.Idle) { + return@withLock FileOfferResponse( + status = FileOfferStatus.RECEIVER_BUSY + ) } - if (result == null) { - expireOperation(operationId) + if (fileOffer.receiveCode != fileReceiveCode) { + return@withLock FileOfferResponse( + status = FileOfferStatus.INVALID_CODE + ) } - return result ?: UserDecision.DECLINED - } - - suspend fun respondToFileOffer(decision: UserDecision) { - val waitingDecision = operationMutex.withLock { - val currentDecision = pendingFileOfferDecision ?: return@withLock null - - _clientServerState.value = when (val state = _clientServerState.value) { - is ClientServerState.IncomingFileOffer -> { - if (decision == UserDecision.ACCEPTED) { - ClientServerState.WaitingForFiles(state.fileOffer) - } else { - ClientServerState.Idle - } - } - - else -> return@withLock null - } - - pendingFileOfferDecision = null - currentDecision - } ?: return - - waitingDecision.complete(decision) - } - - private suspend fun expireOperation(operationId: Uuid) { - val waitingDecision = operationMutex.withLock { - if (!_clientServerState.value.matchesExpirableOperation(operationId)) { - return@withLock null - } + val acceptedFileOffer = fileOffer.copy(receiveCode = "") - _clientServerState.value = ClientServerState.Idle - pendingFileOfferDecision.also { pendingFileOfferDecision = null } + try { + prepare(acceptedFileOffer) + } catch (exception: Exception) { + exception.printStackTrace() + return@withLock FileOfferResponse( + status = FileOfferStatus.PREPARATION_FAILED + ) } - waitingDecision?.complete(UserDecision.DECLINED) + _clientServerState.value = ClientServerState.ReceivingFiles( + fileOffer = acceptedFileOffer, + completedFileCount = 0, + progress = FileTransferProgress.waiting(acceptedFileOffer.totalSizeBytes) + ) + + FileOfferResponse( + status = FileOfferStatus.ACCEPTED + ) } internal suspend fun cancelOperation( operationId: Uuid, senderDeviceId: String - ): Boolean { - val cancellation = operationMutex.withLock { - if (!_clientServerState.value.matchesOperation(operationId, senderDeviceId)) { - return@withLock null - } - - _clientServerState.value = ClientServerState.Idle - Cancellation( - waitingDecision = pendingFileOfferDecision.also { - pendingFileOfferDecision = null - } - ) - } ?: return false - - cancellation.waitingDecision?.complete(UserDecision.CANCELLED) - return true - } - - suspend fun prepareAcceptedFileTransfer( - operationId: Uuid, - prepare: () -> Unit ): Boolean = operationMutex.withLock { - val state = _clientServerState.value as? ClientServerState.WaitingForFiles - ?: return@withLock false - if (state.fileOffer.operationId != operationId) return@withLock false + if (!_clientServerState.value.matchesOperation(operationId, senderDeviceId)) { + return@withLock false + } - prepare() - _clientServerState.value = ClientServerState.ReceivingFiles( - fileOffer = state.fileOffer, - completedFileCount = 0, - progress = FileTransferProgress.waiting(state.fileOffer.totalSizeBytes) - ) + _clientServerState.value = ClientServerState.Idle true } @@ -262,8 +173,6 @@ class IncomingServerRequestsController { senderDeviceId: String ): Boolean { val operation = when (this) { - is ClientServerState.IncomingFileOffer -> fileOffer.operationId to fileOffer.senderDeviceId - is ClientServerState.WaitingForFiles -> fileOffer.operationId to fileOffer.senderDeviceId is ClientServerState.ReceivingFiles -> fileOffer.operationId to fileOffer.senderDeviceId else -> return false } @@ -271,23 +180,4 @@ class IncomingServerRequestsController { return operation.first == operationId && operation.second == senderDeviceId } - - private fun ClientServerState.matchesExpirableOperation(operationId: Uuid): Boolean { - val currentOperationId = when (this) { - is ClientServerState.IncomingFileOffer -> fileOffer.operationId - is ClientServerState.WaitingForFiles -> fileOffer.operationId - else -> return false - } - - return currentOperationId == operationId - } - - private data class Cancellation( - val waitingDecision: CompletableDeferred? - ) - - private companion object { - const val OFFER_DECISION_TIMEOUT_MILLIS = 50_000L - const val ACCEPTED_OPERATION_TIMEOUT_MILLIS = 30_000L - } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt index af926f5..2e50baf 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt @@ -1,15 +1,18 @@ package com.liftley.sync360.data +import com.liftley.sync360.data.network.http.client.FileOfferException import com.liftley.sync360.data.network.http.client.Sync360HttpClient import com.liftley.sync360.data.network.http.client.TextDeliveryException import com.liftley.sync360.data.network.http.dto.CancelRequest import com.liftley.sync360.data.network.http.dto.CancelResponse import com.liftley.sync360.data.network.http.dto.file.FileOfferItem import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest +import com.liftley.sync360.data.network.http.dto.file.FileOfferStatus import com.liftley.sync360.data.network.http.dto.text.TextDeliveryRequest import com.liftley.sync360.data.network.http.dto.text.TextDeliveryStatus import com.liftley.sync360.data.network.tcp.FileTransferSender import com.liftley.sync360.domain.local.LocalDeviceInfoProvider +import com.liftley.sync360.domain.model.FileReceiveCode import com.liftley.sync360.domain.model.FileTransferProgress import com.liftley.sync360.domain.model.NearbyDevice import com.liftley.sync360.domain.model.SelectedFile @@ -99,6 +102,7 @@ class OutgoingRequestsController( suspend fun sendFiles( deviceToSendFiles: NearbyDevice, selectedFiles: List, + receiveCode: String, operationId: Uuid, onFileStarted: suspend (fileIndex: Int, file: SelectedFile) -> Unit, onProgress: (FileTransferProgress) -> Unit @@ -109,6 +113,12 @@ class OutgoingRequestsController( ) } + if (!FileReceiveCode.isValid(receiveCode)) { + return Result.failure( + FileOfferException("Enter the receiver's four-digit code") + ) + } + val fileWithUnknownSize = selectedFiles.firstOrNull { it.sizeBytes == null } @@ -140,23 +150,50 @@ class OutgoingRequestsController( operationId = operationId, senderDeviceId = myDeviceInfo.deviceId, senderDeviceName = myDeviceInfo.deviceName, + receiveCode = receiveCode, offeredFiles = offeredFiles, totalSizeBytes = totalSizeBytes ) - httpClient.sendFilesToDevice(deviceToSendFiles, fileOfferRequest).fold( - onSuccess = { - return fileTransferSender.sendFiles( - deviceToSendFiles = deviceToSendFiles, - files = selectedFiles, - operationId = operationId, - onFileStarted = onFileStarted, - onProgress = onProgress + val response = httpClient.sendFilesToDevice( + deviceToSendFiles = deviceToSendFiles, + fileOfferRequest = fileOfferRequest + ).getOrElse { exception -> + return Result.failure(exception) + } + + when (response.status) { + FileOfferStatus.ACCEPTED -> Unit + + FileOfferStatus.INVALID_CODE -> { + return Result.failure( + FileOfferException("The receive code is incorrect") + ) + } + + FileOfferStatus.RECEIVER_BUSY -> { + return Result.failure( + FileOfferException( + "${deviceToSendFiles.deviceName} is currently busy" + ) + ) + } + + FileOfferStatus.PREPARATION_FAILED -> { + return Result.failure( + FileOfferException( + "${deviceToSendFiles.deviceName} could not prepare for the file transfer" + ) ) - }, - onFailure = { error -> - return Result.failure(error) } + } + + return fileTransferSender.sendFiles( + deviceToSendFiles = deviceToSendFiles, + files = selectedFiles, + operationId = operationId, + onFileStarted = onFileStarted, + onProgress = onProgress ) } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/FileOfferException.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/FileOfferException.kt index 2a43437..e70d452 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/FileOfferException.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/FileOfferException.kt @@ -1,3 +1,3 @@ package com.liftley.sync360.data.network.http.client -class FileOfferException(response: String) : Exception("Offer status: $response") \ No newline at end of file +class FileOfferException(message: String) : Exception(message) diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/Sync360HttpClient.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/Sync360HttpClient.kt index a97e76d..47bd7e4 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/Sync360HttpClient.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/Sync360HttpClient.kt @@ -77,7 +77,7 @@ class Sync360HttpClient { val deviceToSendOfferPort = deviceToSendFiles.port return try { - val fileOfferResponse = requestUsingReachableAddress(deviceToSendFiles) { host -> + val response = requestUsingReachableAddress(deviceToSendFiles) { host -> val url = "http://${host.asUrlHost()}:$deviceToSendOfferPort/sync360/file/offer" httpClient.post(url) { contentType(ContentType.Application.Json) @@ -85,15 +85,7 @@ class Sync360HttpClient { }.body() } - when (fileOfferResponse) { - FileOfferResponse.Accepted -> { - Result.success(FileOfferResponse.Accepted) - } - - FileOfferResponse.Declined -> { - Result.failure(FileOfferException("User Declined Request")) - } - } + Result.success(response) } catch (e: Exception) { when (e) { is CancellationException -> throw e diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferRequest.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferRequest.kt index af170d9..a472dd5 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferRequest.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferRequest.kt @@ -8,6 +8,7 @@ data class FileOfferRequest( val operationId: Uuid, val senderDeviceId: String, val senderDeviceName: String, + val receiveCode: String, val offeredFiles: List, val totalSizeBytes: Long -) \ No newline at end of file +) diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferResponse.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferResponse.kt index 86e590a..13f6bf9 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferResponse.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/file/FileOfferResponse.kt @@ -3,6 +3,14 @@ package com.liftley.sync360.data.network.http.dto.file import kotlinx.serialization.Serializable @Serializable -enum class FileOfferResponse { - Accepted, Declined -} \ No newline at end of file +data class FileOfferResponse( + val status: FileOfferStatus +) + +@Serializable +enum class FileOfferStatus { + ACCEPTED, + INVALID_CODE, + RECEIVER_BUSY, + PREPARATION_FAILED +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/server/Sync360HttpServer.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/server/Sync360HttpServer.kt index 21510d0..4ab08d4 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/server/Sync360HttpServer.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/server/Sync360HttpServer.kt @@ -4,10 +4,8 @@ import com.liftley.sync360.data.IncomingServerRequestsController import com.liftley.sync360.data.network.http.dto.CancelRequest import com.liftley.sync360.data.network.http.dto.CancelResponse import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest -import com.liftley.sync360.data.network.http.dto.file.FileOfferResponse import com.liftley.sync360.data.network.http.dto.text.TextDeliveryRequest import com.liftley.sync360.data.network.tcp.FileTransferReceiver -import com.liftley.sync360.domain.model.UserDecision import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.install import io.ktor.server.cio.CIO @@ -44,24 +42,11 @@ class Sync360HttpServer( post("/sync360/file/offer") { val request = call.receive() - val userDecision = - incomingServerRequestsController.awaitFileOfferDecision(request) - - if (userDecision == null) { - call.respond(FileOfferResponse.Declined) - return@post - } - - if (userDecision != UserDecision.ACCEPTED) { - call.respond(FileOfferResponse.Declined) - return@post - } - - val prepared = incomingServerRequestsController.prepareAcceptedFileTransfer( - operationId = request.operationId - ) { + val response = incomingServerRequestsController.prepareIncomingFileTransfer( + fileOffer = request + ) { acceptedFileOffer -> fileTransferReceiver.prepareForTransfer( - fileOffer = request, + fileOffer = acceptedFileOffer, onFileSaved = { completedFileCount -> incomingServerRequestsController.updateCompletedFileCount( operationId = request.operationId, @@ -83,13 +68,7 @@ class Sync360HttpServer( ) } - call.respond( - if (prepared) { - FileOfferResponse.Accepted - } else { - FileOfferResponse.Declined - } - ) + call.respond(response) } post("/sync360/operation/cancel") { diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/ClientServerState.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/ClientServerState.kt index c4527b6..3ca63f6 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/ClientServerState.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/ClientServerState.kt @@ -10,14 +10,6 @@ sealed interface ClientServerState { val text: String ) : ClientServerState - data class IncomingFileOffer( - val fileOffer: FileOfferRequest - ) : ClientServerState - - data class WaitingForFiles( - val fileOffer: FileOfferRequest - ) : ClientServerState - data class ReceivingFiles( val fileOffer: FileOfferRequest, val completedFileCount: Int, @@ -29,7 +21,3 @@ sealed interface ClientServerState { val fileCount: Int ) : ClientServerState } - -enum class UserDecision { - ACCEPTED, DECLINED, CANCELLED -} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/FileReceiveCode.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/FileReceiveCode.kt new file mode 100644 index 0000000..a760787 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/FileReceiveCode.kt @@ -0,0 +1,20 @@ +package com.liftley.sync360.domain.model + +import kotlin.random.Random + +object FileReceiveCode { + const val DIGIT_COUNT = 4 + + fun generate(): String { + return Random.nextInt( + from = 1_000, + until = 10_000 + ).toString() + } + + fun isValid(code: String): Boolean { + return code.length == DIGIT_COUNT && code.all { character -> + character in '0'..'9' + } + } +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt new file mode 100644 index 0000000..99330bc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt @@ -0,0 +1,44 @@ +package com.liftley.sync360.presentation.app.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun FileReceiveCodeCard( + fileReceiveCode: String +) { + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "File receive code", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + fileReceiveCode, + style = MaterialTheme.typography.displayMedium + ) + Text( + "Enter this code on the sending device", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt index 629332f..3db03a8 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt @@ -6,9 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.liftley.sync360.domain.model.UserDecision import com.liftley.sync360.presentation.app.components.Sync360Surface -import com.liftley.sync360.presentation.receive.components.FileOfferStateUi import com.liftley.sync360.presentation.receive.components.IdleReceiveStateUi import com.liftley.sync360.presentation.receive.components.ReceivedFilesStateUi import com.liftley.sync360.presentation.receive.components.ReceivedTextStateUi @@ -27,8 +25,9 @@ fun ReceiveScreen( containerColor = MaterialTheme.colorScheme.surfaceContainer ) { when (val state = receiveScreenState) { - ReceiveScreenState.Idle -> { + is ReceiveScreenState.Idle -> { IdleReceiveStateUi( + fileReceiveCode = state.fileReceiveCode, onTroubleshootClick = onTroubleshootClick ) } @@ -45,14 +44,6 @@ fun ReceiveScreen( ) } - is ReceiveScreenState.IncomingFileOffer -> { - FileOfferStateUi( - state = state, - onAccept = { receiveScreenViewModel.respondToFileOffer(UserDecision.ACCEPTED) }, - onDecline = { receiveScreenViewModel.respondToFileOffer(UserDecision.DECLINED) } - ) - } - is ReceiveScreenState.ReceivingFiles -> { ReceivingFilesStateUi(state) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt index d83735b..5d05250 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.liftley.sync360.data.IncomingServerRequestsController import com.liftley.sync360.domain.model.ClientServerState -import com.liftley.sync360.domain.model.FileTransferProgress -import com.liftley.sync360.domain.model.UserDecision import com.liftley.sync360.domain.repository.ClipboardProvider import com.liftley.sync360.domain.repository.DownloadsFolderOpener import com.liftley.sync360.presentation.receive.model.ReceiveScreenState @@ -20,23 +18,23 @@ class ReceiveScreenViewModel( private val downloadsFolderOpener: DownloadsFolderOpener ) : ViewModel() { - private val _screenState = MutableStateFlow(ReceiveScreenState.Idle) + private val _screenState = MutableStateFlow( + ReceiveScreenState.Idle( + fileReceiveCode = incomingServerRequestsController.fileReceiveCode + ) + ) val screenState: StateFlow = _screenState.asStateFlow() init { viewModelScope.launch { incomingServerRequestsController.clientServerState.collect { state -> - _screenState.value = state.toReceiveScreenState() + _screenState.value = state.toReceiveScreenState( + fileReceiveCode = incomingServerRequestsController.fileReceiveCode + ) } } } - fun respondToFileOffer(decision: UserDecision) { - viewModelScope.launch { - incomingServerRequestsController.respondToFileOffer(decision) - } - } - fun copyReceivedText(text: String) { clipboardProvider.setLatestClipboardTextAs(text) } @@ -53,9 +51,13 @@ class ReceiveScreenViewModel( } -private fun ClientServerState.toReceiveScreenState(): ReceiveScreenState { +private fun ClientServerState.toReceiveScreenState( + fileReceiveCode: String +): ReceiveScreenState { return when (this) { - ClientServerState.Idle -> ReceiveScreenState.Idle + ClientServerState.Idle -> { + ReceiveScreenState.Idle(fileReceiveCode = fileReceiveCode) + } is ClientServerState.TextReceived -> { ReceiveScreenState.ReceivedText( @@ -64,23 +66,6 @@ private fun ClientServerState.toReceiveScreenState(): ReceiveScreenState { ) } - is ClientServerState.IncomingFileOffer -> { - ReceiveScreenState.IncomingFileOffer( - senderDeviceName = fileOffer.senderDeviceName, - fileCount = fileOffer.offeredFiles.size, - totalSizeBytes = fileOffer.totalSizeBytes - ) - } - - is ClientServerState.WaitingForFiles -> { - ReceiveScreenState.ReceivingFiles( - senderDeviceName = fileOffer.senderDeviceName, - fileCount = fileOffer.offeredFiles.size, - completedFileCount = 0, - progress = FileTransferProgress.waiting(fileOffer.totalSizeBytes) - ) - } - is ClientServerState.ReceivingFiles -> { ReceiveScreenState.ReceivingFiles( senderDeviceName = fileOffer.senderDeviceName, diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/FileOfferStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/FileOfferStateUi.kt deleted file mode 100644 index 15a9b71..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/FileOfferStateUi.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.liftley.sync360.presentation.receive.components - -import androidx.compose.foundation.layout.Arrangement -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.liftley.sync360.presentation.app.components.Sync360Surface -import com.liftley.sync360.presentation.receive.model.ReceiveScreenState - -@Composable -fun FileOfferStateUi( - state: ReceiveScreenState.IncomingFileOffer, - onAccept: () -> Unit, - onDecline: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Sync360Surface { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = state.senderDeviceName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - Text( - text = "Wants to send ${state.fileCount} file(s)", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Transfer size", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = formatFileSize(state.totalSizeBytes), - style = MaterialTheme.typography.titleMedium - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = onDecline, - modifier = Modifier.weight(1f).height(48.dp) - ) { - Text( - "Decline", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium - ) - } - Button( - onClick = onAccept, - modifier = Modifier.weight(1f).height(48.dp) - ) { - Text( - "Accept", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium - ) - } - } - } - } - } -} - -private fun formatFileSize(sizeBytes: Long): String { - val unitSize: Long - val unitName: String - - when { - sizeBytes >= 1_000_000_000 -> { - unitSize = 1_000_000_000 - unitName = "GB" - } - - sizeBytes >= 1_000_000 -> { - unitSize = 1_000_000 - unitName = "MB" - } - - sizeBytes >= 1_000 -> { - unitSize = 1_000 - unitName = "KB" - } - - else -> return "$sizeBytes bytes" - } - - val wholePart = sizeBytes / unitSize - val decimalPart = (sizeBytes % unitSize) * 10 / unitSize - - return if (decimalPart == 0L) { - "$wholePart $unitName" - } else { - "$wholePart.$decimalPart $unitName" - } -} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt index 55c06ae..cd3311b 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt @@ -20,9 +20,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Emoji_Nature +import com.liftley.sync360.presentation.app.components.FileReceiveCodeCard @Composable fun IdleReceiveStateUi( + fileReceiveCode: String, onTroubleshootClick: () -> Unit ) { Box( @@ -62,6 +64,8 @@ fun IdleReceiveStateUi( color = MaterialTheme.colorScheme.onSurfaceVariant ) + FileReceiveCodeCard(fileReceiveCode = fileReceiveCode) + TextButton(onClick = onTroubleshootClick) { Text("Troubleshoot") } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/model/ReceiveScreenState.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/model/ReceiveScreenState.kt index 02ea370..dcb0677 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/model/ReceiveScreenState.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/model/ReceiveScreenState.kt @@ -3,19 +3,15 @@ package com.liftley.sync360.presentation.receive.model import com.liftley.sync360.domain.model.FileTransferProgress sealed interface ReceiveScreenState { - data object Idle : ReceiveScreenState + data class Idle( + val fileReceiveCode: String + ) : ReceiveScreenState data class ReceivedText( val senderDeviceName: String, val text: String ) : ReceiveScreenState - data class IncomingFileOffer( - val senderDeviceName: String, - val fileCount: Int, - val totalSizeBytes: Long - ): ReceiveScreenState - data class ReceivingFiles( val senderDeviceName: String, val fileCount: Int, diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt index 181be47..bb3bc0d 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt @@ -19,7 +19,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.liftley.sync360.presentation.app.components.FileReceiveCodeCard import com.liftley.sync360.presentation.app.components.Sync360Surface +import com.liftley.sync360.presentation.send.components.FileReceiveCodeDialog import com.liftley.sync360.presentation.send.components.FilesSendContent import com.liftley.sync360.presentation.send.components.NearbyDevicesSection import com.liftley.sync360.presentation.send.components.SendOperationStateUi @@ -46,6 +48,10 @@ fun SendScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { + FileReceiveCodeCard( + fileReceiveCode = screenState.fileReceiveCode + ) + Sync360Surface( containerColor = MaterialTheme.colorScheme.surface ) { @@ -125,4 +131,13 @@ fun SendScreen( ) } } + + screenState.fileReceiveCodePrompt?.let { prompt -> + FileReceiveCodeDialog( + prompt = prompt, + onCodeChange = sendScreenViewModel::onFileReceiveCodeChanged, + onDismiss = sendScreenViewModel::dismissFileReceiveCodePrompt, + onConfirm = sendScreenViewModel::confirmFileReceiveCode + ) + } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt index 166ff5c..45e55cb 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt @@ -2,15 +2,18 @@ package com.liftley.sync360.presentation.send import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.liftley.sync360.data.IncomingServerRequestsController import com.liftley.sync360.data.NetworkServicesController import com.liftley.sync360.data.OutgoingRequestsController import com.liftley.sync360.data.file.SelectedFileReader +import com.liftley.sync360.domain.model.FileReceiveCode +import com.liftley.sync360.domain.model.FileTransferProgress import com.liftley.sync360.domain.model.NearbyDevice import com.liftley.sync360.domain.model.SelectedFile -import com.liftley.sync360.domain.model.FileTransferProgress import com.liftley.sync360.domain.model.TextDeliveryLimits -import com.liftley.sync360.presentation.send.model.SendScreenState +import com.liftley.sync360.presentation.send.model.FileReceiveCodePrompt import com.liftley.sync360.presentation.send.model.SendOperationState +import com.liftley.sync360.presentation.send.model.SendScreenState import com.liftley.sync360.presentation.send.model.SendTab import com.liftley.sync360.presentation.send.model.toNearbyDeviceUiModel import kotlinx.coroutines.Dispatchers @@ -29,9 +32,14 @@ class SendScreenViewModel( private val selectedFileReader: SelectedFileReader, private val networkServicesController: NetworkServicesController, private val outgoingRequestsController: OutgoingRequestsController, + incomingServerRequestsController: IncomingServerRequestsController ) : ViewModel() { private val _sendScreenState: MutableStateFlow = - MutableStateFlow(SendScreenState()) + MutableStateFlow( + SendScreenState( + fileReceiveCode = incomingServerRequestsController.fileReceiveCode + ) + ) val sendScreenState: StateFlow = _sendScreenState.asStateFlow() private var latestNearbyDevices: List = emptyList() @@ -140,7 +148,10 @@ class SendScreenViewModel( } } - private fun sendFilesToDevice(deviceId: String) { + private fun sendFilesToDevice( + deviceId: String, + receiveCode: String + ) { if (_sendScreenState.value.sendOperationState != SendOperationState.Idle) { return } @@ -165,7 +176,7 @@ class SendScreenViewModel( _sendScreenState.update { it.copy( - sendOperationState = SendOperationState.SendingFileOffer( + sendOperationState = SendOperationState.PreparingFiles( deviceName = deviceToSendFiles.deviceName, fileCount = files.size ) @@ -176,6 +187,7 @@ class SendScreenViewModel( val result = outgoingRequestsController.sendFiles( deviceToSendFiles = deviceToSendFiles, selectedFiles = files, + receiveCode = receiveCode, operationId = operationId, onFileStarted = { fileIndex, file -> currentFileIndex = fileIndex @@ -263,17 +275,68 @@ class SendScreenViewModel( fun onTabSelected(tab: SendTab) { _sendScreenState.update { - it.copy(selectedTab = tab) + it.copy( + selectedTab = tab, + fileReceiveCodePrompt = null + ) + } + } + + fun onFileReceiveCodeChanged(code: String) { + val normalizedCode = code + .filter { character -> character in '0'..'9' } + .take(FileReceiveCode.DIGIT_COUNT) + + _sendScreenState.update { state -> + val prompt = state.fileReceiveCodePrompt ?: return@update state + state.copy( + fileReceiveCodePrompt = prompt.copy(code = normalizedCode) + ) } } + fun dismissFileReceiveCodePrompt() { + _sendScreenState.update { + it.copy(fileReceiveCodePrompt = null) + } + } + + fun confirmFileReceiveCode() { + val prompt = _sendScreenState.value.fileReceiveCodePrompt ?: return + if (!FileReceiveCode.isValid(prompt.code)) return + + _sendScreenState.update { + it.copy(fileReceiveCodePrompt = null) + } + + sendFilesToDevice( + deviceId = prompt.deviceId, + receiveCode = prompt.code + ) + } + fun sendToDevice(deviceId: String) { val state = _sendScreenState.value if (!state.isContentReadyToSend) return when (state.selectedTab) { SendTab.Text -> sendTextToDevice(deviceId) - SendTab.Files -> sendFilesToDevice(deviceId) + SendTab.Files -> showFileReceiveCodePrompt(deviceId) + } + } + + private fun showFileReceiveCodePrompt(deviceId: String) { + val targetDevice = latestNearbyDevices.firstOrNull { device -> + device.id == deviceId + } ?: return + + _sendScreenState.update { state -> + state.copy( + fileReceiveCodePrompt = FileReceiveCodePrompt( + deviceId = targetDevice.id, + deviceName = targetDevice.deviceName + ) + ) } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileReceiveCodeDialog.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileReceiveCodeDialog.kt new file mode 100644 index 0000000..854cbbb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileReceiveCodeDialog.kt @@ -0,0 +1,61 @@ +package com.liftley.sync360.presentation.send.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.liftley.sync360.domain.model.FileReceiveCode +import com.liftley.sync360.presentation.send.model.FileReceiveCodePrompt + +@Composable +fun FileReceiveCodeDialog( + prompt: FileReceiveCodePrompt, + onCodeChange: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Enter receive code") + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Enter the four-digit code shown on ${prompt.deviceName}." + ) + + OutlinedTextField( + value = prompt.code, + onValueChange = onCodeChange, + label = { Text("Receive code") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = FileReceiveCode.isValid(prompt.code) + ) { + Text("Send files") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/SendOperationStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/SendOperationStateUi.kt index 48bc8ee..b02cced 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/SendOperationStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/SendOperationStateUi.kt @@ -45,10 +45,10 @@ fun SendOperationStateUi( ) } - is SendOperationState.SendingFileOffer -> { + is SendOperationState.PreparingFiles -> { SendingOperationUi( - message = "Waiting for ${state.deviceName} to accept " + - fileCountMessage(state.fileCount), + message = "Preparing ${fileCountMessage(state.fileCount)} " + + "for ${state.deviceName}", onCancel = onCancel ) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/FileReceiveCodePrompt.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/FileReceiveCodePrompt.kt new file mode 100644 index 0000000..37907a0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/FileReceiveCodePrompt.kt @@ -0,0 +1,7 @@ +package com.liftley.sync360.presentation.send.model + +data class FileReceiveCodePrompt( + val deviceId: String, + val deviceName: String, + val code: String = "" +) diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendOperationState.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendOperationState.kt index 8425e52..a7bc678 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendOperationState.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendOperationState.kt @@ -12,7 +12,7 @@ sealed interface SendOperationState { val deviceName: String ) : SendOperationState - data class SendingFileOffer( + data class PreparingFiles( val deviceName: String, val fileCount: Int ) : SendOperationState diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt index 023cbac..234158c 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt @@ -6,9 +6,11 @@ import com.liftley.sync360.domain.model.SelectedFile import com.liftley.sync360.domain.model.TextDeliveryLimits data class SendScreenState( + val fileReceiveCode: String, val selectedTab: SendTab = SendTab.Text, val textInput: String = "", val files: List = emptyList(), + val fileReceiveCodePrompt: FileReceiveCodePrompt? = null, val sendOperationState: SendOperationState = SendOperationState.Idle, val nearbyDevices: List = emptyList(), val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle,