From 8bedd9fc9e296d47b9d16fcdb490989b97605775 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:19:26 +0200 Subject: [PATCH 01/15] feat: capture crashes and email sanitized reports on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install global FlutterError/PlatformDispatcher handlers that record uncaught errors on device, sanitized of passwords, tokens, SASL/PASS/AUTHENTICATE arguments, PEM blocks and long opaque tokens. A new Crash reports screen in Settings lists the retained reports and lets the user email one to contact@androidircx.com through their own mail client — nothing is sent automatically and no analytics SDK is used. --- lib/core/diagnostics/crash_report.dart | 91 ++++++++ .../diagnostics/crash_report_sanitizer.dart | 49 +++++ lib/core/diagnostics/crash_reporter.dart | 134 ++++++++++++ .../presentation/crash_reports_screen.dart | 199 ++++++++++++++++++ .../presentation/settings_screen.dart | 15 ++ lib/main.dart | 6 + test/crash_reporter_test.dart | 162 ++++++++++++++ test/crash_reports_screen_test.dart | 75 +++++++ 8 files changed, 731 insertions(+) create mode 100644 lib/core/diagnostics/crash_report.dart create mode 100644 lib/core/diagnostics/crash_report_sanitizer.dart create mode 100644 lib/core/diagnostics/crash_reporter.dart create mode 100644 lib/features/settings/presentation/crash_reports_screen.dart create mode 100644 test/crash_reporter_test.dart create mode 100644 test/crash_reports_screen_test.dart 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/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..08d509d 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -9,6 +9,7 @@ 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'; @@ -466,6 +467,20 @@ class _SettingsScreenState extends State { 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(), + ), + ), + ), ], ), _SettingsSection( 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/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); + }); +} From 977bbaa3cb24b0f41ba9ee352c6c906d4fad0288 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:20:18 +0200 Subject: [PATCH 02/15] test: stabilize flaky DCC transfer-completion test The test polled only ~10ms for the session to reach closed, which was too short on slow CI runners and failed intermittently. Wait up to ~3s for the real completion condition (closed status, 4 bytes transferred, finished log). --- test/chat_session_controller_test.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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( From 5953516eca2c7771f6bfba8f8d03d7b9fdd53801 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:26:28 +0200 Subject: [PATCH 03/15] feat: add per-channel notes Long-pressing a channel tab opens a note dialog whose free-text memo is stored on device, keyed by network and channel (mirrors the RN channelNotes store). The dialog owns its text controller so it is disposed safely after the exit animation. --- .../chat/data/channel_notes_repository.dart | 64 +++++++++++++ .../chat/presentation/chat_screen.dart | 91 +++++++++++++++++++ test/channel_note_dialog_test.dart | 90 ++++++++++++++++++ test/channel_notes_repository_test.dart | 50 ++++++++++ 4 files changed, 295 insertions(+) create mode 100644 lib/features/chat/data/channel_notes_repository.dart create mode 100644 test/channel_note_dialog_test.dart create mode 100644 test/channel_notes_repository_test.dart 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/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 727b514..9b8f15f 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -10,6 +10,7 @@ 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/connections/application/network_list_controller.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; @@ -36,12 +37,17 @@ class ChatScreen extends StatefulWidget { this.networkController, this.onSwitchNetwork, this.onManageNetworks, + this.channelNotesRepository, }); 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; + /// Live sessions across all networks, used by the in-chat network switcher. final SessionRegistry? sessionRegistry; @@ -75,6 +81,10 @@ class _ChatScreenState extends State { widget.filePicker ?? const MethodChannelDccFilePicker(); MediaDownloadService get _mediaDownloadService => widget.mediaDownloadService ?? createMediaDownloadService(); + ChannelNotesRepository? _defaultChannelNotes; + ChannelNotesRepository get _channelNotesRepository => + widget.channelNotesRepository ?? + (_defaultChannelNotes ??= ChannelNotesRepository()); @override void initState() { @@ -829,6 +839,39 @@ 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) => + _ChannelNoteDialog(channel: tab.name, 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.', + ), + ), ); } @@ -1863,6 +1906,54 @@ class _ServiceQuickActions extends StatelessWidget { } } +class _ChannelNoteDialog extends StatefulWidget { + const _ChannelNoteDialog({required this.channel, required this.initialText}); + + final String channel; + final String initialText; + + @override + State<_ChannelNoteDialog> createState() => _ChannelNoteDialogState(); +} + +class _ChannelNoteDialogState extends State<_ChannelNoteDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialText, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Note for ${widget.channel}'), + content: TextField( + controller: _controller, + autofocus: true, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + hintText: 'Notes for this channel (stored only on this device)', + ), + ), + 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, 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'); + }); +} From f2eae889a6c253a71188ffd9cef490d0894d6c59 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:31:39 +0200 Subject: [PATCH 04/15] feat: add per-user (nick) notes Add a note action to the channel user-actions sheet: a free-text memo per nick stored on device, keyed by network and lowercased nick, shown as the sheet subtitle when present. The actions sheet now scrolls so it never overflows on short screens. --- .../chat/data/user_notes_repository.dart | 64 +++++++++++++ .../chat/presentation/chat_screen.dart | 85 ++++++++++++++--- test/user_note_dialog_test.dart | 92 +++++++++++++++++++ test/user_notes_repository_test.dart | 40 ++++++++ 4 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 lib/features/chat/data/user_notes_repository.dart create mode 100644 test/user_note_dialog_test.dart create mode 100644 test/user_notes_repository_test.dart 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/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 9b8f15f..7268bc7 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -11,6 +11,7 @@ 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_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; @@ -38,6 +39,7 @@ class ChatScreen extends StatefulWidget { this.onSwitchNetwork, this.onManageNetworks, this.channelNotesRepository, + this.userNotesRepository, }); final ChatSessionController controller; @@ -48,6 +50,10 @@ class ChatScreen extends StatefulWidget { /// 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; @@ -85,6 +91,10 @@ class _ChatScreenState extends State { ChannelNotesRepository get _channelNotesRepository => widget.channelNotesRepository ?? (_defaultChannelNotes ??= ChannelNotesRepository()); + UserNotesRepository? _defaultUserNotes; + UserNotesRepository get _userNotesRepository => + widget.userNotesRepository ?? + (_defaultUserNotes ??= UserNotesRepository()); @override void initState() { @@ -672,6 +682,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) { @@ -687,7 +702,8 @@ class _ChatScreenState extends State { } return SafeArea( - child: Column( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( @@ -695,9 +711,19 @@ 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)); + }, + ), action('WHOIS', Icons.badge_outlined, ChannelUserAction.whois), action( 'Open query', @@ -719,6 +745,7 @@ class _ChatScreenState extends State { action('Kick', Icons.logout, ChannelUserAction.kick), action('Ban', Icons.block, ChannelUserAction.ban), ], + ), ), ); }, @@ -856,8 +883,11 @@ class _ChatScreenState extends State { } final result = await showDialog( context: context, - builder: (context) => - _ChannelNoteDialog(channel: tab.name, initialText: existing), + builder: (context) => _NoteDialog( + title: 'Note for ${tab.name}', + hint: 'Notes for this channel (stored only on this device)', + initialText: existing, + ), ); if (result == null) { return; @@ -875,6 +905,32 @@ class _ChatScreenState extends State { ); } + 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.', + ), + ), + ); + } + IconData _iconForTab(ChatTabType type) { switch (type) { case ChatTabType.server: @@ -1906,17 +1962,22 @@ class _ServiceQuickActions extends StatelessWidget { } } -class _ChannelNoteDialog extends StatefulWidget { - const _ChannelNoteDialog({required this.channel, required this.initialText}); +class _NoteDialog extends StatefulWidget { + const _NoteDialog({ + required this.title, + required this.hint, + required this.initialText, + }); - final String channel; + final String title; + final String hint; final String initialText; @override - State<_ChannelNoteDialog> createState() => _ChannelNoteDialogState(); + State<_NoteDialog> createState() => _NoteDialogState(); } -class _ChannelNoteDialogState extends State<_ChannelNoteDialog> { +class _NoteDialogState extends State<_NoteDialog> { late final TextEditingController _controller = TextEditingController( text: widget.initialText, ); @@ -1930,15 +1991,13 @@ class _ChannelNoteDialogState extends State<_ChannelNoteDialog> { @override Widget build(BuildContext context) { return AlertDialog( - title: Text('Note for ${widget.channel}'), + title: Text(widget.title), content: TextField( controller: _controller, autofocus: true, minLines: 3, maxLines: 6, - decoration: const InputDecoration( - hintText: 'Notes for this channel (stored only on this device)', - ), + decoration: InputDecoration(hintText: widget.hint), ), actions: [ TextButton( 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'); + }); +} From 1d293dfb7a49e96e39cac22b6581c60e61fb97b2 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:40:58 +0200 Subject: [PATCH 05/15] feat: add auto-op/auto-halfop/auto-voice user lists Persisted mask rules (nick or nick!user@host wildcards, optional channel and network scope) grant a channel mode to matching users when they join a channel where we hold the needed privilege. Rules can be toggled per nick from the channel user-actions sheet and managed on a new Auto-mode lists screen in the drawer. --- .../presentation/bootstrap_screen.dart | 3 + .../application/chat_session_controller.dart | 113 ++++++++++ lib/features/chat/data/user_list_entry.dart | 162 ++++++++++++++ .../chat/data/user_lists_repository.dart | 67 ++++++ .../presentation/auto_mode_lists_screen.dart | 203 ++++++++++++++++++ .../chat/presentation/chat_screen.dart | 87 ++++++++ test/auto_mode_test.dart | 126 +++++++++++ test/auto_mode_toggle_test.dart | 85 ++++++++ test/user_lists_test.dart | 123 +++++++++++ 9 files changed, 969 insertions(+) create mode 100644 lib/features/chat/data/user_list_entry.dart create mode 100644 lib/features/chat/data/user_lists_repository.dart create mode 100644 lib/features/chat/presentation/auto_mode_lists_screen.dart create mode 100644 test/auto_mode_test.dart create mode 100644 test/auto_mode_toggle_test.dart create mode 100644 test/user_lists_test.dart 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..fd9d7ae 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; } @@ -3618,6 +3723,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/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/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 7268bc7..91f1ae2 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -11,8 +11,10 @@ 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/ignore_list_screen.dart'; @@ -266,6 +268,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'), @@ -724,6 +740,9 @@ class _ChatScreenState extends State { 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', @@ -931,6 +950,74 @@ class _ChatScreenState extends State { ); } + 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}.', + ), + ), + ); + } + IconData _iconForTab(ChatTabType type) { switch (type) { case ChatTabType.server: diff --git a/test/auto_mode_test.dart b/test/auto_mode_test.dart new file mode 100644 index 0000000..0a5ba22 --- /dev/null +++ b/test/auto_mode_test.dart @@ -0,0 +1,126 @@ +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('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/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'); + }); + }); +} From 6ac4cd15add0b91b78da377bc31b8d3dc2e3eb6b Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 11:45:27 +0200 Subject: [PATCH 06/15] feat: play audio and video in-app Tapping a video/audio attachment (or a media URL) now opens an in-app player built on video_player instead of leaving the app, with play/pause, a scrubber and position. Audio hides the video surface. The tap routing is extracted into a testable attachmentTapAction(). --- .../chat/presentation/chat_screen.dart | 65 ++++++- .../presentation/media_player_screen.dart | 167 ++++++++++++++++++ pubspec.lock | 60 ++++++- pubspec.yaml | 1 + test/attachment_tap_action_test.dart | 71 ++++++++ 5 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 lib/features/chat/presentation/media_player_screen.dart create mode 100644 test/attachment_tap_action_test.dart diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 91f1ae2..2e9a7a8 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -17,6 +17,7 @@ import 'package:androidircx/features/connections/application/network_list_contro 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'; @@ -2699,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( @@ -3023,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/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..c2b4609 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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/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, + ); + }); +} From 819b26964009778ccfe72309b6f544c0787e8c8f Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:05:42 +0200 Subject: [PATCH 07/15] fix: confirm authentication before enabling app lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling app lock now prompts for fingerprint/PIN first and only turns on after it succeeds, so a device without a working screen lock can never lock the user out. Enabling it while the app is open no longer immediately locks the screen — the lock engages on the next background/resume. --- .../security/presentation/app_lock_gate.dart | 6 +- .../presentation/settings_screen.dart | 62 +++++++++++++- test/app_lock_gate_test.dart | 83 +++++++++++++++++++ test/app_lock_settings_test.dart | 54 ++++++++++++ 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 test/app_lock_gate_test.dart create mode 100644 test/app_lock_settings_test.dart 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/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 08d509d..3e9833d 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -13,6 +13,7 @@ import 'package:androidircx/features/settings/presentation/crash_reports_screen. 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({ @@ -21,6 +22,7 @@ class SettingsScreen extends StatefulWidget { this.settingsController, this.networkController, this.presetService, + this.appLockAuthenticator, }); final SettingsRepository? repository; @@ -31,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(); } @@ -524,9 +530,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( @@ -882,6 +886,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/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); + }); +} From 9dba89f91d70974b0058f54b7b3d672b32da2db2 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:05:47 +0200 Subject: [PATCH 08/15] feat: add auto-mode slash commands /autovoice, /autoop, /autohalfop (and un- variants) add or remove mask rules for the current network, /autolist prints the configured rules. Registered in the command service for autocomplete and help. --- .../application/chat_session_controller.dart | 122 ++++++++++++++++++ .../chat/application/command_service.dart | 42 ++++++ test/auto_mode_test.dart | 26 ++++ 3 files changed, 190 insertions(+) diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index fd9d7ae..3e3509c 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -2670,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) { 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/test/auto_mode_test.dart b/test/auto_mode_test.dart index 0a5ba22..1971bb2 100644 --- a/test/auto_mode_test.dart +++ b/test/auto_mode_test.dart @@ -91,6 +91,32 @@ void main() { 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'); From 6aec7615379290af6ebc9dbf8b9dc53dff8dc0dd Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:22:14 +0200 Subject: [PATCH 09/15] feat: import client certificate/key from a PEM file Add an "Import from file" action to the client-certificate section of the network form. It reads a PEM file via the document picker, splits combined certificate/key bundles into the cert and key fields, and points users to the openssl conversion command for binary .p12/.pfx files. --- lib/features/connections/data/pem_bundle.dart | 33 ++++++++ .../presentation/network_form_screen.dart | 80 +++++++++++++++++- test/network_form_cert_import_test.dart | 78 +++++++++++++++++ test/pem_bundle_test.dart | Bin 0 -> 1553 bytes 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 lib/features/connections/data/pem_bundle.dart create mode 100644 test/network_form_cert_import_test.dart create mode 100644 test/pem_bundle_test.dart 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..02a5677 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -1,8 +1,11 @@ import 'dart:async'; +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 { @@ -68,7 +71,13 @@ class NetworkFormResult { } 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 +85,13 @@ 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 text; defaults to `dart:io`. Injectable for tests. + final Future Function(String path)? certificateFileReader; + @override State createState() => _NetworkFormScreenState(); } @@ -223,6 +239,56 @@ 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; + } + String content; + try { + final reader = widget.certificateFileReader ?? _defaultReadCertFile; + content = await reader(path); + } catch (_) { + _showFormSnack('Could not read the selected file.'); + return; + } + final bundle = PemBundle.parse(content); + if (bundle.isEmpty) { + _showFormSnack( + 'No PEM data found. For a .p12/.pfx file, convert it first: ' + 'openssl pkcs12 -in cert.p12 -out cert.pem -nodes', + ); + return; + } + setState(() { + 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.'); + } + + static Future _defaultReadCertFile(String path) => + File(path).readAsString(); + @override Widget build(BuildContext context) { return Scaffold( @@ -331,6 +397,16 @@ class _NetworkFormScreenState extends State { setState(() => _useClientCertificate = value), ), if (_useClientCertificate) ...[ + 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)'), + ), + ), + const SizedBox(height: 8), TextFormField( controller: _clientCertController, minLines: 2, @@ -338,7 +414,7 @@ class _NetworkFormScreenState extends State { decoration: const InputDecoration( labelText: 'Client certificate PEM', helperText: - 'Paste -----BEGIN CERTIFICATE-----; leave empty to keep the stored one.', + 'Paste or import -----BEGIN CERTIFICATE-----; leave empty to keep the stored one.', ), ), const SizedBox(height: 16), diff --git a/test/network_form_cert_import_test.dart b/test/network_form_cert_import_test.dart new file mode 100644 index 0000000..b418268 --- /dev/null +++ b/test/network_form_cert_import_test.dart @@ -0,0 +1,78 @@ +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 => '$_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('shows a convert hint for non-pem (.p12) files', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: NetworkFormScreen( + certificateFilePicker: const _FakePicker('/tmp/cert.p12'), + certificateFileReader: (_) async => 'binary-pkcs12-bytes', + ), + ), + ); + await enableCertSection(tester); + + await tester.tap(find.byKey(const Key('network-form-import-cert'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('openssl pkcs12'), findsOneWidget); + }); +} diff --git a/test/pem_bundle_test.dart b/test/pem_bundle_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..192824679c9d91513d3ac0304c3169834ede75ce GIT binary patch literal 1553 zcmbtT!EV|>5N&(pE9MZ%Muk}Iu~7~{oz{wq5Ji+XH%nVf~EbPstP}@qCfoC6m!2 zc3+q3OOlMMs{X!I?o&MdcvePCTHGyG9@yDD`9_d%AAdi<#yfgF0zEA{fNI0;y}0wQ zBRFkyl;a)!>cabKX+&>Fx`~5QU(1Rr#n6t&E_H6MDI? z<>;K~m`!KQbOjNpTn*`j>9mzaWuX!%Q@V8pm=(5aFZo=l%NPFYbqij+dtVE_e&gR( On7E$Mx!IcIQ|~t>!01B& literal 0 HcmV?d00001 From 27178e9492d00b9ac9ec0b1eb4b3d1519fd794f1 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:39:18 +0200 Subject: [PATCH 10/15] refactor: reorder settings sections meaningfully Move Connections (server directory, identity profiles) to the top and Help to the very bottom, with consistent spacing between all sections. Make the settings widget tests scroll to each control by key instead of using fixed drag offsets so they are robust to section order. --- .../presentation/settings_screen.dart | 257 +++++++++--------- test/widget_test.dart | 36 ++- 2 files changed, 160 insertions(+), 133 deletions(-) diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 3e9833d..2df60d6 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -98,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: [ @@ -395,130 +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, - ), - ), - 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(), - ), - ), - ), - ], - ), - _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: [ @@ -547,6 +454,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Notifications', children: [ @@ -597,6 +505,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Display', children: [ @@ -622,6 +531,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Writing', children: [ @@ -647,6 +557,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Highlighting', children: [ @@ -672,6 +583,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Away', children: [ @@ -728,6 +640,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Message history', children: [ @@ -770,6 +683,7 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 12), _SettingsSection( title: 'Channels', children: [ @@ -786,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(), + ), + ), + ), + ], + ), ], ), ), 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); From 32260c5964f2afb35e44befd770ee0e6f0b4b2a6 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:47:34 +0200 Subject: [PATCH 11/15] feat: support native PKCS#12 (.p12/.pfx) client certificates Carry a base64 PKCS#12 bundle (plus its password) through the network form, secret storage and ClientCertificate to the TLS SecurityContext, which parses PKCS#12 natively for SASL EXTERNAL/CertFP. Importing a binary .p12/.pfx now loads it directly instead of asking the user to convert to PEM; PEM files still split into the certificate and key fields. --- lib/core/security/certificate_store.dart | 59 +++++++- lib/core/storage/network_secret_keys.dart | 3 +- .../application/network_list_controller.dart | 21 ++- .../presentation/network_form_screen.dart | 132 ++++++++++++------ .../presentation/network_list_screen.dart | 1 + .../services/irc_transport_connector_io.dart | 16 +++ test/certificate_store_test.dart | 54 +++++++ test/network_form_cert_import_test.dart | 17 ++- 8 files changed, 242 insertions(+), 61 deletions(-) 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/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/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 02a5677..19052e9 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:androidircx/core/models/identity_profile.dart'; @@ -37,6 +38,7 @@ class NetworkFormResult { this.useClientCertificate = false, this.clientCertificatePem, this.clientPrivateKeyPem, + this.clientPkcs12Base64, this.clientKeyPassphrase, }); @@ -67,6 +69,7 @@ class NetworkFormResult { final bool useClientCertificate; final String? clientCertificatePem; final String? clientPrivateKeyPem; + final String? clientPkcs12Base64; final String? clientKeyPassphrase; } @@ -89,8 +92,9 @@ class NetworkFormScreen extends StatefulWidget { /// picker. Injectable for tests. final DccFilePicker? certificateFilePicker; - /// Reads the picked file's text; defaults to `dart:io`. Injectable for tests. - final Future Function(String path)? certificateFileReader; + /// 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(); @@ -128,6 +132,7 @@ class _NetworkFormScreenState extends State { late final TextEditingController _clientKeyController; late final TextEditingController _clientKeyPassphraseController; late bool _useClientCertificate; + String? _clientPkcs12Base64; @override void initState() { @@ -255,39 +260,56 @@ class _NetworkFormScreenState extends State { if (path == null || !mounted) { return; } - String content; + List bytes; try { - final reader = widget.certificateFileReader ?? _defaultReadCertFile; - content = await reader(path); + final reader = widget.certificateFileReader ?? _defaultReadCertBytes; + bytes = await reader(path); } catch (_) { _showFormSnack('Could not read the selected file.'); return; } - final bundle = PemBundle.parse(content); - if (bundle.isEmpty) { - _showFormSnack( - 'No PEM data found. For a .p12/.pfx file, convert it first: ' - 'openssl pkcs12 -in cert.p12 -out cert.pem -nodes', - ); + + // 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(() { - if (bundle.hasCertificate) { - _clientCertController.text = bundle.certificate!; - } - if (bundle.hasPrivateKey) { - _clientKeyController.text = bundle.privateKey!; - } + _clientPkcs12Base64 = base64.encode(bytes); + _clientCertController.clear(); + _clientKeyController.clear(); }); - final parts = [ - if (bundle.hasCertificate) 'certificate', - if (bundle.hasPrivateKey) 'private key', - ].join(' and '); - _showFormSnack('Imported $parts from file.'); + _showFormSnack( + 'Imported PKCS#12 bundle. Enter its password below if it has one.', + ); } - static Future _defaultReadCertFile(String path) => - File(path).readAsString(); + static Future> _defaultReadCertBytes(String path) => + File(path).readAsBytes(); @override Widget build(BuildContext context) { @@ -403,35 +425,54 @@ class _NetworkFormScreenState extends State { key: const Key('network-form-import-cert'), onPressed: () => unawaited(_importCertificateFile()), icon: const Icon(Icons.file_open_outlined), - label: const Text('Import from file (.pem)'), + label: const Text('Import from file (.pem / .p12)'), ), ), const SizedBox(height: 8), - 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.', + 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), - TextFormField( - controller: _clientKeyController, - minLines: 2, - maxLines: 4, - decoration: const InputDecoration( - labelText: 'Private key PEM', + const SizedBox(height: 16), + TextFormField( + controller: _clientKeyController, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Private key PEM', + ), ), - ), - const SizedBox(height: 16), + 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), @@ -742,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/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/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/network_form_cert_import_test.dart b/test/network_form_cert_import_test.dart index b418268..8f0f26e 100644 --- a/test/network_form_cert_import_test.dart +++ b/test/network_form_cert_import_test.dart @@ -1,3 +1,5 @@ +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'; @@ -42,7 +44,7 @@ void main() { MaterialApp( home: NetworkFormScreen( certificateFilePicker: const _FakePicker('/tmp/cert.pem'), - certificateFileReader: (_) async => '$_cert\n$_key\n', + certificateFileReader: (_) async => utf8.encode('$_cert\n$_key\n'), ), ), ); @@ -59,12 +61,15 @@ void main() { expect(find.textContaining('MIIEvKEY'), findsWidgets); }); - testWidgets('shows a convert hint for non-pem (.p12) files', (tester) async { + 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 => 'binary-pkcs12-bytes', + certificateFileReader: (_) async => p12Bytes, ), ), ); @@ -73,6 +78,10 @@ void main() { await tester.tap(find.byKey(const Key('network-form-import-cert'))); await tester.pumpAndSettle(); - expect(find.textContaining('openssl pkcs12'), findsOneWidget); + expect( + find.byKey(const Key('network-form-pkcs12-loaded')), + findsOneWidget, + ); + expect(find.text('PKCS#12 bundle loaded'), findsOneWidget); }); } From 9f687989d85e7b601f0e21a6eafee14e642171a3 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:49:38 +0200 Subject: [PATCH 12/15] chore: sync generated plugin registrant and gradle flags Register the video_player macOS plugin and pick up the Flutter migrator's android.builtInKotlin/newDsl gradle flags added during the build. --- android/gradle.properties | 4 ++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ 2 files changed, 6 insertions(+) diff --git a/android/gradle.properties b/android/gradle.properties index c1c6744..eb09578 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 +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false 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")) } From b10d93885993853d4e72da4c911a65d2b858e14c Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 12:51:50 +0200 Subject: [PATCH 13/15] chore: bump version to 1.0.3+5 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index c2b4609..bca5d72 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.2+4 +version: 1.0.3+5 environment: sdk: ^3.11.1 From 0ba1fe822343e2c7e39a6fc3c83bf8ec48d1b467 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 13:16:35 +0200 Subject: [PATCH 14/15] build: upgrade to Gradle 9.1, AGP 9.0.1 and Kotlin 2.3.20 Move to the AGP 9 DSL: migrate kotlinOptions to the Kotlin compilerOptions DSL (jvmTarget 17) and drop the migrator's newDsl compatibility flag. Ignore the Kotlin build session directory. Release appbundle builds and all tests pass on the new toolchain. --- .gitignore | 1 + android/app/build.gradle.kts | 11 +++++++---- android/gradle.properties | 2 +- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 ++-- 5 files changed, 12 insertions(+), 8 deletions(-) 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 eb09578..1c8fe0e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true android.suppressUnsupportedCompileSdk=37.0 kotlin.incremental=false -# This builtInKotlin flag was added automatically by Flutter migrator +# 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") From e9d992901209e6c4d799ceedd2a9294087a95661 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 13:16:35 +0200 Subject: [PATCH 15/15] chore: set a real app description in pubspec --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index bca5d72..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