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() }