diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00d1005..0395351 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+## [0.3.0] - 2026-08-27
+
### Added
- Android-first manual rebuild with shared Compose Multiplatform Send and Receive UI.
@@ -14,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Current macOS/Linux DNS-SD/mDNS discovery and registration through JmDNS on eligible IPv4 and IPv6 LAN addresses.
- Separate discovery and registration lifecycle states shared by Android, Desktop, the controller, and UI.
- Stable per-install device identity and advertised dynamic HTTP/file-transfer ports.
-- Text offers, receiver Accept/Decline, text transfer, Copy, and Clear.
+- One-request direct text delivery with sender name, a 100,000-character limit, Copy, and Clear.
- Android and Desktop multiple-file selection and metadata offers.
- Raw TCP file transfer using one persistent connection per accepted batch.
- Operation-bound file framing with operation ID, index, and size validation plus one final batch result containing receiver success and the completed-file count.
@@ -27,12 +29,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Application-lifetime network startup and state-driven connection repair.
- Enabled iOS device and Apple-silicon Simulator targets with native Bonjour discovery, document selection, clipboard, Files-visible storage, and streamed TCP transfer implementations.
- Added an iOS-only GitHub Actions workflow for an unsigned Simulator app and optional development-signed iPhone IPA.
-- Prepared version `0.2.0` across Android, Desktop, and iOS; retained private Android release signing configuration and the permanent Windows MSI upgrade identity.
+- Prepared version `0.3.0` across Android, Desktop, and iOS; retained private Android release signing configuration and the permanent Windows MSI upgrade identity.
- Public architecture, development, roadmap, security, privacy, and contribution documentation.
### Changed
-- Made incoming transfer state the source of truth for offer type, operation identity, and acceptance phase; accepted text now shows a waiting state until its matching payload arrives.
+- Separated text from file operations: text now delivers directly while idle without an offer, decision, operation ID, waiting state, remote cancellation, or Cancel action.
+- Kept file offers, receiver decisions, operation IDs, cancellation, timeouts, progress, and raw TCP streaming unchanged in purpose.
- Replaced the old generated sync implementation with a smaller, manually understood flow.
- Separated Ktor HTTP offer/control messages from raw TCP file bytes.
- Reused one TCP connection for the complete accepted multi-file batch instead of opening one connection per file.
diff --git a/README.md b/README.md
index 2d47950..e58d9a1 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
### Android → Android
- Nearby discovery, receiver approval, and direct Android-to-Android text/file transfer.
+ Nearby discovery and direct Android-to-Android text/file transfer.
### Desktop → Android
@@ -33,7 +33,7 @@ We have all done it: send a file to ourselves, wait for it to upload, open anoth
Sync360 is for that nearby moment.
```text
-open app -> find nearby device -> choose what to send -> receiver approves -> send directly
+open app -> find nearby device -> choose text or files -> send directly
```
The current app discovers other Sync360 devices on the same local network and transfers content directly between them. The transfer path does not use an account, cloud storage, or a Sync360 backend. It depends on the local network and the two devices involved.
@@ -50,8 +50,8 @@ In an initial Windows 11 Ethernet test, the native Windows DNS-SD backend discov
- Discover nearby Android devices with Android NSD/mDNS.
- Advertise dynamic HTTP and file-transfer ports on the local network.
-- Send a text offer and let the receiver accept or decline it.
-- Transfer accepted text and copy it from the Receive screen.
+- 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.
- Stream file bytes directly over raw TCP without loading an entire file into memory.
@@ -92,13 +92,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 is the control plane.** It carries text/file offers, receiver decisions, file metadata, and text payloads.
+- **Ktor HTTP handles direct text delivery and the file control plane.** It carries text payloads, file offers, receiver decisions, and file 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: offer + decision + metadata"| B
+ A -->|"Ktor: direct text delivery"| B
+ A -->|"Ktor: file offer + decision"| B
A -->|"Raw TCP: streamed file bytes"| B
B -->|"Platform Downloads writer"| D["Downloads"]
```
@@ -113,14 +114,12 @@ Android and Desktop start the shared network controller from their application e
SendScreen
-> SendScreenViewModel
-> OutgoingRequestsController
- -> POST /sync360/text/offer
- -> receiver Accept/Decline
- -> accepted receiver waits for the matching text payload
- -> POST /sync360/text/transfer
- -> ReceiveScreen shows the text
+ -> POST /sync360/text/deliver with sender name and text
+ -> receiver atomically accepts only while idle
+ -> ReceiveScreen shows the sender name and text
```
-The sender shares a preview and character count first. After acceptance, the receiver remains in a waiting-for-text state until the matching full text arrives. One operation ID ties the offer, accepted payload, and any explicit cancellation to the same sender operation.
+Text uses one request and has no offer, receiver decision, operation ID, waiting state, remote cancellation, or Cancel action. The UI, outgoing controller, and receiver reject text above 100,000 Kotlin `String.length` units. The receiver checks `Idle` and publishes the complete received text atomically under the incoming-operation mutex; otherwise it reports that it is busy.
### File path
@@ -237,9 +236,9 @@ macOS/Linux:
2. Connect both devices to the same Wi-Fi network or hotspot.
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. Choose Text or Files, select the nearby device, and send an offer.
-6. Accept the offer on the receiving device.
-7. Received files will be written to the platform's Downloads folder.
+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.
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.
@@ -251,7 +250,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. Operation IDs correlate offers, cancellation, accepted text, and file sockets for correctness, but they are not secret or authenticated. Sync360 does not yet authenticate the sender, encrypt content, or verify file integrity with a cryptographic hash. 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. 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.
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.
@@ -278,7 +277,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 -> approve -> send directly
+find nearby -> send text or approve files -> transfer directly
```
## Why the rebuild is intentionally small
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 4d8516d..6dc591a 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 = 2
- versionName = "0.2.0"
+ versionCode = 3
+ versionName = "0.3.0"
}
buildFeatures {
diff --git a/context.md b/context.md
index 2c0c70f..7d39dc8 100644
--- a/context.md
+++ b/context.md
@@ -3,7 +3,7 @@
Sync360 is an Android-first Kotlin Multiplatform / Compose Multiplatform app for nearby sharing over a local network.
```text
-open app -> discover nearby device -> choose content -> receiver approves -> send directly
+open app -> discover nearby device -> choose text or files -> send directly
```
The old AI-generated sync implementation was removed. The current app is being rebuilt manually so its maintainer can understand and own the complete discovery, request, transfer, and storage path.
@@ -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 offers, receiver decisions, metadata, and text payloads.
+- Ktor HTTP direct text delivery plus file offers, receiver decisions, 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`.
@@ -45,8 +45,7 @@ Compose screen -> ViewModel -> controller/service -> common contract -> platform
Ktor HTTP is the control plane:
```text
-POST /sync360/text/offer
-POST /sync360/text/transfer
+POST /sync360/text/deliver
POST /sync360/file/offer
POST /sync360/operation/cancel
```
@@ -78,7 +77,7 @@ Current shared transfer constants use a 512 KiB payload buffer, 5-second connect
## Important limitations
-Sync360 currently uses cleartext local HTTP and raw TCP. Operation IDs correlate protocol messages and file sockets but do not authenticate a peer. The app has receiver approval but 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. 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.
For detailed and current information, read:
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index 9811a64..956059e 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.2.0"
+ packageVersion = "0.3.0"
appResourcesRootDir.set(
project.layout.projectDirectory.dir("packaging/app-resources")
)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6ac65a3..11c2e4a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -18,9 +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 posts a text or file offer through Ktor HTTP
- -> receiver accepts or declines
- -> accepted text continues through HTTP
+ -> 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
-> accepted file bytes stream through one raw TCP connection
-> platform DownloadsWriter saves the files
```
@@ -74,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` creates offers, calls the Ktor client, and starts accepted file transfers.
-- `IncomingServerRequestsController` serializes Accept/Decline/Cancel races and uses `ClientServerState` as the source of truth for the active incoming operation. Text follows `TextOffer -> WaitingForText -> TextReceived`; files follow `FileOffer -> WaitingForFiles -> ReceivingFiles -> FilesReceived`. Active states retain their accepted request, so operation type, sender identity, operation ID, and acceptance phase are derived from state instead of duplicated in a second operation model.
+- `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.
### Discovery
@@ -99,16 +98,17 @@ The macOS/Linux JmDNS fallback starts on eligible IPv4 and IPv6 addresses from e
## Control plane: Ktor HTTP
-Ktor carries offers, decisions, metadata, and text:
+Ktor carries direct text plus file offers, decisions, and metadata:
```text
-POST /sync360/text/offer
-POST /sync360/text/transfer
+POST /sync360/text/deliver
POST /sync360/file/offer
POST /sync360/operation/cancel
```
-An offer waits up to 50 seconds for the receiver's decision. After acceptance, the controller derives a 30-second payload-preparation timeout from `WaitingForText` or `WaitingForFiles`; leaving either state automatically cancels that timer. A random operation ID correlates the offer, accepted payload, explicit cancellation, and file connection. Cancellation succeeds only when both the operation ID and sender device ID match the active state. The timeouts remain fallbacks for crashes and lost network communication. The shared flow uses `FileOfferRequest` directly for the accepted metadata; file contents still remain in platform file readers and are not placed in the HTTP request.
+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.
## File data plane: raw TCP
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 437a736..b4310ba 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -56,7 +56,7 @@ Windows:
## Preparing public packages
-The current package version is `0.2.0`.
+The current package version is `0.3.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:
@@ -88,8 +88,8 @@ The Windows `upgradeUuid` must remain unchanged for the lifetime of Sync360, and
1. Connect both devices to the same trusted Wi-Fi network or hotspot.
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 a text offer: Accept, Decline, transfer, Copy, and Clear.
-5. Test one file, multiple files, and cancellation.
+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.
@@ -121,7 +121,7 @@ Useful source locations:
- `WindowsNetworkServices` — Windows DNS-SD registration, discovery, resolution, cancellation, and shared-state mapping.
- `WindowsDnsSdApi` — focused JDK Foreign Function and Memory bindings for `dnsapi.dll`.
- `JvmNetworkServices` — current macOS/Linux JmDNS registration, discovery, repair cleanup, and IPv4/IPv6 LAN-interface selection.
-- `Sync360HttpServer` / `Sync360HttpClient` — offer and text routes.
+- `Sync360HttpServer` / `Sync360HttpClient` — direct text delivery and file control routes.
- `OutgoingRequestsController` / `IncomingServerRequestsController` — send/receive coordination.
- platform `FileTransferSender`, `FileTransferReceiver`, and `DownloadsWriter` implementations — file bytes and storage.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index fc7aa76..177e7b2 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, request receiver approval, transfer text, and stream multiple files 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 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.
## Working now
@@ -11,7 +11,7 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby
- A 60-second discovery window derived from the platform-reported running state.
- Manual discovery Reload while registration remains active, plus full connection repair when both lifecycle states are stable.
- Dynamic HTTP and file-transfer ports advertised with device metadata.
-- Text offer, Accept/Decline, transfer, Copy, and Clear.
+- 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.
- One persistent raw TCP connection per accepted file batch.
@@ -73,5 +73,5 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby
The product direction remains focused:
```text
-find nearby -> approve -> send directly
+find nearby -> send text or approve files -> transfer directly
```
diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig
index 10ea9a2..4b5030b 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=2
-MARKETING_VERSION=0.2.0
+CURRENT_PROJECT_VERSION=3
+MARKETING_VERSION=0.3.0
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt
index d8a93ba..3f0913e 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt
@@ -59,7 +59,7 @@ fun Sync360Root() {
val sendScreenViewModel = koinInject()
val receiveScreenState by receiveScreenViewModel.screenState.collectAsStateWithLifecycle()
- val sendScreenState by sendScreenViewModel.screenState.collectAsStateWithLifecycle()
+ val sendScreenState by sendScreenViewModel.sendScreenState.collectAsStateWithLifecycle()
val currentScreen = navigationViewModel.currentScreen()
val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
@@ -86,17 +86,16 @@ fun Sync360Root() {
val receiveTitle = when (receiveScreenState) {
ReceiveScreenState.Idle -> "Sync360"
- is ReceiveScreenState.IncomingTextOffer -> "Incoming text"
is ReceiveScreenState.IncomingFileOffer -> "Incoming files"
- is ReceiveScreenState.WaitingForText -> "Receiving text"
is ReceiveScreenState.ReceivingFiles -> "Receiving files"
is ReceiveScreenState.ReceivedText -> "Received text"
is ReceiveScreenState.ReceivedFiles -> "Files received"
}
+
val sendTitle = when (sendScreenState.sendOperationState) {
SendOperationState.Idle -> "Sync360"
SendOperationState.Cancelled -> "Sending Cancelled"
- is SendOperationState.SendingTextOffer -> "Sending Text Offer"
+ is SendOperationState.SendingText -> "Sending Text"
is SendOperationState.SendingFileOffer -> "Sending File Offer"
is SendOperationState.SendingFile -> "Sending Files"
is SendOperationState.TextSent -> "Text Sent"
@@ -241,7 +240,8 @@ fun Sync360Root() {
}
LaunchedEffect(receiveScreenState) {
- if (receiveScreenState is ReceiveScreenState.IncomingTextOffer ||
+ if (
+ receiveScreenState is ReceiveScreenState.ReceivedText ||
receiveScreenState is ReceiveScreenState.IncomingFileOffer
) {
navigationViewModel.navigateToReceive()
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 d2be06d..a87233f 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/IncomingServerRequestsController.kt
@@ -1,9 +1,12 @@
package com.liftley.sync360.data
import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextOfferRequest
+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.FileTransferProgress
+import com.liftley.sync360.domain.model.TextDeliveryLimits
import com.liftley.sync360.domain.model.UserDecision
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -31,13 +34,12 @@ class IncomingServerRequestsController {
private val operationMutex = Mutex()
private val operationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
- private var pendingUserDecision: CompletableDeferred? = null
+ private var pendingFileOfferDecision: CompletableDeferred? = null
init {
operationScope.launch {
clientServerState.collectLatest { state ->
val operationId = when (state) {
- is ClientServerState.WaitingForText -> state.textOffer.operationId
is ClientServerState.WaitingForFiles -> state.fileOffer.operationId
else -> return@collectLatest
}
@@ -48,21 +50,41 @@ class IncomingServerRequestsController {
}
}
- internal suspend fun awaitTextOfferDecision(
- textOffer: TextOfferRequest
- ): UserDecision? = awaitUserDecision(
- operationId = textOffer.operationId,
- offerState = ClientServerState.TextOffer(textOffer)
- )
+ internal suspend fun deliverIncomingText(
+ request: TextDeliveryRequest
+ ): TextDeliveryResponse {
+ if (request.text.length > TextDeliveryLimits.MAX_CHARACTER_COUNT) {
+ return TextDeliveryResponse(
+ status = TextDeliveryStatus.TEXT_TOO_LARGE
+ )
+ }
+
+ return operationMutex.withLock {
+ if (_clientServerState.value != ClientServerState.Idle) {
+ return@withLock TextDeliveryResponse(
+ status = TextDeliveryStatus.RECEIVER_BUSY
+ )
+ }
+
+ _clientServerState.value = ClientServerState.TextReceived(
+ senderDeviceName = request.senderDeviceName,
+ text = request.text
+ )
+
+ TextDeliveryResponse(
+ status = TextDeliveryStatus.DELIVERED
+ )
+ }
+ }
internal suspend fun awaitFileOfferDecision(
fileOffer: FileOfferRequest
- ): UserDecision? = awaitUserDecision(
+ ): UserDecision? = registerFileOfferAndAwaitDecision(
operationId = fileOffer.operationId,
- offerState = ClientServerState.FileOffer(fileOffer)
+ offerState = ClientServerState.IncomingFileOffer(fileOffer)
)
- private suspend fun awaitUserDecision(
+ private suspend fun registerFileOfferAndAwaitDecision(
operationId: Uuid,
offerState: ClientServerState
): UserDecision? {
@@ -72,7 +94,7 @@ class IncomingServerRequestsController {
}
CompletableDeferred().also { decision ->
- pendingUserDecision = decision
+ pendingFileOfferDecision = decision
_clientServerState.value = offerState
}
} ?: return null
@@ -95,20 +117,12 @@ class IncomingServerRequestsController {
return result ?: UserDecision.DECLINED
}
- suspend fun makeDecision(decision: UserDecision) {
+ suspend fun respondToFileOffer(decision: UserDecision) {
val waitingDecision = operationMutex.withLock {
- val currentDecision = pendingUserDecision ?: return@withLock null
+ val currentDecision = pendingFileOfferDecision ?: return@withLock null
_clientServerState.value = when (val state = _clientServerState.value) {
- is ClientServerState.TextOffer -> {
- if (decision == UserDecision.ACCEPTED) {
- ClientServerState.WaitingForText(state.textOffer)
- } else {
- ClientServerState.Idle
- }
- }
-
- is ClientServerState.FileOffer -> {
+ is ClientServerState.IncomingFileOffer -> {
if (decision == UserDecision.ACCEPTED) {
ClientServerState.WaitingForFiles(state.fileOffer)
} else {
@@ -119,7 +133,7 @@ class IncomingServerRequestsController {
else -> return@withLock null
}
- pendingUserDecision = null
+ pendingFileOfferDecision = null
currentDecision
} ?: return
@@ -133,7 +147,7 @@ class IncomingServerRequestsController {
}
_clientServerState.value = ClientServerState.Idle
- pendingUserDecision.also { pendingUserDecision = null }
+ pendingFileOfferDecision.also { pendingFileOfferDecision = null }
}
waitingDecision?.complete(UserDecision.DECLINED)
@@ -150,8 +164,8 @@ class IncomingServerRequestsController {
_clientServerState.value = ClientServerState.Idle
Cancellation(
- waitingDecision = pendingUserDecision.also {
- pendingUserDecision = null
+ waitingDecision = pendingFileOfferDecision.also {
+ pendingFileOfferDecision = null
}
)
} ?: return false
@@ -177,22 +191,6 @@ class IncomingServerRequestsController {
true
}
- suspend fun receiveAcceptedText(
- operationId: Uuid,
- senderDeviceId: String,
- text: String
- ): Boolean = operationMutex.withLock {
- val state = _clientServerState.value as? ClientServerState.WaitingForText
- ?: return@withLock false
- if (
- state.textOffer.operationId != operationId ||
- state.textOffer.senderDeviceId != senderDeviceId
- ) return@withLock false
-
- _clientServerState.value = ClientServerState.TextReceived(text)
- true
- }
-
fun updateFileProgress(
operationId: Uuid,
progress: FileTransferProgress
@@ -264,9 +262,7 @@ class IncomingServerRequestsController {
senderDeviceId: String
): Boolean {
val operation = when (this) {
- is ClientServerState.TextOffer -> textOffer.operationId to textOffer.senderDeviceId
- is ClientServerState.WaitingForText -> textOffer.operationId to textOffer.senderDeviceId
- is ClientServerState.FileOffer -> fileOffer.operationId to fileOffer.senderDeviceId
+ 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
@@ -278,9 +274,7 @@ class IncomingServerRequestsController {
private fun ClientServerState.matchesExpirableOperation(operationId: Uuid): Boolean {
val currentOperationId = when (this) {
- is ClientServerState.TextOffer -> textOffer.operationId
- is ClientServerState.WaitingForText -> textOffer.operationId
- is ClientServerState.FileOffer -> fileOffer.operationId
+ is ClientServerState.IncomingFileOffer -> fileOffer.operationId
is ClientServerState.WaitingForFiles -> fileOffer.operationId
else -> return false
}
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 64bf894..af926f5 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/OutgoingRequestsController.kt
@@ -1,18 +1,19 @@
package com.liftley.sync360.data
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.text.TextOfferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextTransferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextTransferResponse
+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.FileTransferProgress
import com.liftley.sync360.domain.model.NearbyDevice
import com.liftley.sync360.domain.model.SelectedFile
+import com.liftley.sync360.domain.model.TextDeliveryLimits
import kotlin.uuid.Uuid
class OutgoingRequestsController(
@@ -41,30 +42,58 @@ class OutgoingRequestsController(
suspend fun sendText(
deviceToSendText: NearbyDevice,
- text: String,
- operationId: Uuid
- ): Result {
+ text: String
+ ): Result {
+ if (text.isBlank()) {
+ return Result.failure(
+ TextDeliveryException("Text cannot be empty")
+ )
+ }
+
+ if (text.length > TextDeliveryLimits.MAX_CHARACTER_COUNT) {
+ return Result.failure(
+ TextDeliveryException(
+ "Text cannot exceed " +
+ "${TextDeliveryLimits.MAX_CHARACTER_COUNT} characters"
+ )
+ )
+ }
+
val myDeviceInfo = localDeviceInfoProvider.getLocalDeviceInfo()
- val textOfferRequest = TextOfferRequest(
- operationId = operationId,
- senderDeviceId = myDeviceInfo.deviceId,
+ val request = TextDeliveryRequest(
senderDeviceName = myDeviceInfo.deviceName,
- preview = text.take(200),
- characterCount = text.count()
- )
-
- val textTransferRequest = TextTransferRequest(
- operationId = operationId,
- senderDeviceId = myDeviceInfo.deviceId,
text = text
)
- return httpClient.textTransferRequest(
- deviceToSendText,
- textOfferRequest,
- textTransferRequest
- )
+ val response = httpClient.deliverText(
+ targetDevice = deviceToSendText,
+ request = request
+ ).getOrElse { exception ->
+ return Result.failure(exception)
+ }
+
+ return when (response.status) {
+ TextDeliveryStatus.DELIVERED -> {
+ Result.success(Unit)
+ }
+
+ TextDeliveryStatus.RECEIVER_BUSY -> {
+ Result.failure(
+ TextDeliveryException(
+ "${deviceToSendText.deviceName} is currently busy"
+ )
+ )
+ }
+
+ TextDeliveryStatus.TEXT_TOO_LARGE -> {
+ Result.failure(
+ TextDeliveryException(
+ "The text exceeds the receiver's character limit"
+ )
+ )
+ }
+ }
}
suspend fun sendFiles(
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 63857a6..a97e76d 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
@@ -4,10 +4,8 @@ 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.TextOfferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextOfferResponse
-import com.liftley.sync360.data.network.http.dto.text.TextTransferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextTransferResponse
+import com.liftley.sync360.data.network.http.dto.text.TextDeliveryRequest
+import com.liftley.sync360.data.network.http.dto.text.TextDeliveryResponse
import com.liftley.sync360.domain.model.NearbyDevice
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -17,9 +15,9 @@ import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.timeout
import io.ktor.client.request.post
import io.ktor.client.request.setBody
-import io.ktor.client.plugins.timeout
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
@@ -37,91 +35,38 @@ class Sync360HttpClient {
}
}
- private suspend fun textOfferRequest(
- deviceToSendOffer: NearbyDevice,
- textOfferRequest: TextOfferRequest
- ): Result {
- val deviceToSendOfferPort = deviceToSendOffer.port
-
- return try {
- val textOfferResponse = requestUsingReachableAddress(deviceToSendOffer) { host ->
- val url = "http://${host.asUrlHost()}:$deviceToSendOfferPort/sync360/text/offer"
- httpClient.post(url) {
- contentType(ContentType.Application.Json)
- setBody(textOfferRequest)
- }.body()
- }
-
- when (textOfferResponse) {
- TextOfferResponse.Accepted -> {
- Result.success(TextOfferResponse.Accepted)
- }
-
- TextOfferResponse.Declined -> {
- Result.failure(TextOfferException("User Declined Request"))
- }
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
-
- when (e) {
- is ConnectTimeoutException, is SocketTimeoutException, is HttpRequestTimeoutException -> {
- Result.failure(
- TextOfferException(
- e.message ?: "Device did not respond in time"
- )
- )
- }
-
- else -> Result.failure(e)
- }
- }
- }
-
- suspend fun textTransferRequest(
- deviceToSendText: NearbyDevice,
- textOfferRequest: TextOfferRequest,
- textTransferRequest: TextTransferRequest
- ): Result {
- textOfferRequest(deviceToSendText, textOfferRequest)
- .getOrElse { error -> return Result.failure(error) }
-
- val deviceToSendTextPort = deviceToSendText.port
+ suspend fun deliverText(
+ targetDevice: NearbyDevice,
+ request: TextDeliveryRequest
+ ): Result {
return try {
- val textTransferResponse = requestUsingReachableAddress(deviceToSendText) { host ->
+ val response = requestUsingReachableAddress(targetDevice) { host ->
val url =
- "http://${host.asUrlHost()}:$deviceToSendTextPort/sync360/text/transfer"
+ "http://${host.asUrlHost()}:${targetDevice.port}/sync360/text/deliver"
+
httpClient.post(url) {
contentType(ContentType.Application.Json)
- setBody(textTransferRequest)
- }.body()
+ setBody(request)
+ }.body()
}
- if (textTransferResponse.success) {
- Result.success(textTransferResponse)
- } else {
- Result.failure(
- TextOfferException(
- textTransferResponse.message ?: "Receiver rejected the text transfer"
- )
- )
- }
- } catch (e: Exception) {
- when (e) {
- is CancellationException -> throw e
-
- is ConnectTimeoutException,
- is SocketTimeoutException,
- is HttpRequestTimeoutException -> {
- Result.failure(
- TextOfferException(
- e.message ?: "Device did not respond in time"
- )
- )
- }
-
- else -> Result.failure(e)
- }
+ Result.success(response)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: ConnectTimeoutException) {
+ Result.failure(
+ TextDeliveryException("Could not connect to the device in time")
+ )
+ } catch (exception: SocketTimeoutException) {
+ Result.failure(
+ TextDeliveryException("The device did not respond in time")
+ )
+ } catch (exception: HttpRequestTimeoutException) {
+ Result.failure(
+ TextDeliveryException("The text delivery request timed out")
+ )
+ } catch (exception: Exception) {
+ Result.failure(exception)
}
}
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextDeliveryException.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextDeliveryException.kt
new file mode 100644
index 0000000..35656fd
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextDeliveryException.kt
@@ -0,0 +1,5 @@
+package com.liftley.sync360.data.network.http.client
+
+class TextDeliveryException(
+ message: String
+) : Exception(message)
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextOfferException.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextOfferException.kt
deleted file mode 100644
index 7a1371e..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/client/TextOfferException.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.liftley.sync360.data.network.http.client
-
-class TextOfferException(response: String) : Exception("Offer status: $response")
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferResponse.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryRequest.kt
similarity index 55%
rename from shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferResponse.kt
rename to shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryRequest.kt
index 7bea1c8..ff459d1 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferResponse.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryRequest.kt
@@ -3,6 +3,7 @@ package com.liftley.sync360.data.network.http.dto.text
import kotlinx.serialization.Serializable
@Serializable
-enum class TextOfferResponse {
- Accepted, Declined
-}
\ No newline at end of file
+data class TextDeliveryRequest(
+ val senderDeviceName: String,
+ val text: String
+)
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryResponse.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryResponse.kt
new file mode 100644
index 0000000..dc32268
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextDeliveryResponse.kt
@@ -0,0 +1,15 @@
+package com.liftley.sync360.data.network.http.dto.text
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TextDeliveryResponse(
+ val status: TextDeliveryStatus
+)
+
+@Serializable
+enum class TextDeliveryStatus {
+ DELIVERED,
+ RECEIVER_BUSY,
+ TEXT_TOO_LARGE
+}
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferRequest.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferRequest.kt
deleted file mode 100644
index 78e6aee..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextOfferRequest.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.liftley.sync360.data.network.http.dto.text
-
-import kotlinx.serialization.Serializable
-import kotlin.uuid.Uuid
-
-@Serializable
-data class TextOfferRequest(
- val operationId: Uuid,
- val senderDeviceId: String,
- val senderDeviceName: String,
- val preview: String,
- val characterCount: Int
-)
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferRequest.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferRequest.kt
deleted file mode 100644
index 67351fc..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferRequest.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.liftley.sync360.data.network.http.dto.text
-
-import kotlinx.serialization.Serializable
-import kotlin.uuid.Uuid
-
-@Serializable
-data class TextTransferRequest(
- val operationId: Uuid,
- val senderDeviceId: String,
- val text: String
-)
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferResponse.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferResponse.kt
deleted file mode 100644
index b87945f..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/http/dto/text/TextTransferResponse.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.liftley.sync360.data.network.http.dto.text
-
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class TextTransferResponse(
- val success: Boolean,
- val message: String? = null
-)
\ No newline at end of file
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 1af1da4..21510d0 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
@@ -5,10 +5,7 @@ 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.TextOfferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextOfferResponse
-import com.liftley.sync360.data.network.http.dto.text.TextTransferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextTransferResponse
+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
@@ -39,42 +36,10 @@ class Sync360HttpServer(
}
routing {
- post("/sync360/text/offer") {
- val request = call.receive()
- val userDecision = incomingServerRequestsController.awaitTextOfferDecision(request)
+ post("/sync360/text/deliver") {
+ val textDeliveryRequest = call.receive()
- if (userDecision == null) {
- call.respond(TextOfferResponse.Declined)
- return@post
- }
-
- call.respond(
- if (userDecision == UserDecision.ACCEPTED) {
- TextOfferResponse.Accepted
- } else {
- TextOfferResponse.Declined
- }
- )
- }
-
- post("/sync360/text/transfer") {
- val request = call.receive()
- val accepted = incomingServerRequestsController.receiveAcceptedText(
- operationId = request.operationId,
- senderDeviceId = request.senderDeviceId,
- text = request.text
- )
-
- call.respond(
- TextTransferResponse(
- success = accepted,
- message = if (accepted) {
- null
- } else {
- "No matching accepted text offer"
- }
- )
- )
+ call.respond(incomingServerRequestsController.deliverIncomingText(textDeliveryRequest))
}
post("/sync360/file/offer") {
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 2ab2f30..c4527b6 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
@@ -1,22 +1,16 @@
package com.liftley.sync360.domain.model
import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest
-import com.liftley.sync360.data.network.http.dto.text.TextOfferRequest
sealed interface ClientServerState {
data object Idle : ClientServerState
- data class TextOffer(
- val textOffer: TextOfferRequest
- ) : ClientServerState
-
- data class WaitingForText(
- val textOffer: TextOfferRequest
+ data class TextReceived(
+ val senderDeviceName: String,
+ val text: String
) : ClientServerState
- data class TextReceived(val data: String) : ClientServerState
-
- data class FileOffer(
+ data class IncomingFileOffer(
val fileOffer: FileOfferRequest
) : ClientServerState
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/TextDeliveryLimits.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/TextDeliveryLimits.kt
new file mode 100644
index 0000000..09cc451
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/TextDeliveryLimits.kt
@@ -0,0 +1,5 @@
+package com.liftley.sync360.domain.model
+
+object TextDeliveryLimits {
+ const val MAX_CHARACTER_COUNT = 100_000
+}
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 e8e0070..629332f 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
@@ -13,8 +13,6 @@ import com.liftley.sync360.presentation.receive.components.IdleReceiveStateUi
import com.liftley.sync360.presentation.receive.components.ReceivedFilesStateUi
import com.liftley.sync360.presentation.receive.components.ReceivedTextStateUi
import com.liftley.sync360.presentation.receive.components.ReceivingFilesStateUi
-import com.liftley.sync360.presentation.receive.components.TextOfferStateUi
-import com.liftley.sync360.presentation.receive.components.WaitingForTextStateUi
import com.liftley.sync360.presentation.receive.model.ReceiveScreenState
import org.koin.compose.koinInject
@@ -35,43 +33,30 @@ fun ReceiveScreen(
)
}
- is ReceiveScreenState.IncomingTextOffer -> {
- TextOfferStateUi(
- state = state,
- onAccept = { receiveScreenViewModel.makeDecision(UserDecision.ACCEPTED) },
- onDecline = { receiveScreenViewModel.makeDecision(UserDecision.DECLINED) }
+ is ReceiveScreenState.ReceivedText -> {
+ ReceivedTextStateUi(
+ senderDeviceName = state.senderDeviceName,
+ text = state.text,
+ onCopyText = {
+ receiveScreenViewModel.copyReceivedText(state.text)
+ receiveScreenViewModel.clearState()
+ },
+ onClear = receiveScreenViewModel::clearState
)
}
is ReceiveScreenState.IncomingFileOffer -> {
FileOfferStateUi(
state = state,
- onAccept = { receiveScreenViewModel.makeDecision(UserDecision.ACCEPTED) },
- onDecline = { receiveScreenViewModel.makeDecision(UserDecision.DECLINED) }
+ onAccept = { receiveScreenViewModel.respondToFileOffer(UserDecision.ACCEPTED) },
+ onDecline = { receiveScreenViewModel.respondToFileOffer(UserDecision.DECLINED) }
)
}
- is ReceiveScreenState.WaitingForText -> {
- WaitingForTextStateUi(state)
- }
-
is ReceiveScreenState.ReceivingFiles -> {
ReceivingFilesStateUi(state)
}
- is ReceiveScreenState.ReceivedText -> {
- ReceivedTextStateUi(
- text = state.text,
- onCopyText = {
- receiveScreenViewModel.copyReceivedText(state.text)
- receiveScreenViewModel.clearState()
- },
- onClear = {
- receiveScreenViewModel.clearState()
- }
- )
- }
-
is ReceiveScreenState.ReceivedFiles -> {
ReceivedFilesStateUi(
state = 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 271f1b7..d83735b 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
@@ -31,9 +31,9 @@ class ReceiveScreenViewModel(
}
}
- fun makeDecision(decision: UserDecision) {
+ fun respondToFileOffer(decision: UserDecision) {
viewModelScope.launch {
- incomingServerRequestsController.makeDecision(decision)
+ incomingServerRequestsController.respondToFileOffer(decision)
}
}
@@ -57,7 +57,14 @@ private fun ClientServerState.toReceiveScreenState(): ReceiveScreenState {
return when (this) {
ClientServerState.Idle -> ReceiveScreenState.Idle
- is ClientServerState.FileOffer -> {
+ is ClientServerState.TextReceived -> {
+ ReceiveScreenState.ReceivedText(
+ senderDeviceName = senderDeviceName,
+ text = text
+ )
+ }
+
+ is ClientServerState.IncomingFileOffer -> {
ReceiveScreenState.IncomingFileOffer(
senderDeviceName = fileOffer.senderDeviceName,
fileCount = fileOffer.offeredFiles.size,
@@ -83,26 +90,6 @@ private fun ClientServerState.toReceiveScreenState(): ReceiveScreenState {
)
}
- is ClientServerState.TextOffer -> {
- ReceiveScreenState.IncomingTextOffer(
- senderDeviceName = textOffer.senderDeviceName,
- preview = textOffer.preview,
- characterCount = textOffer.characterCount
- )
- }
-
- is ClientServerState.WaitingForText -> {
- ReceiveScreenState.WaitingForText(
- senderDeviceName = textOffer.senderDeviceName
- )
- }
-
- is ClientServerState.TextReceived -> {
- ReceiveScreenState.ReceivedText(
- text = data
- )
- }
-
is ClientServerState.FilesReceived -> {
ReceiveScreenState.ReceivedFiles(
senderDeviceName = senderDeviceName,
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedTextStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedTextStateUi.kt
index 8303706..01ba903 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedTextStateUi.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedTextStateUi.kt
@@ -28,6 +28,7 @@ import com.liftley.sync360.presentation.app.components.Sync360Surface
@Composable
fun ReceivedTextStateUi(
+ senderDeviceName: String,
text: String,
onCopyText: () -> Unit,
onClear: () -> Unit
@@ -52,7 +53,7 @@ fun ReceivedTextStateUi(
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
Text(
- "Text",
+ text = "Text from $senderDeviceName",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/TextOfferStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/TextOfferStateUi.kt
deleted file mode 100644
index 0eb0ea4..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/TextOfferStateUi.kt
+++ /dev/null
@@ -1,109 +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 TextOfferStateUi(
- state: ReceiveScreenState.IncomingTextOffer,
- onAccept: () -> Unit,
- onDecline: () -> Unit
-) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Sync360Surface {
- Column(
- modifier = Modifier
- .padding(16.dp)
- .fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- state.senderDeviceName,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.align(Alignment.CenterHorizontally)
- )
- Text(
- "Wants to send text (${state.characterCount} characters)",
- 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 Preview",
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- state.preview.ifBlank { "No preview available" },
- maxLines = 5,
- overflow = TextOverflow.Ellipsis,
- 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
- )
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/WaitingForTextStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/WaitingForTextStateUi.kt
deleted file mode 100644
index 844a98d..0000000
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/WaitingForTextStateUi.kt
+++ /dev/null
@@ -1,54 +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.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.LoadingIndicator
-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.font.FontWeight
-import androidx.compose.ui.unit.dp
-import com.liftley.sync360.presentation.app.components.Sync360Surface
-import com.liftley.sync360.presentation.receive.model.ReceiveScreenState
-
-@Composable
-fun WaitingForTextStateUi(
- state: ReceiveScreenState.WaitingForText
-) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Sync360Surface {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = state.senderDeviceName,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold
- )
-
- Text(
- text = "Waiting for text",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- LoadingIndicator()
- }
- }
- }
-}
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 69bcf1c..02ea370 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
@@ -5,17 +5,8 @@ import com.liftley.sync360.domain.model.FileTransferProgress
sealed interface ReceiveScreenState {
data object Idle : ReceiveScreenState
- data class IncomingTextOffer(
- val senderDeviceName: String,
- val preview: String,
- val characterCount: Int
- ) : ReceiveScreenState
-
- data class WaitingForText(
- val senderDeviceName: String
- ) : ReceiveScreenState
-
data class ReceivedText(
+ val senderDeviceName: String,
val text: String
) : ReceiveScreenState
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 b34ca8f..181be47 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
@@ -34,7 +34,7 @@ fun SendScreen(
onTroubleshootClick: () -> Unit
) {
val sendScreenViewModel = koinInject()
- val screenState by sendScreenViewModel.screenState.collectAsStateWithLifecycle()
+ val screenState by sendScreenViewModel.sendScreenState.collectAsStateWithLifecycle()
when (screenState.sendOperationState) {
SendOperationState.Idle -> {
@@ -87,7 +87,8 @@ fun SendScreen(
SendTab.Text -> {
TextSendContent(
textInput = screenState.textInput,
- onTextChange = { sendScreenViewModel.onTextChanged(it) },
+ isTextTooLong = screenState.isTextTooLong,
+ onTextChange = sendScreenViewModel::onTextChanged,
onClearText = { sendScreenViewModel.onTextChanged("") }
)
}
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 6353abc..166ff5c 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
@@ -8,6 +8,7 @@ import com.liftley.sync360.data.file.SelectedFileReader
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.SendOperationState
import com.liftley.sync360.presentation.send.model.SendTab
@@ -29,21 +30,21 @@ class SendScreenViewModel(
private val networkServicesController: NetworkServicesController,
private val outgoingRequestsController: OutgoingRequestsController,
) : ViewModel() {
- private val _screenState: MutableStateFlow =
+ private val _sendScreenState: MutableStateFlow =
MutableStateFlow(SendScreenState())
- val screenState: StateFlow = _screenState.asStateFlow()
+ val sendScreenState: StateFlow = _sendScreenState.asStateFlow()
private var latestNearbyDevices: List = emptyList()
private var activeSendJob: Job? = null
- private var activeSend: ActiveSend? = null
+ private var activeFileSend: ActiveFileSend? = null
init {
viewModelScope.launch {
networkServicesController.nearbyDevices.collect { devices ->
latestNearbyDevices = devices
- _screenState.update {
+ _sendScreenState.update {
it.copy(
nearbyDevices = devices.map { device ->
device.toNearbyDeviceUiModel()
@@ -55,7 +56,7 @@ class SendScreenViewModel(
viewModelScope.launch {
networkServicesController.discoveryServiceStatus.collect { status ->
- _screenState.update {
+ _sendScreenState.update {
it.copy(discoveryStatus = status)
}
}
@@ -63,7 +64,7 @@ class SendScreenViewModel(
viewModelScope.launch {
networkServicesController.registrationServiceStatus.collect { status ->
- _screenState.update {
+ _sendScreenState.update {
it.copy(registrationStatus = status)
}
}
@@ -85,38 +86,40 @@ class SendScreenViewModel(
private fun sendTextToDevice(deviceId: String) {
- if (_screenState.value.sendOperationState != SendOperationState.Idle) {
+ if (_sendScreenState.value.sendOperationState != SendOperationState.Idle) {
return
}
val deviceToSendText = latestNearbyDevices.firstOrNull { it.id == deviceId } ?: return
- val text = screenState.value.textInput
+ val text = sendScreenState.value.textInput
- if (text.isBlank()) return
-
- val operationId = Uuid.random()
-
- activeSend = ActiveSend(
- operationId = operationId,
- targetDevice = deviceToSendText
- )
+ if (
+ text.isBlank() ||
+ text.length > TextDeliveryLimits.MAX_CHARACTER_COUNT
+ ) {
+ return
+ }
- _screenState.update {
+ _sendScreenState.update {
it.copy(
- sendOperationState = SendOperationState.SendingTextOffer(
+ sendOperationState = SendOperationState.SendingText(
deviceName = deviceToSendText.deviceName
)
)
}
- startSendJob(operationId) {
- val result = outgoingRequestsController.sendText(deviceToSendText, text, operationId)
+ startSendJob {
+ val result = outgoingRequestsController.sendText(
+ deviceToSendText = deviceToSendText,
+ text = text
+ )
+
currentCoroutineContext().ensureActive()
result.fold(
onSuccess = {
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.TextSent(
deviceName = deviceToSendText.deviceName
@@ -125,7 +128,7 @@ class SendScreenViewModel(
}
},
onFailure = { error ->
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.OperationFailed(
reason = error.message?.take(300) ?: "Text not sent"
@@ -138,19 +141,19 @@ class SendScreenViewModel(
}
private fun sendFilesToDevice(deviceId: String) {
- if (_screenState.value.sendOperationState != SendOperationState.Idle) {
+ if (_sendScreenState.value.sendOperationState != SendOperationState.Idle) {
return
}
val deviceToSendFiles = latestNearbyDevices.firstOrNull { it.id == deviceId } ?: return
- val files = _screenState.value.files
+ val files = _sendScreenState.value.files
if (files.isEmpty()) return
val operationId = Uuid.random()
- activeSend = ActiveSend(
+ activeFileSend = ActiveFileSend(
operationId = operationId,
targetDevice = deviceToSendFiles
)
@@ -160,7 +163,7 @@ class SendScreenViewModel(
var currentFileName = files.first().displayName
var latestProgress = FileTransferProgress.waiting(totalSizeBytes)
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.SendingFileOffer(
deviceName = deviceToSendFiles.deviceName,
@@ -169,7 +172,7 @@ class SendScreenViewModel(
)
}
- startSendJob(operationId) {
+ startSendJob {
val result = outgoingRequestsController.sendFiles(
deviceToSendFiles = deviceToSendFiles,
selectedFiles = files,
@@ -177,7 +180,7 @@ class SendScreenViewModel(
onFileStarted = { fileIndex, file ->
currentFileIndex = fileIndex
currentFileName = file.displayName
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.SendingFile(
deviceName = deviceToSendFiles.deviceName,
@@ -191,7 +194,7 @@ class SendScreenViewModel(
},
onProgress = { progress ->
latestProgress = progress
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.SendingFile(
deviceName = deviceToSendFiles.deviceName,
@@ -209,7 +212,7 @@ class SendScreenViewModel(
result.fold(
onSuccess = {
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.FilesSent(
deviceName = deviceToSendFiles.deviceName,
@@ -219,7 +222,7 @@ class SendScreenViewModel(
}
},
onFailure = { error ->
- _screenState.update {
+ _sendScreenState.update {
it.copy(
sendOperationState = SendOperationState.OperationFailed(
reason = error.message?.take(300) ?: "Files not sent"
@@ -232,41 +235,40 @@ class SendScreenViewModel(
}
fun cancelSend() {
- val operation = activeSend
- activeSend = null
+ val operation = activeFileSend ?: return
+ activeFileSend = null
activeSendJob?.cancel()
activeSendJob = null
+
outgoingRequestsController.cancelCurrentFileTransfer()
- if (operation != null) {
- viewModelScope.launch {
- outgoingRequestsController.sendCancellationRequestToTargetDevice(
- targetDevice = operation.targetDevice,
- operationId = operation.operationId
- )
- }
+ viewModelScope.launch {
+ outgoingRequestsController.sendCancellationRequestToTargetDevice(
+ targetDevice = operation.targetDevice,
+ operationId = operation.operationId
+ )
}
- _screenState.update {
+ _sendScreenState.update {
it.copy(sendOperationState = SendOperationState.Cancelled)
}
}
fun onTextChanged(text: String) {
- _screenState.update {
+ _sendScreenState.update {
it.copy(textInput = text)
}
}
fun onTabSelected(tab: SendTab) {
- _screenState.update {
+ _sendScreenState.update {
it.copy(selectedTab = tab)
}
}
fun sendToDevice(deviceId: String) {
- val state = _screenState.value
+ val state = _sendScreenState.value
if (!state.isContentReadyToSend) return
when (state.selectedTab) {
@@ -276,7 +278,7 @@ class SendScreenViewModel(
}
fun clearSendOperation() {
- _screenState.update {
+ _sendScreenState.update {
it.copy(sendOperationState = SendOperationState.Idle)
}
}
@@ -287,7 +289,7 @@ class SendScreenViewModel(
selectedFileReader.readSelectedFiles(rawPlatformFiles)
}
- _screenState.update { currentState ->
+ _sendScreenState.update { currentState ->
currentState.copy(
files = (currentState.files + parsedFiles).distinctBy { it.uri }
)
@@ -296,20 +298,19 @@ class SendScreenViewModel(
}
fun clearSelectedFiles() {
- _screenState.update { currentState ->
+ _sendScreenState.update { currentState ->
currentState.copy(files = emptyList())
}
}
fun removeSelectedFileFromList(file: SelectedFile) {
- _screenState.update { currentState ->
+ _sendScreenState.update { currentState ->
val remainingFiles = currentState.files - file
currentState.copy(files = remainingFiles)
}
}
private fun startSendJob(
- operationId: Uuid,
block: suspend () -> Unit
) {
val sendJob = viewModelScope.launch {
@@ -317,17 +318,16 @@ class SendScreenViewModel(
}
activeSendJob = sendJob
+
sendJob.invokeOnCompletion {
if (activeSendJob === sendJob) {
activeSendJob = null
- }
- if (activeSend?.operationId == operationId) {
- activeSend = null
+ activeFileSend = null
}
}
}
- private data class ActiveSend(
+ private data class ActiveFileSend(
val operationId: Uuid,
val targetDevice: NearbyDevice
)
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 a6bbd3d..48bc8ee 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
@@ -38,10 +38,10 @@ fun SendOperationStateUi(
when (state) {
SendOperationState.Idle -> Unit
- is SendOperationState.SendingTextOffer -> {
+ is SendOperationState.SendingText -> {
SendingOperationUi(
- message = "Waiting for ${state.deviceName} to accept",
- onCancel = onCancel
+ message = "Sending text to ${state.deviceName}",
+ onCancel = null
)
}
@@ -101,7 +101,7 @@ private fun SendingOperationUi(
message: String,
detail: String? = null,
transferProgress: FileTransferProgress? = null,
- onCancel: () -> Unit
+ onCancel: (() -> Unit)?
) {
Column(
modifier = Modifier
@@ -151,13 +151,15 @@ private fun SendingOperationUi(
Spacer(Modifier.padding(16.dp))
- OutlinedButton(
- onClick = onCancel,
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp)
- ) {
- Text("Cancel")
+ if (onCancel != null) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ ) {
+ Text("Cancel")
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/TextSendContent.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/TextSendContent.kt
index 4e19903..18eec62 100644
--- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/TextSendContent.kt
+++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/TextSendContent.kt
@@ -12,10 +12,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.liftley.sync360.domain.model.TextDeliveryLimits
@Composable
fun TextSendContent(
textInput: String,
+ isTextTooLong: Boolean,
onTextChange: (String) -> Unit,
onClearText: () -> Unit
) {
@@ -40,14 +42,20 @@ fun TextSendContent(
placeholder = { Text("Type or paste text here") },
minLines = 5,
maxLines = 5,
+ isError = isTextTooLong,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth()
)
Text(
- text = "${textInput.length} characters",
+ text = "${textInput.length} / " +
+ "${TextDeliveryLimits.MAX_CHARACTER_COUNT} characters",
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = if (isTextTooLong) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
)
}
}
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 8c4a6ce..8425e52 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
@@ -4,8 +4,7 @@ import com.liftley.sync360.domain.model.FileTransferProgress
sealed interface SendOperationState {
data object Idle : SendOperationState
-
- data class SendingTextOffer(
+ data class SendingText(
val deviceName: String
) : 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 a7cdec7..023cbac 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
@@ -3,6 +3,7 @@ package com.liftley.sync360.presentation.send.model
import com.liftley.sync360.domain.model.DiscoveryStatus
import com.liftley.sync360.domain.model.RegistrationStatus
import com.liftley.sync360.domain.model.SelectedFile
+import com.liftley.sync360.domain.model.TextDeliveryLimits
data class SendScreenState(
val selectedTab: SendTab = SendTab.Text,
@@ -13,17 +14,25 @@ data class SendScreenState(
val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle,
val registrationStatus: RegistrationStatus = RegistrationStatus.Idle
) {
+ val isTextTooLong: Boolean
+ get() = textInput.length > TextDeliveryLimits.MAX_CHARACTER_COUNT
+
val isContentReadyToSend: Boolean
get() = when (selectedTab) {
- SendTab.Text -> textInput.isNotBlank()
+ SendTab.Text -> textInput.isNotBlank() && !isTextTooLong
SendTab.Files -> files.isNotEmpty()
}
val deviceActionLabel: String
get() = when (selectedTab) {
SendTab.Text -> {
- if (textInput.isBlank()) "Enter text to send" else "Click me to send text"
+ when {
+ textInput.isBlank() -> "Enter text to send"
+ isTextTooLong -> "Text exceeds the character limit"
+ else -> "Click me to send text"
+ }
}
+
SendTab.Files -> {
if (files.isEmpty()) "Add files to send" else "Click me to send files"
}