diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart index ec64cca18..e83f5fb41 100644 --- a/flutter/lib/common/widgets/autocomplete.dart +++ b/flutter/lib/common/widgets/autocomplete.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import '../../../models/platform_model.dart'; @@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart'; +@visibleForTesting +List mergeAutocompletePeers({ + Iterable addressBookPeers = const [], + Iterable groupPeers = const [], + Iterable lanPeers = const [], + Iterable recentPeers = const [], + Iterable restRecentPeerIds = const [], +}) { + final combinedPeers = {}; + + void addPeer(Peer peer) { + if (peer.id.isEmpty) { + return; + } + final existingPeer = combinedPeers[peer.id]; + if (existingPeer == null) { + combinedPeers[peer.id] = Peer.copy(peer); + } else if (peer.online) { + existingPeer.online = true; + } + } + + for (final peer in addressBookPeers) { + addPeer(peer); + } + for (final peer in groupPeers) { + addPeer(peer); + } + for (final peer in lanPeers) { + addPeer(peer); + } + for (final peer in recentPeers) { + addPeer(peer); + } + for (final id in restRecentPeerIds) { + if (id.isNotEmpty && !combinedPeers.containsKey(id)) { + combinedPeers[id] = Peer.fromJson({'id': id}); + } + } + + return combinedPeers.values.toList(growable: false); +} + +@visibleForTesting +bool updateAutocompletePeerOnlineStates( + List peers, { + required Set onlines, + required Set offlines, +}) { + var changed = false; + for (final peer in peers) { + if (onlines.contains(peer.id)) { + if (!peer.online) { + peer.online = true; + changed = true; + } + } else if (offlines.contains(peer.id)) { + if (peer.online) { + peer.online = false; + changed = true; + } + } + } + return changed; +} + +@visibleForTesting +List autocompleteOnlineQueryIds( + Iterable options, { + required int limit, +}) { + final ids = []; + final seenIds = {}; + for (final peer in options) { + if (peer.id.isEmpty || seenIds.contains(peer.id)) { + continue; + } + seenIds.add(peer.id); + ids.add(peer.id); + if (ids.length >= limit) { + break; + } + } + return ids; +} + class AllPeersLoader { List peers = []; bool _isPeersLoading = false; bool _isPeersLoaded = false; + Set _lastQueryOnlineIds = {}; + DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0); + Timer? _queryOnlineTimer; + List _lastQueryOnlineOptions = const []; + Set _lastOnlineIds = {}; + Set _lastOfflineIds = {}; + final Future Function(List ids) _queryOnlines; + final Duration _queryOnlineDebounce; + void Function(VoidCallback)? _setState; + bool _isCleared = false; final String _listenerKey = 'AllPeersLoader'; - - late void Function(VoidCallback) setState; + static const String _cbQueryOnlines = 'callback_query_onlines'; + static const Duration _queryOnlineInterval = Duration(seconds: 5); + static const Duration _defaultQueryOnlineDebounce = + Duration(milliseconds: 300); + static const int _maxQueryOnlineOptions = 20; bool get needLoad => !_isPeersLoaded && !_isPeersLoading; bool get isPeersLoaded => _isPeersLoaded; - AllPeersLoader(); + AllPeersLoader({ + @visibleForTesting Future Function(List ids)? queryOnlines, + @visibleForTesting Duration? queryOnlineDebounce, + }) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)), + _queryOnlineDebounce = + queryOnlineDebounce ?? _defaultQueryOnlineDebounce; void init(void Function(VoidCallback) setState) { - this.setState = setState; + _setState = setState; + _isCleared = false; gFFI.recentPeersModel.addListener(_mergeAllPeers); gFFI.lanPeersModel.addListener(_mergeAllPeers); gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); + platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey, + (evt) async { + _updateOnlineState(evt); + }); } void clear() { @@ -33,6 +145,11 @@ class AllPeersLoader { gFFI.lanPeersModel.removeListener(_mergeAllPeers); gFFI.abModel.removePeerUpdateListener(_listenerKey); gFFI.groupModel.removePeerUpdateListener(_listenerKey); + platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey); + _queryOnlineTimer?.cancel(); + _lastQueryOnlineOptions = const []; + _setState = null; + _isCleared = true; } Future getAllPeers() async { @@ -59,50 +176,106 @@ class AllPeersLoader { } void _mergeAllPeers() { - Map combinedPeers = {}; - for (var p in gFFI.abModel.allPeers()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); - } + if (_isCleared) { + return; } - for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); - } - } - - List parsedPeers = []; - for (var peer in combinedPeers.values) { - parsedPeers.add(Peer.fromJson(peer)); - } - - Set peerIds = combinedPeers.keys.toSet(); - for (final peer in gFFI.lanPeersModel.peers) { - if (!peerIds.contains(peer.id)) { - parsedPeers.add(peer); - peerIds.add(peer.id); - } - } - - for (final peer in gFFI.recentPeersModel.peers) { - if (!peerIds.contains(peer.id)) { - parsedPeers.add(peer); - peerIds.add(peer.id); - } - } - for (final id in gFFI.recentPeersModel.restPeerIds) { - if (!peerIds.contains(id)) { - parsedPeers.add(Peer.fromJson({'id': id})); - peerIds.add(id); - } - } - - peers = parsedPeers; - setState(() { + peers = mergeAutocompletePeers( + addressBookPeers: gFFI.abModel.allPeers(), + groupPeers: gFFI.groupModel.peers, + lanPeers: gFFI.lanPeersModel.peers, + recentPeers: gFFI.recentPeersModel.peers, + restRecentPeerIds: gFFI.recentPeersModel.restPeerIds, + ); + _applyLastOnlineState(peers); + _scheduleSetState(() { _isPeersLoading = false; _isPeersLoaded = true; }); } + + void _updateOnlineState(Map evt) { + if (_isCleared) { + return; + } + _lastOnlineIds = _splitPeerIds(evt['onlines']); + _lastOfflineIds = _splitPeerIds(evt['offlines']); + final peersChanged = _applyLastOnlineState(peers); + final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions); + if (peersChanged || optionsChanged) { + _scheduleSetState(() {}); + } + } + + void _scheduleSetState(VoidCallback callback) { + if (_isCleared) { + return; + } + final setState = _setState; + if (setState == null) { + callback(); + } else { + setState(callback); + } + } + + bool _applyLastOnlineState(List peers) { + return updateAutocompletePeerOnlineStates( + peers, + onlines: _lastOnlineIds, + offlines: _lastOfflineIds, + ); + } + + Set _splitPeerIds(dynamic ids) { + if (ids is! String || ids.isEmpty) { + return {}; + } + return ids.split(',').where((id) => id.isNotEmpty).toSet(); + } + + void queryOnlines(Iterable options) { + if (_isCleared) { + return; + } + _lastQueryOnlineOptions = options.toList(growable: false); + final ids = autocompleteOnlineQueryIds( + _lastQueryOnlineOptions, + limit: _maxQueryOnlineOptions, + ).toSet(); + _queryOnlineTimer?.cancel(); + _queryOnlineTimer = null; + if (ids.isEmpty) { + return; + } + final now = DateTime.now(); + if (setEquals(ids, _lastQueryOnlineIds) && + now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) { + return; + } + + _queryOnlineTimer = Timer(_queryOnlineDebounce, () async { + try { + await _queryOnlines(ids.toList(growable: false)); + if (_isCleared) { + return; + } + _lastQueryOnlineIds = ids; + _lastQueryOnlineTime = DateTime.now(); + } catch (e) { + debugPrint('query autocomplete online state failed: $e'); + } + }); + } + + @visibleForTesting + void updateOnlineStateForTesting(Map evt) { + _updateOnlineState(evt); + } + + @visibleForTesting + bool applyLastOnlineStateForTesting(List peers) { + return _applyLastOnlineState(peers); + } } class AutocompletePeerTile extends StatefulWidget { diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index bdf3829e1..7af8c9911 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -398,6 +398,7 @@ class _ConnectionPageState extends State .contains(textToFind) || peer.alias.toLowerCase().contains(textToFind)) .toList(); + _allPeersLoader.queryOnlines(_autocompleteOpts); } return _autocompleteOpts; }, diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index 0e7e0a480..ff5dc828c 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -207,6 +207,7 @@ class _ConnectionPageState extends State { .contains(textToFind) || peer.alias.toLowerCase().contains(textToFind)) .toList(); + _allPeersLoader.queryOnlines(_autocompleteOpts); } return _autocompleteOpts; }, diff --git a/flutter/lib/models/peer_model.dart b/flutter/lib/models/peer_model.dart index 59acdd591..3171b373a 100644 --- a/flutter/lib/models/peer_model.dart +++ b/flutter/lib/models/peer_model.dart @@ -145,23 +145,26 @@ class Peer { note == other.note; } - Peer.copy(Peer other) - : this( - id: other.id, - hash: other.hash, - password: other.password, - username: other.username, - hostname: other.hostname, - platform: other.platform, - alias: other.alias, - tags: other.tags.toList(), - forceAlwaysRelay: other.forceAlwaysRelay, - rdpPort: other.rdpPort, - rdpUsername: other.rdpUsername, - loginName: other.loginName, - device_group_name: other.device_group_name, - note: other.note, - sameServer: other.sameServer); + factory Peer.copy(Peer other) { + final peer = Peer( + id: other.id, + hash: other.hash, + password: other.password, + username: other.username, + hostname: other.hostname, + platform: other.platform, + alias: other.alias, + tags: other.tags.toList(), + forceAlwaysRelay: other.forceAlwaysRelay, + rdpPort: other.rdpPort, + rdpUsername: other.rdpUsername, + loginName: other.loginName, + device_group_name: other.device_group_name, + note: other.note, + sameServer: other.sameServer); + peer.online = other.online; + return peer; + } } enum UpdateEvent { online, load } diff --git a/flutter/test/autocomplete_peer_merge_test.dart b/flutter/test/autocomplete_peer_merge_test.dart new file mode 100644 index 000000000..e3a4b318c --- /dev/null +++ b/flutter/test/autocomplete_peer_merge_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_hbb/common/widgets/autocomplete.dart'; +import 'package:flutter_hbb/models/peer_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Peer _peer({ + required String id, + String alias = '', + String username = '', + String hostname = '', + bool online = false, +}) { + final peer = Peer( + id: id, + username: username, + hostname: hostname, + alias: alias, + platform: '', + tags: [], + hash: '', + password: '', + forceAlwaysRelay: false, + rdpPort: '', + rdpUsername: '', + loginName: '', + device_group_name: '', + note: '', + ); + peer.online = online; + return peer; +} + +void main() { + test('merged autocomplete peers keep address book metadata and online state', + () { + final peers = mergeAutocompletePeers( + addressBookPeers: [ + _peer(id: '123456789', alias: 'Office PC', username: 'ab-user'), + ], + lanPeers: [ + _peer(id: '123456789', username: 'lan-user', online: true), + ], + ); + + expect(peers, hasLength(1)); + expect(peers.single.id, '123456789'); + expect(peers.single.alias, 'Office PC'); + expect(peers.single.username, 'ab-user'); + expect(peers.single.online, isTrue); + }); + + test('peer copies preserve online state', () { + final peer = _peer(id: '987654321', online: true); + + expect(Peer.copy(peer).online, isTrue); + }); + + test('online callbacks update autocomplete-only peers', () { + final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']); + + final changed = updateAutocompletePeerOnlineStates( + peers, + onlines: {'112233445'}, + offlines: {}, + ); + + expect(changed, isTrue); + expect(peers.single.online, isTrue); + }); + + test('online query ids are deduplicated and limited', () { + final peers = List.generate( + 25, + (index) => _peer(id: index.toString()), + )..insert(1, _peer(id: '0')); + + final ids = autocompleteOnlineQueryIds(peers, limit: 20); + + expect(ids, hasLength(20)); + expect(ids.first, '0'); + expect(ids.where((id) => id == '0'), hasLength(1)); + expect(ids.last, '19'); + }); + + test('empty online query ids cancel pending debounce', () async { + final queriedIds = >[]; + final loader = AllPeersLoader( + queryOnlines: (ids) async { + queriedIds.add(ids); + }, + queryOnlineDebounce: Duration(milliseconds: 1), + ); + + loader.queryOnlines([_peer(id: '123456789')]); + loader.queryOnlines([]); + await Future.delayed(Duration(milliseconds: 2)); + + expect(queriedIds, isEmpty); + }); + + test('failed online query enqueue does not suppress retry', () async { + var queryCount = 0; + final loader = AllPeersLoader( + queryOnlines: (ids) { + queryCount += 1; + return Future.error(Exception('queue full')); + }, + queryOnlineDebounce: Duration(milliseconds: 1), + ); + + loader.queryOnlines([_peer(id: '123456789')]); + await Future.delayed(Duration(milliseconds: 2)); + + loader.queryOnlines([_peer(id: '123456789')]); + await Future.delayed(Duration(milliseconds: 2)); + + expect(queryCount, 2); + }); + + test('online callback updates currently displayed options', () async { + final loader = AllPeersLoader( + queryOnlines: (ids) async {}, + queryOnlineDebounce: Duration(milliseconds: 1), + ); + final displayedOptions = [_peer(id: '123456789')]; + + loader.queryOnlines(displayedOptions); + loader.updateOnlineStateForTesting({ + 'onlines': '123456789', + 'offlines': '', + }); + + expect(displayedOptions.single.online, isTrue); + await Future.delayed(Duration(milliseconds: 2)); + }); + + test('cached online callback state is reapplied after peers merge', () { + final loader = AllPeersLoader(); + loader.updateOnlineStateForTesting({ + 'onlines': '123456789', + 'offlines': '', + }); + + final mergedPeers = [_peer(id: '123456789')]; + loader.applyLastOnlineStateForTesting(mergedPeers); + + expect(mergedPeers.single.online, isTrue); + }); +} diff --git a/flutter/test/cm_demo.dart b/flutter/test/cm_demo.dart new file mode 100644 index 000000000..778418308 --- /dev/null +++ b/flutter/test/cm_demo.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/pages/server_page.dart'; +import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; +import 'package:flutter_hbb/main.dart'; +import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:get/get.dart'; +import 'package:window_manager/window_manager.dart'; + +final testClients = [ + Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false), + Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false), + Client(2, false, false, false, "UserC", "331123123", true, false, false), + Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, + false) +]; + +/// flutter run -d {platform} -t test/cm_demo.dart to test cm +void main() async { + isTest = true; + WidgetsFlutterBinding.ensureInitialized(); + await windowManager.ensureInitialized(); + await windowManager.setSize(const Size(400, 600)); + await windowManager.setAlignment(Alignment.topRight); + await initEnv(kAppTypeMain); + for (var client in testClients) { + gFFI.serverModel.clients.add(client); + gFFI.serverModel.tabController.add(TabInfo( + key: client.id.toString(), + label: client.name, + closable: false, + page: buildConnectionCard(client))); + } + + runApp(GetMaterialApp( + debugShowCheckedModeBanner: false, + theme: MyTheme.lightTheme, + darkTheme: MyTheme.darkTheme, + themeMode: MyTheme.currentThemeMode(), + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: supportedLocales, + home: const DesktopServerPage())); + WindowOptions windowOptions = getHiddenTitleBarWindowOptions( + size: kConnectionManagerWindowSizeClosedChat); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + // ensure initial window size to be changed + await windowManager.setSize(kConnectionManagerWindowSizeClosedChat); + await Future.wait([ + windowManager.setAlignment(Alignment.topRight), + windowManager.focus(), + windowManager.setOpacity(1) + ]); + // ensure + windowManager.setAlignment(Alignment.topRight); + }); +} diff --git a/flutter/test/cm_test.dart b/flutter/test/cm_test.dart index 342764b4a..4766e3c24 100644 --- a/flutter/test/cm_test.dart +++ b/flutter/test/cm_test.dart @@ -1,62 +1,20 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_hbb/desktop/pages/server_page.dart'; -import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; -import 'package:flutter_hbb/main.dart'; -import 'package:flutter_hbb/models/server_model.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:get/get.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; -final testClients = [ - Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false), - Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false), - Client(2, false, false, false, "UserC", "331123123", true, false, false, false), - Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false) -]; +import 'cm_demo.dart' as cm_demo; -/// flutter run -d {platform} -t test/cm_test.dart to test cm -void main(List args) async { - isTest = true; - WidgetsFlutterBinding.ensureInitialized(); - await windowManager.ensureInitialized(); - await windowManager.setSize(const Size(400, 600)); - await windowManager.setAlignment(Alignment.topRight); - await initEnv(kAppTypeMain); - for (var client in testClients) { - gFFI.serverModel.clients.add(client); - gFFI.serverModel.tabController.add(TabInfo( - key: client.id.toString(), - label: client.name, - closable: false, - page: buildConnectionCard(client))); - } - - runApp(GetMaterialApp( - debugShowCheckedModeBanner: false, - theme: MyTheme.lightTheme, - darkTheme: MyTheme.darkTheme, - themeMode: MyTheme.currentThemeMode(), - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: supportedLocales, - home: const DesktopServerPage())); - WindowOptions windowOptions = getHiddenTitleBarWindowOptions( - size: kConnectionManagerWindowSizeClosedChat); - windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - // ensure initial window size to be changed - await windowManager.setSize(kConnectionManagerWindowSizeClosedChat); - await Future.wait([ - windowManager.setAlignment(Alignment.topRight), - windowManager.focus(), - windowManager.setOpacity(1) +void main() { + test('connection manager demo clients match the current Client API', () { + expect(cm_demo.testClients, hasLength(4)); + expect(cm_demo.testClients.map((client) => client.name), [ + 'UserAAAAAA', + 'UserBBBBB', + 'UserC', + 'UserDDDDDDDDDDDd', ]); - // ensure - windowManager.setAlignment(Alignment.topRight); + expect( + cm_demo.testClients.every( + (client) => client.keyboard && !client.clipboard && !client.audio), + isTrue, + ); }); } diff --git a/flutter/test/server_settings_dialog_test.dart b/flutter/test/server_settings_dialog_test.dart new file mode 100644 index 000000000..689ae3625 --- /dev/null +++ b/flutter/test/server_settings_dialog_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/mobile/widgets/dialog.dart'; + +void main() { + testWidgets('server settings text fields preserve literal input', + (tester) async { + final controller = TextEditingController(text: 'AbCdR1c1E='); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: serverSettingsTextFormField( + label: 'Key', + controller: controller, + errorMsg: '', + autofocus: true, + ), + ), + )); + + final textField = tester.widget(find.byType(TextField)); + + expect(textField.controller, controller); + expect(textField.autofocus, isTrue); + expect(textField.keyboardType, TextInputType.visiblePassword); + expect(textField.textCapitalization, TextCapitalization.none); + expect(textField.autocorrect, isFalse); + expect(textField.enableSuggestions, isFalse); + expect(textField.smartDashesType, SmartDashesType.disabled); + expect(textField.smartQuotesType, SmartQuotesType.disabled); + expect(textField.enableIMEPersonalizedLearning, isFalse); + expect( + textField.spellCheckConfiguration, + const SpellCheckConfiguration.disabled(), + ); + }); +}