diff --git a/CHANGELOG.md b/CHANGELOG.md index 880c89a..3286a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- Remove the separate discovery repair command, its Settings screen, and Troubleshoot shortcuts; use Start/Stop or failure retry in Nearby devices. + +- Tie Android nearby discovery to app visibility, with a cancellable two-second background grace period after the process lifecycle delay. +- Remove the 60-second scan expiry and add Start discovery / Stop discovery controls for browsing and advertising. Manual Stop survives background/foreground transitions within the same process. +- Centralize discovery-session start/stop coordination while preserving transfer listeners and content state. Desktop discovery remains active when minimized. +- Give Android scans separate callback ownership, wait for service-info callback cleanup, and queue Android 13 legacy resolution. +- Keep failed discovery cleanup retryable and ignore resolutions for services lost and found again. +- Replace Reload and the top-bar discovery action with Start/Stop and failure retry in the Nearby devices section. +- Present nearby devices in compact rows with a header Stop action and a centered Start discovery state when off. +- Shorten discovery messages and make lifecycle, callback, and platform-operation names more descriptive. + +These changes have not yet been built or validated on devices. + ## [0.4.1] - 2026-08-28 ### Changed diff --git a/README.md b/README.md index 622c19c..aef3217 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ In an initial Windows 11 Ethernet test, the native Windows DNS-SD backend discov - 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 @@ -107,7 +106,7 @@ flowchart LR 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. +Android discovery follows app visibility, with a cancellable two-second grace period after the process lifecycle reports backgrounding. Returning quickly keeps the same session. Discovery stays active while the app is visible, with no 60-second expiry. Desktop remains available while running, including when minimized. Start discovery / Stop discovery control browsing and advertising on both; a manual Stop stays off until Start or a fresh process launch. Discovery cleanup does not cancel an existing transfer or block a peer that already knows the listening address. These lifecycle changes still require device validation. ### Text path @@ -248,9 +247,9 @@ macOS/Linux: Some routers enable client isolation and block local device-to-device traffic. If discovery or transfer does not work, try another trusted Wi-Fi network or a phone hotspot. -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. +If devices disappear after a network change, use **Stop**, wait for discovery to stop, then **Start discovery** in the Nearby devices section on Send. -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. +Stop discovery requests cleanup of browsing and advertising. Start discovery enables them again. Start/Stop lives in the Nearby devices section, with Try again shown when discovery fails. Android does not monitor network changes; use Stop discovery and Start discovery if devices disappear after changing networks. ## Security warning @@ -268,7 +267,7 @@ Use the current app only for development and testing on private networks you con - Add integrity verification. - Test cancellation and failure reporting across more network-loss and transfer stages. - Strengthen lifecycle behavior and local-network reliability. -- Add Android 17 local-network permission handling and serialize Android 13 legacy NSD resolves. +- Add Android 17 local-network permission handling and validate queued Android 13 legacy NSD resolution. - Validate Desktop discovery and transfer across more operating systems, network adapters, routers, and firewall configurations. - Design session validation, authentication, and encryption deliberately. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 888e93a..d804684 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(projects.shared) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.compose.uiToolingPreview) debugImplementation(libs.compose.uiTooling) diff --git a/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt b/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt new file mode 100644 index 0000000..9aeb3c5 --- /dev/null +++ b/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt @@ -0,0 +1,37 @@ +package com.liftley.sync360 + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.liftley.sync360.data.NetworkServicesController + +/** Keeps nearby discovery active while the app is visible, with time to return before stopping. */ +internal class AndroidNearbyDiscoveryObserver( + private val networkServicesController: NetworkServicesController +) : DefaultLifecycleObserver { + private val mainHandler = Handler(Looper.getMainLooper()) + private val stopDiscoveryAfterDelay = Runnable { + networkServicesController.setDiscoveryAllowedByLifecycle(false) + } + + fun observeAppVisibility() { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + override fun onStart(owner: LifecycleOwner) { + mainHandler.removeCallbacks(stopDiscoveryAfterDelay) + networkServicesController.setDiscoveryAllowedByLifecycle(true) + } + + override fun onStop(owner: LifecycleOwner) { + mainHandler.removeCallbacks(stopDiscoveryAfterDelay) + mainHandler.postDelayed(stopDiscoveryAfterDelay, BACKGROUND_GRACE_MILLIS) + } + + private companion object { + // Begins after ProcessLifecycleOwner's own background-event delay. + const val BACKGROUND_GRACE_MILLIS = 2_000L + } +} diff --git a/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt b/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt index 3583af5..24e9016 100644 --- a/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt +++ b/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt @@ -13,8 +13,8 @@ class Sync360Application : Application() { androidContext(applicationContext) } - koinApplication.koin - .get() - .startNetworkServices() + val networkServices = koinApplication.koin.get() + networkServices.startNetworkServices(discoveryAllowedAtStartup = false) + AndroidNearbyDiscoveryObserver(networkServices).observeAppVisibility() } -} \ No newline at end of file +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77954bd..2e7289c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,7 +72,7 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket ### Controllers -- `NetworkServicesController` starts the HTTP server, file receiver, and discovery/registration once for the application lifetime. It also coordinates timed discovery stop, discovery restart, and full connection repair. +- `NetworkServicesController` owns application-lifetime HTTP/file listeners and serializes discovery intent separately. Platform callbacks report progress; the controller decides when to start or stop a discovery session. Discovery stop never closes transfer sockets. - `OutgoingRequestsController` validates and delivers text, creates code-bearing file offers, and starts accepted file transfers. - `IncomingServerRequestsController` generates one in-memory four-digit file receive code for its application session. Under one operation mutex it admits direct text only while idle, or atomically checks the file code, prepares the platform receiver, and publishes `ReceivingFiles`. Text follows `Idle -> TextReceived -> Idle`; files follow `Idle -> ReceivingFiles -> FilesReceived/Idle`. @@ -86,13 +86,19 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket 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. -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. +Discovery and registration expose independent `StateFlow` values (`Idle`, `Starting`, `Running`, `Stopping`); discovery also reports `CleanupFailed` when resources remain owned after cleanup fails. A retry must finish cleanup before a replacement session starts. There is no scan expiry timer. Start discovery and Stop discovery control both browsing and advertising; a manual Stop remains in effect across background/foreground transitions until Start is pressed or the process restarts. Registration success is not a guarantee of reachability from another device. -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. +`AndroidNearbyDiscoveryObserver` observes only app visibility through `ProcessLifecycleOwner`. Foreground entry allows discovery immediately. Background entry schedules a stop after two seconds, in addition to the lifecycle owner's own delay. Returning before that stop cancels it and keeps the current session; returning after cleanup starts a new session unless the user manually stopped discovery. It does not monitor networks or automatically refresh after network changes; users can Stop and Start discovery themselves. Desktop and iOS retain process-lifetime availability; minimizing Desktop does not stop discovery. iOS visibility integration is not part of this change. + +The controller waits for both operations to leave transitional states before issuing a replacement start. Users can Stop discovery and Start it again using the existing HTTP and TCP ports; there is no separate repair command. A failed start/stop is surfaced for explicit retry rather than automatically retried forever. Missing native completion callbacks are not treated as successful cleanup. + +Android discovery commands and callbacks are serialized on the main dispatcher using asynchronous NSD APIs. Each scan owns a distinct listener, result map, and Android 14+ service-info callbacks; a replacement session waits for tracking callbacks to unregister. Old callbacks cannot publish into a newer session. Android 13 one-shot resolution is queued; an outstanding legacy resolve retains its slot until completion because that API level has no stop-resolution API. Its late result is discarded after its session ends or the service is lost and found again. + +Windows cancels browse and pending resolves and deregisters through the native API. The macOS/Linux fallback closes JmDNS instances off the UI thread; instances that fail to close remain owned for a later cleanup attempt. Platform-native callback/memory ownership remains necessary. 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 Windows implementation keeps native request memory alive after terminal callbacks because a callback is still unwinding when Kotlin receives it. Those retired arenas are not yet closed later, so repeated repair cycles can retain small native allocations. Resolved results are keyed by service name and interface, but a TTL-zero browse removal currently clears every interface result for that service name. +The Windows implementation keeps native request memory alive after terminal callbacks because a callback is still unwinding when Kotlin receives it. Those retired arenas are not yet closed later, so repeated discovery restart cycles can retain small native allocations. Resolved results are keyed by service name and interface, but a TTL-zero browse removal currently clears every interface result for that service name. 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. @@ -155,11 +161,11 @@ Previously completed files remain when a later file in the same batch fails. - No authentication, encryption, session token, or cryptographic integrity check. The four-digit code has no attempt throttling and must not be treated as a security boundary. - No retry, pause/resume, or interrupted-transfer recovery. -- Foreground/background and automatic network-change lifecycle handling are not complete. -- Android 17 local-network permission handling is not implemented even though the app targets SDK 37; Android 13 legacy NSD resolves are not serialized or retried after an already-active failure. +- Android foreground discovery is implemented but requires device validation. Automatic network-change recovery is deferred. Background transfer guarantees, Desktop wake/network recovery, and iOS visibility handling remain outside this change. +- Android 17 local-network permission handling is not implemented even though the app targets SDK 37; Android 13 legacy NSD resolves are serialized, but failed resolves are not automatically retried. - Receiver failures do not yet provide rich error details. - HTTP and file-transfer senders retry distinct advertised addresses after connection failures; broader address preference and scoped IPv6 validation still need work. - Desktop interface selection and firewall behavior need broader validation. Windows inbound transfers require an application allow rule or user-approved firewall prompt. -- Repeated Windows repair cycles retain completed native callback arenas, and removing a service from one interface can temporarily clear the same service resolved through another interface. +- Repeated Windows discovery restart cycles retain completed native callback arenas, and removing a service from one interface can temporarily clear the same service resolved through another interface. - Automated transfer coverage is minimal. - 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 fd6dc48..aff2015 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -56,7 +56,7 @@ Windows: ## Preparing public packages -The current package version is `0.4.1`. +The current package version is `0.4.2`. Android release APKs must use the maintainer's permanent private signing key. Copy `keystore.properties.example` to the ignored `keystore.properties` file and set: @@ -95,7 +95,7 @@ The Windows `upgradeUuid` must remain unchanged for the lifetime of Sync360, and 8. Confirm completed files appear in Downloads. 9. Resize the Desktop window and verify compact single-pane navigation and the wider 50/50 Send/Receive layout. -For Windows testing, check IPv4 and IPv6 with Ethernet, Wi-Fi, VPN, WSL, Docker, Hyper-V, or virtual-machine adapters. Windows DNS-SD browses and registers with interface index `0`, so Windows selects the applicable interfaces. Confirm discovery and resolution, live removal when a nearby app closes, removal of Windows from the other device after the Desktop app closes, Reload, and full connection repair. +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, manual Stop/Start. On first network use, allow Sync360 on the intended private network when Windows Firewall prompts. The current MSI does not install its own inbound firewall exception; a denied prompt or administrator policy can block incoming HTTP and file-transfer sockets. @@ -103,12 +103,25 @@ Android currently targets SDK 37 but does not yet declare or request Android 17' macOS and Linux currently retain JmDNS. Test those systems with multiple adapters as well because JmDNS starts separately on each eligible address. +## Discovery lifecycle validation (not yet run) + +- Keep Android visible beyond 60 seconds: scanning and advertising must remain active. +- Background/return every second several times: no teardown inside the grace period; rotate and open/return from the system file picker as well. +- Leave Android hidden beyond the lifecycle delay plus two-second grace: browsing, tracking callbacks and registration stop. Return and check both directions of discovery. +- Return while stop callbacks are still arriving: only one replacement session starts, after cleanup. Old results must not appear in it. +- Tap Stop discovery while starting, scanning, and transferring. Discovery stops, existing transfer resources remain untouched, and selected/received content remains. Background/return must not undo manual Stop. Start enables it again. +- Switch LANs while visible, then use Stop discovery and Start discovery to find peers again. There is no app-level network monitoring or automatic refresh. Check local-only Wi-Fi without Internet, Ethernet, and hotspot discovery. +- Check Android 13 with multiple peers and with a resolve completing after backgrounding. +- Minimize Desktop: discovery stays active. Check repeated manual Stop/Start on each Desktop backend. +- Force a service-info callback cleanup failure: show Try again in the Nearby devices section, retain callback ownership, and finish cleanup before restarting. +- Observe platform startup/stop failures: no unbounded automatic retry and no synthetic successful cleanup. Android 17 local-network permission work remains outstanding. + ## If discovery or transfer fails - Confirm both devices are on the same local network. - Check whether the router enables client isolation. - Try a trusted phone hotspot or another router. -- Keep both apps open; background/foreground lifecycle support is not complete. +- Keep both Android apps visible during transfers. Android discovery stops after a background grace period and resumes on return unless manually stopped; this does not guarantee background transfer execution. - Check the OS firewall and local-network permissions. - On Windows, confirm an inbound allow rule exists for Sync360 if the first-run firewall prompt was dismissed or denied. - Verify that HTTP and file-transfer ports are non-zero in logs. @@ -118,11 +131,11 @@ macOS and Linux currently retain JmDNS. Test those systems with multiple adapter Useful source locations: - 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. +- `NetworkServicesController` — listener startup and discovery start/stop coordination. +- `AndroidNetworkServices` — callback-driven Android NSD registration, discovery, resolution, and cleanup. - `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. +- `JvmNetworkServices` — current macOS/Linux JmDNS registration, discovery, discovery cleanup, and IPv4/IPv6 LAN-interface selection. - `Sync360HttpServer` / `Sync360HttpClient` — direct text delivery and file control routes. - `OutgoingRequestsController` / `IncomingServerRequestsController` — send/receive coordination. - platform `FileTransmitter`, `FileTransferReceiver`, and `DownloadsWriter` implementations — file bytes and storage. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7798337..fd92a59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,8 +8,8 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby - 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. +- Continuous discovery while enabled, with Android visibility controlling its lifetime (device validation pending). +- Explicit Start/Stop for browsing and advertising, a cancellable Android background grace period, and callback-driven discovery-session refresh. - Dynamic HTTP and file-transfer ports advertised with device metadata. - One-request text delivery with sender name, a 100,000-character limit, Copy, and Clear. - Android and Desktop multiple-file selection. @@ -27,6 +27,8 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby ## Next +Upcoming preview versions prioritize code review, quality, failure handling, and validation of the core sharing experience on the path to a stable 1.0. Further native discovery work is under consideration; scope and timing are not confirmed. + ### Transfer feedback and reliability - Improve receiver-side failure details and per-file results. @@ -36,11 +38,11 @@ Sync360 is an active Android-first rebuild. The current MVP can discover nearby ### Discovery and lifecycle -- Detect network/address changes and repair registration automatically. +- Revisit automatic Android network-change recovery later; for now, users can Stop and Start discovery. Implement Desktop wake/network recovery. - Add the appropriate Android foreground/background service behavior. - Add Android 17 `ACCESS_LOCAL_NETWORK` declaration, runtime request, denial handling, and permission-aware network startup. -- Queue Android 13 legacy NSD resolves and retry already-active failures. -- Replace the remaining macOS/Linux JmDNS fallback with Bonjour and Avahi after the Windows-native path is validated. +- Validate queued Android 13 legacy NSD resolves and decide whether failed resolutions need bounded retries. +- Consider replacing the remaining macOS/Linux JmDNS fallback with Bonjour and Avahi; implementation scope and timing are not confirmed. - Validate Desktop LAN-interface selection on more multi-adapter systems. - Add clear Windows Firewall onboarding and decide whether packaging should install an inbound application rule. - Retire Windows native callback arenas after a safe lifetime and preserve per-interface results when only one interface reports service removal. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 783e739..80df74e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ coil = "3.5.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } 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 bd15901..5e3ecfb 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 @@ -1,476 +1,355 @@ package com.liftley.sync360.data.network.discovery import android.content.Context +import android.net.Network import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi 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.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import kotlinx.coroutines.withContext class AndroidNetworkServices( context: Context, - androidLocalDeviceIdentityStore: LocalDeviceIdentityStore + identityStore: LocalDeviceIdentityStore ) : NetworkServices { - - init { - Log.d("Android Network Services", "Created!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") + private val nsdManager = requireNotNull(context.getSystemService(NsdManager::class.java)) + private val mainExecutor = context.mainExecutor + private val deviceUuid = identityStore.getOrCreateDeviceUuid() + private val _nearbyDevices = MutableStateFlow>(emptyList()) + override val nearbyDevices = _nearbyDevices.asStateFlow() + private val _discoveryServiceStatus = MutableStateFlow(DiscoveryStatus.Idle) + override val discoveryServiceStatus = _discoveryServiceStatus.asStateFlow() + private val _registrationServiceStatus = MutableStateFlow(RegistrationStatus.Idle) + override val registrationServiceStatus = _registrationServiceStatus.asStateFlow() + private var activeDiscoverySession: NearbyDeviceScan? = null + private var registrationListener: NsdManager.RegistrationListener? = null + + // Android 13 supports only one legacy resolution at a time. An old request + // retains its slot until its callback, but cannot updateResolvedDevice into a new session. + private val pendingResolutions = ArrayDeque>() + private var isResolvingService = false + + override suspend fun startDiscoveryAndAdvertising(httpServerPort: Int, fileTransferPort: Int) { + withContext(Dispatchers.Main.immediate) { + if (discoveryServiceStatus.value == DiscoveryStatus.Idle) startDeviceScan() + + if (registrationServiceStatus.value == RegistrationStatus.Idle) { + advertiseThisDevice(httpServerPort, fileTransferPort) + } + } } - private val _nearbyDevices: MutableStateFlow> = MutableStateFlow(emptyList()) - - override val nearbyDevices: StateFlow> = _nearbyDevices.asStateFlow() - - private val _discoveryServiceStatus: MutableStateFlow = - MutableStateFlow(DiscoveryStatus.Idle) - 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() + override suspend fun stopDiscoveryAndAdvertising() { + withContext(Dispatchers.Main.immediate) { - val serviceType = "_sync360._tcp." + activeDiscoverySession?.stop() - val deviceUuid = androidLocalDeviceIdentityStore.getOrCreateDeviceUuid() - - val serviceInfoCallbacks: MutableSet = - ConcurrentHashMap.newKeySet() - - @Volatile - private var pendingRepair: PendingRepair? = null - - val discoveryListener = object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(serviceType: String?) { - _discoveryServiceStatus.value = DiscoveryStatus.Running - Log.d("AndroidNetworkServices", "onDiscoveryStarted: $serviceType") + val listener = registrationListener + if (listener != null && registrationServiceStatus.value == RegistrationStatus.Running) { + _registrationServiceStatus.value = RegistrationStatus.Stopping + runCatching { nsdManager.unregisterService(listener) }.onFailure { + _registrationServiceStatus.value = RegistrationStatus.Running + logFailure("Unregister", it) + } + } } + } - override fun onDiscoveryStopped(serviceType: String?) { + private fun startDeviceScan() { + val session = NearbyDeviceScan() + activeDiscoverySession = session + _discoveryServiceStatus.value = DiscoveryStatus.Starting + runCatching { + nsdManager.discoverServices( + SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, null as Network?, mainExecutor, session + ) + }.onFailure { + activeDiscoverySession = null _discoveryServiceStatus.value = DiscoveryStatus.Idle - _nearbyDevices.value = emptyList() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - clearAndStopResolvingServices() - } - - continuePendingRepairIfReady() - Log.d("AndroidNetworkServices", "onDiscoveryStopped: $serviceType") + logFailure("Discover", it) } + } - @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) { - - val serviceInfoCallbackListener = object : NsdManager.ServiceInfoCallback { - var resolvedNearbyDeviceInfo: NearbyDevice? = null - - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - serviceInfoCallbacks.remove(this) - Log.d( - "AndroidNetworkServices", - "onServiceInfoCallbackRegistrationFailed: $errorCode" - ) - } - - override fun onServiceInfoCallbackUnregistered() { - serviceInfoCallbacks.remove(this) - Log.d("AndroidNetworkServices", "onServiceInfoCallbackUnregistered") - resolvedNearbyDeviceInfo = null - } - - override fun onServiceLost() { - Log.d("AndroidNetworkServices", "onServiceLost on Resolve") - _nearbyDevices.update { currentList -> - Log.d( - "AndroidNetworkServices", - "onServiceLost previous list: $currentList" - ) - - val listWithoutLostDevice = - currentList.filterNot { it.id == resolvedNearbyDeviceInfo?.id } - Log.d( - "AndroidNetworkServices", - "onServiceLost current list: $listWithoutLostDevice" - ) - listWithoutLostDevice - } - - if (serviceInfoCallbacks.remove(this)) { - nsdManager.unregisterServiceInfoCallback(this) - } - } + private inner class NearbyDeviceScan : NsdManager.DiscoveryListener { + private var isBrowseStopped = false + private val discoveredServices = mutableMapOf() + private val resolvedDevices = mutableMapOf() + private val serviceInfoCallbacks = mutableMapOf() + private val callbacksBeingRemoved = mutableSetOf() - override fun onServiceUpdated(updatedResolvedDeviceInfo: NsdServiceInfo) { - if (!discoveryIsActive()) return + private fun isActiveSession() = activeDiscoverySession === this && + (discoveryServiceStatus.value == DiscoveryStatus.Starting || + discoveryServiceStatus.value == DiscoveryStatus.Running) - Log.d( - "AndroidNetworkServices", - "onServiceUpdated: $updatedResolvedDeviceInfo" - ) - val newDevice = updatedResolvedDeviceInfo.toNearbyDeviceAndroidImpl() - if (newDevice == null) { - serviceInfoCallbacks.remove(this) - nsdManager.unregisterServiceInfoCallback(this) - return - } + override fun onDiscoveryStarted(serviceType: String) { + if (activeDiscoverySession === this) _discoveryServiceStatus.value = + DiscoveryStatus.Running + } - resolvedNearbyDeviceInfo = newDevice + override fun onDiscoveryStopped(serviceType: String) { + if (activeDiscoverySession !== this) return + isBrowseStopped = true + finishSessionCleanup() + } - if (newDevice.id == deviceUuid) return + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + if (activeDiscoverySession !== this) return + logStatus("Start discovery", errorCode) + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + isBrowseStopped = true + clearNearbyDevices() + stopTrackingServices() + finishSessionCleanup() + } - _nearbyDevices.update { currentList -> - val withoutOldDeviceId = - currentList.filterNot { device -> device.id == newDevice.id } + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + if (activeDiscoverySession !== this) return + logStatus("Stop discovery", errorCode) + _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed + } - val newList = withoutOldDeviceId + newDevice - newList - } + override fun onServiceFound(info: NsdServiceInfo) { + if (!isActiveSession()) return + val serviceKey = serviceKey(info) + if (serviceKey in discoveredServices) return + discoveredServices[serviceKey] = info + if (Build.VERSION.SDK_INT >= 34) { + if (serviceInfoCallbacks.containsKey(serviceKey)) return + val callback = object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + if (serviceInfoCallbacks[serviceKey] === this) serviceInfoCallbacks.remove( + serviceKey + ) + callbacksBeingRemoved.remove(this) + val latestService = discoveredServices.remove(serviceKey) + if (isActiveSession() && latestService != null && latestService !== info) { + onServiceFound(latestService) } + logStatus("Track service", errorCode) + finishSessionCleanup() } - 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( - "AndroidNetworkServices", - "onResolveFailed: $serviceInfo, $errorCode" - ) + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + if (serviceInfoCallbacks[serviceKey] === this && this !in callbacksBeingRemoved) { + updateResolvedDevice(info, serviceInfo) } + } - override fun onServiceResolved(resolvedDeviceInfo: NsdServiceInfo?) { - if (!discoveryIsActive()) return - - Log.d( - "AndroidNetworkServices", - "onServiceResolved: $resolvedDeviceInfo" - ) - - val newDevice = - resolvedDeviceInfo?.toNearbyDeviceAndroidImpl() ?: return - - if (newDevice.id == deviceUuid) return + override fun onServiceLost() { + if (!isActiveSession() || serviceInfoCallbacks[serviceKey] !== this) return + resolvedDevices.remove(serviceKey) + publishNearbyDevices() + } - _nearbyDevices.update { currentList -> - currentList.filterNot { device -> device.id == newDevice.id } + newDevice - } + override fun onServiceInfoCallbackUnregistered() { + if (serviceInfoCallbacks[serviceKey] === this) serviceInfoCallbacks.remove( + serviceKey + ) + callbacksBeingRemoved.remove(this) + finishSessionCleanup() + // A service can return before its old callback finishes stopping. + if (isActiveSession()) { + discoveredServices.remove(serviceKey) + ?.let { latestService -> onServiceFound(latestService) } } } - nsdManager.resolveService( - foundDiscoveryServiceInfo, - resolveListener - ) } + serviceInfoCallbacks[serviceKey] = callback + runCatching { nsdManager.registerServiceInfoCallback(info, mainExecutor, callback) } + .onFailure { + serviceInfoCallbacks.remove(serviceKey) + discoveredServices.remove(serviceKey) + logFailure("Track service", it) + } + } else { + pendingResolutions.addLast(this to info) + resolveNextService() } } - override fun onServiceLost(lostServiceInfo: NsdServiceInfo?) { - Log.d("AndroidNetworkServices", "onServiceLost on Discovery: $lostServiceInfo") - _nearbyDevices.update { currentList -> + override fun onServiceLost(info: NsdServiceInfo) { + if (!isActiveSession()) return + val serviceKey = serviceKey(info) + discoveredServices.remove(serviceKey) + resolvedDevices.remove(serviceKey) + publishNearbyDevices() + if (Build.VERSION.SDK_INT >= 34) serviceInfoCallbacks[serviceKey]?.let(::stopTrackingService) + } - val withoutOldDevice = - currentList.filterNot { device -> device.serviceName == lostServiceInfo?.serviceName } - withoutOldDevice - } + fun isCurrentService(info: NsdServiceInfo): Boolean { + return isActiveSession() && discoveredServices[serviceKey(info)] === info } - override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { - _discoveryServiceStatus.value = DiscoveryStatus.Idle - _nearbyDevices.value = emptyList() - cancelPendingRepair() - Log.d("AndroidNetworkServices", "onStartDiscoveryFailed: $serviceType, $errorCode") + fun updateResolvedDevice(discoveredService: NsdServiceInfo, info: NsdServiceInfo) { + if (!isCurrentService(discoveredService)) return + val serviceKey = serviceKey(discoveredService) + val device = info.toNearbyDeviceAndroidImpl() ?: return + if (device.id == deviceUuid) return + resolvedDevices[serviceKey] = device + publishNearbyDevices() } - override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { - Log.d("AndroidNetworkServices", "onStopDiscoveryFailed: $serviceType, $errorCode") - _discoveryServiceStatus.value = DiscoveryStatus.Running - cancelPendingRepair() + private fun publishNearbyDevices() { + _nearbyDevices.value = resolvedDevices.values.groupBy { it.id }.values.map { matches -> + matches.first() + .copy(hostAddresses = matches.flatMap { it.hostAddresses }.distinct()) + } } - } - val registrationListener = object : NsdManager.RegistrationListener { - override fun onServiceRegistered(serviceInfo: NsdServiceInfo?) { - _registrationServiceStatus.value = RegistrationStatus.Running - Log.d("AndroidNetworkServices", "onServiceRegistered: $serviceInfo") + fun stop() { + if (discoveryServiceStatus.value != DiscoveryStatus.Running && + discoveryServiceStatus.value != DiscoveryStatus.CleanupFailed + ) return + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + clearNearbyDevices() + stopTrackingServices() + if (isBrowseStopped) { + finishSessionCleanup() + } else { + runCatching { nsdManager.stopServiceDiscovery(this) }.onFailure { + _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed + logFailure("Stop discovery", it) + } + } } - override fun onRegistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { - _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() - Log.d("AndroidNetworkServices", "onRegistrationFailed: $serviceInfo, $errorCode") + private fun clearNearbyDevices() { + discoveredServices.clear() + resolvedDevices.clear() + pendingResolutions.clear() + _nearbyDevices.value = emptyList() } - override fun onServiceUnregistered(serviceInfo: NsdServiceInfo?) { - _registrationServiceStatus.value = RegistrationStatus.Idle - continuePendingRepairIfReady() - Log.d("AndroidNetworkServices", "onServiceUnregistered: $serviceInfo") + private fun stopTrackingServices() { + if (Build.VERSION.SDK_INT >= 34) serviceInfoCallbacks.values.toList() + .forEach(::stopTrackingService) } - override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { - _registrationServiceStatus.value = RegistrationStatus.Running - cancelPendingRepair() - Log.d("AndroidNetworkServices", "onUnregistrationFailed: $serviceInfo, $errorCode") + @RequiresApi(34) + private fun stopTrackingService(callback: NsdManager.ServiceInfoCallback) { + if (!callbacksBeingRemoved.add(callback)) return + runCatching { nsdManager.unregisterServiceInfoCallback(callback) }.onFailure { + callbacksBeingRemoved.remove(callback) + // Keep ownership if cleanup failed. Never pretend it was released. + logFailure("Stop service tracking", it) + } } - } - override suspend fun startNetworkServices(httpServerPort: Int, fileTransferPort: Int) { - startDiscoveryService() - startRegistrationService(httpServerPort, fileTransferPort) + private fun finishSessionCleanup() { + if (activeDiscoverySession !== this || !isBrowseStopped) return + if (serviceInfoCallbacks.isEmpty()) { + activeDiscoverySession = null + _discoveryServiceStatus.value = DiscoveryStatus.Idle + } else if (callbacksBeingRemoved.isEmpty()) { + // No cleanup request is in flight. Allow an explicit retry, + // but never start another session over callbacks that still belong to this session. + _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed + } + } } - private fun startDiscoveryService() { - if (discoveryServiceStatus.value != DiscoveryStatus.Idle) { - Log.d( - "AndroidNetworkServices", - "startDiscoveryService ignored because status=${discoveryServiceStatus.value}" - ) - return + // Android 13 has no ServiceInfoCallback API; keep the legacy fallback here. + @Suppress("DEPRECATION") + private fun resolveNextService() { + if (isResolvingService) return + var request = pendingResolutions.removeFirstOrNull() + while (request != null && !request.first.isCurrentService(request.second)) { + request = pendingResolutions.removeFirstOrNull() } + val (session, info) = request ?: return + isResolvingService = true + val listener = object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + logStatus("Resolve", errorCode) + isResolvingService = false + resolveNextService() + } - 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) + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + session.updateResolvedDevice(info, serviceInfo) + isResolvingService = false + resolveNextService() + } } - } - - private fun startRegistrationService( - httpServerPort: Int, - fileTransferPort: Int - ) { - if (registrationServiceStatus.value != RegistrationStatus.Idle) { - Log.d( - "AndroidNetworkServices", - "startRegistrationService ignored because status=${registrationServiceStatus.value}" - ) - return + runCatching { nsdManager.resolveService(info, mainExecutor, listener) }.onFailure { + isResolvingService = false + logFailure("Resolve", it) + resolveNextService() } + } - _registrationServiceStatus.value = RegistrationStatus.Starting - Log.d("AndroidNetworkServices", "startRegistrationService: Starting registration") + private fun advertiseThisDevice(httpServerPort: Int, fileTransferPort: Int) { + val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) { + if (registrationListener === this) _registrationServiceStatus.value = + RegistrationStatus.Running + } - val rawManufacturer = Build.MANUFACTURER.trim() - val rawModel = Build.MODEL.trim() + override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) { + if (registrationListener !== this) return + registrationListener = null + _registrationServiceStatus.value = RegistrationStatus.Idle + logStatus("Register", errorCode) + } - // Capitalize the first letter of the manufacturer safely - val manufacturer = rawManufacturer.replaceFirstChar { - if (it.isLowerCase()) it.titlecase() else it.toString() - } + override fun onServiceUnregistered(info: NsdServiceInfo) { + if (registrationListener !== this) return + registrationListener = null + _registrationServiceStatus.value = RegistrationStatus.Idle + } - // Avoid names like "Google Google Pixel 6" if the model already contains the brand - val cleanDeviceName = if (rawModel.startsWith(rawManufacturer, ignoreCase = true)) { - rawModel - } else { - "$manufacturer $rawModel" + override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) { + if (registrationListener !== this) return + _registrationServiceStatus.value = RegistrationStatus.Running + logStatus("Unregister", errorCode) + } } - - val serviceInfo = runCatching { - NsdServiceInfo().apply { - serviceType = "_sync360._tcp." + registrationListener = listener + _registrationServiceStatus.value = RegistrationStatus.Starting + runCatching { + val manufacturer = Build.MANUFACTURER.trim().replaceFirstChar { it.titlecase() } + val model = Build.MODEL.trim() + val name = if (model.startsWith( + manufacturer, + ignoreCase = true + ) + ) model else "$manufacturer $model" + val info = NsdServiceInfo().apply { + serviceType = SERVICE_TYPE serviceName = "${Build.MODEL} Sync360" port = httpServerPort - setAttribute("deviceUuid", deviceUuid) - setAttribute("deviceName", cleanDeviceName) + setAttribute("deviceName", name) setAttribute("deviceType", "Android") setAttribute("protocolVersion", "1") - - setAttribute( - "fileTransferPort", - fileTransferPort.toString() - ) + setAttribute("fileTransferPort", fileTransferPort.toString()) } - }.getOrElse { exception -> - _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() - Log.d("AndroidNetworkServices", "Could not create registration service", exception) - return - } - - runCatching { - nsdManager.registerService( - serviceInfo, - NsdManager.PROTOCOL_DNS_SD, - registrationListener - ) - }.onFailure { exception -> + nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, mainExecutor, listener) + }.onFailure { + registrationListener = null _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() - Log.d("AndroidNetworkServices", "startRegistrationService failed", exception) - } - } - - override suspend fun repairNetworkServices( - httpServerPort: Int, - fileTransferPort: Int - ) { - 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() + logFailure("Register", it) } - if (pendingRepair == null) return - - continuePendingRepairIfReady() } - override fun stopDiscoveryServices() { - if (discoveryServiceStatus.value != DiscoveryStatus.Running) { - Log.d( - "AndroidNetworkServices", - "stopDiscoveryServices ignored because status=${discoveryServiceStatus.value}" - ) - return - } + private fun serviceKey(info: NsdServiceInfo) = "${info.serviceName}|${info.network}" + private fun logStatus(action: String, status: Int) = + Log.w("AndroidNetworkServices", "$action failed: $status") - _discoveryServiceStatus.value = DiscoveryStatus.Stopping - Log.d("AndroidNetworkServices", "stopDiscoveryServices: Stopping Discovery Services") + private fun logFailure(action: String, error: Throwable) = + Log.w("AndroidNetworkServices", "$action failed", error) - runCatching { - nsdManager.stopServiceDiscovery(discoveryListener) - }.onFailure { exception -> - _discoveryServiceStatus.value = DiscoveryStatus.Running - cancelPendingRepair() - Log.d("AndroidNetworkServices", "stopDiscoveryServices failed", exception) - } + private companion object { + const val SERVICE_TYPE = "_sync360._tcp." } - - private fun stopRegistrationService() { - if (registrationServiceStatus.value != RegistrationStatus.Running) { - Log.d( - "AndroidNetworkServices", - "stopRegistrationService ignored because status=${registrationServiceStatus.value}" - ) - return - } - - _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) - } - } - - 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 = serviceInfoCallbacks.toList() - serviceInfoCallbacks.clear() - - callbacks.forEach { callback -> - runCatching { - nsdManager.unregisterServiceInfoCallback(callback) - } - } - } - - private data class PendingRepair( - val httpServerPort: Int, - val fileTransferPort: Int - ) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt index 03ebff8..1bac464 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt @@ -10,8 +10,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarDefaults @@ -35,12 +33,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND -import com.liftley.sync360.core.designsystem.icons.Back 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.receive.ReceiveScreen @@ -49,7 +43,6 @@ import com.liftley.sync360.presentation.receive.model.ReceiveState import com.liftley.sync360.presentation.send.SendScreen import com.liftley.sync360.presentation.send.SendScreenViewModel import com.liftley.sync360.presentation.send.model.SendState -import com.liftley.sync360.presentation.settings.SettingsScreen import org.koin.compose.koinInject @Preview(showBackground = true) @@ -69,18 +62,6 @@ fun Sync360Root() { val useNavigationRail = windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) - val discoveryIsStable = - sendScreenState.discoveryStatus == DiscoveryStatus.Idle || - sendScreenState.discoveryStatus == DiscoveryStatus.Running - val registrationIsStable = - sendScreenState.registrationStatus == RegistrationStatus.Idle || - sendScreenState.registrationStatus == RegistrationStatus.Running - val repairEnabled = - sendScreenState.sendState is SendState.Idle && - receiveScreenState is ReceiveState.Idle && - discoveryIsStable && - registrationIsStable - val shouldKeepScreenOn = sendScreenState.sendState !is SendState.Idle || receiveScreenState !is ReceiveState.Idle @@ -145,43 +126,16 @@ fun Sync360Root() { } ) - NavigationBarItem( - onClick = { navigationViewModel.navigateTo(NavScreen.SettingsScreen) }, - selected = navigationViewModel.currentScreen() == - NavScreen.SettingsScreen, - label = { Text("Settings") }, - icon = { - Icon( - imageVector = Settings, - contentDescription = null - ) - } - ) } } }, topBar = { CenterAlignedTopAppBar( - navigationIcon = { - if (currentScreen == NavScreen.SettingsScreen) { - IconButton( - modifier = Modifier, - colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface), - onClick = navigationViewModel::goBack - ) { - Icon( - imageVector = Back, - contentDescription = "Close settings" - ) - } - } - }, title = { Text( text = when (currentScreen) { NavScreen.SendScreen -> sendTitle NavScreen.ReceiveScreen -> receiveTitle - NavScreen.SettingsScreen -> "Settings" }, style = MaterialTheme.typography.titleLarge, modifier = Modifier @@ -238,17 +192,6 @@ fun Sync360Root() { label = { Text("Send") } ) - NavigationRailItem( - selected = currentScreen == NavScreen.SettingsScreen, - onClick = { navigationViewModel.navigateTo(NavScreen.SettingsScreen) }, - icon = { - Icon( - imageVector = Settings, - contentDescription = null - ) - }, - label = { Text("Settings") } - ) } } @@ -262,29 +205,13 @@ fun Sync360Root() { when (screen) { NavScreen.SendScreen -> { NavEntry(key = screen) { - SendScreen( - onTroubleshootClick = - { navigationViewModel.navigateTo(NavScreen.SettingsScreen) } - ) + SendScreen() } } NavScreen.ReceiveScreen -> { NavEntry(key = screen) { - ReceiveScreen( - onTroubleshootClick = - { navigationViewModel.navigateTo(NavScreen.SettingsScreen) } - ) - } - } - - NavScreen.SettingsScreen -> { - NavEntry(key = screen) { - SettingsScreen( - repairEnabled = repairEnabled, - onRepairClick = - sendScreenViewModel::repairNetworkServices - ) + ReceiveScreen() } } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/icons/Wifi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/icons/Wifi.kt new file mode 100644 index 0000000..4176f97 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/core/designsystem/icons/Wifi.kt @@ -0,0 +1,78 @@ +package com.liftley.sync360.core.designsystem.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +@Suppress("CheckReturnValue") +public val Wifi: ImageVector + get() { + if (_wifi != null) { + return _wifi!! + } + _wifi = + ImageVector.Builder( + name = "wifi", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(10.23f, 20.27f) + quadTo(9.5f, 19.55f, 9.5f, 18.5f) + reflectiveQuadToRelative(0.73f, -1.77f) + reflectiveQuadTo(12f, 16f) + reflectiveQuadToRelative(1.78f, 0.73f) + reflectiveQuadTo(14.5f, 18.5f) + reflectiveQuadToRelative(-0.72f, 1.77f) + reflectiveQuadTo(12f, 21f) + reflectiveQuadTo(10.23f, 20.27f) + close() + moveTo(6.35f, 15.35f) + lineTo(4.25f, 13.2f) + quadTo(5.73f, 11.73f, 7.71f, 10.86f) + reflectiveQuadTo(12f, 10f) + reflectiveQuadToRelative(4.29f, 0.88f) + reflectiveQuadToRelative(3.46f, 2.38f) + lineToRelative(-2.1f, 2.1f) + quadToRelative(-1.1f, -1.1f, -2.55f, -1.72f) + reflectiveQuadTo(12f, 13f) + reflectiveQuadTo(8.9f, 13.63f) + reflectiveQuadTo(6.35f, 15.35f) + close() + moveTo(2.1f, 11.1f) + lineTo(0f, 9f) + quadTo(2.3f, 6.65f, 5.38f, 5.32f) + reflectiveQuadTo(12f, 4f) + reflectiveQuadToRelative(6.63f, 1.32f) + reflectiveQuadTo(24f, 9f) + lineToRelative(-2.1f, 2.1f) + quadTo(19.98f, 9.17f, 17.44f, 8.09f) + reflectiveQuadTo(12f, 7f) + reflectiveQuadTo(6.56f, 8.09f) + reflectiveQuadTo(2.1f, 11.1f) + close() + } + } + .build() + return _wifi!! + } + +private var _wifi: ImageVector? = null 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 23a9df2..24273bb 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt @@ -5,15 +5,14 @@ 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.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.time.Duration.Companion.milliseconds class NetworkServicesController( private val httpServer: Sync360HttpServer, @@ -21,111 +20,133 @@ class NetworkServicesController( private val networkServices: NetworkServices, ) { private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val discoveryCommands = Channel(Channel.UNLIMITED) + private var hasInitialized = false private var httpServerPort: Int? = null private var fileTransferPort: Int? = null - private val lifecycleMutex = Mutex() - private val repairRequestMutex = Mutex() - - private var hasStarted = false + private var isDiscoveryAllowedByLifecycle = false + private var startRequested = false + private var stopRequested = false + private val _isDiscoveryEnabled = MutableStateFlow(true) + val isDiscoveryEnabled = _isDiscoveryEnabled.asStateFlow() + private val _discoveryErrorMessage = MutableStateFlow(null) + val discoveryErrorMessage = _discoveryErrorMessage.asStateFlow() val nearbyDevices = networkServices.nearbyDevices - val discoveryServiceStatus = networkServices.discoveryServiceStatus - val registrationServiceStatus = networkServices.registrationServiceStatus init { controllerScope.launch { - discoveryServiceStatus.collectLatest { status -> - if (status == DiscoveryStatus.Running) { - delay(DISCOVERY_DURATION_MILLIS.milliseconds) - stopDiscoveryServices() - } - } + discoveryServiceStatus.collect { discoveryCommands.send(DiscoveryCommand.StatusChanged) } } - } - - fun startNetworkServices() { controllerScope.launch { - lifecycleMutex.withLock { - if (hasStarted) return@withLock - - val startedHttpServerPort = httpServer.start() - val startedFileTransferPort = fileTransferReceiver.start() - - httpServerPort = startedHttpServerPort - fileTransferPort = startedFileTransferPort - - networkServices.startNetworkServices( - httpServerPort = startedHttpServerPort, - fileTransferPort = startedFileTransferPort - ) - - hasStarted = true - } + registrationServiceStatus.collect { discoveryCommands.send(DiscoveryCommand.StatusChanged) } } - } - - suspend fun restartDiscoveryServices() { - lifecycleMutex.withLock { - if (discoveryServiceStatus.value == DiscoveryStatus.Idle) { - networkServices.restartDiscoveryServices() + // Only this loop changes lifecycle intent. Callbacks report status; + // they never decide to start a replacement session. + controllerScope.launch { + for (command in discoveryCommands) { + try { + when (command) { + is DiscoveryCommand.Initialize -> { + if (!hasInitialized) { + isDiscoveryAllowedByLifecycle = command.allowed + if (httpServerPort == null) httpServerPort = httpServer.start() + if (fileTransferPort == null) fileTransferPort = fileTransferReceiver.start() + hasInitialized = true + allowDiscoveryRetry() + } + } + is DiscoveryCommand.LifecyclePermission -> { + if (isDiscoveryAllowedByLifecycle != command.allowed) { + isDiscoveryAllowedByLifecycle = command.allowed + allowDiscoveryRetry() + } + } + is DiscoveryCommand.Enable -> { + _isDiscoveryEnabled.value = command.enabled + allowDiscoveryRetry() + } + DiscoveryCommand.StatusChanged -> Unit + } + applyDiscoveryIntent() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + _discoveryErrorMessage.value = "Something went wrong with discovery. Try again." + exception.printStackTrace() + } } } } - suspend fun repairNetworkServices() { - if (!repairRequestMutex.tryLock()) return - - try { - startRepairWhenServicesAreStable() - } finally { - repairRequestMutex.unlock() - } + fun startNetworkServices(discoveryAllowedAtStartup: Boolean = true) { + discoveryCommands.trySend(DiscoveryCommand.Initialize(discoveryAllowedAtStartup)) } - 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) - } + fun setDiscoveryAllowedByLifecycle(allowed: Boolean) { + discoveryCommands.trySend(DiscoveryCommand.LifecyclePermission(allowed)) } - private fun servicesAreStable(): Boolean { - val discoveryStatus = discoveryServiceStatus.value - val registrationStatus = registrationServiceStatus.value - - val discoveryIsStable = - discoveryStatus == DiscoveryStatus.Idle || - discoveryStatus == DiscoveryStatus.Running - val registrationIsStable = - registrationStatus == RegistrationStatus.Idle || - registrationStatus == RegistrationStatus.Running + fun setDiscoveryEnabled(enabled: Boolean) { + discoveryCommands.trySend(DiscoveryCommand.Enable(enabled)) + } - return discoveryIsStable && registrationIsStable + private fun allowDiscoveryRetry() { + startRequested = false + stopRequested = false + _discoveryErrorMessage.value = null } - private suspend fun stopDiscoveryServices() { - lifecycleMutex.withLock { - if (discoveryServiceStatus.value == DiscoveryStatus.Running) { - networkServices.stopDiscoveryServices() + private suspend fun applyDiscoveryIntent() { + val discovery = discoveryServiceStatus.value + val registration = registrationServiceStatus.value + // Wait for real completion even if the user changes their mind. + if (discovery == DiscoveryStatus.Starting || discovery == DiscoveryStatus.Stopping || + registration == RegistrationStatus.Starting || registration == RegistrationStatus.Stopping + ) return + + val bothServicesStopped = discovery == DiscoveryStatus.Idle && registration == RegistrationStatus.Idle + val shouldDiscover = isDiscoveryAllowedByLifecycle && isDiscoveryEnabled.value + if (!shouldDiscover || discovery == DiscoveryStatus.CleanupFailed) { + if (!bothServicesStopped) { + if (!stopRequested) { + stopRequested = true + networkServices.stopDiscoveryAndAdvertising() + discoveryCommands.trySend(DiscoveryCommand.StatusChanged) + } else { + _discoveryErrorMessage.value = "Couldn't stop discovery. Try again." + } + return } + stopRequested = false + _discoveryErrorMessage.value = null + return + } + + if (discoveryServiceStatus.value == DiscoveryStatus.Running && + registrationServiceStatus.value == RegistrationStatus.Running + ) { + _discoveryErrorMessage.value = null + return + } + if (startRequested) { + _discoveryErrorMessage.value = "Couldn't start discovery. Try again." + return } + startRequested = true + // A manual retry can also finish startup if one listener failed initially. + val listeningHttpPort = httpServerPort ?: httpServer.start().also { httpServerPort = it } + val listeningFilePort = fileTransferPort ?: fileTransferReceiver.start().also { fileTransferPort = it } + networkServices.startDiscoveryAndAdvertising(listeningHttpPort, listeningFilePort) + discoveryCommands.trySend(DiscoveryCommand.StatusChanged) } - private companion object { - const val DISCOVERY_DURATION_MILLIS = 60_000L + private sealed interface DiscoveryCommand { + data class Initialize(val allowed: Boolean) : DiscoveryCommand + data class LifecyclePermission(val allowed: Boolean) : DiscoveryCommand + data class Enable(val enabled: Boolean) : DiscoveryCommand + data object StatusChanged : DiscoveryCommand } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/DiscoveryStatus.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/DiscoveryStatus.kt index e1f6cc2..4d12d87 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/DiscoveryStatus.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/DiscoveryStatus.kt @@ -4,5 +4,7 @@ enum class DiscoveryStatus { Idle, Starting, Running, - Stopping + Stopping, + /** Cleanup failed; resources are still owned and must be stopped before reuse. */ + CleanupFailed } \ No newline at end of file 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 ebe032a..2581324 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 @@ -10,11 +10,8 @@ interface NetworkServices { val discoveryServiceStatus: StateFlow val registrationServiceStatus: StateFlow - suspend fun startNetworkServices(httpServerPort: Int, fileTransferPort: Int) + suspend fun startDiscoveryAndAdvertising(httpServerPort: Int, fileTransferPort: Int) - suspend fun repairNetworkServices(httpServerPort: Int, fileTransferPort: Int) - - fun restartDiscoveryServices() - - fun stopDiscoveryServices() + /** Stops discovery and advertising, not transfer listeners. */ + suspend fun stopDiscoveryAndAdvertising() } 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 deleted file mode 100644 index 51eea89..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/NetworkRepairAction.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.liftley.sync360.presentation.app.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -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.unit.dp -import com.liftley.sync360.core.designsystem.Spacing - -@Composable -fun NetworkRepairAction( - enabled: Boolean, - onRepairClick: () -> Unit, -) { - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(Spacing.lg) - ) { - Text( - text = "Connection repair", - style = MaterialTheme.typography.titleLarge - ) - Text( - 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 - ) - Button( - onClick = onRepairClick, - enabled = enabled - ) { - Text("Repair connection") - } - } - } -} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/navigation/NavScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/navigation/NavScreen.kt index 872a4a7..a64d0ab 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/navigation/NavScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/navigation/NavScreen.kt @@ -3,5 +3,4 @@ package com.liftley.sync360.presentation.navigation sealed interface NavScreen { data object SendScreen: NavScreen data object ReceiveScreen: NavScreen - data object SettingsScreen: NavScreen } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt index cbfa3fc..d57a71d 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt @@ -15,9 +15,7 @@ import com.liftley.sync360.presentation.receive.model.ReceiveState import org.koin.compose.koinInject @Composable -fun ReceiveScreen( - onTroubleshootClick: () -> Unit -) { +fun ReceiveScreen() { val receiveScreenViewModel = koinInject() val receiveScreenState by receiveScreenViewModel.screenState.collectAsStateWithLifecycle() Sync360Surface( @@ -27,8 +25,7 @@ fun ReceiveScreen( when (val state = receiveScreenState) { is ReceiveState.Idle -> { IdleReceiveStateUi( - fileReceiveCode = state.fileReceiveCode, - onTroubleshootClick = onTroubleshootClick + fileReceiveCode = state.fileReceiveCode ) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt index 39c9f22..169d0f0 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt @@ -8,8 +8,6 @@ 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.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,8 +16,7 @@ import com.liftley.sync360.presentation.app.components.FileReceiveCodeCard @Composable fun IdleReceiveStateUi( - fileReceiveCode: String, - onTroubleshootClick: () -> Unit + fileReceiveCode: String ) { Box( modifier = Modifier @@ -39,9 +36,6 @@ fun IdleReceiveStateUi( fileReceiveCode = fileReceiveCode ) - TextButton(onClick = onTroubleshootClick) { - Text("Troubleshoot") - } } } } 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 4c137e6..9a4801b 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 @@ -12,7 +12,6 @@ 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 import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -31,9 +30,7 @@ import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SendScreen( - onTroubleshootClick: () -> Unit -) { +fun SendScreen() { val sendScreenViewModel = koinInject() val screenState by sendScreenViewModel.sendScreenState.collectAsStateWithLifecycle() @@ -108,13 +105,11 @@ fun SendScreen( NearbyDevicesSection( screenState = screenState, - onReloadClick = sendScreenViewModel::restartDiscoveryServices, + onDiscoveryEnabledChange = sendScreenViewModel::setDiscoveryEnabled, + onRetryDiscovery = sendScreenViewModel::retryDiscovery, onDeviceClick = sendScreenViewModel::sendToDevice ) - 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 0339c31..012d53c 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 @@ -48,6 +48,17 @@ class SendScreenViewModel( private var activeFileSend: ActiveFileSend? = null init { + viewModelScope.launch { + networkServicesController.isDiscoveryEnabled.collect { enabled -> + _sendScreenState.update { it.copy(isDiscoveryEnabled = enabled) } + } + } + viewModelScope.launch { + networkServicesController.discoveryErrorMessage.collect { error -> + _sendScreenState.update { it.copy(discoveryErrorMessage = error) } + } + } + viewModelScope.launch { networkServicesController.nearbyDevices.collect { devices -> latestNearbyDevices = devices @@ -80,16 +91,12 @@ class SendScreenViewModel( } - fun restartDiscoveryServices() { - viewModelScope.launch { - networkServicesController.restartDiscoveryServices() - } + fun setDiscoveryEnabled(enabled: Boolean) { + networkServicesController.setDiscoveryEnabled(enabled) } - fun repairNetworkServices() { - viewModelScope.launch { - networkServicesController.repairNetworkServices() - } + fun retryDiscovery() { + networkServicesController.setDiscoveryEnabled(sendScreenState.value.isDiscoveryEnabled) } fun sendToDevice(deviceId: String) { 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 deleted file mode 100644 index bfa70cd..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceCard.kt +++ /dev/null @@ -1,90 +0,0 @@ -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.clickable -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.liftley.sync360.core.designsystem.icons.Android -import com.liftley.sync360.core.designsystem.icons.Desktop -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( - device: NearbyDeviceUiModel = NearbyDeviceUiModel( - id = "uuid-9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", - deviceName = "Living Room TV", - deviceType = "Tv", - protocolVersion = "v2.4.1", - hostAddresses = listOf("192.168.1.45", "fe80::1ff:fe23:4567:890a"), - port = 8080, - fileTransferPort = 0, - serviceName = "Chromecast-Ultra-Stream", - serviceType = "_googlecast._tcp.local." - ), - actionLabel: String = "Click me to send files", - enabled: Boolean = true, - onClick: () -> Unit = {} -) { - Sync360Surface( - modifier = Modifier.fillMaxWidth(), - containerColor = MaterialTheme.colorScheme.surfaceContainer, - shape = MaterialTheme.shapes.extraExtraLarge - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - 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( - device.deviceName, - style = MaterialTheme.typography.titleMedium - ) - - Text( - actionLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceScanningCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceScanningCard.kt deleted file mode 100644 index 2c15f3a..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDeviceScanningCard.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.liftley.sync360.presentation.send.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.liftley.sync360.domain.model.DiscoveryStatus - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Preview -@Composable -fun NearbyDeviceScanningCard( - status: DiscoveryStatus = DiscoveryStatus.Running, - reloadEnabled: Boolean = false, - onReloadClick: () -> Unit = {} -) { - val title = when (status) { - DiscoveryStatus.Idle -> "Scanning stopped" - DiscoveryStatus.Starting -> "Starting discovery" - DiscoveryStatus.Running -> "Looking for devices" - DiscoveryStatus.Stopping -> "Stopping discovery" - } - - val subtitle = when (status) { - 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" - } - - Surface( - onClick = onReloadClick, - enabled = reloadEnabled, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when(status) { - DiscoveryStatus.Starting -> LoadingIndicator() - DiscoveryStatus.Stopping -> LoadingIndicator() - else -> {} - } - Text( - title, - style = MaterialTheme.typography.titleMedium - ) - - Text( - subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} 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 a3cf681..91b12e6 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 @@ -6,103 +6,179 @@ 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.material3.CircularWavyProgressIndicator +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.Surface 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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.liftley.sync360.core.designsystem.icons.Reload +import com.liftley.sync360.core.designsystem.icons.Android +import com.liftley.sync360.core.designsystem.icons.Desktop +import com.liftley.sync360.core.designsystem.icons.Tv +import com.liftley.sync360.core.designsystem.icons.Wifi 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, + onDiscoveryEnabledChange: (Boolean) -> Unit, + onRetryDiscovery: () -> Unit, onDeviceClick: (String) -> Unit ) { - val reloadEnabled = - screenState.discoveryStatus == DiscoveryStatus.Idle && - screenState.registrationStatus == RegistrationStatus.Running + val hasDevices = screenState.nearbyDevices.isNotEmpty() + val status = when (screenState.discoveryStatus) { + DiscoveryStatus.Idle -> "Discovery is off" + DiscoveryStatus.Starting -> "Starting discovery…" + DiscoveryStatus.Running -> if (hasDevices) "searching for more devices…" else "Searching for nearby devices…" + DiscoveryStatus.Stopping -> "Stopping discovery…" + DiscoveryStatus.CleanupFailed -> "Couldn’t stop discovery" + } - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + "Nearby devices", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge + ) + if (screenState.isDiscoveryEnabled || hasDevices) { + OutlinedButton(onClick = { onDiscoveryEnabledChange(!screenState.isDiscoveryEnabled) }) { + Text(if (screenState.isDiscoveryEnabled) "Stop" else "Start") + } + } + } + + if (hasDevices) { + screenState.nearbyDevices.forEach { device -> + NearbyDeviceRow( + device = device, + enabled = screenState.isContentReadyToSend, + actionLabel = screenState.deviceActionLabel, + onClick = { onDeviceClick(device.id) }) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + if (screenState.discoveryStatus == DiscoveryStatus.Running) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + Text( + status, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (screenState.discoveryErrorMessage == null) { + Sync360Surface( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Sync360Surface(containerColor = MaterialTheme.colorScheme.primaryContainer) { + Icon( + Wifi, + contentDescription = null, + modifier = Modifier.padding(16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } Text( - "Nearby devices", - style = MaterialTheme.typography.titleLarge + status, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center ) - - 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" - ) - } + Text( + text = if (screenState.isDiscoveryEnabled) { + "Open Sync360 on the other device and connect both to the same Wi-Fi network or hotspot." + } else { + "Click Start discovery to find nearby devices and let them find you." + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (!screenState.isDiscoveryEnabled) { + Button(onClick = { onDiscoveryEnabledChange(true) }) { Text("Start discovery") } } } + } + } - if (screenState.nearbyDevices.isNotEmpty()) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - screenState.nearbyDevices.forEach { device -> - NearbyDeviceCard( - device = device, - actionLabel = screenState.deviceActionLabel, - enabled = screenState.isContentReadyToSend, - onClick = { onDeviceClick(device.id) } - ) - } - } + screenState.discoveryErrorMessage?.let { message -> + Surface( + color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.large + ) { + Column( + Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(message, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onRetryDiscovery) { Text("Try again") } } + } + } +} - if (screenState.nearbyDevices.isEmpty()) { - NearbyDeviceScanningCard( - status = screenState.discoveryStatus, - reloadEnabled = reloadEnabled, - onReloadClick = onReloadClick +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun NearbyDeviceRow( + device: NearbyDeviceUiModel, + enabled: Boolean, + actionLabel: String, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraExtraLarge, + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer) { + Icon( + imageVector = when (device.deviceType) { + "Android" -> Android + "Tv" -> Tv + else -> Desktop + }, + contentDescription = null, + modifier = Modifier.padding(16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Column( + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(device.deviceName, style = MaterialTheme.typography.titleMedium) + Text( + text = "${device.deviceType} · $actionLabel", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } -} +} \ No newline at end of file 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 a63aa7e..766271c 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 @@ -13,6 +13,8 @@ data class SendScreenState( val fileReceiveCodePrompt: FileReceiveCodePrompt? = null, val sendState: SendState = SendState.Idle, val nearbyDevices: List = emptyList(), + val isDiscoveryEnabled: Boolean = true, + val discoveryErrorMessage: String? = null, val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle, val registrationStatus: RegistrationStatus = RegistrationStatus.Idle ) { diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/settings/SettingsScreen.kt deleted file mode 100644 index 73d2cfb..0000000 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/settings/SettingsScreen.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.liftley.sync360.presentation.settings - -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.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.liftley.sync360.presentation.app.components.NetworkRepairAction -import com.liftley.sync360.presentation.app.components.Sync360Surface - -@Composable -fun SettingsScreen( - repairEnabled: Boolean, - onRepairClick: () -> Unit -) { - Sync360Surface( - modifier = Modifier.fillMaxSize(), - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(16.dp) - ) { - NetworkRepairAction( - enabled = repairEnabled, - onRepairClick = onRepairClick - ) - } - } -} 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 index d0feada..7ab2dc0 100644 --- 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 @@ -86,9 +86,8 @@ class IosNetworkServices( private val serviceDetailsByKey = mutableMapOf() private val resolveRefsByKey = mutableMapOf() private val addressRefsByKey = mutableMapOf() - private var pendingRepair: PendingRepair? = null - override suspend fun startNetworkServices( + override suspend fun startDiscoveryAndAdvertising( httpServerPort: Int, fileTransferPort: Int ) { @@ -98,43 +97,14 @@ class IosNetworkServices( } } - override suspend fun repairNetworkServices( - httpServerPort: Int, - fileTransferPort: Int - ) { + override suspend fun stopDiscoveryAndAdvertising() { 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() - } } } @@ -161,7 +131,6 @@ class IosNetworkServices( serviceRef?.let { DNSServiceRefDeallocate(it) } _discoveryServiceStatus.value = DiscoveryStatus.Idle clearDiscoveredServices() - cancelPendingRepair() return } @@ -172,7 +141,6 @@ class IosNetworkServices( DNSServiceRefDeallocate(serviceRef) _discoveryServiceStatus.value = DiscoveryStatus.Idle clearDiscoveredServices() - cancelPendingRepair() return } } @@ -188,7 +156,6 @@ class IosNetworkServices( clearDiscoveredServices() _discoveryServiceStatus.value = DiscoveryStatus.Idle - continuePendingRepairIfReady() } private fun startRegistrationService( @@ -251,7 +218,6 @@ class IosNetworkServices( if (registerResult != kDNSServiceErr_NoError || serviceRef == null) { serviceRef?.let { DNSServiceRefDeallocate(it) } _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() return } @@ -261,11 +227,9 @@ class IosNetworkServices( 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) @@ -280,7 +244,6 @@ class IosNetworkServices( registrationRef = null _registrationServiceStatus.value = RegistrationStatus.Idle - continuePendingRepairIfReady() } private fun handleRegistrationResult(errorCode: Int) { @@ -293,7 +256,6 @@ class IosNetworkServices( registrationRef?.let { DNSServiceRefDeallocate(it) } registrationRef = null _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() } } } @@ -314,7 +276,6 @@ class IosNetworkServices( browseRef = null clearDiscoveredServices() _discoveryServiceStatus.value = DiscoveryStatus.Idle - cancelPendingRepair() return@locked } @@ -563,34 +524,6 @@ class IosNetworkServices( _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 @@ -661,10 +594,6 @@ class IosNetworkServices( } } - private data class PendingRepair( - val httpServerPort: Int, - val fileTransferPort: Int - ) private data class ServiceDetails( val serviceName: String, 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 97f444a..59cfe50 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 @@ -41,7 +41,7 @@ class JvmNetworkServices( private val listenerByAddress = mutableMapOf() private val resolvedDevicesByServiceKey = ConcurrentHashMap() - override suspend fun startNetworkServices( + override suspend fun startDiscoveryAndAdvertising( httpServerPort: Int, fileTransferPort: Int ) { @@ -79,81 +79,19 @@ class JvmNetworkServices( } _discoveryServiceStatus.value = DiscoveryStatus.Running } catch (exception: Exception) { - withContext(Dispatchers.IO) { - closeAllInstances() - } - _registrationServiceStatus.value = RegistrationStatus.Idle - _discoveryServiceStatus.value = DiscoveryStatus.Idle + val closed = withContext(Dispatchers.IO) { closeAllInstances() } + _registrationServiceStatus.value = if (closed) RegistrationStatus.Idle else RegistrationStatus.Running + _discoveryServiceStatus.value = if (closed) DiscoveryStatus.Idle else DiscoveryStatus.CleanupFailed exception.printStackTrace() } } - override suspend fun repairNetworkServices( - httpServerPort: Int, - fileTransferPort: Int - ) { - 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 - } - - 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() - - try { - addDiscoveryListeners() - _discoveryServiceStatus.value = DiscoveryStatus.Running - } catch (exception: Exception) { - closeAllInstancesAfterFailure(exception) - } - } - - override fun stopDiscoveryServices() { - if (discoveryServiceStatus.value != DiscoveryStatus.Running) return - + override suspend fun stopDiscoveryAndAdvertising() { _discoveryServiceStatus.value = DiscoveryStatus.Stopping - - try { - synchronized(this) { - listenerByAddress.forEach { (address, listener) -> - jmDnsByAddress[address]?.removeServiceListener(SERVICE_TYPE, listener) - } - resolvedDevicesByServiceKey.clear() - _nearbyDevices.value = emptyList() - } - _discoveryServiceStatus.value = DiscoveryStatus.Idle - } catch (exception: Exception) { - closeAllInstancesAfterFailure(exception) - } + _registrationServiceStatus.value = RegistrationStatus.Stopping + val closed = withContext(Dispatchers.IO) { closeAllInstances() } + _discoveryServiceStatus.value = if (closed) DiscoveryStatus.Idle else DiscoveryStatus.CleanupFailed + _registrationServiceStatus.value = if (closed) RegistrationStatus.Idle else RegistrationStatus.Running } private fun startOnLanInterfaces( @@ -301,14 +239,6 @@ class JvmNetworkServices( return jmDnsByAddress.isEmpty() } - private fun closeAllInstancesAfterFailure(exception: Exception) { - _registrationServiceStatus.value = RegistrationStatus.Stopping - closeAllInstances() - _registrationServiceStatus.value = RegistrationStatus.Idle - _discoveryServiceStatus.value = DiscoveryStatus.Idle - exception.printStackTrace() - } - @Synchronized private fun publishMergedDevices() { val mergedDevices = resolvedDevicesByServiceKey.values 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 index 9a0a249..6d1b490 100644 --- 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 @@ -46,9 +46,8 @@ class WindowsNetworkServices( // 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( + override suspend fun startDiscoveryAndAdvertising( httpServerPort: Int, fileTransferPort: Int ) { @@ -58,54 +57,24 @@ class WindowsNetworkServices( } } - override suspend fun repairNetworkServices( - httpServerPort: Int, - fileTransferPort: Int - ) { + override suspend fun stopDiscoveryAndAdvertising() { 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() { + private fun stopDiscoveryServices() { synchronized(this) { if (discoveryServiceStatus.value != DiscoveryStatus.Running) return val operation = browseOperation ?: run { _discoveryServiceStatus.value = DiscoveryStatus.Idle clearResolvedDevices() - continuePendingRepairIfReady() return } @@ -122,7 +91,6 @@ class WindowsNetworkServices( if (result != ERROR_SUCCESS) { _discoveryServiceStatus.value = DiscoveryStatus.Running - cancelPendingRepair() logStatus("DnsServiceBrowseCancel", result) } } @@ -167,7 +135,6 @@ class WindowsNetworkServices( operation.arena.close() _discoveryServiceStatus.value = DiscoveryStatus.Idle clearResolvedDevices() - cancelPendingRepair() logStatus("DnsServiceBrowse", result) } } @@ -193,7 +160,6 @@ class WindowsNetworkServices( browseOperation = null _discoveryServiceStatus.value = DiscoveryStatus.Idle clearResolvedDevices() - continuePendingRepairIfReady() return } @@ -204,7 +170,6 @@ class WindowsNetworkServices( _discoveryServiceStatus.value = DiscoveryStatus.Idle clearResolvedDevices() cancelResolveOperations() - cancelPendingRepair() logStatus("Windows DNS-SD browse callback", status) return } @@ -411,7 +376,6 @@ class WindowsNetworkServices( if (serviceInstance.isNullPointer()) { _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() return } @@ -448,7 +412,6 @@ class WindowsNetworkServices( if (result != DNS_REQUEST_PENDING) { releaseRegistrationOperation(retireArena = false) _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() logStatus("DnsServiceRegister", result) } } @@ -481,7 +444,6 @@ class WindowsNetworkServices( } else { releaseRegistrationOperation(retireArena = true) _registrationServiceStatus.value = RegistrationStatus.Idle - cancelPendingRepair() logStatus("Windows DNS-SD registration callback", status) } } @@ -490,11 +452,9 @@ class WindowsNetworkServices( 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) } } @@ -512,7 +472,6 @@ class WindowsNetworkServices( val operation = registrationOperation ?: run { _registrationServiceStatus.value = RegistrationStatus.Idle - continuePendingRepairIfReady() return } @@ -529,7 +488,6 @@ class WindowsNetworkServices( if (result != DNS_REQUEST_PENDING) { operation.action = RegistrationAction.Registering _registrationServiceStatus.value = RegistrationStatus.Running - cancelPendingRepair() logStatus("DnsServiceDeRegister", result) } } @@ -741,24 +699,6 @@ class WindowsNetworkServices( 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) @@ -838,10 +778,6 @@ class WindowsNetworkServices( Deregistering } - private data class PendingRepair( - val httpServerPort: Int, - val fileTransferPort: Int - ) private companion object { val NATIVE_CALLBACK_TYPE: MethodType = MethodType.methodType(