feat: support multiple local IP addresses

This commit is contained in:
Tien Do Nam
2023-01-06 03:20:26 +01:00
parent 09762984d1
commit 338f9a725a
16 changed files with 275 additions and 44 deletions
+4
View File
@@ -1,3 +1,7 @@
## 1.4.0 (2023-01-)
- feat: support multiple local IP addresses
## 1.3.1 (2023-01-03)
- fix: local IP sometimes not found
+1 -1
View File
@@ -136,7 +136,7 @@
"addressInput": {
"title": "Enter address",
"hashtag": "Hashtag",
"ip": "IP"
"ip": "IP Address"
}
}
}
+1 -1
View File
@@ -136,7 +136,7 @@
"addressInput": {
"title": "Adresse eingeben",
"hashtag": "Hashtag",
"ip": "IP"
"ip": "IP-Adresse"
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ part 'nearby_devices_state.freezed.dart';
@freezed
class NearbyDevicesState with _$NearbyDevicesState {
const factory NearbyDevicesState({
required bool running,
required Set<String> runningIps, // list of local ips
required Map<String, Device> devices, // ip -> device
}) = _NearbyDevicesState;
}
+2 -2
View File
@@ -5,7 +5,7 @@ part 'network_info.freezed.dart';
@freezed
class NetworkInfo with _$NetworkInfo {
const factory NetworkInfo({
required String? localIp,
required String? netMask,
required List<String> localIps,
required String? netMask, // not used
}) = _NetworkInfo;
}
+9 -2
View File
@@ -62,7 +62,7 @@ class _ReceiveTagState extends ConsumerState<ReceiveTab> with AutomaticKeepAlive
duration: const Duration(milliseconds: 300),
delay: const Duration(milliseconds: 500),
child: Text(
serverState == null ? t.general.offline : '#${networkInfo?.localIp?.visualId ?? '?'}',
serverState == null ? t.general.offline : networkInfo?.localIps.map((ip) => '#${ip.visualId}').toSet().join(' ') ?? '?',
style: const TextStyle(fontSize: 24),
),
),
@@ -105,7 +105,14 @@ class _ReceiveTagState extends ConsumerState<ReceiveTab> with AutomaticKeepAlive
children: [
Text(t.receiveTab.infoBox.ip),
const SizedBox(width: 10),
Text(networkInfo?.localIp ?? t.general.unknown),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (networkInfo?.localIps.isEmpty ?? true)
Text(t.general.unknown),
...?networkInfo?.localIps.map((ip) => Text(ip)),
],
),
],
),
TableRow(
+61 -18
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/gen/strings.g.dart';
@@ -37,15 +38,15 @@ class _SendTabState extends ConsumerState<SendTab> {
WidgetsBinding.instance.addPostFrameCallback((_) {
final devices = ref.read(nearbyDevicesProvider.select((state) => state.devices));
if (devices.isEmpty) {
_scan();
_scan(null);
}
});
}
void _scan() {
void _scan(String? localIp) {
final port = ref.read(settingsProvider.select((settings) => settings.port));
final networkInfo = ref.read(networkInfoProvider);
final localIp = networkInfo?.localIp;
localIp ??= networkInfo?.localIps.firstOrNull;
if (localIp != null) {
ref.read(nearbyDevicesProvider.notifier).startScan(port: port, localIp: localIp);
}
@@ -54,6 +55,7 @@ class _SendTabState extends ConsumerState<SendTab> {
@override
Widget build(BuildContext context) {
final selectedFiles = ref.watch(selectedFilesProvider);
final networkInfo = ref.watch(networkInfoProvider);
final myDevice = ref.watch(deviceInfoProvider);
final nearbyDevicesState = ref.watch(nearbyDevicesProvider);
final addOptions = [
@@ -184,24 +186,25 @@ class _SendTabState extends ConsumerState<SendTab> {
children: [
Text(t.sendTab.nearbyDevices, style: Theme.of(context).textTheme.subtitle1),
const SizedBox(width: 10),
Tooltip(
message: t.sendTab.scan,
child: RotatingWidget(
duration: const Duration(seconds: 2),
spinning: nearbyDevicesState.running,
reverse: true,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size.zero,
shape: const CircleBorder(),
if (networkInfo?.localIps.length == 1)
Tooltip(
message: t.sendTab.scan,
child: RotatingWidget(
duration: const Duration(seconds: 2),
spinning: nearbyDevicesState.runningIps.isNotEmpty,
reverse: true,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size.zero,
shape: const CircleBorder(),
),
onPressed: () => _scan(null),
child: const Icon(Icons.sync),
),
onPressed: _scan,
child: const Icon(Icons.sync),
),
),
),
Tooltip(
message: t.dialogs.addressInput.title,
child: TextButton(
@@ -233,6 +236,46 @@ class _SendTabState extends ConsumerState<SendTab> {
),
],
),
if ((networkInfo?.localIps.length ?? 0) > 1)
Padding(
padding: const EdgeInsets.only(bottom: 15),
child: Wrap(
spacing: 15,
runSpacing: 15,
children: networkInfo!.localIps.map((ip) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: t.sendTab.scan,
child: RotatingWidget(
duration: const Duration(seconds: 2),
spinning: nearbyDevicesState.runningIps.contains(ip),
reverse: true,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size.zero,
shape: const CircleBorder(),
),
onPressed: () => _scan(ip),
child: const Icon(Icons.sync),
),
),
),
Text(ip),
],
),
),
);
}).toList(),
),
),
Hero(
tag: 'this-device',
child: DeviceListTile(
+2 -1
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/model/device.dart';
import 'package:localsend_app/provider/network/server_provider.dart';
@@ -13,7 +14,7 @@ final deviceInfoProvider = Provider((ref) {
final serverState = ref.watch(serverProvider);
final rawInfo = ref.watch(deviceRawInfoProvider);
return Device(
ip: networkInfo?.localIp ?? '-',
ip: networkInfo?.localIps.firstOrNull ?? '-',
port: serverState?.port ?? -1,
alias: serverState?.alias ?? '-',
deviceModel: rawInfo.deviceModel,
@@ -12,16 +12,19 @@ final nearbyDevicesProvider = StateNotifierProvider<NearbyDevicesNotifier, Nearb
return NearbyDevicesNotifier(dio);
});
Map<String, TaskRunner> _runners = {};
class NearbyDevicesNotifier extends StateNotifier<NearbyDevicesState> {
final Dio dio;
NearbyDevicesNotifier(this.dio) : super(const NearbyDevicesState(running: false, devices: {}));
NearbyDevicesNotifier(this.dio) : super(const NearbyDevicesState(runningIps: {}, devices: {}));
Future<void> startScan({required int port, required String localIp}) async {
if (state.running) {
if (state.runningIps.contains(localIp)) {
// already running for the same localIp
return;
}
state = state.copyWith(running: true);
state = state.copyWith(runningIps: {...state.runningIps, localIp});
await _getStream(localIp, port).forEach((device) {
state = state.copyWith(
@@ -29,12 +32,13 @@ class NearbyDevicesNotifier extends StateNotifier<NearbyDevicesState> {
);
});
state = state.copyWith(running: false);
state = state.copyWith(runningIps: state.runningIps.where((ip) => ip != localIp).toSet());
}
Stream<Device> _getStream(String localIp, int port) {
final ipList = List.generate(256, (i) => '${localIp.split('.').take(3).join('.')}.$i').where((ip) => ip != localIp).toList();
final runner = TaskRunner<Device?>(
_runners[localIp]?.stop();
_runners[localIp] = TaskRunner<Device?>(
initialTasks: List.generate(
ipList.length,
(index) => () => _doRequest(dio, ipList[index], port),
@@ -42,7 +46,7 @@ class NearbyDevicesNotifier extends StateNotifier<NearbyDevicesState> {
concurrency: 50,
);
return runner.stream.where((device) => device != null).cast<Device>();
return _runners[localIp]!.stream.where((device) => device != null).cast<Device>();
}
}
+34 -3
View File
@@ -38,11 +38,12 @@ class NetworkInfoNotifier extends StateNotifier<NetworkInfo?> {
print(e);
}
if (!kIsWeb && ip == null) {
List<String> nativeResult = [];
if (!kIsWeb) {
try {
// fallback with dart:io NetworkInterface
final result = (await NetworkInterface.list()).map((networkInterface) => networkInterface.addresses).expand((ip) => ip);
ip = result.firstWhereOrNull((ip) => ip.type == InternetAddressType.IPv4)?.address;
nativeResult = result.where((ip) => ip.type == InternetAddressType.IPv4).map((address) => address.address).toList();
} catch (e, st) {
print(e);
print(st);
@@ -52,8 +53,38 @@ class NetworkInfoNotifier extends StateNotifier<NetworkInfo?> {
print('New network state: $ip ($mask)');
return NetworkInfo(
localIp: ip,
localIps: rankIpAddresses(nativeResult, ip),
netMask: mask,
);
}
}
List<String> rankIpAddresses(List<String> nativeResult, String? thirdPartyResult) {
if (thirdPartyResult == null) {
// only take the list
return nativeResult._rankIpAddresses(null);
} else if (nativeResult.isEmpty) {
// only take the first IP from third party library
return [thirdPartyResult];
} else if (thirdPartyResult.endsWith('.1')) {
// merge
return {thirdPartyResult, ...nativeResult}.toList()._rankIpAddresses(null);
} else {
// merge but prefer result from third party library
return {thirdPartyResult, ...nativeResult}.toList()._rankIpAddresses(thirdPartyResult);
}
}
/// Sorts Ip addresses with first being the most likely primary local address
/// Currently,
/// - sorts ending with ".1" last
/// - primary is always first
extension ListIpExt on List<String> {
List<String> _rankIpAddresses(String? primary) {
return sorted((a, b) {
int scoreA = a == primary ? 10 : (a.endsWith('.1') ? 0 : 1);
int scoreB = b == primary ? 10 : (b.endsWith('.1') ? 0 : 1);
return scoreB.compareTo(scoreA);
});
}
}
+16
View File
@@ -1,3 +1,19 @@
import 'package:collection/collection.dart';
extension StringIpExt on String {
String get visualId => split('.').last;
}
class IpHelper {
/// Sorts Ip addresses with first being the most likely primary local address
/// Currently,
/// - sorts ending with ".1" last
/// - primary is always first
static List<String> rankIpAddresses(List<String> addresses, String primary) {
return addresses.sorted((a, b) {
int scoreA = a == primary ? 10 : (a.endsWith('.1') ? 0 : 1);
int scoreB = b == primary ? 10 : (b.endsWith('.1') ? 0 : 1);
return scoreB.compareTo(scoreA);
});
}
}
+6 -1
View File
@@ -9,6 +9,7 @@ class TaskRunner<T> {
final int concurrency;
int _runnerCount = 0;
bool _stopped = false;
/// If [true], then the stream will be closed as soon as every task has been finished.
/// By default, it is [false] when [initialTasks] is provided with a non-empty list.
@@ -31,6 +32,10 @@ class TaskRunner<T> {
_fireRunners();
}
void stop() {
_stopped = true;
}
Stream<T> get stream => _streamController.stream;
/// Starts multiple runners until [concurrency].
@@ -41,7 +46,7 @@ class TaskRunner<T> {
_runner(
onFinish: () {
_runnerCount--;
if (_runnerCount == 0 && !_stayAlive) {
if (_stopped || (_runnerCount == 0 && !_stayAlive)) {
_streamController.close();
onFinish?.call();
}
+21 -8
View File
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:localsend_app/gen/strings.g.dart';
@@ -28,15 +29,15 @@ class AddressInputDialog extends ConsumerStatefulWidget {
}
class _AddressInputDialogState extends ConsumerState<AddressInputDialog> {
final _addressController = TextEditingController();
final _selected = List.generate(_InputMode.values.length, (index) => index == 0);
_InputMode _mode = _InputMode.hashtag;
String _input = '';
bool _fetching = false;
bool _failed = false;
Future<void> _submit(String? ipPrefix, int port) async {
final String ip;
final String input = _addressController.text.trim();
final String input = _input.trim();
if (_mode == _InputMode.ip) {
ip = input;
} else {
@@ -62,8 +63,8 @@ class _AddressInputDialogState extends ConsumerState<AddressInputDialog> {
@override
Widget build(BuildContext context) {
final localIp = ref.watch(networkInfoProvider.select((info) => info?.localIp));
final ipPrefix = localIp?.split('.').take(3).join('.');
final localIps = ref.watch(networkInfoProvider.select((info) => info?.localIps));
final ipPrefix = localIps?.firstOrNull?.split('.').take(3).join('.');
final settings = ref.watch(settingsProvider);
return AlertDialog(
@@ -83,25 +84,37 @@ class _AddressInputDialogState extends ConsumerState<AddressInputDialog> {
});
},
borderRadius: BorderRadius.circular(10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
constraints: const BoxConstraints(minWidth: 0, minHeight: 0),
children: _InputMode.values.map((mode) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Text(mode.label),
);
}).toList(),
),
const SizedBox(height: 20),
const SizedBox(height: 15),
TextFormField(
controller: _addressController,
autofocus: true,
enabled: !_fetching,
decoration: InputDecoration(
prefixText: _mode == _InputMode.hashtag ? '# ' : 'IP: ',
),
onChanged: (s) {
setState(() => _input = s);
},
onFieldSubmitted: (s) => _submit(ipPrefix, settings.port),
),
const SizedBox(height: 10),
Text('${t.general.example}: ${_mode == _InputMode.hashtag ? '123' : '${ipPrefix ?? '192.168.2'}.123'}'),
Text(
'${t.general.example}: ${_mode == _InputMode.hashtag ? '123' : '${ipPrefix ?? '192.168.2'}.123'}',
style: const TextStyle(color: Colors.grey),
),
if (_mode == _InputMode.hashtag)
Text(
'${t.dialogs.addressInput.ip}: ${ipPrefix ?? '192.168.2'}.$_input',
style: const TextStyle(color: Colors.grey),
),
if (_failed)
Padding(
padding: const EdgeInsets.only(top: 10),
+84
View File
@@ -43,6 +43,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.2"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
build:
dependency: transitive
description:
@@ -176,6 +183,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
coverage:
dependency: transitive
description:
name: coverage
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.1"
cross_file:
dependency: transitive
description:
@@ -611,6 +625,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
open_filex:
dependency: "direct main"
description:
@@ -919,6 +940,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shelf_router:
dependency: "direct main"
description:
@@ -926,6 +954,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.3"
shelf_static:
dependency: transitive
description:
name: shelf_static
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
shelf_web_socket:
dependency: transitive
description:
@@ -973,6 +1008,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.3"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
source_maps:
dependency: transitive
description:
name: source_maps
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.11"
source_span:
dependency: transitive
description:
@@ -1022,6 +1071,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
test:
dependency: "direct dev"
description:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.22.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.17"
test_core:
dependency: transitive
description:
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.21"
time:
dependency: transitive
description:
@@ -1148,6 +1218,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.13"
vm_service:
dependency: transitive
description:
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "9.4.0"
watcher:
dependency: transitive
description:
@@ -1162,6 +1239,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
wechat_assets_picker:
dependency: "direct main"
description:
+1
View File
@@ -52,6 +52,7 @@ dev_dependencies:
json_serializable: 6.5.4
msix: 3.7.0
slang_build_runner: 3.7.0
test: 1.22.1
flutter:
uses-material-design: true
@@ -0,0 +1,22 @@
import 'package:localsend_app/provider/network_info_provider.dart';
import 'package:test/test.dart';
void main() {
group('rankIpAddresses', () {
test('should only sort list if no primary', () {
expect(rankIpAddresses(['123.456', '222.1', '321.222'], null), ['123.456', '321.222', '222.1']);
});
test('should only take primary', () {
expect(rankIpAddresses([], '123.123'), ['123.123']);
});
test('should sort primary first', () {
expect(rankIpAddresses(['123.456', '222.1', '321.222'], '123.123'), ['123.123','123.456', '321.222', '222.1']);
});
test('should sort primary first and remove duplicates', () {
expect(rankIpAddresses(['123.456', '123.123', '222.1', '222.1', '321.222'], '123.123'), ['123.123','123.456', '321.222', '222.1']);
});
});
}