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..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, @@ -5,10 +10,14 @@ 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.saslMechanism = SaslMechanism.plain, this.autoConnect = false, }); @@ -17,10 +26,14 @@ 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 SaslMechanism saslMechanism; final bool autoConnect; NetworkConfig copyWith({ @@ -29,10 +42,14 @@ class NetworkConfig { String? host, int? port, String? nickname, + String? altNickname, String? username, String? realName, bool? useTls, String? password, + String? saslAccount, + String? saslPassword, + SaslMechanism? saslMechanism, bool? autoConnect, }) { return NetworkConfig( @@ -41,10 +58,14 @@ 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, + saslMechanism: saslMechanism ?? this.saslMechanism, autoConnect: autoConnect ?? this.autoConnect, ); } @@ -56,10 +77,14 @@ class NetworkConfig { 'host': host, 'port': port, 'nickname': nickname, + 'altNickname': altNickname, 'username': username, 'realName': realName, 'useTls': useTls, 'password': password, + 'saslAccount': saslAccount, + 'saslPassword': saslPassword, + 'saslMechanism': saslMechanism.name, 'autoConnect': autoConnect, }; } @@ -71,10 +96,16 @@ 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?, + saslMechanism: json['saslMechanism'] == null + ? SaslMechanism.plain + : SaslMechanism.values.byName(json['saslMechanism']! 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/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 1c279ee..129766a 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,35 @@ 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 currentNick => _ircService.currentNick ?? network.nickname; + 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 +107,7 @@ class ChatSessionController extends ChangeNotifier { Future start() async { if (!_isBootstrapped) { + await _commandService.load(); await _loadPersistedState(); _isBootstrapped = true; notifyListeners(); @@ -105,18 +139,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 +277,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 +307,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 +335,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 +429,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 +437,145 @@ 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 '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, + '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 +590,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 +600,7 @@ class ChatSessionController extends ChangeNotifier { ); } case 'QUIT': + _removeUserFromAllChannels(frame.senderNick); _appendMessage( tabId: _serverTabId(network.id), sender: '*', @@ -331,6 +609,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 +622,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 +670,7 @@ class ChatSessionController extends ChangeNotifier { sender: frame.senderNick ?? 'notice', content: content, ); + _markActivityIfInactive(tabId); } void _handlePrivmsg(IrcMessageFrame frame) { @@ -392,6 +690,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 +753,8 @@ class ChatSessionController extends ChangeNotifier { ); _tabs = [..._tabs, tab]; _messages.putIfAbsent(tab.id, () => []); + _channelUsers.putIfAbsent(tab.id, () => {}); + _channelTopics.putIfAbsent(tab.id, () => ''); return tab; } @@ -485,7 +823,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 +852,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/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/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..4dec01f 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'; @@ -10,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(); } @@ -49,12 +49,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), @@ -85,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( @@ -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,58 @@ 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), + _ConnectionBanner( + controller: _controller, + network: _controller.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 +238,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 +296,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, @@ -343,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/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..0af4e5c 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -30,7 +30,12 @@ class NetworkListController extends ChangeNotifier { required String host, required int port, required String nickname, + required String altNickname, required bool useTls, + required bool autoConnect, + required SaslMechanism saslMechanism, + String? saslAccount, + String? saslPassword, String? networkId, }) async { final network = NetworkConfig( @@ -39,7 +44,12 @@ class NetworkListController extends ChangeNotifier { host: host, port: port, nickname: nickname, + altNickname: altNickname.trim(), useTls: useTls, + autoConnect: autoConnect, + saslMechanism: saslMechanism, + 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..6cd7ac5 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -7,14 +7,24 @@ class NetworkFormResult { required this.host, required this.port, required this.nickname, + required this.altNickname, required this.useTls, + required this.autoConnect, + required this.saslMechanism, + this.saslAccount, + this.saslPassword, }); final String name; final String host; final int port; final String nickname; + final String altNickname; final bool useTls; + final bool autoConnect; + final SaslMechanism saslMechanism; + final String? saslAccount; + final String? saslPassword; } class NetworkFormScreen extends StatefulWidget { @@ -35,7 +45,12 @@ 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; + late bool _autoConnect; + late SaslMechanism _saslMechanism; @override void initState() { @@ -49,7 +64,14 @@ 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; + _autoConnect = initial?.autoConnect ?? false; + _saslMechanism = initial?.saslMechanism ?? SaslMechanism.plain; } @override @@ -58,6 +80,9 @@ class _NetworkFormScreenState extends State { _hostController.dispose(); _portController.dispose(); _nicknameController.dispose(); + _altNicknameController.dispose(); + _saslAccountController.dispose(); + _saslPasswordController.dispose(); super.dispose(); } @@ -108,6 +133,54 @@ 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: 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, @@ -116,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, @@ -148,7 +228,12 @@ class _NetworkFormScreenState extends State { host: _hostController.text.trim(), port: int.parse(_portController.text.trim()), 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 48596da..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), ); }, @@ -78,15 +100,21 @@ class NetworkListScreen extends StatelessWidget { host: result.host, port: result.port, nickname: result.nickname, + altNickname: result.altNickname, useTls: result.useTls, + autoConnect: result.autoConnect, + saslMechanism: result.saslMechanism, + saslAccount: result.saslAccount, + saslPassword: result.saslPassword, networkId: initialValue?.id, ); } 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), ), ); } @@ -103,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; @@ -157,20 +191,153 @@ 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, ), + 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 f7123b7..856ec74 100644 --- a/lib/irc/services/irc_service.dart +++ b/lib/irc/services/irc_service.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:convert'; 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); @@ -11,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 = @@ -29,9 +34,23 @@ class IrcService { StreamSubscription? _linesSubscription; ConnectionSnapshot _state; String? _currentNick; + final Set _capAvailable = {}; + final Set _capEnabled = {}; + 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; @@ -42,7 +61,19 @@ 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; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; _updateState( ConnectionSnapshot( networkId: network.id, @@ -59,10 +90,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 +159,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 +233,41 @@ class IrcService { return; } + if (frame.command == 'CAP') { + _handleCap(frame); + return; + } + + if (frame.command == 'AUTHENTICATE') { + _handleAuthenticate(frame); + return; + } + + if (frame.command == '903') { + _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; + _rawEventsController.add('** SASL authentication successful'); + unawaited(_endCapNegotiation()); + return; + } + + if (frame.command == '904' || + frame.command == '905' || + frame.command == '906' || + frame.command == '907') { + _saslInProgress = false; + _activeSaslMechanism = null; + _scramSession = null; + _scramAwaitingServerFinal = false; + _rawEventsController.add('** SASL authentication failed'); + unawaited(_endCapNegotiation()); + return; + } + if (frame.command == '001') { + _altNickAttempt = 0; _updateState( ConnectionSnapshot( networkId: _state.networkId, @@ -160,6 +285,213 @@ 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(' '); + _capAvailable.addAll(_parseCapabilityNames(capabilities)); + 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(' '); + _capEnabled.addAll(_parseCapabilityNames(ackSource)); + if (_capEnabled.contains('sasl') && _shouldUseSasl(_network)) { + _saslInProgress = true; + 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: + break; + } + } + + void _handleAuthenticate(IrcMessageFrame frame) { + if (!_saslInProgress) { + return; + } + + final payload = frame.params.isNotEmpty ? frame.params.first : frame.trailing; + final network = _network; + if (network == null) { + return; + } + + final mechanism = _activeSaslMechanism ?? network.saslMechanism; + if (mechanism == SaslMechanism.scramSha256) { + _handleScramAuthenticate(payload); + return; + } + + if (payload != '+') { + 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 +')); + } + } + + 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'); + } + + bool _shouldUseSasl(NetworkConfig? network) { + if (network == null) { + return false; + } + + return (network.saslAccount ?? '').isNotEmpty && + (network.saslPassword ?? '').isNotEmpty; } void _handleTransportDone() { @@ -203,4 +535,67 @@ 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'; + } + + 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/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..a5a53db --- /dev/null +++ b/test/irc_service_sasl_test.dart @@ -0,0 +1,207 @@ +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'; + +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(); + }); + + 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 e6f9a09..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 { @@ -83,5 +90,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)); + }); }); } 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(); + }); }