diff --git a/services/service_qbittorrent/lib/service_qbittorrent.dart b/services/service_qbittorrent/lib/service_qbittorrent.dart index c86efc7d..0249b175 100644 --- a/services/service_qbittorrent/lib/service_qbittorrent.dart +++ b/services/service_qbittorrent/lib/service_qbittorrent.dart @@ -7,10 +7,12 @@ library; export 'src/add_torrent_sheet.dart'; export 'src/models/qbit_detail.dart'; +export 'src/models/qbit_log_entry.dart'; export 'src/models/qbit_torrent.dart'; export 'src/models/qbit_transfer_info.dart'; export 'src/qbittorrent_client.dart'; export 'src/qbittorrent_filter_drawer.dart'; export 'src/qbittorrent_home.dart'; +export 'src/qbittorrent_logs_tab.dart'; export 'src/qbittorrent_providers.dart'; export 'src/torrent_detail_screen.dart'; diff --git a/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart new file mode 100644 index 00000000..09aad584 --- /dev/null +++ b/services/service_qbittorrent/lib/src/models/qbit_log_entry.dart @@ -0,0 +1,79 @@ +/// Severity levels for qBittorrent log entries. +enum QbitLogLevel { + normal('Normal'), + info('Info'), + warning('Warning'), + critical('Critical'); + + const QbitLogLevel(this.label); + final String label; + + static QbitLogLevel fromType(int type) { + return switch (type) { + 1 => QbitLogLevel.normal, + 2 => QbitLogLevel.info, + 4 => QbitLogLevel.warning, + 8 => QbitLogLevel.critical, + _ => QbitLogLevel.normal, + }; + } +} + +/// A single application log message from `GET /api/v2/log/main`. +class QbitLogEntry { + const QbitLogEntry({ + required this.id, + required this.message, + required this.timestamp, + required this.type, + }); + + /// The unique ID of the message. + final int id; + + /// The content of the log entry. + final String message; + + /// When the message was logged, in seconds since the epoch. qBittorrent + /// sent milliseconds before 4.5.0, so [dateTime] reads either. + final int timestamp; + + /// Message type: 1 = normal, 2 = info, 4 = warning, 8 = critical. + final int type; + + /// Parsed severity level. + QbitLogLevel get level => QbitLogLevel.fromType(type); + + /// Normalized [DateTime] from [timestamp]. + DateTime get dateTime { + if (timestamp > 100000000000) { + return DateTime.fromMillisecondsSinceEpoch(timestamp); + } + return DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + } + + /// Formatted `HH:mm:ss` local time string. + String get timeText { + final DateTime local = dateTime.toLocal(); + final String h = local.hour.toString().padLeft(2, '0'); + final String m = local.minute.toString().padLeft(2, '0'); + final String s = local.second.toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + factory QbitLogEntry.fromJson(Map json) { + return QbitLogEntry( + id: json['id'] as int? ?? 0, + message: json['message'] as String? ?? '', + timestamp: json['timestamp'] as int? ?? 0, + type: json['type'] as int? ?? 1, + ); + } + + Map toJson() => { + 'id': id, + 'message': message, + 'timestamp': timestamp, + 'type': type, + }; +} diff --git a/services/service_qbittorrent/lib/src/qbittorrent_client.dart b/services/service_qbittorrent/lib/src/qbittorrent_client.dart index b99ab610..79c87211 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_client.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_client.dart @@ -9,6 +9,7 @@ import 'package:dio/io.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'models/qbit_detail.dart'; +import 'models/qbit_log_entry.dart'; import 'models/qbit_torrent.dart'; import 'models/qbit_transfer_info.dart'; @@ -596,7 +597,7 @@ class QbittorrentClient { _guarded(() async { try { final Response res = - await _dio.get('api/v2/app/networkInterfacesList'); + await _dio.get('api/v2/app/networkInterfaceList'); dynamic raw = res.data; if (raw is String && raw.isNotEmpty) { try { @@ -628,10 +629,10 @@ class QbittorrentClient { _guarded(() async { try { final Response res = await _dio.get( - 'api/v2/app/networkInterfaceAddressesList', - queryParameters: iface != null && iface.isNotEmpty - ? {'iface': iface} - : null, + 'api/v2/app/networkInterfaceAddressList', + // Sent even when empty: qBittorrent answers 400 without it, and + // an empty value is how every address is asked for. + queryParameters: {'iface': iface ?? ''}, ); dynamic raw = res.data; if (raw is String && raw.isNotEmpty) { @@ -675,6 +676,37 @@ class QbittorrentClient { } }); + /// Retrieves application logs (`GET /api/v2/log/main`). + Future> getLogs({ + bool normal = true, + bool info = true, + bool warning = true, + bool critical = true, + int lastKnownId = -1, + }) => + _guarded(() async { + final Response resp = await _dio.get( + 'api/v2/log/main', + queryParameters: { + 'normal': normal, + 'info': info, + 'warning': warning, + 'critical': critical, + if (lastKnownId >= 0) 'last_known_id': lastKnownId, + }, + ); + final dynamic data = resp.data; + if (data is! List) return const []; + return data + .map( + (dynamic item) => item is Map + ? QbitLogEntry.fromJson(Map.from(item)) + : null, + ) + .whereType() + .toList(); + }); + /// Ensures a session exists, runs [call], and re-logins once on a 403. Future _guarded(Future Function() call) async { // API-key auth is stateless: no login round-trip, and a 403 means the key diff --git a/services/service_qbittorrent/lib/src/qbittorrent_home.dart b/services/service_qbittorrent/lib/src/qbittorrent_home.dart index 73da2eba..9272dc16 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_home.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_home.dart @@ -15,6 +15,7 @@ import 'models/qbit_transfer_info.dart'; import 'qbittorrent_action_utils.dart'; import 'qbittorrent_client.dart'; import 'qbittorrent_filter_drawer.dart'; +import 'qbittorrent_logs_tab.dart'; import 'qbittorrent_providers.dart'; import 'qbittorrent_settings_tab.dart'; import 'torrent_detail_screen.dart'; @@ -38,17 +39,23 @@ class QbittorrentHome extends ConsumerWidget { final int currentIndex = ref.watch(qbitActiveTabBarIndexProvider(instance)); final bool isNavbarVisible = ref.watch(qbitBottomNavVisibleProvider(instance)); + final bool isHomeTab = currentIndex == 0; final List tabs = [ _TorrentsTab(instance: instance), QbittorrentSettingsTab(instance: instance), + _BuiltOnceSelected( + selected: currentIndex == 2, + child: QbittorrentLogsTab(instance: instance), + ), ]; return Scaffold( drawerEdgeDragWidth: drawer != null ? MediaQuery.sizeOf(context).width * 0.15 : null, drawer: drawer, - endDrawer: QbittorrentFilterDrawer(instance: instance), + endDrawer: isHomeTab ? QbittorrentFilterDrawer(instance: instance) : null, + endDrawerEnableOpenDragGesture: isHomeTab, body: NotificationListener( onNotification: (ScrollNotification notification) { if (notification.metrics.axis == Axis.vertical) { @@ -167,6 +174,11 @@ class QbittorrentHome extends ConsumerWidget { selectedIcon: Icon(Icons.settings), label: 'Settings', ), + NavigationDestination( + icon: Icon(Icons.article_outlined), + selectedIcon: Icon(Icons.article), + label: 'Logs', + ), ], ), ), @@ -176,6 +188,36 @@ class QbittorrentHome extends ConsumerWidget { } } +/// Builds [child] the first time its tab is selected, and keeps it after. +/// +/// [IndexedStack] builds every tab as soon as the screen opens, so a tab +/// that fetches while building fetches whether or not it is ever shown. For +/// the Logs tab that meant downloading qBittorrent's whole log, which can be +/// 20,000 entries, on every visit to the screen. +class _BuiltOnceSelected extends StatefulWidget { + const _BuiltOnceSelected({required this.selected, required this.child}); + + final bool selected; + final Widget child; + + @override + State<_BuiltOnceSelected> createState() => _BuiltOnceSelectedState(); +} + +class _BuiltOnceSelectedState extends State<_BuiltOnceSelected> { + late bool _built = widget.selected; + + @override + void didUpdateWidget(_BuiltOnceSelected oldWidget) { + super.didUpdateWidget(oldWidget); + _built = _built || widget.selected; + } + + @override + Widget build(BuildContext context) => + _built ? widget.child : const SizedBox.shrink(); +} + class _TorrentsTab extends ConsumerStatefulWidget { const _TorrentsTab({required this.instance}); diff --git a/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart new file mode 100644 index 00000000..264e2983 --- /dev/null +++ b/services/service_qbittorrent/lib/src/qbittorrent_logs_tab.dart @@ -0,0 +1,465 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'qbittorrent_providers.dart'; + +/// The Logs tab for qBittorrent displaying execution logs from `/api/v2/log/main`. +class QbittorrentLogsTab extends ConsumerStatefulWidget { + const QbittorrentLogsTab({required this.instance, super.key}); + + final Instance instance; + + @override + ConsumerState createState() => _QbittorrentLogsTabState(); +} + +class _QbittorrentLogsTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + String _searchQuery = ''; + QbitLogLevel? _selectedLevel; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + final String query = _searchController.text.trim().toLowerCase(); + if (query != _searchQuery) { + setState(() => _searchQuery = query); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _scrollToTop() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + /// The most text one copy puts on the clipboard. + /// + /// Android hands clipboard text to the system in a single binder call, + /// which is refused outright past about a megabyte. qBittorrent keeps up to + /// 20,000 log entries, and copying all of them came to 1.3 MB and failed. + static const int _maxCopyChars = 100000; + + Future _copyLogs(List logs) async { + if (logs.isEmpty) return; + // The newest entries that fit, put back in the order they happened. + final List lines = []; + int chars = 0; + for (final QbitLogEntry entry in logs.reversed) { + final String line = _copyLine(entry); + if (lines.isNotEmpty && chars + line.length > _maxCopyChars) break; + lines.add(line); + chars += line.length + 1; + } + final int total = logs.length; + await _copyToClipboard( + lines.reversed.join('\n'), + lines.length == total + ? 'Copied $total log ${total == 1 ? "entry" : "entries"} to clipboard' + : 'Copied the newest ${lines.length} of $total log entries to ' + 'clipboard', + const Duration(seconds: 2), + ); + } + + Future _copyLogEntry(QbitLogEntry entry) => _copyToClipboard( + _copyLine(entry), + 'Log entry copied to clipboard', + const Duration(seconds: 1), + ); + + String _copyLine(QbitLogEntry entry) => + '[${entry.timeText}] [${entry.level.label.toUpperCase()}] ${entry.message}'; + + /// Copies [text], then says whether it worked. + /// + /// The platform can refuse the write, and a success message shown anyway + /// leaves someone pasting whatever was on the clipboard before. + Future _copyToClipboard( + String text, + String copiedMessage, + Duration duration, + ) async { + String message = copiedMessage; + try { + await Clipboard.setData(ClipboardData(text: text)); + } on PlatformException { + message = 'Could not copy to the clipboard'; + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: duration), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme cs = theme.colorScheme; + + // Listen to scroll to top signal from bottom nav tap (index 2 is Logs) + ref.listen( + qbitHomeScrollToTopProvider((widget.instance, 2)), + (_, __) => _scrollToTop(), + ); + + final AsyncValue> logsAsync = + ref.watch(qbitLogsProvider(widget.instance)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + style: theme.textTheme.titleMedium, + decoration: InputDecoration( + hintText: 'Search logs...', + border: InputBorder.none, + hintStyle: theme.textTheme.titleMedium + ?.copyWith(color: cs.onSurfaceVariant), + ), + ) + : Text('${widget.instance.name} Logs'), + actions: [ + if (_isSearching) + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close search', + onPressed: () { + setState(() { + _isSearching = false; + _searchController.clear(); + _searchQuery = ''; + }); + }, + ) + else ...[ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search logs', + onPressed: () => setState(() => _isSearching = true), + ), + IconButton( + icon: const Icon(Icons.copy_all_outlined), + // It copies what the filter and search leave, not everything. + tooltip: 'Copy logs', + onPressed: () { + final List? currentLogs = logsAsync.value; + if (currentLogs != null) { + final List filtered = _filterLogs(currentLogs); + _copyLogs(filtered); + } + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.invalidate(qbitLogsProvider(widget.instance)), + ), + ], + const SizedBox(width: Insets.xs), + ], + ), + body: Column( + children: [ + _buildFilterChips(cs), + const Divider(height: 1), + Expanded( + child: AsyncValueView>( + value: logsAsync, + onRetry: () => ref.invalidate(qbitLogsProvider(widget.instance)), + data: (List allLogs) { + final List filtered = _filterLogs(allLogs); + + if (filtered.isEmpty) { + return EmptyView( + icon: Icons.article_outlined, + title: allLogs.isEmpty + ? 'No logs available' + : 'No matching logs', + message: allLogs.isEmpty + ? 'qBittorrent has not reported any log entries yet.' + : 'Try changing your search query or level filter.', + ); + } + + // Show newest logs at top by reversing the list + final List reversed = filtered.reversed.toList(); + + return EasyRefresh( + header: const ClassicHeader( + dragText: 'Pull to refresh', + armedText: 'Release ready', + readyText: 'Refreshing...', + processingText: 'Refreshing...', + processedText: 'Succeeded', + failedText: 'Failed', + messageText: 'Last updated at %T', + ), + onRefresh: () async { + ref.invalidate(qbitLogsProvider(widget.instance)); + await ref.read(qbitLogsProvider(widget.instance).future); + }, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only( + top: Insets.xs, + bottom: 80, + ), + itemCount: reversed.length, + itemBuilder: (BuildContext context, int index) { + final QbitLogEntry entry = reversed[index]; + return _LogCard( + entry: entry, + onTap: () => _copyLogEntry(entry), + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } + + List _filterLogs(List logs) { + return logs.where((QbitLogEntry e) { + if (_selectedLevel != null && e.level != _selectedLevel) { + return false; + } + if (_searchQuery.isNotEmpty) { + return e.message.toLowerCase().contains(_searchQuery); + } + return true; + }).toList(); + } + + Widget _buildFilterChips(ColorScheme cs) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: Insets.md, + vertical: Insets.xs, + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: Insets.xs), + child: FilterChip( + selected: _selectedLevel == null, + label: const Text('All'), + onSelected: (_) => setState(() => _selectedLevel = null), + ), + ), + for (final QbitLogLevel level in QbitLogLevel.values) + Padding( + padding: const EdgeInsets.only(right: Insets.xs), + child: FilterChip( + selected: _selectedLevel == level, + label: Text(level.label), + onSelected: (bool selected) { + setState(() => _selectedLevel = selected ? level : null); + }, + ), + ), + ], + ), + ); + } +} + +class _LogVisual { + const _LogVisual({ + required this.color, + required this.container, + required this.onContainer, + required this.icon, + }); + + final Color color; + final Color container; + final Color onContainer; + final IconData icon; +} + +_LogVisual _visualForLevel(QbitLogLevel level, ColorScheme cs) { + return switch (level) { + QbitLogLevel.critical => _LogVisual( + color: cs.error, + container: cs.errorContainer, + onContainer: cs.onErrorContainer, + icon: Icons.error_outline_rounded, + ), + QbitLogLevel.warning => _LogVisual( + color: cs.secondary, + container: cs.secondaryContainer, + onContainer: cs.onSecondaryContainer, + icon: Icons.warning_amber_rounded, + ), + QbitLogLevel.info => _LogVisual( + color: cs.tertiary, + container: cs.tertiaryContainer, + onContainer: cs.onTertiaryContainer, + icon: Icons.info_outline_rounded, + ), + QbitLogLevel.normal => _LogVisual( + color: cs.primary, + container: cs.primaryContainer, + onContainer: cs.onPrimaryContainer, + icon: Icons.article_rounded, + ), + }; +} + +class _StatePill extends StatelessWidget { + const _StatePill({required this.label, required this.visual}); + + final String label; + final _LogVisual visual; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(visual.icon, size: 12, color: visual.color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: visual.color, + ), + ), + ], + ), + ); + } +} + +class _LogCard extends StatelessWidget { + const _LogCard({required this.entry, required this.onTap}); + + final QbitLogEntry entry; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme cs = theme.colorScheme; + final _LogVisual v = _visualForLevel(entry.level, cs); + + final Widget tile = Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: v.container, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + v.icon, + size: 22, + color: v.onContainer, + ), + ), + const SizedBox(width: Insets.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.message, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + _StatePill( + label: entry.level.label.toUpperCase(), + visual: v, + ), + const SizedBox(width: Insets.sm), + Expanded( + child: Text( + entry.timeText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ), + Icon( + Icons.copy_outlined, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB( + Insets.md, + Insets.xs, + Insets.md, + Insets.xs, + ), + child: tile, + ); + } +} diff --git a/services/service_qbittorrent/lib/src/qbittorrent_providers.dart b/services/service_qbittorrent/lib/src/qbittorrent_providers.dart index 001ffe74..34e225b8 100644 --- a/services/service_qbittorrent/lib/src/qbittorrent_providers.dart +++ b/services/service_qbittorrent/lib/src/qbittorrent_providers.dart @@ -5,10 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'models/qbit_detail.dart'; +import 'models/qbit_log_entry.dart'; import 'models/qbit_torrent.dart'; import 'models/qbit_transfer_info.dart'; import 'qbittorrent_client.dart'; +export 'models/qbit_log_entry.dart'; + /// How often list-level data (torrents, global speeds) refreshes while a /// qBittorrent screen is visible. qBit's own web UI polls at 1.5s; 3s is a /// good mobile compromise. @@ -598,3 +601,14 @@ final qbitNetworkInterfaceAddressesProvider = await ref.watch(qbittorrentClientProvider(instance).future); return client.getNetworkInterfaceAddresses(iface: iface); }); + +/// qBittorrent main log messages provider. +final qbitLogsProvider = FutureProvider.family + .autoDispose, Instance>(( + Ref ref, + Instance instance, +) async { + final QbittorrentClient client = + await ref.watch(qbittorrentClientProvider(instance).future); + return client.getLogs(); +}); diff --git a/services/service_qbittorrent/test/qbit_filter_button_test.dart b/services/service_qbittorrent/test/qbit_filter_button_test.dart index 42be5cb1..bfb9ae75 100644 --- a/services/service_qbittorrent/test/qbit_filter_button_test.dart +++ b/services/service_qbittorrent/test/qbit_filter_button_test.dart @@ -122,6 +122,60 @@ void main() { 'which owns no end drawer, and the tap does nothing', ); }); + + testWidgets( + 'end drawer and swipe gesture are active on home tab, disabled on logs and settings tabs', + (WidgetTester tester) async { + final ProviderContainer container = ProviderContainer( + overrides: [ + qbitRawTorrentsProvider(_instance) + .overrideWith((Ref ref) async => const []), + qbitTransferProvider(_instance) + .overrideWith((Ref ref) async => const QbitTransferInfo()), + qbitLogsProvider(_instance) + .overrideWith((Ref ref) async => const []), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentHome(instance: _instance), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // On home view (tab 0) + final Scaffold homeScaffold = + tester.widget(find.byType(Scaffold).first); + expect(homeScaffold.endDrawer, isNotNull); + expect(homeScaffold.endDrawerEnableOpenDragGesture, isTrue); + + // Switch to settings tab (tab 1) + container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 1; + await tester.pump(); + await tester.pump(); + + final Scaffold settingsScaffold = + tester.widget(find.byType(Scaffold).first); + expect(settingsScaffold.endDrawer, isNull); + expect(settingsScaffold.endDrawerEnableOpenDragGesture, isFalse); + + // Switch to logs tab (tab 2) + container.read(qbitActiveTabBarIndexProvider(_instance).notifier).state = 2; + await tester.pump(); + await tester.pump(); + + final Scaffold logsScaffold = + tester.widget(find.byType(Scaffold).first); + expect(logsScaffold.endDrawer, isNull); + expect(logsScaffold.endDrawerEnableOpenDragGesture, isFalse); + }); } const Instance _instance = Instance( diff --git a/services/service_qbittorrent/test/qbit_logs_copy_test.dart b/services/service_qbittorrent/test/qbit_logs_copy_test.dart new file mode 100644 index 00000000..2ebadc75 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_copy_test.dart @@ -0,0 +1,143 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// Copying logs has to fit on the clipboard and say truthfully what it did. +/// +/// qBittorrent keeps up to 20,000 log entries. With a live server holding +/// that many, Copy put 1.3 MB on the clipboard, Android refused it with a +/// TransactionTooLargeException, and the app still said "Copied 20000 log +/// entries to clipboard" while nothing had been copied. +void main() { + const Instance instance = Instance( + id: 'qbit-test', + name: 'My qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost:8080', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), + ); + + // Entries shaped like the ones a busy server fills its log with, each + // numbered so the order of copied lines can be checked. + List logsOf(int count) => [ + for (int i = 0; i < count; i++) + QbitLogEntry( + id: i, + message: 'WebAPI login success. IP: ::ffff:172.20.0.1 #$i', + timestamp: 1789662480 + i, + type: 1, + ), + ]; + + Future pumpLogs(WidgetTester tester, int count) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => logsOf(count)), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // Stands in for the platform clipboard, recording what was copied, or + // refusing the way Android does when the text is too large. + void clipboard(WidgetTester tester, {void Function(String)? onCopy}) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method != 'Clipboard.setData') return null; + if (onCopy == null) { + throw PlatformException( + code: 'error', + message: 'android.os.TransactionTooLargeException', + ); + } + onCopy((call.arguments as Map)['text']! as String); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + } + + Future copy(WidgetTester tester) async { + await tester.tap(find.byTooltip('Copy logs')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets('a log that fits is copied whole, oldest first', + (WidgetTester tester) async { + String? copied; + clipboard(tester, onCopy: (String text) => copied = text); + await pumpLogs(tester, 3); + + await copy(tester); + + expect(copied!.split('\n'), hasLength(3)); + expect(copied!.split('\n').first, endsWith('#0')); + expect(copied!.split('\n').last, endsWith('#2')); + expect(find.text('Copied 3 log entries to clipboard'), findsOneWidget); + }); + + testWidgets('a full log copies the newest entries that fit, and says so', + (WidgetTester tester) async { + String? copied; + clipboard(tester, onCopy: (String text) => copied = text); + await pumpLogs(tester, 20000); + + await copy(tester); + + expect(copied!.length, lessThanOrEqualTo(100000)); + final List lines = copied!.split('\n'); + expect(lines.length, lessThan(20000)); + // The newest entry is kept, and what came before it runs on unbroken. + expect(lines.last, endsWith('#19999')); + expect(lines.first, endsWith('#${20000 - lines.length}')); + expect( + find.text( + 'Copied the newest ${lines.length} of 20000 log entries to clipboard', + ), + findsOneWidget, + ); + }); + + testWidgets('a refused copy says so instead of claiming it worked', + (WidgetTester tester) async { + clipboard(tester); + await pumpLogs(tester, 3); + + await copy(tester); + + expect(find.textContaining('Copied'), findsNothing); + expect(find.text('Could not copy to the clipboard'), findsOneWidget); + }); + + testWidgets('tapping one entry reports a refused copy too', + (WidgetTester tester) async { + clipboard(tester); + await pumpLogs(tester, 3); + + await tester.tap(find.textContaining('#2')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Log entry copied to clipboard'), findsNothing); + expect(find.text('Could not copy to the clipboard'), findsOneWidget); + }); +} diff --git a/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart b/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart new file mode 100644 index 00000000..4bb5abf6 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_lazy_load_test.dart @@ -0,0 +1,77 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// The log is only fetched once the Logs tab is opened. +/// +/// The tabs sit in an IndexedStack, which builds all of them when the screen +/// opens, so the Logs tab fetched qBittorrent's whole log every time the +/// screen was opened. Seen through a proxy in front of a live qBittorrent: +/// opening the screen on the Home tab requested /api/v2/log/main, and a busy +/// server keeps 20,000 entries, about 2 MB of JSON. +void main() { + testWidgets('the log is fetched when Logs is opened, not before', + (WidgetTester tester) async { + int logFetches = 0; + final ProviderContainer container = ProviderContainer( + overrides: [ + qbitRawTorrentsProvider(_instance) + .overrideWith((Ref ref) async => const []), + qbitTransferProvider(_instance) + .overrideWith((Ref ref) async => const QbitTransferInfo()), + qbitLogsProvider(_instance).overrideWith((Ref ref) async { + logFetches++; + return const []; + }), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentHome(instance: _instance), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(logFetches, 0, reason: 'nobody has opened the Logs tab yet'); + + void selectTab(int index) => container + .read(qbitActiveTabBarIndexProvider(_instance).notifier) + .state = index; + + selectTab(2); + await tester.pump(); + await tester.pump(); + + expect(logFetches, 1); + expect(find.text('No logs available'), findsOneWidget); + + // The tab is kept once built, so going back to it does not fetch again. + selectTab(0); + await tester.pump(); + selectTab(2); + await tester.pump(); + + expect(logFetches, 1); + }); +} + +const Instance _instance = Instance( + id: 'test-qbit', + name: 'Test qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), +); diff --git a/services/service_qbittorrent/test/qbit_logs_test.dart b/services/service_qbittorrent/test/qbit_logs_test.dart new file mode 100644 index 00000000..7ac58525 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_logs_test.dart @@ -0,0 +1,209 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +void main() { + group('QbitLogEntry', () { + test('parses json with milliseconds timestamp and computes level', () { + final Map json = { + 'id': 10, + 'message': 'qBittorrent v5.0 started', + 'timestamp': 1603884800000, + 'type': 1, + }; + + final QbitLogEntry entry = QbitLogEntry.fromJson(json); + expect(entry.id, 10); + expect(entry.message, 'qBittorrent v5.0 started'); + expect(entry.timestamp, 1603884800000); + expect(entry.type, 1); + expect(entry.level, QbitLogLevel.normal); + expect(entry.dateTime.millisecondsSinceEpoch, 1603884800000); + expect(entry.timeText, isNotEmpty); + expect(entry.toJson(), json); + }); + + test('handles seconds timestamp and all severity types', () { + final QbitLogEntry normal = QbitLogEntry.fromJson(const { + 'id': 1, + 'message': 'normal msg', + 'timestamp': 1603884800, + 'type': 1, + }); + final QbitLogEntry info = QbitLogEntry.fromJson(const { + 'id': 2, + 'message': 'info msg', + 'timestamp': 1603884800, + 'type': 2, + }); + final QbitLogEntry warning = QbitLogEntry.fromJson(const { + 'id': 3, + 'message': 'warning msg', + 'timestamp': 1603884800, + 'type': 4, + }); + final QbitLogEntry critical = + QbitLogEntry.fromJson(const { + 'id': 4, + 'message': 'critical msg', + 'timestamp': 1603884800, + 'type': 8, + }); + + expect(normal.level, QbitLogLevel.normal); + expect(info.level, QbitLogLevel.info); + expect(warning.level, QbitLogLevel.warning); + expect(critical.level, QbitLogLevel.critical); + expect(normal.dateTime.millisecondsSinceEpoch, 1603884800000); + }); + }); + + group('QbittorrentLogsTab', () { + const Instance instance = Instance( + id: 'qbit-test', + name: 'My qBittorrent', + kind: ServiceKind.qbittorrent, + localUrl: 'http://localhost:8080', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'k'), + ); + + final List sampleLogs = [ + const QbitLogEntry( + id: 1, + message: 'System initialized successfully', + timestamp: 1603884800000, + type: 1, + ), + const QbitLogEntry( + id: 2, + message: 'UPnP port mapping failed', + timestamp: 1603884810000, + type: 4, + ), + const QbitLogEntry( + id: 3, + message: 'Fatal error: disk space exhausted', + timestamp: 1603884820000, + type: 8, + ), + ]; + + testWidgets('renders logs with time, level badge, and messages', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('My qBittorrent Logs'), findsOneWidget); + expect(find.text('System initialized successfully'), findsOneWidget); + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + expect(find.text('NORMAL'), findsOneWidget); + expect(find.text('WARNING'), findsOneWidget); + expect(find.text('CRITICAL'), findsOneWidget); + }); + + testWidgets('filters logs by level chip', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap 'Warning' chip + await tester.tap(find.widgetWithText(FilterChip, 'Warning')); + await tester.pumpAndSettle(); + + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('System initialized successfully'), findsNothing); + expect(find.text('Fatal error: disk space exhausted'), findsNothing); + + // Tap 'All' chip to reset + await tester.tap(find.widgetWithText(FilterChip, 'All')); + await tester.pumpAndSettle(); + + expect(find.text('System initialized successfully'), findsOneWidget); + expect(find.text('UPnP port mapping failed'), findsOneWidget); + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + }); + + testWidgets('filters logs by search query', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap search icon + await tester.tap(find.byTooltip('Search logs')); + await tester.pumpAndSettle(); + + // Type search query + await tester.enterText(find.byType(TextField), 'disk'); + await tester.pumpAndSettle(); + + expect(find.text('Fatal error: disk space exhausted'), findsOneWidget); + expect(find.text('System initialized successfully'), findsNothing); + expect(find.text('UPnP port mapping failed'), findsNothing); + }); + + testWidgets('shows empty state when no logs match', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + qbitLogsProvider(instance) + .overrideWith((Ref ref) async => sampleLogs), + ], + child: MaterialApp( + theme: AtriumTheme.light(null), + home: const QbittorrentLogsTab(instance: instance), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap search icon + await tester.tap(find.byTooltip('Search logs')); + await tester.pumpAndSettle(); + + // Type search query that matches nothing + await tester.enterText(find.byType(TextField), 'nonexistent query'); + await tester.pumpAndSettle(); + + expect(find.text('No matching logs'), findsOneWidget); + }); + }); +} diff --git a/services/service_qbittorrent/test/qbit_network_interfaces_test.dart b/services/service_qbittorrent/test/qbit_network_interfaces_test.dart new file mode 100644 index 00000000..9bbccce2 --- /dev/null +++ b/services/service_qbittorrent/test/qbit_network_interfaces_test.dart @@ -0,0 +1,109 @@ +import 'dart:typed_data'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_qbittorrent/service_qbittorrent.dart'; + +/// Answers the two network interface routes the way qBittorrent 5.2.3 does. +/// +/// The bodies and status codes below were taken from a live server: any +/// route it does not have is a 404, and the address route is a 400 unless +/// `iface` is sent, where an empty value lists every address. +class _Qbittorrent implements HttpClientAdapter { + final List requested = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requested.add(options.uri); + final Map query = options.uri.queryParameters; + return switch (options.uri.path) { + '/api/v2/app/networkInterfaceList' => _json( + '[{"name":"lo","value":"lo"},{"name":"eth0","value":"eth0"}]', + ), + '/api/v2/app/networkInterfaceAddressList' => switch (query['iface']) { + null => _text(400, 'Missing required parameters: iface'), + '' => _json('["127.0.0.1","::1","172.20.0.2"]'), + 'eth0' => _json('["172.20.0.2"]'), + _ => _json('[]'), + }, + _ => _text(404, 'Endpoint does not exist'), + }; + } + + ResponseBody _json(String body) => ResponseBody.fromString( + body, + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + + ResponseBody _text(int status, String body) => ResponseBody.fromString( + body, + status, + headers: >{ + Headers.contentTypeHeader: ['text/plain; charset=UTF-8'], + }, + ); + + @override + void close({bool force = false}) {} +} + +/// The Advanced settings pickers list the host's interfaces and addresses. +/// +/// They never listed anything: the client asked for networkInterfacesList +/// and networkInterfaceAddressesList, routes qBittorrent does not have, and +/// returned an empty list when both came back 404. +void main() { + late _Qbittorrent qbittorrent; + late QbittorrentClient client; + + setUp(() { + qbittorrent = _Qbittorrent(); + client = QbittorrentClient( + dio: Dio(BaseOptions(baseUrl: 'https://qbit.example.test/')) + ..httpClientAdapter = qbittorrent, + cookies: CookieJar(), + username: '', + password: '', + apiKey: 'k', + ); + }); + + test('lists the network interfaces', () async { + expect( + await client.getNetworkInterfaces(), + >[ + {'name': 'lo', 'value': 'lo'}, + {'name': 'eth0', 'value': 'eth0'}, + ], + ); + }); + + test('lists every address when no interface is chosen', () async { + // "Any interface" is the default, and qBittorrent needs iface sent + // empty to answer it rather than refusing the request. + expect( + await client.getNetworkInterfaceAddresses(), + ['127.0.0.1', '::1', '172.20.0.2'], + ); + expect(await client.getNetworkInterfaceAddresses(iface: ''), hasLength(3)); + expect( + qbittorrent.requested.map((Uri uri) => uri.queryParameters['iface']), + everyElement(''), + ); + }); + + test('lists the addresses of the chosen interface', () async { + expect( + await client.getNetworkInterfaceAddresses(iface: 'eth0'), + ['172.20.0.2'], + ); + }); +}