Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- Remove the separate discovery repair command, its Settings screen, and Troubleshoot shortcuts; use Start/Stop or failure retry in Nearby devices.

- Tie Android nearby discovery to app visibility, with a cancellable two-second background grace period after the process lifecycle delay.
- Remove the 60-second scan expiry and add Start discovery / Stop discovery controls for browsing and advertising. Manual Stop survives background/foreground transitions within the same process.
- Centralize discovery-session start/stop coordination while preserving transfer listeners and content state. Desktop discovery remains active when minimized.
- Give Android scans separate callback ownership, wait for service-info callback cleanup, and queue Android 13 legacy resolution.
- Keep failed discovery cleanup retryable and ignore resolutions for services lost and found again.
- Replace Reload and the top-bar discovery action with Start/Stop and failure retry in the Nearby devices section.
- Present nearby devices in compact rows with a header Stop action and a centered Start discovery state when off.
- Shorten discovery messages and make lifecycle, callback, and platform-operation names more descriptive.

These changes have not yet been built or validated on devices.

## [0.4.1] - 2026-08-28

### Changed
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ In an initial Windows 11 Ethernet test, the native Windows DNS-SD backend discov
- Select multiple Desktop files with the native file dialog and send them through the same offer and TCP protocol.
- Save received Desktop files safely into Downloads through a temporary `.part` file, then move completed files into place without overwriting an existing name.
- Copy received text and open the Downloads folder on Desktop.
- Open connection troubleshooting from Send, Receive, or the top app bar, then manually restart local discovery and service advertising without resetting the app or removing received files.
- Provide enabled iOS device and simulator targets with native Bonjour discovery, file selection, clipboard, Files-visible storage, and streamed TCP transfer implementations.

### Still needs work
Expand Down Expand Up @@ -107,7 +106,7 @@ flowchart LR

Android uses `NsdManager`. Windows uses the built-in `dnsapi.dll` DNS-SD API on all interfaces through Java's Foreign Function and Memory API. macOS and Linux currently retain JmDNS. Every implementation advertises the `_sync360._tcp.` DNS-SD service with a stable per-install device ID, device details, protocol version, an OS-assigned HTTP port, and a separate OS-assigned file-transfer port.

Android and Desktop start the shared network controller from their application entry points after Koin is ready. Discovery and registration have separate lifecycle states, and the 60-second discovery window begins only after discovery reports `Running`. A normal Reload restarts only discovery while registration remains active; connection repair stops and recreates both operations after their current platform callbacks reach stable states.
Android discovery follows app visibility, with a cancellable two-second grace period after the process lifecycle reports backgrounding. Returning quickly keeps the same session. Discovery stays active while the app is visible, with no 60-second expiry. Desktop remains available while running, including when minimized. Start discovery / Stop discovery control browsing and advertising on both; a manual Stop stays off until Start or a fresh process launch. Discovery cleanup does not cancel an existing transfer or block a peer that already knows the listening address. These lifecycle changes still require device validation.

### Text path

Expand Down Expand Up @@ -248,9 +247,9 @@ macOS/Linux:

Some routers enable client isolation and block local device-to-device traffic. If discovery or transfer does not work, try another trusted Wi-Fi network or a phone hotspot.

If devices still cannot discover this device or fail to connect after a network change, open **Settings** from the top app bar or select **Troubleshoot** on Send or Receive, then use **Repair connection**. Repair restarts local discovery and advertises Sync360 again; it does not reset the app or remove received files.
If devices disappear after a network change, use **Stop**, wait for discovery to stop, then **Start discovery** in the Nearby devices section on Send.

Reload is available only after the current discovery window has stopped while service registration is still running. Repair is enabled only while sending, receiving, discovery, and registration are in states where restarting them is safe.
Stop discovery requests cleanup of browsing and advertising. Start discovery enables them again. Start/Stop lives in the Nearby devices section, with Try again shown when discovery fails. Android does not monitor network changes; use Stop discovery and Start discovery if devices disappear after changing networks.

## Security warning

Expand All @@ -268,7 +267,7 @@ Use the current app only for development and testing on private networks you con
- Add integrity verification.
- Test cancellation and failure reporting across more network-loss and transfer stages.
- Strengthen lifecycle behavior and local-network reliability.
- Add Android 17 local-network permission handling and serialize Android 13 legacy NSD resolves.
- Add Android 17 local-network permission handling and validate queued Android 13 legacy NSD resolution.
- Validate Desktop discovery and transfer across more operating systems, network adapters, routers, and firewall configurations.
- Design session validation, authentication, and encryption deliberately.

