diff --git a/.gitignore b/.gitignore index 3b20813..c2a5df5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ app.*.map.json /android/app/release /secrets/ /android/build/reports/problems/problems-report.html +/android/.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f24d122..8a75d2b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,6 @@ import java.io.File import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -68,10 +69,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - defaultConfig { applicationId = "com.androidircx.flutter" minSdk = flutter.minSdkVersion @@ -105,6 +102,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + flutter { source = "../.." } diff --git a/android/gradle.properties b/android/gradle.properties index c1c6744..1c8fe0e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true android.suppressUnsupportedCompileSdk=37.0 kotlin.incremental=false +# Use the external org.jetbrains.kotlin.android plugin, not AGP's built-in Kotlin. +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43f..2d428bf 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe06..c21f0c5 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false } include(":app") diff --git a/lib/core/diagnostics/crash_report.dart b/lib/core/diagnostics/crash_report.dart new file mode 100644 index 0000000..efb83f3 --- /dev/null +++ b/lib/core/diagnostics/crash_report.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +/// A single captured crash / uncaught-error record, already sanitized of any +/// credentials. Stored locally and, only if the user chooses, emailed as a +/// plaintext report. Nothing is ever sent automatically. +class CrashReport { + const CrashReport({ + required this.timestamp, + required this.fatal, + required this.source, + required this.message, + required this.stack, + this.platform, + }); + + /// When the error was captured (UTC ISO-8601). + final DateTime timestamp; + + /// Whether the error was fatal (uncaught) rather than a handled report. + final bool fatal; + + /// Where it came from, e.g. `FlutterError.onError`, `PlatformDispatcher`, + /// `zone`, or a feature name. + final String source; + + /// Sanitized error message. + final String message; + + /// Sanitized stack trace (may be empty). + final String stack; + + /// Optional platform description (e.g. `android`). + final String? platform; + + Map toJson() => { + 'timestamp': timestamp.toUtc().toIso8601String(), + 'fatal': fatal, + 'source': source, + 'message': message, + 'stack': stack, + if (platform != null) 'platform': platform, + }; + + static CrashReport fromJson(Map json) => CrashReport( + timestamp: + DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ?? + DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + fatal: json['fatal'] as bool? ?? true, + source: json['source'] as String? ?? 'unknown', + message: json['message'] as String? ?? '', + stack: json['stack'] as String? ?? '', + platform: json['platform'] as String?, + ); + + String encode() => jsonEncode(toJson()); + + static CrashReport? decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return fromJson(decoded); + } + } catch (_) { + // Ignore malformed entries. + } + return null; + } + + /// Human-readable plaintext body for the crash email / on-screen preview. + String toPlainText() { + final buffer = StringBuffer() + ..writeln('AndroidIRCX crash report') + ..writeln('Time: ${timestamp.toUtc().toIso8601String()}') + ..writeln('Fatal: $fatal') + ..writeln('Source: $source'); + if (platform != null) { + buffer.writeln('Platform: $platform'); + } + buffer + ..writeln() + ..writeln('Message:') + ..writeln(message); + if (stack.trim().isNotEmpty) { + buffer + ..writeln() + ..writeln('Stack:') + ..writeln(stack); + } + return buffer.toString(); + } +} diff --git a/lib/core/diagnostics/crash_report_sanitizer.dart b/lib/core/diagnostics/crash_report_sanitizer.dart new file mode 100644 index 0000000..40e9717 --- /dev/null +++ b/lib/core/diagnostics/crash_report_sanitizer.dart @@ -0,0 +1,49 @@ +/// Redacts credentials and other secrets from crash text before it is stored +/// or leaves the device. Ported from the React Native `ErrorReportingService` +/// sanitizer: IRC auth commands, generic password/token key-values, PEM blocks +/// and long opaque tokens are replaced with `[redacted]`. +class CrashReportSanitizer { + const CrashReportSanitizer(); + + static const String redaction = '[redacted]'; + + /// IRC/auth verbs whose trailing argument is a secret (case-insensitive). + /// The verb is kept, the value is redacted. + static final RegExp _authCommand = RegExp( + r'\b(PASS|AUTHENTICATE|OPER|IDENTIFY|NICKSERV|NS)\b[ \t]+(?![:#&])(\S+)', + caseSensitive: false, + ); + + /// `password: xxx`, `token=xxx`, `secret "xxx"`, `apikey => xxx`, etc. + static final RegExp _keyValue = RegExp( + r'\b(password|passwd|pass|token|secret|sasl|oauth|apikey|api_key|authorization|bearer|cert|key)\b' + r'''[ \t]*[:=]{1,2}[ \t]*["'`]?([^\s"'`,;)]+)''', + caseSensitive: false, + ); + + /// PEM private-key / certificate blocks. + static final RegExp _pemBlock = RegExp( + r'-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----', + ); + + /// Long opaque tokens (base64/hex-ish runs of 40+ chars). + static final RegExp _longToken = RegExp(r'\b[A-Za-z0-9+/=_-]{40,}\b'); + + String sanitize(String input) { + if (input.isEmpty) { + return input; + } + var out = input; + out = out.replaceAll(_pemBlock, redaction); + out = out.replaceAllMapped( + _authCommand, + (m) => '${m.group(1)} $redaction', + ); + out = out.replaceAllMapped( + _keyValue, + (m) => '${m.group(1)}=$redaction', + ); + out = out.replaceAll(_longToken, redaction); + return out; + } +} diff --git a/lib/core/diagnostics/crash_reporter.dart b/lib/core/diagnostics/crash_reporter.dart new file mode 100644 index 0000000..faf2785 --- /dev/null +++ b/lib/core/diagnostics/crash_reporter.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:androidircx/core/diagnostics/crash_report.dart'; +import 'package:androidircx/core/diagnostics/crash_report_sanitizer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Captures uncaught errors, sanitizes them, and keeps the most recent few on +/// device so the user can review and — only if they choose — email a plaintext +/// report. No automatic/network reporting and no analytics SDK: the report is +/// sent solely through the user's own mail client via a `mailto:` link. +class CrashReporter { + CrashReporter({ + CrashReportSanitizer sanitizer = const CrashReportSanitizer(), + Future Function()? prefsLoader, + String? platformName, + DateTime Function()? clock, + this.contactEmail = defaultContactEmail, + this.maxStored = 5, + }) : _sanitizer = sanitizer, + _prefsLoader = prefsLoader ?? SharedPreferences.getInstance, + _platformName = platformName ?? _defaultPlatformName(), + _clock = clock ?? DateTime.now; + + static const String defaultContactEmail = 'contact@androidircx.com'; + static const String emailSubject = 'AndroidIRCX Crash Report'; + static const String _storageKey = 'androidircx.crashReports'; + + final CrashReportSanitizer _sanitizer; + final Future Function() _prefsLoader; + final String? _platformName; + final DateTime Function() _clock; + + /// Address the crash email is pre-addressed to. + final String contactEmail; + + /// Maximum number of reports retained on device. + final int maxStored; + + static String? _defaultPlatformName() { + // Avoid dart:io so this stays testable; use the platform embedder name. + final name = defaultTargetPlatform.name; + return name.isEmpty ? null : name; + } + + /// Records an error, persisting a sanitized [CrashReport]. Never throws. + Future record( + Object error, + StackTrace? stack, { + bool fatal = true, + String source = 'unknown', + }) async { + try { + final report = CrashReport( + timestamp: _clock().toUtc(), + fatal: fatal, + source: source, + message: _sanitizer.sanitize(error.toString()), + stack: _sanitizer.sanitize(stack?.toString() ?? ''), + platform: _platformName, + ); + final prefs = await _prefsLoader(); + final existing = prefs.getStringList(_storageKey) ?? []; + final updated = [report.encode(), ...existing]; + if (updated.length > maxStored) { + updated.removeRange(maxStored, updated.length); + } + await prefs.setStringList(_storageKey, updated); + return report; + } catch (_) { + // Diagnostics must never worsen a crash. + return null; + } + } + + /// Loads the retained reports, newest first. + Future> loadReports() async { + try { + final prefs = await _prefsLoader(); + final raw = prefs.getStringList(_storageKey) ?? []; + return raw + .map(CrashReport.decode) + .whereType() + .toList(growable: false); + } catch (_) { + return const []; + } + } + + /// Clears all retained reports. + Future clear() async { + try { + final prefs = await _prefsLoader(); + await prefs.remove(_storageKey); + } catch (_) { + // Best effort. + } + } + + /// Builds a `mailto:` URI pre-addressed to [contactEmail] with the report as + /// the plaintext body. Spaces are percent-encoded (not `+`) for broad mail + /// client compatibility. + Uri buildMailtoUri(CrashReport report) { + final subject = Uri.encodeComponent(emailSubject); + final body = Uri.encodeComponent(report.toPlainText()); + return Uri.parse('mailto:$contactEmail?subject=$subject&body=$body'); + } + + /// Installs global handlers so uncaught framework and platform errors are + /// recorded. Existing handlers are preserved and still invoked. + void install() { + final priorFlutterHandler = FlutterError.onError; + FlutterError.onError = (details) { + unawaited( + record( + details.exception, + details.stack, + source: 'FlutterError.onError', + ), + ); + if (priorFlutterHandler != null) { + priorFlutterHandler(details); + } else { + FlutterError.presentError(details); + } + }; + + final priorPlatformHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (error, stack) { + unawaited(record(error, stack, source: 'PlatformDispatcher')); + return priorPlatformHandler?.call(error, stack) ?? false; + }; + } +} diff --git a/lib/core/security/certificate_store.dart b/lib/core/security/certificate_store.dart index 8ad0b79..4c336b3 100644 --- a/lib/core/security/certificate_store.dart +++ b/lib/core/security/certificate_store.dart @@ -8,19 +8,30 @@ import 'package:androidircx/core/storage/network_secret_keys.dart'; /// config JSON, logs, or exports. class ClientCertificate { const ClientCertificate({ - required this.certificatePem, - required this.privateKeyPem, + this.certificatePem = '', + this.privateKeyPem = '', + this.pkcs12Base64, this.privateKeyPassphrase, }); final String certificatePem; final String privateKeyPem; + + /// Base64-encoded PKCS#12 (.p12/.pfx) bundle. When set, TLS uses this bundle + /// (with [privateKeyPassphrase] as the import password) instead of the PEM + /// fields. Dart's [SecurityContext] parses PKCS#12 natively. + final String? pkcs12Base64; + final String? privateKeyPassphrase; + /// Whether this certificate is backed by a PKCS#12 bundle. + bool get isPkcs12 => (pkcs12Base64 ?? '').isNotEmpty; + @override String toString() => 'ClientCertificate(certificatePem: [REDACTED], ' 'privateKeyPem: [REDACTED], ' + 'pkcs12Base64: ${isPkcs12 ? '[REDACTED]' : 'null'}, ' 'privateKeyPassphrase: ${privateKeyPassphrase == null ? 'null' : '[REDACTED]'})'; } @@ -54,6 +65,10 @@ class CertificateStore { _key(networkId, NetworkSecretField.clientPrivateKey), certificate.privateKeyPem.trim(), ); + await _storage.setSecret( + _key(networkId, NetworkSecretField.clientPkcs12), + certificate.pkcs12Base64?.trim(), + ); await _storage.setSecret( _key(networkId, NetworkSecretField.clientKeyPassphrase), certificate.privateKeyPassphrase, @@ -61,6 +76,22 @@ class CertificateStore { } Future read(String networkId) async { + final passphrase = await _storage.getSecret( + _key(networkId, NetworkSecretField.clientKeyPassphrase), + ); + final normalizedPassphrase = + (passphrase == null || passphrase.isEmpty) ? null : passphrase; + + final pkcs12 = await _storage.getSecret( + _key(networkId, NetworkSecretField.clientPkcs12), + ); + if (pkcs12 != null && pkcs12.isNotEmpty) { + return ClientCertificate( + pkcs12Base64: pkcs12, + privateKeyPassphrase: normalizedPassphrase, + ); + } + final certificatePem = await _storage.getSecret( _key(networkId, NetworkSecretField.clientCertificate), ); @@ -73,18 +104,20 @@ class CertificateStore { privateKeyPem.isEmpty) { return null; } - final passphrase = await _storage.getSecret( - _key(networkId, NetworkSecretField.clientKeyPassphrase), - ); return ClientCertificate( certificatePem: certificatePem, privateKeyPem: privateKeyPem, - privateKeyPassphrase: - (passphrase == null || passphrase.isEmpty) ? null : passphrase, + privateKeyPassphrase: normalizedPassphrase, ); } Future has(String networkId) async { + final pkcs12 = await _storage.getSecret( + _key(networkId, NetworkSecretField.clientPkcs12), + ); + if (pkcs12 != null && pkcs12.isNotEmpty) { + return true; + } final certificatePem = await _storage.getSecret( _key(networkId, NetworkSecretField.clientCertificate), ); @@ -104,6 +137,9 @@ class CertificateStore { await _storage.removeSecret( _key(networkId, NetworkSecretField.clientPrivateKey), ); + await _storage.removeSecret( + _key(networkId, NetworkSecretField.clientPkcs12), + ); await _storage.removeSecret( _key(networkId, NetworkSecretField.clientKeyPassphrase), ); @@ -119,6 +155,15 @@ class CertificateStore { /// This is a structural check (well-formed BEGIN/END blocks with base64 bodies), /// not a cryptographic verification — the TLS stack performs the real handshake. void validateClientCertificate(ClientCertificate certificate) { + if (certificate.isPkcs12) { + final body = certificate.pkcs12Base64!.replaceAll(RegExp(r'\s'), ''); + if (body.isEmpty || !_isBase64(body)) { + throw const CertificateFormatException( + 'PKCS#12 bundle must be base64-encoded .p12/.pfx data.', + ); + } + return; + } if (!_isPemBlock( certificate.certificatePem, const ['CERTIFICATE'], diff --git a/lib/core/storage/network_secret_keys.dart b/lib/core/storage/network_secret_keys.dart index ed5d739..2c24beb 100644 --- a/lib/core/storage/network_secret_keys.dart +++ b/lib/core/storage/network_secret_keys.dart @@ -16,7 +16,8 @@ enum NetworkSecretField { proxyPassword('proxyPassword'), clientCertificate('clientCertificate'), clientPrivateKey('clientPrivateKey'), - clientKeyPassphrase('clientKeyPassphrase'); + clientKeyPassphrase('clientKeyPassphrase'), + clientPkcs12('clientPkcs12'); const NetworkSecretField(this.jsonKey); diff --git a/lib/features/bootstrap/presentation/bootstrap_screen.dart b/lib/features/bootstrap/presentation/bootstrap_screen.dart index 1e393ca..9834d3b 100644 --- a/lib/features/bootstrap/presentation/bootstrap_screen.dart +++ b/lib/features/bootstrap/presentation/bootstrap_screen.dart @@ -11,6 +11,7 @@ import 'package:androidircx/features/chat/application/chat_session_controller.da import 'package:androidircx/features/chat/application/session_registry.dart'; import 'package:androidircx/features/chat/data/encrypted_history_database.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; +import 'package:androidircx/features/chat/data/user_lists_repository.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'; @@ -52,6 +53,7 @@ class _BootstrapScreenState extends State late final ForegroundConnectionService _foregroundConnectionService; late final NetworkListController _controller; late final SessionRegistry _sessionRegistry; + final UserListsRepository _userListsRepository = UserListsRepository(); MessageHistoryRepository? _historyRepository; bool _bootstrapComplete = false; @@ -65,6 +67,7 @@ class _BootstrapScreenState extends State sessionFactory: (network) => ChatSessionController( network: network, historyRepository: _historyRepository, + userListsRepository: _userListsRepository, ), ); _controller = NetworkListController( diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index ce9dbb6..3e3509c 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -15,6 +15,8 @@ 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'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:androidircx/features/chat/data/user_lists_repository.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/core/models/channel_list_entry.dart'; import 'package:androidircx/core/security/certificate_store.dart'; @@ -66,6 +68,7 @@ class ChatSessionController extends ChangeNotifier { MessageHistoryRepository? historyRepository, SettingsRepository? settingsRepository, CommandService? commandService, + UserListsRepository? userListsRepository, int maxReconnectAttempts = 6, Duration reconnectBaseDelay = const Duration(seconds: 2), Duration reconnectMaxDelay = const Duration(seconds: 60), @@ -82,6 +85,7 @@ class ChatSessionController extends ChangeNotifier { _settingsRepository = settingsRepository ?? SharedPrefsSettingsRepository(), _commandService = commandService ?? CommandService(), + _userListsRepository = userListsRepository, _maxReconnectAttempts = maxReconnectAttempts, _reconnectBaseDelay = reconnectBaseDelay, _reconnectMaxDelay = reconnectMaxDelay, @@ -106,6 +110,9 @@ class ChatSessionController extends ChangeNotifier { final MessageHistoryRepository? _historyRepository; final SettingsRepository _settingsRepository; final CommandService _commandService; + final UserListsRepository? _userListsRepository; + List _autoModeEntries = const []; + bool _autoModeEntriesLoaded = false; final int _maxReconnectAttempts; final Duration _reconnectBaseDelay; final Duration _reconnectMaxDelay; @@ -690,6 +697,103 @@ class ChatSessionController extends ChangeNotifier { users.putIfAbsent(key, () => {}).addAll(modes); } + /// Automatic-mode rules (auto-op / auto-halfop / auto-voice) applied when a + /// matching user joins a channel where we hold the needed privilege. + List get autoModeEntries => + List.unmodifiable(_autoModeEntries); + + Future _loadAutoModeEntries() async { + if (_autoModeEntriesLoaded) { + return; + } + final repository = _userListsRepository; + if (repository != null) { + try { + _autoModeEntries = await repository.loadAll(); + } catch (_) { + _autoModeEntries = const []; + } + } + _autoModeEntriesLoaded = true; + } + + Future addAutoModeEntry(UserListEntry entry) async { + final repository = _userListsRepository; + if (repository != null) { + _autoModeEntries = await repository.add(entry); + } else { + _autoModeEntries = [ + ..._autoModeEntries.where((e) => e.key != entry.key), + entry, + ]; + } + _autoModeEntriesLoaded = true; + notifyListeners(); + } + + Future removeAutoModeEntry(UserListEntry entry) async { + final repository = _userListsRepository; + if (repository != null) { + _autoModeEntries = await repository.remove(entry); + } else { + _autoModeEntries = + _autoModeEntries.where((e) => e.key != entry.key).toList(); + } + notifyListeners(); + } + + /// When [nick] joins [channel], grants the highest auto-mode we are entitled + /// to and the user is listed for. No-op for our own joins or when we lack the + /// needed channel privilege. + void _maybeApplyAutoModes( + String channel, + String nick, + String tabId, { + String? ident, + String? host, + }) { + if (_autoModeEntries.isEmpty || _isSelfNick(nick)) { + return; + } + final ownModes = + _channelUserModes[tabId]?[currentNick.trim().toLowerCase()] ?? + const {}; + final hasOp = ownModes.contains('o') || + ownModes.contains('q') || + ownModes.contains('a'); + final hasHalfOp = ownModes.contains('h'); + for (final type in const [ + UserListType.autoOp, + UserListType.autoHalfOp, + UserListType.autoVoice, + ]) { + final matched = _autoModeEntries.any( + (entry) => + entry.type == type && + entry.matches( + nick: nick, + ident: ident, + host: host, + channel: channel, + networkId: network.id, + ), + ); + if (!matched) { + continue; + } + final canApply = switch (type) { + UserListType.autoOp || UserListType.autoHalfOp => hasOp, + UserListType.autoVoice => hasOp || hasHalfOp, + }; + if (canApply) { + unawaited( + _ircService.sendRaw('MODE $channel +${type.modeChar} $nick'), + ); + return; + } + } + } + Future start() { if (_isDisposed) { return Future.value(); @@ -713,6 +817,7 @@ class ChatSessionController extends ChangeNotifier { if (!_isBootstrapped) { await _commandService.load(); await _loadPersistedState(); + await _loadAutoModeEntries(); if (_isDisposed) { return; } @@ -2565,12 +2670,134 @@ class ChatSessionController extends ChangeNotifier { case 'disconnect': await _ircService.disconnect(rest.isEmpty ? null : rest); return; + case 'autovoice': + await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: false); + return; + case 'unautovoice': + await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: true); + return; + case 'autoop': + await _handleAutoModeCommand(UserListType.autoOp, rest, remove: false); + return; + case 'unautoop': + await _handleAutoModeCommand(UserListType.autoOp, rest, remove: true); + return; + case 'autohalfop': + await _handleAutoModeCommand( + UserListType.autoHalfOp, + rest, + remove: false, + ); + return; + case 'unautohalfop': + await _handleAutoModeCommand( + UserListType.autoHalfOp, + rest, + remove: true, + ); + return; + case 'autolist': + case 'autolists': + _handleAutoListCommand(); + return; default: await _ircService.sendRaw(commandLine); return; } } + Future _handleAutoModeCommand( + UserListType type, + String rest, { + required bool remove, + }) async { + final tokens = rest + .trim() + .split(RegExp(r'\s+')) + .where((token) => token.isNotEmpty) + .toList(); + if (tokens.isEmpty) { + _appendMessage( + tabId: activeTab.id, + sender: 'error', + content: + 'Usage: /${remove ? 'un' : ''}${type.id} [#chan,#chan]', + kind: IrcMessageKind.error, + ); + return; + } + final mask = tokens.first; + final channels = tokens.length > 1 + ? 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 matches = _autoModeEntries + .where( + (entry) => + entry.type == type && + entry.normalizedMask.toLowerCase() == normalized && + (entry.network == null || entry.network == network.id), + ) + .toList(); + for (final match in matches) { + await removeAutoModeEntry(match); + } + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: matches.isEmpty + ? '$mask was not on ${type.label}.' + : 'Removed $mask from ${type.label}.', + kind: IrcMessageKind.system, + ); + return; + } + + await addAutoModeEntry( + UserListEntry( + type: type, + mask: mask, + channels: channels, + network: network.id, + ), + ); + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: channels.isEmpty + ? 'Added $mask to ${type.label}.' + : 'Added $mask to ${type.label} (${channels.join(', ')}).', + kind: IrcMessageKind.system, + ); + } + + void _handleAutoListCommand() { + if (_autoModeEntries.isEmpty) { + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: 'No auto-mode rules configured.', + kind: IrcMessageKind.system, + ); + return; + } + for (final entry in _autoModeEntries) { + final scope = entry.channels.isEmpty + ? 'all channels' + : entry.channels.join(', '); + _appendMessage( + tabId: activeTab.id, + sender: '*', + content: '${entry.type.label}: ${entry.mask} · $scope', + kind: IrcMessageKind.system, + ); + } + } + void _handleHelpCommand(String rest) { final requested = rest.trim(); if (requested.isNotEmpty) { @@ -3618,6 +3845,14 @@ class ChatSessionController extends ChangeNotifier { ); if (nick == (_ircService.currentNick ?? network.nickname)) { _activeTabId = tab.id; + } else { + _maybeApplyAutoModes( + channel, + nick, + tab.id, + ident: identity.ident, + host: identity.host, + ); } } case 'PART': diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart index 8963e00..a609578 100644 --- a/lib/features/chat/application/command_service.dart +++ b/lib/features/chat/application/command_service.dart @@ -701,6 +701,48 @@ class CommandService { description: 'Send a command to BotServ', kind: CommandKind.service, ), + CommandDefinition( + name: 'autovoice', + usage: '/autovoice [#chan,#chan]', + description: 'Auto-voice matching users on join', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'unautovoice', + usage: '/unautovoice ', + description: 'Remove an auto-voice rule', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'autoop', + usage: '/autoop [#chan,#chan]', + description: 'Auto-op matching users on join', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'unautoop', + usage: '/unautoop ', + description: 'Remove an auto-op rule', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'autohalfop', + usage: '/autohalfop [#chan,#chan]', + description: 'Auto-halfop matching users on join', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'unautohalfop', + usage: '/unautohalfop ', + description: 'Remove an auto-halfop rule', + kind: CommandKind.channel, + ), + CommandDefinition( + name: 'autolist', + usage: '/autolist', + description: 'List configured auto-mode rules', + kind: CommandKind.local, + ), ]; static final Map _commandRegistry = { diff --git a/lib/features/chat/data/channel_notes_repository.dart b/lib/features/chat/data/channel_notes_repository.dart new file mode 100644 index 0000000..4f56fd6 --- /dev/null +++ b/lib/features/chat/data/channel_notes_repository.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Local, per-channel free-text notes keyed by `network::channel`. Stored as a +/// single JSON object in shared preferences (mirrors the RN `channelNotes` +/// store). Purely on-device; no IRC protocol involvement. +class ChannelNotesRepository { + ChannelNotesRepository({Future Function()? prefsLoader}) + : _prefsLoader = prefsLoader ?? SharedPreferences.getInstance; + + static const String storageKey = 'channelNotes'; + + final Future Function() _prefsLoader; + + String _compositeKey(String network, String channel) => + '$network::$channel'; + + Future> _readAll(SharedPreferences prefs) async { + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded.map( + (key, value) => MapEntry('$key', '${value ?? ''}'), + ); + } + } catch (_) { + // Corrupt blob: start fresh rather than throw. + } + return {}; + } + + /// Returns the note for [channel] on [network], or an empty string. + Future getNote(String network, String channel) async { + final prefs = await _prefsLoader(); + final all = await _readAll(prefs); + return all[_compositeKey(network, channel)] ?? ''; + } + + /// Saves [note] for [channel] on [network]. An empty/whitespace note removes + /// the entry. + Future setNote(String network, String channel, String note) async { + final prefs = await _prefsLoader(); + final all = await _readAll(prefs); + final key = _compositeKey(network, channel); + final trimmed = note.trim(); + if (trimmed.isEmpty) { + all.remove(key); + } else { + all[key] = trimmed; + } + await prefs.setString(storageKey, jsonEncode(all)); + } + + /// All notes, keyed by `network::channel`. + Future> allNotes() async { + final prefs = await _prefsLoader(); + return _readAll(prefs); + } +} diff --git a/lib/features/chat/data/user_list_entry.dart b/lib/features/chat/data/user_list_entry.dart new file mode 100644 index 0000000..3000bac --- /dev/null +++ b/lib/features/chat/data/user_list_entry.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +/// The kinds of automatic-mode user lists. Each grants a channel mode to a +/// matching user when they join a channel where we hold the needed privilege. +enum UserListType { + autoOp('autoop', 'o', 'Auto-op'), + autoHalfOp('autohalfop', 'h', 'Auto-halfop'), + autoVoice('autovoice', 'v', 'Auto-voice'); + + const UserListType(this.id, this.modeChar, this.label); + + /// Stable storage id. + final String id; + + /// The channel mode letter this list grants (`o`, `h`, `v`). + final String modeChar; + + /// Human-readable label. + final String label; + + static UserListType? fromId(String id) { + for (final type in UserListType.values) { + if (type.id == id) { + return type; + } + } + return null; + } +} + +/// A single automatic-mode rule: grant [type]'s mode to users matching [mask] +/// in the given [channels] (empty = all channels) on the given [network] +/// (null = all networks). +class UserListEntry { + const UserListEntry({ + required this.type, + required this.mask, + this.channels = const [], + this.network, + }); + + final UserListType type; + + /// A `nick`, `nick!user@host`, or wildcard mask (`*` / `?`). A bare nick is + /// treated as `nick!*@*`. + final String mask; + + /// Channels this rule applies to (case-insensitive). Empty means all. + final List channels; + + /// Network id this rule applies to; null means all networks. + final String? network; + + /// Normalizes [mask] to full `nick!user@host` form for matching. + String get normalizedMask { + final trimmed = mask.trim(); + if (trimmed.isEmpty) { + return '*!*@*'; + } + if (!trimmed.contains('!') && !trimmed.contains('@')) { + return '$trimmed!*@*'; + } + return trimmed; + } + + bool appliesToNetwork(String? networkId) => + network == null || network == networkId; + + bool appliesToChannel(String channel) { + if (channels.isEmpty) { + return true; + } + final target = channel.toLowerCase(); + return channels.any((c) => c.trim().toLowerCase() == target); + } + + /// Whether this rule matches the given user on [channel]/[networkId]. + bool matches({ + required String nick, + String? ident, + String? host, + required String channel, + String? networkId, + }) { + if (!appliesToNetwork(networkId) || !appliesToChannel(channel)) { + return false; + } + final target = + '${nick.trim()}!${(ident ?? '*').trim()}@${(host ?? '*').trim()}'; + return maskMatches(normalizedMask, target); + } + + UserListEntry copyWith({ + UserListType? type, + String? mask, + List? channels, + String? network, + bool clearNetwork = false, + }) { + return UserListEntry( + type: type ?? this.type, + mask: mask ?? this.mask, + channels: channels ?? this.channels, + network: clearNetwork ? null : (network ?? this.network), + ); + } + + Map toJson() => { + 'type': type.id, + 'mask': mask, + if (channels.isNotEmpty) 'channels': channels, + if (network != null) 'network': network, + }; + + static UserListEntry? fromJson(Map json) { + final type = UserListType.fromId('${json['type']}'); + final mask = (json['mask'] as String?)?.trim() ?? ''; + if (type == null || mask.isEmpty) { + return null; + } + final rawChannels = json['channels']; + final channels = rawChannels is List + ? rawChannels.map((e) => '$e').where((e) => e.isNotEmpty).toList() + : const []; + return UserListEntry( + type: type, + mask: mask, + channels: channels, + network: (json['network'] as String?)?.trim().isEmpty ?? true + ? null + : (json['network'] as String).trim(), + ); + } + + String encode() => jsonEncode(toJson()); + + /// Stable identity used for de-duplication and removal. + String get key => + '${type.id}|${network ?? '*'}|${normalizedMask.toLowerCase()}|' + '${(List.from(channels)..sort()).join(',').toLowerCase()}'; +} + +/// Case-insensitive IRC mask matching with `*` (any run) and `?` (one char). +bool maskMatches(String mask, String target) { + final pattern = StringBuffer('^'); + for (final rune in mask.runes) { + final char = String.fromCharCode(rune); + switch (char) { + case '*': + pattern.write('.*'); + case '?': + pattern.write('.'); + default: + pattern.write(RegExp.escape(char)); + } + } + pattern.write(r'$'); + return RegExp( + pattern.toString(), + caseSensitive: false, + ).hasMatch(target.trim()); +} diff --git a/lib/features/chat/data/user_lists_repository.dart b/lib/features/chat/data/user_lists_repository.dart new file mode 100644 index 0000000..513302f --- /dev/null +++ b/lib/features/chat/data/user_lists_repository.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persists automatic-mode user-list rules (auto-op / auto-halfop / auto-voice) +/// as a single JSON array in shared preferences. De-duplicates by rule identity. +class UserListsRepository { + UserListsRepository({Future Function()? prefsLoader}) + : _prefsLoader = prefsLoader ?? SharedPreferences.getInstance; + + static const String storageKey = 'userLists'; + + final Future Function() _prefsLoader; + + Future> loadAll() async { + final prefs = await _prefsLoader(); + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) { + return []; + } + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded + .whereType() + .map((e) => UserListEntry.fromJson(Map.from(e))) + .whereType() + .toList(); + } + } catch (_) { + // Corrupt blob: start fresh. + } + return []; + } + + Future _saveAll(List entries) async { + final prefs = await _prefsLoader(); + await prefs.setString( + storageKey, + jsonEncode(entries.map((e) => e.toJson()).toList()), + ); + } + + /// Adds [entry], replacing any existing rule with the same identity. Returns + /// the updated full list. + Future> add(UserListEntry entry) async { + final entries = await loadAll(); + entries.removeWhere((e) => e.key == entry.key); + entries.add(entry); + await _saveAll(entries); + return entries; + } + + /// Removes any rule whose identity matches [entry]. Returns the updated list. + Future> remove(UserListEntry entry) async { + final entries = await loadAll(); + entries.removeWhere((e) => e.key == entry.key); + await _saveAll(entries); + return entries; + } + + Future> replaceAll(List entries) async { + await _saveAll(entries); + return entries; + } +} diff --git a/lib/features/chat/data/user_notes_repository.dart b/lib/features/chat/data/user_notes_repository.dart new file mode 100644 index 0000000..9dc424e --- /dev/null +++ b/lib/features/chat/data/user_notes_repository.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Local, per-user free-text notes keyed by `network::nick`. Stored as a single +/// JSON object in shared preferences (mirrors the RN user-notes store). Purely +/// on-device; no IRC protocol involvement. Nick lookups are case-insensitive. +class UserNotesRepository { + UserNotesRepository({Future Function()? prefsLoader}) + : _prefsLoader = prefsLoader ?? SharedPreferences.getInstance; + + static const String storageKey = 'userNotes'; + + final Future Function() _prefsLoader; + + String _compositeKey(String network, String nick) => + '$network::${nick.toLowerCase()}'; + + Future> _readAll(SharedPreferences prefs) async { + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded.map( + (key, value) => MapEntry('$key', '${value ?? ''}'), + ); + } + } catch (_) { + // Corrupt blob: start fresh rather than throw. + } + return {}; + } + + /// Returns the note for [nick] on [network], or an empty string. + Future getNote(String network, String nick) async { + final prefs = await _prefsLoader(); + final all = await _readAll(prefs); + return all[_compositeKey(network, nick)] ?? ''; + } + + /// Saves [note] for [nick] on [network]. An empty/whitespace note removes the + /// entry. + Future setNote(String network, String nick, String note) async { + final prefs = await _prefsLoader(); + final all = await _readAll(prefs); + final key = _compositeKey(network, nick); + final trimmed = note.trim(); + if (trimmed.isEmpty) { + all.remove(key); + } else { + all[key] = trimmed; + } + await prefs.setString(storageKey, jsonEncode(all)); + } + + /// All notes, keyed by `network::nick`. + Future> allNotes() async { + final prefs = await _prefsLoader(); + return _readAll(prefs); + } +} diff --git a/lib/features/chat/presentation/auto_mode_lists_screen.dart b/lib/features/chat/presentation/auto_mode_lists_screen.dart new file mode 100644 index 0000000..c1c867b --- /dev/null +++ b/lib/features/chat/presentation/auto_mode_lists_screen.dart @@ -0,0 +1,203 @@ +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:flutter/material.dart'; + +/// Manages the automatic-mode rules (auto-op / auto-halfop / auto-voice) for the +/// active session. Operates directly on the controller so live joins and stored +/// rules stay in sync. +class AutoModeListsScreen extends StatelessWidget { + const AutoModeListsScreen({super.key, required this.controller}); + + final ChatSessionController controller; + + Future _add(BuildContext context) async { + final entry = await showDialog( + context: context, + builder: (_) => _AutoModeEntryDialog(network: controller.network.id), + ); + if (entry != null) { + await controller.addAutoModeEntry(entry); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Auto-mode lists')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _add(context), + icon: const Icon(Icons.add), + label: const Text('Add rule'), + ), + body: AnimatedBuilder( + animation: controller, + builder: (context, _) { + final entries = controller.autoModeEntries; + if (entries.isEmpty) { + return const _EmptyState(); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: entries.length + 1, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + if (index == 0) { + return const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Text( + 'When a matching user joins a channel where you hold the ' + 'needed privilege, the mode below is set automatically.', + ), + ); + } + final entry = entries[index - 1]; + final scope = entry.channels.isEmpty + ? 'all channels' + : entry.channels.join(', '); + return ListTile( + leading: Icon(_iconFor(entry.type)), + title: Text(entry.mask), + subtitle: Text('${entry.label} · $scope'), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () => controller.removeAutoModeEntry(entry), + ), + ); + }, + ); + }, + ), + ); + } + + static IconData _iconFor(UserListType type) => switch (type) { + UserListType.autoOp => Icons.shield_moon_outlined, + UserListType.autoHalfOp => Icons.shield_outlined, + UserListType.autoVoice => Icons.record_voice_over_outlined, + }; +} + +extension on UserListEntry { + String get label => type.label; +} + +class _AutoModeEntryDialog extends StatefulWidget { + const _AutoModeEntryDialog({required this.network}); + + final String network; + + @override + State<_AutoModeEntryDialog> createState() => _AutoModeEntryDialogState(); +} + +class _AutoModeEntryDialogState extends State<_AutoModeEntryDialog> { + final TextEditingController _mask = TextEditingController(); + final TextEditingController _channels = TextEditingController(); + UserListType _type = UserListType.autoVoice; + + @override + void dispose() { + _mask.dispose(); + _channels.dispose(); + super.dispose(); + } + + void _save() { + final mask = _mask.text.trim(); + if (mask.isEmpty) { + return; + } + final channels = _channels.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + Navigator.of(context).pop( + UserListEntry( + type: _type, + mask: mask, + channels: channels, + network: widget.network, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add auto-mode rule'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration(labelText: 'Mode'), + items: [ + for (final type in UserListType.values) + DropdownMenuItem(value: type, child: Text(type.label)), + ], + onChanged: (value) => setState(() => _type = value ?? _type), + ), + TextField( + controller: _mask, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Nick or mask', + hintText: 'alice or *!*@*.example.net', + ), + ), + TextField( + controller: _channels, + decoration: const InputDecoration( + labelText: 'Channels (optional)', + hintText: '#flutter, #dart — blank = all', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _save, child: const Text('Save')), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.rule_folder_outlined, + size: 44, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No auto-mode rules', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Add a rule, or use a channel user’s actions to auto-voice or ' + 'auto-op them.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 727b514..2e9a7a8 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -10,9 +10,14 @@ import 'package:androidircx/dcc/services/dcc_file_picker.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/application/session_registry.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:androidircx/features/chat/data/user_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/features/chat/presentation/auto_mode_lists_screen.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; +import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; import 'package:androidircx/features/chat/presentation/ignore_list_screen.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/irc/parser/irc_formatter.dart'; @@ -36,12 +41,22 @@ class ChatScreen extends StatefulWidget { this.networkController, this.onSwitchNetwork, this.onManageNetworks, + this.channelNotesRepository, + this.userNotesRepository, }); final ChatSessionController controller; final DccFilePicker? filePicker; final MediaDownloadService? mediaDownloadService; + /// Local per-channel notes store; defaults to a shared-preferences backed + /// instance. Injectable for tests. + final ChannelNotesRepository? channelNotesRepository; + + /// Local per-user (nick) notes store; defaults to a shared-preferences + /// backed instance. Injectable for tests. + final UserNotesRepository? userNotesRepository; + /// Live sessions across all networks, used by the in-chat network switcher. final SessionRegistry? sessionRegistry; @@ -75,6 +90,14 @@ class _ChatScreenState extends State { widget.filePicker ?? const MethodChannelDccFilePicker(); MediaDownloadService get _mediaDownloadService => widget.mediaDownloadService ?? createMediaDownloadService(); + ChannelNotesRepository? _defaultChannelNotes; + ChannelNotesRepository get _channelNotesRepository => + widget.channelNotesRepository ?? + (_defaultChannelNotes ??= ChannelNotesRepository()); + UserNotesRepository? _defaultUserNotes; + UserNotesRepository get _userNotesRepository => + widget.userNotesRepository ?? + (_defaultUserNotes ??= UserNotesRepository()); @override void initState() { @@ -246,6 +269,20 @@ class _ChatScreenState extends State { ); }, ), + ListTile( + leading: const Icon(Icons.rule_outlined), + title: const Text('Auto-mode lists'), + subtitle: const Text('Auto-op, auto-halfop, auto-voice'), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + AutoModeListsScreen(controller: _controller), + ), + ); + }, + ), ListTile( leading: const Icon(Icons.info_outline), title: const Text('Connection details'), @@ -662,6 +699,11 @@ class _ChatScreenState extends State { } Future _showChannelUserActions(String nick) async { + final network = _controller.network.id; + final note = await _userNotesRepository.getNote(network, nick); + if (!mounted) { + return; + } await showModalBottomSheet( context: context, builder: (sheetContext) { @@ -677,7 +719,8 @@ class _ChatScreenState extends State { } return SafeArea( - child: Column( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( @@ -685,9 +728,22 @@ class _ChatScreenState extends State { nick, style: Theme.of(sheetContext).textTheme.titleMedium, ), - subtitle: const Text('Channel user actions'), + subtitle: Text( + note.isEmpty ? 'Channel user actions' : 'Note: $note', + ), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.sticky_note_2_outlined), + title: Text(note.isEmpty ? 'Add note' : 'Edit note'), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited(_showUserNoteDialog(nick, note)); + }, + ), + for (final type in UserListType.values) + _autoModeToggleTile(sheetContext, nick, type), + const Divider(height: 1), action('WHOIS', Icons.badge_outlined, ChannelUserAction.whois), action( 'Open query', @@ -709,6 +765,7 @@ class _ChatScreenState extends State { action('Kick', Icons.logout, ChannelUserAction.kick), action('Ban', Icons.block, ChannelUserAction.ban), ], + ), ), ); }, @@ -829,6 +886,136 @@ class _ChatScreenState extends State { _controller.selectTab(tab.id); Navigator.of(context).pop(); }, + onLongPress: tab.type == ChatTabType.channel + ? () { + Navigator.of(context).pop(); + unawaited(_showChannelNoteDialog(tab)); + } + : null, + ); + } + + Future _showChannelNoteDialog(ChatTab tab) async { + final network = _controller.network.id; + final existing = await _channelNotesRepository.getNote(network, tab.name); + if (!mounted) { + return; + } + final result = await showDialog( + context: context, + builder: (context) => _NoteDialog( + title: 'Note for ${tab.name}', + hint: 'Notes for this channel (stored only on this device)', + initialText: existing, + ), + ); + if (result == null) { + return; + } + await _channelNotesRepository.setNote(network, tab.name, result); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.trim().isEmpty ? 'Channel note cleared.' : 'Channel note saved.', + ), + ), + ); + } + + Future _showUserNoteDialog(String nick, String existing) async { + final network = _controller.network.id; + final result = await showDialog( + context: context, + builder: (context) => _NoteDialog( + title: 'Note for $nick', + hint: 'Notes about this user (stored only on this device)', + initialText: existing, + ), + ); + if (result == null) { + return; + } + await _userNotesRepository.setNote(network, nick, result); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.trim().isEmpty ? 'User note cleared.' : 'User note saved.', + ), + ), + ); + } + + UserListEntry? _existingAutoModeEntry(String nick, UserListType type) { + final normalized = '${nick.trim().toLowerCase()}!*@*'; + for (final entry in _controller.autoModeEntries) { + if (entry.type == type && + entry.normalizedMask.toLowerCase() == normalized && + (entry.network == null || entry.network == _controller.network.id)) { + return entry; + } + } + return null; + } + + Widget _autoModeToggleTile( + BuildContext sheetContext, + String nick, + UserListType type, + ) { + final existing = _existingAutoModeEntry(nick, type); + final active = existing != null; + return ListTile( + leading: Icon( + switch (type) { + UserListType.autoOp => Icons.shield_moon_outlined, + UserListType.autoHalfOp => Icons.shield_outlined, + UserListType.autoVoice => Icons.record_voice_over_outlined, + }, + ), + title: Text(type.label), + trailing: active + ? const Icon(Icons.check, size: 18) + : const Icon(Icons.add, size: 18), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited(_toggleAutoMode(nick, type, existing)); + }, + ); + } + + Future _toggleAutoMode( + String nick, + UserListType type, + UserListEntry? existing, + ) async { + if (existing != null) { + await _controller.removeAutoModeEntry(existing); + } else { + await _controller.addAutoModeEntry( + UserListEntry( + type: type, + mask: nick, + network: _controller.network.id, + ), + ); + } + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + existing != null + ? 'Removed $nick from ${type.label}.' + : 'Added $nick to ${type.label}.', + ), + ), ); } @@ -1863,6 +2050,57 @@ class _ServiceQuickActions extends StatelessWidget { } } +class _NoteDialog extends StatefulWidget { + const _NoteDialog({ + required this.title, + required this.hint, + required this.initialText, + }); + + final String title; + final String hint; + final String initialText; + + @override + State<_NoteDialog> createState() => _NoteDialogState(); +} + +class _NoteDialogState extends State<_NoteDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialText, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + minLines: 3, + maxLines: 6, + decoration: InputDecoration(hintText: widget.hint), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_controller.text), + child: const Text('Save'), + ), + ], + ); + } +} + class _MessageList extends StatelessWidget { const _MessageList({ required this.messages, @@ -2462,11 +2700,20 @@ class _AttachmentCard extends StatelessWidget { borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), - onTap: url == null - ? null - : () => isImage - ? _showImagePreview(context, url) - : _openExternalUrl(url), + onTap: () { + switch (attachmentTapAction(attachment)) { + case AttachmentTapAction.none: + break; + case AttachmentTapAction.imagePreview: + unawaited(_showImagePreview(context, url!)); + case AttachmentTapAction.playVideo: + _openMediaPlayer(context, url!, isAudio: false, title: title); + case AttachmentTapAction.playAudio: + _openMediaPlayer(context, url!, isAudio: true, title: title); + case AttachmentTapAction.external: + unawaited(_openExternalUrl(url!)); + } + }, child: Padding( padding: const EdgeInsets.all(10), child: Column( @@ -2786,6 +3033,51 @@ Future _openExternalUrl(String rawUrl) async { await launchUrl(uri, mode: LaunchMode.platformDefault); } +/// How tapping an attachment should behave. Extracted for testing. +enum AttachmentTapAction { none, imagePreview, playVideo, playAudio, external } + +AttachmentTapAction attachmentTapAction(IrcMessageAttachment attachment) { + final url = attachment.uri; + if (url == null) { + return AttachmentTapAction.none; + } + switch (attachment.type) { + case IrcMessageAttachmentType.image: + return AttachmentTapAction.imagePreview; + case IrcMessageAttachmentType.video: + return AttachmentTapAction.playVideo; + case IrcMessageAttachmentType.audio: + return AttachmentTapAction.playAudio; + case IrcMessageAttachmentType.url: + if (isVideoUrl(url)) { + return AttachmentTapAction.playVideo; + } + if (isAudioUrl(url)) { + return AttachmentTapAction.playAudio; + } + return AttachmentTapAction.external; + case IrcMessageAttachmentType.file: + case IrcMessageAttachmentType.media: + case IrcMessageAttachmentType.dccChat: + case IrcMessageAttachmentType.dccSend: + return AttachmentTapAction.external; + } +} + +void _openMediaPlayer( + BuildContext context, + String url, { + required bool isAudio, + String? title, +}) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + MediaPlayerScreen(url: url, isAudio: isAudio, title: title), + ), + ); +} + Future _copyToClipboard(BuildContext context, String text) async { await Clipboard.setData(ClipboardData(text: text)); if (!context.mounted) { diff --git a/lib/features/chat/presentation/media_player_screen.dart b/lib/features/chat/presentation/media_player_screen.dart new file mode 100644 index 0000000..047c4e7 --- /dev/null +++ b/lib/features/chat/presentation/media_player_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// In-app audio/video player for a media URL. Uses [VideoPlayerController] for +/// both (audio just hides the video surface), mirroring the RN player. +class MediaPlayerScreen extends StatefulWidget { + const MediaPlayerScreen({ + super.key, + required this.url, + this.isAudio = false, + this.title, + @visibleForTesting this.controllerFactory, + }); + + final String url; + final bool isAudio; + final String? title; + + /// Overridable for tests so a real platform player is not created. + @visibleForTesting + final VideoPlayerController Function(Uri uri)? controllerFactory; + + @override + State createState() => _MediaPlayerScreenState(); +} + +class _MediaPlayerScreenState extends State { + VideoPlayerController? _controller; + Object? _error; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + try { + final uri = Uri.parse(widget.url); + final controller = + widget.controllerFactory?.call(uri) ?? + VideoPlayerController.networkUrl(uri); + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + controller.addListener(_onTick); + setState(() => _controller = controller); + await controller.play(); + } catch (error) { + if (mounted) { + setState(() => _error = error); + } + } + } + + void _onTick() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + void _togglePlay() { + final controller = _controller; + if (controller == null) { + return; + } + controller.value.isPlaying ? controller.pause() : controller.play(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title ?? 'Media')), + body: Center(child: _buildBody(context)), + ); + } + + Widget _buildBody(BuildContext context) { + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 44), + const SizedBox(height: 12), + Text( + 'Could not play this media.', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '$_error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + final controller = _controller; + if (controller == null || !controller.value.isInitialized) { + return const CircularProgressIndicator(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isAudio) + Padding( + padding: const EdgeInsets.all(32), + child: Icon( + Icons.audiotrack, + size: 96, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + AspectRatio( + aspectRatio: controller.value.aspectRatio == 0 + ? 16 / 9 + : controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + VideoProgressIndicator(controller, allowScrubbing: true), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + IconButton( + iconSize: 40, + onPressed: _togglePlay, + icon: Icon( + controller.value.isPlaying + ? Icons.pause_circle + : Icons.play_circle, + ), + ), + const SizedBox(width: 8), + Text( + '${_format(controller.value.position)} / ' + '${_format(controller.value.duration)}', + ), + ], + ), + ), + ], + ); + } + + static String _format(Duration d) { + final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + final hours = d.inHours; + return hours > 0 ? '$hours:$minutes:$seconds' : '$minutes:$seconds'; + } +} diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index efdde76..04c521a 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -60,6 +60,7 @@ class NetworkListController extends ChangeNotifier { bool useClientCertificate = false, String? clientCertificatePem, String? clientPrivateKeyPem, + String? clientPkcs12Base64, String? clientKeyPassphrase, String? networkId, }) async { @@ -102,15 +103,27 @@ class NetworkListController extends ChangeNotifier { final certPem = (clientCertificatePem ?? '').trim(); final keyPem = (clientPrivateKeyPem ?? '').trim(); - if (useClientCertificate && certPem.isNotEmpty && keyPem.isNotEmpty) { + final pkcs12 = (clientPkcs12Base64 ?? '').trim(); + final passphrase = (clientKeyPassphrase ?? '').trim().isEmpty + ? null + : clientKeyPassphrase; + if (useClientCertificate && pkcs12.isNotEmpty) { + await _certificateStore.save( + network.id, + ClientCertificate( + pkcs12Base64: pkcs12, + privateKeyPassphrase: passphrase, + ), + ); + } else if (useClientCertificate && + certPem.isNotEmpty && + keyPem.isNotEmpty) { await _certificateStore.save( network.id, ClientCertificate( certificatePem: certPem, privateKeyPem: keyPem, - privateKeyPassphrase: (clientKeyPassphrase ?? '').trim().isEmpty - ? null - : clientKeyPassphrase, + privateKeyPassphrase: passphrase, ), ); } diff --git a/lib/features/connections/data/pem_bundle.dart b/lib/features/connections/data/pem_bundle.dart new file mode 100644 index 0000000..4a91683 --- /dev/null +++ b/lib/features/connections/data/pem_bundle.dart @@ -0,0 +1,33 @@ +/// Result of parsing a PEM file that may contain a certificate, a private key, +/// or both (a combined CertFP `.pem` as produced by the usual openssl one-liner). +class PemBundle { + const PemBundle({this.certificate, this.privateKey}); + + final String? certificate; + final String? privateKey; + + bool get hasCertificate => (certificate ?? '').isNotEmpty; + bool get hasPrivateKey => (privateKey ?? '').isNotEmpty; + bool get isEmpty => !hasCertificate && !hasPrivateKey; + + static final RegExp _certificate = RegExp( + r'-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----', + ); + static final RegExp _privateKey = RegExp( + r'-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----' + r'[\s\S]*?' + r'-----END (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----', + ); + + /// Extracts every certificate block and the first private-key block from + /// [text]. Returns an empty bundle when no PEM blocks are present (e.g. the + /// file is binary DER/PKCS#12). + static PemBundle parse(String text) { + final certs = _certificate.allMatches(text).map((m) => m.group(0)!).toList(); + final keyMatch = _privateKey.firstMatch(text); + return PemBundle( + certificate: certs.isEmpty ? null : certs.join('\n'), + privateKey: keyMatch?.group(0), + ); + } +} diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index fd56bfa..19052e9 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:androidircx/core/models/identity_profile.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/identity_profile_repository.dart'; +import 'package:androidircx/dcc/services/dcc_file_picker.dart'; +import 'package:androidircx/features/connections/data/pem_bundle.dart'; import 'package:flutter/material.dart'; class NetworkFormResult { @@ -34,6 +38,7 @@ class NetworkFormResult { this.useClientCertificate = false, this.clientCertificatePem, this.clientPrivateKeyPem, + this.clientPkcs12Base64, this.clientKeyPassphrase, }); @@ -64,11 +69,18 @@ class NetworkFormResult { final bool useClientCertificate; final String? clientCertificatePem; final String? clientPrivateKeyPem; + final String? clientPkcs12Base64; final String? clientKeyPassphrase; } class NetworkFormScreen extends StatefulWidget { - const NetworkFormScreen({super.key, this.initialValue, this.profileRepository}); + const NetworkFormScreen({ + super.key, + this.initialValue, + this.profileRepository, + this.certificateFilePicker, + this.certificateFileReader, + }); final NetworkConfig? initialValue; @@ -76,6 +88,14 @@ class NetworkFormScreen extends StatefulWidget { /// shared-prefs storage. final IdentityProfileRepository? profileRepository; + /// Picks a certificate/key file to import; defaults to the native document + /// picker. Injectable for tests. + final DccFilePicker? certificateFilePicker; + + /// Reads the picked file's bytes; defaults to `dart:io`. Injectable for + /// tests. PEM files decode as text; binary .p12/.pfx are kept as bytes. + final Future> Function(String path)? certificateFileReader; + @override State createState() => _NetworkFormScreenState(); } @@ -112,6 +132,7 @@ class _NetworkFormScreenState extends State { late final TextEditingController _clientKeyController; late final TextEditingController _clientKeyPassphraseController; late bool _useClientCertificate; + String? _clientPkcs12Base64; @override void initState() { @@ -223,6 +244,73 @@ class _NetworkFormScreenState extends State { super.dispose(); } + void _showFormSnack(String message) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + Future _importCertificateFile() async { + final picker = + widget.certificateFilePicker ?? const MethodChannelDccFilePicker(); + final path = await picker.pickFile(); + if (path == null || !mounted) { + return; + } + List bytes; + try { + final reader = widget.certificateFileReader ?? _defaultReadCertBytes; + bytes = await reader(path); + } catch (_) { + _showFormSnack('Could not read the selected file.'); + return; + } + + // Decode as UTF-8 to detect PEM; binary .p12/.pfx will either fail to + // decode or contain no PEM blocks. + String? text; + try { + text = utf8.decode(bytes); + } catch (_) { + text = null; + } + final bundle = text == null ? const PemBundle() : PemBundle.parse(text); + + if (!bundle.isEmpty) { + setState(() { + _clientPkcs12Base64 = null; + if (bundle.hasCertificate) { + _clientCertController.text = bundle.certificate!; + } + if (bundle.hasPrivateKey) { + _clientKeyController.text = bundle.privateKey!; + } + }); + final parts = [ + if (bundle.hasCertificate) 'certificate', + if (bundle.hasPrivateKey) 'private key', + ].join(' and '); + _showFormSnack('Imported $parts from file.'); + return; + } + + // Binary PKCS#12 (.p12/.pfx): keep the bytes, TLS parses them natively. + setState(() { + _clientPkcs12Base64 = base64.encode(bytes); + _clientCertController.clear(); + _clientKeyController.clear(); + }); + _showFormSnack( + 'Imported PKCS#12 bundle. Enter its password below if it has one.', + ); + } + + static Future> _defaultReadCertBytes(String path) => + File(path).readAsBytes(); + @override Widget build(BuildContext context) { return Scaffold( @@ -331,31 +419,60 @@ class _NetworkFormScreenState extends State { setState(() => _useClientCertificate = value), ), if (_useClientCertificate) ...[ - TextFormField( - controller: _clientCertController, - minLines: 2, - maxLines: 4, - decoration: const InputDecoration( - labelText: 'Client certificate PEM', - helperText: - 'Paste -----BEGIN CERTIFICATE-----; leave empty to keep the stored one.', + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + key: const Key('network-form-import-cert'), + onPressed: () => unawaited(_importCertificateFile()), + icon: const Icon(Icons.file_open_outlined), + label: const Text('Import from file (.pem / .p12)'), ), ), - const SizedBox(height: 16), - TextFormField( - controller: _clientKeyController, - minLines: 2, - maxLines: 4, - decoration: const InputDecoration( - labelText: 'Private key PEM', + const SizedBox(height: 8), + if (_clientPkcs12Base64 != null) + ListTile( + key: const Key('network-form-pkcs12-loaded'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.lock_outline), + title: const Text('PKCS#12 bundle loaded'), + subtitle: const Text( + 'The .p12/.pfx will be used for the TLS handshake.', + ), + trailing: TextButton( + onPressed: () => + setState(() => _clientPkcs12Base64 = null), + child: const Text('Clear'), + ), + ) + else ...[ + TextFormField( + controller: _clientCertController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Client certificate PEM', + helperText: + 'Paste or import -----BEGIN CERTIFICATE-----; leave empty to keep the stored one.', + ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), + TextFormField( + controller: _clientKeyController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Private key PEM', + ), + ), + const SizedBox(height: 16), + ], TextFormField( controller: _clientKeyPassphraseController, obscureText: true, - decoration: const InputDecoration( - labelText: 'Private key passphrase (optional)', + decoration: InputDecoration( + labelText: _clientPkcs12Base64 != null + ? 'PKCS#12 password (optional)' + : 'Private key passphrase (optional)', ), ), const SizedBox(height: 16), @@ -666,6 +783,7 @@ class _NetworkFormScreenState extends State { useClientCertificate: _useClientCertificate, clientCertificatePem: _clientCertController.text, clientPrivateKeyPem: _clientKeyController.text, + clientPkcs12Base64: _clientPkcs12Base64, clientKeyPassphrase: _clientKeyPassphraseController.text, ), ); diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 6f9b0fb..53dccdb 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -157,6 +157,7 @@ class NetworkListScreen extends StatelessWidget { useClientCertificate: result.useClientCertificate, clientCertificatePem: result.clientCertificatePem, clientPrivateKeyPem: result.clientPrivateKeyPem, + clientPkcs12Base64: result.clientPkcs12Base64, clientKeyPassphrase: result.clientKeyPassphrase, networkId: initialValue?.id, ); diff --git a/lib/features/security/presentation/app_lock_gate.dart b/lib/features/security/presentation/app_lock_gate.dart index 1b69f39..bcfa1a2 100644 --- a/lib/features/security/presentation/app_lock_gate.dart +++ b/lib/features/security/presentation/app_lock_gate.dart @@ -47,8 +47,10 @@ class _AppLockGateState extends State if (!widget.enabled) { _unlocked = true; } else if (!oldWidget.enabled && widget.enabled) { - _unlocked = false; - WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock()); + // 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; } } diff --git a/lib/features/settings/presentation/crash_reports_screen.dart b/lib/features/settings/presentation/crash_reports_screen.dart new file mode 100644 index 0000000..4150100 --- /dev/null +++ b/lib/features/settings/presentation/crash_reports_screen.dart @@ -0,0 +1,199 @@ +import 'package:androidircx/core/diagnostics/crash_report.dart'; +import 'package:androidircx/core/diagnostics/crash_reporter.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Lists on-device crash reports and lets the user email one to the AndroidIRCX +/// contact address via their own mail client. Nothing is sent automatically. +class CrashReportsScreen extends StatefulWidget { + CrashReportsScreen({super.key, CrashReporter? reporter, this.launcher}) + : reporter = reporter ?? CrashReporter(); + + final CrashReporter reporter; + + /// Overridable mail launcher for tests. Returns whether the URI was launched. + final Future Function(Uri uri)? launcher; + + @override + State createState() => _CrashReportsScreenState(); +} + +class _CrashReportsScreenState extends State { + List? _reports; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final reports = await widget.reporter.loadReports(); + if (!mounted) { + return; + } + setState(() => _reports = reports); + } + + Future _launch(Uri uri) { + final launcher = widget.launcher; + if (launcher != null) { + return launcher(uri); + } + return launchUrl(uri, mode: LaunchMode.externalApplication); + } + + Future _email(CrashReport report) async { + final uri = widget.reporter.buildMailtoUri(report); + final ok = await _launch(uri); + if (!mounted) { + return; + } + if (!ok) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No email app available to send the report.'), + ), + ); + } + } + + Future _clear() async { + await widget.reporter.clear(); + await _load(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Crash reports cleared.')), + ); + } + + @override + Widget build(BuildContext context) { + final reports = _reports; + return Scaffold( + appBar: AppBar( + title: const Text('Crash reports'), + actions: [ + if (reports != null && reports.isNotEmpty) + IconButton( + key: const Key('crash-reports-clear'), + icon: const Icon(Icons.delete_outline), + tooltip: 'Clear all', + onPressed: _clear, + ), + ], + ), + body: reports == null + ? const Center(child: CircularProgressIndicator()) + : reports.isEmpty + ? const _EmptyState() + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: reports.length + 1, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + if (index == 0) { + return const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Text( + 'Reports are stored only on this device and sanitized of ' + 'passwords. Sending one opens your email app addressed to ' + 'the AndroidIRCX team — nothing is sent automatically.', + ), + ); + } + final report = reports[index - 1]; + return _CrashReportTile( + report: report, + onEmail: () => _email(report), + ); + }, + ), + ); + } +} + +class _CrashReportTile extends StatelessWidget { + const _CrashReportTile({required this.report, required this.onEmail}); + + final CrashReport report; + final VoidCallback onEmail; + + @override + Widget build(BuildContext context) { + final firstLine = report.message.split('\n').first; + return ExpansionTile( + leading: Icon( + report.fatal ? Icons.error_outline : Icons.report_gmailerrorred, + color: report.fatal + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text( + firstLine.isEmpty ? '(no message)' : firstLine, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${report.source} · ${report.timestamp.toLocal()}', + style: Theme.of(context).textTheme.bodySmall, + ), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + children: [ + Align( + alignment: Alignment.centerLeft, + child: SelectableText( + report.toPlainText(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: onEmail, + icon: const Icon(Icons.email_outlined), + label: const Text('Email report'), + ), + ), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline, + size: 44, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No crash reports', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'If the app ever crashes, a sanitized report appears here so you ' + 'can email it to the AndroidIRCX team.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 4046460..2df60d6 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -9,9 +9,11 @@ import 'package:androidircx/features/connections/presentation/profiles_screen.da import 'package:androidircx/features/connections/presentation/server_directory_picker.dart'; import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:androidircx/features/settings/presentation/backup_screen.dart'; +import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; import 'package:androidircx/features/settings/presentation/theme_editor_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:local_auth/local_auth.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ @@ -20,6 +22,7 @@ class SettingsScreen extends StatefulWidget { this.settingsController, this.networkController, this.presetService, + this.appLockAuthenticator, }); final SettingsRepository? repository; @@ -30,6 +33,10 @@ class SettingsScreen extends StatefulWidget { final NetworkListController? networkController; final ServerPresetService? presetService; + /// Confirms the user can authenticate before app lock is enabled. Overridable + /// for tests; defaults to a biometric/PIN prompt. + final Future Function()? appLockAuthenticator; + @override State createState() => _SettingsScreenState(); } @@ -91,6 +98,37 @@ class _SettingsScreenState extends State { : ListView( padding: const EdgeInsets.all(16), children: [ + _SettingsSection( + title: 'Connections', + children: [ + if (widget.networkController != null) + ListTile( + leading: const Icon(Icons.public), + title: const Text('Server directory'), + subtitle: const Text( + 'Add a network from the online IRC server list.', + ), + onTap: () => showServerDirectoryPicker( + context, + widget.networkController!, + presetService: widget.presetService, + ), + ), + ListTile( + leading: const Icon(Icons.badge_outlined), + title: const Text('Identity profiles'), + subtitle: const Text( + 'Reusable nick/realname identities to attach to networks.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ProfilesScreen(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), _SettingsSection( title: 'Appearance', children: [ @@ -388,116 +426,6 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), - _SettingsSection( - title: 'Help', - children: [ - ListTile( - key: const Key('settings-help-topic'), - leading: const Icon(Icons.help_outline), - title: const Text('IRC help'), - subtitle: const Text( - 'Connection, SASL, channel keys, DCC, and proxy notes.', - ), - onTap: () => - _showInfoDialog(title: 'IRC help', body: _helpText), - ), - const Divider(height: 1), - ListTile( - key: const Key('settings-privacy-topic'), - leading: const Icon(Icons.privacy_tip_outlined), - title: const Text('Privacy'), - subtitle: const Text( - 'What stays on-device and what goes to IRC servers.', - ), - onTap: () => _showInfoDialog( - title: 'Privacy', - body: _privacyText, - ), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.shield_outlined), - title: const Text('Data & privacy'), - subtitle: const Text( - 'How your data is stored and the privacy policy.', - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DataPrivacyScreen(), - ), - ), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.backup_outlined), - title: const Text('Backup & restore'), - subtitle: const Text( - 'Export or import networks, settings, and profiles.', - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const BackupScreen(), - ), - ), - ), - const Divider(height: 1), - ListTile( - key: const Key('settings-support-topic'), - leading: const Icon(Icons.support_agent_outlined), - title: const Text('Support'), - subtitle: const Text( - 'What to include when reporting a connection issue.', - ), - onTap: () => _showInfoDialog( - title: 'Support', - body: _supportText, - ), - ), - const Divider(height: 1), - ListTile( - key: const Key('settings-release-audit-topic'), - leading: const Icon(Icons.verified_outlined), - title: const Text('Release audit'), - subtitle: const Text( - 'Package, version, permissions, and signing gates.', - ), - onTap: () => _showInfoDialog( - title: 'Release audit', - body: _releaseAuditText, - ), - ), - ], - ), - _SettingsSection( - title: 'Connections', - children: [ - if (widget.networkController != null) - ListTile( - leading: const Icon(Icons.public), - title: const Text('Server directory'), - subtitle: const Text( - 'Add a network from the online IRC server list.', - ), - onTap: () => showServerDirectoryPicker( - context, - widget.networkController!, - presetService: widget.presetService, - ), - ), - ListTile( - leading: const Icon(Icons.badge_outlined), - title: const Text('Identity profiles'), - subtitle: const Text( - 'Reusable nick/realname identities to attach to networks.', - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ProfilesScreen(), - ), - ), - ), - ], - ), _SettingsSection( title: 'Security', children: [ @@ -509,9 +437,7 @@ class _SettingsScreenState extends State { 'Require fingerprint/PIN to open the app.', ), value: _settings.appLockEnabled, - onChanged: (value) => _saveSettings( - _settings.copyWith(appLockEnabled: value), - ), + onChanged: (value) => _toggleAppLock(value), ), const Divider(height: 1), SwitchListTile( @@ -528,6 +454,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Notifications', children: [ @@ -578,6 +505,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Display', children: [ @@ -603,6 +531,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Writing', children: [ @@ -628,6 +557,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Highlighting', children: [ @@ -653,6 +583,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Away', children: [ @@ -709,6 +640,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Message history', children: [ @@ -751,6 +683,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Channels', children: [ @@ -767,6 +700,101 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), + _SettingsSection( + title: 'Help', + children: [ + ListTile( + key: const Key('settings-help-topic'), + leading: const Icon(Icons.help_outline), + title: const Text('IRC help'), + subtitle: const Text( + 'Connection, SASL, channel keys, DCC, and proxy notes.', + ), + onTap: () => + _showInfoDialog(title: 'IRC help', body: _helpText), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-privacy-topic'), + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('Privacy'), + subtitle: const Text( + 'What stays on-device and what goes to IRC servers.', + ), + onTap: () => _showInfoDialog( + title: 'Privacy', + body: _privacyText, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.shield_outlined), + title: const Text('Data & privacy'), + subtitle: const Text( + 'How your data is stored and the privacy policy.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DataPrivacyScreen(), + ), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.backup_outlined), + title: const Text('Backup & restore'), + subtitle: const Text( + 'Export or import networks, settings, and profiles.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const BackupScreen(), + ), + ), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-support-topic'), + leading: const Icon(Icons.support_agent_outlined), + title: const Text('Support'), + subtitle: const Text( + 'What to include when reporting a connection issue.', + ), + onTap: () => _showInfoDialog( + title: 'Support', + body: _supportText, + ), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-release-audit-topic'), + leading: const Icon(Icons.verified_outlined), + title: const Text('Release audit'), + subtitle: const Text( + 'Package, version, permissions, and signing gates.', + ), + onTap: () => _showInfoDialog( + title: 'Release audit', + body: _releaseAuditText, + ), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-crash-reports'), + leading: const Icon(Icons.bug_report_outlined), + title: const Text('Crash reports'), + subtitle: const Text( + 'Review and email crash reports to the AndroidIRCX team.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CrashReportsScreen(), + ), + ), + ), + ], + ), ], ), ), @@ -867,6 +895,58 @@ class _SettingsScreenState extends State { ); } + Future _defaultAppLockAuth() async { + try { + final auth = LocalAuthentication(); + final supported = + await auth.isDeviceSupported() || await auth.canCheckBiometrics; + if (!supported) { + return false; + } + return await auth.authenticate( + localizedReason: 'Confirm your fingerprint or PIN to enable app lock', + biometricOnly: false, + persistAcrossBackgrounding: true, + ); + } catch (_) { + return false; + } + } + + 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. + if (!value) { + await _saveSettings(_settings.copyWith(appLockEnabled: false)); + return; + } + final confirmed = + await (widget.appLockAuthenticator ?? _defaultAppLockAuth)(); + if (!mounted) { + return; + } + if (!confirmed) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'App lock not enabled: could not verify fingerprint or PIN. ' + 'Set up a screen lock on your device first.', + ), + ), + ); + return; + } + await _saveSettings(_settings.copyWith(appLockEnabled: true)); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('App lock enabled. It will lock when you leave the app.'), + ), + ); + } + Future _saveMediaDownloadDirectory(String value) { return _saveSettings( _settings.copyWith(mediaDownloadDirectoryPath: value.trim()), diff --git a/lib/irc/services/irc_transport_connector_io.dart b/lib/irc/services/irc_transport_connector_io.dart index 5c1e8c5..2c8244a 100644 --- a/lib/irc/services/irc_transport_connector_io.dart +++ b/lib/irc/services/irc_transport_connector_io.dart @@ -21,6 +21,22 @@ Future connectDefaultTransport( /// SASL EXTERNAL / CertFP. SecurityContext buildClientSecurityContext(ClientCertificate certificate) { final context = SecurityContext(withTrustedRoots: true); + if (certificate.isPkcs12) { + // Dart's SecurityContext parses PKCS#12 natively for both the certificate + // chain and the private key, using the import password. + final bytes = base64.decode( + certificate.pkcs12Base64!.replaceAll(RegExp(r'\s'), ''), + ); + context.useCertificateChainBytes( + bytes, + password: certificate.privateKeyPassphrase, + ); + context.usePrivateKeyBytes( + bytes, + password: certificate.privateKeyPassphrase, + ); + return context; + } context.useCertificateChainBytes(utf8.encode(certificate.certificatePem)); context.usePrivateKeyBytes( utf8.encode(certificate.privateKeyPem), diff --git a/lib/main.dart b/lib/main.dart index c030f14..3e86d59 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,12 @@ import 'package:androidircx/app/app.dart'; +import 'package:androidircx/core/diagnostics/crash_reporter.dart'; import 'package:flutter/widgets.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); + // Capture uncaught framework/platform errors into on-device crash reports. + // Nothing is sent anywhere automatically; the user emails a report manually + // from Settings if they choose. + CrashReporter().install(); runApp(const AndroidIrcxApp()); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cb77adc..15575c2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import in_app_review import local_auth_darwin import shared_preferences_foundation import url_launcher_macos +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) @@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index d94dbb3..ba01af8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.9.0" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -384,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -1029,6 +1045,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: e229676f8fade3e0124482495aaa2b548872cc4018b6268adfca4868be16aa74 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: c238f5f0a26845cd0bcc2956049b63065a4d3c40ddfc22c3414cd170bef7fff9 + url: "https://pub.dev" + source: hosted + version: "2.11.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.dev" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -1094,5 +1150,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.1 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 80e3b66..f67f5f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: androidircx -description: "A new Flutter project." +description: "AndroidIRCx Flutter - A powerful and feature-rich IRC client for Android" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -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.2+4 +version: 1.0.3+5 environment: sdk: ^3.11.1 @@ -48,6 +48,7 @@ dependencies: cryptography: ^2.9.0 image_picker: ^1.2.3 in_app_review: ^2.0.12 + video_player: ^2.9.2 dev_dependencies: flutter_test: diff --git a/test/app_lock_gate_test.dart b/test/app_lock_gate_test.dart new file mode 100644 index 0000000..9faf8cb --- /dev/null +++ b/test/app_lock_gate_test.dart @@ -0,0 +1,83 @@ +import 'package:androidircx/features/security/presentation/app_lock_gate.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Host extends StatefulWidget { + const _Host({required this.unlock}); + final AppUnlockCallback unlock; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> { + bool _enabled = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () => setState(() => _enabled = true), + child: const Text('enable'), + ), + Expanded( + child: AppLockGate( + enabled: _enabled, + unlock: widget.unlock, + child: const Text('home content'), + ), + ), + ], + ), + ), + ); + } +} + +void main() { + testWidgets('enabling app lock at runtime does not immediately lock', ( + tester, + ) async { + var unlockCalls = 0; + await tester.pumpWidget( + _Host( + unlock: () async { + unlockCalls++; + return true; + }, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('home content'), findsOneWidget); + + // Toggle app lock on while the app is in the foreground. + await tester.tap(find.text('enable')); + await tester.pumpAndSettle(); + + // Stays unlocked; no unlock prompt fired, no lock screen shown. + expect(find.text('home content'), findsOneWidget); + expect(find.text('AndroidIRCX is locked'), findsNothing); + expect(unlockCalls, 0); + }); + + testWidgets('app launched with lock enabled shows the lock screen', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: AppLockGate( + enabled: true, + unlock: () async => false, + child: const Text('home content'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('AndroidIRCX is locked'), findsOneWidget); + expect(find.text('home content'), findsNothing); + }); +} diff --git a/test/app_lock_settings_test.dart b/test/app_lock_settings_test.dart new file mode 100644 index 0000000..56a5094 --- /dev/null +++ b/test/app_lock_settings_test.dart @@ -0,0 +1,54 @@ +import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; +import 'package:androidircx/features/settings/presentation/settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future pumpSettings( + WidgetTester tester, { + required Future Function() auth, + }) async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(appLockAuthenticator: auth)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-app-lock')), + 400, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + testWidgets('enabling app lock requires successful authentication', ( + tester, + ) async { + await pumpSettings(tester, auth: () async => false); + + await tester.tap(find.byKey(const Key('settings-app-lock'))); + await tester.pumpAndSettle(); + + final settings = await SharedPrefsSettingsRepository().loadSettings(); + expect(settings.appLockEnabled, isFalse); + expect( + find.textContaining('could not verify fingerprint or PIN'), + findsOneWidget, + ); + }); + + testWidgets('app lock is enabled once authentication succeeds', ( + tester, + ) async { + await pumpSettings(tester, auth: () async => true); + + await tester.tap(find.byKey(const Key('settings-app-lock'))); + await tester.pumpAndSettle(); + + final settings = await SharedPrefsSettingsRepository().loadSettings(); + expect(settings.appLockEnabled, isTrue); + }); +} diff --git a/test/attachment_tap_action_test.dart b/test/attachment_tap_action_test.dart new file mode 100644 index 0000000..b1e117c --- /dev/null +++ b/test/attachment_tap_action_test.dart @@ -0,0 +1,71 @@ +import 'package:androidircx/core/models/irc_message.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; + +IrcMessageAttachment att( + IrcMessageAttachmentType type, { + String? uri, +}) => IrcMessageAttachment(type: type, label: '', uri: uri); + +void main() { + test('null uri means no action', () { + expect( + attachmentTapAction(att(IrcMessageAttachmentType.video)), + AttachmentTapAction.none, + ); + }); + + test('image opens the image preview', () { + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.image, uri: 'https://x/y.png'), + ), + AttachmentTapAction.imagePreview, + ); + }); + + test('video/audio attachments play in-app', () { + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.video, uri: 'https://x/y.mp4'), + ), + AttachmentTapAction.playVideo, + ); + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.audio, uri: 'https://x/y.mp3'), + ), + AttachmentTapAction.playAudio, + ); + }); + + test('url attachments route by extension', () { + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.url, uri: 'https://x/clip.mp4'), + ), + AttachmentTapAction.playVideo, + ); + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.url, uri: 'https://x/song.ogg'), + ), + AttachmentTapAction.playAudio, + ); + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.url, uri: 'https://example.net/page'), + ), + AttachmentTapAction.external, + ); + }); + + test('file/dcc attachments go external', () { + expect( + attachmentTapAction( + att(IrcMessageAttachmentType.file, uri: 'https://x/y.zip'), + ), + AttachmentTapAction.external, + ); + }); +} diff --git a/test/auto_mode_test.dart b/test/auto_mode_test.dart new file mode 100644 index 0000000..1971bb2 --- /dev/null +++ b/test/auto_mode_test.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.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 { + if (!_controller.isClosed) await _controller.close(); + } + + @override + Future sendLine(String line) async => sentLines.add(line); +} + +Future<(ChatSessionController, _FakeTransport)> _connected() 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', + ), + ircService: service, + ); + await controller.start(); + transport.emit(':server 001 AndroidIRCX :Welcome'); + await Future.delayed(Duration.zero); + return (controller, transport); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('auto-voices a matching user when we hold op', () async { + final (controller, transport) = await _connected(); + // Join and become op via NAMES prefix. + transport.emit(':AndroidIRCX!u@h JOIN #flutter'); + transport.emit(':server 353 AndroidIRCX = #flutter :@AndroidIRCX'); + transport.emit(':server 366 AndroidIRCX #flutter :End of NAMES'); + await Future.delayed(Duration.zero); + + await controller.addAutoModeEntry( + const UserListEntry(type: UserListType.autoVoice, mask: 'bob'), + ); + + transport.sentLines.clear(); + transport.emit(':bob!id@host JOIN #flutter'); + await Future.delayed(Duration.zero); + + expect(transport.sentLines, contains('MODE #flutter +v bob')); + controller.dispose(); + }); + + test('does not set mode when we lack privilege', () async { + final (controller, transport) = await _connected(); + transport.emit(':AndroidIRCX!u@h JOIN #flutter'); + transport.emit(':server 353 AndroidIRCX = #flutter :AndroidIRCX'); + transport.emit(':server 366 AndroidIRCX #flutter :End of NAMES'); + await Future.delayed(Duration.zero); + + await controller.addAutoModeEntry( + const UserListEntry(type: UserListType.autoVoice, mask: 'bob'), + ); + + transport.sentLines.clear(); + transport.emit(':bob!id@host JOIN #flutter'); + await Future.delayed(Duration.zero); + + expect( + transport.sentLines.where((l) => l.startsWith('MODE')), + isEmpty, + ); + controller.dispose(); + }); + + test('slash commands add and remove auto-mode rules', () async { + final (controller, _) = await _connected(); + + await controller.handleComposerSubmit('/autovoice bob #flutter,#dart'); + expect(controller.autoModeEntries, hasLength(1)); + final entry = controller.autoModeEntries.first; + expect(entry.type, UserListType.autoVoice); + expect(entry.mask, 'bob'); + expect(entry.channels, ['#flutter', '#dart']); + expect(entry.network, 'dbase'); + + await controller.handleComposerSubmit(r'/autoop *!*@evil.host'); + expect(controller.autoModeEntries, hasLength(2)); + + await controller.handleComposerSubmit('/unautovoice bob'); + expect( + controller.autoModeEntries.where( + (e) => e.type == UserListType.autoVoice, + ), + isEmpty, + ); + expect(controller.autoModeEntries, hasLength(1)); + + controller.dispose(); + }); + + test('auto-ops take priority and honor channel scope', () async { + final (controller, transport) = await _connected(); + transport.emit(':AndroidIRCX!u@h JOIN #ops'); + transport.emit(':server 353 AndroidIRCX = #ops :@AndroidIRCX'); + transport.emit(':server 366 AndroidIRCX #ops :End of NAMES'); + await Future.delayed(Duration.zero); + + await controller.addAutoModeEntry( + const UserListEntry( + type: UserListType.autoOp, + mask: 'carol!*@*', + channels: ['#ops'], + ), + ); + + // Wrong channel: no auto-op. + transport.emit(':AndroidIRCX!u@h JOIN #other'); + transport.emit(':server 353 AndroidIRCX = #other :@AndroidIRCX'); + transport.emit(':server 366 AndroidIRCX #other :End of NAMES'); + await Future.delayed(Duration.zero); + transport.sentLines.clear(); + transport.emit(':carol!id@host JOIN #other'); + await Future.delayed(Duration.zero); + expect(transport.sentLines.where((l) => l.startsWith('MODE')), isEmpty); + + // Right channel: auto-op. + transport.emit(':carol!id@host JOIN #ops'); + await Future.delayed(Duration.zero); + expect(transport.sentLines, contains('MODE #ops +o carol')); + + controller.dispose(); + }); +} diff --git a/test/auto_mode_toggle_test.dart b/test/auto_mode_toggle_test.dart new file mode 100644 index 0000000..0d16d71 --- /dev/null +++ b/test/auto_mode_toggle_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + void emit(String line) => _controller.add(line); + + @override + Future close() async { + if (!_controller.isClosed) await _controller.close(); + } + + @override + Future sendLine(String line) async {} +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('toggle auto-voice for a nick from the actions sheet', ( + tester, + ) async { + final transports = <_FakeTransport>[]; + final service = IrcService( + transportConnector: (_) async { + final t = _FakeTransport(); + transports.add(t); + return t; + }, + ); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + final t = transports.single; + t.emit(':server 001 AndroidIRCX :Welcome'); + t.emit(':AndroidIRCX!u@h JOIN #flutter'); + t.emit(':server 353 AndroidIRCX = #flutter :@AndroidIRCX bob'); + t.emit(':server 366 AndroidIRCX #flutter :End of NAMES'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + + tester.state(find.byType(Scaffold).first).openEndDrawer(); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'bob').last); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Auto-voice')); + await tester.pumpAndSettle(); + + final entries = controller.autoModeEntries; + expect(entries, hasLength(1)); + expect(entries.first.type, UserListType.autoVoice); + expect(entries.first.mask, 'bob'); + expect(entries.first.network, 'dbase'); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); +} diff --git a/test/certificate_store_test.dart b/test/certificate_store_test.dart index 7a1a75e..ee3c261 100644 --- a/test/certificate_store_test.dart +++ b/test/certificate_store_test.dart @@ -114,6 +114,60 @@ void main() { ); }); + test('saves and reads a PKCS#12 bundle', () async { + final storage = InMemorySecretStorage(); + final store = CertificateStore(storage); + const p12 = 'YWJjZGVmMTIzNDU2Nzg5MA=='; + + await store.save( + 'net-1', + const ClientCertificate(pkcs12Base64: p12, privateKeyPassphrase: 'pw'), + ); + + expect(await store.has('net-1'), isTrue); + final read = await store.read('net-1'); + expect(read, isNotNull); + expect(read!.isPkcs12, isTrue); + expect(read.pkcs12Base64, p12); + expect(read.privateKeyPassphrase, 'pw'); + expect(read.certificatePem, isEmpty); + }); + + test('switching from PKCS#12 to PEM clears the bundle', () async { + final storage = InMemorySecretStorage(); + final store = CertificateStore(storage); + await store.save( + 'net-1', + const ClientCertificate(pkcs12Base64: 'YWJjZA=='), + ); + await store.save( + 'net-1', + const ClientCertificate(certificatePem: _certPem, privateKeyPem: _keyPem), + ); + final read = await store.read('net-1'); + expect(read!.isPkcs12, isFalse); + expect(read.certificatePem, _certPem); + }); + + test('delete removes a PKCS#12 bundle', () async { + final store = CertificateStore(InMemorySecretStorage()); + await store.save( + 'net-1', + const ClientCertificate(pkcs12Base64: 'YWJjZA=='), + ); + await store.delete('net-1'); + expect(await store.has('net-1'), isFalse); + }); + + test('rejects a non-base64 PKCS#12 bundle', () { + expect( + () => validateClientCertificate( + const ClientCertificate(pkcs12Base64: 'not base64 !!!'), + ), + throwsA(isA()), + ); + }); + test('save rejects invalid material before writing to storage', () async { final storage = InMemorySecretStorage(); final store = CertificateStore(storage); diff --git a/test/channel_note_dialog_test.dart b/test/channel_note_dialog_test.dart new file mode 100644 index 0000000..a89ff5a --- /dev/null +++ b/test/channel_note_dialog_test.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + void emit(String line) => _controller.add(line); + + @override + Future close() async { + if (!_controller.isClosed) await _controller.close(); + } + + @override + Future sendLine(String line) async {} +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('long-press channel tab saves a per-channel note', ( + tester, + ) async { + final transports = <_FakeTransport>[]; + final service = IrcService( + transportConnector: (_) async { + final t = _FakeTransport(); + transports.add(t); + return t; + }, + ); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + final notes = ChannelNotesRepository( + prefsLoader: SharedPreferences.getInstance, + ); + + await tester.pumpWidget( + MaterialApp( + home: ChatScreen(controller: controller, channelNotesRepository: notes), + ), + ); + await tester.pump(); + transports.single.emit(':server 001 AndroidIRCX :Welcome'); + transports.single.emit(':AndroidIRCX!u@h JOIN #flutter'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + + // Open the navigation drawer that lists the tabs. + tester.state(find.byType(Scaffold).first).openDrawer(); + await tester.pumpAndSettle(); + + // Long-press the channel tab tile. + await tester.longPress(find.widgetWithText(ListTile, '#flutter').last); + await tester.pumpAndSettle(); + + expect(find.text('Note for #flutter'), findsOneWidget); + await tester.enterText(find.byType(TextField).last, 'ops: alice'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(await notes.getNote('dbase', '#flutter'), 'ops: alice'); + + // Unmount before disposing so pending snackbar animations don't touch the + // disposed controller. + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); +} diff --git a/test/channel_notes_repository_test.dart b/test/channel_notes_repository_test.dart new file mode 100644 index 0000000..6bc5233 --- /dev/null +++ b/test/channel_notes_repository_test.dart @@ -0,0 +1,50 @@ +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + ChannelNotesRepository repo() => + ChannelNotesRepository(prefsLoader: SharedPreferences.getInstance); + + test('returns empty string when no note exists', () async { + expect(await repo().getNote('dbase', '#flutter'), ''); + }); + + test('saves and reloads a note per network+channel', () async { + final r = repo(); + await r.setNote('dbase', '#flutter', ' ops: alice, bob '); + expect(await r.getNote('dbase', '#flutter'), 'ops: alice, bob'); + // Different channel / network is independent. + expect(await r.getNote('dbase', '#dart'), ''); + expect(await r.getNote('other', '#flutter'), ''); + }); + + test('empty note removes the entry', () async { + final r = repo(); + await r.setNote('dbase', '#flutter', 'keep me'); + await r.setNote('dbase', '#flutter', ' '); + expect(await r.getNote('dbase', '#flutter'), ''); + expect(await r.allNotes(), isEmpty); + }); + + test('allNotes returns every stored note', () async { + final r = repo(); + await r.setNote('dbase', '#a', 'note a'); + await r.setNote('dbase', '#b', 'note b'); + final all = await r.allNotes(); + expect(all['dbase::#a'], 'note a'); + expect(all['dbase::#b'], 'note b'); + }); + + test('survives a corrupt blob', () async { + SharedPreferences.setMockInitialValues({ + ChannelNotesRepository.storageKey: 'not json', + }); + final r = repo(); + expect(await r.getNote('dbase', '#x'), ''); + await r.setNote('dbase', '#x', 'fresh'); + expect(await r.getNote('dbase', '#x'), 'fresh'); + }); +} diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index ebb284e..6119d8a 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2303,11 +2303,20 @@ void main() { dccBackend.connection.emitBytes([1, 2, 3, 4]); await Future.delayed(Duration.zero); await dccBackend.connection.finish(); - for (var i = 0; i < 10; i += 1) { - if (controller.activeDccSession?.status == DccSessionStatus.closed) { + // Wait for the transfer to fully settle (status + file write + log line). + // A short fixed poll was flaky on slow CI runners, so wait up to ~3s for + // the actual completion condition instead of a handful of 1ms ticks. + for (var i = 0; i < 300; i += 1) { + final session = controller.activeDccSession; + final finished = controller.activeMessages.any( + (message) => message.content.contains('DCC SEND finished'), + ); + if (session?.status == DccSessionStatus.closed && + session?.bytesTransferred == 4 && + finished) { break; } - await Future.delayed(const Duration(milliseconds: 1)); + await Future.delayed(const Duration(milliseconds: 10)); } expect( diff --git a/test/crash_reporter_test.dart b/test/crash_reporter_test.dart new file mode 100644 index 0000000..5aa65d4 --- /dev/null +++ b/test/crash_reporter_test.dart @@ -0,0 +1,162 @@ +import 'package:androidircx/core/diagnostics/crash_report.dart'; +import 'package:androidircx/core/diagnostics/crash_report_sanitizer.dart'; +import 'package:androidircx/core/diagnostics/crash_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + const sanitizer = CrashReportSanitizer(); + + group('CrashReportSanitizer', () { + test('redacts IRC auth command arguments but keeps the verb', () { + final out = sanitizer.sanitize( + 'sent: PASS hunter2 then AUTHENTICATE bXlzZWNyZXQ=', + ); + expect(out, contains('PASS [redacted]')); + expect(out, contains('AUTHENTICATE [redacted]')); + expect(out, isNot(contains('hunter2'))); + expect(out, isNot(contains('bXlzZWNyZXQ='))); + }); + + test('does not redact a channel name after JOIN-like tokens', () { + final out = sanitizer.sanitize('IDENTIFY #general'); + // #channel is not a secret; the negative lookahead keeps it. + expect(out, contains('#general')); + }); + + test('redacts password/token key-value pairs', () { + final out = sanitizer.sanitize( + 'config password: s3cr3tvalue, token=abc123def, host=irc.example.net', + ); + expect(out, contains('password=[redacted]')); + expect(out, contains('token=[redacted]')); + expect(out, isNot(contains('s3cr3tvalue'))); + expect(out, isNot(contains('abc123def'))); + // Non-secret values survive. + expect(out, contains('irc.example.net')); + }); + + test('redacts PEM blocks and long opaque tokens', () { + final pem = + '-----BEGIN PRIVATE KEY-----\nAAAABBBBCCCC\n-----END PRIVATE KEY-----'; + final out = sanitizer.sanitize('key $pem end'); + expect(out, isNot(contains('BEGIN PRIVATE KEY'))); + expect(out, contains('[redacted]')); + + final longToken = 'Zm9vYmFyMTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXY='; + final out2 = sanitizer.sanitize('bearer $longToken'); + expect(out2, isNot(contains(longToken))); + }); + + test('leaves ordinary text untouched', () { + const msg = 'RangeError: index 5 out of range for list of length 3'; + expect(sanitizer.sanitize(msg), msg); + }); + }); + + group('CrashReport', () { + test('round-trips through json', () { + final report = CrashReport( + timestamp: DateTime.utc(2026, 8, 21, 9, 30), + fatal: true, + source: 'zone', + message: 'boom', + stack: '#0 main', + platform: 'android', + ); + final decoded = CrashReport.decode(report.encode()); + expect(decoded, isNotNull); + expect(decoded!.message, 'boom'); + expect(decoded.source, 'zone'); + expect(decoded.fatal, isTrue); + expect(decoded.platform, 'android'); + expect(decoded.timestamp, report.timestamp); + }); + + test('plaintext body includes the key fields', () { + final report = CrashReport( + timestamp: DateTime.utc(2026, 8, 21), + fatal: false, + source: 'manual', + message: 'something failed', + stack: '#0 somewhere', + ); + final text = report.toPlainText(); + expect(text, contains('Fatal: false')); + expect(text, contains('Source: manual')); + expect(text, contains('something failed')); + expect(text, contains('#0 somewhere')); + }); + + test('decode returns null on malformed input', () { + expect(CrashReport.decode('not json'), isNull); + expect(CrashReport.decode('[]'), isNull); + }); + }); + + group('CrashReporter', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + CrashReporter build() => CrashReporter( + prefsLoader: SharedPreferences.getInstance, + platformName: 'android', + clock: () => DateTime.utc(2026, 8, 21, 10), + ); + + test('records a sanitized report and reloads it', () async { + final reporter = build(); + await reporter.record( + Exception('login failed PASS topsecret'), + StackTrace.fromString('#0 auth'), + source: 'test', + ); + final reports = await reporter.loadReports(); + expect(reports, hasLength(1)); + expect(reports.first.source, 'test'); + expect(reports.first.message, contains('PASS [redacted]')); + expect(reports.first.message, isNot(contains('topsecret'))); + expect(reports.first.platform, 'android'); + }); + + test('keeps newest first and caps at maxStored', () async { + final reporter = CrashReporter( + prefsLoader: SharedPreferences.getInstance, + platformName: 'android', + clock: () => DateTime.utc(2026, 8, 21, 10), + maxStored: 3, + ); + for (var i = 0; i < 5; i++) { + await reporter.record(Exception('error $i'), null, source: 's$i'); + } + final reports = await reporter.loadReports(); + expect(reports, hasLength(3)); + // Newest (error 4) first. + expect(reports.first.message, contains('error 4')); + expect(reports.last.message, contains('error 2')); + }); + + test('clear removes all reports', () async { + final reporter = build(); + await reporter.record(Exception('x'), null); + expect(await reporter.loadReports(), isNotEmpty); + await reporter.clear(); + expect(await reporter.loadReports(), isEmpty); + }); + + test('builds a mailto uri addressed to the contact with subject/body', + () async { + final reporter = build(); + final report = await reporter.record( + Exception('kaboom'), + null, + source: 'test', + ); + final uri = reporter.buildMailtoUri(report!); + expect(uri.scheme, 'mailto'); + expect(uri.path, 'contact@androidircx.com'); + expect(uri.query, contains('subject=AndroidIRCX%20Crash%20Report')); + expect(Uri.decodeComponent(uri.queryParameters['body']!), + contains('kaboom')); + }); + }); +} diff --git a/test/crash_reports_screen_test.dart b/test/crash_reports_screen_test.dart new file mode 100644 index 0000000..eb64875 --- /dev/null +++ b/test/crash_reports_screen_test.dart @@ -0,0 +1,75 @@ +import 'package:androidircx/core/diagnostics/crash_reporter.dart'; +import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + CrashReporter reporter() => CrashReporter( + prefsLoader: SharedPreferences.getInstance, + platformName: 'android', + clock: () => DateTime.utc(2026, 8, 21, 10), + ); + + testWidgets('shows empty state when there are no reports', (tester) async { + await tester.pumpWidget( + MaterialApp(home: CrashReportsScreen(reporter: reporter())), + ); + await tester.pumpAndSettle(); + expect(find.text('No crash reports'), findsOneWidget); + }); + + testWidgets('lists a report and emails it via the launcher', (tester) async { + final r = reporter(); + await r.record( + Exception('boom PASS secret'), + StackTrace.fromString('#0 main'), + source: 'test', + ); + + Uri? launched; + await tester.pumpWidget( + MaterialApp( + home: CrashReportsScreen( + reporter: r, + launcher: (uri) async { + launched = uri; + return true; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + // The sanitized message is shown, secret redacted. + expect(find.textContaining('PASS [redacted]'), findsWidgets); + expect(find.textContaining('secret'), findsNothing); + + // Expand and tap "Email report". + await tester.tap(find.textContaining('boom').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Email report')); + await tester.pumpAndSettle(); + + expect(launched, isNotNull); + expect(launched!.scheme, 'mailto'); + expect(launched!.path, 'contact@androidircx.com'); + }); + + testWidgets('clear removes reports', (tester) async { + final r = reporter(); + await r.record(Exception('x'), null, source: 'test'); + + await tester.pumpWidget( + MaterialApp(home: CrashReportsScreen(reporter: r)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('crash-reports-clear'))); + await tester.pumpAndSettle(); + + expect(find.text('No crash reports'), findsOneWidget); + }); +} diff --git a/test/network_form_cert_import_test.dart b/test/network_form_cert_import_test.dart new file mode 100644 index 0000000..8f0f26e --- /dev/null +++ b/test/network_form_cert_import_test.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:androidircx/dcc/services/dcc_file_picker.dart'; +import 'package:androidircx/features/connections/presentation/network_form_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakePicker implements DccFilePicker { + const _FakePicker(this.path); + final String? path; + @override + Future pickFile() async => path; +} + +const _cert = + '-----BEGIN CERTIFICATE-----\nMIIByyCERT\n-----END CERTIFICATE-----'; +const _key = + '-----BEGIN PRIVATE KEY-----\nMIIEvKEY\n-----END PRIVATE KEY-----'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future enableCertSection(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Client certificate (SASL EXTERNAL)'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Client certificate (SASL EXTERNAL)')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('network-form-import-cert')), + 300, + scrollable: find.byType(Scrollable).first, + ); + } + + testWidgets('imports certificate and key from a combined pem file', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: NetworkFormScreen( + certificateFilePicker: const _FakePicker('/tmp/cert.pem'), + certificateFileReader: (_) async => utf8.encode('$_cert\n$_key\n'), + ), + ), + ); + await enableCertSection(tester); + + await tester.tap(find.byKey(const Key('network-form-import-cert'))); + await tester.pumpAndSettle(); + + expect( + find.text('Imported certificate and private key from file.'), + findsOneWidget, + ); + expect(find.textContaining('MIIByyCERT'), findsWidgets); + expect(find.textContaining('MIIEvKEY'), findsWidgets); + }); + + testWidgets('loads a binary .p12 bundle', (tester) async { + // Bytes that are not valid UTF-8 and contain no PEM blocks -> treated as a + // PKCS#12 bundle. + final p12Bytes = [0x30, 0x82, 0x04, 0xff, 0xfe, 0x00, 0x01]; + await tester.pumpWidget( + MaterialApp( + home: NetworkFormScreen( + certificateFilePicker: const _FakePicker('/tmp/cert.p12'), + certificateFileReader: (_) async => p12Bytes, + ), + ), + ); + await enableCertSection(tester); + + await tester.tap(find.byKey(const Key('network-form-import-cert'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('network-form-pkcs12-loaded')), + findsOneWidget, + ); + expect(find.text('PKCS#12 bundle loaded'), findsOneWidget); + }); +} diff --git a/test/pem_bundle_test.dart b/test/pem_bundle_test.dart new file mode 100644 index 0000000..1928246 Binary files /dev/null and b/test/pem_bundle_test.dart differ diff --git a/test/user_lists_test.dart b/test/user_lists_test.dart new file mode 100644 index 0000000..df5b7ed --- /dev/null +++ b/test/user_lists_test.dart @@ -0,0 +1,123 @@ +import 'package:androidircx/features/chat/data/user_list_entry.dart'; +import 'package:androidircx/features/chat/data/user_lists_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('maskMatches', () { + test('bare nick matches any user@host via normalizedMask', () { + const entry = UserListEntry(type: UserListType.autoVoice, mask: 'alice'); + expect(entry.normalizedMask, 'alice!*@*'); + expect( + entry.matches(nick: 'alice', ident: 'x', host: 'y', channel: '#a'), + isTrue, + ); + expect( + entry.matches(nick: 'bob', ident: 'x', host: 'y', channel: '#a'), + isFalse, + ); + }); + + test('wildcard host mask', () { + expect(maskMatches('*!*@*.example.net', 'bob!id@host.example.net'), + isTrue); + expect(maskMatches('*!*@*.example.net', 'bob!id@host.other.org'), + isFalse); + }); + + test('is case-insensitive', () { + expect(maskMatches('Alice!*@*', 'alice!id@host'), isTrue); + }); + + test('? matches exactly one char', () { + expect(maskMatches('ali?e!*@*', 'alice!x@y'), isTrue); + expect(maskMatches('ali?e!*@*', 'aliiice!x@y'), isFalse); + }); + }); + + group('UserListEntry channel/network scoping', () { + test('empty channels applies to all channels', () { + const e = UserListEntry(type: UserListType.autoOp, mask: 'a'); + expect(e.appliesToChannel('#anything'), isTrue); + }); + + test('channel filter is case-insensitive and specific', () { + const e = UserListEntry( + type: UserListType.autoOp, + mask: 'a', + channels: ['#Flutter'], + ); + expect(e.appliesToChannel('#flutter'), isTrue); + expect(e.appliesToChannel('#dart'), isFalse); + }); + + test('network filter', () { + const e = UserListEntry( + type: UserListType.autoVoice, + mask: 'a', + network: 'dbase', + ); + expect(e.appliesToNetwork('dbase'), isTrue); + expect(e.appliesToNetwork('other'), isFalse); + const global = UserListEntry(type: UserListType.autoVoice, mask: 'a'); + expect(global.appliesToNetwork('anything'), isTrue); + }); + }); + + group('UserListsRepository', () { + setUp(() => SharedPreferences.setMockInitialValues({})); + + UserListsRepository repo() => + UserListsRepository(prefsLoader: SharedPreferences.getInstance); + + test('add, persist, reload', () async { + final r = repo(); + await r.add( + const UserListEntry(type: UserListType.autoVoice, mask: 'alice'), + ); + final loaded = await r.loadAll(); + expect(loaded, hasLength(1)); + expect(loaded.first.type, UserListType.autoVoice); + expect(loaded.first.mask, 'alice'); + }); + + test('add de-duplicates by identity', () async { + final r = repo(); + await r.add( + const UserListEntry(type: UserListType.autoVoice, mask: 'alice'), + ); + await r.add( + const UserListEntry(type: UserListType.autoVoice, mask: 'alice'), + ); + expect(await r.loadAll(), hasLength(1)); + }); + + test('remove by identity', () async { + final r = repo(); + await r.add( + const UserListEntry(type: UserListType.autoOp, mask: 'a'), + ); + await r.add( + const UserListEntry(type: UserListType.autoVoice, mask: 'b'), + ); + await r.remove(const UserListEntry(type: UserListType.autoOp, mask: 'a')); + final loaded = await r.loadAll(); + expect(loaded, hasLength(1)); + expect(loaded.first.mask, 'b'); + }); + + test('json round-trip preserves channels and network', () { + const e = UserListEntry( + type: UserListType.autoHalfOp, + mask: 'nick!*@*', + channels: ['#a', '#b'], + network: 'dbase', + ); + final decoded = UserListEntry.fromJson(e.toJson()); + expect(decoded, isNotNull); + expect(decoded!.type, UserListType.autoHalfOp); + expect(decoded.channels, ['#a', '#b']); + expect(decoded.network, 'dbase'); + }); + }); +} diff --git a/test/user_note_dialog_test.dart b/test/user_note_dialog_test.dart new file mode 100644 index 0000000..cc0865a --- /dev/null +++ b/test/user_note_dialog_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/user_notes_repository.dart'; +import 'package:androidircx/features/chat/presentation/chat_screen.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + void emit(String line) => _controller.add(line); + + @override + Future close() async { + if (!_controller.isClosed) await _controller.close(); + } + + @override + Future sendLine(String line) async {} +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('add a note for a nick from the channel user actions', ( + tester, + ) async { + final transports = <_FakeTransport>[]; + final service = IrcService( + transportConnector: (_) async { + final t = _FakeTransport(); + transports.add(t); + return t; + }, + ); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ), + ircService: service, + ); + final notes = UserNotesRepository( + prefsLoader: SharedPreferences.getInstance, + ); + + await tester.pumpWidget( + MaterialApp( + home: ChatScreen(controller: controller, userNotesRepository: notes), + ), + ); + await tester.pump(); + final t = transports.single; + t.emit(':server 001 AndroidIRCX :Welcome'); + t.emit(':AndroidIRCX!u@h JOIN #flutter'); + t.emit(':server 353 AndroidIRCX = #flutter :AndroidIRCX bob'); + t.emit(':server 366 AndroidIRCX #flutter :End of NAMES'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + + tester.state(find.byType(Scaffold).first).openEndDrawer(); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ListTile, 'bob').last); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add note')); + await tester.pumpAndSettle(); + + expect(find.text('Note for bob'), findsOneWidget); + await tester.enterText(find.byType(TextField).last, 'met in #flutter'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(await notes.getNote('dbase', 'bob'), 'met in #flutter'); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); +} diff --git a/test/user_notes_repository_test.dart b/test/user_notes_repository_test.dart new file mode 100644 index 0000000..431aa3b --- /dev/null +++ b/test/user_notes_repository_test.dart @@ -0,0 +1,40 @@ +import 'package:androidircx/features/chat/data/user_notes_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + UserNotesRepository repo() => + UserNotesRepository(prefsLoader: SharedPreferences.getInstance); + + test('returns empty when no note exists', () async { + expect(await repo().getNote('dbase', 'alice'), ''); + }); + + test('saves, trims, and reloads a note (case-insensitive nick)', () async { + final r = repo(); + await r.setNote('dbase', 'Alice', ' knows the ops '); + expect(await r.getNote('dbase', 'alice'), 'knows the ops'); + expect(await r.getNote('dbase', 'ALICE'), 'knows the ops'); + expect(await r.getNote('other', 'alice'), ''); + }); + + test('empty note removes the entry', () async { + final r = repo(); + await r.setNote('dbase', 'bob', 'temp'); + await r.setNote('dbase', 'bob', ''); + expect(await r.getNote('dbase', 'bob'), ''); + expect(await r.allNotes(), isEmpty); + }); + + test('survives a corrupt blob', () async { + SharedPreferences.setMockInitialValues({ + UserNotesRepository.storageKey: '{{bad', + }); + final r = repo(); + expect(await r.getNote('dbase', 'x'), ''); + await r.setNote('dbase', 'x', 'ok'); + expect(await r.getNote('dbase', 'x'), 'ok'); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index f492fd8..caeb0bc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -675,6 +675,16 @@ void main() { await tester.pump(); await tester.pump(); + Future scrollTo(String key) async { + await tester.scrollUntilVisible( + find.byKey(Key(key)), + 120, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + await scrollTo('settings-theme-preset'); await tester.tap(find.byKey(const Key('settings-theme-preset'))); await tester.pumpAndSettle(); await tester.tap(find.text('Custom').last); @@ -682,6 +692,7 @@ void main() { const customJson = '{"brightness":"dark","primary":"#336699","messageDcc":"#224433"}'; + await scrollTo('settings-custom-theme-json'); await tester.enterText( find.byKey(const Key('settings-custom-theme-json')), customJson, @@ -689,20 +700,19 @@ void main() { await tester.tap(find.byTooltip('Save custom theme')); await tester.pumpAndSettle(); + await scrollTo('settings-message-density'); await tester.tap(find.byKey(const Key('settings-message-density'))); await tester.pumpAndSettle(); await tester.tap(find.text('Compact').last); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -350)); - await tester.pumpAndSettle(); + await scrollTo('settings-monospace-messages'); await tester.tap( find.byKey(const Key('settings-monospace-messages')).first, ); await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -220)); - await tester.pumpAndSettle(); + await scrollTo('settings-nick-color-mode'); await tester.tap(find.byKey(const Key('settings-nick-color-mode')).first); await tester.pumpAndSettle(); await tester.tap(find.text('Vivid').last); @@ -726,11 +736,16 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); final settingsScrollable = find.byType(Scrollable).first; - await tester.scrollUntilVisible( - find.byKey(const Key('settings-help-topic')), - 500, - scrollable: settingsScrollable, - ); + Future scrollTo(String key) async { + await tester.scrollUntilVisible( + find.byKey(Key(key)), + 200, + scrollable: settingsScrollable, + ); + await tester.pumpAndSettle(); + } + + await scrollTo('settings-help-topic'); await tester.tap(find.byKey(const Key('settings-help-topic'))); await tester.pumpAndSettle(); expect(find.text('IRC help'), findsWidgets); @@ -738,6 +753,7 @@ void main() { await tester.tap(find.text('Close')); await tester.pumpAndSettle(); + await scrollTo('settings-privacy-topic'); await tester.tap(find.byKey(const Key('settings-privacy-topic'))); await tester.pumpAndSettle(); expect(find.text('Privacy'), findsWidgets); @@ -745,6 +761,7 @@ void main() { await tester.tap(find.text('Close')); await tester.pumpAndSettle(); + await scrollTo('settings-support-topic'); await tester.tap(find.byKey(const Key('settings-support-topic'))); await tester.pumpAndSettle(); expect(find.text('Support'), findsWidgets); @@ -752,6 +769,7 @@ void main() { await tester.tap(find.text('Close')); await tester.pumpAndSettle(); + await scrollTo('settings-release-audit-topic'); await tester.tap(find.byKey(const Key('settings-release-audit-topic'))); await tester.pumpAndSettle(); expect(find.text('Release audit'), findsWidgets);