diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..62bf79f --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,232 @@ +name: iOS + +on: + push: + paths: + - ".github/workflows/ios.yml" + - "iosApp/**" + - "shared/**" + - "gradle/**" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + pull_request: + paths: + - ".github/workflows/ios.yml" + - "iosApp/**" + - "shared/**" + - "gradle/**" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + workflow_dispatch: + inputs: + build_signed_ipa: + description: "Build a development-signed IPA for registered iPhones" + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ios-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + simulator-app: + name: Build iOS Simulator app + runs-on: macos-26 + timeout-minutes: 60 + + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Set up JDK 23 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "23" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Select Xcode 26.4 + run: sudo xcode-select --switch /Applications/Xcode_26.4.app/Contents/Developer + + - name: Show Apple toolchain + run: xcodebuild -version + + - name: Build unsigned Simulator app + run: | + xcodebuild \ + -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath build/ios-simulator \ + CODE_SIGNING_ALLOWED=NO \ + TEAM_ID= \ + build + + - name: Package Simulator app + run: | + APP_PATH="build/ios-simulator/Build/Products/Debug-iphonesimulator/Sync360.app" + test -d "$APP_PATH" + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" Sync360-iOS-Simulator.zip + + - name: Upload Simulator app + uses: actions/upload-artifact@v4 + with: + name: Sync360-iOS-Simulator + path: Sync360-iOS-Simulator.zip + if-no-files-found: error + retention-days: 14 + + signed-iphone-ipa: + name: Build signed iPhone IPA + if: github.event_name == 'workflow_dispatch' && inputs.build_signed_ipa + runs-on: macos-26 + timeout-minutes: 60 + env: + IOS_DEVELOPMENT_CERTIFICATE_BASE64: ${{ secrets.IOS_DEVELOPMENT_CERTIFICATE_BASE64 }} + IOS_DEVELOPMENT_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEVELOPMENT_CERTIFICATE_PASSWORD }} + IOS_DEVELOPMENT_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_BASE64 }} + IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} + + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Set up JDK 23 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "23" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Select Xcode 26.4 + run: sudo xcode-select --switch /Applications/Xcode_26.4.app/Contents/Developer + + - name: Require Apple signing secrets + shell: bash + run: | + missing=0 + for secret_name in \ + IOS_DEVELOPMENT_CERTIFICATE_BASE64 \ + IOS_DEVELOPMENT_CERTIFICATE_PASSWORD \ + IOS_DEVELOPMENT_PROVISIONING_PROFILE_BASE64 \ + IOS_TEAM_ID + do + if [ -z "${!secret_name:-}" ]; then + echo "::error::Missing GitHub Actions secret: $secret_name" + missing=1 + fi + done + exit "$missing" + + - name: Install development certificate and provisioning profile + shell: bash + run: | + CERTIFICATE_PATH="$RUNNER_TEMP/ios-development.p12" + PROFILE_PATH="$RUNNER_TEMP/ios-development.mobileprovision" + PROFILE_PLIST="$RUNNER_TEMP/ios-development-profile.plist" + KEYCHAIN_PATH="$RUNNER_TEMP/sync360-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" + + printf '%s' "$IOS_DEVELOPMENT_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" + printf '%s' "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" \ + -P "$IOS_DEVELOPMENT_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST" + PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST")" + PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST")" + APPLICATION_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST")" + BUNDLE_IDENTIFIER="${APPLICATION_IDENTIFIER#*.}" + + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision" + + echo "IOS_PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV" + echo "IOS_BUNDLE_IDENTIFIER=$BUNDLE_IDENTIFIER" >> "$GITHUB_ENV" + echo "IOS_ARCHIVE_PATH=$RUNNER_TEMP/Sync360.xcarchive" >> "$GITHUB_ENV" + echo "IOS_EXPORT_PATH=$RUNNER_TEMP/ios-export" >> "$GITHUB_ENV" + + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + - name: Archive signed iPhone app + shell: bash + run: | + xcodebuild \ + -project iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Debug \ + -destination "generic/platform=iOS" \ + -archivePath "$IOS_ARCHIVE_PATH" \ + TEAM_ID="$IOS_TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_IDENTIFIER" \ + DEVELOPMENT_TEAM="$IOS_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Development" \ + PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \ + archive + + - name: Export signed IPA + shell: bash + run: | + EXPORT_OPTIONS="$RUNNER_TEMP/ExportOptions.plist" + cat > "$EXPORT_OPTIONS" < + + + + method + debugging + signingStyle + manual + teamID + $IOS_TEAM_ID + provisioningProfiles + + $IOS_BUNDLE_IDENTIFIER + $IOS_PROFILE_NAME + + + + EOF + + xcodebuild \ + -exportArchive \ + -archivePath "$IOS_ARCHIVE_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS" \ + -exportPath "$IOS_EXPORT_PATH" + + test -n "$(find "$IOS_EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)" + + - name: Upload signed IPA + uses: actions/upload-artifact@v4 + with: + name: Sync360-iPhone-Development-IPA + path: ${{ runner.temp }}/ios-export/*.ipa + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 1054ede..9c6a55c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ captures/ # Signing secrets *.jks +*.jkis *.keystore keystore.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index b5dd9c8..8506da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Android-first manual rebuild with shared Compose Multiplatform Send and Receive UI. - Android DNS-SD/mDNS discovery and registration through `NsdManager`. -- Desktop DNS-SD/mDNS discovery and registration through JmDNS. +- Windows DNS-SD/mDNS discovery and registration through the operating system `dnsapi.dll` API on all interfaces. +- 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. - Android and Desktop multiple-file selection and metadata offers. @@ -22,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Shared transfer buffer/timeout constants, currently using a 512 KiB payload buffer. - Compose Desktop startup, platform DI implementations, native file dialog, clipboard, and Downloads actions. - Navigation 3 adaptive 50/50 Send/Receive scene for wider windows. +- 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.1.0` across Android, Desktop, and iOS; added private Android release signing configuration and a permanent Windows MSI upgrade identity. - Public architecture, development, roadmap, security, privacy, and contribution documentation. ### Changed @@ -30,13 +36,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. - Removed per-file flush-and-acknowledgement waits so an accepted batch can stream continuously before one final receiver result. +- Moved network startup from the Send ViewModel to the Android and Desktop application entry points. +- Derived the 60-second discovery window from the platform-reported running state. +- Made Android repair advance through NSD callbacks and made JVM cleanup retain JmDNS instances that fail to close. +- Restricted discovery Reload and full connection repair to compatible discovery and registration states. +- Selected the Windows-native discovery backend at Desktop DI startup while retaining JmDNS for macOS and Linux. +- Used the JDK Foreign Function and Memory API for Windows interop without adding a third-party native bridge. +- Made Windows discovery process native add and TTL-zero removal notifications so the nearby-device list can update during an active browse. +- Moved Windows discovery out of its initial loading state as soon as the operating system accepts the asynchronous browse request. +- Confirmed in an initial Windows 11 Ethernet test that Android and Windows advertisements appeared promptly and were removed after the corresponding app closed. +- Aligned Kotlin 2.4.10, Android Gradle Plugin 9.1.1, and Gradle 9.3.1 within their documented compatibility ranges while retaining Android API 37. - Positioned the project around direct local-network nearby sharing rather than chat or cloud sync. ### Known limitations - No stable public release yet. - No authentication, encryption, transfer/session token, or cryptographic integrity verification. -- No byte percentage, speed, ETA, retry, pause/resume, or interrupted-transfer recovery. +- No speed, ETA, retry, pause/resume, or interrupted-transfer recovery; transfer progress currently shows batch-wide whole-byte percentage. - Foreground/background and network-change lifecycle handling are incomplete. - Desktop support needs broader operating-system, adapter, firewall, and router validation. -- Automated transfer coverage is minimal; iOS is inactive. +- Automated transfer coverage is minimal; iOS physical-device discovery and transfer are unverified. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index be72cf9..20be3e2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -30,7 +30,7 @@ Project maintainers may remove comments, close issues, reject contributions, or If you see a problem, report it privately to the maintainer. -Maintainer contact: TODO: add private contact email +Until a dedicated contact email is added, contact the maintainer privately through the GitHub or LinkedIn profile linked in `README.md`. ## Scope diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce3130f..a2855ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,20 +6,21 @@ The project is not looking for giant rewrites right now. The most useful contrib ## Current project status -Current working slice: +Implemented today: -- Android NSD discovery. -- Dynamic Ktor server port advertisement. -- Ktor client/server ping request. -- Experimental receiver Accept/Decline state. +- Android nearby discovery and registration through `NsdManager`. +- Windows nearby discovery and registration through the system DNS-SD API. +- Current macOS/Linux discovery and registration through JmDNS. +- Direct text and multi-file transfer between nearby devices. +- Shared Compose UI for Android and Desktop. +- Enabled iOS source implementation for Bonjour discovery, text/file transfer, selection, clipboard, and Files-visible storage. -Not built yet: +Important current limitations: -- Real file transfer. -- Direct text sending. -- Desktop rebuilt networking flow. -- Security/session validation. -- Production-ready UX. +- Local transfers are not authenticated or encrypted. +- Background and automatic network-change lifecycle handling is incomplete. +- Desktop networking has not been broadly validated across operating systems, adapters, VPNs, and routers. +- iOS physical-device discovery and transfer behavior is not yet validated. Please keep that status in mind when opening issues or PRs. @@ -27,7 +28,7 @@ Please keep that status in mind when opening issues or PRs. Prerequisites: -- JDK 17 +- JDK 23 - Android Studio or IntelliJ IDEA - Android SDK - Gradle wrapper from this repository @@ -53,13 +54,13 @@ On Windows: ./gradlew.bat :androidApp:assembleDebug ``` -Desktop shell: +Run Desktop: ```bash ./gradlew :desktopApp:run ``` -The rebuilt networking flow is currently Android-first, so desktop behavior may lag behind Android. +Android remains the primary reference implementation. Platform networking behavior can differ where operating-system APIs require it. ## Before you start @@ -69,7 +70,7 @@ For anything large, open an issue first. Examples: - changing architecture boundaries - changing discovery behavior -- adding file transfer +- changing the transfer protocol - adding security - changing Gradle/KMP target setup - adding persistence/database code @@ -82,6 +83,7 @@ Good early contributions: - Add screenshots or demo GIFs. - Improve error messages and logs. - Test Android discovery on different devices/routers. +- Test Windows discovery across Ethernet, Wi-Fi, VPN, and virtual adapters. - Improve host address selection, especially IPv4 vs IPv6. - Clean up naming where the current intent is obvious. - Add small tests around pure Kotlin models/controllers when useful. @@ -153,6 +155,8 @@ For networking changes, manual validation notes are useful: - one Android device - two Android devices on same Wi-Fi +- Android and Desktop on the same network +- Desktop adapter and operating-system version - Android hotspot if relevant - what happened on sender - what happened on receiver diff --git a/PRIVACY.md b/PRIVACY.md index 1c3d158..efd7906 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,33 +1,35 @@ # Sync360 Privacy -Last updated: June 29, 2026 +Last updated: August 3, 2026 -Sync360 is currently an early rebuild prototype. The current Android-first milestone supports local discovery and a simple Ktor request/response proof between nearby devices. Real text transfer, file transfer, production security, and public releases are not implemented yet. +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. ## Data Handling - No account is required. -- No cloud service is used by the current prototype flow. +- No cloud service is used for discovery or transfer. - No analytics, advertising, tracking, or telemetry is included. - A random installation identifier is stored locally so devices can identify each other. -- Nearby-device discovery data stays on the local device while the app is running. -- The current prototype does not transfer or save user-selected files. -- The current prototype does not send user content to the developer. +- 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. +- 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 -The current prototype uses cleartext HTTP on the local network for learning and testing. Final authentication, session validation, request signing, replay protection, and encryption are not implemented yet. +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. Do not treat the current code as production-secure file-transfer software. Use it only on private networks you control while testing. ## Permissions -Sync360 uses network access for local discovery and request/response testing. Future versions may require additional Android permissions for reliable transfer sessions, notifications, foreground services, wake locks, Wi-Fi multicast behavior, and file access. +Sync360 uses network access for local discovery and direct transfer. Android uses system file pickers and `MediaStore` for selected and received files. iOS source declares local-network and Bonjour usage and exposes its app Documents directory through Files. Future lifecycle work may require notification, foreground-service, wake-lock, or other platform permissions. ## Retention -The current prototype stores a local installation identifier. Discovery/request state is runtime state. Real transfer retention behavior will be documented when file transfer is implemented. +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. ## Contact -Add a support email or website before publishing this policy. +For privacy questions, contact the maintainer through the GitHub profile linked in `README.md`. Report security-sensitive findings through the private-contact guidance in `SECURITY.md`. diff --git a/README.md b/README.md index e974d58..361de03 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Direct text and file sharing between Android and Desktop devices on the same local network. - [![Kotlin](https://img.shields.io/badge/Kotlin-2.3.21-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org/) + [![Kotlin](https://img.shields.io/badge/Kotlin-2.4.10-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Compose Multiplatform](https://img.shields.io/badge/Compose%20Multiplatform-1.11.1-4285F4)](https://www.jetbrains.com/lp/compose-multiplatform/) [![Ktor](https://img.shields.io/badge/Ktor-3.5.1-087CFA)](https://ktor.io/) [![Android](https://img.shields.io/badge/Android-13%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com/) @@ -42,7 +42,9 @@ Chat apps and cloud drives are great when the other person is far away. Sync360 ## Current status -Sync360 has a working Android-to-Android MVP for text and multiple-file transfer. The Desktop/JVM app now uses the same shared flow, and Desktop-to-Android file transfer is working in manual testing. It is still an active rebuild, not a production-ready release. +Sync360 has a working Android-to-Android MVP for text and multiple-file transfer. The Desktop/JVM app now uses the same shared flow, and Desktop-to-Android file transfer is working in manual testing. An initial iOS implementation is enabled in source and has opened successfully in a cloud simulator, but nearby discovery and transfer still need physical-device validation. It is still an active rebuild, not a production-ready release. + +In an initial Windows 11 Ethernet test, the native Windows DNS-SD backend discovered the Android device quickly, removed it promptly after the Android app closed, appeared promptly on Android after Sync360 started, and disappeared from Android after the Desktop app closed. The Desktop discovery UI also left its initial loading state when the native browse operation started instead of continuing to show loading while resolved devices were already visible. These are manual observations from one setup, not broad Windows or laptop compatibility guarantees. ### Working now @@ -60,11 +62,12 @@ Sync360 has a working Android-to-Android MVP for text and multiple-file transfer - 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. - Run the shared Send/Receive UI on Desktop, with an adaptive 50/50 two-pane layout in wider windows. -- Discover and advertise Desktop devices through JmDNS using the same DNS-SD service as Android. +- 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. - Save received Desktop files safely into Downloads through a temporary `.part` file, then move completed files into place without overwriting an existing name. - Copy received text and open the Downloads folder on Desktop. - Open connection troubleshooting from Send, Receive, or the top app bar, then manually restart local discovery and service advertising without resetting the app or removing received files. +- Provide enabled iOS device and simulator targets with native Bonjour discovery, file selection, clipboard, Files-visible storage, and streamed TCP transfer implementations. ### Still needs work @@ -72,12 +75,13 @@ Sync360 has a working Android-to-Android MVP for text and multiple-file transfer - File integrity hashes/checksums. - Rich receiver-side failure details and per-file results. - More robust discovery, server, foreground/background, and cleanup lifecycles. -- Better IP address selection and IPv6 handling. +- Broader IPv6 transfer validation and better address preference/selection. - Retry, pause/resume, and interrupted-transfer recovery. - Automated transfer coverage and broader device/router testing. - Broader Desktop validation across Windows, macOS, Linux, routers, firewalls, VPNs, and machines with multiple network adapters. - Desktop packaging and release testing. -- iOS discovery, transfer, and storage implementations. +- Physical iOS device testing for local-network permission, discovery, text/file transfer, cancellation, and Files behavior. +- Public iOS packaging, signing, and distribution. The current progress UI tracks the exact bytes transferred across the accepted batch and displays the resulting percentage. @@ -90,13 +94,15 @@ Sync360 uses two small networking paths with different jobs: ```mermaid flowchart LR - A["Sender device"] -->|"Android NSD or Desktop JmDNS"| B["Receiver device"] + A["Sender device"] -->|"Android NSD or platform Desktop DNS-SD"| B["Receiver device"] A -->|"Ktor: offer + decision + metadata"| B A -->|"Raw TCP: streamed file bytes"| B B -->|"Platform Downloads writer"| D["Downloads"] ``` -Android uses `NsdManager`; Desktop uses JmDNS. Both advertise the `_sync360._tcp.` DNS-SD service with a stable per-install device ID, device details, protocol version, an OS-assigned HTTP port, and a separate OS-assigned file-transfer port. +Android uses `NsdManager`. Windows uses the built-in `dnsapi.dll` DNS-SD API on all interfaces through Java's Foreign Function and Memory API. macOS and Linux currently retain JmDNS. Every implementation advertises the `_sync360._tcp.` DNS-SD service with a stable per-install device ID, device details, protocol version, an OS-assigned HTTP port, and a separate OS-assigned file-transfer port. + +Android and Desktop start the shared network controller from their application entry points after Koin is ready. Discovery and registration have separate lifecycle states, and the 60-second discovery window begins only after discovery reports `Running`. A normal Reload restarts only discovery while registration remains active; connection repair stops and recreates both operations after their current platform callbacks reach stable states. ### Text path @@ -148,15 +154,16 @@ Compose screen -> ViewModel -> controller/service -> common contract -> platform - `androidApp/` — Android application host, manifest, launcher assets, and app entry point. - `shared/src/commonMain/` — shared Compose UI, adaptive Navigation 3 layout, ViewModels, screen/domain state, controllers, Ktor client/server, transfer contracts, and dependency injection. - `shared/src/androidMain/` — Android NSD, file selection metadata, clipboard, local identity, raw TCP transfer, Downloads storage, and Android DI bindings. -- `shared/src/jvmMain/` — JmDNS discovery/registration, native file selection metadata, clipboard, local identity, raw TCP transfer, Downloads storage, and Desktop DI bindings. +- `shared/src/jvmMain/` — Windows system DNS-SD and macOS/Linux JmDNS discovery/registration, native file selection metadata, clipboard, local identity, raw TCP transfer, Downloads storage, and Desktop DI bindings. - `desktopApp/` — Compose Desktop entry point and DMG/MSI/DEB packaging configuration. -- `iosApp/` — iOS shell; iOS targets are currently disabled in the shared Gradle configuration. +- `shared/src/iosMain/` — iOS Bonjour discovery/registration, file selection, clipboard, identity, streamed TCP transfer, Files-visible storage, and iOS DI bindings. +- `iosApp/` — SwiftUI iOS host for the enabled device and Apple-silicon Simulator targets. -The project remains Android-first, but the current Desktop app reuses the shared UI, ViewModels, controllers, HTTP protocol, and transfer contracts. Platform source sets implement only the parts that require Android or JVM APIs. +The project remains Android-first, but Desktop and iOS reuse the shared UI, ViewModels, controllers, HTTP protocol, and transfer contracts. Platform source sets implement only the parts that require Android, JVM, or iOS APIs. ## Tech stack -- Kotlin 2.3.21 and Kotlin Multiplatform +- Kotlin 2.4.10 and Kotlin Multiplatform - Compose Multiplatform 1.11.1 with Material 3 - Android min SDK 33, compile/target SDK 37 - Ktor 3.5.1 client/server with CIO @@ -164,18 +171,19 @@ The project remains Android-first, but the current Desktop app reuses the shared - Coroutines and `StateFlow` - kotlinx.serialization JSON - Android NSD/mDNS -- JmDNS 3.6.3 for Desktop DNS-SD/mDNS +- Windows `dnsapi.dll` through the JDK Foreign Function and Memory API +- JmDNS 3.6.3 for current macOS/Linux DNS-SD/mDNS - Java `Socket` / `ServerSocket` for file bytes - Android `ContentResolver` and `MediaStore` - Navigation 3 with a Material-adaptive 50/50 two-pane Scene on wider windows -- Gradle 9.4.1 wrapper +- Gradle 9.3.1 wrapper ## Getting started ### Requirements -- JDK 17 -- A recent Android Studio version compatible with Android Gradle Plugin 9.2.x +- JDK 23 +- A recent Android Studio version compatible with Android Gradle Plugin 9.1.x - Android SDK Platform 37 - Two physical Android 13+ devices for Android-to-Android testing, or one Android device and one Desktop machine for cross-platform testing - A Wi-Fi network or hotspot that allows devices to communicate with each other @@ -233,6 +241,8 @@ Some routers enable client isolation and block local device-to-device traffic. I If devices still cannot discover this device or fail to connect after a network change, open **Settings** from the top app bar or select **Troubleshoot** on Send or Receive, then use **Repair connection**. Repair restarts local discovery and advertises Sync360 again; it does not reset the app or remove received files. +Reload is available only after the current discovery window has stopped while service registration is still running. Repair is enabled only while sending, receiving, discovery, and registration are in states where restarting them is safe. + ## Security warning Sync360 is **not secure for untrusted networks yet**. @@ -255,7 +265,7 @@ Use the current app only for development and testing on private networks you con ### Later: bring the same simple flow to more devices - Desktop packaging, release workflow, and broader compatibility testing. -- iOS investigation and implementation. +- iOS physical-device validation, signing, and distribution. - More actionable connection errors and broader troubleshooting guidance. - Retry or resume support where the added protocol complexity is justified. diff --git a/SECURITY.md b/SECURITY.md index abe7110..323868f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,8 @@ # Security Policy -Sync360 is an early local-network sharing app. It is not production-secure yet. +Sync360 is an early local-network sharing app. It is not secure for untrusted networks yet. -The current rebuild intentionally focuses on understanding local discovery and request/response before adding the final security model. Security work is planned, especially before real file transfer is treated as user-ready. +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. ## Supported versions @@ -16,13 +16,7 @@ There are no stable supported releases yet. Please do not open a public issue for security-sensitive reports. -Send private reports to: - -```text -TODO: add security contact email -``` - -Until a contact email is added, please contact the maintainer privately through their GitHub/LinkedIn profile once available. +Until a dedicated security email is added, contact the maintainer privately through the GitHub or LinkedIn profile linked in `README.md`. Do not include exploit details in a public issue. ## What to report privately @@ -43,16 +37,15 @@ General bugs, crashes, UI issues, documentation problems, and non-sensitive arch ## Current security status -Current implementation is a learning-stage prototype: +Current implementation: -- Android NSD discovery works. -- Ktor request/response proof exists. -- Receiver Accept/Decline proof exists. -- Real file transfer is not implemented yet. -- Final authentication/session validation is not implemented yet. -- Encryption is not implemented yet. +- 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. +- 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. +- Sender authentication, encryption, replay protection, and cryptographic integrity verification are not implemented. -Do not use the current code as a security model for production file transfer. +Use current builds only on private local networks you control. Do not use the current code as a security model for production file transfer. ## Planned security work diff --git a/STORE_LISTING.md b/STORE_LISTING.md index 094beb3..dd80e17 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. Connections require device approval and authenticated session requests, but transferred content is not encrypted by Sync360. +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. 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,12 +14,12 @@ 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. -- Session approvals, tokens, and shared text are temporary. +- Offer decisions, transfer state, and shared text are temporary runtime state. - Received files remain on the receiving device. ## Publishing Checklist -- Add support contact to `PRIVACY.md`. +- Keep the maintainer contact in `PRIVACY.md` current. - Publish `PRIVACY.md` at a public URL for store submission. - Keep store data-safety answers consistent with shipped code and permissions. - Revisit this disclosure before adding crash reporting, analytics, cloud services, or encrypted pairing. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 24cae1b..825122f 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.util.Properties plugins { alias(libs.plugins.androidApplication) @@ -6,6 +7,21 @@ plugins { alias(libs.plugins.composeCompiler) } +val releaseSigningPropertiesFile = rootProject.file("keystore.properties") +val releaseSigningProperties = Properties().apply { + if (releaseSigningPropertiesFile.isFile) { + releaseSigningPropertiesFile.inputStream().use(::load) + } +} +val releaseSigningIsConfigured = listOf( + "storeFile", + "storePassword", + "keyAlias", + "keyPassword" +).all { propertyName -> + !releaseSigningProperties.getProperty(propertyName).isNullOrBlank() +} + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -21,7 +37,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 - versionName = "1.0" + versionName = "0.1.0" } buildFeatures { @@ -34,11 +50,24 @@ android { } } + signingConfigs { + if (releaseSigningIsConfigured) { + create("release") { + storeFile = rootProject.file( + releaseSigningProperties.getProperty("storeFile") + ) + storePassword = releaseSigningProperties.getProperty("storePassword") + keyAlias = releaseSigningProperties.getProperty("keyAlias") + keyPassword = releaseSigningProperties.getProperty("keyPassword") + } + } + } + buildTypes { getByName("release") { isMinifyEnabled = true isShrinkResources = true - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.findByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -62,4 +91,4 @@ dependencies { implementation(libs.koin.core) implementation(libs.koin.android) -} \ No newline at end of file +} diff --git a/androidApp/src/main/ic_launcher-playstore.png b/androidApp/src/main/ic_launcher-playstore.png index 26c679c..7ed5f2e 100644 Binary files a/androidApp/src/main/ic_launcher-playstore.png and b/androidApp/src/main/ic_launcher-playstore.png differ diff --git a/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt b/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt index 14b29d6..3583af5 100644 --- a/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt +++ b/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt @@ -2,14 +2,19 @@ package com.liftley.sync360 import android.app.Application import com.liftley.sync360.core.di.androidModule -import com.liftley.sync360.core.di.initKoin +import com.liftley.sync360.core.di.initKoinSync360 +import com.liftley.sync360.data.NetworkServicesController import org.koin.android.ext.koin.androidContext class Sync360Application : Application() { override fun onCreate() { super.onCreate() - initKoin(androidModule) { + val koinApplication = initKoinSync360(androidModule) { androidContext(applicationContext) } + + koinApplication.koin + .get() + .startNetworkServices() } } \ No newline at end of file diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp index 261ee93..030075f 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 135cef6..07bd5fd 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index 135cef6..07bd5fd 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 77f761d..020da83 100644 Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp index 1de79e7..022d5c8 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index 49e9518..ae5b427 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp index 49e9518..ae5b427 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp index b103f45..f3d0594 100644 Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp index e1f77b1..fb2edf2 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index b69dd45..dfbc2be 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index b69dd45..dfbc2be 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 83a4a85..77387c8 100644 Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 1a59014..ae6ce8b 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 53c2d35..10c35a4 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index 53c2d35..10c35a4 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 1ae38fe..fdbfcf2 100644 Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 9c70c95..c4b2332 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 17e3bc0..fb688b6 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index 17e3bc0..fb688b6 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 18274d3..a883e57 100644 Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/context.md b/context.md index ef97a69..806a904 100644 --- a/context.md +++ b/context.md @@ -13,15 +13,18 @@ The old AI-generated sync implementation was removed. The current app is being r - Shared Compose Send/Receive UI, ViewModels, controllers, state, and Navigation 3. - Compact single-pane navigation and a 50/50 Send/Receive scene on wider windows. - Android discovery/registration through `NsdManager`. -- Desktop discovery/registration through JmDNS. +- 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. - 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`. - Desktop native file selection, Java file streams, and safe Downloads writing through temporary `.part` files. +- iOS Bonjour discovery, native document selection, streamed Ktor TCP transfer, and Files-visible Downloads storage. - Best-effort cancellation and batch-wide byte percentage. -Android-to-Android text and multiple-file flows have manual validation. The Desktop/JVM implementation is present, and Desktop-to-Android transfer has initial manual validation. The app is still development software, not a production-ready release. +Android-to-Android text and multiple-file flows have manual validation. Desktop-to-Android transfer has initial manual validation. In one Windows 11 Ethernet test, native Windows discovery added and removed Android promptly as its app opened and closed, while Android added and removed Windows promptly as the Desktop app opened and closed. The enabled iOS implementation has opened successfully in a cloud simulator, but same-LAN discovery and transfer could not be tested there. Laptop, macOS, Linux, physical iOS, and broader adapter/network behavior still need validation. The app is still development software, not a production-ready release. ## Architecture rule @@ -35,7 +38,7 @@ Compose screen -> ViewModel -> controller/service -> common contract -> platform - Ktor DTOs remain at the HTTP boundary. - Blocking file/socket work runs on `Dispatchers.IO`. - Files are streamed; they are never loaded whole into memory. -- Platform APIs stay in Android/JVM source sets. +- Platform APIs stay in Android, JVM, and iOS source sets. ## Protocol summary @@ -59,12 +62,12 @@ one connection per accepted batch -> receiver returns final success and completed-file count ``` -Current shared transfer constants use a 512 KiB payload buffer, 5-second connect timeout, 60-second connected-socket timeout, and 10-second wait for the first file connection after acceptance. +Current shared transfer constants use a 512 KiB payload buffer, 5-second connect timeout, 60-second connected-socket timeout, and 30-second wait for the first file connection after acceptance. ## Current priorities - Better receiver-side errors and per-file results. -- Android registration repair after network/address changes. +- Automatic registration repair after network/address changes. - Foreground/background lifecycle support. - Broader Desktop adapter, firewall, router, and operating-system validation. - Session validation, authentication, encryption, and integrity verification. diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 7a184cb..44c718c 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinJvm) @@ -6,6 +7,12 @@ plugins { alias(libs.plugins.composeCompiler) } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_23) + } +} + dependencies { implementation(projects.shared) @@ -22,11 +29,18 @@ dependencies { compose.desktop { application { mainClass = "com.liftley.sync360.MainKt" + jvmArgs += listOf( + "--enable-native-access=ALL-UNNAMED", + $$"-splash:$APPDIR/resources/sync360-splash.png" + ) nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Sync360" - packageVersion = "1.0.0" + packageVersion = "0.1.0" + appResourcesRootDir.set( + project.layout.projectDirectory.dir("packaging/app-resources") + ) macOS { iconFile.set(project.file("src/main/resources/icons/icon.icns")) @@ -36,10 +50,11 @@ compose.desktop { shortcut = true menu = true menuGroup = "Sync360" + upgradeUuid = "7f48cc4d-365c-4f59-96e6-3a2f6d794847" } linux { iconFile.set(project.file("src/main/resources/icons/icon.png")) } } } -} \ No newline at end of file +} diff --git a/desktopApp/packaging/app-resources/common/sync360-splash.png b/desktopApp/packaging/app-resources/common/sync360-splash.png new file mode 100644 index 0000000..fe27ca1 Binary files /dev/null and b/desktopApp/packaging/app-resources/common/sync360-splash.png differ diff --git a/desktopApp/src/main/kotlin/com/liftley/sync360/main.kt b/desktopApp/src/main/kotlin/com/liftley/sync360/main.kt index da03a2a..6cda9b8 100644 --- a/desktopApp/src/main/kotlin/com/liftley/sync360/main.kt +++ b/desktopApp/src/main/kotlin/com/liftley/sync360/main.kt @@ -3,14 +3,18 @@ package com.liftley.sync360 import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.liftley.sync360.core.designsystem.Sync360Theme -import com.liftley.sync360.core.di.initKoin +import com.liftley.sync360.core.di.initKoinSync360 import com.liftley.sync360.core.di.jvmModule +import com.liftley.sync360.data.NetworkServicesController import org.jetbrains.compose.resources.painterResource import sync360.shared.generated.resources.Res import sync360.shared.generated.resources.app_icon fun main() { - initKoin(jvmModule) {} + + val koinApplication = initKoinSync360(jvmModule) {} + + koinApplication.koin.get().startNetworkServices() application { Window( @@ -18,7 +22,10 @@ fun main() { title = "Sync360", icon = painterResource(Res.drawable.app_icon) ) { - Sync360Theme(darkTheme = false, dynamicColor = false) { + Sync360Theme( + darkTheme = false, + dynamicColor = false + ) { Sync360Root() } } diff --git a/desktopApp/src/main/resources/icons/icon.icns b/desktopApp/src/main/resources/icons/icon.icns index 0691a55..0ddf315 100644 Binary files a/desktopApp/src/main/resources/icons/icon.icns and b/desktopApp/src/main/resources/icons/icon.icns differ diff --git a/desktopApp/src/main/resources/icons/icon.ico b/desktopApp/src/main/resources/icons/icon.ico index 8f57c2c..af7f93d 100644 Binary files a/desktopApp/src/main/resources/icons/icon.ico and b/desktopApp/src/main/resources/icons/icon.ico differ diff --git a/desktopApp/src/main/resources/icons/icon.png b/desktopApp/src/main/resources/icons/icon.png index 42f9a9a..c27f5cf 100644 Binary files a/desktopApp/src/main/resources/icons/icon.png and b/desktopApp/src/main/resources/icons/icon.png differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af3c44e..d2e9265 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Sync360 is an Android-first Kotlin Multiplatform app for direct nearby sharing over a local network. Android and Desktop reuse the common UI and transfer flow; platform source sets implement discovery, file access, storage, identity, clipboard, and raw socket I/O. +Sync360 is an Android-first Kotlin Multiplatform app for direct nearby sharing over a local network. Android, Desktop, and iOS reuse the common UI and transfer flow; platform source sets implement discovery, file access, storage, identity, clipboard, and raw socket I/O. The architecture intentionally follows one readable path: @@ -13,9 +13,9 @@ Compose screen -> ViewModel -> controller/service -> common contract -> platform ```text app starts -> Koin creates common services and platform implementations - -> SendScreenViewModel starts NetworkServicesController - -> FileTransferReceiver opens an OS-assigned TCP port + -> Android, Desktop, or iOS entry point starts NetworkServicesController once -> Sync360HttpServer opens an OS-assigned HTTP port + -> 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 @@ -47,9 +47,14 @@ shared/src/androidMain/ Android clipboard, identity, device info, TCP sender/receiver, and DI shared/src/jvmMain/ - JmDNS discovery/registration + Windows system DNS-SD or macOS/Linux JmDNS discovery/registration AWT file selection and clipboard Java file/Downloads handling, identity, device info, TCP sender/receiver, and DI + +shared/src/iosMain/ + Apple Bonjour discovery/registration + native document selection and clipboard + Files-visible storage, identity, device info, Ktor TCP sender/receiver, and DI ``` ## Main responsibilities @@ -68,7 +73,7 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket ### Controllers -- `NetworkServicesController` starts the file receiver, HTTP server, and discovery/registration in the required order. +- `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` exposes incoming offers and receiver decisions to the HTTP server and Receive UI. @@ -77,11 +82,18 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket `NetworkServices` is the common contract. - Android uses `NsdManager` with `_sync360._tcp.`. -- Desktop uses JmDNS with `_sync360._tcp.local.`. +- Windows uses the operating system DNS-SD functions in `dnsapi.dll`. +- macOS and Linux currently use JmDNS with `_sync360._tcp.local.`. Both advertise a stable device UUID, device name/type, protocol version, dynamic HTTP port, and dynamic file-transfer port. A device filters its own UUID from discovery results. -The current Desktop implementation selects the first active, non-loopback, non-virtual, site-local IPv4 interface. Machines with VPN, WSL, Docker, virtual-machine, Ethernet, and Wi-Fi adapters still need broader validation. +Discovery and registration expose independent `StateFlow` values. Each can be `Idle`, `Starting`, `Running`, or `Stopping`, and lifecycle commands are accepted only from compatible states. The controller derives the 60-second discovery window from `DiscoveryStatus.Running`, so platform startup time does not consume the scan window. Reload starts discovery again only while registration is still running. + +Connection repair waits until both operations are stable, then stops discovery and registration, clears stale devices, and advertises the existing HTTP and TCP ports again. Android advances repair from `NsdManager` callbacks instead of fixed callback timeouts. Windows cancels its native browse and pending resolves, deregisters through the Windows API, and waits for the corresponding state transitions. The macOS/Linux fallback closes and recreates its JmDNS instances; an instance that fails to close remains tracked so a later repair can retry cleanup. + +Windows calls `DnsServiceBrowse`, `DnsServiceResolve`, `DnsServiceRegister`, and `DnsServiceDeRegister` through the JDK Foreign Function and Memory API. Browse and registration use interface index `0`, which delegates all-interface IPv4/IPv6 handling to Windows. Native registration and deregistration callbacks drive `RegistrationStatus`; browse cancellation drives the final transition back to `DiscoveryStatus.Idle`. Browse callbacks start resolution for added PTR records and remove devices reported with a zero TTL. Resolved TXT properties and IPv4/IPv6 addresses are converted into the same shared `NearbyDevice` model used by Android. + +The macOS/Linux JmDNS fallback starts on eligible IPv4 and IPv6 addresses from every active, multicast-capable, non-loopback, non-virtual LAN interface. Windows DNS-SD and the fallback still need broader validation with VPN, WSL, Docker, virtual-machine, Ethernet, and Wi-Fi adapters. ## Control plane: Ktor HTTP @@ -93,7 +105,7 @@ POST /sync360/text/transfer POST /sync360/file/offer ``` -An offer waits up to 55 seconds for the receiver's decision. File metadata is converted from HTTP DTOs into the shared `FileTransferOffer` domain model at the Ktor boundary. +An offer waits up to 55 seconds for the receiver's decision. 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. ## File data plane: raw TCP @@ -117,7 +129,7 @@ Files remain sequential. The receiver verifies each index and size directly agai - 512 KiB payload buffers - 5-second connect timeout - 60-second connected-socket timeout -- 10-second wait for the first file connection after acceptance +- 30-second wait for the first file connection after acceptance The sender and receiver do not need matching read boundaries because TCP is a byte stream; exact file sizes define the protocol framing. Flushing once after the batch makes any remaining buffered bytes available before the sender waits for the final result, but the flush does not define file boundaries. @@ -125,6 +137,7 @@ The sender and receiver do not need matching read boundaries because TCP is a by - Android writes into public Downloads with a pending `MediaStore` entry. It publishes the entry only after success and deletes the incomplete current entry on failure. - Desktop writes to a temporary `.part` file in the user's Downloads folder, deletes it on failure, and moves it to a collision-safe final name after success. +- iOS writes to a temporary `.part` file in the app's Files-visible `Documents/Downloads` directory, deletes it on failure, and moves it to a collision-safe final name after success. Previously completed files remain when a later file in the same batch fails. @@ -132,9 +145,9 @@ Previously completed files remain when a later file in the same batch fails. - No authentication, encryption, session token, or cryptographic integrity check. - No retry, pause/resume, or interrupted-transfer recovery. -- Foreground/background and network-change lifecycle handling are not complete. +- Foreground/background and automatic network-change lifecycle handling are not complete. - Receiver failures do not yet provide rich error details. -- Host selection still uses the first resolved address. +- 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. - Automated transfer coverage is minimal. -- iOS targets and implementations are inactive. +- iOS source targets and implementations are enabled, but physical-device discovery and transfer remain unverified. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3cec7ae..ef5c120 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,20 +4,23 @@ This guide covers the current Android and Desktop/JVM development flow. ## Requirements -- JDK 17 -- A recent Android Studio or IntelliJ IDEA version compatible with Kotlin 2.3.21 and Android Gradle Plugin 9.2.x +- JDK 23 +- A recent Android Studio or IntelliJ IDEA version compatible with Kotlin 2.4.10 and Android Gradle Plugin 9.1.x - Android SDK Platform 37 for Android development - Git - A local network or hotspot that allows device-to-device traffic - Two Android 13+ devices for Android-to-Android testing, or Android plus Desktop for cross-platform testing -The repository includes the Gradle 9.4.1 wrapper. +The repository includes the Gradle 9.3.1 wrapper. + +Gradle and Desktop use JDK/JVM 23 because the Windows backend uses the finalized Foreign Function and Memory API. Android continues to emit Java 17 bytecode; Windows `jvmMain` sources are not part of the Android artifact, so raising Android's bytecode target would add no FFM capability. ## Modules - `androidApp` — Android application host. - `desktopApp` — Compose Desktop entry point and DMG/MSI/DEB packaging configuration. -- `shared` — shared UI, ViewModels, controllers, Ktor protocol, contracts, and Android/JVM implementations. +- `iosApp` — SwiftUI iOS application host. +- `shared` — shared UI, ViewModels, controllers, Ktor protocol, contracts, and Android/JVM/iOS implementations. ## Common commands @@ -51,6 +54,35 @@ Windows: ./gradlew.bat :desktopApp:run ``` +## Preparing public packages + +The first public package version is `0.1.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: + +```properties +storeFile=C:/absolute/path/to/keystore.jkis +storePassword=your-keystore-password +keyAlias=your-key-alias +keyPassword=your-key-password +``` + +Never commit the keystore, `keystore.properties`, passwords, or private keys. Keep secure backups of the signing key because future APK updates must use the same key. + +Build the Android release APK: + +```powershell +./gradlew.bat :androidApp:assembleRelease +``` + +Windows public packages currently use the normal Compose Desktop MSI task, not the ProGuard release-MSI task: + +```powershell +./gradlew.bat :desktopApp:packageMsi +``` + +The Windows `upgradeUuid` must remain unchanged for the lifetime of Sync360, and `packageVersion` must increase for every public MSI so a newer installer can replace an older installed version. Windows packages are currently unsigned and may show an unknown-publisher or SmartScreen warning. + ## Manual local-network testing 1. Connect both devices to the same trusted Wi-Fi network or hotspot. @@ -61,7 +93,9 @@ Windows: 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. -For Desktop testing, also check systems with multiple adapters, VPNs, WSL, Docker, or virtual machines. The current JmDNS implementation selects one site-local IPv4 interface. +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. + +macOS and Linux currently retain JmDNS. Test those systems with multiple adapters as well because JmDNS starts separately on each eligible address. ## If discovery or transfer fails @@ -76,13 +110,18 @@ For Desktop testing, also check systems with multiple adapters, VPNs, WSL, Docke Useful source locations: -- `NetworkServicesController` — startup order and discovery window. -- `AndroidNetworkServices` — Android NSD registration, discovery, and resolution. -- `JvmNetworkServices` — JmDNS registration, discovery, and LAN-interface selection. +- Android `Sync360Application` and Desktop `main` — one-time application network startup after Koin initialization. +- `NetworkServicesController` — startup order, state-derived discovery window, restart, and repair coordination. +- `AndroidNetworkServices` — callback-driven Android NSD registration, discovery, resolution, and repair. +- `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. - `OutgoingRequestsController` / `IncomingServerRequestsController` — send/receive coordination. - platform `FileTransferSender`, `FileTransferReceiver`, and `DownloadsWriter` implementations — file bytes and storage. +The Windows backend currently requires a 64-bit Desktop JVM, matching the project's Windows packaging target and the native ABI used by the binding. + ## Working style Prefer small changes, direct names, explicit ownership, route-specific DTOs, streaming I/O, and platform implementations behind common contracts. diff --git a/docs/OPEN_SOURCE_NOTES.md b/docs/OPEN_SOURCE_NOTES.md index 63353b2..59a0fbb 100644 --- a/docs/OPEN_SOURCE_NOTES.md +++ b/docs/OPEN_SOURCE_NOTES.md @@ -62,4 +62,4 @@ Sync360 is still early, but the useful local flow is real: local discovery -> receiver approval -> direct text or file transfer ``` -Android is the most-tested platform. Desktop/JVM now implements the same shared flow and has initial Desktop-to-Android validation, but broader operating-system, adapter, firewall, and router testing is still needed. That is the story to tell clearly without presenting the app as a finished or secure release. +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 3c744e4..481c74f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,11 +1,15 @@ # 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; the Desktop/JVM implementation now exists and has initial Desktop-to-Android manual validation. +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. ## Working now - Android DNS-SD/mDNS discovery and registration through `NsdManager`. -- Desktop DNS-SD/mDNS discovery and registration through JmDNS. +- Windows DNS-SD/mDNS discovery and registration through the operating system `dnsapi.dll` API on all interfaces. +- Current macOS/Linux DNS-SD/mDNS discovery and registration through JmDNS on eligible IPv4 and IPv6 LAN addresses. +- Application-lifetime network startup with separate discovery and registration lifecycle states. +- 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. - Android and Desktop multiple-file selection. @@ -17,6 +21,7 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby - Best-effort sender cancellation. - 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. ## Next @@ -28,11 +33,12 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby ### Discovery and lifecycle -- Repair registration automatically after network/address changes. +- Detect network/address changes and repair registration automatically. - Add the appropriate Android foreground/background service behavior. -- Improve Desktop LAN-interface selection for multi-adapter systems. +- Replace the remaining macOS/Linux JmDNS fallback with Bonjour and Avahi after the Windows-native path is validated. +- Validate Desktop LAN-interface selection on more multi-adapter systems. - Test more routers, hotspots, firewalls, VPNs, and multicast-restricted networks. -- Improve IPv4/IPv6 host selection and URL handling. +- Improve IPv4/IPv6 host preference and scoped-address URL handling. ### Security @@ -47,7 +53,7 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby - Retry or resume support if its protocol complexity is justified. - Desktop packaging, update, and release workflow. - Wider Windows, macOS, and Linux compatibility testing. -- iOS discovery, transfer, storage, and permission investigation. +- iOS physical-device discovery, transfer, cancellation, storage, permission, signing, and distribution validation. - Better onboarding and local-network troubleshooting UI. ## Not planned right now diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 050d142..2bb03c3 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,12 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9aafe8bc391c4bbca3e440130e15608b/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/109553caae279a667336ea8850b50c92/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e9e57f77c3aebae0f611dfbdb21215eb/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/de9901730551c372b4aa435f226e853f/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e9e57f77c3aebae0f611dfbdb21215eb/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/de9901730551c372b4aa435f226e853f/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/91084cb957cff3e6e128df9d3842d8e5/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/731a0551564c07034d56e472ad261771/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e9e57f77c3aebae0f611dfbdb21215eb/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/de9901730551c372b4aa435f226e853f/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/279d7084502c61d93d475f2334fbafb2/redirect toolchainVendor=AMAZON -toolchainVersion=21 \ No newline at end of file +toolchainVersion=23 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6d8972..783e739 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.2.1" +agp = "9.1.1" android-compileSdk = "37" android-minSdk = "33" android-targetSdk = "37" @@ -8,7 +8,7 @@ androidx-core = "1.19.0" androidx-lifecycle = "2.11.0" composeMultiplatform = "1.11.1" junit = "4.13.2" -kotlin = "2.3.21" +kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" ktor = "3.5.1" @@ -46,6 +46,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 56ddc0f..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists \ No newline at end of file +zipStorePath=wrapper/dists diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index e898325..836fa23 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -4,4 +4,4 @@ PRODUCT_NAME=Sync360 PRODUCT_BUNDLE_IDENTIFIER=com.liftley.sync360.Sync360$(TEAM_ID) CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=0.1.0 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ce0e491..71d9dbd 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -152,7 +152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n/bin/sh ./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -221,7 +221,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -280,7 +280,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -370,4 +370,4 @@ /* End XCConfigurationList section */ }; rootObject = 138BDE434109F01D5C313CFA /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index ed67386..e2df644 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,15 @@ CADisableMinimumFrameDurationOnPhone + NSLocalNetworkUsageDescription + Sync360 uses your local network to find and connect to nearby devices. + NSBonjourServices + + _sync360._tcp + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + - \ No newline at end of file + diff --git a/keystore.properties.example b/keystore.properties.example new file mode 100644 index 0000000..f6b6ce7 --- /dev/null +++ b/keystore.properties.example @@ -0,0 +1,6 @@ +# Copy this file to keystore.properties and keep that local file private. +# Use forward slashes in Windows paths. +storeFile=C:/absolute/path/to/keystore.jkis +storePassword=replace-me +keyAlias=replace-me +keyPassword=replace-me diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b44f885..0da9c15 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -11,7 +11,7 @@ plugins { kotlin { jvm { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_23) } } @@ -33,7 +33,6 @@ kotlin { } } - /* listOf( iosArm64(), iosSimulatorArm64() @@ -43,7 +42,6 @@ kotlin { isStatic = true } } - */ sourceSets { androidMain.dependencies { @@ -57,6 +55,10 @@ kotlin { implementation(libs.jmdns) } + iosMain.dependencies { + implementation(libs.ktor.network) + } + commonMain.dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/discovery/AndroidNetworkServices.kt b/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/discovery/AndroidNetworkServices.kt index 9c24b81..bd15901 100644 --- a/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/discovery/AndroidNetworkServices.kt +++ b/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/discovery/AndroidNetworkServices.kt @@ -8,20 +8,16 @@ import android.util.Log import com.liftley.sync360.domain.local.LocalDeviceIdentityStore import com.liftley.sync360.domain.model.DiscoveryStatus import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.domain.service.NetworkServices import com.liftley.sync360.domain.toNearbyDeviceAndroidImpl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration.Companion.milliseconds class AndroidNetworkServices( context: Context, @@ -41,6 +37,12 @@ class AndroidNetworkServices( override val discoveryServiceStatus: StateFlow = _discoveryServiceStatus.asStateFlow() + private val _registrationServiceStatus: MutableStateFlow = + MutableStateFlow(RegistrationStatus.Idle) + + override val registrationServiceStatus: StateFlow = + _registrationServiceStatus.asStateFlow() + val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager val executor: ExecutorService = Executors.newSingleThreadExecutor() @@ -49,33 +51,34 @@ class AndroidNetworkServices( val deviceUuid = androidLocalDeviceIdentityStore.getOrCreateDeviceUuid() - val serviceInfoCallbackMap = ConcurrentHashMap() + val serviceInfoCallbacks: MutableSet = + ConcurrentHashMap.newKeySet() - private val repairMutex = Mutex() @Volatile - private var isDiscoveryRunning = false - @Volatile - private var isRegistrationRequested = false - private var discoveryStoppedCompletion: CompletableDeferred? = null - private var serviceUnregisteredCompletion: CompletableDeferred? = null + private var pendingRepair: PendingRepair? = null val discoveryListener = object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(serviceType: String?) { - isDiscoveryRunning = true _discoveryServiceStatus.value = DiscoveryStatus.Running Log.d("AndroidNetworkServices", "onDiscoveryStarted: $serviceType") } override fun onDiscoveryStopped(serviceType: String?) { - isDiscoveryRunning = false _discoveryServiceStatus.value = DiscoveryStatus.Idle - discoveryStoppedCompletion?.complete(Unit) - discoveryStoppedCompletion = null + _nearbyDevices.value = emptyList() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + clearAndStopResolvingServices() + } + + continuePendingRepairIfReady() Log.d("AndroidNetworkServices", "onDiscoveryStopped: $serviceType") } @Suppress("NewApi", "DEPRECATION") override fun onServiceFound(foundDiscoveryServiceInfo: NsdServiceInfo?) { + if (!discoveryIsActive()) return + Log.d("AndroidNetworkServices", "onServiceFound: $foundDiscoveryServiceInfo") foundDiscoveryServiceInfo?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -84,16 +87,15 @@ class AndroidNetworkServices( var resolvedNearbyDeviceInfo: NearbyDevice? = null override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + serviceInfoCallbacks.remove(this) Log.d( "AndroidNetworkServices", "onServiceInfoCallbackRegistrationFailed: $errorCode" ) - resolvedNearbyDeviceInfo?.id?.let { deviceId -> - serviceInfoCallbackMap.remove(deviceId) - } } override fun onServiceInfoCallbackUnregistered() { + serviceInfoCallbacks.remove(this) Log.d("AndroidNetworkServices", "onServiceInfoCallbackUnregistered") resolvedNearbyDeviceInfo = null } @@ -115,26 +117,27 @@ class AndroidNetworkServices( listWithoutLostDevice } - resolvedNearbyDeviceInfo?.id?.let { deviceId -> - val serviceCallbackObject = serviceInfoCallbackMap.remove(deviceId) - serviceCallbackObject?.let { listener -> - nsdManager.unregisterServiceInfoCallback(listener) - } + if (serviceInfoCallbacks.remove(this)) { + nsdManager.unregisterServiceInfoCallback(this) } } override fun onServiceUpdated(updatedResolvedDeviceInfo: NsdServiceInfo) { + if (!discoveryIsActive()) return + Log.d( "AndroidNetworkServices", "onServiceUpdated: $updatedResolvedDeviceInfo" ) val newDevice = updatedResolvedDeviceInfo.toNearbyDeviceAndroidImpl() - resolvedNearbyDeviceInfo = newDevice if (newDevice == null) { + serviceInfoCallbacks.remove(this) nsdManager.unregisterServiceInfoCallback(this) return } + resolvedNearbyDeviceInfo = newDevice + if (newDevice.id == deviceUuid) return _nearbyDevices.update { currentList -> @@ -144,18 +147,25 @@ class AndroidNetworkServices( val newList = withoutOldDeviceId + newDevice newList } - - serviceInfoCallbackMap[newDevice.id] = this } } - nsdManager.registerServiceInfoCallback( - foundDiscoveryServiceInfo, - executor, - serviceInfoCallbackListener - ) - } else - { + serviceInfoCallbacks += serviceInfoCallbackListener + runCatching { + nsdManager.registerServiceInfoCallback( + foundDiscoveryServiceInfo, + executor, + serviceInfoCallbackListener + ) + }.onFailure { exception -> + serviceInfoCallbacks.remove(serviceInfoCallbackListener) + Log.d( + "AndroidNetworkServices", + "registerServiceInfoCallback failed", + exception + ) + } + } else { val resolveListener = object : NsdManager.ResolveListener { override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { Log.d( @@ -165,9 +175,15 @@ class AndroidNetworkServices( } override fun onServiceResolved(resolvedDeviceInfo: NsdServiceInfo?) { - Log.d("AndroidNetworkServices", "onServiceResolved: $resolvedDeviceInfo") + if (!discoveryIsActive()) return + + Log.d( + "AndroidNetworkServices", + "onServiceResolved: $resolvedDeviceInfo" + ) - val newDevice = resolvedDeviceInfo?.toNearbyDeviceAndroidImpl() ?: return + val newDevice = + resolvedDeviceInfo?.toNearbyDeviceAndroidImpl() ?: return if (newDevice.id == deviceUuid) return @@ -186,61 +202,99 @@ class AndroidNetworkServices( override fun onServiceLost(lostServiceInfo: NsdServiceInfo?) { Log.d("AndroidNetworkServices", "onServiceLost on Discovery: $lostServiceInfo") + _nearbyDevices.update { currentList -> + + val withoutOldDevice = + currentList.filterNot { device -> device.serviceName == lostServiceInfo?.serviceName } + withoutOldDevice + } } override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { - isDiscoveryRunning = false _discoveryServiceStatus.value = DiscoveryStatus.Idle + _nearbyDevices.value = emptyList() + cancelPendingRepair() Log.d("AndroidNetworkServices", "onStartDiscoveryFailed: $serviceType, $errorCode") } override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { Log.d("AndroidNetworkServices", "onStopDiscoveryFailed: $serviceType, $errorCode") - _discoveryServiceStatus.value = DiscoveryStatus.Idle - isDiscoveryRunning = false - discoveryStoppedCompletion?.complete(Unit) - discoveryStoppedCompletion = null + _discoveryServiceStatus.value = DiscoveryStatus.Running + cancelPendingRepair() } } val registrationListener = object : NsdManager.RegistrationListener { - override fun onRegistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { - isRegistrationRequested = false - Log.d("AndroidNetworkServices", "onRegistrationFailed: $serviceInfo, $errorCode") - } - override fun onServiceRegistered(serviceInfo: NsdServiceInfo?) { - isRegistrationRequested = true + _registrationServiceStatus.value = RegistrationStatus.Running Log.d("AndroidNetworkServices", "onServiceRegistered: $serviceInfo") } + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + Log.d("AndroidNetworkServices", "onRegistrationFailed: $serviceInfo, $errorCode") + } + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo?) { - isRegistrationRequested = false - serviceUnregisteredCompletion?.complete(Unit) - serviceUnregisteredCompletion = null + _registrationServiceStatus.value = RegistrationStatus.Idle + continuePendingRepairIfReady() Log.d("AndroidNetworkServices", "onServiceUnregistered: $serviceInfo") } override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { - isRegistrationRequested = false - serviceUnregisteredCompletion?.complete(Unit) - serviceUnregisteredCompletion = null + _registrationServiceStatus.value = RegistrationStatus.Running + cancelPendingRepair() Log.d("AndroidNetworkServices", "onUnregistrationFailed: $serviceInfo, $errorCode") } } override suspend fun startNetworkServices(httpServerPort: Int, fileTransferPort: Int) { + startDiscoveryService() + startRegistrationService(httpServerPort, fileTransferPort) + } + + private fun startDiscoveryService() { if (discoveryServiceStatus.value != DiscoveryStatus.Idle) { Log.d( "AndroidNetworkServices", - "startNetworkServices ignored because status=${discoveryServiceStatus.value}" + "startDiscoveryService ignored because status=${discoveryServiceStatus.value}" ) return } - Log.d("AndroidNetworkServices", "startNetworkServices: Starting discovery and registration") + Log.d("AndroidNetworkServices", "startDiscoveryService: Starting discovery") _discoveryServiceStatus.value = DiscoveryStatus.Starting + runCatching { + nsdManager.discoverServices( + serviceType, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener + ) + }.onFailure { exception -> + _discoveryServiceStatus.value = DiscoveryStatus.Idle + _nearbyDevices.value = emptyList() + cancelPendingRepair() + Log.d("AndroidNetworkServices", "startDiscoveryService failed", exception) + } + } + + private fun startRegistrationService( + httpServerPort: Int, + fileTransferPort: Int + ) { + if (registrationServiceStatus.value != RegistrationStatus.Idle) { + Log.d( + "AndroidNetworkServices", + "startRegistrationService ignored because status=${registrationServiceStatus.value}" + ) + return + } + + _registrationServiceStatus.value = RegistrationStatus.Starting + Log.d("AndroidNetworkServices", "startRegistrationService: Starting registration") + val rawManufacturer = Build.MANUFACTURER.trim() val rawModel = Build.MODEL.trim() @@ -256,77 +310,68 @@ class AndroidNetworkServices( "$manufacturer $rawModel" } - val serviceInfo = NsdServiceInfo().apply { - serviceType = "_sync360._tcp." - serviceName = "${Build.MODEL} Sync360" - port = httpServerPort - - setAttribute("deviceUuid", deviceUuid) - setAttribute("deviceName", cleanDeviceName) - setAttribute("deviceType", "Android") - setAttribute("protocolVersion", "1") + val serviceInfo = runCatching { + NsdServiceInfo().apply { + serviceType = "_sync360._tcp." + serviceName = "${Build.MODEL} Sync360" + port = httpServerPort + + setAttribute("deviceUuid", deviceUuid) + setAttribute("deviceName", cleanDeviceName) + setAttribute("deviceType", "Android") + setAttribute("protocolVersion", "1") + + setAttribute( + "fileTransferPort", + fileTransferPort.toString() + ) + } + }.getOrElse { exception -> + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + Log.d("AndroidNetworkServices", "Could not create registration service", exception) + return + } - setAttribute( - "fileTransferPort", - fileTransferPort.toString() + runCatching { + nsdManager.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListener ) + }.onFailure { exception -> + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + Log.d("AndroidNetworkServices", "startRegistrationService failed", exception) } - - isRegistrationRequested = true - nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) - nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) } override suspend fun repairNetworkServices( httpServerPort: Int, fileTransferPort: Int ) { - repairMutex.withLock { - _discoveryServiceStatus.value = DiscoveryStatus.Stopping - - if (isDiscoveryRunning) { - val stopped = CompletableDeferred() - discoveryStoppedCompletion = stopped - - runCatching { - nsdManager.stopServiceDiscovery(discoveryListener) - }.onFailure { - stopped.complete(Unit) - } - - withTimeoutOrNull(REPAIR_STEP_TIMEOUT_MILLIS.milliseconds) { - stopped.await() - } - } + val discoveryIsStable = + discoveryServiceStatus.value == DiscoveryStatus.Idle || + discoveryServiceStatus.value == DiscoveryStatus.Running + val registrationIsStable = + registrationServiceStatus.value == RegistrationStatus.Idle || + registrationServiceStatus.value == RegistrationStatus.Running - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - clearAndStopResolvingServices() - } + if (!discoveryIsStable || !registrationIsStable) return - if (isRegistrationRequested) { - val unregistered = CompletableDeferred() - serviceUnregisteredCompletion = unregistered + pendingRepair = PendingRepair(httpServerPort, fileTransferPort) - runCatching { - nsdManager.unregisterService(registrationListener) - }.onFailure { - unregistered.complete(Unit) - } - - withTimeoutOrNull(REPAIR_STEP_TIMEOUT_MILLIS.milliseconds) { - unregistered.await() - } - } - - isDiscoveryRunning = false - isRegistrationRequested = false - discoveryStoppedCompletion = null - serviceUnregisteredCompletion = null - _nearbyDevices.value = emptyList() - _discoveryServiceStatus.value = DiscoveryStatus.Idle + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + stopDiscoveryServices() + } + if (pendingRepair == null) return - startNetworkServices(httpServerPort, fileTransferPort) + if (registrationServiceStatus.value == RegistrationStatus.Running) { + stopRegistrationService() } + if (pendingRepair == null) return + + continuePendingRepairIfReady() } override fun stopDiscoveryServices() { @@ -340,33 +385,82 @@ class AndroidNetworkServices( _discoveryServiceStatus.value = DiscoveryStatus.Stopping Log.d("AndroidNetworkServices", "stopDiscoveryServices: Stopping Discovery Services") - nsdManager.stopServiceDiscovery(discoveryListener) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - clearAndStopResolvingServices() + runCatching { + nsdManager.stopServiceDiscovery(discoveryListener) + }.onFailure { exception -> + _discoveryServiceStatus.value = DiscoveryStatus.Running + cancelPendingRepair() + Log.d("AndroidNetworkServices", "stopDiscoveryServices failed", exception) } } - override fun restartDiscoveryServices() { - if (discoveryServiceStatus.value != DiscoveryStatus.Idle) { + private fun stopRegistrationService() { + if (registrationServiceStatus.value != RegistrationStatus.Running) { Log.d( "AndroidNetworkServices", - "restartDiscoveryServices ignored because status=${discoveryServiceStatus.value}" + "stopRegistrationService ignored because status=${registrationServiceStatus.value}" ) return } - _discoveryServiceStatus.value = DiscoveryStatus.Starting - Log.d("AndroidNetworkServices", "restartDiscoveryServices: Restarting discovery") - _nearbyDevices.update { - emptyList() + + _registrationServiceStatus.value = RegistrationStatus.Stopping + Log.d("AndroidNetworkServices", "stopRegistrationService: Stopping Registration Service") + + runCatching { + nsdManager.unregisterService(registrationListener) + }.onFailure { exception -> + _registrationServiceStatus.value = RegistrationStatus.Running + cancelPendingRepair() + Log.d("AndroidNetworkServices", "stopRegistrationService failed", exception) } - nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } + + override fun restartDiscoveryServices() { + if ( + discoveryServiceStatus.value != DiscoveryStatus.Idle || + registrationServiceStatus.value != RegistrationStatus.Running + ) { + Log.d( + "AndroidNetworkServices", + "restartDiscoveryServices ignored because discovery=${discoveryServiceStatus.value}, " + + "registration=${registrationServiceStatus.value}" + ) + return + } + + _nearbyDevices.value = emptyList() + startDiscoveryService() + } + + @Synchronized + private fun continuePendingRepairIfReady() { + val repair = pendingRepair ?: return + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + pendingRepair = null + _nearbyDevices.value = emptyList() + startDiscoveryService() + startRegistrationService( + httpServerPort = repair.httpServerPort, + fileTransferPort = repair.fileTransferPort + ) + } + + private fun cancelPendingRepair() { + pendingRepair = null + } + + private fun discoveryIsActive(): Boolean { + return discoveryServiceStatus.value == DiscoveryStatus.Starting || + discoveryServiceStatus.value == DiscoveryStatus.Running } @Suppress("NewApi") private fun clearAndStopResolvingServices() { - val callbacks = serviceInfoCallbackMap.values.toList() - serviceInfoCallbackMap.clear() + val callbacks = serviceInfoCallbacks.toList() + serviceInfoCallbacks.clear() callbacks.forEach { callback -> runCatching { @@ -375,7 +469,8 @@ class AndroidNetworkServices( } } - private companion object { - const val REPAIR_STEP_TIMEOUT_MILLIS = 3_000L - } + private data class PendingRepair( + val httpServerPort: Int, + val fileTransferPort: Int + ) } diff --git a/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/tcp/AndroidFileTransferReceiver.kt b/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/tcp/AndroidFileTransferReceiver.kt index b74903a..e658626 100644 --- a/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/tcp/AndroidFileTransferReceiver.kt +++ b/shared/src/androidMain/kotlin/com/liftley/sync360/data/network/tcp/AndroidFileTransferReceiver.kt @@ -40,9 +40,9 @@ class AndroidFileTransferReceiver( override var port: Int = 0 private set - override suspend fun start() { + override suspend fun start(): Int { if (serverSocket != null) { - return + return port } val startedServerSocket = withContext(Dispatchers.IO) { @@ -64,6 +64,8 @@ class AndroidFileTransferReceiver( } } } + + return port } @Synchronized diff --git a/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.android.kt b/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.android.kt index 3c87481..3bf56c9 100644 --- a/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.android.kt +++ b/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.android.kt @@ -3,152 +3,37 @@ package com.liftley.sync360.presentation.send.components import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.core.designsystem.icons.Close -import com.liftley.sync360.presentation.app.components.Sync360Surface -import com.liftley.sync360.presentation.send.SendScreenViewModel -import org.koin.compose.koinInject +import com.liftley.sync360.domain.model.SelectedFile @Composable -actual fun FilesSendContent() { - val sendScreenViewModel = koinInject() - val sendScreenState = sendScreenViewModel.screenState.collectAsStateWithLifecycle() - +actual fun FilesSendContent( + files: List, + onFilesSelected: (List) -> Unit, + onClearFiles: () -> Unit, + onRemoveFile: (SelectedFile) -> Unit +) { val multipleMediaPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> - sendScreenViewModel.handleFilesSelected(uris) + onFilesSelected(uris) } val openDocuments = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> - sendScreenViewModel.handleFilesSelected(uris) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Selected Files", - style = MaterialTheme.typography.titleLarge - ) - if (sendScreenState.value.files.isNotEmpty()) { - IconButton( - onClick = { sendScreenViewModel.clearSelectedFiles() }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier.height(48.dp) - ) { - Icon(imageVector = Close, contentDescription = null) - } + onFilesSelected(uris) } - } - - if (sendScreenState.value.files.isNotEmpty()) { - val files = sendScreenState.value.files - - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 250.dp) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(files) { file -> - FileItemCard(file) { - sendScreenViewModel.removeSelectedFileFromList(it) - } - } - } - if (files.size > 3) { - Text( - text = "${files.size} files selected - scroll to view", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - } - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = MaterialTheme.shapes.large.copy( - topStart = CornerSize(24.dp), - bottomStart = CornerSize(24.dp), - topEnd = CornerSize(4.dp), - bottomEnd = CornerSize(4.dp) - ), - onClick = { multipleMediaPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) }, - modifier = Modifier.weight(1f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Image/Videos", style = MaterialTheme.typography.titleMedium) - } - } - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = MaterialTheme.shapes.large.copy( - topStart = CornerSize(4.dp), - bottomStart = CornerSize(4.dp), - topEnd = CornerSize(24.dp), - bottomEnd = CornerSize(24.dp) - ), - onClick = { openDocuments.launch(arrayOf("*/*")) }, - modifier = Modifier.weight(1f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Files", style = MaterialTheme.typography.titleMedium) - } - } - } -} \ No newline at end of file + FileSelectionContent( + files = files, + onClearFiles = onClearFiles, + onRemoveFile = onRemoveFile, + onPickMedia = { + multipleMediaPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + }, + onPickFiles = { openDocuments.launch(arrayOf("*/*")) } + ) +} + +internal actual fun filePreviewModel(file: SelectedFile): Any = file.uri diff --git a/shared/src/commonMain/composeResources/drawable/app_icon.png b/shared/src/commonMain/composeResources/drawable/app_icon.png index 42f9a9a..c27f5cf 100644 Binary files a/shared/src/commonMain/composeResources/drawable/app_icon.png and b/shared/src/commonMain/composeResources/drawable/app_icon.png differ diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt index 8c653a4..3666f73 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt @@ -1,10 +1,7 @@ package com.liftley.sync360 -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.CenterAlignedTopAppBar @@ -25,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview @@ -39,6 +35,7 @@ import com.liftley.sync360.core.designsystem.icons.Download import com.liftley.sync360.core.designsystem.icons.Send import com.liftley.sync360.core.designsystem.icons.Settings import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.presentation.navigation.NavScreen import com.liftley.sync360.presentation.navigation.NavigationViewModel import com.liftley.sync360.presentation.navigation.TwoPaneScene @@ -69,9 +66,20 @@ fun Sync360Root() { val twoPaneStrategy = remember(windowSizeClass) { TwoPaneSceneStrategy(windowSizeClass) } + val discoveryIsStable = + sendScreenState.discoveryStatus == DiscoveryStatus.Idle || + sendScreenState.discoveryStatus == DiscoveryStatus.Running + val registrationIsStable = + sendScreenState.registrationStatus == RegistrationStatus.Idle || + sendScreenState.registrationStatus == RegistrationStatus.Running + val repairEnabled = + sendScreenState.sendOperationState == SendOperationState.Idle && + receiveScreenState == ReceiveScreenState.Idle && + discoveryIsStable && + registrationIsStable val receiveTitle = when (receiveScreenState) { - ReceiveScreenState.Idle -> "Receive" + ReceiveScreenState.Idle -> "Sync360" is ReceiveScreenState.IncomingTextOffer -> "Incoming text" is ReceiveScreenState.IncomingFileOffer -> "Incoming files" is ReceiveScreenState.ReceivingFiles -> "Receiving files" @@ -99,7 +107,7 @@ fun Sync360Root() { // 2. Add outer floating padding around the bar (converted from dp) .padding(horizontal = 32.dp, vertical = 16.dp) // 3. Clip the corners after padding to create the floating card shape - .clip(MaterialTheme.shapes.extraLarge), + .clip(MaterialTheme.shapes.extraExtraLarge), containerColor = MaterialTheme.colorScheme.surface, // 4. Disable internal inset consumption so our custom modifiers control the shape windowInsets = WindowInsets(0, 0, 0, 0) @@ -138,7 +146,7 @@ fun Sync360Root() { navigationIcon = { if (currentScreen == NavScreen.SettingsScreen) { IconButton( - modifier = Modifier.height(48.dp), + modifier = Modifier, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface), onClick = navigationViewModel::removeLast ) { @@ -150,28 +158,19 @@ fun Sync360Root() { } }, title = { - Box( - Modifier - .clip(MaterialTheme.shapes.extraLarge) - .height(48.dp) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = when (currentScreen) { - NavScreen.SendScreen -> sendTitle - NavScreen.ReceiveScreen -> receiveTitle - NavScreen.SettingsScreen -> "Settings" - }, - style = MaterialTheme.typography.titleLarge - ) - } + Text( + text = when (currentScreen) { + NavScreen.SendScreen -> sendTitle + NavScreen.ReceiveScreen -> receiveTitle + NavScreen.SettingsScreen -> "Settings" + }, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + ) }, actions = { if (currentScreen != NavScreen.SettingsScreen) { IconButton( - modifier = Modifier.height(48.dp), colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface), onClick = { navigationViewModel.addScreen(NavScreen.SettingsScreen) @@ -228,10 +227,7 @@ fun Sync360Root() { is NavScreen.SettingsScreen -> { NavEntry(key = screen) { SettingsScreen( - repairEnabled = sendScreenState.sendOperationState == SendOperationState.Idle && - receiveScreenState == ReceiveScreenState.Idle && - (sendScreenState.discoveryStatus == DiscoveryStatus.Idle || - sendScreenState.discoveryStatus == DiscoveryStatus.Running), + repairEnabled = repairEnabled, onRepairClick = sendScreenViewModel::repairNetworkServices ) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/canvas/AppIcon.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/canvas/AppIcon.kt index beb0282..2239944 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/canvas/AppIcon.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/canvas/AppIcon.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlin.math.cos +import kotlin.math.PI import kotlin.math.sin @Preview(showBackground = true) @@ -59,7 +60,7 @@ fun AppIcon() { val midY = (startY + endY) / 2f // 4. Convert angle to radians - val radians = Math.toRadians(pullAngleDegrees.toDouble()).toFloat() + val radians = (pullAngleDegrees * PI / 180.0).toFloat() // 5. Calculate the control point by offsetting from the midpoint towards the angle val controlX = midX + pullDistance * cos(radians) @@ -95,4 +96,4 @@ fun AppIcon() { ) ) } -} \ No newline at end of file +} 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 15f366f..57b3d33 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 @@ -8,6 +8,7 @@ import com.liftley.sync360.data.OutgoingRequestsController import com.liftley.sync360.presentation.navigation.NavigationViewModel import com.liftley.sync360.presentation.receive.ReceiveScreenViewModel import com.liftley.sync360.presentation.send.SendScreenViewModel +import org.koin.core.KoinApplication import org.koin.core.context.startKoin import org.koin.core.module.Module import org.koin.dsl.KoinAppDeclaration @@ -40,8 +41,8 @@ val appModule = module { single { NetworkServicesController(get(), get(), get()) } } -fun initKoin(platformModule: Module, appDeclaration: KoinAppDeclaration) { - startKoin { +fun initKoinSync360(platformModule: Module, appDeclaration: KoinAppDeclaration): KoinApplication { + return startKoin { appDeclaration() modules(platformModule, appModule) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt index 22cd6cf..23a9df2 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt @@ -3,12 +3,13 @@ package com.liftley.sync360.data import com.liftley.sync360.data.network.http.server.Sync360HttpServer import com.liftley.sync360.data.network.tcp.FileTransferReceiver import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.domain.service.NetworkServices -import kotlinx.coroutines.delay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -20,88 +21,107 @@ class NetworkServicesController( private val networkServices: NetworkServices, ) { private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var discoveryStopJob: Job? = null private var httpServerPort: Int? = null private var fileTransferPort: Int? = null - private val repairMutex = Mutex() + private val lifecycleMutex = Mutex() + private val repairRequestMutex = Mutex() + + private var hasStarted = false val nearbyDevices = networkServices.nearbyDevices val discoveryServiceStatus = networkServices.discoveryServiceStatus - suspend fun startNetworkServices() { - fileTransferReceiver.start() + val registrationServiceStatus = networkServices.registrationServiceStatus - val startedHttpServerPort = httpServer.start() - val startedFileTransferPort = fileTransferReceiver.port + init { + controllerScope.launch { + discoveryServiceStatus.collectLatest { status -> + if (status == DiscoveryStatus.Running) { + delay(DISCOVERY_DURATION_MILLIS.milliseconds) + stopDiscoveryServices() + } + } + } + } - httpServerPort = startedHttpServerPort - fileTransferPort = startedFileTransferPort + fun startNetworkServices() { + controllerScope.launch { + lifecycleMutex.withLock { + if (hasStarted) return@withLock - networkServices.startNetworkServices(startedHttpServerPort, startedFileTransferPort) - scheduleDiscoveryStop() - } + val startedHttpServerPort = httpServer.start() + val startedFileTransferPort = fileTransferReceiver.start() - suspend fun restartDiscoveryServices() { - when (discoveryServiceStatus.value) { - DiscoveryStatus.Idle -> { - networkServices.restartDiscoveryServices() - scheduleDiscoveryStop() - } + httpServerPort = startedHttpServerPort + fileTransferPort = startedFileTransferPort - DiscoveryStatus.Stopping -> { - return - } + networkServices.startNetworkServices( + httpServerPort = startedHttpServerPort, + fileTransferPort = startedFileTransferPort + ) - DiscoveryStatus.Starting -> { - return + hasStarted = true } + } + } - DiscoveryStatus.Running -> { - return + suspend fun restartDiscoveryServices() { + lifecycleMutex.withLock { + if (discoveryServiceStatus.value == DiscoveryStatus.Idle) { + networkServices.restartDiscoveryServices() } } } suspend fun repairNetworkServices() { - repairMutex.withLock { - val activeHttpServerPort = httpServerPort ?: return@withLock - val activeFileTransferPort = fileTransferPort ?: return@withLock - - discoveryStopJob?.cancel() - networkServices.repairNetworkServices( - httpServerPort = activeHttpServerPort, - fileTransferPort = activeFileTransferPort - ) - scheduleDiscoveryStop() + if (!repairRequestMutex.tryLock()) return + + try { + startRepairWhenServicesAreStable() + } finally { + repairRequestMutex.unlock() } } - fun stopDiscoveryServices() { - when (discoveryServiceStatus.value) { - DiscoveryStatus.Idle -> { - return + private suspend fun startRepairWhenServicesAreStable() { + val activeHttpServerPort = httpServerPort ?: return + val activeFileTransferPort = fileTransferPort ?: return + + while (true) { + val repairStarted = lifecycleMutex.withLock { + if (servicesAreStable()) { + networkServices.repairNetworkServices( + httpServerPort = activeHttpServerPort, + fileTransferPort = activeFileTransferPort + ) + true + } else false } + if (repairStarted) return + delay(500.milliseconds) + } + } - DiscoveryStatus.Stopping -> { - return - } + private fun servicesAreStable(): Boolean { + val discoveryStatus = discoveryServiceStatus.value + val registrationStatus = registrationServiceStatus.value - DiscoveryStatus.Starting -> { - return - } + val discoveryIsStable = + discoveryStatus == DiscoveryStatus.Idle || + discoveryStatus == DiscoveryStatus.Running + val registrationIsStable = + registrationStatus == RegistrationStatus.Idle || + registrationStatus == RegistrationStatus.Running - DiscoveryStatus.Running -> { - networkServices.stopDiscoveryServices() - } - } + return discoveryIsStable && registrationIsStable } - private fun scheduleDiscoveryStop() { - discoveryStopJob?.cancel() - discoveryStopJob = controllerScope.launch { - delay(DISCOVERY_DURATION_MILLIS.milliseconds) - stopDiscoveryServices() + private suspend fun stopDiscoveryServices() { + lifecycleMutex.withLock { + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + networkServices.stopDiscoveryServices() + } } } 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 162ce07..78a1f4c 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 @@ -211,6 +211,7 @@ class Sync360HttpClient { if (!contains(':')) return this val unwrappedHost = removePrefix("[").removeSuffix("]") + .replace("%", "%25") return "[$unwrappedHost]" } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/tcp/FileTransferReceiver.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/tcp/FileTransferReceiver.kt index 06166fd..e80a734 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/tcp/FileTransferReceiver.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/network/tcp/FileTransferReceiver.kt @@ -6,7 +6,7 @@ import com.liftley.sync360.domain.model.FileTransferProgress interface FileTransferReceiver { val port: Int - suspend fun start() + suspend fun start(): Int fun prepareForTransfer( fileOffer: FileOfferRequest, diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt new file mode 100644 index 0000000..d50af1b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt @@ -0,0 +1,8 @@ +package com.liftley.sync360.domain.model + +enum class RegistrationStatus { + Idle, + Starting, + Running, + Stopping +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/service/NetworkServices.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/service/NetworkServices.kt index a3132f0..ebe032a 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/service/NetworkServices.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/service/NetworkServices.kt @@ -2,11 +2,14 @@ package com.liftley.sync360.domain.service import com.liftley.sync360.domain.model.DiscoveryStatus import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.RegistrationStatus import kotlinx.coroutines.flow.StateFlow interface NetworkServices { val nearbyDevices: StateFlow> val discoveryServiceStatus: StateFlow + val registrationServiceStatus: StateFlow + suspend fun startNetworkServices(httpServerPort: Int, fileTransferPort: Int) suspend fun repairNetworkServices(httpServerPort: Int, fileTransferPort: Int) diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/NetworkRepairAction.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/NetworkRepairAction.kt index 83ac292..51eea89 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/NetworkRepairAction.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/NetworkRepairAction.kt @@ -4,9 +4,9 @@ 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.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,23 +26,18 @@ fun NetworkRepairAction( .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + verticalArrangement = Arrangement.spacedBy(Spacing.lg) ) { Text( text = "Connection repair", - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleLarge ) Text( - text = "Use this if nearby devices cannot discover this device or fail to connect after the network changes.", + text = "Use this if nearby devices cannot discover this device or fail to connect after the network changes.\n\nRepair restarts local discovery and advertises Sync360 again. It does not remove received files or reset the app.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Text( - text = "Repair restarts local discovery and advertises Sync360 again. It does not remove received files or reset the app.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - TextButton( + Button( onClick = onRepairClick, enabled = enabled ) { diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/Sync360Surface.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/Sync360Surface.kt index 789f8a3..8df4267 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/Sync360Surface.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/Sync360Surface.kt @@ -1,21 +1,24 @@ package com.liftley.sync360.presentation.app.components +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color + @Composable fun Sync360Surface( modifier: Modifier = Modifier, containerColor: Color = MaterialTheme.colorScheme.surface, + shape: CornerBasedShape = MaterialTheme.shapes.large, content: @Composable (() -> Unit) ) { Surface( modifier = modifier, color = containerColor, - shape = MaterialTheme.shapes.large + shape = shape ) { content() } 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 index 8bf07b4..15a9b71 100644 --- 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 @@ -1,20 +1,23 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.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.material3.TextButton 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 @@ -25,12 +28,15 @@ fun FileOfferStateUi( onAccept: () -> Unit, onDecline: () -> Unit ) { - Box( + Column( modifier = Modifier - .padding(16.dp) .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface(modifier = Modifier.align(Alignment.Center)) { + Sync360Surface { Column( modifier = Modifier .fillMaxWidth() @@ -56,12 +62,12 @@ fun FileOfferStateUi( Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = "Transfer size", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -71,26 +77,35 @@ fun FileOfferStateUi( ) } } - } - } - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = onAccept, - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Text("Accept", style = MaterialTheme.typography.titleMedium) - } - TextButton( - onClick = onDecline, - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Text("Decline", 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 + ) + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedFilesStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedFilesStateUi.kt index 950bee4..1e2fb40 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedFilesStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivedFilesStateUi.kt @@ -1,8 +1,8 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -18,6 +20,7 @@ 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.TextOverflow import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Download import com.liftley.sync360.presentation.app.components.Sync360Surface @@ -29,13 +32,15 @@ fun ReceivedFilesStateUi( onOpenDownloads: () -> Unit, onDone: () -> Unit ) { - Box( + Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), - contentAlignment = Alignment.Center + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface(modifier = Modifier.align(Alignment.Center)) { + Sync360Surface { Column( modifier = Modifier .fillMaxWidth() @@ -68,34 +73,30 @@ fun ReceivedFilesStateUi( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - } - } - - Column( - modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = onDone, - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - ) { - Text("Done") - } - OutlinedButton( - onClick = onOpenDownloads, - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - ) { - Icon( - imageVector = Download, - contentDescription = null - ) - Spacer(Modifier.width(8.dp)) - Text("Open Downloads") + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onOpenDownloads, + modifier = Modifier.weight(1f).height(48.dp) + ) { + Icon( + imageVector = Download, + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text("Open Downloads", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = onDone, + modifier = Modifier.weight(1f).height(48.dp) + ) { + Text("Done") + } + } } } } 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 dc45c68..8303706 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 @@ -1,24 +1,26 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton 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.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Copy @@ -30,25 +32,64 @@ fun ReceivedTextStateUi( onCopyText: () -> Unit, onClear: () -> Unit ) { - Box( + Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), - contentAlignment = Alignment.Center + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface(modifier = Modifier.align(Alignment.Center)) { + Sync360Surface { Column( modifier = Modifier.padding(16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd + Sync360Surface(containerColor = MaterialTheme.colorScheme.surfaceContainer) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp) + ) { + Text( + "Text", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + Text( + text, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth(), + style = MaterialTheme.typography.titleMedium + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { OutlinedButton( + onClick = onClear, + modifier = Modifier.weight(1f).height(48.dp) + ) { + Text( + "Clear & Close", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.titleMedium + ) + } + + Button( onClick = onCopyText, + modifier = Modifier.weight(1f).height(48.dp) ) { Icon( imageVector = Copy, @@ -57,51 +98,12 @@ fun ReceivedTextStateUi( Spacer(Modifier.width(8.dp)) Text( "Copy", + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium ) } } - Text( - text, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth(), - style = MaterialTheme.typography.titleMedium - ) - } - } - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = onCopyText, - modifier = Modifier.height(48.dp).fillMaxWidth() - ) { - Icon( - imageVector = Copy, - contentDescription = null - ) - Spacer(Modifier.width(8.dp)) - Text( - "Copy", - style = MaterialTheme.typography.titleMedium - ) - } - - TextButton( - onClick = onClear, - modifier = Modifier.height(48.dp).fillMaxWidth() - ) { - Text( - "Clear", - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Normal, - style = MaterialTheme.typography.titleMedium - ) } } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivingFilesStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivingFilesStateUi.kt index d70e8f5..6e1956a 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivingFilesStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/ReceivingFilesStateUi.kt @@ -1,11 +1,12 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box 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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,19 +14,21 @@ 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.app.components.FileTransferProgressUi +import com.liftley.sync360.presentation.app.components.Sync360Surface import com.liftley.sync360.presentation.receive.model.ReceiveScreenState @Composable fun ReceivingFilesStateUi( state: ReceiveScreenState.ReceivingFiles ) { - Box( + Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), - contentAlignment = Alignment.Center + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { Sync360Surface { Column( 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 index d76e751..0eb0ea4 100644 --- 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 @@ -1,16 +1,18 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.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.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,12 +28,15 @@ fun TextOfferStateUi( onAccept: () -> Unit, onDecline: () -> Unit ) { - Box( + Column( modifier = Modifier - .padding(16.dp) .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface(modifier = Modifier.align(Alignment.Center)) { + Sync360Surface { Column( modifier = Modifier .padding(16.dp) @@ -58,8 +63,8 @@ fun TextOfferStateUi( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Preview", - style = MaterialTheme.typography.titleMedium, + "Text Preview", + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( @@ -70,25 +75,34 @@ fun TextOfferStateUi( ) } } - } - } - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = onAccept, - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Text("Accept", style = MaterialTheme.typography.titleMedium) - } - TextButton( - onClick = onDecline, - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Text("Decline", 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 + ) + } + } } } } 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 a86fa4e..cb75273 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 @@ -4,11 +4,15 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -26,6 +30,7 @@ import com.liftley.sync360.presentation.send.model.SendOperationState import com.liftley.sync360.presentation.send.model.SendTab import org.koin.compose.koinInject +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SendScreen( onTroubleshootClick: () -> Unit @@ -43,25 +48,43 @@ fun SendScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Sync360Surface { + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface + ) { + ButtonGroup( + overflowIndicator = { menuState -> + ButtonGroupDefaults.OverflowIndicator(menuState) + }, + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + toggleableItem( + checked = screenState.selectedTab == SendTab.Text, + label = "Text", + onCheckedChange = { checked -> + if (checked) sendScreenViewModel.onTabSelected(SendTab.Text) + }, + weight = 1f + ) + + toggleableItem( + checked = screenState.selectedTab == SendTab.Files, + label = "Files", + onCheckedChange = { checked -> + if (checked) sendScreenViewModel.onTabSelected(SendTab.Files) + }, + weight = 1f + ) + } + } + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface + ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - SecondaryTabRow( - selectedTabIndex = screenState.selectedTab.ordinal - ) { - SendTab.entries.forEach { tab -> - Tab( - selected = screenState.selectedTab == tab, - onClick = { sendScreenViewModel.onTabSelected(tab) }, - text = { Text(tab.name) } - ) - } - } - when (screenState.selectedTab) { SendTab.Text -> { TextSendContent( @@ -72,7 +95,12 @@ fun SendScreen( } SendTab.Files -> { - FilesSendContent() + FilesSendContent( + files = screenState.files, + onFilesSelected = sendScreenViewModel::handleFilesSelected, + onClearFiles = sendScreenViewModel::clearSelectedFiles, + onRemoveFile = sendScreenViewModel::removeSelectedFileFromList + ) } } } @@ -81,17 +109,19 @@ fun SendScreen( NearbyDevicesSection( screenState = screenState, onReloadClick = sendScreenViewModel::restartDiscoveryServices, - onDeviceClick = { device -> - if (screenState.selectedTab == SendTab.Text) { - sendScreenViewModel.sendTextToDevice(device.id) - } - - if (screenState.selectedTab == SendTab.Files) { - sendScreenViewModel.sendFilesToDevice(device.id) - } - } + onDeviceClick = { device -> sendScreenViewModel.onDeviceSelected(device.id) } ) + Button( + onClick = sendScreenViewModel::sendToSelectedDevice, + enabled = screenState.canSend, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + ) { + Text(screenState.sendButtonLabel) + } + TextButton(onClick = onTroubleshootClick) { Text("Troubleshoot") } 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 85fa6ce..a94069a 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 @@ -36,10 +36,6 @@ class SendScreenViewModel( private var activeSendJob: Job? = null init { - viewModelScope.launch { - networkServicesController.startNetworkServices() - } - viewModelScope.launch { networkServicesController.nearbyDevices.collect { devices -> latestNearbyDevices = devices @@ -48,6 +44,9 @@ class SendScreenViewModel( it.copy( nearbyDevices = devices.map { device -> device.toNearbyDeviceUiModel() + }, + selectedDeviceId = it.selectedDeviceId?.takeIf { selectedId -> + devices.any { device -> device.id == selectedId } } ) } @@ -61,6 +60,14 @@ class SendScreenViewModel( } } } + + viewModelScope.launch { + networkServicesController.registrationServiceStatus.collect { status -> + _screenState.update { + it.copy(registrationStatus = status) + } + } + } } @@ -230,6 +237,26 @@ class SendScreenViewModel( } } + fun onDeviceSelected(deviceId: String) { + if (latestNearbyDevices.none { it.id == deviceId }) return + + _screenState.update { + it.copy(selectedDeviceId = deviceId) + } + } + + fun sendToSelectedDevice() { + val state = _screenState.value + if (!state.canSend) return + + val deviceId = state.selectedDeviceId ?: return + + when (state.selectedTab) { + SendTab.Text -> sendTextToDevice(deviceId) + SendTab.Files -> sendFilesToDevice(deviceId) + } + } + fun clearSendOperation() { _screenState.update { it.copy(sendOperationState = SendOperationState.Idle) @@ -238,12 +265,14 @@ class SendScreenViewModel( fun handleFilesSelected(rawPlatformFiles: List) { viewModelScope.launch { - val parsedFiles = withContext(Dispatchers.IO) { + val parsedFiles = withContext(Dispatchers.Default) { selectedFileReader.readSelectedFiles(rawPlatformFiles) } _screenState.update { currentState -> - currentState.copy(files = currentState.files + parsedFiles) + currentState.copy( + files = (currentState.files + parsedFiles).distinctBy { it.uri } + ) } } } @@ -254,9 +283,10 @@ class SendScreenViewModel( } } - fun removeSelectedFileFromList(file: SelectedFile){ + fun removeSelectedFileFromList(file: SelectedFile) { _screenState.update { currentState -> - currentState.copy(files = currentState.files - file) + val remainingFiles = currentState.files - file + currentState.copy(files = remainingFiles) } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileItemCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileItemCard.kt deleted file mode 100644 index fdbbf4a..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileItemCard.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.liftley.sync360.presentation.send.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -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.TextOverflow -import androidx.compose.ui.unit.dp -import com.liftley.sync360.core.designsystem.icons.Close -import com.liftley.sync360.presentation.app.components.Sync360Surface -import com.liftley.sync360.domain.model.SelectedFile - -@Composable -fun FileItemCard(file: SelectedFile, onRemoveClick: (SelectedFile) -> Unit) { - Sync360Surface(containerColor = MaterialTheme.colorScheme.surface) { - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - file.displayName.formatDisplayName(), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - IconButton( - onClick = { onRemoveClick(file) }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Icon(imageVector = Close, contentDescription = null) - } - } - } -} - -fun String.formatDisplayName(): String { - // If the name is already short, don't change anything - if (this.length <= 20) return this - - // Find the last dot to isolate the extension - val dotIndex = this.lastIndexOf('.') - - // Edge case: No extension found or dot is at the very beginning/end - if (dotIndex <= 0 || dotIndex >= this.length - 1) { - return this.take(20) + "..." - } - - val nameWithoutExtension = this.substring(0, dotIndex) - val extension = this.substring(dotIndex) // Includes the dot (e.g., ".pdf") - - // Take the first 20 characters of the name, add ellipsis, and paste the extension - val truncatedName = nameWithoutExtension.take(20) - - return "$truncatedName...$extension" -} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileSelectionContent.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileSelectionContent.kt new file mode 100644 index 0000000..5e2cc10 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FileSelectionContent.kt @@ -0,0 +1,230 @@ +package com.liftley.sync360.presentation.send.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.liftley.sync360.core.designsystem.icons.Close +import com.liftley.sync360.domain.model.SelectedFile +import com.liftley.sync360.presentation.app.components.Sync360Surface + +private val ImageFileExtensions = setOf( + "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "webp" +) + +// Fixed size tiers — never smaller than Compact, never bigger than Expanded. +private val CompactThumbnailSize = 128.dp // phones +private val MediumThumbnailSize = 160.dp // tablets (portrait) / small desktop windows +private val ExpandedThumbnailSize = 192.dp // tablets (landscape) / desktop + +private fun thumbnailSizeFor(maxWidth: Dp): Dp = when { + maxWidth < 600.dp -> CompactThumbnailSize + maxWidth < 1024.dp -> MediumThumbnailSize + else -> ExpandedThumbnailSize +} + +@Composable +internal fun FileSelectionContent( + files: List, + onClearFiles: () -> Unit, + onRemoveFile: (SelectedFile) -> Unit, + onPickMedia: (() -> Unit)?, + onPickFiles: () -> Unit +) { + BoxWithConstraints { + val thumbnailSize = thumbnailSizeFor(maxWidth) + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Files to send", style = MaterialTheme.typography.titleLarge) + if (files.isNotEmpty()) { + TextButton(onClick = onClearFiles) { + Text("Clear all") + } + } + } + + if (files.isNotEmpty()) { + Sync360Surface( + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "${files.size} ${if (files.size == 1) "file" else "files"} selected", + style = MaterialTheme.typography.titleMedium + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = files, key = { it.uri }) { file -> + SelectedFilePreview( + file = file, + onRemoveFile = onRemoveFile, + size = thumbnailSize + ) + } + } + } + } + } + + FilePickerActions( + hasSelectedFiles = files.isNotEmpty(), + onPickMedia = onPickMedia, + onPickFiles = onPickFiles + ) + } + } +} + +@Composable +internal fun SelectedFilePreview( + file: SelectedFile, + onRemoveFile: (file: SelectedFile) -> Unit, + size: Dp, + contentDescription: String? = null +) { + Box( + modifier = Modifier + .size(size) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surface) + ) { + if (file.isImageFile()) { + AsyncImage( + model = filePreviewModel(file), + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size) + ) + } else { + Text( + text = fileTypeLabel(file), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier.align(Alignment.Center) + ) + } + + // Filename overlaid at the bottom, scrim behind it — same pattern as the recents view. + Text( + text = truncateKeepingExtension(file.displayName), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + + IconButton( + onClick = { onRemoveFile(file) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(24.dp) + ) { + Icon( + imageVector = Close, + contentDescription = "Remove ${file.displayName}" + ) + } + } +} + +@Composable +private fun FilePickerActions( + hasSelectedFiles: Boolean, + onPickMedia: (() -> Unit)?, + onPickFiles: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (onPickMedia != null) { + Button(onClick = onPickMedia, modifier = Modifier.weight(1f)) { + Text("Select Media", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onPickFiles, modifier = Modifier.weight(1f)) { + Text("Select docs", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + FilledTonalButton(onClick = onPickFiles, modifier = Modifier.fillMaxWidth()) { + Text(if (hasSelectedFiles) "Add more files" else "Select files") + } + } + } +} + +private fun SelectedFile.isImageFile(): Boolean { + if (mimeType?.startsWith("image/") == true) return true + return displayName.substringAfterLast('.', missingDelimiterValue = "").lowercase() in ImageFileExtensions +} + +private fun fileTypeLabel(file: SelectedFile): String { + val extension = file.displayName.substringAfterLast('.', missingDelimiterValue = "") + if (extension.isNotBlank()) return extension.take(4).uppercase() + return when { + file.mimeType?.startsWith("video/") == true -> "VIDEO" + file.mimeType?.startsWith("audio/") == true -> "AUDIO" + else -> "FILE" + } +} + +private fun truncateKeepingExtension(name: String, maxBaseChars: Int = 12): String { + val dotIndex = name.lastIndexOf('.') + if (dotIndex <= 0 || dotIndex == name.length - 1) return name // no real extension + + val base = name.substring(0, dotIndex) + val extension = name.substring(dotIndex) // includes the dot, e.g. ".pdf" + + return if (base.length > maxBaseChars) { + "${base.take(maxBaseChars)}…$extension" + } else { + name + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.kt index d273552..021e1e2 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.kt @@ -1,6 +1,14 @@ package com.liftley.sync360.presentation.send.components import androidx.compose.runtime.Composable +import com.liftley.sync360.domain.model.SelectedFile @Composable -expect fun FilesSendContent() \ No newline at end of file +expect fun FilesSendContent( + files: List, + onFilesSelected: (List) -> Unit, + onClearFiles: () -> Unit, + onRemoveFile: (SelectedFile) -> Unit +) + +internal expect fun filePreviewModel(file: SelectedFile): Any diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceCard.kt index c0bd0c0..6674e71 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceCard.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceCard.kt @@ -1,22 +1,21 @@ package com.liftley.sync360.presentation.send.components -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Android @@ -25,6 +24,7 @@ import com.liftley.sync360.core.designsystem.icons.Tv import com.liftley.sync360.presentation.app.components.Sync360Surface import com.liftley.sync360.presentation.send.model.NearbyDeviceUiModel +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Preview @Composable fun NearbyDeviceCard( @@ -39,55 +39,44 @@ fun NearbyDeviceCard( serviceName = "Chromecast-Ultra-Stream", serviceType = "_googlecast._tcp.local." ), + selected: Boolean = false, onClick: () -> Unit = {} ) { - Box( - Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .clickable { onClick() } - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center + Sync360Surface( + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + shape = MaterialTheme.shapes.extraExtraLarge ) { Row( modifier = Modifier .fillMaxWidth() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton + ) .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { - when (device.deviceType) { - "Android" -> { - Sync360Surface(containerColor = MaterialTheme.colorScheme.surface) { - Icon( - imageVector = Android, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp).padding(8.dp) - ) - } - } - "Desktop" -> { - Sync360Surface(containerColor = MaterialTheme.colorScheme.surface) { - Icon( - imageVector = Desktop, - contentDescription = null, - modifier = Modifier.size(48.dp).padding(8.dp) - ) - } - } - "Tv" -> { - Sync360Surface(containerColor = MaterialTheme.colorScheme.surface) { - Icon( - imageVector = Tv, - contentDescription = null, - modifier = Modifier.size(48.dp).padding(8.dp) - ) - } + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface + ) { + val deviceIcon = when (device.deviceType) { + "Android" -> Android + "Tv" -> Tv + else -> Desktop } + Icon( + imageVector = deviceIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp).padding(8.dp) + ) } Column( + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -96,11 +85,16 @@ fun NearbyDeviceCard( ) Text( - "IP and Port: ${device.hostAddresses.first()}:${device.port}", + "Available nearby", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + RadioButton( + selected = selected, + onClick = null + ) } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceEmptyCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceEmptyCard.kt index a678d3f..e0f5119 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceEmptyCard.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceEmptyCard.kt @@ -1,21 +1,17 @@ package com.liftley.sync360.presentation.send.components -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.liftley.sync360.domain.model.DiscoveryStatus @@ -25,32 +21,31 @@ import com.liftley.sync360.domain.model.DiscoveryStatus @Composable fun NearbyDeviceEmptyCard( status: DiscoveryStatus = DiscoveryStatus.Running, + reloadEnabled: Boolean = false, onReloadClick: () -> Unit = {} ) { val title = when (status) { - DiscoveryStatus.Idle -> "Stopped Nearby Scanning" - DiscoveryStatus.Starting -> "Starting Discovery" - DiscoveryStatus.Running -> "Looking for Devices" - DiscoveryStatus.Stopping -> "Stopping Discovery" + DiscoveryStatus.Idle -> "Scanning stopped" + DiscoveryStatus.Starting -> "Starting discovery" + DiscoveryStatus.Running -> "Looking for devices" + DiscoveryStatus.Stopping -> "Stopping discovery" } val subtitle = when (status) { - DiscoveryStatus.Idle -> "Tap to rescan" + DiscoveryStatus.Idle -> { + if (reloadEnabled) "Tap to rescan" else "Use connection repair in Settings" + } DiscoveryStatus.Starting -> "Preparing nearby scan" DiscoveryStatus.Running -> "Keep both devices on the same Wi-Fi" DiscoveryStatus.Stopping -> "Cleaning up current scan" } - Box( - Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .clickable( - enabled = status == DiscoveryStatus.Idle, - onClick = onReloadClick - ) - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center + Surface( + onClick = onReloadClick, + enabled = reloadEnabled, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer ) { Column( modifier = Modifier.padding(8.dp), @@ -60,7 +55,6 @@ fun NearbyDeviceEmptyCard( when(status) { DiscoveryStatus.Starting -> LoadingIndicator() DiscoveryStatus.Stopping -> LoadingIndicator() - DiscoveryStatus.Running -> CircularWavyProgressIndicator() else -> {} } Text( @@ -75,4 +69,4 @@ fun NearbyDeviceEmptyCard( ) } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt index f25789a..eb2a42a 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt @@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -17,17 +20,25 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Reload import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.presentation.app.components.Sync360Surface import com.liftley.sync360.presentation.send.model.NearbyDeviceUiModel import com.liftley.sync360.presentation.send.model.SendScreenState +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun NearbyDevicesSection( screenState: SendScreenState, onReloadClick: () -> Unit, onDeviceClick: (NearbyDeviceUiModel) -> Unit ) { - Sync360Surface { + val reloadEnabled = + screenState.discoveryStatus == DiscoveryStatus.Idle && + screenState.registrationStatus == RegistrationStatus.Running + + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface + ) { Column( modifier = Modifier .fillMaxWidth() @@ -40,36 +51,60 @@ fun NearbyDevicesSection( verticalAlignment = Alignment.CenterVertically ) { Text( - "Nearby Devices", + "Nearby devices", style = MaterialTheme.typography.titleLarge ) - IconButton( - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier.height(48.dp), - enabled = screenState.discoveryStatus == DiscoveryStatus.Idle, - onClick = onReloadClick + if (screenState.discoveryStatus == DiscoveryStatus.Running) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularWavyProgressIndicator(modifier = Modifier.size(24.dp)) + Text( + "Scanning", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + IconButton( + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + enabled = reloadEnabled, + onClick = onReloadClick + ) { + Icon( + imageVector = Reload, + contentDescription = "Scan again" + ) + } + } + } + + if (screenState.nearbyDevices.isNotEmpty()) { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - imageVector = Reload, - contentDescription = null - ) + screenState.nearbyDevices.forEach { device -> + NearbyDeviceCard( + device = device, + selected = screenState.selectedDeviceId == device.id, + onClick = { onDeviceClick(device) } + ) + } } } - screenState.nearbyDevices.forEach { device -> - NearbyDeviceCard( - device = device, - onClick = { onDeviceClick(device) } + if (screenState.nearbyDevices.isEmpty()) { + NearbyDeviceEmptyCard( + status = screenState.discoveryStatus, + reloadEnabled = reloadEnabled, + onReloadClick = onReloadClick ) } - - NearbyDeviceEmptyCard( - status = screenState.discoveryStatus, - onReloadClick = onReloadClick - ) } } } 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 b57b662..a6bbd3d 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 @@ -1,13 +1,15 @@ package com.liftley.sync360.presentation.send.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer 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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -23,8 +25,8 @@ import androidx.compose.ui.unit.dp import com.liftley.sync360.core.designsystem.icons.Close import com.liftley.sync360.core.designsystem.icons.Send import com.liftley.sync360.domain.model.FileTransferProgress -import com.liftley.sync360.presentation.app.components.Sync360Surface import com.liftley.sync360.presentation.app.components.FileTransferProgressUi +import com.liftley.sync360.presentation.app.components.Sync360Surface import com.liftley.sync360.presentation.send.model.SendOperationState @Composable @@ -101,18 +103,21 @@ private fun SendingOperationUi( transferProgress: FileTransferProgress? = null, onCancel: () -> Unit ) { - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface(modifier = Modifier.align(Alignment.Center)) { + Sync360Surface { Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = message, @@ -144,9 +149,11 @@ private fun SendingOperationUi( } } + Spacer(Modifier.padding(16.dp)) + OutlinedButton( onClick = onCancel, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier .fillMaxWidth() .height(48.dp) ) { @@ -161,14 +168,15 @@ private fun SendResultUi( wasSuccessful: Boolean, onDone: () -> Unit ) { - Box(modifier = Modifier - .fillMaxSize() - .padding(16.dp) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Sync360Surface( - modifier = Modifier - .align(Alignment.Center) - ) { + Sync360Surface { Column( modifier = Modifier .fillMaxWidth() @@ -196,9 +204,12 @@ private fun SendResultUi( ) } } + + Spacer(Modifier.padding(16.dp)) + Button( onClick = onDone, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier .fillMaxWidth() .height(48.dp) ) { 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 3689ed7..4e19903 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 @@ -1,20 +1,17 @@ package com.liftley.sync360.presentation.send.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField 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.core.designsystem.icons.Close @Composable fun TextSendContent( @@ -27,29 +24,30 @@ fun TextSendContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - "Selected Text", - style = MaterialTheme.typography.titleLarge - ) + Text("Text to send", style = MaterialTheme.typography.titleLarge) if (textInput.isNotEmpty()) { - IconButton( - onClick = onClearText, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier.height(48.dp) - ) { - Icon(imageVector = Close, contentDescription = null) + TextButton(onClick = onClearText) { + Text("Clear", style = MaterialTheme.typography.titleMedium) } } } - OutlinedTextField( - value = textInput, - onValueChange = onTextChange, - label = { Text("Add text to send") }, - maxLines = 5, - shape = MaterialTheme.shapes.large, - modifier = Modifier.fillMaxWidth() - ) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = textInput, + onValueChange = onTextChange, + label = { Text("Message") }, + placeholder = { Text("Type or paste text here") }, + minLines = 5, + maxLines = 5, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = "${textInput.length} characters", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } 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 6b520cf..e28b8f3 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 @@ -1,6 +1,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 data class SendScreenState( @@ -9,5 +10,33 @@ data class SendScreenState( val files: List = emptyList(), val sendOperationState: SendOperationState = SendOperationState.Idle, val nearbyDevices: List = emptyList(), - val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle -) + val selectedDeviceId: String? = null, + val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle, + val registrationStatus: RegistrationStatus = RegistrationStatus.Idle +) { + val selectedDevice: NearbyDeviceUiModel? + get() = nearbyDevices.firstOrNull { it.id == selectedDeviceId } + + val canSend: Boolean + get() = selectedDevice != null && when (selectedTab) { + SendTab.Text -> textInput.isNotBlank() + SendTab.Files -> files.isNotEmpty() + } + + val sendButtonLabel: String + get() { + val device = selectedDevice ?: return "Select a nearby device" + val contentName = when (selectedTab) { + SendTab.Text -> { + if (textInput.isBlank()) return "Enter text to send" + "text" + } + SendTab.Files -> { + val count = files.size + if (count == 0) return "Add files to send" + "$count ${if (count == 1) "file" else "files"}" + } + } + return "Send $contentName to ${device.deviceName}" + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/MainViewController.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/MainViewController.kt index 36f7b75..de86a54 100644 --- a/shared/src/iosMain/kotlin/com/liftley/sync360/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/MainViewController.kt @@ -1,5 +1,35 @@ package com.liftley.sync360 import androidx.compose.ui.window.ComposeUIViewController +import com.liftley.sync360.core.designsystem.Sync360Theme +import com.liftley.sync360.core.di.initKoinSync360 +import com.liftley.sync360.core.di.iosModule +import com.liftley.sync360.core.platform.IosViewControllerProvider +import com.liftley.sync360.data.NetworkServicesController +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIViewController -fun MainViewController() = ComposeUIViewController { App(isDesktop = false) } \ No newline at end of file +private val iosKoinApplication by lazy { + initKoinSync360(iosModule) {} + .also { koinApplication -> + koinApplication.koin + .get() + .startNetworkServices() + } +} + +@OptIn(ExperimentalForeignApi::class) +fun MainViewController(): UIViewController { + val koinApplication = iosKoinApplication + val composeViewController = ComposeUIViewController { + Sync360Theme { + Sync360Root() + } + } + + koinApplication.koin + .get() + .attach(composeViewController) + + return composeViewController +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/core/designsystem/Theme.ios.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/core/designsystem/Theme.ios.kt index ef05697..43d946f 100644 --- a/shared/src/iosMain/kotlin/com/liftley/sync360/core/designsystem/Theme.ios.kt +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/core/designsystem/Theme.ios.kt @@ -4,7 +4,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @Composable -actual fun AppTheme( +actual fun Sync360Theme( darkTheme: Boolean, dynamicColor: Boolean, content: @Composable () -> Unit @@ -14,7 +14,7 @@ actual fun AppTheme( MaterialTheme( colorScheme = colorScheme, typography = appTypography(), - shapes = AppShapes, + shapes = Sync360Shapes, content = content ) } diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/core/di/koin.ios.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/core/di/koin.ios.kt new file mode 100644 index 0000000..9207459 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/core/di/koin.ios.kt @@ -0,0 +1,39 @@ +package com.liftley.sync360.core.di + +import com.liftley.sync360.core.platform.IosViewControllerProvider +import com.liftley.sync360.data.file.IosDocumentsStorage +import com.liftley.sync360.data.file.IosDownloadsFolderOpener +import com.liftley.sync360.data.file.IosSelectedFileReader +import com.liftley.sync360.data.file.SelectedFileReader +import com.liftley.sync360.data.local.IosClipboardProvider +import com.liftley.sync360.data.local.IosLocalDeviceIdentityStore +import com.liftley.sync360.data.local.IosLocalDeviceInfoProvider +import com.liftley.sync360.data.network.discovery.IosNetworkServices +import com.liftley.sync360.data.network.tcp.FileTransferReceiver +import com.liftley.sync360.data.network.tcp.FileTransferSender +import com.liftley.sync360.data.network.tcp.IosFileTransferReceiver +import com.liftley.sync360.data.network.tcp.IosFileTransferSender +import com.liftley.sync360.domain.local.LocalDeviceIdentityStore +import com.liftley.sync360.domain.local.LocalDeviceInfoProvider +import com.liftley.sync360.domain.repository.ClipboardProvider +import com.liftley.sync360.domain.repository.DownloadsFolderOpener +import com.liftley.sync360.domain.service.NetworkServices +import org.koin.dsl.module + +val iosModule = module { + single { IosClipboardProvider() } + single { IosViewControllerProvider() } + single { IosDownloadsFolderOpener(get()) } + single { IosSelectedFileReader() } + single { IosDocumentsStorage() } + single { IosFileTransferSender() } + single { IosFileTransferReceiver(get()) } + + single { IosLocalDeviceIdentityStore() } + single { + IosLocalDeviceInfoProvider( + deviceUuid = get().getOrCreateDeviceUuid() + ) + } + single { IosNetworkServices(get()) } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/core/platform/IosViewControllerProvider.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/core/platform/IosViewControllerProvider.kt new file mode 100644 index 0000000..1c4736e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/core/platform/IosViewControllerProvider.kt @@ -0,0 +1,14 @@ +package com.liftley.sync360.core.platform + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIViewController + +@OptIn(ExperimentalForeignApi::class) +class IosViewControllerProvider { + var rootViewController: UIViewController? = null + private set + + fun attach(rootViewController: UIViewController) { + this.rootViewController = rootViewController + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDocumentsStorage.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDocumentsStorage.kt new file mode 100644 index 0000000..6b744f1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDocumentsStorage.kt @@ -0,0 +1,165 @@ +package com.liftley.sync360.data.file + +import com.liftley.sync360.data.network.tcp.FileTransferConstants +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.withTimeout +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.Foundation.NSFileManager +import platform.Foundation.NSOutputStream +import platform.Foundation.NSURL +import platform.Foundation.NSUUID + +@OptIn(ExperimentalForeignApi::class) +class IosDocumentsStorage { + suspend fun writeFile( + fileName: String, + fileSizeBytes: Long, + input: ByteReadChannel, + onBytesWritten: (byteCount: Int) -> Unit + ) { + val fileManager = NSFileManager.defaultManager + val documentsUrl = iosDownloadsDirectoryUrl() + + val safeFileName = safeFileName(fileName) + val temporaryUrl = documentsUrl.URLByAppendingPathComponent( + ".sync360-${NSUUID().UUIDString}.part" + ) ?: error("Could not create a temporary file URL") + try { + val output = NSOutputStream( + uRL = temporaryUrl, + append = false + ) + output.open() + + try { + val buffer = ByteArray(FileTransferConstants.PAYLOAD_BUFFER_SIZE_BYTES) + var bytesRemaining = fileSizeBytes + + while (bytesRemaining > 0) { + val bytesRequested = minOf( + buffer.size.toLong(), + bytesRemaining + ).toInt() + val bytesRead = withTimeout( + FileTransferConstants.SOCKET_TIMEOUT_MILLIS.toLong() + ) { + input.readAvailable( + buffer = buffer, + offset = 0, + length = bytesRequested + ) + } + + if (bytesRead == -1) { + error("Connection ended before $safeFileName was complete") + } + if (bytesRead == 0) { + continue + } + + writeFully(output, buffer, bytesRead) + bytesRemaining -= bytesRead + onBytesWritten(bytesRead) + } + } finally { + output.close() + } + + val destinationUrl = availableDestination( + documentsUrl = documentsUrl, + fileName = safeFileName + ) + check( + fileManager.moveItemAtURL( + srcURL = temporaryUrl, + toURL = destinationUrl, + error = null + ) + ) { + "Could not move $safeFileName into the iOS Documents directory" + } + } catch (exception: Throwable) { + fileManager.removeItemAtURL(temporaryUrl, error = null) + throw exception + } + } + + private fun writeFully( + output: NSOutputStream, + buffer: ByteArray, + byteCount: Int + ) { + buffer.usePinned { pinnedBuffer -> + var offset = 0 + + while (offset < byteCount) { + val written = output.write( + buffer = pinnedBuffer.addressOf(offset).reinterpret(), + maxLength = (byteCount - offset).toULong() + ) + + if (written <= 0) { + error( + output.streamError?.localizedDescription + ?: "Could not write the received file" + ) + } + + offset += written.toInt() + } + } + } + + private fun availableDestination( + documentsUrl: NSURL, + fileName: String + ): NSURL { + val fileManager = NSFileManager.defaultManager + val requestedUrl = documentsUrl.URLByAppendingPathComponent(fileName) + ?: error("Could not create the destination file URL") + val requestedPath = requestedUrl.path + ?: error("Could not create the destination file path") + + if (!fileManager.fileExistsAtPath(requestedPath)) { + return requestedUrl + } + + val extensionIndex = fileName.lastIndexOf('.') + val hasExtension = extensionIndex > 0 + val nameWithoutExtension = if (hasExtension) { + fileName.substring(0, extensionIndex) + } else { + fileName + } + val extension = if (hasExtension) fileName.substring(extensionIndex) else "" + + var copyNumber = 1 + while (true) { + val candidateUrl = documentsUrl.URLByAppendingPathComponent( + "$nameWithoutExtension ($copyNumber)$extension" + ) ?: error("Could not create the destination file URL") + val candidatePath = candidateUrl.path + ?: error("Could not create the destination file path") + + if (!fileManager.fileExistsAtPath(candidatePath)) { + return candidateUrl + } + + copyNumber++ + } + } + + private fun safeFileName(fileName: String): String { + val leafName = fileName + .substringAfterLast('/') + .substringAfterLast('\\') + .trim() + + return leafName.takeUnless { it.isBlank() || it == "." || it == ".." } + ?: "received_file" + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsDirectory.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsDirectory.kt new file mode 100644 index 0000000..53384ee --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsDirectory.kt @@ -0,0 +1,34 @@ +package com.liftley.sync360.data.file + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +@OptIn(ExperimentalForeignApi::class) +internal fun iosDownloadsDirectoryUrl(): NSURL { + val fileManager = NSFileManager.defaultManager + val documentsUrl = fileManager.URLsForDirectory( + directory = NSDocumentDirectory, + inDomains = NSUserDomainMask + ).firstOrNull() as? NSURL + ?: error("The iOS Documents directory is unavailable") + val downloadsUrl = documentsUrl.URLByAppendingPathComponent( + pathComponent = "Downloads", + isDirectory = true + ) ?: error("Could not create the iOS Downloads directory URL") + + check( + fileManager.createDirectoryAtURL( + url = downloadsUrl, + withIntermediateDirectories = true, + attributes = null, + error = null + ) + ) { + "Could not create the iOS Downloads directory" + } + + return downloadsUrl +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsFolderOpener.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsFolderOpener.kt new file mode 100644 index 0000000..3dfe71e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosDownloadsFolderOpener.kt @@ -0,0 +1,36 @@ +package com.liftley.sync360.data.file + +import com.liftley.sync360.core.platform.IosViewControllerProvider +import com.liftley.sync360.domain.repository.DownloadsFolderOpener +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIDocumentPickerViewController +import platform.UniformTypeIdentifiers.UTTypeItem + +@OptIn(ExperimentalForeignApi::class) +class IosDownloadsFolderOpener( + private val viewControllerProvider: IosViewControllerProvider +) : DownloadsFolderOpener { + override fun openDownloads() { + val downloadsUrl = runCatching { + iosDownloadsDirectoryUrl() + }.getOrNull() ?: return + var presentingViewController = + viewControllerProvider.rootViewController ?: return + while (presentingViewController.presentedViewController != null) { + presentingViewController = + presentingViewController.presentedViewController ?: break + } + val documentPicker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypeItem), + asCopy = false + ).apply { + directoryURL = downloadsUrl + } + + presentingViewController.presentViewController( + viewControllerToPresent = documentPicker, + animated = true, + completion = null + ) + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosSelectedFileReader.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosSelectedFileReader.kt new file mode 100644 index 0000000..1496514 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/file/IosSelectedFileReader.kt @@ -0,0 +1,45 @@ +package com.liftley.sync360.data.file + +import com.liftley.sync360.domain.model.SelectedFile +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileSize +import platform.Foundation.NSNumber +import platform.Foundation.NSURL + +@OptIn(ExperimentalForeignApi::class) +class IosSelectedFileReader : SelectedFileReader { + override fun readSelectedFiles(platformFiles: List): List { + return platformFiles + .filterIsInstance() + .mapNotNull { url -> + val uri = url.absoluteString ?: return@mapNotNull null + val fileName = url.lastPathComponent + ?.takeIf { it.isNotBlank() } + ?: "Unknown_File" + val hasSecurityScopedAccess = url.startAccessingSecurityScopedResource() + + try { + val fileSizeBytes = url.path + ?.let { path -> + NSFileManager.defaultManager + .attributesOfItemAtPath(path, error = null) + ?.get(NSFileSize) + } + .let { it as? NSNumber } + ?.longLongValue + + SelectedFile( + uri = uri, + displayName = fileName, + sizeBytes = fileSizeBytes, + mimeType = null + ) + } finally { + if (hasSecurityScopedAccess) { + url.stopAccessingSecurityScopedResource() + } + } + } + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosClipboardProvider.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosClipboardProvider.kt new file mode 100644 index 0000000..62d4832 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosClipboardProvider.kt @@ -0,0 +1,18 @@ +package com.liftley.sync360.data.local + +import com.liftley.sync360.domain.repository.ClipboardProvider +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIPasteboard + +@OptIn(ExperimentalForeignApi::class) +class IosClipboardProvider : ClipboardProvider { + private val pasteboard = UIPasteboard.generalPasteboard + + override fun provideLatestClipboard(): String? { + return pasteboard.string + } + + override fun setLatestClipboardTextAs(text: String) { + pasteboard.string = text + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceIdentityStore.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceIdentityStore.kt new file mode 100644 index 0000000..7989f64 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceIdentityStore.kt @@ -0,0 +1,25 @@ +package com.liftley.sync360.data.local + +import com.liftley.sync360.domain.local.LocalDeviceIdentityStore +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSUUID +import platform.Foundation.NSUserDefaults + +@OptIn(ExperimentalForeignApi::class) +class IosLocalDeviceIdentityStore : LocalDeviceIdentityStore { + private val userDefaults = NSUserDefaults.standardUserDefaults + + override fun getOrCreateDeviceUuid(): String { + val existingDeviceUuid = userDefaults.stringForKey(DEVICE_UUID_KEY) + if (existingDeviceUuid != null) return existingDeviceUuid + + val createdDeviceUuid = NSUUID().UUIDString + userDefaults.setObject(createdDeviceUuid, forKey = DEVICE_UUID_KEY) + + return createdDeviceUuid + } + + private companion object { + const val DEVICE_UUID_KEY = "sync360_device_uuid" + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceInfoProvider.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceInfoProvider.kt new file mode 100644 index 0000000..330c59b --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/local/IosLocalDeviceInfoProvider.kt @@ -0,0 +1,26 @@ +package com.liftley.sync360.data.local + +import com.liftley.sync360.domain.local.LocalDeviceInfoProvider +import com.liftley.sync360.domain.model.LocalDeviceInfo +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIDevice + +@OptIn(ExperimentalForeignApi::class) +class IosLocalDeviceInfoProvider( + private val deviceUuid: String +) : LocalDeviceInfoProvider { + private val localDeviceInfo = run { + val deviceName = UIDevice.currentDevice.name + .trim() + .ifBlank { UIDevice.currentDevice.model } + + LocalDeviceInfo( + deviceId = deviceUuid, + deviceName = deviceName, + deviceType = "iOS", + protocolVersion = "1" + ) + } + + override fun getLocalDeviceInfo(): LocalDeviceInfo = localDeviceInfo +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/discovery/IosNetworkServices.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/discovery/IosNetworkServices.kt new file mode 100644 index 0000000..d0feada --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/discovery/IosNetworkServices.kt @@ -0,0 +1,789 @@ +package com.liftley.sync360.data.network.discovery + +import com.liftley.sync360.domain.local.LocalDeviceInfoProvider +import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.RegistrationStatus +import com.liftley.sync360.domain.service.NetworkServices +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.pointed +import kotlinx.cinterop.ptr +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.Foundation.NSLock +import platform.darwin.DNSServiceBrowse +import platform.darwin.DNSServiceGetAddrInfo +import platform.darwin.DNSServiceRef +import platform.darwin.DNSServiceRefDeallocate +import platform.darwin.DNSServiceRefVar +import platform.darwin.DNSServiceRegister +import platform.darwin.DNSServiceResolve +import platform.darwin.DNSServiceSetDispatchQueue +import platform.darwin.TXTRecordCreate +import platform.darwin.TXTRecordDeallocate +import platform.darwin.TXTRecordGetBytesPtr +import platform.darwin.TXTRecordGetLength +import platform.darwin.TXTRecordGetValuePtr +import platform.darwin.TXTRecordRef +import platform.darwin.TXTRecordSetValue +import platform.darwin.dispatch_queue_create +import platform.darwin.kDNSServiceErr_NoError +import platform.darwin.kDNSServiceFlagsAdd +import platform.darwin.kDNSServiceProtocol_IPv4 +import platform.darwin.kDNSServiceProtocol_IPv6 +import platform.posix.NI_MAXHOST +import platform.posix.NI_NUMERICHOST +import platform.posix.getnameinfo +import platform.posix.sockaddr + +@OptIn(ExperimentalForeignApi::class) +class IosNetworkServices( + private val localDeviceInfoProvider: LocalDeviceInfoProvider +) : NetworkServices { + private val localDevice = localDeviceInfoProvider.getLocalDeviceInfo() + private val stateLock = NSLock() + + // This Koin singleton and its native callbacks intentionally share the app-process lifetime. + private val callbackContext = StableRef.create(this) + private val callbackQueue = checkNotNull( + dispatch_queue_create( + "com.liftley.sync360.bonjour", + null + ) + ) + + private val _nearbyDevices = MutableStateFlow>(emptyList()) + override val nearbyDevices: StateFlow> = _nearbyDevices.asStateFlow() + + private val _discoveryServiceStatus = MutableStateFlow(DiscoveryStatus.Idle) + override val discoveryServiceStatus: StateFlow = + _discoveryServiceStatus.asStateFlow() + + private val _registrationServiceStatus = MutableStateFlow(RegistrationStatus.Idle) + override val registrationServiceStatus: StateFlow = + _registrationServiceStatus.asStateFlow() + + private var browseRef: DNSServiceRef? = null + private var registrationRef: DNSServiceRef? = null + private val serviceDetailsByKey = mutableMapOf() + private val resolveRefsByKey = mutableMapOf() + private val addressRefsByKey = mutableMapOf() + private var pendingRepair: PendingRepair? = null + + override suspend fun startNetworkServices( + httpServerPort: Int, + fileTransferPort: Int + ) { + locked { + startDiscoveryService() + startRegistrationService(httpServerPort, fileTransferPort) + } + } + + override suspend fun repairNetworkServices( + httpServerPort: Int, + fileTransferPort: Int + ) { + locked { + if (!servicesAreStable()) return@locked + + pendingRepair = PendingRepair(httpServerPort, fileTransferPort) + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + stopDiscoveryService() + } + if (registrationServiceStatus.value == RegistrationStatus.Running) { + stopRegistrationService() + } + continuePendingRepairIfReady() + } + } + + override fun restartDiscoveryServices() { + locked { + if ( + discoveryServiceStatus.value != DiscoveryStatus.Idle || + registrationServiceStatus.value != RegistrationStatus.Running + ) { + return@locked + } + + clearDiscoveredServices() + startDiscoveryService() + } + } + + override fun stopDiscoveryServices() { + locked { + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + stopDiscoveryService() + } + } + } + + private fun startDiscoveryService() { + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + + _discoveryServiceStatus.value = DiscoveryStatus.Starting + + memScoped { + val newBrowseRef = alloc() + newBrowseRef.value = null + val result = DNSServiceBrowse( + sdRef = newBrowseRef.ptr, + flags = 0u, + interfaceIndex = 0u, + regtype = SERVICE_TYPE, + domain = null, + callBack = browseCallback, + context = callbackContext.asCPointer() + ) + val serviceRef = newBrowseRef.value + + if (result != kDNSServiceErr_NoError || serviceRef == null) { + serviceRef?.let { DNSServiceRefDeallocate(it) } + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearDiscoveredServices() + cancelPendingRepair() + return + } + + browseRef = serviceRef + val queueResult = DNSServiceSetDispatchQueue(serviceRef, callbackQueue) + if (queueResult != kDNSServiceErr_NoError) { + browseRef = null + DNSServiceRefDeallocate(serviceRef) + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearDiscoveredServices() + cancelPendingRepair() + return + } + } + + _discoveryServiceStatus.value = DiscoveryStatus.Running + } + + private fun stopDiscoveryService() { + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + + browseRef?.let { DNSServiceRefDeallocate(it) } + browseRef = null + clearDiscoveredServices() + + _discoveryServiceStatus.value = DiscoveryStatus.Idle + continuePendingRepairIfReady() + } + + private fun startRegistrationService( + httpServerPort: Int, + fileTransferPort: Int + ) { + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + _registrationServiceStatus.value = RegistrationStatus.Starting + val properties = linkedMapOf( + "deviceUuid" to localDevice.deviceId, + "deviceName" to localDevice.deviceName, + "deviceType" to localDevice.deviceType, + "protocolVersion" to localDevice.protocolVersion, + "fileTransferPort" to fileTransferPort.toString() + ) + + memScoped { + val txtRecord = alloc() + TXTRecordCreate(txtRecord.ptr, 0u, null) + + try { + properties.forEach { (key, value) -> + val valueBytes = value.encodeToByteArray() + check(valueBytes.size <= UByte.MAX_VALUE.toInt()) { + "Bonjour TXT value is too large: $key" + } + + val result = valueBytes.usePinned { pinnedValue -> + TXTRecordSetValue( + txtRecord = txtRecord.ptr, + key = key, + valueSize = valueBytes.size.toUByte(), + value = pinnedValue.addressOf(0) + ) + } + check(result == kDNSServiceErr_NoError) { + "Could not add Bonjour TXT value: $key ($result)" + } + } + + val newRegistrationRef = alloc() + newRegistrationRef.value = null + val registerResult = DNSServiceRegister( + sdRef = newRegistrationRef.ptr, + flags = 0u, + interfaceIndex = 0u, + name = "${localDevice.deviceName} Sync360", + regtype = SERVICE_TYPE, + domain = null, + host = null, + port = swapPortByteOrder(httpServerPort.toUShort()), + txtLen = TXTRecordGetLength(txtRecord.ptr), + txtRecord = TXTRecordGetBytesPtr(txtRecord.ptr), + callBack = registrationCallback, + context = callbackContext.asCPointer() + ) + val serviceRef = newRegistrationRef.value + + if (registerResult != kDNSServiceErr_NoError || serviceRef == null) { + serviceRef?.let { DNSServiceRefDeallocate(it) } + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + return + } + + registrationRef = serviceRef + val queueResult = DNSServiceSetDispatchQueue(serviceRef, callbackQueue) + if (queueResult != kDNSServiceErr_NoError) { + registrationRef = null + DNSServiceRefDeallocate(serviceRef) + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + } + } catch (exception: Throwable) { + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + println("Could not register iOS Bonjour service: ${exception.message}") + } finally { + TXTRecordDeallocate(txtRecord.ptr) + } + } + } + + private fun stopRegistrationService() { + _registrationServiceStatus.value = RegistrationStatus.Stopping + + registrationRef?.let { DNSServiceRefDeallocate(it) } + registrationRef = null + + _registrationServiceStatus.value = RegistrationStatus.Idle + continuePendingRepairIfReady() + } + + private fun handleRegistrationResult(errorCode: Int) { + locked { + if (registrationServiceStatus.value != RegistrationStatus.Starting) return@locked + + if (errorCode == kDNSServiceErr_NoError && registrationRef != null) { + _registrationServiceStatus.value = RegistrationStatus.Running + } else { + registrationRef?.let { DNSServiceRefDeallocate(it) } + registrationRef = null + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + } + } + } + + private fun handleBrowseResult( + flags: UInt, + interfaceIndex: UInt, + errorCode: Int, + serviceName: String?, + regtype: String?, + domain: String? + ) { + locked { + if (!discoveryIsActive()) return@locked + + if (errorCode != kDNSServiceErr_NoError) { + browseRef?.let { DNSServiceRefDeallocate(it) } + browseRef = null + clearDiscoveredServices() + _discoveryServiceStatus.value = DiscoveryStatus.Idle + cancelPendingRepair() + return@locked + } + + val name = serviceName ?: return@locked + val type = regtype ?: return@locked + val serviceDomain = domain ?: return@locked + val serviceKey = serviceKey(name, type, serviceDomain, interfaceIndex) + + if ((flags and kDNSServiceFlagsAdd) != 0u) { + startResolve( + serviceKey = serviceKey, + serviceName = name, + regtype = type, + domain = serviceDomain, + interfaceIndex = interfaceIndex + ) + } else { + removeService(serviceKey) + } + } + } + + private fun startResolve( + serviceKey: String, + serviceName: String, + regtype: String, + domain: String, + interfaceIndex: UInt + ) { + removeResolveOperation(serviceKey) + removeAddressOperation(serviceKey) + serviceDetailsByKey.remove(serviceKey) + publishDiscoveredDevices() + + memScoped { + val newResolveRef = alloc() + newResolveRef.value = null + val result = DNSServiceResolve( + sdRef = newResolveRef.ptr, + flags = 0u, + interfaceIndex = interfaceIndex, + name = serviceName, + regtype = regtype, + domain = domain, + callBack = resolveCallback, + context = callbackContext.asCPointer() + ) + val serviceRef = newResolveRef.value + if (result != kDNSServiceErr_NoError || serviceRef == null) { + serviceRef?.let { DNSServiceRefDeallocate(it) } + return + } + + serviceDetailsByKey[serviceKey] = ServiceDetails( + serviceName = serviceName, + serviceType = regtype, + interfaceIndex = interfaceIndex + ) + resolveRefsByKey[serviceKey] = serviceRef + + if (DNSServiceSetDispatchQueue(serviceRef, callbackQueue) != kDNSServiceErr_NoError) { + removeResolveOperation(serviceKey) + serviceDetailsByKey.remove(serviceKey) + } + } + } + + private fun handleResolveResult( + serviceRef: DNSServiceRef?, + interfaceIndex: UInt, + errorCode: Int, + hostTarget: String?, + networkPort: UShort, + txtLength: UShort, + txtRecord: CPointer? + ) { + locked { + if (!discoveryIsActive() || serviceRef == null) return@locked + val serviceKey = resolveRefsByKey.entries + .firstOrNull { (_, ref) -> ref == serviceRef } + ?.key + ?: return@locked + + if (errorCode != kDNSServiceErr_NoError || hostTarget == null) { + removeService(serviceKey) + return@locked + } + + val properties = REQUIRED_TXT_KEYS.associateWith { key -> + readTxtValue(txtLength, txtRecord, key) + } + val deviceUuid = properties["deviceUuid"] ?: run { + removeService(serviceKey) + return@locked + } + val deviceName = properties["deviceName"] ?: run { + removeService(serviceKey) + return@locked + } + val deviceType = properties["deviceType"] ?: run { + removeService(serviceKey) + return@locked + } + val protocolVersion = properties["protocolVersion"] ?: run { + removeService(serviceKey) + return@locked + } + val fileTransferPort = properties["fileTransferPort"] + ?.toIntOrNull() + ?.takeIf { it > 0 } + ?: run { + removeService(serviceKey) + return@locked + } + val httpPort = swapPortByteOrder(networkPort).toInt() + if (httpPort <= 0) { + removeService(serviceKey) + return@locked + } + + val previous = serviceDetailsByKey[serviceKey] ?: return@locked + serviceDetailsByKey[serviceKey] = previous.copy( + deviceId = deviceUuid, + deviceName = deviceName, + deviceType = deviceType, + protocolVersion = protocolVersion, + httpPort = httpPort, + fileTransferPort = fileTransferPort, + hostAddresses = emptyList() + ) + publishDiscoveredDevices() + + startAddressLookup( + serviceKey = serviceKey, + hostname = hostTarget, + interfaceIndex = interfaceIndex + ) + } + } + + private fun startAddressLookup( + serviceKey: String, + hostname: String, + interfaceIndex: UInt + ) { + removeAddressOperation(serviceKey) + + memScoped { + val newAddressRef = alloc() + newAddressRef.value = null + val result = DNSServiceGetAddrInfo( + sdRef = newAddressRef.ptr, + flags = 0u, + interfaceIndex = interfaceIndex, + protocol = kDNSServiceProtocol_IPv4 or kDNSServiceProtocol_IPv6, + hostname = hostname, + callBack = addressCallback, + context = callbackContext.asCPointer() + ) + val serviceRef = newAddressRef.value + if (result != kDNSServiceErr_NoError || serviceRef == null) { + serviceRef?.let { DNSServiceRefDeallocate(it) } + return + } + + addressRefsByKey[serviceKey] = serviceRef + if (DNSServiceSetDispatchQueue(serviceRef, callbackQueue) != kDNSServiceErr_NoError) { + removeAddressOperation(serviceKey) + } + } + } + + private fun handleAddressResult( + serviceRef: DNSServiceRef?, + flags: UInt, + errorCode: Int, + address: CPointer?, + ttl: UInt + ) { + locked { + if (!discoveryIsActive() || serviceRef == null) return@locked + val serviceKey = addressRefsByKey.entries + .firstOrNull { (_, ref) -> ref == serviceRef } + ?.key + ?: return@locked + + if (errorCode != kDNSServiceErr_NoError) { + removeService(serviceKey) + return@locked + } + if (address == null) return@locked + val hostAddress = address.toNumericHost() ?: return@locked + val serviceDetails = serviceDetailsByKey[serviceKey] ?: return@locked + val addressWasAdded = (flags and kDNSServiceFlagsAdd) != 0u && ttl > 0u + val addresses = if (addressWasAdded) { + serviceDetails.hostAddresses + hostAddress + } else { + serviceDetails.hostAddresses - hostAddress + } + + serviceDetailsByKey[serviceKey] = serviceDetails.copy( + hostAddresses = addresses.distinct() + ) + publishDiscoveredDevices() + } + } + + private fun publishDiscoveredDevices() { + _nearbyDevices.value = serviceDetailsByKey.values + .mapNotNull(ServiceDetails::toNearbyDevice) + .filterNot { device -> device.id == localDevice.deviceId } + .groupBy { device -> device.id } + .values + .map { matchingDevices -> + val firstDevice = matchingDevices.first() + firstDevice.copy( + hostAddresses = matchingDevices + .flatMap { device -> device.hostAddresses } + .distinct() + ) + } + .sortedBy { device -> device.deviceName.lowercase() } + } + + private fun removeService(serviceKey: String) { + removeResolveOperation(serviceKey) + removeAddressOperation(serviceKey) + serviceDetailsByKey.remove(serviceKey) + publishDiscoveredDevices() + } + + private fun removeResolveOperation(serviceKey: String) { + resolveRefsByKey.remove(serviceKey)?.let { DNSServiceRefDeallocate(it) } + } + + private fun removeAddressOperation(serviceKey: String) { + addressRefsByKey.remove(serviceKey)?.let { DNSServiceRefDeallocate(it) } + } + + private fun clearDiscoveredServices() { + resolveRefsByKey.values.forEach { DNSServiceRefDeallocate(it) } + addressRefsByKey.values.forEach { DNSServiceRefDeallocate(it) } + resolveRefsByKey.clear() + addressRefsByKey.clear() + serviceDetailsByKey.clear() + _nearbyDevices.value = emptyList() + } + + private fun continuePendingRepairIfReady() { + val repair = pendingRepair ?: return + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + pendingRepair = null + clearDiscoveredServices() + startDiscoveryService() + startRegistrationService( + httpServerPort = repair.httpServerPort, + fileTransferPort = repair.fileTransferPort + ) + } + + private fun cancelPendingRepair() { + pendingRepair = null + } + + private fun servicesAreStable(): Boolean { + val discoveryStable = + discoveryServiceStatus.value == DiscoveryStatus.Idle || + discoveryServiceStatus.value == DiscoveryStatus.Running + val registrationStable = + registrationServiceStatus.value == RegistrationStatus.Idle || + registrationServiceStatus.value == RegistrationStatus.Running + return discoveryStable && registrationStable + } + + private fun discoveryIsActive(): Boolean { + return discoveryServiceStatus.value == DiscoveryStatus.Starting || + discoveryServiceStatus.value == DiscoveryStatus.Running + } + + private fun readTxtValue( + txtLength: UShort, + txtRecord: CPointer?, + key: String + ): String? { + if (txtRecord == null) return null + + return memScoped { + val valueLength = alloc() + val valuePointer = TXTRecordGetValuePtr( + txtLen = txtLength, + txtRecord = txtRecord, + key = key, + valueLen = valueLength.ptr + ) ?: return@memScoped null + + valuePointer + .reinterpret() + .readBytes(valueLength.value.toInt()) + .decodeToString() + } + } + + private fun CPointer.toNumericHost(): String? { + return memScoped { + val hostBuffer = allocArray(NI_MAXHOST) + val result = getnameinfo( + this@toNumericHost, + pointed.sa_len.convert(), + hostBuffer, + NI_MAXHOST.convert(), + null, + 0u, + NI_NUMERICHOST + ) + if (result == 0) hostBuffer.toKString() else null + } + } + + private fun serviceKey( + serviceName: String, + regtype: String, + domain: String, + interfaceIndex: UInt + ): String { + return "$serviceName|$regtype|$domain|$interfaceIndex".lowercase() + } + + private fun swapPortByteOrder(port: UShort): UShort { + val value = port.toInt() + return ( + ((value and 0x00FF) shl 8) or + ((value and 0xFF00) ushr 8) + ).toUShort() + } + + private inline fun locked(block: () -> T): T { + stateLock.lock() + return try { + block() + } finally { + stateLock.unlock() + } + } + + private data class PendingRepair( + val httpServerPort: Int, + val fileTransferPort: Int + ) + + private data class ServiceDetails( + val serviceName: String, + val serviceType: String, + val interfaceIndex: UInt, + val deviceId: String? = null, + val deviceName: String? = null, + val deviceType: String? = null, + val protocolVersion: String? = null, + val httpPort: Int? = null, + val fileTransferPort: Int? = null, + val hostAddresses: List = emptyList() + ) { + fun toNearbyDevice(): NearbyDevice? { + val resolvedDeviceId = deviceId ?: return null + val resolvedDeviceName = deviceName ?: return null + val resolvedDeviceType = deviceType ?: return null + val resolvedProtocolVersion = protocolVersion ?: return null + val resolvedHttpPort = httpPort ?: return null + val resolvedFileTransferPort = fileTransferPort ?: return null + if (hostAddresses.isEmpty()) return null + + return NearbyDevice( + id = resolvedDeviceId, + deviceName = resolvedDeviceName, + deviceType = resolvedDeviceType, + protocolVersion = resolvedProtocolVersion, + hostAddresses = hostAddresses, + port = resolvedHttpPort, + fileTransferPort = resolvedFileTransferPort, + serviceName = serviceName, + serviceType = "${serviceType.trimEnd('.')}." + ) + } + } + + private companion object { + const val SERVICE_TYPE = "_sync360._tcp" + val REQUIRED_TXT_KEYS = listOf( + "deviceUuid", + "deviceName", + "deviceType", + "protocolVersion", + "fileTransferPort" + ) + + val browseCallback = staticCFunction { + _: DNSServiceRef?, + flags: UInt, + interfaceIndex: UInt, + errorCode: Int, + serviceName: CPointer?, + regtype: CPointer?, + domain: CPointer?, + context: COpaquePointer? -> + context?.asStableRef()?.get()?.handleBrowseResult( + flags = flags, + interfaceIndex = interfaceIndex, + errorCode = errorCode, + serviceName = serviceName?.toKString(), + regtype = regtype?.toKString(), + domain = domain?.toKString() + ) + Unit + } + + val resolveCallback = staticCFunction { + serviceRef: DNSServiceRef?, + _: UInt, + interfaceIndex: UInt, + errorCode: Int, + _: CPointer?, + hostTarget: CPointer?, + port: UShort, + txtLength: UShort, + txtRecord: CPointer?, + context: COpaquePointer? -> + context?.asStableRef()?.get()?.handleResolveResult( + serviceRef = serviceRef, + interfaceIndex = interfaceIndex, + errorCode = errorCode, + hostTarget = hostTarget?.toKString(), + networkPort = port, + txtLength = txtLength, + txtRecord = txtRecord + ) + Unit + } + + val addressCallback = staticCFunction { + serviceRef: DNSServiceRef?, + flags: UInt, + _: UInt, + errorCode: Int, + _: CPointer?, + address: CPointer?, + ttl: UInt, + context: COpaquePointer? -> + context?.asStableRef()?.get()?.handleAddressResult( + serviceRef = serviceRef, + flags = flags, + errorCode = errorCode, + address = address, + ttl = ttl + ) + Unit + } + + val registrationCallback = staticCFunction { + _: DNSServiceRef?, + _: UInt, + errorCode: Int, + _: CPointer?, + _: CPointer?, + _: CPointer?, + context: COpaquePointer? -> + context?.asStableRef()?.get() + ?.handleRegistrationResult(errorCode) + Unit + } + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferReceiver.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferReceiver.kt new file mode 100644 index 0000000..9fc4c27 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferReceiver.kt @@ -0,0 +1,221 @@ +package com.liftley.sync360.data.network.tcp + +import com.liftley.sync360.data.file.IosDocumentsStorage +import com.liftley.sync360.data.network.http.dto.file.FileOfferRequest +import com.liftley.sync360.domain.model.FileTransferProgress +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.ServerSocket +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.network.sockets.port +import io.ktor.utils.io.readInt +import io.ktor.utils.io.readLong +import io.ktor.utils.io.writeByte +import io.ktor.utils.io.writeInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSLock +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalForeignApi::class) +class IosFileTransferReceiver( + private val documentsStorage: IosDocumentsStorage +) : FileTransferReceiver { + private val receiverScope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + ) + private val selectorManager = SelectorManager(Dispatchers.Default) + private val stateLock = NSLock() + private var serverSocket: ServerSocket? = null + private var expectedFileOffer: FileOfferRequest? = null + private var onFileSaved: ((completedFileCount: Int) -> Unit)? = null + private var onProgress: ((FileTransferProgress) -> Unit)? = null + private var onTransferFinished: ((wasSuccessful: Boolean) -> Unit)? = null + private var waitingForSenderTimeout: Job? = null + private var waitingForSenderGeneration = 0L + + override var port: Int = 0 + private set + + override suspend fun start(): Int { + if (serverSocket != null) { + return port + } + + val startedServerSocket = aSocket(selectorManager) + .tcp() + .bind(hostname = "0.0.0.0", port = 0) + + serverSocket = startedServerSocket + port = startedServerSocket.port + + receiverScope.launch { + while (isActive) { + try { + receiveTransfer(startedServerSocket.accept()) + } catch (exception: Exception) { + if (isActive) { + println("iOS file receiver failed: ${exception.message}") + } + } + } + } + + return port + } + + override fun prepareForTransfer( + fileOffer: FileOfferRequest, + onFileSaved: (completedFileCount: Int) -> Unit, + onProgress: (FileTransferProgress) -> Unit, + onTransferFinished: (wasSuccessful: Boolean) -> Unit + ) { + locked { + expectedFileOffer = fileOffer + this.onFileSaved = onFileSaved + this.onProgress = onProgress + this.onTransferFinished = onTransferFinished + startWaitingForSenderTimeout() + } + } + + override fun clearExpectedTransfer() { + locked { + waitingForSenderTimeout?.cancel() + waitingForSenderGeneration++ + expectedFileOffer = null + onFileSaved = null + onProgress = null + onTransferFinished = null + } + } + + private suspend fun receiveTransfer(senderSocket: Socket) { + val fileOffer = locked { + waitingForSenderTimeout?.cancel() + waitingForSenderGeneration++ + expectedFileOffer + } + + try { + val socketInput = senderSocket.openReadChannel() + val socketOutput = senderSocket.openWriteChannel(autoFlush = false) + var completedFileCount = 0 + + try { + val acceptedFileOffer = fileOffer + ?: error("No accepted file offer is waiting") + val progressTracker = FileTransferProgressTracker( + totalBytes = acceptedFileOffer.totalSizeBytes, + onProgress = { progress -> + val callback = locked { onProgress } + callback?.invoke(progress) + } + ) + + acceptedFileOffer.files.forEach { expectedFile -> + val receivedFileIndex = withTimeout( + FileTransferConstants.SOCKET_TIMEOUT_MILLIS.toLong() + ) { + socketInput.readInt() + } + val receivedFileSize = withTimeout( + FileTransferConstants.SOCKET_TIMEOUT_MILLIS.toLong() + ) { + socketInput.readLong() + } + + if (receivedFileIndex != expectedFile.index) { + error( + "Expected file index ${expectedFile.index}, " + + "but received $receivedFileIndex" + ) + } + if (receivedFileSize != expectedFile.fileSizeBytes) { + error("File size does not match the accepted offer") + } + + documentsStorage.writeFile( + fileName = expectedFile.fileName, + fileSizeBytes = expectedFile.fileSizeBytes, + input = socketInput, + onBytesWritten = progressTracker::addBytes + ) + + completedFileCount++ + val callback = locked { onFileSaved } + callback?.invoke(completedFileCount) + } + + socketOutput.writeByte(1.toByte()) + socketOutput.writeInt(completedFileCount) + socketOutput.flush() + finishTransfer(wasSuccessful = true) + } catch (exception: Exception) { + println("iOS file transfer failed: ${exception.message}") + + runCatching { + socketOutput.writeByte(0.toByte()) + socketOutput.writeInt(completedFileCount) + socketOutput.flush() + } + + finishTransfer(wasSuccessful = false) + } + } finally { + senderSocket.close() + } + } + + private fun finishTransfer(wasSuccessful: Boolean) { + val completionCallback = locked { + val callback = onTransferFinished + waitingForSenderTimeout?.cancel() + waitingForSenderGeneration++ + expectedFileOffer = null + onFileSaved = null + onProgress = null + onTransferFinished = null + callback + } + + completionCallback?.invoke(wasSuccessful) + } + + private fun startWaitingForSenderTimeout() { + waitingForSenderTimeout?.cancel() + waitingForSenderGeneration++ + val generation = waitingForSenderGeneration + + waitingForSenderTimeout = receiverScope.launch { + delay( + FileTransferConstants.WAITING_FOR_FIRST_FILE_TIMEOUT_MILLIS.milliseconds + ) + val timeoutIsCurrent = locked { + waitingForSenderGeneration == generation && + expectedFileOffer != null + } + if (timeoutIsCurrent) { + finishTransfer(wasSuccessful = false) + } + } + } + + private inline fun locked(block: () -> T): T { + stateLock.lock() + return try { + block() + } finally { + stateLock.unlock() + } + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferSender.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferSender.kt new file mode 100644 index 0000000..a3a3c43 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/tcp/IosFileTransferSender.kt @@ -0,0 +1,215 @@ +package com.liftley.sync360.data.network.tcp + +import com.liftley.sync360.domain.model.FileTransferProgress +import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.SelectedFile +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.readByte +import io.ktor.utils.io.readInt +import io.ktor.utils.io.writeFully +import io.ktor.utils.io.writeInt +import io.ktor.utils.io.writeLong +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import platform.Foundation.NSLock +import platform.Foundation.NSInputStream +import platform.Foundation.NSURL + +@OptIn(ExperimentalForeignApi::class) +class IosFileTransferSender : FileTransferSender { + private val selectorManager = SelectorManager(Dispatchers.Default) + private val stateLock = NSLock() + private var activeSocket: Socket? = null + + override fun cancelCurrentTransfer() { + val socket = locked { + val currentSocket = activeSocket + activeSocket = null + currentSocket + } + runCatching { socket?.close() } + } + + override suspend fun sendFiles( + deviceToSendFiles: NearbyDevice, + files: List, + onFileStarted: suspend (fileIndex: Int, file: SelectedFile) -> Unit, + onProgress: (FileTransferProgress) -> Unit + ): Result = withContext(Dispatchers.Default) { + try { + if (files.isEmpty()) { + return@withContext Result.success(Unit) + } + + val socket = connectToDevice(deviceToSendFiles) + locked { + activeSocket = socket + } + + try { + val socketOutput = socket.openWriteChannel(autoFlush = false) + val socketInput = socket.openReadChannel() + val buffer = ByteArray(FileTransferConstants.PAYLOAD_BUFFER_SIZE_BYTES) + val progressTracker = FileTransferProgressTracker( + totalBytes = files.sumOf { file -> requireNotNull(file.sizeBytes) }, + onProgress = onProgress + ) + + files.forEachIndexed { fileIndex, file -> + currentCoroutineContext().ensureActive() + onFileStarted(fileIndex, file) + sendOneFile( + fileIndex = fileIndex, + file = file, + socketOutput = socketOutput, + buffer = buffer, + progressTracker = progressTracker + ) + } + + socketOutput.flush() + + val receiverSavedTransferSuccessfully = socketInput.readByte().toInt() != 0 + val completedFileCount = socketInput.readInt() + + check(receiverSavedTransferSuccessfully && completedFileCount == files.size) { + "Receiver saved $completedFileCount of ${files.size} files" + } + } finally { + locked { + if (activeSocket === socket) { + activeSocket = null + } + } + socket.close() + } + + Result.success(Unit) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + currentCoroutineContext().ensureActive() + Result.failure(exception) + } + } + + private suspend fun sendOneFile( + fileIndex: Int, + file: SelectedFile, + socketOutput: ByteWriteChannel, + buffer: ByteArray, + progressTracker: FileTransferProgressTracker + ) { + val fileSize = file.sizeBytes + ?: error("File size is unknown: ${file.displayName}") + val fileUrl = NSURL(string = file.uri) + ?: error("Could not open file: ${file.displayName}") + val hasSecurityScopedAccess = fileUrl.startAccessingSecurityScopedResource() + + try { + val input = NSInputStream(uRL = fileUrl) + input.open() + + try { + socketOutput.writeInt(fileIndex) + socketOutput.writeLong(fileSize) + + var bytesRemaining = fileSize + while (bytesRemaining > 0) { + currentCoroutineContext().ensureActive() + + val bytesRequested = minOf( + buffer.size.toLong(), + bytesRemaining + ).toInt() + val bytesRead = readFileChunk(input, buffer, bytesRequested) + + if (bytesRead == 0) { + error("${file.displayName} ended before its reported size") + } + + socketOutput.writeFully( + value = buffer, + startIndex = 0, + endIndex = bytesRead + ) + bytesRemaining -= bytesRead + progressTracker.addBytes(bytesRead) + } + } finally { + input.close() + } + } finally { + if (hasSecurityScopedAccess) { + fileUrl.stopAccessingSecurityScopedResource() + } + } + } + + private fun readFileChunk( + input: NSInputStream, + buffer: ByteArray, + bytesRequested: Int + ): Int { + val bytesRead = buffer.usePinned { pinnedBuffer -> + input.read( + buffer = pinnedBuffer.addressOf(0).reinterpret(), + maxLength = bytesRequested.toULong() + ) + } + + if (bytesRead < 0) { + error( + input.streamError?.localizedDescription + ?: "Could not read the selected file" + ) + } + + return bytesRead.toInt() + } + + private suspend fun connectToDevice(device: NearbyDevice): Socket { + var lastFailure: Exception? = null + + device.hostAddresses.distinct().forEach { hostAddress -> + currentCoroutineContext().ensureActive() + + try { + return withTimeout(FileTransferConstants.CONNECT_TIMEOUT_MILLIS.toLong()) { + aSocket(selectorManager) + .tcp() + .connect(hostAddress, device.fileTransferPort) { + socketTimeout = FileTransferConstants.SOCKET_TIMEOUT_MILLIS.toLong() + } + } + } catch (exception: Exception) { + currentCoroutineContext().ensureActive() + lastFailure = exception + } + } + + throw lastFailure ?: error("No address is available for ${device.deviceName}") + } + + private inline fun locked(block: () -> T): T { + stateLock.lock() + return try { + block() + } finally { + stateLock.unlock() + } + } +} diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.ios.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.ios.kt new file mode 100644 index 0000000..178e659 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.ios.kt @@ -0,0 +1,66 @@ +package com.liftley.sync360.presentation.send.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.uikit.LocalUIViewController +import com.liftley.sync360.domain.model.SelectedFile +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSURL +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UniformTypeIdentifiers.UTTypeImage +import platform.UniformTypeIdentifiers.UTTypeItem +import platform.UniformTypeIdentifiers.UTTypeMovie +import platform.darwin.NSObject + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun FilesSendContent( + files: List, + onFilesSelected: (List) -> Unit, + onClearFiles: () -> Unit, + onRemoveFile: (SelectedFile) -> Unit +) { + val viewController = LocalUIViewController.current + val currentOnFilesSelected = rememberUpdatedState(onFilesSelected) + val documentPickerDelegate = remember { + object : NSObject(), UIDocumentPickerDelegateProtocol { + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*> + ) { + val selectedUrls = didPickDocumentsAtURLs.filterIsInstance() + if (selectedUrls.isNotEmpty()) { + currentOnFilesSelected.value(selectedUrls) + } + } + } + } + + FileSelectionContent( + files = files, + onClearFiles = onClearFiles, + onRemoveFile = onRemoveFile, + onPickMedia = { + val picker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypeImage, UTTypeMovie), + asCopy = true + ) + picker.allowsMultipleSelection = true + picker.delegate = documentPickerDelegate + viewController.presentViewController(picker, animated = true, completion = null) + }, + onPickFiles = { + val picker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypeItem), + asCopy = true + ) + picker.allowsMultipleSelection = true + picker.delegate = documentPickerDelegate + viewController.presentViewController(picker, animated = true, completion = null) + } + ) +} + +internal actual fun filePreviewModel(file: SelectedFile): Any = file.uri diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/core/di/koin.jvm.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/core/di/koin.jvm.kt index 92c745e..4bfbfb1 100644 --- a/shared/src/jvmMain/kotlin/com/liftley/sync360/core/di/koin.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/core/di/koin.jvm.kt @@ -9,6 +9,7 @@ import com.liftley.sync360.data.local.JvmClipboardProvider import com.liftley.sync360.data.local.JvmLocalDeviceIdentityStore import com.liftley.sync360.data.local.JvmLocalDeviceInfoProvider import com.liftley.sync360.data.network.discovery.JvmNetworkServices +import com.liftley.sync360.data.network.discovery.windows.WindowsNetworkServices import com.liftley.sync360.data.network.tcp.FileTransferReceiver import com.liftley.sync360.data.network.tcp.FileTransferSender import com.liftley.sync360.data.network.tcp.JvmFileTransferReceiver @@ -35,5 +36,15 @@ val jvmModule = module { deviceUuid = get().getOrCreateDeviceUuid() ) } - single { JvmNetworkServices(get()) } + single { + if ( + System.getProperty("os.name") + .orEmpty() + .startsWith("Windows", ignoreCase = true) + ) { + WindowsNetworkServices(get()) + } else { + JvmNetworkServices(get()) + } + } } diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/JvmNetworkServices.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/JvmNetworkServices.kt index b11a152..97f444a 100644 --- a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/JvmNetworkServices.kt +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/JvmNetworkServices.kt @@ -3,6 +3,7 @@ package com.liftley.sync360.data.network.discovery import com.liftley.sync360.domain.local.LocalDeviceInfoProvider import com.liftley.sync360.domain.model.DiscoveryStatus import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.domain.service.NetworkServices import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import java.net.Inet4Address +import java.net.Inet6Address import java.net.InetAddress import java.net.NetworkInterface import java.util.Collections @@ -29,10 +31,15 @@ class JvmNetworkServices( override val discoveryServiceStatus: StateFlow = _discoveryServiceStatus.asStateFlow() + private val _registrationServiceStatus: MutableStateFlow = + MutableStateFlow(RegistrationStatus.Idle) + + override val registrationServiceStatus: StateFlow = + _registrationServiceStatus.asStateFlow() + private val jmDnsByAddress = mutableMapOf() private val listenerByAddress = mutableMapOf() private val resolvedDevicesByServiceKey = ConcurrentHashMap() - private var listenersAreRunning = false override suspend fun startNetworkServices( httpServerPort: Int, @@ -40,21 +47,44 @@ class JvmNetworkServices( ) { if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + val registrationIsStarting = + registrationServiceStatus.value == RegistrationStatus.Idle + + if ( + !registrationIsStarting && + registrationServiceStatus.value != RegistrationStatus.Running + ) { + return + } + _discoveryServiceStatus.value = DiscoveryStatus.Starting + if (registrationIsStarting) { + _registrationServiceStatus.value = RegistrationStatus.Starting + } try { withContext(Dispatchers.IO) { - if (jmDnsByAddress.isEmpty()) { + if (registrationIsStarting) { + if (jmDnsByAddress.isNotEmpty() && !closeAllInstances()) { + error("Could not close the previous JmDNS instances") + } startOnLanInterfaces(httpServerPort, fileTransferPort) } else { addDiscoveryListeners() } } + + if (registrationIsStarting) { + _registrationServiceStatus.value = RegistrationStatus.Running + } _discoveryServiceStatus.value = DiscoveryStatus.Running } catch (exception: Exception) { - closeAllInstances() + withContext(Dispatchers.IO) { + closeAllInstances() + } + _registrationServiceStatus.value = RegistrationStatus.Idle _discoveryServiceStatus.value = DiscoveryStatus.Idle - throw exception + exception.printStackTrace() } } @@ -62,26 +92,49 @@ class JvmNetworkServices( httpServerPort: Int, fileTransferPort: Int ) { - _discoveryServiceStatus.value = DiscoveryStatus.Stopping + val discoveryIsStable = + discoveryServiceStatus.value == DiscoveryStatus.Idle || + discoveryServiceStatus.value == DiscoveryStatus.Running + val registrationIsStable = + registrationServiceStatus.value == RegistrationStatus.Idle || + registrationServiceStatus.value == RegistrationStatus.Running + + if (!discoveryIsStable || !registrationIsStable) return + + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + } + if (registrationServiceStatus.value == RegistrationStatus.Running) { + _registrationServiceStatus.value = RegistrationStatus.Stopping + } - withContext(Dispatchers.IO) { + val allInstancesClosed = withContext(Dispatchers.IO) { closeAllInstances() } _discoveryServiceStatus.value = DiscoveryStatus.Idle + _registrationServiceStatus.value = RegistrationStatus.Idle + + if (!allInstancesClosed) return startNetworkServices(httpServerPort, fileTransferPort) } override fun restartDiscoveryServices() { if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + if (registrationServiceStatus.value != RegistrationStatus.Running) return if (jmDnsByAddress.isEmpty()) return _discoveryServiceStatus.value = DiscoveryStatus.Starting _nearbyDevices.value = emptyList() resolvedDevicesByServiceKey.clear() - addDiscoveryListeners() - _discoveryServiceStatus.value = DiscoveryStatus.Running + + try { + addDiscoveryListeners() + _discoveryServiceStatus.value = DiscoveryStatus.Running + } catch (exception: Exception) { + closeAllInstancesAfterFailure(exception) + } } override fun stopDiscoveryServices() { @@ -89,14 +142,18 @@ class JvmNetworkServices( _discoveryServiceStatus.value = DiscoveryStatus.Stopping - synchronized(this) { - listenerByAddress.forEach { (address, listener) -> - jmDnsByAddress[address]?.removeServiceListener(SERVICE_TYPE, listener) + try { + synchronized(this) { + listenerByAddress.forEach { (address, listener) -> + jmDnsByAddress[address]?.removeServiceListener(SERVICE_TYPE, listener) + } + resolvedDevicesByServiceKey.clear() + _nearbyDevices.value = emptyList() } - listenersAreRunning = false + _discoveryServiceStatus.value = DiscoveryStatus.Idle + } catch (exception: Exception) { + closeAllInstancesAfterFailure(exception) } - - _discoveryServiceStatus.value = DiscoveryStatus.Idle } private fun startOnLanInterfaces( @@ -125,20 +182,22 @@ class JvmNetworkServices( listenerByAddress[address] = listener } } catch (exception: Exception) { - runCatching { jmDns?.close() } + runCatching { + jmDns?.close() + }.onFailure { closeException -> + closeException.printStackTrace() + } lastFailure = exception } } synchronized(this) { - listenersAreRunning = jmDnsByAddress.isNotEmpty() - } - - if (jmDnsByAddress.isEmpty()) { - throw IllegalStateException( - "Could not start nearby-device discovery on any active IPv4 LAN interface", - lastFailure - ) + if (jmDnsByAddress.isEmpty()) { + throw IllegalStateException( + "Could not start Sync360 on any active LAN interface", + lastFailure + ) + } } } @@ -178,7 +237,14 @@ class JvmNetworkServices( } override fun serviceResolved(event: ServiceEvent) { - val nearbyDevice = event.info.toNearbyDevice() ?: return + if ( + discoveryServiceStatus.value != DiscoveryStatus.Starting && + discoveryServiceStatus.value != DiscoveryStatus.Running + ) { + return + } + + val nearbyDevice = event.info.toNearbyDevice(interfaceAddress) ?: return val localDeviceId = localDeviceInfoProvider.getLocalDeviceInfo().deviceId if (nearbyDevice.id == localDeviceId) return @@ -189,27 +255,58 @@ class JvmNetworkServices( @Synchronized private fun addDiscoveryListeners() { - if (listenersAreRunning) return + if (listenerByAddress.isEmpty()) { + error("No registered JmDNS instances are available for discovery") + } + + val addedListeners = mutableListOf>() - listenerByAddress.forEach { (address, listener) -> - jmDnsByAddress[address]?.addServiceListener(SERVICE_TYPE, listener) + try { + listenerByAddress.forEach { (address, listener) -> + val jmDns = jmDnsByAddress[address] ?: return@forEach + jmDns.addServiceListener(SERVICE_TYPE, listener) + addedListeners += jmDns to listener + } + } catch (exception: Exception) { + addedListeners.forEach { (jmDns, listener) -> + runCatching { + jmDns.removeServiceListener(SERVICE_TYPE, listener) + } + } + throw exception } - listenersAreRunning = true } @Synchronized - private fun closeAllInstances() { - val instances = jmDnsByAddress.values.toList() + private fun closeAllInstances(): Boolean { + val closedAddresses = mutableListOf() + + jmDnsByAddress.forEach { (address, jmDns) -> + runCatching { + jmDns.close() + }.onSuccess { + closedAddresses += address + }.onFailure { exception -> + exception.printStackTrace() + } + } - jmDnsByAddress.clear() - listenerByAddress.clear() + closedAddresses.forEach { address -> + jmDnsByAddress.remove(address) + listenerByAddress.remove(address) + } resolvedDevicesByServiceKey.clear() _nearbyDevices.value = emptyList() - listenersAreRunning = false - instances.forEach { jmDns -> - runCatching { jmDns.close() } - } + return jmDnsByAddress.isEmpty() + } + + private fun closeAllInstancesAfterFailure(exception: Exception) { + _registrationServiceStatus.value = RegistrationStatus.Stopping + closeAllInstances() + _registrationServiceStatus.value = RegistrationStatus.Idle + _discoveryServiceStatus.value = DiscoveryStatus.Idle + exception.printStackTrace() } @Synchronized @@ -230,7 +327,7 @@ class JvmNetworkServices( _nearbyDevices.value = mergedDevices } - private fun ServiceInfo.toNearbyDevice(): NearbyDevice? { + private fun ServiceInfo.toNearbyDevice(interfaceAddress: InetAddress): NearbyDevice? { val deviceUuid = getPropertyString("deviceUuid") ?: return null val deviceName = getPropertyString("deviceName") ?: return null val deviceType = getPropertyString("deviceType") ?: return null @@ -240,8 +337,17 @@ class JvmNetworkServices( ?.takeIf { it > 0 } ?: return null val httpPort = port.takeIf { it > 0 } ?: return null - val hostAddresses = inet4Addresses - .map { address -> address.hostAddress } + val networkInterface = runCatching { + NetworkInterface.getByInetAddress(interfaceAddress) + }.getOrNull() + val hostAddresses = buildList { + addAll(inet4Addresses.map { address -> address.hostAddress }) + addAll( + inet6Addresses.mapNotNull { address -> + address.hostAddressWithScope(networkInterface) + } + ) + } .distinct() if (hostAddresses.isEmpty()) return null @@ -259,6 +365,17 @@ class JvmNetworkServices( ) } + private fun Inet6Address.hostAddressWithScope( + networkInterface: NetworkInterface? + ): String? { + if (!isLinkLocalAddress || scopeId > 0) return hostAddress + if (networkInterface == null) return null + + return runCatching { + Inet6Address.getByAddress(null, address, networkInterface).hostAddress + }.getOrNull() + } + private fun ServiceEvent.serviceKey(interfaceAddress: InetAddress): String { return "${interfaceAddress.hostAddress}|$type|$name" } @@ -278,14 +395,21 @@ class JvmNetworkServices( .flatMap { networkInterface -> Collections.list(networkInterface.inetAddresses).asSequence() } - .filterIsInstance() .filter { address -> - address.isSiteLocalAddress && !address.isLoopbackAddress + when (address) { + is Inet4Address -> address.isSiteLocalAddress + is Inet6Address -> + !address.isAnyLocalAddress && + !address.isLoopbackAddress && + !address.isMulticastAddress + + else -> false + } } .distinctBy { address -> address.hostAddress } .toList() .ifEmpty { - error("No active multicast-capable IPv4 LAN interface is available") + error("No active multicast-capable LAN interface is available") } } diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsDnsSdApi.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsDnsSdApi.kt new file mode 100644 index 0000000..ea5fd43 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsDnsSdApi.kt @@ -0,0 +1,234 @@ +package com.liftley.sync360.data.network.discovery.windows + +import java.lang.foreign.Arena +import java.lang.foreign.FunctionDescriptor +import java.lang.foreign.Linker +import java.lang.foreign.MemorySegment +import java.lang.foreign.SymbolLookup +import java.lang.foreign.ValueLayout +import java.lang.invoke.MethodHandle +import java.nio.charset.StandardCharsets.UTF_16LE + +internal class WindowsDnsSdApi { + init { + require(ValueLayout.ADDRESS.byteSize() == 8L) { + "Windows DNS-SD requires a 64-bit Desktop JVM" + } + } + + private val arena = Arena.ofShared() + private val linker = Linker.nativeLinker() + private val symbols = SymbolLookup.libraryLookup("dnsapi", arena) + + private val browse = downcall("DnsServiceBrowse", REQUEST_DESCRIPTOR) + private val browseCancel = downcall("DnsServiceBrowseCancel", CANCEL_DESCRIPTOR) + private val resolve = downcall("DnsServiceResolve", REQUEST_DESCRIPTOR) + private val resolveCancel = downcall("DnsServiceResolveCancel", CANCEL_DESCRIPTOR) + private val constructInstance = downcall( + "DnsServiceConstructInstance", + FunctionDescriptor.of( + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS, + ValueLayout.JAVA_SHORT, + ValueLayout.JAVA_SHORT, + ValueLayout.JAVA_SHORT, + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS + ) + ) + private val register = downcall("DnsServiceRegister", REQUEST_DESCRIPTOR) + private val deregister = downcall("DnsServiceDeRegister", REQUEST_DESCRIPTOR) + private val freeInstance = downcall( + "DnsServiceFreeInstance", + FunctionDescriptor.ofVoid(ValueLayout.ADDRESS) + ) + private val freeRecordList = downcall( + "DnsRecordListFree", + FunctionDescriptor.ofVoid( + ValueLayout.ADDRESS, + ValueLayout.JAVA_INT + ) + ) + + fun createCallback(callback: MethodHandle): MemorySegment = + linker.upcallStub(callback, CALLBACK_DESCRIPTOR, arena) + + fun browse(request: MemorySegment, cancel: MemorySegment): Int = + browse.invokeWithArguments(request, cancel) as Int + + fun cancelBrowse(cancel: MemorySegment): Int = + browseCancel.invokeWithArguments(cancel) as Int + + fun resolve(request: MemorySegment, cancel: MemorySegment): Int = + resolve.invokeWithArguments(request, cancel) as Int + + fun cancelResolve(cancel: MemorySegment): Int = + resolveCancel.invokeWithArguments(cancel) as Int + + fun constructInstance( + serviceName: MemorySegment, + hostName: MemorySegment, + port: Short, + propertyCount: Int, + keys: MemorySegment, + values: MemorySegment + ): MemorySegment { + return constructInstance.invokeWithArguments( + serviceName, + hostName, + MemorySegment.NULL, + MemorySegment.NULL, + port, + 0.toShort(), + 0.toShort(), + propertyCount, + keys, + values + ) as MemorySegment + } + + fun register(request: MemorySegment): Int = + register.invokeWithArguments(request, MemorySegment.NULL) as Int + + fun deregister(request: MemorySegment): Int = + deregister.invokeWithArguments(request, MemorySegment.NULL) as Int + + fun freeInstance(instance: MemorySegment) { + freeInstance.invokeWithArguments(instance) + } + + fun freeRecordList(records: MemorySegment) { + freeRecordList.invokeWithArguments(records, DNS_FREE_RECORD_LIST) + } + + private fun downcall( + name: String, + descriptor: FunctionDescriptor + ): MethodHandle { + val symbol = symbols.find(name).orElseThrow { + UnsatisfiedLinkError("$name is unavailable in dnsapi.dll") + } + return linker.downcallHandle(symbol, descriptor) + } + + private companion object { + val REQUEST_DESCRIPTOR: FunctionDescriptor = FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS + ) + val CANCEL_DESCRIPTOR: FunctionDescriptor = FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS + ) + val CALLBACK_DESCRIPTOR: FunctionDescriptor = + FunctionDescriptor.ofVoid( + ValueLayout.JAVA_INT, + ValueLayout.ADDRESS, + ValueLayout.ADDRESS + ) + + const val DNS_FREE_RECORD_LIST = 1 + } +} + +internal object WindowsDnsLayouts { + private val pointerSize = ValueLayout.ADDRESS.byteSize() + private val pointerAlignment = ValueLayout.ADDRESS.byteAlignment() + + const val VERSION_OFFSET = 0L + const val INTERFACE_INDEX_OFFSET = 4L + + const val BROWSE_QUERY_NAME_OFFSET = 8L + val BROWSE_CALLBACK_OFFSET = BROWSE_QUERY_NAME_OFFSET + pointerSize + private val browseContextOffset = BROWSE_CALLBACK_OFFSET + pointerSize + val BROWSE_REQUEST_SIZE = + align(browseContextOffset + pointerSize, pointerAlignment) + + const val RESOLVE_QUERY_NAME_OFFSET = 8L + val RESOLVE_CALLBACK_OFFSET = RESOLVE_QUERY_NAME_OFFSET + pointerSize + val RESOLVE_CONTEXT_OFFSET = RESOLVE_CALLBACK_OFFSET + pointerSize + val RESOLVE_REQUEST_SIZE = + align(RESOLVE_CONTEXT_OFFSET + pointerSize, pointerAlignment) + + const val REGISTER_INSTANCE_OFFSET = 8L + val REGISTER_CALLBACK_OFFSET = REGISTER_INSTANCE_OFFSET + pointerSize + private val registerContextOffset = REGISTER_CALLBACK_OFFSET + pointerSize + private val registerCredentialsOffset = registerContextOffset + pointerSize + private val registerUnicastOffset = registerCredentialsOffset + pointerSize + val REGISTER_REQUEST_SIZE = align( + registerUnicastOffset + ValueLayout.JAVA_INT.byteSize(), + pointerAlignment + ) + + const val RECORD_NEXT_OFFSET = 0L + val RECORD_TYPE_OFFSET = pointerSize * 2 + private val recordDataLengthOffset = + RECORD_TYPE_OFFSET + ValueLayout.JAVA_SHORT.byteSize() + private val recordFlagsOffset = + recordDataLengthOffset + ValueLayout.JAVA_SHORT.byteSize() + val RECORD_TTL_OFFSET = + recordFlagsOffset + ValueLayout.JAVA_INT.byteSize() + private val recordReservedOffset = + RECORD_TTL_OFFSET + ValueLayout.JAVA_INT.byteSize() + val RECORD_DATA_OFFSET = align( + recordReservedOffset + ValueLayout.JAVA_INT.byteSize(), + pointerAlignment + ) + val RECORD_READABLE_SIZE = RECORD_DATA_OFFSET + pointerSize + + const val INSTANCE_NAME_OFFSET = 0L + val INSTANCE_IPV4_OFFSET = pointerSize * 2 + val INSTANCE_IPV6_OFFSET = pointerSize * 3 + val INSTANCE_PORT_OFFSET = pointerSize * 4 + private val instancePriorityOffset = + INSTANCE_PORT_OFFSET + ValueLayout.JAVA_SHORT.byteSize() + private val instanceWeightOffset = + instancePriorityOffset + ValueLayout.JAVA_SHORT.byteSize() + val INSTANCE_PROPERTY_COUNT_OFFSET = align( + instanceWeightOffset + ValueLayout.JAVA_SHORT.byteSize(), + ValueLayout.JAVA_INT.byteAlignment() + ) + val INSTANCE_KEYS_OFFSET = align( + INSTANCE_PROPERTY_COUNT_OFFSET + ValueLayout.JAVA_INT.byteSize(), + pointerAlignment + ) + val INSTANCE_VALUES_OFFSET = INSTANCE_KEYS_OFFSET + pointerSize + val INSTANCE_INTERFACE_INDEX_OFFSET = INSTANCE_VALUES_OFFSET + pointerSize + val INSTANCE_READABLE_SIZE = align( + INSTANCE_INTERFACE_INDEX_OFFSET + ValueLayout.JAVA_INT.byteSize(), + pointerAlignment + ) + + val CANCEL_SIZE = pointerSize + val NATIVE_ALIGNMENT = pointerAlignment + val POINTER_BYTE_SIZE = pointerSize + + private fun align(value: Long, alignment: Long): Long { + return (value + alignment - 1) / alignment * alignment + } +} + +internal fun Arena.allocateZeroed(size: Long): MemorySegment { + return allocate(size, WindowsDnsLayouts.NATIVE_ALIGNMENT).apply { + fill(0) + } +} + +internal fun Arena.allocateWideString(value: String): MemorySegment { + return allocateFrom(value, UTF_16LE) +} + +internal fun MemorySegment.readWideString(): String? { + if (address() == 0L) return null + + return runCatching { + reinterpret(MAX_WIDE_STRING_BYTES).getString(0, UTF_16LE) + }.getOrNull() +} + +private const val MAX_WIDE_STRING_BYTES = 2_048L diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsNetworkServices.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsNetworkServices.kt new file mode 100644 index 0000000..9a0a249 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/discovery/windows/WindowsNetworkServices.kt @@ -0,0 +1,866 @@ +package com.liftley.sync360.data.network.discovery.windows + +import com.liftley.sync360.domain.local.LocalDeviceInfoProvider +import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.NearbyDevice +import com.liftley.sync360.domain.model.RegistrationStatus +import com.liftley.sync360.domain.service.NetworkServices +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.lang.foreign.Arena +import java.lang.foreign.MemorySegment +import java.lang.foreign.ValueLayout +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.net.Inet6Address +import java.net.InetAddress +import java.util.concurrent.atomic.AtomicLong + +class WindowsNetworkServices( + private val localDeviceInfoProvider: LocalDeviceInfoProvider +) : NetworkServices { + private val dnsApi = WindowsDnsSdApi() + private val callbackLookup = MethodHandles.lookup() + private val browseCallback = nativeCallback("onNativeBrowseResult") + private val resolveCallback = nativeCallback("onNativeResolveResult") + private val registrationCallback = nativeCallback("onNativeRegistrationResult") + + private val _nearbyDevices = MutableStateFlow>(emptyList()) + override val nearbyDevices: StateFlow> = _nearbyDevices.asStateFlow() + + private val _discoveryServiceStatus = MutableStateFlow(DiscoveryStatus.Idle) + override val discoveryServiceStatus: StateFlow = + _discoveryServiceStatus.asStateFlow() + + private val _registrationServiceStatus = MutableStateFlow(RegistrationStatus.Idle) + override val registrationServiceStatus: StateFlow = + _registrationServiceStatus.asStateFlow() + + private var browseOperation: BrowseOperation? = null + private var registrationOperation: RegistrationOperation? = null + private val resolveOperationIds = AtomicLong() + private val resolveOperationsByService = mutableMapOf() + private val resolveOperationsById = mutableMapOf() + private val resolvedDevicesByServiceKey = mutableMapOf() + // Keep native request memory alive after terminal callbacks because the + // Windows callback is still unwinding when Kotlin receives it. + private val retiredNativeArenas = mutableListOf() + private var pendingRepair: PendingRepair? = null + + override suspend fun startNetworkServices( + httpServerPort: Int, + fileTransferPort: Int + ) { + synchronized(this) { + startDiscoveryService() + startRegistrationService(httpServerPort, fileTransferPort) + } + } + + override suspend fun repairNetworkServices( + httpServerPort: Int, + fileTransferPort: Int + ) { + synchronized(this) { + val discoveryIsStable = + discoveryServiceStatus.value == DiscoveryStatus.Idle || + discoveryServiceStatus.value == DiscoveryStatus.Running + val registrationIsStable = + registrationServiceStatus.value == RegistrationStatus.Idle || + registrationServiceStatus.value == RegistrationStatus.Running + + if (!discoveryIsStable || !registrationIsStable) return + + pendingRepair = PendingRepair(httpServerPort, fileTransferPort) + + if (discoveryServiceStatus.value == DiscoveryStatus.Running) { + stopDiscoveryServices() + } + if (pendingRepair == null) return + + if (registrationServiceStatus.value == RegistrationStatus.Running) { + stopRegistrationService() + } + if (pendingRepair == null) return + + continuePendingRepairIfReady() + } + } + + override fun restartDiscoveryServices() { + synchronized(this) { + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + if (registrationServiceStatus.value != RegistrationStatus.Running) return + + clearResolvedDevices() + startDiscoveryService() + } + } + + override fun stopDiscoveryServices() { + synchronized(this) { + if (discoveryServiceStatus.value != DiscoveryStatus.Running) return + + val operation = browseOperation ?: run { + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearResolvedDevices() + continuePendingRepairIfReady() + return + } + + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + clearResolvedDevices() + cancelResolveOperations() + + val result = runCatching { + dnsApi.cancelBrowse(operation.cancel) + }.getOrElse { exception -> + logFailure("Could not stop Windows DNS-SD discovery", exception) + ERROR_CANCELLED + } + + if (result != ERROR_SUCCESS) { + _discoveryServiceStatus.value = DiscoveryStatus.Running + cancelPendingRepair() + logStatus("DnsServiceBrowseCancel", result) + } + } + } + + private fun startDiscoveryService() { + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + + _discoveryServiceStatus.value = DiscoveryStatus.Starting + val arena = Arena.ofShared() + val request = arena.allocateZeroed(WindowsDnsLayouts.BROWSE_REQUEST_SIZE).apply { + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.VERSION_OFFSET, DNS_REQUEST_VERSION_1) + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.INTERFACE_INDEX_OFFSET, ALL_INTERFACES) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.BROWSE_QUERY_NAME_OFFSET, + arena.allocateWideString(SERVICE_QUERY) + ) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.BROWSE_CALLBACK_OFFSET, + browseCallback + ) + } + val cancel = arena.allocateZeroed(WindowsDnsLayouts.CANCEL_SIZE) + val operation = BrowseOperation(arena, request, cancel) + browseOperation = operation + + val result = runCatching { + dnsApi.browse(request, cancel) + }.getOrElse { exception -> + logFailure("Could not start Windows DNS-SD discovery", exception) + ERROR_CANCELLED + } + + if (result == DNS_REQUEST_PENDING) { + if (discoveryServiceStatus.value == DiscoveryStatus.Starting) { + _discoveryServiceStatus.value = DiscoveryStatus.Running + } + } else { + browseOperation = null + operation.arena.close() + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearResolvedDevices() + cancelPendingRepair() + logStatus("DnsServiceBrowse", result) + } + } + + @Suppress("unused") + private fun onNativeBrowseResult( + status: Int, + queryContext: MemorySegment, + records: MemorySegment + ) { + runCatching { + handleBrowseResult(status, records) + }.onFailure { exception -> + logFailure("Windows DNS-SD browse callback failed", exception) + } + } + + @Synchronized + private fun handleBrowseResult(status: Int, records: MemorySegment) { + if (status == ERROR_CANCELLED) { + freeDnsRecords(records) + browseOperation?.arena?.let(::retireNativeArena) + browseOperation = null + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearResolvedDevices() + continuePendingRepairIfReady() + return + } + + if (status != ERROR_SUCCESS) { + freeDnsRecords(records) + browseOperation?.arena?.let(::retireNativeArena) + browseOperation = null + _discoveryServiceStatus.value = DiscoveryStatus.Idle + clearResolvedDevices() + cancelResolveOperations() + cancelPendingRepair() + logStatus("Windows DNS-SD browse callback", status) + return + } + + if (!discoveryIsActive()) { + freeDnsRecords(records) + return + } + + try { + var recordPointer = records + while (!recordPointer.isNullPointer()) { + val record = recordPointer.reinterpret(WindowsDnsLayouts.RECORD_READABLE_SIZE) + val recordType = record.get( + ValueLayout.JAVA_SHORT, + WindowsDnsLayouts.RECORD_TYPE_OFFSET + ).toInt() and 0xFFFF + + if (recordType == DNS_TYPE_PTR) { + val serviceName = record.get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.RECORD_DATA_OFFSET + ).readWideString() + if (!serviceName.isNullOrBlank()) { + val ttl = record.get( + ValueLayout.JAVA_INT, + WindowsDnsLayouts.RECORD_TTL_OFFSET + ) + if (ttl == 0) { + removeService(serviceName) + } else { + startResolveService(serviceName) + } + } + } + + recordPointer = record.get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.RECORD_NEXT_OFFSET + ) + } + } finally { + freeDnsRecords(records) + } + } + + private fun startResolveService(serviceName: String) { + val serviceKey = serviceName.normalizedServiceKey() + if (resolveOperationsByService.containsKey(serviceKey)) return + + val operationId = resolveOperationIds.incrementAndGet() + val arena = Arena.ofShared() + val context = arena.allocate(ValueLayout.JAVA_LONG).apply { + set(ValueLayout.JAVA_LONG, 0, operationId) + } + val request = arena.allocateZeroed(WindowsDnsLayouts.RESOLVE_REQUEST_SIZE).apply { + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.VERSION_OFFSET, DNS_REQUEST_VERSION_1) + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.INTERFACE_INDEX_OFFSET, ALL_INTERFACES) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.RESOLVE_QUERY_NAME_OFFSET, + arena.allocateWideString(serviceName) + ) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.RESOLVE_CALLBACK_OFFSET, + resolveCallback + ) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.RESOLVE_CONTEXT_OFFSET, + context + ) + } + val cancel = arena.allocateZeroed(WindowsDnsLayouts.CANCEL_SIZE) + val operation = ResolveOperation( + id = operationId, + serviceName = serviceName, + arena = arena, + request = request, + cancel = cancel + ) + resolveOperationsByService[serviceKey] = operation + resolveOperationsById[operationId] = operation + + val result = runCatching { + dnsApi.resolve(request, cancel) + }.getOrElse { exception -> + logFailure("Could not resolve $serviceName through Windows DNS-SD", exception) + ERROR_CANCELLED + } + + if (result != DNS_REQUEST_PENDING) { + removeResolveOperation(operation, retireArena = false) + logStatus("DnsServiceResolve", result) + } + } + + @Suppress("unused") + private fun onNativeResolveResult( + status: Int, + queryContext: MemorySegment, + instance: MemorySegment + ) { + if (queryContext.isNullPointer()) { + if (!instance.isNullPointer()) { + dnsApi.freeInstance(instance) + } + return + } + + runCatching { + val operationId = queryContext + .reinterpret(ValueLayout.JAVA_LONG.byteSize()) + .get(ValueLayout.JAVA_LONG, 0) + handleResolveResult(operationId, status, instance) + }.onFailure { exception -> + logFailure("Windows DNS-SD resolve callback failed", exception) + } + } + + @Synchronized + private fun handleResolveResult( + operationId: Long, + status: Int, + instancePointer: MemorySegment + ) { + try { + val operation = resolveOperationsById[operationId] ?: return + + if (status == ERROR_CANCELLED) { + removeResolveOperation(operation, retireArena = true) + return + } + + if (status != ERROR_SUCCESS) { + removeResolveOperation(operation, retireArena = true) + logStatus("Windows DNS-SD resolve callback", status) + return + } + + val activeOperation = resolveOperationsByService[ + operation.serviceName.normalizedServiceKey() + ] + if (activeOperation?.id != operation.id) return + + if (!discoveryIsActive()) return + + if (instancePointer.isNullPointer()) return + val instance = instancePointer.reinterpret(WindowsDnsLayouts.INSTANCE_READABLE_SIZE) + val nearbyDevice = instance.toNearbyDevice() ?: return + val localDeviceId = localDeviceInfoProvider.getLocalDeviceInfo().deviceId + if (nearbyDevice.id == localDeviceId) return + + val interfaceIndex = instance.get( + ValueLayout.JAVA_INT, + WindowsDnsLayouts.INSTANCE_INTERFACE_INDEX_OFFSET + ) + val resolvedKey = + "${operation.serviceName.normalizedServiceKey()}|$interfaceIndex" + resolvedDevicesByServiceKey[resolvedKey] = nearbyDevice + publishMergedDevices() + } finally { + if (!instancePointer.isNullPointer()) { + dnsApi.freeInstance(instancePointer) + } + } + } + + private fun startRegistrationService( + httpServerPort: Int, + fileTransferPort: Int + ) { + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + _registrationServiceStatus.value = RegistrationStatus.Starting + val localDevice = localDeviceInfoProvider.getLocalDeviceInfo() + val properties = linkedMapOf( + "deviceUuid" to localDevice.deviceId, + "deviceName" to localDevice.deviceName, + "deviceType" to localDevice.deviceType, + "protocolVersion" to localDevice.protocolVersion, + "fileTransferPort" to fileTransferPort.toString() + ) + val serviceInstance = Arena.ofConfined().use { temporaryArena -> + val keys = temporaryArena.allocatePointerArray(properties.keys) + val values = temporaryArena.allocatePointerArray(properties.values) + runCatching { + dnsApi.constructInstance( + serviceName = temporaryArena.allocateWideString( + "${localDevice.deviceName} Sync360.$SERVICE_QUERY" + ), + hostName = temporaryArena.allocateWideString(localHostName()), + port = httpServerPort.toShort(), + propertyCount = properties.size, + keys = keys, + values = values + ) + }.getOrElse { exception -> + logFailure("Could not create the Windows DNS-SD service", exception) + MemorySegment.NULL + } + } + + if (serviceInstance.isNullPointer()) { + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + return + } + + val arena = Arena.ofShared() + val request = arena.allocateZeroed(WindowsDnsLayouts.REGISTER_REQUEST_SIZE).apply { + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.VERSION_OFFSET, DNS_REQUEST_VERSION_1) + set(ValueLayout.JAVA_INT, WindowsDnsLayouts.INTERFACE_INDEX_OFFSET, ALL_INTERFACES) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.REGISTER_INSTANCE_OFFSET, + serviceInstance + ) + set( + ValueLayout.ADDRESS, + WindowsDnsLayouts.REGISTER_CALLBACK_OFFSET, + registrationCallback + ) + } + val operation = RegistrationOperation( + arena = arena, + request = request, + serviceInstance = serviceInstance, + action = RegistrationAction.Registering + ) + registrationOperation = operation + + val result = runCatching { + dnsApi.register(request) + }.getOrElse { exception -> + logFailure("Could not register the Windows DNS-SD service", exception) + ERROR_CANCELLED + } + + if (result != DNS_REQUEST_PENDING) { + releaseRegistrationOperation(retireArena = false) + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + logStatus("DnsServiceRegister", result) + } + } + + @Suppress("unused") + private fun onNativeRegistrationResult( + status: Int, + queryContext: MemorySegment, + instance: MemorySegment + ) { + runCatching { + handleRegistrationResult(status, instance) + }.onFailure { exception -> + logFailure("Windows DNS-SD registration callback failed", exception) + } + } + + @Synchronized + private fun handleRegistrationResult( + status: Int, + instancePointer: MemorySegment + ) { + try { + val operation = registrationOperation ?: return + + when (operation.action) { + RegistrationAction.Registering -> { + if (status == ERROR_SUCCESS) { + _registrationServiceStatus.value = RegistrationStatus.Running + } else { + releaseRegistrationOperation(retireArena = true) + _registrationServiceStatus.value = RegistrationStatus.Idle + cancelPendingRepair() + logStatus("Windows DNS-SD registration callback", status) + } + } + + RegistrationAction.Deregistering -> { + if (status == ERROR_SUCCESS || status == ERROR_CANCELLED) { + releaseRegistrationOperation(retireArena = true) + _registrationServiceStatus.value = RegistrationStatus.Idle + continuePendingRepairIfReady() + } else { + operation.action = RegistrationAction.Registering + _registrationServiceStatus.value = RegistrationStatus.Running + cancelPendingRepair() + logStatus("Windows DNS-SD deregistration callback", status) + } + } + + } + } finally { + if (!instancePointer.isNullPointer()) { + dnsApi.freeInstance(instancePointer) + } + } + } + + private fun stopRegistrationService() { + if (registrationServiceStatus.value != RegistrationStatus.Running) return + + val operation = registrationOperation ?: run { + _registrationServiceStatus.value = RegistrationStatus.Idle + continuePendingRepairIfReady() + return + } + + _registrationServiceStatus.value = RegistrationStatus.Stopping + operation.action = RegistrationAction.Deregistering + + val result = runCatching { + dnsApi.deregister(operation.request) + }.getOrElse { exception -> + logFailure("Could not deregister the Windows DNS-SD service", exception) + ERROR_CANCELLED + } + + if (result != DNS_REQUEST_PENDING) { + operation.action = RegistrationAction.Registering + _registrationServiceStatus.value = RegistrationStatus.Running + cancelPendingRepair() + logStatus("DnsServiceDeRegister", result) + } + } + + private fun MemorySegment.toNearbyDevice(): NearbyDevice? { + val properties = readProperties() + val deviceUuid = properties["deviceUuid"] ?: return null + val deviceName = properties["deviceName"] ?: return null + val deviceType = properties["deviceType"] ?: return null + val protocolVersion = properties["protocolVersion"] ?: return null + val fileTransferPort = properties["fileTransferPort"] + ?.toIntOrNull() + ?.takeIf { it > 0 } + ?: return null + val httpPort = get( + ValueLayout.JAVA_SHORT, + WindowsDnsLayouts.INSTANCE_PORT_OFFSET + ).toInt() and 0xFFFF + if (httpPort <= 0) return null + + val interfaceIndex = get( + ValueLayout.JAVA_INT, + WindowsDnsLayouts.INSTANCE_INTERFACE_INDEX_OFFSET + ) + val addresses = buildList { + get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.INSTANCE_IPV4_OFFSET + ).readAddress(IPV4_ADDRESS_SIZE, interfaceIndex)?.let(::add) + get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.INSTANCE_IPV6_OFFSET + ).readAddress(IPV6_ADDRESS_SIZE, interfaceIndex)?.let(::add) + }.distinct() + if (addresses.isEmpty()) return null + + val fullServiceName = get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.INSTANCE_NAME_OFFSET + ).readWideString() ?: return null + val normalizedServiceName = fullServiceName.trimEnd('.') + val serviceSuffix = ".$SERVICE_QUERY" + val serviceName = if ( + normalizedServiceName.endsWith(serviceSuffix, ignoreCase = true) + ) { + normalizedServiceName.dropLast(serviceSuffix.length) + } else { + normalizedServiceName + } + + return NearbyDevice( + id = deviceUuid, + deviceName = deviceName, + deviceType = deviceType, + protocolVersion = protocolVersion, + hostAddresses = addresses, + port = httpPort, + fileTransferPort = fileTransferPort, + serviceName = serviceName, + serviceType = ANDROID_STYLE_SERVICE_TYPE + ) + } + + private fun MemorySegment.readProperties(): Map { + val propertyCount = get( + ValueLayout.JAVA_INT, + WindowsDnsLayouts.INSTANCE_PROPERTY_COUNT_OFFSET + ).coerceIn(0, MAX_PROPERTY_COUNT) + if (propertyCount == 0) return emptyMap() + + val keys = get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.INSTANCE_KEYS_OFFSET + ) + val values = get( + ValueLayout.ADDRESS, + WindowsDnsLayouts.INSTANCE_VALUES_OFFSET + ) + if (keys.isNullPointer() || values.isNullPointer()) return emptyMap() + + val arraySize = propertyCount.toLong() * WindowsDnsLayouts.POINTER_BYTE_SIZE + val keyArray = keys.reinterpret(arraySize) + val valueArray = values.reinterpret(arraySize) + + return buildMap { + repeat(propertyCount) { index -> + val key = keyArray + .getAtIndex(ValueLayout.ADDRESS, index.toLong()) + .readWideString() + val value = valueArray + .getAtIndex(ValueLayout.ADDRESS, index.toLong()) + .readWideString() + if (!key.isNullOrBlank() && value != null) { + put(key, value) + } + } + } + } + + private fun Arena.allocatePointerArray(values: Collection): MemorySegment { + val pointers = allocate(ValueLayout.ADDRESS, values.size.toLong()) + values.forEachIndexed { index, value -> + pointers.setAtIndex( + ValueLayout.ADDRESS, + index.toLong(), + allocateWideString(value) + ) + } + return pointers + } + + private fun MemorySegment.readAddress(size: Int, interfaceIndex: Int): String? { + if (isNullPointer()) return null + + return runCatching { + val bytes = reinterpret(size.toLong()).toArray(ValueLayout.JAVA_BYTE) + val address = InetAddress.getByAddress(bytes) + + if ( + address is Inet6Address && + address.isLinkLocalAddress && + interfaceIndex > 0 + ) { + Inet6Address.getByAddress(null, bytes, interfaceIndex).hostAddress + } else { + address.hostAddress + } + }.getOrNull() + } + + private fun removeService(serviceName: String) { + val serviceKey = serviceName.normalizedServiceKey() + resolvedDevicesByServiceKey.keys + .filter { key -> key.startsWith("$serviceKey|") } + .forEach(resolvedDevicesByServiceKey::remove) + + resolveOperationsByService[serviceKey]?.let(::cancelResolveOperation) + publishMergedDevices() + } + + private fun cancelResolveOperations() { + resolveOperationsByService.values + .toList() + .forEach(::cancelResolveOperation) + } + + private fun cancelResolveOperation(operation: ResolveOperation) { + resolveOperationsByService.remove(operation.serviceName.normalizedServiceKey()) + val result = runCatching { + dnsApi.cancelResolve(operation.cancel) + }.getOrElse { exception -> + logFailure("Could not cancel a Windows DNS-SD resolve operation", exception) + ERROR_CANCELLED + } + + if (result != ERROR_SUCCESS) { + logStatus("DnsServiceResolveCancel", result) + } + } + + private fun removeResolveOperation( + operation: ResolveOperation, + retireArena: Boolean + ) { + val serviceKey = operation.serviceName.normalizedServiceKey() + if (resolveOperationsByService[serviceKey]?.id == operation.id) { + resolveOperationsByService.remove(serviceKey) + } + resolveOperationsById.remove(operation.id) + if (retireArena) { + retireNativeArena(operation.arena) + } else { + operation.arena.close() + } + } + + private fun clearResolvedDevices() { + resolvedDevicesByServiceKey.clear() + _nearbyDevices.value = emptyList() + } + + private fun publishMergedDevices() { + _nearbyDevices.value = resolvedDevicesByServiceKey.values + .groupBy { device -> device.id } + .values + .map { matchingDevices -> + val firstDevice = matchingDevices.first() + firstDevice.copy( + hostAddresses = matchingDevices + .flatMap { device -> device.hostAddresses } + .distinct() + ) + } + .sortedBy { device -> device.deviceName.lowercase() } + } + + private fun releaseRegistrationOperation(retireArena: Boolean) { + val operation = registrationOperation ?: return + registrationOperation = null + dnsApi.freeInstance(operation.serviceInstance) + if (retireArena) { + retireNativeArena(operation.arena) + } else { + operation.arena.close() + } + } + + private fun retireNativeArena(arena: Arena) { + retiredNativeArenas += arena + } + + private fun continuePendingRepairIfReady() { + val repair = pendingRepair ?: return + if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + pendingRepair = null + clearResolvedDevices() + startDiscoveryService() + startRegistrationService( + httpServerPort = repair.httpServerPort, + fileTransferPort = repair.fileTransferPort + ) + } + + private fun cancelPendingRepair() { + pendingRepair = null + } + + private fun freeDnsRecords(records: MemorySegment) { + if (!records.isNullPointer()) { + dnsApi.freeRecordList(records) + } + } + + private fun localHostName(): String { + val rawHostName = runCatching { + InetAddress.getLocalHost().hostName + }.getOrNull() + ?.substringBefore('.') + ?.replace(Regex("[^A-Za-z0-9-]"), "-") + ?.trim('-') + ?.take(63) + ?.takeIf { it.isNotBlank() } + ?: "sync360" + + return "$rawHostName.local" + } + + private fun String.normalizedServiceKey(): String { + return trimEnd('.').lowercase() + } + + private fun nativeCallback(methodName: String): MemorySegment { + val callback = callbackLookup.findVirtual( + WindowsNetworkServices::class.java, + methodName, + NATIVE_CALLBACK_TYPE + ).bindTo(this) + return dnsApi.createCallback(callback) + } + + private fun discoveryIsActive(): Boolean { + return discoveryServiceStatus.value == DiscoveryStatus.Starting || + discoveryServiceStatus.value == DiscoveryStatus.Running + } + + private fun MemorySegment.isNullPointer(): Boolean { + return address() == 0L + } + + private fun logStatus(operation: String, status: Int) { + if (status != ERROR_SUCCESS && status != ERROR_CANCELLED) { + System.err.println("$operation failed with Windows status $status") + } + } + + private fun logFailure(message: String, exception: Throwable) { + System.err.println("$message: ${exception.message}") + exception.printStackTrace() + } + + private data class BrowseOperation( + val arena: Arena, + val request: MemorySegment, + val cancel: MemorySegment + ) + + private data class ResolveOperation( + val id: Long, + val serviceName: String, + val arena: Arena, + val request: MemorySegment, + val cancel: MemorySegment + ) + + private data class RegistrationOperation( + val arena: Arena, + val request: MemorySegment, + val serviceInstance: MemorySegment, + var action: RegistrationAction + ) + + private enum class RegistrationAction { + Registering, + Deregistering + } + + private data class PendingRepair( + val httpServerPort: Int, + val fileTransferPort: Int + ) + + private companion object { + val NATIVE_CALLBACK_TYPE: MethodType = MethodType.methodType( + Void.TYPE, + Int::class.javaPrimitiveType, + MemorySegment::class.java, + MemorySegment::class.java + ) + + const val SERVICE_QUERY = "_sync360._tcp.local" + const val ANDROID_STYLE_SERVICE_TYPE = "_sync360._tcp." + const val ALL_INTERFACES = 0 + const val DNS_REQUEST_VERSION_1 = 1 + const val DNS_REQUEST_PENDING = 9506 + const val DNS_TYPE_PTR = 12 + const val ERROR_SUCCESS = 0 + const val ERROR_CANCELLED = 1223 + const val IPV4_ADDRESS_SIZE = 4 + const val IPV6_ADDRESS_SIZE = 16 + const val MAX_PROPERTY_COUNT = 64 + } +} diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/tcp/JvmFileTransferReceiver.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/tcp/JvmFileTransferReceiver.kt index 9447880..0ffdf36 100644 --- a/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/tcp/JvmFileTransferReceiver.kt +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/data/network/tcp/JvmFileTransferReceiver.kt @@ -38,8 +38,8 @@ class JvmFileTransferReceiver( override var port: Int = 0 private set - override suspend fun start() { - if (serverSocket != null) return + override suspend fun start(): Int { + if (serverSocket != null) return port val startedServerSocket = withContext(Dispatchers.IO) { ServerSocket(0) @@ -57,6 +57,8 @@ class JvmFileTransferReceiver( } } } + + return port } @Synchronized diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.jvm.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.jvm.kt index a8d6065..e8a1a9f 100644 --- a/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/send/components/FilesSendContent.jvm.kt @@ -1,121 +1,42 @@ package com.liftley.sync360.presentation.send.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.core.designsystem.icons.Close -import com.liftley.sync360.presentation.app.components.Sync360Surface -import com.liftley.sync360.presentation.send.SendScreenViewModel -import org.koin.compose.koinInject +import com.liftley.sync360.domain.model.SelectedFile import java.awt.FileDialog import java.awt.Frame +import java.io.File @Composable -actual fun FilesSendContent() { - val sendScreenViewModel = koinInject() - val sendScreenState = sendScreenViewModel.screenState.collectAsStateWithLifecycle() - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Selected Files", - style = MaterialTheme.typography.titleLarge - ) - if (sendScreenState.value.files.isNotEmpty()) { - IconButton( - onClick = sendScreenViewModel::clearSelectedFiles, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier.height(48.dp) - ) { - Icon(imageVector = Close, contentDescription = null) - } - } - } - - if (sendScreenState.value.files.isNotEmpty()) { - val files = sendScreenState.value.files - - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 250.dp) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(files) { file -> - FileItemCard(file) { - sendScreenViewModel.removeSelectedFileFromList(it) - } - } - } - - if (files.size > 3) { - Text( - text = "${files.size} files selected - scroll to view", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - } - } - } - } - - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = MaterialTheme.shapes.large, - onClick = { - val selectedFiles = FileDialog( +actual fun FilesSendContent( + files: List, + onFilesSelected: (List) -> Unit, + onClearFiles: () -> Unit, + onRemoveFile: (SelectedFile) -> Unit +) { + FileSelectionContent( + files = files, + onClearFiles = onClearFiles, + onRemoveFile = onRemoveFile, + onPickMedia = null, + onPickFiles = { + val dialog = FileDialog( null as Frame?, "Select files", FileDialog.LOAD - ).run { - isMultipleMode = true - isVisible = true - files.toList() + ) + val selectedFiles = try { + dialog.isMultipleMode = true + dialog.isVisible = true + dialog.files.toList() + } finally { + dialog.dispose() } if (selectedFiles.isNotEmpty()) { - sendScreenViewModel.handleFilesSelected(selectedFiles) + onFilesSelected(selectedFiles) } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "Choose Files", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp) - ) - } + } + ) } + +internal actual fun filePreviewModel(file: SelectedFile): Any = File(file.uri)