Expand Down
1 change: 1 addition & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ dependencies {
implementation(projects.shared)

implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.process)

implementation(libs.compose.uiToolingPreview)
debugImplementation(libs.compose.uiTooling)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.liftley.sync360

import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.liftley.sync360.data.NetworkServicesController

/** Keeps nearby discovery active while the app is visible, with time to return before stopping. */
internal class AndroidNearbyDiscoveryObserver(
private val networkServicesController: NetworkServicesController
) : DefaultLifecycleObserver {
private val mainHandler = Handler(Looper.getMainLooper())
private val stopDiscoveryAfterDelay = Runnable {
networkServicesController.setDiscoveryAllowedByLifecycle(false)
}

fun observeAppVisibility() {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}

override fun onStart(owner: LifecycleOwner) {
mainHandler.removeCallbacks(stopDiscoveryAfterDelay)
networkServicesController.setDiscoveryAllowedByLifecycle(true)
}

override fun onStop(owner: LifecycleOwner) {
mainHandler.removeCallbacks(stopDiscoveryAfterDelay)
mainHandler.postDelayed(stopDiscoveryAfterDelay, BACKGROUND_GRACE_MILLIS)
}

private companion object {
// Begins after ProcessLifecycleOwner's own background-event delay.
const val BACKGROUND_GRACE_MILLIS = 2_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ class Sync360Application : Application() {
androidContext(applicationContext)
}

koinApplication.koin
.get<NetworkServicesController>()
.startNetworkServices()
val networkServices = koinApplication.koin.get<NetworkServicesController>()
networkServices.startNetworkServices(discoveryAllowedAtStartup = false)
AndroidNearbyDiscoveryObserver(networkServices).observeAppVisibility()
}
}
}
20 changes: 13 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket

### Controllers

- `NetworkServicesController` starts the HTTP server, file receiver, and discovery/registration once for the application lifetime. It also coordinates timed discovery stop, discovery restart, and full connection repair.
- `NetworkServicesController` owns application-lifetime HTTP/file listeners and serializes discovery intent separately. Platform callbacks report progress; the controller decides when to start or stop a discovery session. Discovery stop never closes transfer sockets.
- `OutgoingRequestsController` validates and delivers text, creates code-bearing file offers, and starts accepted file transfers.
- `IncomingServerRequestsController` generates one in-memory four-digit file receive code for its application session. Under one operation mutex it admits direct text only while idle, or atomically checks the file code, prepares the platform receiver, and publishes `ReceivingFiles`. Text follows `Idle -> TextReceived -> Idle`; files follow `Idle -> ReceivingFiles -> FilesReceived/Idle`.

Expand All @@ -86,13 +86,19 @@ ViewModels launch UI-facing work. They do not implement platform APIs or socket

Both advertise a stable device UUID, device name/type, protocol version, dynamic HTTP port, and dynamic file-transfer port. A device filters its own UUID from discovery results.

Discovery and registration expose independent `StateFlow` values. Each can be `Idle`, `Starting`, `Running`, or `Stopping`, and lifecycle commands are accepted only from compatible states. The controller derives the 60-second discovery window from `DiscoveryStatus.Running`, so platform startup time does not consume the scan window. Reload starts discovery again only while registration is still running.
Discovery and registration expose independent `StateFlow` values (`Idle`, `Starting`, `Running`, `Stopping`); discovery also reports `CleanupFailed` when resources remain owned after cleanup fails. A retry must finish cleanup before a replacement session starts. There is no scan expiry timer. Start discovery and Stop discovery control both browsing and advertising; a manual Stop remains in effect across background/foreground transitions until Start is pressed or the process restarts. Registration success is not a guarantee of reachability from another device.

Connection repair waits until both operations are stable, then stops discovery and registration, clears stale devices, and advertises the existing HTTP and TCP ports again. Android advances repair from `NsdManager` callbacks instead of fixed callback timeouts. Windows cancels its native browse and pending resolves, deregisters through the Windows API, and waits for the corresponding state transitions. The macOS/Linux fallback closes and recreates its JmDNS instances; an instance that fails to close remains tracked so a later repair can retry cleanup.
`AndroidNearbyDiscoveryObserver` observes only app visibility through `ProcessLifecycleOwner`. Foreground entry allows discovery immediately. Background entry schedules a stop after two seconds, in addition to the lifecycle owner's own delay. Returning before that stop cancels it and keeps the current session; returning after cleanup starts a new session unless the user manually stopped discovery. It does not monitor networks or automatically refresh after network changes; users can Stop and Start discovery themselves. Desktop and iOS retain process-lifetime availability; minimizing Desktop does not stop discovery. iOS visibility integration is not part of this change.

