diff --git a/integration_test/network_smb_listing_test.dart b/integration_test/network_smb_listing_test.dart new file mode 100644 index 00000000..5025a967 --- /dev/null +++ b/integration_test/network_smb_listing_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +import '../test/support/library_collections_benchmark.dart'; +import '../test/support/smb_listing_benchmark.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final report = { + 'platform': Platform.operatingSystem, + 'note': + 'SMB result adapter/sort only, no live server or network throughput/FPS claim.', + }; + var completed = 0; + tearDownAll(() { + report['completed_cases'] = completed; + binding.reportData ??= {}; + binding.reportData!['smb_listing'] = report; + }); + for (final count in [32, 256, 1024, 4096, 10000, 50000]) { + testWidgets('prepare $count SMB entries', (tester) async { + final input = await compute(smbListingFixture, count); + final expected = smbListingRows(legacySmbListing(input)); + final samples = >>{ + for (final kind in ['legacy', 'current']) kind: [], + }; + legacySmbListing(input); + await NetworkStorageService.prepareSmbEntries(input.files, input.path); + for (var pair = 0; pair < 5; pair++) { + for (final kind + in pair.isEven ? samples.keys : samples.keys.toList().reversed) { + final result = await measureCollectionOperation>( + () => switch (kind) { + 'legacy' => legacySmbListing(input), + _ => NetworkStorageService.prepareSmbEntries( + input.files, + input.path, + ), + }, + ); + expect(smbListingRows(result.value), expected); + expect(input.files.length, count); + samples[kind]!.add(result.timing); + } + } + report['entries_$count'] = samples; + completed++; + }); + } +} diff --git a/lib/services/network_storage_service.dart b/lib/services/network_storage_service.dart index 34639649..56078d3c 100644 --- a/lib/services/network_storage_service.dart +++ b/lib/services/network_storage_service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:dart_smb2/dart_smb2.dart' show Smb2DirEntry; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -16,6 +17,23 @@ import 'package:xml/xml.dart'; part 'network_storage_upload.dart'; +typedef _SmbListingInput = ({ + List<({String name, bool directory, int size})> files, + String path, +}); + +List _prepareSmbListingInBackground(_SmbListingInput input) => + NetworkStorageService._sorted([ + for (final file in input.files) + if (file.name != '.' && file.name != '..') + NetworkEntry( + '${input.path}${file.name}${file.directory ? '/' : ''}', + file.name, + directory: file.directory, + size: file.size, + ), + ]); + List _parseNetworkListingInBackground( Map request, ) { @@ -447,24 +465,16 @@ class NetworkStorageService { ]); } final smb = await _smb(c, target); + final List files; try { - final files = await smb.list().timeout(timeout); - return _sorted([ - for (final file in files) - if (file.name != '.' && file.name != '..') - NetworkEntry( - '$path${file.name}${file.stat.isDirectory ? '/' : ''}', - file.name, - directory: file.stat.isDirectory, - size: file.stat.size, - ), - ]); + files = await smb.list().timeout(timeout); } finally { // A failed disconnect must not hide the original share/login error. try { await smb.close(); } catch (_) {} } + return prepareSmbEntries(files, path); } final client = _client(); try { @@ -506,12 +516,43 @@ class NetworkStorageService { } } - static List _sorted(List entries) => - entries..sort( - (a, b) => a.directory != b.directory - ? (a.directory ? -1 : 1) - : a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); + /// Large SMB listings arrive from native I/O as plain stat objects. Transfer + /// only fields used by the browser, avoiding copies of unused timestamps. + static Future> prepareSmbEntries( + List files, + String path, + ) async { + final input = ( + path: path, + files: [ + for (final file in files) + ( + name: file.name, + directory: file.stat.isDirectory, + size: file.stat.size, + ), + ], + ); + // Small lists are cheaper inline; large sorts must leave the UI isolate. + if (files.length < 4096) return _prepareSmbListingInBackground(input); + return compute(_prepareSmbListingInBackground, input); + } + + static List _sorted(List entries) { + final keyed = + [ + for (final entry in entries) + (entry: entry, key: entry.name.toLowerCase()), + ]..sort( + (a, b) => a.entry.directory != b.entry.directory + ? (a.entry.directory ? -1 : 1) + : a.key.compareTo(b.key), + ); + for (var index = 0; index < entries.length; index++) { + entries[index] = keyed[index].entry; + } + return entries; + } static List parseListing( NetworkConnection c, diff --git a/test/network_smb_listing_test.dart b/test/network_smb_listing_test.dart new file mode 100644 index 00000000..c7a59ea4 --- /dev/null +++ b/test/network_smb_listing_test.dart @@ -0,0 +1,69 @@ +import 'package:dart_smb2/dart_smb2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +import 'support/smb_listing_benchmark.dart'; + +void main() { + for (final count in [0, 1, 32, 256, 4095, 4096, 10000]) { + test( + 'SMB adapter retains original order and values for $count entries', + () async { + final input = smbListingFixture(count); + final original = [...input.files]; + final expected = smbListingRows(legacySmbListing(input)); + final result = await NetworkStorageService.prepareSmbEntries( + input.files, + input.path, + ); + expect(smbListingRows(result), expected); + expect(input.files, original); + expect( + result.any((entry) => entry.name == '.' || entry.name == '..'), + isFalse, + ); + }, + ); + } + + test( + 'case ties, links, literal paths and large sizes retain their semantics', + () async { + final date = DateTime.utc(2026); + final input = ( + path: '100% + #1/日本語/', + files: [ + for (final (name, type, size) in [ + ('Same', Smb2FileType.file, 9007199254740991), + ('SAME', Smb2FileType.directory, 1), + ('same', Smb2FileType.file, 2), + ('same', Smb2FileType.link, 3), + ('İ.dot', Smb2FileType.file, 4), + ('i.dot', Smb2FileType.file, 5), + ('literal%20 #+.mp3', Smb2FileType.file, 6), + ('..', Smb2FileType.directory, 0), + ('.', Smb2FileType.directory, 0), + ]) + Smb2DirEntry( + name: name, + stat: Smb2Stat( + type: type, + size: size, + modified: date, + created: date, + ), + ), + ], + ); + final result = await NetworkStorageService.prepareSmbEntries( + input.files, + input.path, + ); + expect(smbListingRows(result), smbListingRows(legacySmbListing(input))); + expect(result.first.path, '100% + #1/日本語/SAME/'); + expect(result.where((entry) => entry.directory), hasLength(1)); + expect(result.singleWhere((entry) => entry.size == 3).directory, isFalse); + expect(result.any((entry) => entry.size == 9007199254740991), isTrue); + }, + ); +} diff --git a/test/support/smb_listing_benchmark.dart b/test/support/smb_listing_benchmark.dart new file mode 100644 index 00000000..068dd473 --- /dev/null +++ b/test/support/smb_listing_benchmark.dart @@ -0,0 +1,65 @@ +import 'dart:math'; + +import 'package:dart_smb2/dart_smb2.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +typedef SmbListingInput = ({List files, String path}); + +// Frozen adapter and comparator from NetworkStorageService.list/_sorted. +List legacySmbListing(SmbListingInput input) => + [ + for (final file in input.files) + if (file.name != '.' && file.name != '..') + NetworkEntry( + '${input.path}${file.name}${file.stat.isDirectory ? '/' : ''}', + file.name, + directory: file.stat.isDirectory, + size: file.stat.size, + ), + ]..sort( + (a, b) => a.directory != b.directory + ? (a.directory ? -1 : 1) + : a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + +SmbListingInput smbListingFixture(int count) { + const names = [ + 'Track', + 'track', + 'Ångström', + 'İstanbul', + '音楽 🎵', + 'Κόσμος', + 'ß', + ]; + final random = Random(8127); + final files = List.generate(count, (index) { + final directory = index % 7 == 0; + return Smb2DirEntry( + name: switch (index) { + 0 => '.', + 1 => '..', + _ => + '${names[index % names.length]} — ${index % 400 == 3 ? 'SAME' : index.toString()} Album with spaces #1${directory ? '' : '.flac'}', + }, + stat: Smb2Stat( + type: directory ? Smb2FileType.directory : Smb2FileType.file, + size: 1000000000 + index, + modified: DateTime.fromMillisecondsSinceEpoch( + 1700000000000 + index, + isUtc: true, + ), + created: DateTime.fromMillisecondsSinceEpoch( + 1600000000000 + index, + isUtc: true, + ), + ), + ); + })..shuffle(random); + return (files: files, path: 'Folder #1/'); +} + +List smbListingRows(List entries) => [ + for (final entry in entries) + [entry.path, entry.name, entry.directory, entry.size], +];