From cf6228e68955cd29b08815dc8d0931fbe4efc5f3 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Mon, 16 Mar 2026 14:53:12 +0100 Subject: [PATCH 1/2] Add IRC auth, command, and stability improvements Expand the chat and IRC flow with command handling, SASL support, alt nick fallback, and session persistence fixes. Also prevent the CodeQL workflow from failing on private repositories without GHAS. --- .github/workflows/codeql.yml | 12 + lib/core/models/chat_tab.dart | 18 + lib/core/models/network_config.dart | 18 + .../storage/in_memory_network_repository.dart | 1 + .../shared_prefs_network_repository.dart | 1 + .../application/chat_session_controller.dart | 390 +++++++++++++++++- .../chat/application/command_service.dart | 103 +++++ .../chat/data/chat_session_persistence.dart | 4 +- .../chat/presentation/chat_screen.dart | 173 ++++++-- .../presentation/join_channel_dialog.dart | 51 +++ .../application/network_list_controller.dart | 6 + .../presentation/network_form_screen.dart | 45 ++ .../presentation/network_list_screen.dart | 5 +- lib/irc/services/irc_service.dart | 276 ++++++++++++- test/command_service_test.dart | 31 ++ test/irc_service_sasl_test.dart | 102 +++++ test/storage_repositories_test.dart | 43 ++ 17 files changed, 1244 insertions(+), 35 deletions(-) create mode 100644 lib/features/chat/application/command_service.dart create mode 100644 test/command_service_test.dart create mode 100644 test/irc_service_sasl_test.dart diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 223cf96..07d47e5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,9 +9,21 @@ on: - cron: '0 3 * * 1' jobs: + codeql-disabled: + name: CodeQL Disabled + runs-on: ubuntu-latest + if: ${{ github.event.repository.private && vars.ENABLE_PRIVATE_CODEQL != 'true' }} + + steps: + - name: Explain why CodeQL is skipped + run: | + echo "Skipping CodeQL because this repository is private and GitHub Advanced Security is not enabled." + echo "Enable GitHub Advanced Security, then set the ENABLE_PRIVATE_CODEQL repository variable to true." + analyze: name: Analyze runs-on: ubuntu-latest + if: ${{ github.event.repository.private == false || vars.ENABLE_PRIVATE_CODEQL == 'true' }} permissions: actions: read diff --git a/lib/core/models/chat_tab.dart b/lib/core/models/chat_tab.dart index 4cf316f..1e02e2c 100644 --- a/lib/core/models/chat_tab.dart +++ b/lib/core/models/chat_tab.dart @@ -17,6 +17,24 @@ class ChatTab { final bool hasActivity; final bool isEncrypted; + ChatTab copyWith({ + String? id, + String? name, + ChatTabType? type, + String? networkId, + bool? hasActivity, + bool? isEncrypted, + }) { + return ChatTab( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + networkId: networkId ?? this.networkId, + hasActivity: hasActivity ?? this.hasActivity, + isEncrypted: isEncrypted ?? this.isEncrypted, + ); + } + Map toJson() { return { 'id': id, diff --git a/lib/core/models/network_config.dart b/lib/core/models/network_config.dart index 626b5c5..dd8c256 100644 --- a/lib/core/models/network_config.dart +++ b/lib/core/models/network_config.dart @@ -5,10 +5,13 @@ class NetworkConfig { required this.host, required this.port, required this.nickname, + this.altNickname, this.username = 'androidircx', this.realName = 'AndroidIRCX', this.useTls = true, this.password, + this.saslAccount, + this.saslPassword, this.autoConnect = false, }); @@ -17,10 +20,13 @@ class NetworkConfig { final String host; final int port; final String nickname; + final String? altNickname; final String username; final String realName; final bool useTls; final String? password; + final String? saslAccount; + final String? saslPassword; final bool autoConnect; NetworkConfig copyWith({ @@ -29,10 +35,13 @@ class NetworkConfig { String? host, int? port, String? nickname, + String? altNickname, String? username, String? realName, bool? useTls, String? password, + String? saslAccount, + String? saslPassword, bool? autoConnect, }) { return NetworkConfig( @@ -41,10 +50,13 @@ class NetworkConfig { host: host ?? this.host, port: port ?? this.port, nickname: nickname ?? this.nickname, + altNickname: altNickname ?? this.altNickname, username: username ?? this.username, realName: realName ?? this.realName, useTls: useTls ?? this.useTls, password: password ?? this.password, + saslAccount: saslAccount ?? this.saslAccount, + saslPassword: saslPassword ?? this.saslPassword, autoConnect: autoConnect ?? this.autoConnect, ); } @@ -56,10 +68,13 @@ class NetworkConfig { 'host': host, 'port': port, 'nickname': nickname, + 'altNickname': altNickname, 'username': username, 'realName': realName, 'useTls': useTls, 'password': password, + 'saslAccount': saslAccount, + 'saslPassword': saslPassword, 'autoConnect': autoConnect, }; } @@ -71,10 +86,13 @@ class NetworkConfig { host: json['host']! as String, port: (json['port']! as num).toInt(), nickname: json['nickname']! as String, + altNickname: json['altNickname'] as String?, username: (json['username'] as String?) ?? 'androidircx', realName: (json['realName'] as String?) ?? 'AndroidIRCX', useTls: (json['useTls'] as bool?) ?? true, password: json['password'] as String?, + saslAccount: json['saslAccount'] as String?, + saslPassword: json['saslPassword'] as String?, autoConnect: (json['autoConnect'] as bool?) ?? false, ); } diff --git a/lib/core/storage/in_memory_network_repository.dart b/lib/core/storage/in_memory_network_repository.dart index 9899181..2f7ce74 100644 --- a/lib/core/storage/in_memory_network_repository.dart +++ b/lib/core/storage/in_memory_network_repository.dart @@ -15,6 +15,7 @@ class InMemoryNetworkRepository implements NetworkRepository { host: 'irc.dbase.in.rs', port: 6697, nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', useTls: true, ), ]; diff --git a/lib/core/storage/shared_prefs_network_repository.dart b/lib/core/storage/shared_prefs_network_repository.dart index c7b9f7d..97ecbf5 100644 --- a/lib/core/storage/shared_prefs_network_repository.dart +++ b/lib/core/storage/shared_prefs_network_repository.dart @@ -55,6 +55,7 @@ class SharedPrefsNetworkRepository implements NetworkRepository { host: 'irc.dbase.in.rs', port: 6697, nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', useTls: true, ), ]; diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 1c279ee..c330f7e 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -5,6 +5,7 @@ import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; @@ -19,10 +20,12 @@ class ChatSessionController extends ChangeNotifier { IrcService? ircService, ChatSessionPersistence? persistence, SettingsRepository? settingsRepository, + CommandService? commandService, }) : _ircService = ircService ?? IrcService(), _persistence = persistence ?? ChatSessionPersistence(), _settingsRepository = - settingsRepository ?? SharedPrefsSettingsRepository() { + settingsRepository ?? SharedPrefsSettingsRepository(), + _commandService = commandService ?? CommandService() { final serverTab = ChatTab( id: _serverTabId(network.id), name: network.name, @@ -38,7 +41,11 @@ class ChatSessionController extends ChangeNotifier { final IrcService _ircService; final ChatSessionPersistence _persistence; final SettingsRepository _settingsRepository; + final CommandService _commandService; final Map> _messages = {}; + final Map> _channelUsers = {}; + final Map _channelTopics = {}; + final Map _channelModes = {}; final List> _subscriptions = []; Timer? _reconnectTimer; @@ -58,9 +65,34 @@ class ChatSessionController extends ChangeNotifier { String get activeTabId => _activeTabId; ConnectionSnapshot get connection => _connection; AppSettings get settings => _settings; + List get commandHistory => _commandService.history; bool get isReconnectScheduled => _reconnectTimer?.isActive ?? false; Duration? get pendingReconnectDelay => _pendingReconnectDelay; ChatTab get activeTab => _tabs.firstWhere((tab) => tab.id == _activeTabId); + String? get activeChannelTopic => _channelTopics[activeTabId]; + String? get activeChannelModes => _channelModes[activeTabId]; + String get activeChannelSummary { + if (activeTab.type != ChatTabType.channel) { + return ''; + } + + final users = activeChannelUsers.length; + final modes = (activeChannelModes ?? '').trim(); + if (modes.isEmpty) { + return '$users users'; + } + + return '$users users • $modes'; + } + List get activeChannelUsers { + final users = _channelUsers[activeTab.id]; + if (users == null) { + return const []; + } + + final sorted = users.toList()..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return List.unmodifiable(sorted); + } List get activeMessages { final source = _messages[_activeTabId] ?? const []; if (_settings.showRawEvents) { @@ -74,6 +106,7 @@ class ChatSessionController extends ChangeNotifier { Future start() async { if (!_isBootstrapped) { + await _commandService.load(); await _loadPersistedState(); _isBootstrapped = true; notifyListeners(); @@ -105,18 +138,41 @@ class ChatSessionController extends ChangeNotifier { void selectTab(String tabId) { _activeTabId = tabId; + _setTabActivity(tabId, false); + unawaited(_persistState()); + notifyListeners(); + } + + void closeTab(String tabId) { + final tab = _findTab(tabId); + if (tab == null || tab.type == ChatTabType.server) { + return; + } + + _tabs = _tabs.where((item) => item.id != tabId).toList(growable: false); + _messages.remove(tabId); + _channelUsers.remove(tabId); + _channelTopics.remove(tabId); + + if (_activeTabId == tabId) { + _activeTabId = _serverTabId(network.id); + _setTabActivity(_activeTabId, false); + } + unawaited(_persistState()); notifyListeners(); } Future handleComposerSubmit(String input) async { - final text = input.trim(); + final text = _commandService.normalizeCommand(input.trim()); if (text.isEmpty) { return; } if (text.startsWith('/')) { + await _commandService.addToHistory(text); await _handleSlashCommand(text.substring(1)); + notifyListeners(); return; } @@ -220,7 +276,15 @@ class ChatSessionController extends ChangeNotifier { } case 'part': if (activeTab.type == ChatTabType.channel) { - await _ircService.sendRaw('PART ${activeTab.name}'); + final suffix = rest.isEmpty ? '' : ' :$rest'; + await _ircService.sendRaw('PART ${activeTab.name}$suffix'); + return; + } + case 'query': + if (rest.isNotEmpty) { + final tab = _ensureQueryTab(rest.split(' ').first); + _activeTabId = tab.id; + unawaited(_persistState()); return; } case 'msg': @@ -242,6 +306,16 @@ class ChatSessionController extends ChangeNotifier { return; } } + case 'notice': + final space = rest.indexOf(' '); + if (space != -1) { + final target = rest.substring(0, space); + final text = rest.substring(space + 1).trim(); + if (text.isNotEmpty) { + await _ircService.sendNotice(target: target, text: text); + return; + } + } case 'me': if (rest.isNotEmpty && activeTab.type != ChatTabType.server) { await _ircService.sendAction(target: activeTab.name, text: rest); @@ -260,6 +334,72 @@ class ChatSessionController extends ChangeNotifier { await _ircService.sendRaw('NICK $rest'); return; } + case 'whois': + if (rest.isNotEmpty) { + await _ircService.sendWhois(rest.split(' ').first); + return; + } + case 'who': + await _ircService.sendWho(rest.isEmpty && activeTab.type == ChatTabType.channel ? activeTab.name : rest); + return; + case 'whowas': + if (rest.isNotEmpty) { + await _ircService.sendWhowas(rest.split(' ').first); + return; + } + case 'names': + if (activeTab.type == ChatTabType.channel) { + await _ircService.sendNames(activeTab.name); + return; + } + if (rest.isNotEmpty) { + await _ircService.sendNames(rest.split(' ').first); + return; + } + case 'invite': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + await _ircService.sendInvite( + nick: rest.split(' ').first, + channel: activeTab.name, + ); + return; + } + case 'kick': + if (rest.isNotEmpty && activeTab.type == ChatTabType.channel) { + final parts = rest.split(' '); + final nick = parts.first; + final reason = parts.length > 1 ? parts.skip(1).join(' ') : null; + await _ircService.sendKick( + channel: activeTab.name, + nick: nick, + reason: reason, + ); + return; + } + case 'topic': + if (activeTab.type == ChatTabType.channel) { + await _ircService.sendTopic(channel: activeTab.name, topic: rest.isEmpty ? null : rest); + return; + } + case 'mode': + if (rest.isNotEmpty) { + final args = activeTab.type == ChatTabType.channel ? '${activeTab.name} $rest' : rest; + await _ircService.sendMode(args); + return; + } + case 'quote': + case 'raw': + if (rest.isNotEmpty) { + await _ircService.sendRaw(rest); + return; + } + case 'clear': + _messages[activeTab.id] = []; + if (activeTab.type == ChatTabType.channel) { + _channelUsers.putIfAbsent(activeTab.id, () => {}); + } + unawaited(_persistState()); + return; case 'quit': await _ircService.disconnect(rest.isEmpty ? null : rest); return; @@ -288,6 +428,7 @@ class ChatSessionController extends ChangeNotifier { if (frame.params.length >= 2 && frame.trailing != null) { final channel = frame.params[1]; final tab = _ensureChannelTab(channel); + _channelTopics[tab.id] = frame.trailing!; _appendMessage( tabId: tab.id, sender: '*', @@ -295,11 +436,131 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } + case '331': + if (frame.params.length >= 2) { + final channel = frame.params[1]; + final tab = _ensureChannelTab(channel); + _channelTopics.remove(tab.id); + _appendMessage( + tabId: tab.id, + sender: '*', + content: frame.trailing ?? 'No topic is set.', + kind: IrcMessageKind.system, + ); + } + case '333': + if (frame.params.length >= 3) { + final channel = frame.params[1]; + final author = frame.params[2]; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Topic set by $author', + kind: IrcMessageKind.system, + ); + } + case '324': + if (frame.params.length >= 3) { + final channel = frame.params[1]; + final modes = frame.params.skip(2).join(' '); + final tab = _ensureChannelTab(channel); + _channelModes[tab.id] = modes; + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'Channel modes: $modes', + kind: IrcMessageKind.system, + ); + } + case '311': + _appendWhoisMessage( + frame, + 'WHOIS: ${frame.params.length > 3 ? '${frame.params[1]} is ${frame.params[2]}@${frame.params[3]}' : frame.raw}', + ); + case '312': + _appendWhoisMessage( + frame, + 'WHOIS server: ${frame.params.length > 2 ? '${frame.params[1]} on ${frame.params[2]} ${frame.trailing ?? ''}'.trim() : frame.raw}', + ); + case '317': + _appendWhoisMessage( + frame, + 'WHOIS idle: ${frame.params.length > 2 ? '${frame.params[1]} idle ${frame.params[2]}s' : frame.raw}', + ); + case '319': + _appendWhoisMessage( + frame, + 'WHOIS channels: ${frame.params.length > 1 ? '${frame.params[1]} ${frame.trailing ?? ''}'.trim() : frame.raw}', + ); + case '318': + _appendWhoisMessage( + frame, + 'End of WHOIS for ${frame.params.length > 1 ? frame.params[1] : ''}'.trim(), + ); + case '314': + _appendWhoisMessage( + frame, + 'WHOWAS: ${frame.params.length > 3 ? '${frame.params[1]} was ${frame.params[2]}@${frame.params[3]}' : frame.raw}', + ); + case '352': + if (frame.params.length >= 6) { + final channel = frame.params[1]; + final nick = frame.params[5]; + final tab = _ensureChannelTab(channel); + _channelUsers.putIfAbsent(tab.id, () => {}).add(nick); + _appendMessage( + tabId: tab.id, + sender: '*', + content: 'WHO: $nick ${frame.params[2]}@${frame.params[3]}', + kind: IrcMessageKind.system, + ); + } + case '315': + if (frame.params.length >= 2) { + final target = frame.params[1]; + final tabId = target.startsWith('#') + ? _ensureChannelTab(target).id + : _serverTabId(network.id); + _appendMessage( + tabId: tabId, + sender: '*', + content: frame.trailing ?? 'End of WHO.', + kind: IrcMessageKind.system, + ); + } + case '369': + _appendWhoisMessage( + frame, + 'End of WHOWAS for ${frame.params.length > 1 ? frame.params[1] : ''}'.trim(), + ); + case '353': + if (frame.params.length >= 3 && frame.trailing != null) { + final channel = frame.params[2]; + final tab = _ensureChannelTab(channel); + final users = frame.trailing! + .split(RegExp(r'\s+')) + .where((item) => item.isNotEmpty) + .map(_normalizeNickPrefix); + _channelUsers.putIfAbsent(tab.id, () => {}).addAll(users); + } + case '366': + if (frame.params.length >= 2) { + final channel = frame.params[1]; + final tab = _ensureChannelTab(channel); + _appendMessage( + tabId: tab.id, + sender: '*', + content: frame.trailing ?? 'Nick list complete.', + kind: IrcMessageKind.system, + ); + } case 'JOIN': final channel = frame.trailing ?? _firstOrNull(frame.params); if (channel != null) { final tab = _ensureChannelTab(channel); final nick = frame.senderNick ?? '*'; + _channelUsers.putIfAbsent(tab.id, () => {}).add(nick); _appendMessage( tabId: tab.id, sender: '*', @@ -314,6 +575,7 @@ class ChatSessionController extends ChangeNotifier { final channel = _firstOrNull(frame.params); if (channel != null) { final tab = _ensureChannelTab(channel); + _channelUsers.putIfAbsent(tab.id, () => {}).remove(frame.senderNick ?? ''); _appendMessage( tabId: tab.id, sender: '*', @@ -323,6 +585,7 @@ class ChatSessionController extends ChangeNotifier { ); } case 'QUIT': + _removeUserFromAllChannels(frame.senderNick); _appendMessage( tabId: _serverTabId(network.id), sender: '*', @@ -331,6 +594,10 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); case 'NICK': + _renameUserAcrossChannels( + frame.senderNick, + frame.trailing ?? _firstOrNull(frame.params), + ); _appendMessage( tabId: _serverTabId(network.id), sender: '*', @@ -340,8 +607,23 @@ class ChatSessionController extends ChangeNotifier { ); case 'NOTICE': _handleNotice(frame); + case 'TOPIC': + _handleTopic(frame); + case 'MODE': + _handleMode(frame); case 'PRIVMSG': _handlePrivmsg(frame); + case '401': + case '403': + case '442': + case '421': + case '433': + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'error', + content: frame.trailing ?? frame.raw, + kind: IrcMessageKind.system, + ); case 'ERROR': _appendMessage( tabId: _serverTabId(network.id), @@ -373,6 +655,7 @@ class ChatSessionController extends ChangeNotifier { sender: frame.senderNick ?? 'notice', content: content, ); + _markActivityIfInactive(tabId); } void _handlePrivmsg(IrcMessageFrame frame) { @@ -392,6 +675,44 @@ class ChatSessionController extends ChangeNotifier { sender: frame.senderNick ?? target, content: _normalizeContent(content), ); + _markActivityIfInactive(tab.id); + } + + void _handleTopic(IrcMessageFrame frame) { + final channel = _firstOrNull(frame.params); + final topic = frame.trailing; + if (channel == null || topic == null) { + return; + } + + final tab = _ensureChannelTab(channel); + _channelTopics[tab.id] = topic; + _appendMessage( + tabId: tab.id, + sender: '*', + content: '${frame.senderNick ?? '*'} changed the topic to: $topic', + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tab.id); + } + + void _handleMode(IrcMessageFrame frame) { + if (frame.params.length < 2) { + return; + } + + final target = frame.params.first; + final modeText = [...frame.params.skip(1), if (frame.trailing != null) frame.trailing!].join(' '); + final tabId = target.startsWith('#') + ? _ensureChannelTab(target).id + : _serverTabId(network.id); + _appendMessage( + tabId: tabId, + sender: '*', + content: '${frame.senderNick ?? '*'} set mode $modeText on $target', + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(tabId); } String _normalizeContent(String content) { @@ -417,6 +738,8 @@ class ChatSessionController extends ChangeNotifier { ); _tabs = [..._tabs, tab]; _messages.putIfAbsent(tab.id, () => []); + _channelUsers.putIfAbsent(tab.id, () => {}); + _channelTopics.putIfAbsent(tab.id, () => ''); return tab; } @@ -485,7 +808,12 @@ class ChatSessionController extends ChangeNotifier { for (final tab in _tabs) { _messages.putIfAbsent(tab.id, () => []); - } + if (tab.type == ChatTabType.channel) { + _channelUsers.putIfAbsent(tab.id, () => {}); + _channelTopics.putIfAbsent(tab.id, () => ''); + _channelModes.putIfAbsent(tab.id, () => ''); + } + } if (snapshot.activeTabId.isNotEmpty && _findTab(snapshot.activeTabId) != null) { _activeTabId = snapshot.activeTabId; @@ -509,6 +837,60 @@ class ChatSessionController extends ChangeNotifier { return values.first; } + void _appendWhoisMessage(IrcMessageFrame frame, String content) { + final nick = frame.params.length > 1 ? frame.params[1] : null; + final targetTabId = nick == null ? _serverTabId(network.id) : _ensureQueryTab(nick).id; + _appendMessage( + tabId: targetTabId, + sender: '*', + content: content, + kind: IrcMessageKind.system, + ); + _markActivityIfInactive(targetTabId); + } + + String _normalizeNickPrefix(String value) { + return value.replaceFirst(RegExp(r'^[~&@%+]'), ''); + } + + void _removeUserFromAllChannels(String? nick) { + if (nick == null || nick.isEmpty) { + return; + } + + for (final users in _channelUsers.values) { + users.remove(nick); + } + } + + void _renameUserAcrossChannels(String? oldNick, String? newNick) { + if (oldNick == null || oldNick.isEmpty || newNick == null || newNick.isEmpty) { + return; + } + + for (final users in _channelUsers.values) { + if (users.remove(oldNick)) { + users.add(newNick); + } + } + } + + void _markActivityIfInactive(String tabId) { + if (tabId == _activeTabId) { + return; + } + + _setTabActivity(tabId, true); + } + + void _setTabActivity(String tabId, bool hasActivity) { + _tabs = _tabs + .map( + (tab) => tab.id == tabId ? tab.copyWith(hasActivity: hasActivity) : tab, + ) + .toList(growable: false); + } + @override void dispose() { _cancelReconnect(); diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart new file mode 100644 index 0000000..8b7e843 --- /dev/null +++ b/lib/features/chat/application/command_service.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class CommandAlias { + const CommandAlias({ + required this.alias, + required this.command, + }); + + final String alias; + final String command; +} + +class CommandHistoryEntry { + const CommandHistoryEntry({ + required this.id, + required this.command, + required this.timestamp, + }); + + final String id; + final String command; + final DateTime timestamp; + + Map toJson() { + return { + 'id': id, + 'command': command, + 'timestamp': timestamp.toIso8601String(), + }; + } + + factory CommandHistoryEntry.fromJson(Map json) { + return CommandHistoryEntry( + id: json['id']! as String, + command: json['command']! as String, + timestamp: DateTime.parse(json['timestamp']! as String), + ); + } +} + +class CommandService { + static const _historyKey = 'androidircx.commandHistory'; + static const _maxHistory = 50; + + final Map _aliases = { + 'j': const CommandAlias(alias: 'j', command: '/join'), + 'p': const CommandAlias(alias: 'p', command: '/part'), + 'q': const CommandAlias(alias: 'q', command: '/quit'), + 'w': const CommandAlias(alias: 'w', command: '/whois'), + 'n': const CommandAlias(alias: 'n', command: '/nick'), + 'm': const CommandAlias(alias: 'm', command: '/msg'), + }; + + List _history = const []; + + List get history => List.unmodifiable(_history); + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_historyKey); + if (raw == null || raw.isEmpty) { + _history = const []; + return; + } + + final decoded = jsonDecode(raw) as List; + _history = decoded + .map((item) => CommandHistoryEntry.fromJson(item as Map)) + .toList(growable: false); + } + + Future addToHistory(String command) async { + final entry = CommandHistoryEntry( + id: '${DateTime.now().microsecondsSinceEpoch}', + command: command, + timestamp: DateTime.now(), + ); + _history = [entry, ..._history].take(_maxHistory).toList(growable: false); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _historyKey, + jsonEncode(_history.map((item) => item.toJson()).toList(growable: false)), + ); + } + + String normalizeCommand(String input) { + if (!input.startsWith('/')) { + return input; + } + + final parts = input.split(' '); + final head = parts.first.substring(1).toLowerCase(); + final alias = _aliases[head]; + if (alias == null) { + return input; + } + + final tail = parts.length > 1 ? ' ${parts.skip(1).join(' ')}' : ''; + return '${alias.command}$tail'; + } +} diff --git a/lib/features/chat/data/chat_session_persistence.dart b/lib/features/chat/data/chat_session_persistence.dart index 731f511..3106801 100644 --- a/lib/features/chat/data/chat_session_persistence.dart +++ b/lib/features/chat/data/chat_session_persistence.dart @@ -27,13 +27,13 @@ class ChatSessionPersistence { final decoded = jsonDecode(raw) as Map; final tabs = ((decoded['tabs'] as List?) ?? const []) .map((item) => ChatTab.fromJson(item as Map)) - .toList(growable: false); + .toList(); final messagesMap = >{}; final rawMessages = (decoded['messagesByTab'] as Map?) ?? const {}; for (final entry in rawMessages.entries) { messagesMap[entry.key] = (entry.value as List) .map((item) => IrcMessage.fromJson(item as Map)) - .toList(growable: false); + .toList(); } return ChatSessionSnapshot( diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 06652fe..bf41f6e 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -2,6 +2,7 @@ import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/features/settings/presentation/settings_screen.dart'; @@ -49,12 +50,25 @@ class _ChatScreenState extends State { children: [ Text(_controller.activeTab.name), Text( - _statusText(_controller.connection), + _controller.activeTab.type == ChatTabType.channel && + _controller.activeChannelSummary.isNotEmpty + ? _controller.activeChannelSummary + : _statusText(_controller.connection), style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ + if (_controller.activeTab.type == ChatTabType.channel) + Builder( + builder: (context) { + return IconButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + icon: const Icon(Icons.people_outline), + tooltip: 'Nick list', + ); + }, + ), IconButton( onPressed: _showJoinDialog, icon: const Icon(Icons.tag), @@ -97,8 +111,33 @@ class _ChatScreenState extends State { final selected = tab.id == _controller.activeTabId; return ListTile( selected: selected, - leading: Icon(_iconForTab(tab.type)), + leading: Stack( + clipBehavior: Clip.none, + children: [ + Icon(_iconForTab(tab.type)), + if (tab.hasActivity) + Positioned( + right: -2, + top: -2, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), title: Text(tab.name), + trailing: tab.type == ChatTabType.server + ? null + : IconButton( + onPressed: () => _controller.closeTab(tab.id), + icon: const Icon(Icons.close, size: 18), + tooltip: 'Close tab', + ), onTap: () { _controller.selectTab(tab.id); Navigator.of(context).pop(); @@ -111,13 +150,55 @@ class _ChatScreenState extends State { ), ), ), + endDrawer: _controller.activeTab.type == ChatTabType.channel + ? Drawer( + child: SafeArea( + child: Column( + children: [ + ListTile( + title: Text(_controller.activeTab.name), + subtitle: Text( + '${_controller.activeChannelUsers.length} users', + ), + ), + const Divider(height: 1), + Expanded( + child: _controller.activeChannelUsers.isEmpty + ? const Center(child: Text('No nick list yet.')) + : ListView.builder( + itemCount: _controller.activeChannelUsers.length, + itemBuilder: (context, index) { + final nick = _controller.activeChannelUsers[index]; + return ListTile( + leading: const Icon(Icons.person_outline), + title: Text(nick), + onTap: () { + _composerController.text = '/whois $nick'; + Navigator.of(context).pop(); + }, + ); + }, + ), + ), + ], + ), + ), + ) + : null, body: SafeArea( child: Column( children: [ _ConnectionBanner(controller: _controller, network: widget.network), + if ((_controller.activeChannelTopic ?? '').trim().isNotEmpty) + _ChannelTopicBar(topic: _controller.activeChannelTopic!.trim()), Expanded( child: _MessageList(messages: _controller.activeMessages), ), + if (_controller.commandHistory.isNotEmpty) + _CommandHistoryBar( + entries: _controller.commandHistory, + onSelect: (value) => setState(() => _composerController.text = value), + ), const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), @@ -154,35 +235,12 @@ class _ChatScreenState extends State { } Future _showJoinDialog() async { - final controller = TextEditingController(text: '#'); final result = await showDialog( context: context, builder: (context) { - return AlertDialog( - title: const Text('Join channel'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Channel'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(context).pop( - JoinChannelRequest(channel: controller.text.trim()), - ); - }, - child: const Text('Join'), - ), - ], - ); + return const JoinChannelDialog(); }, ); - controller.dispose(); if (result != null) { await _controller.joinChannel(result); @@ -235,6 +293,69 @@ class _ChatScreenState extends State { } } +class _ChannelTopicBar extends StatelessWidget { + const _ChannelTopicBar({ + required this.topic, + }); + + final String topic; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.black.withValues(alpha: 0.08)), + ), + child: Text( + topic, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} + +class _CommandHistoryBar extends StatelessWidget { + const _CommandHistoryBar({ + required this.entries, + required this.onSelect, + }); + + final List entries; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final items = entries.take(5).toList(growable: false); + return SizedBox( + height: 44, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final entry = items[index]; + return ActionChip( + label: Text( + entry.command, + style: theme.textTheme.labelMedium, + ), + onPressed: () => onSelect(entry.command), + ); + }, + ), + ); + } +} + class _MessageList extends StatelessWidget { const _MessageList({ required this.messages, diff --git a/lib/features/chat/presentation/join_channel_dialog.dart b/lib/features/chat/presentation/join_channel_dialog.dart index a05fbd1..5868d17 100644 --- a/lib/features/chat/presentation/join_channel_dialog.dart +++ b/lib/features/chat/presentation/join_channel_dialog.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + class JoinChannelRequest { const JoinChannelRequest({ required this.channel, @@ -5,3 +7,52 @@ class JoinChannelRequest { final String channel; } + +class JoinChannelDialog extends StatefulWidget { + const JoinChannelDialog({super.key}); + + @override + State createState() => _JoinChannelDialogState(); +} + +class _JoinChannelDialogState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: '#'); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Join channel'), + content: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Channel'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop( + JoinChannelRequest(channel: _controller.text.trim()), + ); + }, + child: const Text('Join'), + ), + ], + ); + } +} diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index f5122db..1d24937 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -30,7 +30,10 @@ class NetworkListController extends ChangeNotifier { required String host, required int port, required String nickname, + required String altNickname, required bool useTls, + String? saslAccount, + String? saslPassword, String? networkId, }) async { final network = NetworkConfig( @@ -39,7 +42,10 @@ class NetworkListController extends ChangeNotifier { host: host, port: port, nickname: nickname, + altNickname: altNickname.trim(), useTls: useTls, + saslAccount: (saslAccount ?? '').trim().isEmpty ? null : saslAccount?.trim(), + saslPassword: (saslPassword ?? '').trim().isEmpty ? null : saslPassword, ); await _repository.saveNetwork(network); diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 468209e..34d8132 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -7,14 +7,20 @@ class NetworkFormResult { required this.host, required this.port, required this.nickname, + required this.altNickname, required this.useTls, + this.saslAccount, + this.saslPassword, }); final String name; final String host; final int port; final String nickname; + final String altNickname; final bool useTls; + final String? saslAccount; + final String? saslPassword; } class NetworkFormScreen extends StatefulWidget { @@ -35,6 +41,9 @@ class _NetworkFormScreenState extends State { late final TextEditingController _hostController; late final TextEditingController _portController; late final TextEditingController _nicknameController; + late final TextEditingController _altNicknameController; + late final TextEditingController _saslAccountController; + late final TextEditingController _saslPasswordController; late bool _useTls; @override @@ -49,6 +58,11 @@ class _NetworkFormScreenState extends State { _nicknameController = TextEditingController( text: initial?.nickname ?? 'AndroidIRCX', ); + _altNicknameController = TextEditingController( + text: initial?.altNickname ?? 'AndroidIRCX_', + ); + _saslAccountController = TextEditingController(text: initial?.saslAccount ?? ''); + _saslPasswordController = TextEditingController(text: initial?.saslPassword ?? ''); _useTls = initial?.useTls ?? true; } @@ -58,6 +72,9 @@ class _NetworkFormScreenState extends State { _hostController.dispose(); _portController.dispose(); _nicknameController.dispose(); + _altNicknameController.dispose(); + _saslAccountController.dispose(); + _saslPasswordController.dispose(); super.dispose(); } @@ -108,6 +125,31 @@ class _NetworkFormScreenState extends State { decoration: const InputDecoration(labelText: 'Nickname'), validator: _requiredValidator, ), + const SizedBox(height: 16), + TextFormField( + controller: _altNicknameController, + decoration: const InputDecoration( + labelText: 'Alt nickname', + helperText: 'Used when the primary nick is already taken.', + ), + validator: _requiredValidator, + ), + const SizedBox(height: 16), + TextFormField( + controller: _saslAccountController, + decoration: const InputDecoration( + labelText: 'SASL account', + helperText: 'Optional. Enables SASL PLAIN when combined with a password.', + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _saslPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'SASL password', + ), + ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -148,7 +190,10 @@ class _NetworkFormScreenState extends State { host: _hostController.text.trim(), port: int.parse(_portController.text.trim()), nickname: _nicknameController.text.trim(), + altNickname: _altNicknameController.text.trim(), useTls: _useTls, + saslAccount: _saslAccountController.text.trim(), + saslPassword: _saslPasswordController.text, ), ); } diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 48596da..0e28110 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -78,7 +78,10 @@ class NetworkListScreen extends StatelessWidget { host: result.host, port: result.port, nickname: result.nickname, + altNickname: result.altNickname, useTls: result.useTls, + saslAccount: result.saslAccount, + saslPassword: result.saslPassword, networkId: initialValue?.id, ); } @@ -157,7 +160,7 @@ class _NetworkCard extends StatelessWidget { Text('${network.host}:${network.port}'), const SizedBox(height: 4), Text( - 'Nick: ${network.nickname} • ${network.useTls ? 'TLS' : 'Plain TCP'}', + 'Nick: ${network.nickname} / ${network.altNickname ?? '${network.nickname}_'} • ${network.useTls ? 'TLS' : 'Plain TCP'}', style: theme.textTheme.bodySmall, ), const SizedBox(height: 16), diff --git a/lib/irc/services/irc_service.dart b/lib/irc/services/irc_service.dart index f7123b7..75cd450 100644 --- a/lib/irc/services/irc_service.dart +++ b/lib/irc/services/irc_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/network_config.dart'; @@ -29,6 +30,15 @@ class IrcService { StreamSubscription? _linesSubscription; ConnectionSnapshot _state; String? _currentNick; + final Set _capAvailable = {}; + final Set _capEnabled = {}; + bool _capNegotiationActive = false; + bool _capEnded = false; + bool _saslInProgress = false; + NetworkConfig? _network; + String? _primaryNick; + String? _altNickBase; + int _altNickAttempt = 0; ConnectionSnapshot get state => _state; String? get currentNick => _currentNick; @@ -42,7 +52,16 @@ class IrcService { return; } - _currentNick = network.nickname; + _primaryNick = network.nickname.trim(); + _altNickBase = _resolveAltNickBase(network); + _altNickAttempt = 0; + _currentNick = _primaryNick; + _network = network; + _capAvailable.clear(); + _capEnabled.clear(); + _capNegotiationActive = false; + _capEnded = false; + _saslInProgress = false; _updateState( ConnectionSnapshot( networkId: network.id, @@ -59,10 +78,14 @@ class IrcService { onDone: _handleTransportDone, ); + if (_shouldUseSasl(network)) { + _capNegotiationActive = true; + await sendRaw('CAP LS 302'); + } if ((network.password ?? '').isNotEmpty) { await sendRaw('PASS ${network.password}'); } - await sendRaw('NICK ${network.nickname}'); + await _sendNick(_primaryNick!); await sendRaw('USER ${network.username} 0 * :${network.realName}'); } catch (error) { _updateState( @@ -124,6 +147,62 @@ class IrcService { await sendRaw('PRIVMSG $target :$text'); } + Future sendNotice({ + required String target, + required String text, + }) async { + await sendRaw('NOTICE $target :$text'); + } + + Future sendWhois(String nick) async { + await sendRaw('WHOIS $nick $nick'); + } + + Future sendWho(String mask) async { + final value = mask.trim(); + await sendRaw(value.isEmpty ? 'WHO' : 'WHO $value'); + } + + Future sendWhowas(String nick) async { + await sendRaw('WHOWAS $nick'); + } + + Future sendNames(String channel) async { + await sendRaw('NAMES $channel'); + } + + Future sendInvite({ + required String nick, + required String channel, + }) async { + await sendRaw('INVITE $nick $channel'); + } + + Future sendKick({ + required String channel, + required String nick, + String? reason, + }) async { + final suffix = (reason ?? '').trim().isEmpty ? '' : ' :${reason!.trim()}'; + await sendRaw('KICK $channel $nick$suffix'); + } + + Future sendTopic({ + required String channel, + String? topic, + }) async { + if ((topic ?? '').trim().isEmpty) { + await sendRaw('TOPIC $channel'); + return; + } + + await sendRaw('TOPIC $channel :$topic'); + } + + Future sendMode(String args) async { + await sendRaw('MODE $args'); + } + Future sendAction({ required String target, required String text, @@ -142,7 +221,35 @@ class IrcService { return; } + if (frame.command == 'CAP') { + _handleCap(frame); + return; + } + + if (frame.command == 'AUTHENTICATE') { + _handleAuthenticate(frame); + return; + } + + if (frame.command == '903') { + _saslInProgress = false; + _rawEventsController.add('** SASL authentication successful'); + unawaited(_endCapNegotiation()); + return; + } + + if (frame.command == '904' || + frame.command == '905' || + frame.command == '906' || + frame.command == '907') { + _saslInProgress = false; + _rawEventsController.add('** SASL authentication failed'); + unawaited(_endCapNegotiation()); + return; + } + if (frame.command == '001') { + _altNickAttempt = 0; _updateState( ConnectionSnapshot( networkId: _state.networkId, @@ -160,6 +267,119 @@ class IrcService { _currentNick = nextNick; } } + + if (frame.command == '433' || frame.command == '436') { + _handleNicknameCollision(frame); + } + } + + void _handleCap(IrcMessageFrame frame) { + final params = frame.params; + if (params.length < 2) { + return; + } + + final subcommandIndex = params.first == '*' ? 1 : 0; + if (subcommandIndex >= params.length) { + return; + } + + final subcommand = params[subcommandIndex].toUpperCase(); + final rest = params.skip(subcommandIndex + 1).toList(growable: false); + final trailing = frame.trailing ?? ''; + + switch (subcommand) { + case 'LS': + final capabilities = [ + ...rest.where((item) => item != '*'), + if (trailing.isNotEmpty) trailing, + ].join(' '); + for (final cap in capabilities.split(RegExp(r'\s+'))) { + final name = cap.split('=').first.trim(); + if (name.isNotEmpty) { + _capAvailable.add(name); + } + } + final isLast = !rest.contains('*'); + if (isLast) { + if (_capAvailable.contains('sasl') && _shouldUseSasl(_network)) { + unawaited(sendRaw('CAP REQ :sasl')); + } else { + unawaited(_endCapNegotiation()); + } + } + case 'ACK': + final ackSource = [...rest, if (trailing.isNotEmpty) trailing].join(' '); + for (final cap in ackSource.split(RegExp(r'\s+'))) { + final name = cap.split('=').first.trim(); + if (name.isNotEmpty) { + _capEnabled.add(name); + } + } + if (_capEnabled.contains('sasl') && _shouldUseSasl(_network)) { + _saslInProgress = true; + unawaited(sendRaw('AUTHENTICATE PLAIN')); + } else { + unawaited(_endCapNegotiation()); + } + case 'NAK': + unawaited(_endCapNegotiation()); + default: + break; + } + } + + void _handleAuthenticate(IrcMessageFrame frame) { + if (!_saslInProgress) { + return; + } + + final payload = frame.params.isNotEmpty ? frame.params.first : frame.trailing; + if (payload != '+') { + return; + } + + final network = _network; + if (network == null) { + return; + } + + final account = network.saslAccount; + final password = network.saslPassword; + if ((account ?? '').isEmpty || (password ?? '').isEmpty) { + return; + } + + final auth = base64.encode(utf8.encode('$account\u0000$account\u0000$password')); + final chunks = []; + for (var i = 0; i < auth.length; i += 400) { + chunks.add(auth.substring(i, i + 400 > auth.length ? auth.length : i + 400)); + } + + for (final chunk in chunks) { + unawaited(sendRaw('AUTHENTICATE $chunk')); + } + if (auth.length % 400 == 0) { + unawaited(sendRaw('AUTHENTICATE +')); + } + } + + Future _endCapNegotiation() async { + if (_capEnded || !_capNegotiationActive) { + return; + } + + _capEnded = true; + await sendRaw('CAP END'); + } + + bool _shouldUseSasl(NetworkConfig? network) { + if (network == null) { + return false; + } + + return (network.saslAccount ?? '').isNotEmpty && + (network.saslPassword ?? '').isNotEmpty; } void _handleTransportDone() { @@ -203,4 +423,56 @@ class IrcService { return items.first; } + + Future _sendNick(String nick) async { + _currentNick = nick; + await sendRaw('NICK $nick'); + } + + String _resolveAltNickBase(NetworkConfig network) { + final explicit = (network.altNickname ?? '').trim(); + if (explicit.isNotEmpty) { + return explicit; + } + + final primary = network.nickname.trim(); + return primary.isEmpty ? 'AndroidIRCX_' : '${primary}_'; + } + + void _handleNicknameCollision(IrcMessageFrame frame) { + final nextNick = _nextNickCandidate(); + if (nextNick == null) { + return; + } + + _rawEventsController.add('** Nickname in use, trying $nextNick'); + _updateState( + ConnectionSnapshot( + networkId: _state.networkId, + phase: ConnectionPhase.connecting, + message: 'Nickname in use, trying $nextNick', + ), + ); + unawaited(_sendNick(nextNick)); + } + + String? _nextNickCandidate() { + final primary = (_primaryNick ?? '').trim(); + final altBase = (_altNickBase ?? '').trim(); + if (primary.isEmpty || altBase.isEmpty) { + return null; + } + + if (_currentNick == primary) { + return altBase; + } + + if (_currentNick == altBase) { + _altNickAttempt = 1; + return '$altBase$_altNickAttempt'; + } + + _altNickAttempt += 1; + return '$altBase$_altNickAttempt'; + } } diff --git a/test/command_service_test.dart b/test/command_service_test.dart new file mode 100644 index 0000000..a62ae10 --- /dev/null +++ b/test/command_service_test.dart @@ -0,0 +1,31 @@ +import 'package:androidircx/features/chat/application/command_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('normalizes default aliases', () { + final service = CommandService(); + + expect(service.normalizeCommand('/j #flutter'), '/join #flutter'); + expect(service.normalizeCommand('/w nick'), '/whois nick'); + expect(service.normalizeCommand('hello'), 'hello'); + }); + + test('persists command history', () async { + final service = CommandService(); + + await service.load(); + await service.addToHistory('/join #flutter'); + await service.addToHistory('/whois nick'); + + final secondInstance = CommandService(); + await secondInstance.load(); + + expect(secondInstance.history, hasLength(2)); + expect(secondInstance.history.first.command, '/whois nick'); + }); +} diff --git a/test/irc_service_sasl_test.dart b/test/irc_service_sasl_test.dart new file mode 100644 index 0000000..b9b2d62 --- /dev/null +++ b/test/irc_service_sasl_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = StreamController.broadcast(); + final List sentLines = []; + + @override + Stream get lines => _controller.stream; + + void emit(String line) { + _controller.add(line); + } + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendLine(String line) async { + sentLines.add(line); + } +} + +void main() { + test('starts CAP negotiation and SASL PLAIN when configured', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + saslAccount: 'alice', + saslPassword: 'secret', + ), + ); + + expect(transport.sentLines, containsAllInOrder(['CAP LS 302', 'NICK AndroidIRCX'])); + + transport.emit(':server CAP * LS :multi-prefix sasl'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('CAP REQ :sasl')); + + transport.emit(':server CAP * ACK :sasl'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('AUTHENTICATE PLAIN')); + + transport.emit('AUTHENTICATE +'); + await Future.delayed(Duration.zero); + expect( + transport.sentLines.any((line) => line.startsWith('AUTHENTICATE ') && line != 'AUTHENTICATE PLAIN'), + isTrue, + ); + + transport.emit(':server 903 AndroidIRCX :SASL authentication successful'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('CAP END')); + + service.dispose(); + }); + + test('retries with alt nick and numbered suffix when nick is in use', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ); + + expect(transport.sentLines, contains('NICK AndroidIRCX')); + + transport.emit(':server 433 * AndroidIRCX :Nickname is already in use'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('NICK AndroidIRCX_')); + + transport.emit(':server 433 * AndroidIRCX_ :Nickname is already in use'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('NICK AndroidIRCX_1')); + + service.dispose(); + }); +} diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index e6f9a09..a83b322 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -83,5 +83,48 @@ void main() { expect(snapshot.activeTabId, tab.id); expect(snapshot.messagesByTab[tab.id]!.single.content, 'hello'); }); + + test('chat session persistence restores growable message lists', () async { + final persistence = ChatSessionPersistence(); + const tab = ChatTab( + id: 'server::dbase', + name: 'DBase', + type: ChatTabType.server, + networkId: 'dbase', + ); + final message = IrcMessage( + id: '1', + tabId: tab.id, + sender: '*', + content: 'connected', + timestamp: DateTime(2026, 3, 16, 12, 0), + kind: IrcMessageKind.system, + ); + + await persistence.save( + networkId: 'dbase', + tabs: const [tab], + messagesByTab: { + tab.id: [message], + }, + activeTabId: tab.id, + ); + + final snapshot = await persistence.load('dbase'); + final restored = snapshot!.messagesByTab[tab.id]!; + + restored.add( + IrcMessage( + id: '2', + tabId: tab.id, + sender: '*', + content: 'raw line', + timestamp: DateTime(2026, 3, 16, 12, 1), + kind: IrcMessageKind.raw, + ), + ); + + expect(restored, hasLength(2)); + }); }); } From c1870c82f2761961f4f48027e16b4fbb806c119a Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Mon, 16 Mar 2026 15:10:44 +0100 Subject: [PATCH 2/2] Advance sprint 4 auth and multi-session parity Add shared session orchestration, auto-connect startup, SCRAM-SHA-256 support, richer auth status handling, and broader widget and service test coverage for the current Sprint 4 scope. --- lib/core/models/network_config.dart | 13 ++ .../presentation/bootstrap_screen.dart | 39 +++- .../application/chat_session_controller.dart | 15 ++ .../chat/application/session_registry.dart | 67 +++++++ .../chat/presentation/chat_screen.dart | 24 ++- .../application/network_list_controller.dart | 4 + .../presentation/network_form_screen.dart | 40 ++++ .../presentation/network_list_screen.dart | 178 +++++++++++++++++- lib/irc/sasl/scram_sha256_session.dart | 138 ++++++++++++++ lib/irc/services/irc_service.dart | 155 +++++++++++++-- pubspec.lock | 16 ++ pubspec.yaml | 1 + test/chat_session_controller_test.dart | 77 ++++++++ test/irc_service_sasl_test.dart | 105 +++++++++++ test/scram_sha256_session_test.dart | 24 +++ test/session_registry_test.dart | 50 +++++ test/storage_repositories_test.dart | 7 + test/widget_test.dart | 46 +++++ 18 files changed, 966 insertions(+), 33 deletions(-) create mode 100644 lib/features/chat/application/session_registry.dart create mode 100644 lib/irc/sasl/scram_sha256_session.dart create mode 100644 test/chat_session_controller_test.dart create mode 100644 test/scram_sha256_session_test.dart create mode 100644 test/session_registry_test.dart diff --git a/lib/core/models/network_config.dart b/lib/core/models/network_config.dart index dd8c256..9221e30 100644 --- a/lib/core/models/network_config.dart +++ b/lib/core/models/network_config.dart @@ -1,3 +1,8 @@ +enum SaslMechanism { + plain, + scramSha256, +} + class NetworkConfig { const NetworkConfig({ required this.id, @@ -12,6 +17,7 @@ class NetworkConfig { this.password, this.saslAccount, this.saslPassword, + this.saslMechanism = SaslMechanism.plain, this.autoConnect = false, }); @@ -27,6 +33,7 @@ class NetworkConfig { final String? password; final String? saslAccount; final String? saslPassword; + final SaslMechanism saslMechanism; final bool autoConnect; NetworkConfig copyWith({ @@ -42,6 +49,7 @@ class NetworkConfig { String? password, String? saslAccount, String? saslPassword, + SaslMechanism? saslMechanism, bool? autoConnect, }) { return NetworkConfig( @@ -57,6 +65,7 @@ class NetworkConfig { password: password ?? this.password, saslAccount: saslAccount ?? this.saslAccount, saslPassword: saslPassword ?? this.saslPassword, + saslMechanism: saslMechanism ?? this.saslMechanism, autoConnect: autoConnect ?? this.autoConnect, ); } @@ -75,6 +84,7 @@ class NetworkConfig { 'password': password, 'saslAccount': saslAccount, 'saslPassword': saslPassword, + 'saslMechanism': saslMechanism.name, 'autoConnect': autoConnect, }; } @@ -93,6 +103,9 @@ class NetworkConfig { password: json['password'] as String?, saslAccount: json['saslAccount'] as String?, saslPassword: json['saslPassword'] as String?, + saslMechanism: json['saslMechanism'] == null + ? SaslMechanism.plain + : SaslMechanism.values.byName(json['saslMechanism']! as String), autoConnect: (json['autoConnect'] as bool?) ?? false, ); } diff --git a/lib/features/bootstrap/presentation/bootstrap_screen.dart b/lib/features/bootstrap/presentation/bootstrap_screen.dart index 79c898f..0107a4f 100644 --- a/lib/features/bootstrap/presentation/bootstrap_screen.dart +++ b/lib/features/bootstrap/presentation/bootstrap_screen.dart @@ -1,4 +1,5 @@ import 'package:androidircx/core/storage/shared_prefs_network_repository.dart'; +import 'package:androidircx/features/chat/application/session_registry.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/connections/presentation/network_list_screen.dart'; import 'package:flutter/material.dart'; @@ -12,23 +13,57 @@ class BootstrapScreen extends StatefulWidget { class _BootstrapScreenState extends State { late final NetworkListController _controller; + late final SessionRegistry _sessionRegistry; + bool _bootstrapComplete = false; @override void initState() { super.initState(); + _sessionRegistry = SessionRegistry(); _controller = NetworkListController( repository: SharedPrefsNetworkRepository(), - )..load(); + ); + _bootstrap(); + } + + Future _bootstrap() async { + await _controller.load(); + for (final network in _controller.networks.where((item) => item.autoConnect)) { + final session = _sessionRegistry.obtainSession(network); + await session.start(); + } + + if (!mounted) { + return; + } + + setState(() { + _bootstrapComplete = true; + }); } @override void dispose() { _controller.dispose(); + _sessionRegistry.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return NetworkListScreen(controller: _controller); + if (!_bootstrapComplete && _controller.isLoading) { + return const Scaffold( + body: SafeArea( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + + return NetworkListScreen( + controller: _controller, + sessionRegistry: _sessionRegistry, + ); } } diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index c330f7e..129766a 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -69,6 +69,7 @@ class ChatSessionController extends ChangeNotifier { bool get isReconnectScheduled => _reconnectTimer?.isActive ?? false; Duration? get pendingReconnectDelay => _pendingReconnectDelay; ChatTab get activeTab => _tabs.firstWhere((tab) => tab.id == _activeTabId); + String get currentNick => _ircService.currentNick ?? network.nickname; String? get activeChannelTopic => _channelTopics[activeTabId]; String? get activeChannelModes => _channelModes[activeTabId]; String get activeChannelSummary { @@ -478,6 +479,20 @@ class ChatSessionController extends ChangeNotifier { frame, 'WHOIS: ${frame.params.length > 3 ? '${frame.params[1]} is ${frame.params[2]}@${frame.params[3]}' : frame.raw}', ); + case '900': + case '901': + case '902': + case '903': + case '904': + case '905': + case '906': + case '907': + _appendMessage( + tabId: _serverTabId(network.id), + sender: 'auth', + content: frame.trailing ?? frame.raw, + kind: IrcMessageKind.system, + ); case '312': _appendWhoisMessage( frame, diff --git a/lib/features/chat/application/session_registry.dart b/lib/features/chat/application/session_registry.dart new file mode 100644 index 0000000..264a079 --- /dev/null +++ b/lib/features/chat/application/session_registry.dart @@ -0,0 +1,67 @@ +import 'package:androidircx/core/models/connection_state.dart'; +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:flutter/foundation.dart'; + +class SessionRegistry extends ChangeNotifier { + final Map _sessions = {}; + final Map _listeners = {}; + + List get sessions => + List.unmodifiable(_sessions.values); + + bool hasSession(String networkId) => _sessions.containsKey(networkId); + + ChatSessionController obtainSession(NetworkConfig network) { + final existing = _sessions[network.id]; + if (existing != null) { + return existing; + } + + final controller = ChatSessionController(network: network); + void listener() => notifyListeners(); + controller.addListener(listener); + _listeners[network.id] = listener; + _sessions[network.id] = controller; + notifyListeners(); + return controller; + } + + ConnectionSnapshot connectionFor(String networkId) { + return _sessions[networkId]?.connection ?? + const ConnectionSnapshot(networkId: '', phase: ConnectionPhase.idle); + } + + String? currentNickFor(String networkId) { + return _sessions[networkId]?.currentNick; + } + + Future closeSession(String networkId) async { + final controller = _sessions.remove(networkId); + final listener = _listeners.remove(networkId); + if (controller == null) { + return; + } + + if (listener != null) { + controller.removeListener(listener); + } + await controller.disconnect(); + controller.dispose(); + notifyListeners(); + } + + @override + void dispose() { + for (final entry in _sessions.entries) { + final listener = _listeners[entry.key]; + if (listener != null) { + entry.value.removeListener(listener); + } + entry.value.dispose(); + } + _sessions.clear(); + _listeners.clear(); + super.dispose(); + } +} diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index bf41f6e..4dec01f 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -11,30 +11,29 @@ import 'package:flutter/material.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({ super.key, - required this.network, + required this.controller, }); - final NetworkConfig network; + final ChatSessionController controller; @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { - late final ChatSessionController _controller; final TextEditingController _composerController = TextEditingController(); + ChatSessionController get _controller => widget.controller; + @override void initState() { super.initState(); - _controller = ChatSessionController(network: widget.network); _controller.start(); } @override void dispose() { _composerController.dispose(); - _controller.dispose(); super.dispose(); } @@ -99,8 +98,9 @@ class _ChatScreenState extends State { child: Column( children: [ ListTile( - title: Text(widget.network.name), - subtitle: Text('${widget.network.host}:${widget.network.port}'), + title: Text(_controller.network.name), + subtitle: + Text('${_controller.network.host}:${_controller.network.port}'), ), const Divider(height: 1), Expanded( @@ -188,7 +188,10 @@ class _ChatScreenState extends State { body: SafeArea( child: Column( children: [ - _ConnectionBanner(controller: _controller, network: widget.network), + _ConnectionBanner( + controller: _controller, + network: _controller.network, + ), if ((_controller.activeChannelTopic ?? '').trim().isNotEmpty) _ChannelTopicBar(topic: _controller.activeChannelTopic!.trim()), Expanded( @@ -464,6 +467,11 @@ class _ConnectionBanner extends StatelessWidget { '${network.host}:${network.port} • ${network.useTls ? 'TLS' : 'Plain TCP'}', style: theme.textTheme.bodySmall, ), + const SizedBox(height: 4), + Text( + 'Current nick: ${controller.currentNick}', + style: theme.textTheme.bodySmall, + ), if ((snapshot.message ?? '').isNotEmpty) ...[ const SizedBox(height: 4), Text(snapshot.message!, style: theme.textTheme.bodySmall), diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index 1d24937..0af4e5c 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -32,6 +32,8 @@ class NetworkListController extends ChangeNotifier { required String nickname, required String altNickname, required bool useTls, + required bool autoConnect, + required SaslMechanism saslMechanism, String? saslAccount, String? saslPassword, String? networkId, @@ -44,6 +46,8 @@ class NetworkListController extends ChangeNotifier { nickname: nickname, altNickname: altNickname.trim(), useTls: useTls, + autoConnect: autoConnect, + saslMechanism: saslMechanism, saslAccount: (saslAccount ?? '').trim().isEmpty ? null : saslAccount?.trim(), saslPassword: (saslPassword ?? '').trim().isEmpty ? null : saslPassword, ); diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 34d8132..6cd7ac5 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -9,6 +9,8 @@ class NetworkFormResult { required this.nickname, required this.altNickname, required this.useTls, + required this.autoConnect, + required this.saslMechanism, this.saslAccount, this.saslPassword, }); @@ -19,6 +21,8 @@ class NetworkFormResult { final String nickname; final String altNickname; final bool useTls; + final bool autoConnect; + final SaslMechanism saslMechanism; final String? saslAccount; final String? saslPassword; } @@ -45,6 +49,8 @@ class _NetworkFormScreenState extends State { late final TextEditingController _saslAccountController; late final TextEditingController _saslPasswordController; late bool _useTls; + late bool _autoConnect; + late SaslMechanism _saslMechanism; @override void initState() { @@ -64,6 +70,8 @@ class _NetworkFormScreenState extends State { _saslAccountController = TextEditingController(text: initial?.saslAccount ?? ''); _saslPasswordController = TextEditingController(text: initial?.saslPassword ?? ''); _useTls = initial?.useTls ?? true; + _autoConnect = initial?.autoConnect ?? false; + _saslMechanism = initial?.saslMechanism ?? SaslMechanism.plain; } @override @@ -150,6 +158,29 @@ class _NetworkFormScreenState extends State { labelText: 'SASL password', ), ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _saslMechanism, + decoration: const InputDecoration( + labelText: 'SASL mechanism', + ), + items: const [ + DropdownMenuItem( + value: SaslMechanism.plain, + child: Text('PLAIN'), + ), + DropdownMenuItem( + value: SaslMechanism.scramSha256, + child: Text('SCRAM-SHA-256'), + ), + ], + onChanged: (value) { + if (value == null) { + return; + } + setState(() => _saslMechanism = value); + }, + ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, @@ -158,6 +189,13 @@ class _NetworkFormScreenState extends State { value: _useTls, onChanged: (value) => setState(() => _useTls = value), ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Auto connect'), + subtitle: const Text('Start this network automatically on app launch.'), + value: _autoConnect, + onChanged: (value) => setState(() => _autoConnect = value), + ), const SizedBox(height: 24), FilledButton( onPressed: _submit, @@ -192,6 +230,8 @@ class _NetworkFormScreenState extends State { nickname: _nicknameController.text.trim(), altNickname: _altNicknameController.text.trim(), useTls: _useTls, + autoConnect: _autoConnect, + saslMechanism: _saslMechanism, saslAccount: _saslAccountController.text.trim(), saslPassword: _saslPasswordController.text, ), diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 0e28110..9d41924 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -1,4 +1,6 @@ import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/models/connection_state.dart'; +import 'package:androidircx/features/chat/application/session_registry.dart'; import 'package:androidircx/features/chat/presentation/chat_screen.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/connections/presentation/network_form_screen.dart'; @@ -9,14 +11,16 @@ class NetworkListScreen extends StatelessWidget { const NetworkListScreen({ super.key, required this.controller, + required this.sessionRegistry, }); final NetworkListController controller; + final SessionRegistry sessionRegistry; @override Widget build(BuildContext context) { return AnimatedBuilder( - animation: controller, + animation: Listenable.merge([controller, sessionRegistry]), builder: (context, _) { return Scaffold( appBar: AppBar( @@ -41,14 +45,32 @@ class NetworkListScreen extends StatelessWidget { ? _EmptyState(onAddNetwork: () => _openForm(context)) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - itemCount: controller.networks.length, + itemCount: controller.networks.length + 1, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, index) { - final network = controller.networks[index]; + if (index == 0) { + return _ActiveSessionsCard( + registry: sessionRegistry, + onOpen: (network) => _openChat(context, network), + onClose: sessionRegistry.closeSession, + ); + } + + final network = controller.networks[index - 1]; + final snapshot = + sessionRegistry.connectionFor(network.id); + final currentNick = + sessionRegistry.currentNickFor(network.id); return _NetworkCard( network: network, + connection: snapshot, + hasSession: sessionRegistry.hasSession(network.id), + currentNick: currentNick, onEdit: () => _openForm(context, initialValue: network), - onDelete: () => controller.deleteNetwork(network.id), + onDelete: () async { + await sessionRegistry.closeSession(network.id); + await controller.deleteNetwork(network.id); + }, onConnect: () => _openChat(context, network), ); }, @@ -80,6 +102,8 @@ class NetworkListScreen extends StatelessWidget { nickname: result.nickname, altNickname: result.altNickname, useTls: result.useTls, + autoConnect: result.autoConnect, + saslMechanism: result.saslMechanism, saslAccount: result.saslAccount, saslPassword: result.saslPassword, networkId: initialValue?.id, @@ -87,9 +111,10 @@ class NetworkListScreen extends StatelessWidget { } Future _openChat(BuildContext context, NetworkConfig network) async { + final session = sessionRegistry.obtainSession(network); await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChatScreen(network: network), + builder: (_) => ChatScreen(controller: session), ), ); } @@ -106,12 +131,18 @@ class NetworkListScreen extends StatelessWidget { class _NetworkCard extends StatelessWidget { const _NetworkCard({ required this.network, + required this.connection, + required this.hasSession, + required this.currentNick, required this.onEdit, required this.onDelete, required this.onConnect, }); final NetworkConfig network; + final ConnectionSnapshot connection; + final bool hasSession; + final String? currentNick; final VoidCallback onEdit; final VoidCallback onDelete; final VoidCallback onConnect; @@ -163,17 +194,150 @@ class _NetworkCard extends StatelessWidget { 'Nick: ${network.nickname} / ${network.altNickname ?? '${network.nickname}_'} • ${network.useTls ? 'TLS' : 'Plain TCP'}', style: theme.textTheme.bodySmall, ), + if (network.autoConnect) ...[ + const SizedBox(height: 4), + Text( + 'Auto connect enabled', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], + if (hasSession) ...[ + const SizedBox(height: 4), + Text( + 'Session: ${_statusLabel(connection.phase)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + if ((currentNick ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'Active nick: $currentNick', + style: theme.textTheme.bodySmall, + ), + ], + ], const SizedBox(height: 16), FilledButton.icon( onPressed: onConnect, - icon: const Icon(Icons.wifi_tethering), - label: const Text('Connect'), + icon: Icon(hasSession ? Icons.forum_outlined : Icons.wifi_tethering), + label: Text(hasSession ? 'Open session' : 'Connect'), + ), + ], + ), + ), + ); + } + + String _statusLabel(ConnectionPhase phase) { + switch (phase) { + case ConnectionPhase.idle: + return 'Idle'; + case ConnectionPhase.connecting: + return 'Connecting'; + case ConnectionPhase.connected: + return 'Connected'; + case ConnectionPhase.disconnecting: + return 'Disconnecting'; + case ConnectionPhase.disconnected: + return 'Disconnected'; + case ConnectionPhase.error: + return 'Error'; + } + } +} + +class _ActiveSessionsCard extends StatelessWidget { + const _ActiveSessionsCard({ + required this.registry, + required this.onOpen, + required this.onClose, + }); + + final SessionRegistry registry; + final ValueChanged onOpen; + final Future Function(String networkId) onClose; + + @override + Widget build(BuildContext context) { + final sessions = registry.sessions; + if (sessions.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active sessions', + style: theme.textTheme.titleMedium, ), + const SizedBox(height: 12), + for (final session in sessions) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + _iconFor(session.connection.phase), + color: theme.colorScheme.primary, + ), + title: Text(session.network.name), + subtitle: Text( + '${session.network.host}:${session.network.port} • ${_labelFor(session.connection.phase)}', + ), + trailing: IconButton( + onPressed: () => onClose(session.network.id), + icon: const Icon(Icons.close), + tooltip: 'Close session', + ), + onTap: () => onOpen(session.network), + ), + if (session != sessions.last) const Divider(height: 1), + ], ], ), ), ); } + + String _labelFor(ConnectionPhase phase) { + switch (phase) { + case ConnectionPhase.idle: + return 'Idle'; + case ConnectionPhase.connecting: + return 'Connecting'; + case ConnectionPhase.connected: + return 'Connected'; + case ConnectionPhase.disconnecting: + return 'Disconnecting'; + case ConnectionPhase.disconnected: + return 'Disconnected'; + case ConnectionPhase.error: + return 'Error'; + } + } + + IconData _iconFor(ConnectionPhase phase) { + switch (phase) { + case ConnectionPhase.idle: + return Icons.pause_circle_outline; + case ConnectionPhase.connecting: + return Icons.sync; + case ConnectionPhase.connected: + return Icons.check_circle_outline; + case ConnectionPhase.disconnecting: + return Icons.link_off; + case ConnectionPhase.disconnected: + return Icons.portable_wifi_off; + case ConnectionPhase.error: + return Icons.error_outline; + } + } } class _EmptyState extends StatelessWidget { diff --git a/lib/irc/sasl/scram_sha256_session.dart b/lib/irc/sasl/scram_sha256_session.dart new file mode 100644 index 0000000..c18a0e0 --- /dev/null +++ b/lib/irc/sasl/scram_sha256_session.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class ScramSha256Session { + ScramSha256Session({ + required this.username, + required this.password, + String Function()? nonceGenerator, + }) : _nonceGenerator = nonceGenerator ?? _defaultNonceGenerator; + + final String username; + final String password; + final String Function() _nonceGenerator; + String? get expectedServerSignature => _expectedServerSignature; + + String? _clientFirstBare; + String? _expectedServerSignature; + + String createClientFirstMessage() { + final nonce = _nonceGenerator(); + _clientFirstBare = 'n=${_escape(username)},r=$nonce'; + return 'n,,$_clientFirstBare'; + } + + String createClientFinalMessage(String serverFirstMessage) { + final attributes = _parseAttributes(serverFirstMessage); + final nonce = attributes['r']; + final salt = attributes['s']; + final iterationText = attributes['i']; + final clientFirstBare = _clientFirstBare; + if (nonce == null || + salt == null || + iterationText == null || + clientFirstBare == null) { + throw const FormatException('Invalid SCRAM server-first message.'); + } + + if (!nonce.startsWith(_parseAttributes(clientFirstBare)['r']!)) { + throw const FormatException('SCRAM nonce mismatch.'); + } + + final iterations = int.tryParse(iterationText); + if (iterations == null || iterations <= 0) { + throw const FormatException('Invalid SCRAM iteration count.'); + } + + final clientFinalWithoutProof = 'c=biws,r=$nonce'; + final authMessage = + '$clientFirstBare,$serverFirstMessage,$clientFinalWithoutProof'; + final saltedPassword = _pbkdf2Sha256( + utf8.encode(password), + base64.decode(salt), + iterations, + ); + final clientKey = _hmacSha256(saltedPassword, utf8.encode('Client Key')); + final storedKey = sha256.convert(clientKey).bytes; + final clientSignature = + _hmacSha256(storedKey, utf8.encode(authMessage)); + final clientProof = _xor(clientKey, clientSignature); + final serverKey = _hmacSha256(saltedPassword, utf8.encode('Server Key')); + final serverSignature = + _hmacSha256(serverKey, utf8.encode(authMessage)); + _expectedServerSignature = base64.encode(serverSignature); + + return '$clientFinalWithoutProof,p=${base64.encode(clientProof)}'; + } + + bool validateServerFinalMessage(String serverFinalMessage) { + final expected = _expectedServerSignature; + if (expected == null) { + return false; + } + + final attributes = _parseAttributes(serverFinalMessage); + final verification = attributes['v']; + return verification != null && verification == expected; + } + + static String _defaultNonceGenerator() { + final random = Random.secure(); + final bytes = + List.generate(18, (_) => random.nextInt(256), growable: false); + return base64.encode(bytes).replaceAll('=', ''); + } + + static String _escape(String input) { + return input.replaceAll('=', '=3D').replaceAll(',', '=2C'); + } + + static Map _parseAttributes(String message) { + final attributes = {}; + for (final part in message.split(',')) { + if (part.length < 3 || part[1] != '=') { + continue; + } + attributes[part[0]] = part.substring(2); + } + return attributes; + } + + static List _hmacSha256(List key, List data) { + return Hmac(sha256, key).convert(data).bytes; + } + + static List _pbkdf2Sha256( + List password, + List salt, + int iterations, + ) { + final blockIndex = Uint8List.fromList([ + ...salt, + 0, + 0, + 0, + 1, + ]); + var u = _hmacSha256(password, blockIndex); + final output = Uint8List.fromList(u); + for (var i = 1; i < iterations; i++) { + u = _hmacSha256(password, u); + for (var j = 0; j < output.length; j++) { + output[j] ^= u[j]; + } + } + return output; + } + + static List _xor(List left, List right) { + return List.generate( + left.length, + (index) => left[index] ^ right[index], + growable: false, + ); + } +} diff --git a/lib/irc/services/irc_service.dart b/lib/irc/services/irc_service.dart index 75cd450..856ec74 100644 --- a/lib/irc/services/irc_service.dart +++ b/lib/irc/services/irc_service.dart @@ -5,6 +5,7 @@ import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/irc/models/irc_message_frame.dart'; import 'package:androidircx/irc/parser/irc_message_parser.dart'; +import 'package:androidircx/irc/sasl/scram_sha256_session.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; typedef IrcTransportConnector = Future Function(NetworkConfig network); @@ -12,13 +13,16 @@ typedef IrcTransportConnector = Future Function(NetworkConfig netw class IrcService { IrcService({ IrcTransportConnector? transportConnector, + String Function()? scramNonceGenerator, }) : _transportConnector = transportConnector ?? SocketIrcTransport.connect, + _scramNonceGenerator = scramNonceGenerator, _state = const ConnectionSnapshot( networkId: '', phase: ConnectionPhase.idle, ); final IrcTransportConnector _transportConnector; + final String Function()? _scramNonceGenerator; final StreamController _rawEventsController = StreamController.broadcast(); final StreamController _framesController = @@ -35,13 +39,18 @@ class IrcService { bool _capNegotiationActive = false; bool _capEnded = false; bool _saslInProgress = false; + SaslMechanism? _activeSaslMechanism; NetworkConfig? _network; String? _primaryNick; String? _altNickBase; int _altNickAttempt = 0; + ScramSha256Session? _scramSession; + bool _scramAwaitingServerFinal = false; ConnectionSnapshot get state => _state; String? get currentNick => _currentNick; + Set get enabledCapabilities => Set.unmodifiable(_capEnabled); + Set get availableCapabilities => Set.unmodifiable(_capAvailable); Stream get rawEvents => _rawEventsController.stream; Stream get frames => _framesController.stream; Stream get stateStream => _stateController.stream; @@ -62,6 +71,9 @@ class IrcService { _capNegotiationActive = false; _capEnded = false; _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; _updateState( ConnectionSnapshot( networkId: network.id, @@ -233,6 +245,9 @@ class IrcService { if (frame.command == '903') { _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; _rawEventsController.add('** SASL authentication successful'); unawaited(_endCapNegotiation()); return; @@ -243,6 +258,9 @@ class IrcService { frame.command == '906' || frame.command == '907') { _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; _rawEventsController.add('** SASL authentication failed'); unawaited(_endCapNegotiation()); return; @@ -294,12 +312,7 @@ class IrcService { ...rest.where((item) => item != '*'), if (trailing.isNotEmpty) trailing, ].join(' '); - for (final cap in capabilities.split(RegExp(r'\s+'))) { - final name = cap.split('=').first.trim(); - if (name.isNotEmpty) { - _capAvailable.add(name); - } - } + _capAvailable.addAll(_parseCapabilityNames(capabilities)); final isLast = !rest.contains('*'); if (isLast) { if (_capAvailable.contains('sasl') && _shouldUseSasl(_network)) { @@ -310,18 +323,48 @@ class IrcService { } case 'ACK': final ackSource = [...rest, if (trailing.isNotEmpty) trailing].join(' '); - for (final cap in ackSource.split(RegExp(r'\s+'))) { - final name = cap.split('=').first.trim(); - if (name.isNotEmpty) { - _capEnabled.add(name); - } - } + _capEnabled.addAll(_parseCapabilityNames(ackSource)); if (_capEnabled.contains('sasl') && _shouldUseSasl(_network)) { _saslInProgress = true; - unawaited(sendRaw('AUTHENTICATE PLAIN')); + final mechanism = _network?.saslMechanism ?? SaslMechanism.plain; + _activeSaslMechanism = mechanism; + if (mechanism == SaslMechanism.scramSha256) { + final network = _network; + if (network != null) { + _scramSession = ScramSha256Session( + username: network.saslAccount!, + password: network.saslPassword!, + nonceGenerator: _scramNonceGenerator, + ); + } + unawaited(sendRaw('AUTHENTICATE SCRAM-SHA-256')); + } else { + unawaited(sendRaw('AUTHENTICATE PLAIN')); + } } else { unawaited(_endCapNegotiation()); } + case 'NEW': + final newCaps = [...rest, if (trailing.isNotEmpty) trailing].join(' '); + final names = _parseCapabilityNames(newCaps); + _capAvailable.addAll(names); + if (names.isNotEmpty) { + _rawEventsController.add( + '** CAP NEW: ${names.toList(growable: false)..sort()}', + ); + } + case 'DEL': + final removedCaps = [...rest, if (trailing.isNotEmpty) trailing].join(' '); + final names = _parseCapabilityNames(removedCaps); + for (final name in names) { + _capAvailable.remove(name); + _capEnabled.remove(name); + } + if (names.isNotEmpty) { + _rawEventsController.add( + '** CAP DEL: ${names.toList(growable: false)..sort()}', + ); + } case 'NAK': unawaited(_endCapNegotiation()); default: @@ -335,12 +378,18 @@ class IrcService { } final payload = frame.params.isNotEmpty ? frame.params.first : frame.trailing; - if (payload != '+') { + final network = _network; + if (network == null) { return; } - final network = _network; - if (network == null) { + final mechanism = _activeSaslMechanism ?? network.saslMechanism; + if (mechanism == SaslMechanism.scramSha256) { + _handleScramAuthenticate(payload); + return; + } + + if (payload != '+') { return; } @@ -364,12 +413,75 @@ class IrcService { } } + void _handleScramAuthenticate(String? payload) { + final session = _scramSession; + if (session == null || payload == null) { + return; + } + + try { + if (payload == '+') { + final clientFirst = session.createClientFirstMessage(); + _sendAuthenticatePayload(clientFirst); + return; + } + + final decoded = utf8.decode(base64.decode(payload)); + if (!_scramAwaitingServerFinal) { + final clientFinal = session.createClientFinalMessage(decoded); + _scramAwaitingServerFinal = true; + _sendAuthenticatePayload(clientFinal); + return; + } + + if (!session.validateServerFinalMessage(decoded)) { + _rawEventsController.add('** SASL SCRAM verification failed'); + unawaited(_abortSasl()); + return; + } + + _rawEventsController.add('** SASL SCRAM server signature verified'); + } on FormatException catch (error) { + _rawEventsController.add('** SASL SCRAM error: ${error.message}'); + unawaited(_abortSasl()); + } + } + + void _sendAuthenticatePayload(String message) { + final encoded = base64.encode(utf8.encode(message)); + final chunks = []; + for (var i = 0; i < encoded.length; i += 400) { + chunks.add( + encoded.substring(i, i + 400 > encoded.length ? encoded.length : i + 400), + ); + } + + for (final chunk in chunks) { + unawaited(sendRaw('AUTHENTICATE $chunk')); + } + if (encoded.length % 400 == 0) { + unawaited(sendRaw('AUTHENTICATE +')); + } + } + + Future _abortSasl() async { + _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; + await sendRaw('AUTHENTICATE *'); + await _endCapNegotiation(); + } + Future _endCapNegotiation() async { if (_capEnded || !_capNegotiationActive) { return; } _capEnded = true; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; await sendRaw('CAP END'); } @@ -475,4 +587,15 @@ class IrcService { _altNickAttempt += 1; return '$altBase$_altNickAttempt'; } + + Set _parseCapabilityNames(String source) { + final names = {}; + for (final cap in source.split(RegExp(r'\s+'))) { + final name = cap.split('=').first.trim(); + if (name.isNotEmpty) { + names.add(name); + } + } + return names; + } } diff --git a/pubspec.lock b/pubspec.lock index 7692e33..7a3b780 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -309,6 +317,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cf237a8..6ed2e77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + crypto: ^3.0.6 shared_preferences: ^2.5.3 dev_dependencies: diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart new file mode 100644 index 0000000..e6d7b29 --- /dev/null +++ b/test/chat_session_controller_test.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = StreamController.broadcast(); + final List sentLines = []; + + @override + Stream get lines => _controller.stream; + + void emit(String line) { + _controller.add(line); + } + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendLine(String line) async { + sentLines.add(line); + } +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('routes SASL/auth numerics into server messages', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + saslAccount: 'alice', + saslPassword: 'secret', + ), + ircService: service, + ); + + await controller.start(); + transport.emit(':server 900 AndroidIRCX alice!ident@example :You are now logged in as alice'); + transport.emit(':server 903 AndroidIRCX :SASL authentication successful'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect( + controller.activeMessages.any( + (message) => message.content.contains('logged in as alice'), + ), + isTrue, + ); + expect( + controller.activeMessages.any( + (message) => message.content.contains('SASL authentication successful'), + ), + isTrue, + ); + + controller.dispose(); + }); +} diff --git a/test/irc_service_sasl_test.dart b/test/irc_service_sasl_test.dart index b9b2d62..a5a53db 100644 --- a/test/irc_service_sasl_test.dart +++ b/test/irc_service_sasl_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/sasl/scram_sha256_session.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -99,4 +101,107 @@ void main() { service.dispose(); }); + + test('starts SCRAM-SHA-256 authentication when configured', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + scramNonceGenerator: () => 'fixedNonce', + ); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + saslAccount: 'alice', + saslPassword: 'secret', + saslMechanism: SaslMechanism.scramSha256, + ), + ); + + transport.emit(':server CAP * LS :multi-prefix sasl'); + await Future.delayed(Duration.zero); + transport.emit(':server CAP * ACK :sasl'); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('AUTHENTICATE SCRAM-SHA-256')); + + transport.emit('AUTHENTICATE +'); + await Future.delayed(Duration.zero); + final clientFirstLine = transport.sentLines.last; + expect(clientFirstLine, startsWith('AUTHENTICATE ')); + final clientFirst = + utf8.decode(base64.decode(clientFirstLine.substring('AUTHENTICATE '.length))); + expect(clientFirst, 'n,,n=alice,r=fixedNonce'); + + final verifier = ScramSha256Session( + username: 'alice', + password: 'secret', + nonceGenerator: () => 'fixedNonce', + ); + verifier.createClientFirstMessage(); + verifier.createClientFinalMessage('r=fixedNonceServer,s=c2FsdHlTYWx0,i=4096'); + + transport.emit( + 'AUTHENTICATE ${base64.encode(utf8.encode('r=fixedNonceServer,s=c2FsdHlTYWx0,i=4096'))}', + ); + await Future.delayed(Duration.zero); + final clientFinalLine = transport.sentLines.last; + final clientFinal = + utf8.decode(base64.decode(clientFinalLine.substring('AUTHENTICATE '.length))); + expect(clientFinal, startsWith('c=biws,r=fixedNonceServer,p=')); + + transport.emit( + 'AUTHENTICATE ${base64.encode(utf8.encode('v=${verifier.expectedServerSignature}'))}', + ); + await Future.delayed(Duration.zero); + + transport.emit(':server 903 AndroidIRCX :SASL authentication successful'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('CAP END')); + + service.dispose(); + }); + + test('tracks CAP NEW and DEL updates after registration', () async { + final transport = _FakeTransport(); + final service = IrcService( + transportConnector: (_) async => transport, + ); + final rawEvents = []; + final subscription = service.rawEvents.listen(rawEvents.add); + + await service.connect( + const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ); + + transport.emit(':server CAP * NEW :draft/labeled-response echo-message'); + await Future.delayed(Duration.zero); + expect(service.availableCapabilities, contains('draft/labeled-response')); + expect(service.availableCapabilities, contains('echo-message')); + + transport.emit(':server CAP * DEL :echo-message'); + await Future.delayed(Duration.zero); + expect(service.availableCapabilities, isNot(contains('echo-message'))); + expect( + rawEvents.any((event) => event.contains('CAP NEW')), + isTrue, + ); + expect( + rawEvents.any((event) => event.contains('CAP DEL')), + isTrue, + ); + + await subscription.cancel(); + service.dispose(); + }); } diff --git a/test/scram_sha256_session_test.dart b/test/scram_sha256_session_test.dart new file mode 100644 index 0000000..05defdb --- /dev/null +++ b/test/scram_sha256_session_test.dart @@ -0,0 +1,24 @@ +import 'package:androidircx/irc/sasl/scram_sha256_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('builds and validates SCRAM-SHA-256 exchange', () { + final session = ScramSha256Session( + username: 'alice', + password: 'secret', + nonceGenerator: () => 'fixedNonce', + ); + + final clientFirst = session.createClientFirstMessage(); + expect(clientFirst, 'n,,n=alice,r=fixedNonce'); + + final clientFinal = session.createClientFinalMessage( + 'r=fixedNonceServer,s=c2FsdHlTYWx0,i=4096', + ); + expect(clientFinal, startsWith('c=biws,r=fixedNonceServer,p=')); + + final serverFinal = 'v=${session.expectedServerSignature}'; + expect(session.validateServerFinalMessage(serverFinal), isTrue); + expect(session.validateServerFinalMessage('v=invalid'), isFalse); + }); +} diff --git a/test/session_registry_test.dart b/test/session_registry_test.dart new file mode 100644 index 0000000..c3e6a1c --- /dev/null +++ b/test/session_registry_test.dart @@ -0,0 +1,50 @@ +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/session_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('reuses existing session per network id', () { + final registry = SessionRegistry(); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + + final first = registry.obtainSession(network); + final second = registry.obtainSession(network); + + expect(identical(first, second), isTrue); + expect(registry.sessions, hasLength(1)); + + registry.dispose(); + }); + + test('closeSession removes controller from registry', () async { + final registry = SessionRegistry(); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + + registry.obtainSession(network); + await registry.closeSession(network.id); + + expect(registry.hasSession(network.id), isFalse); + expect(registry.sessions, isEmpty); + + registry.dispose(); + }); +} diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index a83b322..b1b87ad 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -33,13 +33,20 @@ void main() { host: 'irc.test.net', port: 6667, nickname: 'tester', + altNickname: 'tester_', useTls: false, + saslMechanism: SaslMechanism.scramSha256, + autoConnect: true, ), ); final networks = await repository.loadNetworks(); expect(networks.any((item) => item.id == 'testnet'), isTrue); + final saved = networks.firstWhere((item) => item.id == 'testnet'); + expect(saved.autoConnect, isTrue); + expect(saved.altNickname, 'tester_'); + expect(saved.saslMechanism, SaslMechanism.scramSha256); }); test('settings repository saves and loads showRawEvents', () async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 324b9ce..f9165aa 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,4 +1,10 @@ import 'package:androidircx/app/app.dart'; +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/storage/in_memory_network_repository.dart'; +import 'package:androidircx/features/chat/application/session_registry.dart'; +import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/features/connections/presentation/network_list_screen.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,4 +20,44 @@ void main() { expect(find.text('DBase'), findsOneWidget); expect(find.text('Connect'), findsOneWidget); }); + + testWidgets('shows active sessions and auto-connect labels in network list', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + autoConnect: true, + ); + final controller = NetworkListController( + repository: InMemoryNetworkRepository(const [network]), + ); + final registry = SessionRegistry(); + + await controller.load(); + registry.obtainSession(network); + + await tester.pumpWidget( + MaterialApp( + home: NetworkListScreen( + controller: controller, + sessionRegistry: registry, + ), + ), + ); + await tester.pump(); + + expect(find.text('Active sessions'), findsOneWidget); + expect(find.text('Auto connect enabled'), findsOneWidget); + expect(find.text('Open session'), findsOneWidget); + expect(find.text('Active nick: AndroidIRCX'), findsOneWidget); + + registry.dispose(); + controller.dispose(); + }); }