The controller waits for both operations to leave transitional states before issuing a replacement start. Users can Stop discovery and Start it again using the existing HTTP and TCP ports; there is no separate repair command. A failed start/stop is surfaced for explicit retry rather than automatically retried forever. Missing native completion callbacks are not treated as successful cleanup.

Android discovery commands and callbacks are serialized on the main dispatcher using asynchronous NSD APIs. Each scan owns a distinct listener, result map, and Android 14+ service-info callbacks; a replacement session waits for tracking callbacks to unregister. Old callbacks cannot publish into a newer session. Android 13 one-shot resolution is queued; an outstanding legacy resolve retains its slot until completion because that API level has no stop-resolution API. Its late result is discarded after its session ends or the service is lost and found again.

Windows cancels browse and pending resolves and deregisters through the native API. The macOS/Linux fallback closes JmDNS instances off the UI thread; instances that fail to close remain owned for a later cleanup attempt. Platform-native callback/memory ownership remains necessary.

Windows calls `DnsServiceBrowse`, `DnsServiceResolve`, `DnsServiceRegister`, and `DnsServiceDeRegister` through the JDK Foreign Function and Memory API. Browse and registration use interface index `0`, which delegates all-interface IPv4/IPv6 handling to Windows. Native registration and deregistration callbacks drive `RegistrationStatus`; browse cancellation drives the final transition back to `DiscoveryStatus.Idle`. Browse callbacks start resolution for added PTR records and remove devices reported with a zero TTL. Resolved TXT properties and IPv4/IPv6 addresses are converted into the same shared `NearbyDevice` model used by Android.

The Windows implementation keeps native request memory alive after terminal callbacks because a callback is still unwinding when Kotlin receives it. Those retired arenas are not yet closed later, so repeated repair cycles can retain small native allocations. Resolved results are keyed by service name and interface, but a TTL-zero browse removal currently clears every interface result for that service name.
The Windows implementation keeps native request memory alive after terminal callbacks because a callback is still unwinding when Kotlin receives it. Those retired arenas are not yet closed later, so repeated discovery restart cycles can retain small native allocations. Resolved results are keyed by service name and interface, but a TTL-zero browse removal currently clears every interface result for that service name.

The macOS/Linux JmDNS fallback starts on eligible IPv4 and IPv6 addresses from every active, multicast-capable, non-loopback, non-virtual LAN interface. Windows DNS-SD and the fallback still need broader validation with VPN, WSL, Docker, virtual-machine, Ethernet, and Wi-Fi adapters.

Expand Down Expand Up @@ -155,11 +161,11 @@ Previously completed files remain when a later file in the same batch fails.

- No authentication, encryption, session token, or cryptographic integrity check. The four-digit code has no attempt throttling and must not be treated as a security boundary.
- No retry, pause/resume, or interrupted-transfer recovery.
- Foreground/background and automatic network-change lifecycle handling are not complete.
- Android 17 local-network permission handling is not implemented even though the app targets SDK 37; Android 13 legacy NSD resolves are not serialized or retried after an already-active failure.
- Android foreground discovery is implemented but requires device validation. Automatic network-change recovery is deferred. Background transfer guarantees, Desktop wake/network recovery, and iOS visibility handling remain outside this change.
- Android 17 local-network permission handling is not implemented even though the app targets SDK 37; Android 13 legacy NSD resolves are serialized, but failed resolves are not automatically retried.
- Receiver failures do not yet provide rich error details.
- HTTP and file-transfer senders retry distinct advertised addresses after connection failures; broader address preference and scoped IPv6 validation still need work.
- Desktop interface selection and firewall behavior need broader validation. Windows inbound transfers require an application allow rule or user-approved firewall prompt.
- Repeated Windows repair cycles retain completed native callback arenas, and removing a service from one interface can temporarily clear the same service resolved through another interface.
- Repeated Windows discovery restart cycles retain completed native callback arenas, and removing a service from one interface can temporarily clear the same service resolved through another interface.
- Automated transfer coverage is minimal.
- iOS source targets and implementations are enabled, but physical-device discovery and transfer remain unverified.
Loading
Loading