LocalSend Onboarding LabChapter IV — Device Discovery: Multicast & Subnet Scans0/6Contents
Chapter IV

Device Discovery: Multicast & Subnet Scans

Nearby device discovery in LocalSend runs two parallel strategies — a UDP multicast announce/response and HTTP scans (favorites first, then a full /24 subnet sweep as a fallback) — both funneling every found Device through a single RegisterDeviceAction into the in-memory NearbyDevicesState cache. The send tab reads this cache directly (merging IP-keyed and signaling-fingerprint-keyed devices via allDevices) to populate the device picker, so tapping a device there is the exact point where discovery output becomes the target of an outgoing transfer.


From scan trigger to send target
StartSmartScan…provider/network/scan_facade.dart
StartMulticastScan…work/nearby_devices_provider.dart
StartFavoriteScan…work/nearby_devices_provider.dart
StartLegacySubnetScan…provider/network/scan_facade.dart
RegisterDeviceAction…work/nearby_devices_provider.dart
NearbyDevicesState…l/state/nearby_devices_state.dart
sendTabVmProvider…p/lib/pages/tabs/send_tab_vm.dart
sendProvider.startSession…p/lib/pages/tabs/send_tab_vm.dart
Figure I · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
walkthrough

StartSmartScan: escalate only when needed

app/lib/provider/network/scan_facade.dart · lines 1659
lines 18–23

Multicast dispatch fires immediately (fire-and-forget), while the favorite-device HTTP scan is awaited right after — both cheap methods run before anything expensive.

lines 25–29

Gives multicast/favorite results a 1-second head start before deciding whether more scanning is needed; forceLegacy skips this grace period entirely.

lines 31–39

Escalates to a full subnet HTTP sweep only if the device cache is still empty and the user hasn't navigated away from the send tab (or forceLegacy overrides both checks).

lines 40–48

Otherwise it just logs why the legacy scan was skipped — either devices were already found, or the user left the send tab — instead of silently sweeping the subnet anyway.

  Future<void> reduce() async {    // Try performant Multicast/UDP method first    ref.redux(nearbyDevicesProvider).dispatch(StartMulticastScan());     // At the same time, try to discover favorites    final favorites = ref.read(favoritesProvider);    final https = ref.read(settingsProvider).https;    await ref.redux(nearbyDevicesProvider).dispatchAsync(StartFavoriteScan(devices: favorites, https: https));     if (!forceLegacy) {      // Wait a bit before trying the legacy method.      // Skip waiting if [forceLegacy] is true.      await sleepAsync(1000);    }     // If no devices has been found, then switch to legacy discovery mode    // which is purely HTTP/TCP based.    final stillEmpty = ref.read(nearbyDevicesProvider).devices.isEmpty;    final stillInSendTab = ref.read(homePageControllerProvider).currentTab == HomeTab.send;    if (forceLegacy || (stillEmpty && stillInSendTab)) {      final networkInterfaces = ref.read(localIpProvider).localIps.take(maxInterfaces).toList();      if (networkInterfaces.isNotEmpty) {        await dispatchAsync(StartLegacySubnetScan(subnets: networkInterfaces));      }    } else {      if (!stillEmpty) {        emitMessage('Already found devices. This network seem to work, no need to start legacy scan.');      }      if (!stillInSendTab) {        emitMessage('User left the send tab. No need to start legacy scan.');      }    }  }
step 1 of 4
  Stream<Device> getStream({required String networkInterface, required int port, required bool https}) {    final ipList = List.generate(256, (i) => '${networkInterface.split('.').take(3).join('.')}.$i').where((ip) => ip != networkInterface).toList();    _runners[networkInterface]?.stop();    _runners[networkInterface] = TaskRunner<Device?>(      initialTasks: List.generate(        ipList.length,        (index) => () async => _doRequest(ipList[index], port, https),      ),      concurrency: 50,    );     return _runners[networkInterface]!.stream.where((device) => device != null).cast<Device>();  }
common/lib/src/task/discovery/http_scan_discovery.dart · lines 2436
Figure II · Legacy HTTP discovery isn't smart about which addresses to try — it enumerates all 256 addresses of the /24 subnet (minus its own IP) and probes them 50 at a time via a TaskRunner, one HTTP request per candidate host.
class RegisterDeviceAction extends AsyncReduxAction<NearbyDevicesService, NearbyDevicesState> {  final Device device;   RegisterDeviceAction(this.device);   @override  bool get trackOrigin => false;   @override  Future<NearbyDevicesState> reduce() async {    assert(device.ip?.isNotEmpty ?? false, 'IP must not be empty');     final favoriteDevice = notifier._favoriteService.state.firstWhereOrNull((e) => e.fingerprint == device.fingerprint);    if (favoriteDevice != null && !favoriteDevice.customAlias) {      // Update existing favorite with new alias      await external(notifier._favoriteService).dispatchAsync(UpdateFavoriteAction(favoriteDevice.copyWith(alias: device.alias)));    } else {      await Future.microtask(() {});    }    return state.copyWith(      devices: {...state.devices}..update(device.ip!, (_) => device, ifAbsent: () => device),    );  }}
app/lib/provider/network/nearby_devices_provider.dart · lines 7295
Figure III · The final copyWith is the actual write into the cache: devices is a Map<String, Device> keyed by IP, so update(...) silently replaces whatever device previously occupied that address — every scan method (multicast or HTTP) converges on this same overwrite-by-IP semantics.
Figure IV · data modelNearbyDevicesState shapeapp/lib/model/state/nearby_devices_state.dart
{
: Map<String, Device>,
: Map<String, Set<Device>>,
: Set<String>,
: Map<String, Device> (getter)
}
checkpoint

Check your understanding

Answered in place — nothing is graded, everything is explained. 0 / 3 passed

Why can two Device entries with the same fingerprint coexist in signalingDevices but never in devices?

What actually triggers StartSmartScan to run StartLegacySubnetScan's full /24 HTTP sweep?

How does the app avoid launching two concurrent HTTP scans of the same local subnet?