From cb8be79115b0cdf8cc4f3b283974892671ef6a4a Mon Sep 17 00:00:00 2001 From: CodePandaaAI Date: Wed, 30 Sep 2026 19:41:09 +0530 Subject: [PATCH] refactor(network): take ownership of Android NSD for 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is not a normal “fix one bug” or “add one feature” update. For around ten days, Sync360’s nearby-network code has been rebuilt with a different goal: not to make the code look smaller, not to add an abstraction layer, and not to pretend that the old implementation had no useful defensive ideas. The goal was to make the Android NSD implementation understandable enough that it can genuinely be owned, maintained, questioned, and improved by a human who reads it later. The previous Android implementation was defensive and tried to cover many difficult situations. Some of that work was valuable. But it had become hard to follow as one story. It did not read like “start discovery, receive a device, resolve it if needed, publish it, remove it, and stop it.” It read more like a collection of mechanisms whose purpose had to be rediscovered before changing anything safely. This release replaces that relationship with a more direct one. The shared NetworkServices contract now says exactly what the app does: - start discovery - stop discovery - start advertising - stop advertising Android then owns the Android-specific details behind those four actions. On Tiramisu SDK extension 22 and newer, discovery uses DiscoveryRequest and one ServiceInfoCallback. That callback owns the modern discovery path: it receives device updates, device loss, and callback registration/unregistration outcomes. Below extension 22, discovery uses DiscoveryListener and the older one-shot resolveService API. Legacy resolution is deliberately serialized with a queue because only one legacy resolve is allowed to be active at a time. A resolver slot is released after success, failure, malformed data, a null result, self-device filtering, or a synchronous resolveService exception, so the next queued service can continue. Advertising is separate from discovery but shared across Android versions through one RegistrationListener. Discovery and advertising now expose their real platform status instead of hiding state behind a generic controller error message or a second “enabled” boolean that can disagree with Android. The shared controller was simplified heavily. It starts the HTTP and TCP listeners once, asks NetworkServices to start or stop nearby sharing, and exposes the platform status flows and nearby-device list. The Send UI now chooses Start, Stop, or Try again from the actual discovery status. It does not maintain a duplicate story about whether discovery should theoretically be enabled. Desktop and iOS implementations were also split into the same four explicit operations. This is not an attempt to make every platform implementation identical. It is an attempt to make the shared contract honest while allowing each platform to keep its own native behavior. This change may contain more code in some native places than a compact abstraction would. That is intentional. Explicit listener ownership, callbacks, queues, status changes, and cleanup are easier to reason about than clever code which is shorter but requires faith to modify. This is a developer-ownership release. The normal Android foreground path has been manually observed: nearby devices appear, update, and disappear. It is not a claim that every lifecycle, failure, multi-network, permission, or legacy NSD edge case is finished forever. Those are now visible, named problems in code that can be understood and improved deliberately instead of being hidden behind code we do not really own. Prepare Android, Desktop, and iOS package metadata as version 0.6.0. Android and iOS build numbers move to 10. --- CHANGELOG.md | 16 + androidApp/build.gradle.kts | 4 +- .../sync360/AndroidNearbyDiscoveryObserver.kt | 37 - .../com/liftley/sync360/Sync360Application.kt | 20 +- desktopApp/build.gradle.kts | 4 +- docs/DEVELOPMENT.md | 6 +- iosApp/Configuration/Config.xcconfig | 4 +- .../discovery/AndroidNetworkServices.kt | 638 +++++++++++------- .../sync360/data/NetworkServicesController.kt | 141 +--- .../sync360/domain/model/DiscoveryStatus.kt | 1 + .../domain/model/RegistrationStatus.kt | 4 +- .../sync360/domain/service/NetworkServices.kt | 8 +- .../sync360/presentation/send/SendScreen.kt | 3 +- .../presentation/send/SendScreenViewModel.kt | 21 +- .../send/components/NearbyDevicesSection.kt | 74 +- .../send/model/SendScreenState.kt | 2 - .../network/discovery/IosNetworkServices.kt | 17 +- .../network/discovery/JvmNetworkServices.kt | 182 +++-- .../windows/WindowsNetworkServices.kt | 17 +- 19 files changed, 642 insertions(+), 557 deletions(-) delete mode 100644 androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 331929f..b771f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.6.0] - 2026-09-30 + +### Changed + +- Rebuild the common nearby-network contract around four direct actions: start and stop discovery, plus start and stop advertising. +- Introduce the new Android `NsdManager` implementation and select it through Android dependency injection. The implementation keeps separate modern and legacy discovery paths, a serialized legacy resolver, and one registration listener for advertising. +- Simplify the shared network-services controller to start servers once and directly start, stop, or retry nearby sharing without carrying duplicate UI intent/error fields. +- Derive Send-screen discovery actions from platform-reported discovery status instead of a separate enabled boolean. +- Split Desktop and iOS discovery/advertising operations to match the new common contract. +- Prepare Android, Desktop, and iOS packages as `0.6.0`; Android and iOS build numbers are `10`. + +### Known limitations + +- This release is primarily a developer-ownership and readability rebuild. Normal Android foreground discovery, addition, and removal have been manually observed, but broader lifecycle and failure validation is still pending. +- Android 17 local-network permission handling is still not implemented. + ## [0.5.2] - 2026-09-19 ### Changed diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 6f7a13e..81d9494 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -36,8 +36,8 @@ android { applicationId = "com.liftley.sync360" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 9 - versionName = "0.5.2" + versionCode = 10 + versionName = "0.6.0" } buildFeatures { diff --git a/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt b/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt deleted file mode 100644 index 9aeb3c5..0000000 --- a/androidApp/src/main/kotlin/com/liftley/sync360/AndroidNearbyDiscoveryObserver.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 24e9016..c1bb5e7 100644 --- a/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt +++ b/androidApp/src/main/kotlin/com/liftley/sync360/Sync360Application.kt @@ -1,6 +1,10 @@ package com.liftley.sync360 import android.app.Application +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import com.liftley.sync360.core.di.androidModule import com.liftley.sync360.core.di.initKoinSync360 import com.liftley.sync360.data.NetworkServicesController @@ -14,7 +18,19 @@ class Sync360Application : Application() { } val networkServices = koinApplication.koin.get() - networkServices.startNetworkServices(discoveryAllowedAtStartup = false) - AndroidNearbyDiscoveryObserver(networkServices).observeAppVisibility() + + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + networkServices.startNetworkServices() + Log.d("LC", "visible") + } + + override fun onStop(owner: LifecycleOwner) { + networkServices.stopNetworkServices() + Log.d("LC", "hidden") + } + } + ) } } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 2be51e4..7507d00 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -37,7 +37,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Sync360" - packageVersion = "0.5.2" + packageVersion = "0.6.0" appResourcesRootDir.set( project.layout.projectDirectory.dir("packaging/app-resources") ) @@ -67,4 +67,4 @@ tasks.matching { it.name == "hotRun" }.configureEach { languageVersion.set(JavaLanguageVersion.of(23)) }) } -} \ No newline at end of file +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b13843c..16793b5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -58,7 +58,7 @@ The Desktop `hotRun` task is configured to use a Java 23 toolchain when it is a ## Preparing public packages -The current package version is `0.5.2`. +The current package version is `0.6.0`. Android release APKs must use the maintainer's permanent private signing key. Copy `keystore.properties.example` to the ignored `keystore.properties` file and set: @@ -156,8 +156,8 @@ Automated coverage is still minimal. Add focused tests for pure Kotlin logic whe There is no stable release yet. Treat current builds as development software. -## 0.5.2 UI validation +## 0.6.0 validation Before publishing the final packages, check the grouped Nearby devices header, rows, and footer with zero, one, and multiple devices. Check discovery off, scanning, and failure/retry states; light and dark themes; compact and wide windows; and the intentionally limited empty-state text at larger font sizes. Check media/document selection and adding more files. The shared default surface shape changed, so inspect other screens that use its default corners too. -Build and device validation for 0.5.2 have not been recorded in this preparation task. +Build and device validation for 0.6.0 have not been recorded in this preparation task. diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 802f738..867f51d 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Sync360 PRODUCT_BUNDLE_IDENTIFIER=com.liftley.sync360.Sync360$(TEAM_ID) -CURRENT_PROJECT_VERSION=9 -MARKETING_VERSION=0.5.2 +CURRENT_PROJECT_VERSION=10 +MARKETING_VERSION=0.6.0 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 5e3ecfb..fe460a0 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,355 +1,469 @@ package com.liftley.sync360.data.network.discovery import android.content.Context -import android.net.Network +import android.net.nsd.DiscoveryRequest import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build +import android.os.ext.SdkExtensions import android.util.Log -import androidx.annotation.RequiresApi +import androidx.annotation.RequiresExtension 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.withContext +import kotlinx.coroutines.flow.update -class AndroidNetworkServices( - context: Context, - identityStore: LocalDeviceIdentityStore -) : NetworkServices { +class AndroidNetworkServices(context: Context, identityStore: LocalDeviceIdentityStore) : + NetworkServices { private val nsdManager = requireNotNull(context.getSystemService(NsdManager::class.java)) private val mainExecutor = context.mainExecutor - private val deviceUuid = identityStore.getOrCreateDeviceUuid() + + private val myDeviceUuid = identityStore.getOrCreateDeviceUuid() private val _nearbyDevices = MutableStateFlow>(emptyList()) - override val nearbyDevices = _nearbyDevices.asStateFlow() + override val nearbyDevices: StateFlow> = _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 + override val discoveryServiceStatus: StateFlow = + _discoveryServiceStatus.asStateFlow() + + // Modern Discovery Service Holder (One Complete Object) + private var modernDiscoveryCallback: NsdManager.ServiceInfoCallback? = null + + // Legacy Discovery Service Holder + private var legacyDiscoveryCallback: NsdManager.DiscoveryListener? = null + + // Legacy Discovery and Resolving system should resolve one discovered service at once, or + // it may break/crash or errors will occur, that's why we give Android only one service to + // resolve at a time by using queue, we wait until one resolves or fails to resolve, then + // after anything happens we resolve next in the queue @isLegacyPendingResolutionQueue, + // Also @isLegacyResolverCurrentlyResolving turns true when resoling and false when resolved + + private var legacyPendingResolutionsQueue: ArrayDeque = ArrayDeque() + + private var isLegacyResolverCurrentlyResolving: Boolean = false + + // Universal Registration 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 + private val _registrationServiceStatus = MutableStateFlow(RegistrationStatus.Idle) + override val registrationServiceStatus: StateFlow = + _registrationServiceStatus.asStateFlow() + - override suspend fun startDiscoveryAndAdvertising(httpServerPort: Int, fileTransferPort: Int) { - withContext(Dispatchers.Main.immediate) { - if (discoveryServiceStatus.value == DiscoveryStatus.Idle) startDeviceScan() + override suspend fun startDiscovery() { + if (discoveryServiceStatus.value != DiscoveryStatus.Idle && discoveryServiceStatus.value != DiscoveryStatus.FailedToStart) return - if (registrationServiceStatus.value == RegistrationStatus.Idle) { - advertiseThisDevice(httpServerPort, fileTransferPort) - } + if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 22) { + startModernDiscovery() + } else { + startLegacyDiscovery() } + } - override suspend fun stopDiscoveryAndAdvertising() { - withContext(Dispatchers.Main.immediate) { + override suspend fun stopDiscovery() { + Log.d("Discovery Cleanup Engine", "Stopping and Cleaning Discovery and Advertising") + if (discoveryServiceStatus.value == DiscoveryStatus.FailedToStart) { + _discoveryServiceStatus.value = DiscoveryStatus.Idle + _nearbyDevices.value = emptyList() + legacyPendingResolutionsQueue.clear() + return + } + if (discoveryServiceStatus.value != DiscoveryStatus.Running && discoveryServiceStatus.value != DiscoveryStatus.CleanupFailed) return + + _discoveryServiceStatus.value = DiscoveryStatus.Stopping - activeDiscoverySession?.stop() + if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 22) { + runCatching { + modernDiscoveryCallback?.let { + nsdManager.unregisterServiceInfoCallback(it) + } + _nearbyDevices.value = emptyList() + }.onFailure { + _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed - 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) + Log.d("Discovery", "Could not Unregister ServiceInfoCallback | Cleanup Failed") + } + } else { + runCatching { + legacyDiscoveryCallback?.let { + nsdManager.stopServiceDiscovery(it) } + + _nearbyDevices.value = emptyList() + }.onFailure { + _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed + + Log.d("Legacy Discovery", "Could not Stop Discovery | Cleanup Failed") } } + + Log.d("Discovery Cleanup Engine", "Discovery cleanup ran but not guaranteed it worked") } - private fun startDeviceScan() { - val session = NearbyDeviceScan() - activeDiscoverySession = session + @RequiresExtension(extension = Build.VERSION_CODES.TIRAMISU, version = 22) + private fun startModernDiscovery() { + // If discovery is in any state from which it shouldn't be called then return(i.e running) + if (discoveryServiceStatus.value != DiscoveryStatus.Idle && discoveryServiceStatus.value != DiscoveryStatus.FailedToStart) return + + // Change state to starting so UI knows to show appropriate ui + any changes that should + // happen _discoveryServiceStatus.value = DiscoveryStatus.Starting + + val discoveryRequest = DiscoveryRequest.Builder(SERVICE_TYPE) + .setNetwork(null) + .build() + + modernDiscoveryCallback = object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + // Android accepted the request call, then asynchronously rejected registration. + modernDiscoveryCallback = null + + _discoveryServiceStatus.value = DiscoveryStatus.FailedToStart + + Log.d("Discovery", "Service failed to start | Error: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + modernDiscoveryCallback = null + + _discoveryServiceStatus.value = DiscoveryStatus.Idle + + _nearbyDevices.value = emptyList() + + Log.d("Discovery", "Successfully Unregistered Discovery Callback") + } + + override fun onServiceLost() { + Log.d("Discovery", "onServiceLost without parameter: Info not available") + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + Log.d("Discovery", "Service Lost with parameter: $serviceInfo") + + val cleanList = + nearbyDevices.value.filterNot { it.serviceName == serviceInfo.serviceName } + + _nearbyDevices.update { + cleanList + } + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + val resolvedDevice = serviceInfo.toNearbyDeviceAndroidImpl() ?: return + if (resolvedDevice.id == myDeviceUuid) return + + val cleanList = nearbyDevices.value.filterNot { it.id == resolvedDevice.id } + + _nearbyDevices.update { + cleanList + resolvedDevice + } + + Log.d("Discovery", "New service found or new update for existing: $resolvedDevice") + } + } + runCatching { - nsdManager.discoverServices( - SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, null as Network?, mainExecutor, session - ) + modernDiscoveryCallback?.let { + nsdManager.registerServiceInfoCallback( + discoveryRequest, + mainExecutor, + it + ) + } + + _discoveryServiceStatus.value = DiscoveryStatus.Running }.onFailure { - activeDiscoverySession = null - _discoveryServiceStatus.value = DiscoveryStatus.Idle - logFailure("Discover", it) + modernDiscoveryCallback = null + + _discoveryServiceStatus.value = DiscoveryStatus.FailedToStart + + Log.d("Discovery", "Service failed to start | Error: ${it.message}") } } - 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() - private fun isActiveSession() = activeDiscoverySession === this && - (discoveryServiceStatus.value == DiscoveryStatus.Starting || - discoveryServiceStatus.value == DiscoveryStatus.Running) + private fun startLegacyDiscovery() { + // If discovery is in any state from which it shouldn't be called then return(i.e running) + if (discoveryServiceStatus.value != DiscoveryStatus.Idle && discoveryServiceStatus.value != DiscoveryStatus.FailedToStart) return - override fun onDiscoveryStarted(serviceType: String) { - if (activeDiscoverySession === this) _discoveryServiceStatus.value = - DiscoveryStatus.Running - } + // Change state to starting so UI knows to show appropriate ui + any changes that should + // happen + _discoveryServiceStatus.value = DiscoveryStatus.Starting - override fun onDiscoveryStopped(serviceType: String) { - if (activeDiscoverySession !== this) return - isBrowseStopped = true - finishSessionCleanup() - } + legacyDiscoveryCallback = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String?) { + _discoveryServiceStatus.value = DiscoveryStatus.Running - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - if (activeDiscoverySession !== this) return - logStatus("Start discovery", errorCode) - _discoveryServiceStatus.value = DiscoveryStatus.Stopping - isBrowseStopped = true - clearNearbyDevices() - stopTrackingServices() - finishSessionCleanup() - } + Log.d("Legacy Discovery", "Discovery has started") + } - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - if (activeDiscoverySession !== this) return - logStatus("Stop discovery", errorCode) - _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed - } + override fun onDiscoveryStopped(serviceType: String?) { + legacyDiscoveryCallback = null - 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() - } - - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - if (serviceInfoCallbacks[serviceKey] === this && this !in callbacksBeingRemoved) { - updateResolvedDevice(info, serviceInfo) - } - } - - override fun onServiceLost() { - if (!isActiveSession() || serviceInfoCallbacks[serviceKey] !== this) return - resolvedDevices.remove(serviceKey) - publishNearbyDevices() - } - - 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) } - } - } - } - 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() + _discoveryServiceStatus.value = DiscoveryStatus.Idle + + _nearbyDevices.value = emptyList() + + legacyPendingResolutionsQueue.clear() + + Log.d("Legacy Discovery", "Discovery has stopped") } - } - 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) - } + override fun onServiceFound(serviceInfo: NsdServiceInfo?) { + Log.d("Legacy Discovery", "Service Found with parameter: $serviceInfo") - fun isCurrentService(info: NsdServiceInfo): Boolean { - return isActiveSession() && discoveredServices[serviceKey(info)] === info - } + if (serviceInfo == null) return - 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() - } + legacyPendingResolutionsQueue.addLast(serviceInfo) - private fun publishNearbyDevices() { - _nearbyDevices.value = resolvedDevices.values.groupBy { it.id }.values.map { matches -> - matches.first() - .copy(hostAddresses = matches.flatMap { it.hostAddresses }.distinct()) + resolveNextLegacyService() } - } - 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 onServiceLost(serviceInfo: NsdServiceInfo?) { + Log.d("Legacy Discovery", "Service Lost with parameter: $serviceInfo") + + if (serviceInfo == null) return + + val cleanList = + nearbyDevices.value.filterNot { it.serviceName == serviceInfo.serviceName } + + _nearbyDevices.update { + cleanList } } - } - private fun clearNearbyDevices() { - discoveredServices.clear() - resolvedDevices.clear() - pendingResolutions.clear() - _nearbyDevices.value = emptyList() - } + override fun onStartDiscoveryFailed( + serviceType: String?, + errorCode: Int + ) { + legacyDiscoveryCallback = null - private fun stopTrackingServices() { - if (Build.VERSION.SDK_INT >= 34) serviceInfoCallbacks.values.toList() - .forEach(::stopTrackingService) - } + _discoveryServiceStatus.value = DiscoveryStatus.FailedToStart - @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) + Log.d( + "Legacy Discovery", + "Discovery failed to start: $serviceType | Error: $errorCode" + ) } - } - 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. + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { _discoveryServiceStatus.value = DiscoveryStatus.CleanupFailed + + Log.d( + "Legacy Discovery", + "Discovery Failed to stop: $serviceType | Error: $errorCode" + ) } } - } - // 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() + runCatching { + nsdManager.discoverServices( + SERVICE_TYPE, + NsdManager.PROTOCOL_DNS_SD, + legacyDiscoveryCallback + ) + }.onFailure { + legacyDiscoveryCallback = null + + _discoveryServiceStatus.value = DiscoveryStatus.FailedToStart + + Log.d("Legacy Discovery", "Discovery failed to start | Error: ${it.message}") } - 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() + } + + @Suppress("Deprecation") + fun resolveNextLegacyService() { + if (isLegacyResolverCurrentlyResolving) return + + val nextServiceToResolve = legacyPendingResolutionsQueue.removeFirstOrNull() ?: return + + isLegacyResolverCurrentlyResolving = true + + val resolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { + Log.d( + "Legacy Resolver", + "Resolve Failed: $serviceInfo | Error: $errorCode" + ) + + // Turns isLegacyResolverCurrentlyResolving to false and again calls this same + // function for resolving next service + + finishLegacyResolution() } - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - session.updateResolvedDevice(info, serviceInfo) - isResolvingService = false - resolveNextService() + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + Log.d( + "Legacy Resolver", + "Resolve Success: $serviceInfo" + ) + + // Turns isLegacyResolverCurrentlyResolving to false and again calls this same + // function for resolving next service + finishLegacyResolution() + + if (serviceInfo == null) return + + val resolvedDevice = serviceInfo.toNearbyDeviceAndroidImpl() ?: return + + if (resolvedDevice.id == myDeviceUuid) return + + val cleanList = nearbyDevices.value.filterNot { it.id == resolvedDevice.id } + + _nearbyDevices.update { + cleanList + resolvedDevice + } } } - runCatching { nsdManager.resolveService(info, mainExecutor, listener) }.onFailure { - isResolvingService = false - logFailure("Resolve", it) - resolveNextService() + + runCatching { + nsdManager.resolveService(nextServiceToResolve, resolveListener) + }.onFailure { + Log.d( + "Legacy Resolver", + "Inside runCatching's onFailure Block, Error: ${it.message}" + ) + + finishLegacyResolution() } } - private fun advertiseThisDevice(httpServerPort: Int, fileTransferPort: Int) { - val listener = object : NsdManager.RegistrationListener { - override fun onServiceRegistered(info: NsdServiceInfo) { - if (registrationListener === this) _registrationServiceStatus.value = - RegistrationStatus.Running - } + private fun finishLegacyResolution() { + isLegacyResolverCurrentlyResolving = false + resolveNextLegacyService() + } + + override suspend fun startAdvertising( + httpServerPort: Int, + fileTransferPort: Int + ) { + if (registrationServiceStatus.value != RegistrationStatus.Idle && registrationServiceStatus.value != RegistrationStatus.FailedToStart) return - override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) { - if (registrationListener !== this) return + _registrationServiceStatus.value = RegistrationStatus.Starting + + val serviceInfo = getMyDeviceServiceInfo( + httpServerPort = httpServerPort, + fileTransferPort = fileTransferPort + ) + + registrationListener = object : NsdManager.RegistrationListener { + override fun onRegistrationFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { registrationListener = null - _registrationServiceStatus.value = RegistrationStatus.Idle - logStatus("Register", errorCode) + + _registrationServiceStatus.value = RegistrationStatus.FailedToStart + + Log.d( + "Advertising", + "Advertising failed to start | ServiceInfo: $serviceInfo | Error: $errorCode" + ) + } + + override fun onServiceRegistered(serviceInfo: NsdServiceInfo?) { + _registrationServiceStatus.value = RegistrationStatus.Running + + Log.d("Advertising", "Advertised Successfully | ServiceInfo: $serviceInfo") } - override fun onServiceUnregistered(info: NsdServiceInfo) { - if (registrationListener !== this) return + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo?) { registrationListener = null + _registrationServiceStatus.value = RegistrationStatus.Idle + + Log.d( + "Advertising", + "Advertising Unregistered Successfully | ServiceInfo: $serviceInfo" + ) } - override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) { - if (registrationListener !== this) return - _registrationServiceStatus.value = RegistrationStatus.Running - logStatus("Unregister", errorCode) + override fun onUnregistrationFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { + _registrationServiceStatus.value = RegistrationStatus.CleanupFailed + + Log.d( + "Advertising", + "Advertising failed to stop/unregister | ServiceInfo: $serviceInfo" + ) } + } - 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", name) - setAttribute("deviceType", "Android") - setAttribute("protocolVersion", "1") - setAttribute("fileTransferPort", fileTransferPort.toString()) - } - nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, mainExecutor, listener) + nsdManager.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListener + ) }.onFailure { registrationListener = null - _registrationServiceStatus.value = RegistrationStatus.Idle - logFailure("Register", it) + + _registrationServiceStatus.value = RegistrationStatus.FailedToStart + + Log.d("Resolver", "Resolver failed to start | Error: ${it.message}") } } - private fun serviceKey(info: NsdServiceInfo) = "${info.serviceName}|${info.network}" - private fun logStatus(action: String, status: Int) = - Log.w("AndroidNetworkServices", "$action failed: $status") + override suspend fun stopAdvertising() { + if (registrationServiceStatus.value == RegistrationStatus.FailedToStart) { + _registrationServiceStatus.value = RegistrationStatus.Idle + return + } + if (registrationServiceStatus.value != RegistrationStatus.Running && + registrationServiceStatus.value != RegistrationStatus.CleanupFailed + ) return - private fun logFailure(action: String, error: Throwable) = - Log.w("AndroidNetworkServices", "$action failed", error) + _registrationServiceStatus.value = RegistrationStatus.Stopping + + runCatching { + registrationListener?.let { + nsdManager.unregisterService(it) + } + }.onFailure { + _registrationServiceStatus.value = RegistrationStatus.CleanupFailed + } + + Log.d("Advertising Cleanup Engine", "Advertising cleanup ran but not guaranteed it worked") + } + + private fun getMyDeviceServiceInfo( + httpServerPort: Int, + fileTransferPort: Int + ): NsdServiceInfo { + 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" + + return NsdServiceInfo().apply { + serviceType = SERVICE_TYPE + serviceName = "${Build.MODEL} Sync360" + port = httpServerPort + setAttribute("deviceUuid", myDeviceUuid) + setAttribute("deviceName", name) + setAttribute("deviceType", "Android") + setAttribute("protocolVersion", "1") + setAttribute("fileTransferPort", fileTransferPort.toString()) + } + } private companion object { - const val SERVICE_TYPE = "_sync360._tcp." + const val SERVICE_TYPE = "_sync360._tcp" } } 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 24273bb..32cad05 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt @@ -5,13 +5,9 @@ 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.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class NetworkServicesController( @@ -20,133 +16,58 @@ 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 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 { + fun startNetworkServices() { controllerScope.launch { - discoveryServiceStatus.collect { discoveryCommands.send(DiscoveryCommand.StatusChanged) } + startServers() + startNearbySharing() } + } + + fun stopNetworkServices() { controllerScope.launch { - registrationServiceStatus.collect { discoveryCommands.send(DiscoveryCommand.StatusChanged) } + stopNearbySharing() } - // Only this loop changes lifecycle intent. Callbacks report status; - // they never decide to start a replacement session. + } + + fun retryNearbySharing() { 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() - } + if (hasCleanupFailure()) { + stopNearbySharing() + } else { + startServers() + startNearbySharing() } } } - fun startNetworkServices(discoveryAllowedAtStartup: Boolean = true) { - discoveryCommands.trySend(DiscoveryCommand.Initialize(discoveryAllowedAtStartup)) - } - - fun setDiscoveryAllowedByLifecycle(allowed: Boolean) { - discoveryCommands.trySend(DiscoveryCommand.LifecyclePermission(allowed)) + private suspend fun startServers() { + if (httpServerPort == null) httpServerPort = httpServer.start() + if (fileTransferPort == null) fileTransferPort = fileTransferReceiver.start() } - fun setDiscoveryEnabled(enabled: Boolean) { - discoveryCommands.trySend(DiscoveryCommand.Enable(enabled)) + private suspend fun startNearbySharing() { + networkServices.startDiscovery() + networkServices.startAdvertising( + httpServerPort = checkNotNull(httpServerPort), + fileTransferPort = checkNotNull(fileTransferPort) + ) } - private fun allowDiscoveryRetry() { - startRequested = false - stopRequested = false - _discoveryErrorMessage.value = null - } - - 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 suspend fun stopNearbySharing() { + networkServices.stopDiscovery() + networkServices.stopAdvertising() } - 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 + private fun hasCleanupFailure(): Boolean { + return discoveryServiceStatus.value == DiscoveryStatus.CleanupFailed || + registrationServiceStatus.value == RegistrationStatus.CleanupFailed } } 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 4d12d87..68ca326 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 @@ -3,6 +3,7 @@ package com.liftley.sync360.domain.model enum class DiscoveryStatus { Idle, Starting, + FailedToStart, Running, Stopping, /** Cleanup failed; resources are still owned and must be stopped before reuse. */ diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt index d50af1b..8b8226a 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/domain/model/RegistrationStatus.kt @@ -3,6 +3,8 @@ package com.liftley.sync360.domain.model enum class RegistrationStatus { Idle, Starting, + FailedToStart, Running, - Stopping + Stopping, + CleanupFailed } 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 ac1bf00..8cc80ee 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,7 +10,11 @@ interface NetworkServices { val discoveryServiceStatus: StateFlow val registrationServiceStatus: StateFlow - suspend fun startDiscoveryAndAdvertising(httpServerPort: Int, fileTransferPort: Int) + suspend fun startDiscovery() - suspend fun stopDiscoveryAndAdvertising() + suspend fun stopDiscovery() + + suspend fun startAdvertising(httpServerPort: Int, fileTransferPort: Int) + + suspend fun stopAdvertising() } 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 d2a08ef..6a9c03f 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 @@ -104,7 +104,8 @@ fun SendScreen() { NearbyDevicesSection( screenState = screenState, - onDiscoveryEnabledChange = sendScreenViewModel::setDiscoveryEnabled, + onStartNearbySharing = sendScreenViewModel::startNearbySharing, + onStopNearbySharing = sendScreenViewModel::stopNearbySharing, onRetryDiscovery = sendScreenViewModel::retryDiscovery, onDeviceClick = sendScreenViewModel::sendToDevice ) 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 012d53c..72025fc 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,17 +48,6 @@ 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 @@ -91,12 +80,16 @@ class SendScreenViewModel( } - fun setDiscoveryEnabled(enabled: Boolean) { - networkServicesController.setDiscoveryEnabled(enabled) + fun startNearbySharing() { + networkServicesController.startNetworkServices() + } + + fun stopNearbySharing() { + networkServicesController.stopNetworkServices() } fun retryDiscovery() { - networkServicesController.setDiscoveryEnabled(sendScreenState.value.isDiscoveryEnabled) + networkServicesController.retryNearbySharing() } fun sendToDevice(deviceId: String) { 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 9f346cd..d322076 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 @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -38,7 +37,8 @@ import com.liftley.sync360.presentation.send.model.SendScreenState @Composable fun NearbyDevicesSection( screenState: SendScreenState, - onDiscoveryEnabledChange: (Boolean) -> Unit, + onStartNearbySharing: () -> Unit, + onStopNearbySharing: () -> Unit, onRetryDiscovery: () -> Unit, onDeviceClick: (String) -> Unit ) { @@ -46,10 +46,19 @@ fun NearbyDevicesSection( val status = when (screenState.discoveryStatus) { DiscoveryStatus.Idle -> "Discovery is off" DiscoveryStatus.Starting -> "Starting discovery…" + DiscoveryStatus.FailedToStart -> "Couldn’t start discovery" DiscoveryStatus.Running -> if (hasDevices) "searching for more devices…" else "Searching for nearby devices…" DiscoveryStatus.Stopping -> "Stopping discovery…" DiscoveryStatus.CleanupFailed -> "Couldn’t stop discovery" } + val (buttonLabel, onButtonClick) = when (screenState.discoveryStatus) { + DiscoveryStatus.Idle -> "Start" to onStartNearbySharing + DiscoveryStatus.Starting, + DiscoveryStatus.Running, + DiscoveryStatus.Stopping -> "Stop" to onStopNearbySharing + DiscoveryStatus.FailedToStart, + DiscoveryStatus.CleanupFailed -> "Try again" to onRetryDiscovery + } Column( modifier = Modifier .fillMaxWidth(), @@ -71,10 +80,8 @@ fun NearbyDevicesSection( modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleLarge ) - if (screenState.isDiscoveryEnabled || hasDevices) { - OutlinedButton(onClick = { onDiscoveryEnabledChange(!screenState.isDiscoveryEnabled) }) { - Text(if (screenState.isDiscoveryEnabled) "Stop" else "Start") - } + OutlinedButton(onClick = onButtonClick) { + Text(buttonLabel) } } } @@ -119,7 +126,7 @@ fun NearbyDevicesSection( ) } } - } else if (screenState.discoveryErrorMessage == null) { + } else { Sync360Surface( shape = MaterialTheme.shapes.large.copy( topStart = CornerSize(8.dp), @@ -146,10 +153,11 @@ fun NearbyDevicesSection( textAlign = TextAlign.Center ) 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." + text = when (screenState.discoveryStatus) { + DiscoveryStatus.Idle -> "Click Start to find nearby devices and let them find you." + DiscoveryStatus.FailedToStart, + DiscoveryStatus.CleanupFailed -> "Tap Try again to retry discovery." + else -> "Open Sync360 on the other device and connect both to the same Wi-Fi network or hotspot." }, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -157,48 +165,6 @@ fun NearbyDevicesSection( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) - if (!screenState.isDiscoveryEnabled) { - Button(onClick = { onDiscoveryEnabledChange(true) }) { Text("Start discovery") } - } - } - } - } - - screenState.discoveryErrorMessage?.let { message -> - Sync360Surface( - modifier = Modifier.fillMaxWidth(), - shape = if (hasDevices) MaterialTheme.shapes.extraLarge else { - MaterialTheme.shapes.large.copy( - topStart = CornerSize(8.dp), - topEnd = CornerSize(8.dp) - ) - } - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Sync360Surface(containerColor = MaterialTheme.colorScheme.errorContainer) { - Icon( - imageVector = Wifi, - contentDescription = null, - modifier = Modifier.padding(16.dp).size(24.dp), - tint = MaterialTheme.colorScheme.onErrorContainer - ) - } - Text( - text = "Discovery needs attention", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - OutlinedButton(onClick = onRetryDiscovery) { Text("Try again") } } } } @@ -250,4 +216,4 @@ private fun NearbyDeviceRow( } } } -} \ 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 766271c..a63aa7e 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,8 +13,6 @@ 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/iosMain/kotlin/com/liftley/sync360/data/network/discovery/IosNetworkServices.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/data/network/discovery/IosNetworkServices.kt index 7ab2dc0..aeec04f 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 @@ -87,21 +87,28 @@ class IosNetworkServices( private val resolveRefsByKey = mutableMapOf() private val addressRefsByKey = mutableMapOf() - override suspend fun startDiscoveryAndAdvertising( - httpServerPort: Int, - fileTransferPort: Int - ) { + override suspend fun startDiscovery() { locked { startDiscoveryService() + } + } + + override suspend fun startAdvertising(httpServerPort: Int, fileTransferPort: Int) { + locked { startRegistrationService(httpServerPort, fileTransferPort) } } - override suspend fun stopDiscoveryAndAdvertising() { + override suspend fun stopDiscovery() { locked { if (discoveryServiceStatus.value == DiscoveryStatus.Running) { stopDiscoveryService() } + } + } + + override suspend fun stopAdvertising() { + locked { if (registrationServiceStatus.value == RegistrationStatus.Running) { stopRegistrationService() } 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 59cfe50..77679a1 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 @@ -39,85 +39,97 @@ class JvmNetworkServices( private val jmDnsByAddress = mutableMapOf() private val listenerByAddress = mutableMapOf() + private val serviceByAddress = mutableMapOf() private val resolvedDevicesByServiceKey = ConcurrentHashMap() - override suspend fun startDiscoveryAndAdvertising( - httpServerPort: Int, - fileTransferPort: Int - ) { + override suspend fun startDiscovery() { if (discoveryServiceStatus.value != DiscoveryStatus.Idle) return - val registrationIsStarting = - registrationServiceStatus.value == RegistrationStatus.Idle + _discoveryServiceStatus.value = DiscoveryStatus.Starting - if ( - !registrationIsStarting && - registrationServiceStatus.value != RegistrationStatus.Running - ) { - return + try { + withContext(Dispatchers.IO) { + ensureJmDnsOnLanInterfaces() + addDiscoveryListeners() + } + _discoveryServiceStatus.value = DiscoveryStatus.Running + } catch (exception: Exception) { + removeDiscoveryListeners() + _discoveryServiceStatus.value = DiscoveryStatus.FailedToStart + exception.printStackTrace() } + } - _discoveryServiceStatus.value = DiscoveryStatus.Starting - if (registrationIsStarting) { - _registrationServiceStatus.value = RegistrationStatus.Starting + override suspend fun stopDiscovery() { + if (discoveryServiceStatus.value == DiscoveryStatus.FailedToStart) { + _discoveryServiceStatus.value = DiscoveryStatus.Idle + closeInstancesIfUnused() + return } + if (discoveryServiceStatus.value != DiscoveryStatus.Running && + discoveryServiceStatus.value != DiscoveryStatus.CleanupFailed + ) return + + _discoveryServiceStatus.value = DiscoveryStatus.Stopping + val stopped = withContext(Dispatchers.IO) { removeDiscoveryListeners() } + _discoveryServiceStatus.value = if (stopped) DiscoveryStatus.Idle else DiscoveryStatus.CleanupFailed + if (stopped) closeInstancesIfUnused() + } + override suspend fun startAdvertising(httpServerPort: Int, fileTransferPort: Int) { + if (registrationServiceStatus.value != RegistrationStatus.Idle) return + + _registrationServiceStatus.value = RegistrationStatus.Starting try { withContext(Dispatchers.IO) { - if (registrationIsStarting) { - if (jmDnsByAddress.isNotEmpty() && !closeAllInstances()) { - error("Could not close the previous JmDNS instances") - } - startOnLanInterfaces(httpServerPort, fileTransferPort) - } else { - addDiscoveryListeners() - } + ensureJmDnsOnLanInterfaces() + registerServices(httpServerPort, fileTransferPort) } - - if (registrationIsStarting) { - _registrationServiceStatus.value = RegistrationStatus.Running - } - _discoveryServiceStatus.value = DiscoveryStatus.Running + _registrationServiceStatus.value = RegistrationStatus.Running } catch (exception: Exception) { - val closed = withContext(Dispatchers.IO) { closeAllInstances() } - _registrationServiceStatus.value = if (closed) RegistrationStatus.Idle else RegistrationStatus.Running - _discoveryServiceStatus.value = if (closed) DiscoveryStatus.Idle else DiscoveryStatus.CleanupFailed + unregisterServices() + _registrationServiceStatus.value = RegistrationStatus.FailedToStart + closeInstancesIfUnused() exception.printStackTrace() } } - override suspend fun stopDiscoveryAndAdvertising() { - _discoveryServiceStatus.value = DiscoveryStatus.Stopping + override suspend fun stopAdvertising() { + if (registrationServiceStatus.value == RegistrationStatus.FailedToStart) { + _registrationServiceStatus.value = RegistrationStatus.Idle + closeInstancesIfUnused() + return + } + if (registrationServiceStatus.value != RegistrationStatus.Running && + registrationServiceStatus.value != RegistrationStatus.CleanupFailed + ) return + _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 + val stopped = withContext(Dispatchers.IO) { unregisterServices() } + _registrationServiceStatus.value = if (stopped) { + RegistrationStatus.Idle + } else { + RegistrationStatus.CleanupFailed + } + if (stopped) closeInstancesIfUnused() } - private fun startOnLanInterfaces( - httpServerPort: Int, - fileTransferPort: Int - ) { + private fun ensureJmDnsOnLanInterfaces() { val addresses = findLanAddresses() var lastFailure: Throwable? = null addresses.forEach { address -> + synchronized(this) { + if (address in jmDnsByAddress) return@forEach + } + var jmDns: JmDNS? = null try { val startedJmDns = JmDNS.create(address) jmDns = startedJmDns - val listener = createServiceListener(startedJmDns, address) - val service = createService( - httpServerPort = httpServerPort, - fileTransferPort = fileTransferPort - ) - - startedJmDns.registerService(service) - startedJmDns.addServiceListener(SERVICE_TYPE, listener) synchronized(this) { jmDnsByAddress[address] = startedJmDns - listenerByAddress[address] = listener } } catch (exception: Exception) { runCatching { @@ -132,13 +144,33 @@ class JvmNetworkServices( synchronized(this) { if (jmDnsByAddress.isEmpty()) { throw IllegalStateException( - "Could not start Sync360 on any active LAN interface", + "Could not create JmDNS on any active LAN interface", lastFailure ) } } } + @Synchronized + private fun registerServices(httpServerPort: Int, fileTransferPort: Int) { + val registeredAddresses = mutableListOf() + try { + jmDnsByAddress.forEach { (address, jmDns) -> + if (address in serviceByAddress) return@forEach + val service = createService(httpServerPort, fileTransferPort) + jmDns.registerService(service) + serviceByAddress[address] = service + registeredAddresses += address + } + } catch (exception: Exception) { + registeredAddresses.forEach { address -> + val service = serviceByAddress.remove(address) ?: return@forEach + runCatching { jmDnsByAddress[address]?.unregisterService(service) } + } + throw exception + } + } + private fun createService( httpServerPort: Int, fileTransferPort: Int @@ -193,16 +225,18 @@ class JvmNetworkServices( @Synchronized private fun addDiscoveryListeners() { - if (listenerByAddress.isEmpty()) { - error("No registered JmDNS instances are available for discovery") + if (jmDnsByAddress.isEmpty()) { + error("No JmDNS instances are available for discovery") } val addedListeners = mutableListOf>() try { - listenerByAddress.forEach { (address, listener) -> - val jmDns = jmDnsByAddress[address] ?: return@forEach + jmDnsByAddress.forEach { (address, jmDns) -> + if (address in listenerByAddress) return@forEach + val listener = createServiceListener(jmDns, address) jmDns.addServiceListener(SERVICE_TYPE, listener) + listenerByAddress[address] = listener addedListeners += jmDns to listener } } catch (exception: Exception) { @@ -210,11 +244,44 @@ class JvmNetworkServices( runCatching { jmDns.removeServiceListener(SERVICE_TYPE, listener) } + listenerByAddress.entries.removeAll { it.value === listener } } throw exception } } + @Synchronized + private fun removeDiscoveryListeners(): Boolean { + var stopped = true + listenerByAddress.toMap().forEach { (address, listener) -> + val jmDns = jmDnsByAddress[address] ?: return@forEach + runCatching { jmDns.removeServiceListener(SERVICE_TYPE, listener) } + .onSuccess { listenerByAddress.remove(address) } + .onFailure { + stopped = false + it.printStackTrace() + } + } + resolvedDevicesByServiceKey.clear() + _nearbyDevices.value = emptyList() + return stopped + } + + @Synchronized + private fun unregisterServices(): Boolean { + var stopped = true + serviceByAddress.toMap().forEach { (address, service) -> + val jmDns = jmDnsByAddress[address] ?: return@forEach + runCatching { jmDns.unregisterService(service) } + .onSuccess { serviceByAddress.remove(address) } + .onFailure { + stopped = false + it.printStackTrace() + } + } + return stopped + } + @Synchronized private fun closeAllInstances(): Boolean { val closedAddresses = mutableListOf() @@ -232,6 +299,7 @@ class JvmNetworkServices( closedAddresses.forEach { address -> jmDnsByAddress.remove(address) listenerByAddress.remove(address) + serviceByAddress.remove(address) } resolvedDevicesByServiceKey.clear() _nearbyDevices.value = emptyList() @@ -239,6 +307,14 @@ class JvmNetworkServices( return jmDnsByAddress.isEmpty() } + private suspend fun closeInstancesIfUnused() { + if (discoveryServiceStatus.value != DiscoveryStatus.Idle || + registrationServiceStatus.value != RegistrationStatus.Idle + ) return + + withContext(Dispatchers.IO) { closeAllInstances() } + } + @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 6d1b490..1e58bcf 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 @@ -47,21 +47,28 @@ class WindowsNetworkServices( // Windows callback is still unwinding when Kotlin receives it. private val retiredNativeArenas = mutableListOf() - override suspend fun startDiscoveryAndAdvertising( - httpServerPort: Int, - fileTransferPort: Int - ) { + override suspend fun startDiscovery() { synchronized(this) { startDiscoveryService() + } + } + + override suspend fun startAdvertising(httpServerPort: Int, fileTransferPort: Int) { + synchronized(this) { startRegistrationService(httpServerPort, fileTransferPort) } } - override suspend fun stopDiscoveryAndAdvertising() { + override suspend fun stopDiscovery() { synchronized(this) { if (discoveryServiceStatus.value == DiscoveryStatus.Running) { stopDiscoveryServices() } + } + } + + override suspend fun stopAdvertising() { + synchronized(this) { if (registrationServiceStatus.value == RegistrationStatus.Running) { stopRegistrationService() }