From ce9ae753e28d27001dd45e755f425d94f73043ea Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 17 Jun 2026 04:12:04 +0800 Subject: [PATCH] autocomplete online --- flutter/lib/common/widgets/autocomplete.dart | 190 ++++++++++++++---- flutter/lib/common/widgets/dialog.dart | 14 +- .../lib/desktop/pages/connection_page.dart | 1 + flutter/lib/mobile/pages/connection_page.dart | 1 + flutter/lib/models/peer_model.dart | 37 ++-- .../test/autocomplete_peer_merge_test.dart | 83 ++++++++ flutter/test/cm_demo.dart | 63 ++++++ flutter/test/cm_test.dart | 72 ++----- flutter/test/dialog_text_field_test.dart | 35 ++++ flutter/test/server_settings_dialog_test.dart | 38 ++++ 10 files changed, 420 insertions(+), 114 deletions(-) create mode 100644 flutter/test/autocomplete_peer_merge_test.dart create mode 100644 flutter/test/cm_demo.dart create mode 100644 flutter/test/dialog_text_field_test.dart create mode 100644 flutter/test/server_settings_dialog_test.dart diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart index ec64cca18..40c734cea 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,13 +8,106 @@ 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; final String _listenerKey = 'AllPeersLoader'; + static const String _cbQueryOnlines = 'callback_query_onlines'; + static const Duration _queryOnlineInterval = Duration(seconds: 5); + static const Duration _queryOnlineDebounce = Duration(milliseconds: 300); + static const int _maxQueryOnlineOptions = 20; late void Function(VoidCallback) setState; @@ -26,6 +122,10 @@ class AllPeersLoader { 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 +133,8 @@ class AllPeersLoader { gFFI.lanPeersModel.removeListener(_mergeAllPeers); gFFI.abModel.removePeerUpdateListener(_listenerKey); gFFI.groupModel.removePeerUpdateListener(_listenerKey); + platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey); + _queryOnlineTimer?.cancel(); } Future getAllPeers() async { @@ -59,50 +161,60 @@ class AllPeersLoader { } void _mergeAllPeers() { - Map combinedPeers = {}; - for (var p in gFFI.abModel.allPeers()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); - } - } - 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; + peers = mergeAutocompletePeers( + addressBookPeers: gFFI.abModel.allPeers(), + groupPeers: gFFI.groupModel.peers, + lanPeers: gFFI.lanPeersModel.peers, + recentPeers: gFFI.recentPeersModel.peers, + restRecentPeerIds: gFFI.recentPeersModel.restPeerIds, + ); setState(() { _isPeersLoading = false; _isPeersLoaded = true; }); } + + void _updateOnlineState(Map evt) { + final changed = updateAutocompletePeerOnlineStates( + peers, + onlines: _splitPeerIds(evt['onlines']), + offlines: _splitPeerIds(evt['offlines']), + ); + if (changed) { + setState(() {}); + } + } + + Set _splitPeerIds(dynamic ids) { + if (ids is! String || ids.isEmpty) { + return {}; + } + return ids.split(',').where((id) => id.isNotEmpty).toSet(); + } + + void queryOnlines(Iterable options) { + final ids = autocompleteOnlineQueryIds( + options, + limit: _maxQueryOnlineOptions, + ).toSet(); + if (ids.isEmpty) { + return; + } + final now = DateTime.now(); + if (setEquals(ids, _lastQueryOnlineIds) && + now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) { + return; + } + + _queryOnlineTimer?.cancel(); + _queryOnlineTimer = Timer(_queryOnlineDebounce, () { + _lastQueryOnlineIds = ids; + _lastQueryOnlineTime = DateTime.now(); + bind.queryOnlines(ids: ids.toList(growable: false)).catchError((e) { + debugPrint('query autocomplete online state failed: $e'); + }); + }); + } } class AutocompletePeerTile extends StatefulWidget { diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index 7534fb2a1..631b61078 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -393,6 +393,7 @@ class DialogTextField extends StatelessWidget { final TextInputType? keyboardType; final List? inputFormatters; final int? maxLength; + final bool literalInput; static const kUsernameTitle = 'Username'; static const kUsernameIcon = Icon(Icons.account_circle_outlined); @@ -411,6 +412,7 @@ class DialogTextField extends StatelessWidget { this.keyboardType, this.inputFormatters, this.maxLength, + this.literalInput = false, required this.title, required this.controller}) : super(key: key); @@ -435,7 +437,17 @@ class DialogTextField extends StatelessWidget { focusNode: focusNode, autofocus: true, obscureText: obscureText, - keyboardType: keyboardType, + keyboardType: keyboardType ?? + (literalInput ? TextInputType.visiblePassword : null), + textCapitalization: TextCapitalization.none, + autocorrect: !literalInput, + enableSuggestions: !literalInput, + smartDashesType: literalInput ? SmartDashesType.disabled : null, + smartQuotesType: literalInput ? SmartQuotesType.disabled : null, + enableIMEPersonalizedLearning: !literalInput, + spellCheckConfiguration: literalInput + ? const SpellCheckConfiguration.disabled() + : null, inputFormatters: inputFormatters, maxLength: maxLength, ), 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..8dc87676c --- /dev/null +++ b/flutter/test/autocomplete_peer_merge_test.dart @@ -0,0 +1,83 @@ +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'); + }); +} 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/dialog_text_field_test.dart b/flutter/test/dialog_text_field_test.dart new file mode 100644 index 000000000..40ebd770f --- /dev/null +++ b/flutter/test/dialog_text_field_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; + +void main() { + testWidgets('DialogTextField can preserve literal input', (tester) async { + final controller = TextEditingController(text: 'P@ss1c1E='); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DialogTextField( + title: 'Password', + controller: controller, + literalInput: true, + ), + ), + )); + + final textField = tester.widget(find.byType(TextField)); + + expect(textField.controller, controller); + 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(), + ); + }); +} 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(), + ); + }); +}