diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bb997b..3b37123 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,8 @@ Thanks for your interest in contributing to AndroidIRCX. ## Code Style -- Use TypeScript for new code. -- Prefer small, focused services and components. +- Use Dart and Flutter for new code. +- Prefer small, focused services, controllers, repositories, and widgets. - Keep changes scoped and avoid unrelated refactors. ## Reporting Bugs @@ -32,4 +32,4 @@ Please include: ## License -By contributing, you agree that your contributions will be licensed under the GPL-3.0. \ No newline at end of file +By contributing, you agree that your contributions will be licensed under the GPL-3.0. diff --git a/README.md b/README.md index f09f4e8..243f79c 100644 --- a/README.md +++ b/README.md @@ -20,39 +20,32 @@ existing React Native application. ## Current Status -This repository is an active rewrite in progress. +This repository is an active Flutter rewrite in release hardening. Implemented in the current Flutter app: -- network list and add/edit flow -- basic IRC socket connection lifecycle -- server, channel, and query tabs -- basic message sending and receiving -- local persistence for networks, tabs, message history, and a small settings set -- reconnect banner and basic connection status UX +- network list, server directory, add/edit flow, identity profiles, and in-chat network switching +- TCP/TLS IRC transport, IRCv3 WebSocket transport, reconnect/backoff, and foreground-service integration +- IRC parser, ISUPPORT, numerics, CAP 302, SASL PLAIN/SCRAM-SHA-256/EXTERNAL, CTCP, and IRCv3 message features +- server, channel, query, notice, and DCC tabs with command suggestions and rich channel user actions +- encrypted local message history with retention/search/export support +- DCC CHAT/SEND, safe file picking, transfer progress, media cards, previews, downloads, camera/gallery capture, and in-app audio/video playback +- onboarding, consent/privacy screens, app lock, notification/display/writing settings, themes, backup/restore, ignore lists, auto-away/highlights/sounds, auto-rejoin, diagnostics, and crash reporting -Not implemented yet in full parity: +Still outside the default-client release scope: -- CAP/SASL full flow -- multi-network orchestration parity -- advanced numerics and command coverage -- notifications/background behavior -- encryption -- DCC/media -- monetization and security platform features - -The feature list below describes the target AndroidIRCx parity scope, not the current Flutter -implementation status. +- E2EE, ads/IAP, and scripting are later product/security decisions +- WebRTC calling is not planned +- upload/share endpoints are deferred until a concrete product endpoint exists ## 🔐 Security - **TLS/SSL** -- full encrypted connection support - **SASL** -- PLAIN, SCRAM-SHA-256, EXTERNAL (client certificates) -- **E2E Encryption** -- libsodium XChaCha20-Poly1305 with context-bound AAD -- **Secure Storage** -- device Keychain for secrets (AsyncStorage fallback with warning) +- **Encrypted History** -- local message bodies encrypted with AES-256-GCM +- **Secure Storage** -- platform-backed storage for server passwords, SASL secrets, channel keys, and certificates - **App Lock** -- PIN and biometric with auto-lock on background/launch -- **Kill Switch** -- emergency disconnect and optional data wipe -- **Play Integrity** -- Google Play Integrity verification +- **Crash Reports** -- sanitized on-device reports with secret redaction ## 🤝 Contributing diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51af3d4..ca15fd6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ - requestNotifications(); Future hasNotifications(); - Future requestCamera(); - Future hasCamera(); /// Opens the OS app-settings page (used after a permanent denial). Future openSettingsPage(); @@ -38,14 +36,6 @@ class PermissionHandlerAppPermissions implements AppPermissions { Future hasNotifications() async => (await Permission.notification.status).isGranted; - @override - Future requestCamera() async => - _map(await Permission.camera.request()); - - @override - Future hasCamera() async => - (await Permission.camera.status).isGranted; - @override Future openSettingsPage() async { await openAppSettings(); diff --git a/lib/core/security/device_unlock_session.dart b/lib/core/security/device_unlock_session.dart new file mode 100644 index 0000000..678ff95 --- /dev/null +++ b/lib/core/security/device_unlock_session.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:local_auth/local_auth.dart'; + +typedef DeviceUnlockPrompt = Future Function(String reason); + +/// Coalesces biometric/PIN prompts and allows a short reuse window after a +/// successful device unlock. +class DeviceUnlockSession { + DeviceUnlockSession({ + DeviceUnlockPrompt? prompt, + Duration reuseWindow = const Duration(seconds: 30), + DateTime Function()? now, + }) : _prompt = prompt ?? _defaultPrompt, + _reuseWindow = reuseWindow, + _now = now ?? DateTime.now; + + static final DeviceUnlockSession instance = DeviceUnlockSession(); + + final DeviceUnlockPrompt _prompt; + final Duration _reuseWindow; + final DateTime Function() _now; + DateTime? _lastSuccessAt; + Future? _inFlight; + + Future authenticate({required String reason}) { + final lastSuccessAt = _lastSuccessAt; + final now = _now(); + if (lastSuccessAt != null && + now.difference(lastSuccessAt).abs() <= _reuseWindow) { + return Future.value(true); + } + + final existing = _inFlight; + if (existing != null) { + return existing; + } + + final next = _prompt(reason).then((unlocked) { + if (unlocked) { + _lastSuccessAt = _now(); + } + return unlocked; + }); + _inFlight = next; + return next.whenComplete(() { + if (identical(_inFlight, next)) { + _inFlight = null; + } + }); + } + + void invalidate() { + _lastSuccessAt = null; + } + + static Future _defaultPrompt(String reason) async { + try { + final auth = LocalAuthentication(); + final supported = + await auth.isDeviceSupported() || await auth.canCheckBiometrics; + if (!supported) { + return false; + } + return await auth.authenticate( + localizedReason: reason, + biometricOnly: false, + persistAcrossBackgrounding: true, + ); + } catch (_) { + return false; + } + } +} diff --git a/lib/core/security/local_auth_history_unlock.dart b/lib/core/security/local_auth_history_unlock.dart index a20b20a..d341c46 100644 --- a/lib/core/security/local_auth_history_unlock.dart +++ b/lib/core/security/local_auth_history_unlock.dart @@ -1,30 +1,40 @@ import 'package:androidircx/core/security/history_encryption_key_manager.dart'; +import 'package:androidircx/core/security/device_unlock_session.dart'; import 'package:local_auth/local_auth.dart'; /// Biometric (+ device PIN/passphrase fallback) gate for the encrypted history /// key, backed by `local_auth`. -class LocalAuthHistoryUnlockAuthenticator implements HistoryUnlockAuthenticator { +class LocalAuthHistoryUnlockAuthenticator + implements HistoryUnlockAuthenticator { LocalAuthHistoryUnlockAuthenticator([LocalAuthentication? auth]) - : _auth = auth ?? LocalAuthentication(); + : _unlockSession = auth == null + ? DeviceUnlockSession.instance + : DeviceUnlockSession(prompt: _promptFor(auth)); - final LocalAuthentication _auth; + final DeviceUnlockSession _unlockSession; @override Future authenticate({required String reason}) async { - try { - final supported = - await _auth.isDeviceSupported() || await _auth.canCheckBiometrics; - if (!supported) { + return _unlockSession.authenticate(reason: reason); + } + + static DeviceUnlockPrompt _promptFor(LocalAuthentication auth) { + return (reason) async { + try { + final supported = + await auth.isDeviceSupported() || await auth.canCheckBiometrics; + if (!supported) { + return false; + } + return await auth.authenticate( + localizedReason: reason, + // biometricOnly: false allows the device PIN/passphrase fallback. + biometricOnly: false, + persistAcrossBackgrounding: true, + ); + } catch (_) { return false; } - return await _auth.authenticate( - localizedReason: reason, - // biometricOnly: false allows the device PIN/passphrase fallback. - biometricOnly: false, - persistAcrossBackgrounding: true, - ); - } catch (_) { - return false; - } + }; } } diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index e75a1f5..9d3b1ad 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -736,8 +736,9 @@ class ChatSessionController extends ChangeNotifier { if (repository != null) { _autoModeEntries = await repository.remove(entry); } else { - _autoModeEntries = - _autoModeEntries.where((e) => e.key != entry.key).toList(); + _autoModeEntries = _autoModeEntries + .where((e) => e.key != entry.key) + .toList(); } notifyListeners(); } @@ -758,7 +759,8 @@ class ChatSessionController extends ChangeNotifier { final ownModes = _channelUserModes[tabId]?[currentNick.trim().toLowerCase()] ?? const {}; - final hasOp = ownModes.contains('o') || + final hasOp = + ownModes.contains('o') || ownModes.contains('q') || ownModes.contains('a'); final hasHalfOp = ownModes.contains('h'); @@ -786,9 +788,7 @@ class ChatSessionController extends ChangeNotifier { UserListType.autoVoice => hasOp || hasHalfOp, }; if (canApply) { - unawaited( - _ircService.sendRaw('MODE $channel +${type.modeChar} $nick'), - ); + unawaited(_ircService.sendRaw('MODE $channel +${type.modeChar} $nick')); return; } } @@ -2671,10 +2671,18 @@ class ChatSessionController extends ChangeNotifier { await _ircService.disconnect(rest.isEmpty ? null : rest); return; case 'autovoice': - await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: false); + await _handleAutoModeCommand( + UserListType.autoVoice, + rest, + remove: false, + ); return; case 'unautovoice': - await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: true); + await _handleAutoModeCommand( + UserListType.autoVoice, + rest, + remove: true, + ); return; case 'autoop': await _handleAutoModeCommand(UserListType.autoOp, rest, remove: false); @@ -2728,13 +2736,18 @@ class ChatSessionController extends ChangeNotifier { } final mask = tokens.first; final channels = tokens.length > 1 - ? tokens[1].split(',').map((c) => c.trim()).where((c) => c.isNotEmpty).toList() + ? tokens[1] + .split(',') + .map((c) => c.trim()) + .where((c) => c.isNotEmpty) + .toList() : const []; if (remove) { - final normalized = UserListEntry(type: type, mask: mask) - .normalizedMask - .toLowerCase(); + final normalized = UserListEntry( + type: type, + mask: mask, + ).normalizedMask.toLowerCase(); final matches = _autoModeEntries .where( (entry) => @@ -2812,8 +2825,9 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } else { - final names = - _commandService.commands.map((command) => '/${command.name}').join(' '); + final names = _commandService.commands + .map((command) => '/${command.name}') + .join(' '); _appendMessage( tabId: activeTab.id, sender: '*', @@ -2851,7 +2865,9 @@ class ChatSessionController extends ChangeNotifier { _appendMessage( tabId: activeTab.id, sender: '*', - content: alreadyIgnored ? 'Already ignoring $mask' : 'Now ignoring $mask', + content: alreadyIgnored + ? 'Already ignoring $mask' + : 'Now ignoring $mask', kind: IrcMessageKind.system, ); } @@ -2911,8 +2927,9 @@ class ChatSessionController extends ChangeNotifier { return; } - final echoEnabled = - _ircService.enabledCapabilities.contains('echo-message'); + final echoEnabled = _ircService.enabledCapabilities.contains( + 'echo-message', + ); for (final tab in channelTabs) { if (asAction) { await _ircService.sendAction(target: tab.name, text: text); @@ -3014,8 +3031,9 @@ class ChatSessionController extends ChangeNotifier { } byHost.putIfAbsent(host, () => []).add(bare); } - final clones = byHost.entries.where((entry) => entry.value.length > 1).toList() - ..sort((a, b) => a.key.compareTo(b.key)); + final clones = + byHost.entries.where((entry) => entry.value.length > 1).toList() + ..sort((a, b) => a.key.compareTo(b.key)); if (clones.isEmpty) { _appendMessage( @@ -3062,18 +3080,18 @@ class ChatSessionController extends ChangeNotifier { return false; } for (final mask in _ignoreMasks) { - if (_matchesIgnoreMask(mask, prefix: frame.prefix, nick: frame.senderNick)) { + if (_matchesIgnoreMask( + mask, + prefix: frame.prefix, + nick: frame.senderNick, + )) { return true; } } return false; } - static bool _matchesIgnoreMask( - String mask, { - String? prefix, - String? nick, - }) { + static bool _matchesIgnoreMask(String mask, {String? prefix, String? nick}) { final hasHostMask = mask.contains('!') || mask.contains('@'); final target = hasHostMask ? (prefix ?? nick) : (nick ?? prefix); if (target == null || target.isEmpty) { @@ -3101,7 +3119,9 @@ class ChatSessionController extends ChangeNotifier { void _handleFilterCommand(String rest) { final trimmed = rest.trim(); // `-g` (global) is accepted for RN parity; filtering here is per-session. - final text = trimmed.startsWith('-g') ? trimmed.substring(2).trim() : trimmed; + final text = trimmed.startsWith('-g') + ? trimmed.substring(2).trim() + : trimmed; if (text.isEmpty) { _appendMessage( tabId: activeTab.id, @@ -3144,8 +3164,9 @@ class ChatSessionController extends ChangeNotifier { _appendMessage( tabId: activeTab.id, sender: '*', - content: - removed ? 'No longer filtering "$text"' : 'Not filtering "$text"', + content: removed + ? 'No longer filtering "$text"' + : 'Not filtering "$text"', kind: IrcMessageKind.system, ); } @@ -3252,7 +3273,10 @@ class ChatSessionController extends ChangeNotifier { final delayMs = int.tryParse(parts[1]); final repetitions = int.tryParse(parts[2]); final command = parts.skip(3).join(' '); - if (delayMs == null || delayMs <= 0 || repetitions == null || repetitions < 0) { + if (delayMs == null || + delayMs <= 0 || + repetitions == null || + repetitions < 0) { _appendMessage( tabId: activeTab.id, sender: 'error', @@ -3265,19 +3289,18 @@ class ChatSessionController extends ChangeNotifier { _commandTimers.remove(name)?.cancel(); var remaining = repetitions; - _commandTimers[name] = Timer.periodic( - Duration(milliseconds: delayMs), - (timer) { - unawaited(handleComposerSubmit(command)); - if (repetitions != 0) { - remaining--; - if (remaining <= 0) { - timer.cancel(); - _commandTimers.remove(name); - } + _commandTimers[name] = Timer.periodic(Duration(milliseconds: delayMs), ( + timer, + ) { + unawaited(handleComposerSubmit(command)); + if (repetitions != 0) { + remaining--; + if (remaining <= 0) { + timer.cancel(); + _commandTimers.remove(name); } - }, - ); + } + }); _appendMessage( tabId: activeTab.id, sender: '*', @@ -3933,9 +3956,9 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.event, ); case 'PONG': - final token = (frame.trailing ?? - (frame.params.isEmpty ? '' : frame.params.last)) - .trim(); + final token = + (frame.trailing ?? (frame.params.isEmpty ? '' : frame.params.last)) + .trim(); if (token.startsWith('LAG')) { final sentMs = int.tryParse(token.substring(3)); if (sentMs != null) { @@ -4368,6 +4391,7 @@ class ChatSessionController extends ChangeNotifier { _serverSupport = _serverSupport.mergeFrame(frame); _nickPrefixChars = _serverSupport.nickPrefixSymbols; _channelPrefixChars = _serverSupport.channelTypes; + _dedupeEquivalentTabs(); final tokens = isupportTokensFromFrame(frame); final supportText = tokens.join(' '); @@ -5123,14 +5147,17 @@ class ChatSessionController extends ChangeNotifier { } ChatTab _ensureChannelTab(String channel) { - final existing = _findTab(_channelTabId(network.id, channel)); + final normalizedChannel = channel.trim(); + final existing = + _findTab(_channelTabId(network.id, normalizedChannel)) ?? + _findEquivalentTab(ChatTabType.channel, normalizedChannel); if (existing != null) { return existing; } final tab = ChatTab( - id: _channelTabId(network.id, channel), - name: channel, + id: _channelTabId(network.id, normalizedChannel), + name: normalizedChannel, type: ChatTabType.channel, networkId: network.id, ); @@ -5142,14 +5169,17 @@ class ChatSessionController extends ChangeNotifier { } ChatTab _ensureQueryTab(String nick) { - final existing = _findTab(_queryTabId(network.id, nick)); + final normalizedNick = nick.trim(); + final existing = + _findTab(_queryTabId(network.id, normalizedNick)) ?? + _findEquivalentTab(ChatTabType.query, normalizedNick); if (existing != null) { return existing; } final tab = ChatTab( - id: _queryTabId(network.id, nick), - name: nick, + id: _queryTabId(network.id, normalizedNick), + name: normalizedNick, type: ChatTabType.query, networkId: network.id, ); @@ -5335,6 +5365,159 @@ class ChatSessionController extends ChangeNotifier { return null; } + ChatTab? _findEquivalentTab(ChatTabType type, String name) { + final key = _ircCasefold(name); + for (final tab in _tabs) { + if (tab.type == type && _ircCasefold(tab.name) == key) { + return tab; + } + } + return null; + } + + void _dedupeEquivalentTabs() { + final canonicalByKey = {}; + final aliases = {}; + final nextTabs = []; + + for (final tab in _tabs) { + if (tab.type != ChatTabType.channel && tab.type != ChatTabType.query) { + nextTabs.add(tab); + continue; + } + + final key = '${tab.type.name}:${_ircCasefold(tab.name)}'; + final canonical = canonicalByKey[key]; + if (canonical == null) { + canonicalByKey[key] = tab; + nextTabs.add(tab); + continue; + } + + aliases[tab.id] = canonical.id; + final canonicalIndex = nextTabs.indexWhere( + (item) => item.id == canonical.id, + ); + if (canonicalIndex != -1) { + nextTabs[canonicalIndex] = canonical.copyWith( + hasActivity: canonical.hasActivity || tab.hasActivity, + unreadCount: canonical.unreadCount + tab.unreadCount, + isEncrypted: canonical.isEncrypted || tab.isEncrypted, + ); + canonicalByKey[key] = nextTabs[canonicalIndex]; + } + } + + if (aliases.isEmpty) { + return; + } + + _tabs = nextTabs; + _activeTabId = aliases[_activeTabId] ?? _activeTabId; + _rekeyMessages(aliases); + _rekeySetMap(_channelUsers, aliases); + _rekeyStringMap(_channelTopics, aliases); + _rekeyStringMap(_channelModes, aliases); + _rekeyNestedSetMap(_channelUserModes, aliases); + _rekeyDateTimeMap(_readMarkers, aliases); + _rekeyNestedSetMap(_messageReactions, aliases); + _rekeySetMap(_typingUsersByTab, aliases); + } + + String _ircCasefold(String value) { + final lower = value.trim().toLowerCase(); + return switch (_serverSupport.caseMapping.toLowerCase()) { + 'ascii' => lower, + 'strict-rfc1459' => + lower.replaceAll('[', '{').replaceAll(']', '}').replaceAll(r'\', '|'), + _ => + lower + .replaceAll('[', '{') + .replaceAll(']', '}') + .replaceAll(r'\', '|') + .replaceAll('^', '~'), + }; + } + + void _rekeyMessages(Map aliases) { + final next = >{}; + for (final entry in _messages.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final messages = next.putIfAbsent(tabId, () => []); + messages.addAll( + entry.value.map( + (message) => + message.tabId == tabId ? message : message.copyWith(tabId: tabId), + ), + ); + } + _messages + ..clear() + ..addAll(next); + } + + void _rekeySetMap(Map> map, Map aliases) { + final next = >{}; + for (final entry in map.entries) { + next + .putIfAbsent(aliases[entry.key] ?? entry.key, () => {}) + .addAll(entry.value); + } + map + ..clear() + ..addAll(next); + } + + void _rekeyStringMap(Map map, Map aliases) { + final next = {}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final existing = next[tabId]; + next[tabId] = existing == null || existing.trim().isEmpty + ? entry.value + : existing; + } + map + ..clear() + ..addAll(next); + } + + void _rekeyNestedSetMap( + Map>> map, + Map aliases, + ) { + final next = >>{}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final nested = next.putIfAbsent(tabId, () => >{}); + for (final nestedEntry in entry.value.entries) { + nested + .putIfAbsent(nestedEntry.key, () => {}) + .addAll(nestedEntry.value); + } + } + map + ..clear() + ..addAll(next); + } + + void _rekeyDateTimeMap( + Map map, + Map aliases, + ) { + final next = {}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final existing = next[tabId]; + next[tabId] = existing == null || entry.value.isAfter(existing) + ? entry.value + : existing; + } + map + ..clear() + ..addAll(next); + } + IrcMessage? _appendMessage({ required String tabId, required String sender, @@ -5381,9 +5564,7 @@ class ChatSessionController extends ChangeNotifier { final appended = list.last; final repository = _historyRepository; if (repository != null) { - unawaited( - repository.append(networkId: network.id, message: appended), - ); + unawaited(repository.append(networkId: network.id, message: appended)); } return appended; } @@ -5636,6 +5817,7 @@ class ChatSessionController extends ChangeNotifier { _messages ..clear() ..addAll(snapshot.messagesByTab); + _dedupeEquivalentTabs(); for (final tab in _tabs) { _messages.putIfAbsent(tab.id, () => []); @@ -5703,8 +5885,9 @@ class ChatSessionController extends ChangeNotifier { } final current = _messages[tabId]; - final anchor = - (current == null || current.isEmpty) ? null : current.first.id; + final anchor = (current == null || current.isEmpty) + ? null + : current.first.id; final older = await repository.loadTabHistory( networkId: network.id, tabId: tabId, @@ -6498,7 +6681,8 @@ class ChatSessionController extends ChangeNotifier { String _serverTabId(String networkId) => 'server::$networkId'; String _noticeTabId(String networkId) => 'notice::$networkId'; String _channelTabId(String networkId, String name) => - 'channel::$networkId::$name'; -String _queryTabId(String networkId, String nick) => 'query::$networkId::$nick'; + 'channel::$networkId::${name.trim().toLowerCase()}'; +String _queryTabId(String networkId, String nick) => + 'query::$networkId::${nick.trim().toLowerCase()}'; String _dccTabId(String networkId, String sessionId) => 'dcc::$networkId::$sessionId'; diff --git a/lib/features/security/presentation/app_lock_gate.dart b/lib/features/security/presentation/app_lock_gate.dart index bcfa1a2..4d4fc68 100644 --- a/lib/features/security/presentation/app_lock_gate.dart +++ b/lib/features/security/presentation/app_lock_gate.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:local_auth/local_auth.dart'; +import 'package:androidircx/core/security/device_unlock_session.dart'; /// Returns true if the user successfully authenticated. typedef AppUnlockCallback = Future Function(); @@ -25,10 +25,10 @@ class AppLockGate extends StatefulWidget { State createState() => _AppLockGateState(); } -class _AppLockGateState extends State - with WidgetsBindingObserver { +class _AppLockGateState extends State with WidgetsBindingObserver { bool _unlocked = false; bool _prompting = false; + bool _hasMountedChild = false; @override void initState() { @@ -38,6 +38,7 @@ class _AppLockGateState extends State WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock()); } else { _unlocked = true; + _hasMountedChild = true; } } @@ -46,11 +47,13 @@ class _AppLockGateState extends State super.didUpdateWidget(oldWidget); if (!widget.enabled) { _unlocked = true; + _hasMountedChild = true; } else if (!oldWidget.enabled && widget.enabled) { // Just enabled at runtime: the user is already in the app (and confirmed // authentication in Settings), so stay unlocked now. The lock engages on // the next time the app is backgrounded and resumed. _unlocked = true; + _hasMountedChild = true; } } @@ -68,6 +71,7 @@ class _AppLockGateState extends State if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { if (mounted) { + DeviceUnlockSession.instance.invalidate(); setState(() => _unlocked = false); } } else if (state == AppLifecycleState.resumed && !_unlocked) { @@ -76,21 +80,9 @@ class _AppLockGateState extends State } Future _defaultUnlock() async { - try { - final auth = LocalAuthentication(); - final supported = - await auth.isDeviceSupported() || await auth.canCheckBiometrics; - if (!supported) { - return false; - } - return await auth.authenticate( - localizedReason: 'Unlock AndroidIRCX', - biometricOnly: false, - persistAcrossBackgrounding: true, - ); - } catch (_) { - return false; - } + return DeviceUnlockSession.instance.authenticate( + reason: 'Unlock AndroidIRCX', + ); } Future _attemptUnlock() async { @@ -101,27 +93,60 @@ class _AppLockGateState extends State final unlocked = await (widget.unlock ?? _defaultUnlock)(); _prompting = false; if (mounted && unlocked) { - setState(() => _unlocked = true); + setState(() { + _unlocked = true; + _hasMountedChild = true; + }); } } @override Widget build(BuildContext context) { - if (!widget.enabled || _unlocked) { - return widget.child; + final locked = widget.enabled && !_unlocked; + if (!_hasMountedChild && locked) { + return const _AppLockScreen(); } + return Stack( + fit: StackFit.expand, + children: [ + ExcludeSemantics( + excluding: locked, + child: IgnorePointer( + ignoring: locked, + child: TickerMode(enabled: !locked, child: widget.child), + ), + ), + if (locked) const Positioned.fill(child: _AppLockScreen()), + ], + ); + } +} + +class _AppLockScreen extends StatelessWidget { + const _AppLockScreen(); + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.lock_outline, size: 56, color: theme.colorScheme.primary), + Icon( + Icons.lock_outline, + size: 56, + color: theme.colorScheme.primary, + ), const SizedBox(height: 16), Text('AndroidIRCX is locked', style: theme.textTheme.titleMedium), const SizedBox(height: 16), FilledButton.icon( - onPressed: _attemptUnlock, + onPressed: () { + final state = context + .findAncestorStateOfType<_AppLockGateState>(); + state?._attemptUnlock(); + }, icon: const Icon(Icons.fingerprint), label: const Text('Unlock'), ), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 62c5c52..d616847 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -41,7 +41,7 @@ class SettingsScreen extends StatefulWidget { /// for tests; defaults to a biometric/PIN prompt. final Future Function()? appLockAuthenticator; - /// Runtime OS permissions (notifications, camera). Overridable for tests; + /// Runtime OS permissions. Overridable for tests; /// defaults to the `permission_handler` backed implementation. final AppPermissions? permissions; @@ -60,7 +60,6 @@ class _SettingsScreenState extends State { AppSettings _settings = const AppSettings(); bool _isLoading = true; bool _didResolveController = false; - bool _cameraGranted = false; AppPermissions get _permissions => widget.permissions ?? const PermissionHandlerAppPermissions(); @@ -546,21 +545,6 @@ class _SettingsScreenState extends State { _SettingsSection( title: 'Permissions', children: [ - ListTile( - key: const Key('settings-permission-camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: const Text('Camera access'), - subtitle: Text( - _cameraGranted - ? 'Granted — you can capture photos and video.' - : 'Needed to capture photos/video for media and DCC.', - ), - trailing: _cameraGranted - ? const Icon(Icons.check_circle_outline) - : const Text('Grant'), - onTap: _cameraGranted ? null : _requestCameraPermission, - ), - const Divider(height: 1), SwitchListTile( key: const Key('settings-analytics-consent'), secondary: const Icon(Icons.insights_outlined), @@ -949,19 +933,15 @@ class _SettingsScreenState extends State { } /// Reconciles permission-gated settings on entry: notifications can only be - /// on while the OS permission is granted, and refreshes the camera status. + /// on while the OS permission is granted. Future _refreshPermissionStatuses() async { final hasNotifications = await _permissions.hasNotifications(); - final hasCamera = await _permissions.hasCamera(); if (!mounted) { return; } if (_settings.notificationsEnabled && !hasNotifications) { await _saveSettings(_settings.copyWith(notificationsEnabled: false)); } - if (mounted && hasCamera != _cameraGranted) { - setState(() => _cameraGranted = hasCamera); - } } Future _toggleNotifications(bool value) async { @@ -1001,33 +981,6 @@ class _SettingsScreenState extends State { ); } - Future _requestCameraPermission() async { - final result = await _permissions.requestCamera(); - if (!mounted) { - return; - } - if (result == AppPermissionResult.granted) { - setState(() => _cameraGranted = true); - return; - } - final permanentlyDenied = result == AppPermissionResult.permanentlyDenied; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - permanentlyDenied - ? 'Camera is blocked. Enable it in system settings.' - : 'Camera permission denied.', - ), - action: permanentlyDenied - ? SnackBarAction( - label: 'Settings', - onPressed: () => unawaited(_permissions.openSettingsPage()), - ) - : null, - ), - ); - } - Future _toggleAppLock(bool value) async { // Turning off is immediate. Turning on first confirms the user can actually // authenticate, so enabling it can never lock them out of the app. diff --git a/pubspec.yaml b/pubspec.yaml index 35dc346..dc1eebb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.4+6 +version: 1.0.5+8 environment: sdk: ^3.11.1 diff --git a/test/app_lock_gate_test.dart b/test/app_lock_gate_test.dart index 9faf8cb..ba065e4 100644 --- a/test/app_lock_gate_test.dart +++ b/test/app_lock_gate_test.dart @@ -37,6 +37,26 @@ class _HostState extends State<_Host> { } } +class _Probe extends StatefulWidget { + const _Probe({required this.onDisposed}); + + final VoidCallback onDisposed; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + @override + void dispose() { + widget.onDisposed(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('home content'); +} + void main() { testWidgets('enabling app lock at runtime does not immediately lock', ( tester, @@ -80,4 +100,35 @@ void main() { expect(find.text('AndroidIRCX is locked'), findsOneWidget); expect(find.text('home content'), findsNothing); }); + + testWidgets('re-locking keeps the child session tree mounted', ( + tester, + ) async { + var unlockCalls = 0; + var disposed = false; + await tester.pumpWidget( + MaterialApp( + home: AppLockGate( + enabled: true, + unlock: () async { + unlockCalls++; + return true; + }, + child: _Probe(onDisposed: () => disposed = true), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(unlockCalls, 1); + expect(find.text('home content'), findsOneWidget); + + final state = tester.state(find.byType(AppLockGate)) as dynamic; + state.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pump(); + + expect(find.text('AndroidIRCX is locked'), findsOneWidget); + expect(disposed, isFalse); + expect(unlockCalls, 1); + }); } diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 8a6ed1e..2a285fb 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -382,6 +382,36 @@ void main() { }); }); + test('joining the same channel with different case reuses one tab', () async { + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ); + final controller = ChatSessionController( + network: network, + ircService: IrcService(), + ); + + await controller.joinChannel( + const JoinChannelRequest(channel: '#AndroidIRCX'), + ); + await controller.joinChannel( + const JoinChannelRequest(channel: '#androidircx'), + ); + + final channelTabs = controller.tabs + .where((tab) => tab.type == ChatTabType.channel) + .toList(growable: false); + expect(channelTabs, hasLength(1)); + expect(channelTabs.single.name, '#AndroidIRCX'); + expect(controller.activeTabId, channelTabs.single.id); + + controller.dispose(); + }); + test( 'app lifecycle pause flushes message history without reconnecting', () async { @@ -2747,8 +2777,9 @@ void main() { transport.emit(':b!u@h PRIVMSG #two :hi'); await Future.delayed(Duration.zero); - final serverTab = - controller.tabs.firstWhere((tab) => tab.type == ChatTabType.server); + final serverTab = controller.tabs.firstWhere( + (tab) => tab.type == ChatTabType.server, + ); controller.selectTab(serverTab.id); final startId = controller.activeTabId; @@ -2779,8 +2810,9 @@ void main() { transport.emit(':server 001 AndroidIRCX :Welcome'); await Future.delayed(Duration.zero); await controller.measureLag(); - final pingLine = - transport.sentLines.lastWhere((line) => line.startsWith('PING :LAG')); + final pingLine = transport.sentLines.lastWhere( + (line) => line.startsWith('PING :LAG'), + ); final token = pingLine.substring('PING :'.length); transport.emit(':server PONG server :$token'); await Future.delayed(Duration.zero); @@ -2844,10 +2876,13 @@ void main() { transport.emit(':server 323 AndroidIRCX :End of /LIST'); await Future.delayed(Duration.zero); - expect(controller.channelListing.map((entry) => entry.name), - containsAll(['#dbase', '#flutter'])); - final dbase = - controller.channelListing.firstWhere((entry) => entry.name == '#dbase'); + expect( + controller.channelListing.map((entry) => entry.name), + containsAll(['#dbase', '#flutter']), + ); + final dbase = controller.channelListing.firstWhere( + (entry) => entry.name == '#dbase', + ); expect(dbase.userCount, 42); expect(dbase.topic, 'Main channel'); expect(controller.channelListInProgress, isFalse); @@ -2929,10 +2964,7 @@ void main() { isEmpty, ); // The message is still delivered to the query tab, just not notified. - expect( - controller.tabs.any((tab) => tab.name == 'alice'), - isTrue, - ); + expect(controller.tabs.any((tab) => tab.name == 'alice'), isTrue); await sub.cancel(); controller.dispose(); @@ -3444,9 +3476,7 @@ void main() { await controller.start(); Future emitCtcp(String body) async { - transport.emit( - ':alice!user@example PRIVMSG AndroidIRCX :$body', - ); + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :$body'); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); } @@ -3460,10 +3490,7 @@ void main() { // Every CTCP reply must go out as a NOTICE (never PRIVMSG) so it cannot // trigger a reply loop, and PING must echo the requester's token back. - expect( - transport.sentLines, - contains('NOTICE alice :PING 999'), - ); + expect(transport.sentLines, contains('NOTICE alice :PING 999')); expect( transport.sentLines, contains( @@ -3488,8 +3515,7 @@ void main() { ); expect( transport.sentLines.any( - (line) => line.startsWith('NOTICE alice :TIME ') && - line.endsWith(''), + (line) => line.startsWith('NOTICE alice :TIME ') && line.endsWith(''), ), isTrue, ); @@ -3521,7 +3547,9 @@ void main() { await controller.handleComposerSubmit('/echo hello world'); expect( - controller.activeMessages.any((message) => message.content == 'hello world'), + controller.activeMessages.any( + (message) => message.content == 'hello world', + ), isTrue, ); // /echo must never hit the wire. @@ -3589,14 +3617,8 @@ void main() { expect(transport.sentLines, contains('PRIVMSG #room :hello all')); expect(transport.sentLines, contains('PRIVMSG #other :hello all')); - expect( - transport.sentLines, - contains('PRIVMSG #room :ACTION waves'), - ); - expect( - transport.sentLines, - contains('PRIVMSG #other :ACTION waves'), - ); + expect(transport.sentLines, contains('PRIVMSG #room :ACTION waves')); + expect(transport.sentLines, contains('PRIVMSG #other :ACTION waves')); controller.dispose(); }); @@ -3628,8 +3650,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('before')); expect(contents, isNot(contains('after'))); expect(contents, contains('hi')); @@ -3671,8 +3694,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, isNot(contains('spam'))); expect(contents, contains('welcome')); @@ -3731,8 +3755,9 @@ void main() { await Future.delayed(Duration.zero); await controller.handleComposerSubmit('/clones #room'); - final contents = - controller.activeMessages.map((message) => message.content).join('\n'); + final contents = controller.activeMessages + .map((message) => message.content) + .join('\n'); expect(contents, contains('Clones detected in #room')); expect(contents, contains('shared.example')); expect(contents.contains('bob') && contents.contains('carol'), isTrue); @@ -3768,54 +3793,69 @@ void main() { controller.dispose(); }); - test('performs channel user actions through existing command paths', () 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_', - ), - ircService: service, - ); + test( + 'performs channel user actions through existing command paths', + () 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_', + ), + ircService: service, + ); - await controller.start(); - transport.emit(':alice!user@example PRIVMSG #room :hi'); - await Future.delayed(Duration.zero); - final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); - controller.selectTab(roomTab.id); + await controller.start(); + transport.emit(':alice!user@example PRIVMSG #room :hi'); + await Future.delayed(Duration.zero); + final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(roomTab.id); - await controller.performChannelUserAction('alice', ChannelUserAction.op); - await controller.performChannelUserAction('alice', ChannelUserAction.voice); - await controller.performChannelUserAction('@alice', ChannelUserAction.kick); - await controller.performChannelUserAction('alice', ChannelUserAction.ban); - await controller.performChannelUserAction('alice', ChannelUserAction.whois); - await controller.performChannelUserAction('alice', ChannelUserAction.query); + await controller.performChannelUserAction('alice', ChannelUserAction.op); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.voice, + ); + await controller.performChannelUserAction( + '@alice', + ChannelUserAction.kick, + ); + await controller.performChannelUserAction('alice', ChannelUserAction.ban); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.whois, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.query, + ); - expect(transport.sentLines, contains('MODE #room +o alice')); - expect(transport.sentLines, contains('MODE #room +v alice')); - expect( - transport.sentLines.any((line) => line.startsWith('KICK #room alice')), - isTrue, - ); - expect(transport.sentLines, contains('MODE #room +b alice')); - expect( - transport.sentLines.any((line) => line.startsWith('WHOIS alice')), - isTrue, - ); - expect( - controller.tabs.any( - (tab) => tab.type.name == 'query' && tab.name == 'alice', - ), - isTrue, - ); + expect(transport.sentLines, contains('MODE #room +o alice')); + expect(transport.sentLines, contains('MODE #room +v alice')); + expect( + transport.sentLines.any((line) => line.startsWith('KICK #room alice')), + isTrue, + ); + expect(transport.sentLines, contains('MODE #room +b alice')); + expect( + transport.sentLines.any((line) => line.startsWith('WHOIS alice')), + isTrue, + ); + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'alice', + ), + isTrue, + ); - controller.dispose(); - }); + controller.dispose(); + }, + ); test('announces bouncer compatibility on registration', () async { final transport = _FakeTransport(); @@ -3874,8 +3914,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('hello spam word')); expect(contents, isNot(contains('another spam here'))); expect(contents, contains('clean message')); @@ -3977,10 +4018,7 @@ void main() { networkId: 'dbase', tabId: roomTab.id, ); - expect( - stored.any((message) => message.content == 'hello history'), - isTrue, - ); + expect(stored.any((message) => message.content == 'hello history'), isTrue); controller.dispose(); }); @@ -4027,8 +4065,9 @@ void main() { final roomTab = controller2.tabs.firstWhere((tab) => tab.name == '#room'); controller2.selectTab(roomTab.id); - final contents = - controller2.activeMessages.map((message) => message.content).toList(); + final contents = controller2.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('first message')); expect(contents, contains('second message')); diff --git a/test/device_unlock_session_test.dart b/test/device_unlock_session_test.dart new file mode 100644 index 0000000..82074f8 --- /dev/null +++ b/test/device_unlock_session_test.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:androidircx/core/security/device_unlock_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('reuses a recent successful unlock', () async { + var now = DateTime(2026, 8, 24, 15); + var calls = 0; + final session = DeviceUnlockSession( + reuseWindow: const Duration(minutes: 1), + now: () => now, + prompt: (_) async { + calls++; + return true; + }, + ); + + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + expect(await session.authenticate(reason: 'Unlock history'), isTrue); + expect(calls, 1); + + now = now.add(const Duration(minutes: 2)); + expect(await session.authenticate(reason: 'Unlock history'), isTrue); + expect(calls, 2); + }); + + test('coalesces concurrent unlock requests', () async { + var calls = 0; + final prompt = Completer(); + final session = DeviceUnlockSession( + prompt: (_) { + calls++; + return prompt.future; + }, + ); + + final first = session.authenticate(reason: 'Unlock app'); + final second = session.authenticate(reason: 'Unlock history'); + expect(calls, 1); + + prompt.complete(true); + expect(await first, isTrue); + expect(await second, isTrue); + }); + + test('invalidate clears the cached unlock', () async { + var calls = 0; + final session = DeviceUnlockSession( + prompt: (_) async { + calls++; + return true; + }, + ); + + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + session.invalidate(); + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + expect(calls, 2); + }); +} diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart index 340e5db..277707a 100644 --- a/test/notification_permission_settings_test.dart +++ b/test/notification_permission_settings_test.dart @@ -9,23 +9,16 @@ import 'package:shared_preferences/shared_preferences.dart'; class _FakePermissions implements AppPermissions { _FakePermissions({ this.notifResult = AppPermissionResult.granted, - this.camResult = AppPermissionResult.granted, this.hasNotif = false, }); AppPermissionResult notifResult; - AppPermissionResult camResult; bool hasNotif; - bool hasCam = false; int notifRequests = 0; - int camRequests = 0; @override Future hasNotifications() async => hasNotif; - @override - Future hasCamera() async => hasCam; - @override Future requestNotifications() async { notifRequests++; @@ -33,13 +26,6 @@ class _FakePermissions implements AppPermissions { return notifResult; } - @override - Future requestCamera() async { - camRequests++; - if (camResult == AppPermissionResult.granted) hasCam = true; - return camResult; - } - @override Future openSettingsPage() async {} } @@ -138,28 +124,4 @@ void main() { expect(saved.analyticsConsent, isTrue); }); - testWidgets('granting camera permission shows granted state', (tester) async { - final perms = _FakePermissions(camResult: AppPermissionResult.granted); - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(permissions: perms)), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.scrollUntilVisible( - find.byKey(const Key('settings-permission-camera')), - 250, - scrollable: find.byType(Scrollable).first, - ); - await tester.ensureVisible( - find.byKey(const Key('settings-permission-camera')), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('settings-permission-camera'))); - await tester.pumpAndSettle(); - - expect(perms.camRequests, 1); - expect(find.text('Granted — you can capture photos and video.'), - findsOneWidget); - }); } diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart index fc9981b..75e257b 100644 --- a/test/onboarding_permission_test.dart +++ b/test/onboarding_permission_test.dart @@ -19,11 +19,7 @@ class _FakePermissions implements AppPermissions { @override Future hasNotifications() async => false; - @override - Future requestCamera() async => - AppPermissionResult.granted; - @override - Future hasCamera() async => false; + @override Future openSettingsPage() async {} }