From 3d11169f211a2d813ff25e0ad4c3e9d26e608d34 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:18:41 +0700 Subject: [PATCH] fix(network): request Android 17 local network access before NAS connections --- android/app/src/main/AndroidManifest.xml | 1 + .../com/zarz/spotiflac/LocalNetworkPlugin.kt | 113 +++++++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 3 + lib/screens/network_storage_screen.dart | 2 + lib/services/local_network_access.dart | 81 ++++++ lib/services/network_storage_error.dart | 5 + lib/services/network_storage_service.dart | 12 +- test/local_network_access_test.dart | 233 ++++++++++++++++++ test/network_storage_error_test.dart | 3 + test/network_storage_screen_test.dart | 39 +++ 10 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/LocalNetworkPlugin.kt create mode 100644 lib/services/local_network_access.dart create mode 100644 test/local_network_access_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 56c2507b..3a83e953 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + () + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/local_network") + channel.setMethodCallHandler(this) + } + + private fun isGranted(): Boolean = + Build.VERSION.SDK_INT < 37 || context.applicationInfo.targetSdkVersion < 37 || + context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "isGranted" -> result.success(isGranted()) + "request" -> request(result) + else -> result.notImplemented() + } + } + + private fun request(result: MethodChannel.Result) { + if (isGranted()) { + result.success(true) + return + } + if (pending.isNotEmpty()) { + pending.add(result) + return + } + val activity = activityBinding?.activity + val lifecycle = (activity as? LifecycleOwner)?.lifecycle + if (activity == null || activity.isFinishing || activity.isDestroyed || + lifecycle?.currentState?.isAtLeast(Lifecycle.State.STARTED) != true) { + // Background scans/uploads must not open a permission dialog. + result.success(false) + return + } + pending.add(result) + try { + activity.requestPermissions(arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK), REQUEST_CODE) + } catch (_: Exception) { + complete(false) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { + if (requestCode != REQUEST_CODE) return false + complete(isGranted()) + return true + } + + private fun complete(granted: Boolean) { + val results = pending.toList() + pending.clear() + results.forEach { it.success(granted) } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addRequestPermissionsResultListener(this) + if (pending.isNotEmpty() && isGranted()) complete(true) + } + + private fun detachActivity() { + activityBinding?.removeRequestPermissionsResultListener(this) + activityBinding = null + } + + override fun onDetachedFromActivityForConfigChanges() = detachActivity() + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = + onAttachedToActivity(binding) + + override fun onDetachedFromActivity() { + detachActivity() + complete(false) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + detachActivity() + complete(false) + } + + private companion object { + const val REQUEST_CODE = 3701 + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index dba2e458..79b0929d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1002,6 +1002,9 @@ class MainActivity: FlutterFragmentActivity() { onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger + if (!flutterEngine.plugins.has(LocalNetworkPlugin::class.java)) { + flutterEngine.plugins.add(LocalNetworkPlugin()) + } if (!flutterEngine.plugins.has(UsbBitPerfectPlugin::class.java)) { flutterEngine.plugins.add(UsbBitPerfectPlugin()) } diff --git a/lib/screens/network_storage_screen.dart b/lib/screens/network_storage_screen.dart index afc763a0..149d55ac 100644 --- a/lib/screens/network_storage_screen.dart +++ b/lib/screens/network_storage_screen.dart @@ -893,6 +893,8 @@ String _networkErrorMessage(BuildContext context, NetworkStorageError error) => NetworkStorageError.authentication => context.l10n.networkErrorAuthentication, NetworkStorageError.permission => context.l10n.networkErrorPermission, + NetworkStorageError.localNetworkPermission => + context.l10n.networkErrorLocalPermission, NetworkStorageError.missingShare => context.l10n.networkErrorShare, NetworkStorageError.incompatible => context.l10n.networkErrorProtocol, NetworkStorageError.invalidAddress => context.l10n.networkErrorAddress, diff --git a/lib/services/local_network_access.dart b/lib/services/local_network_access.dart new file mode 100644 index 00000000..1cbdf815 --- /dev/null +++ b/lib/services/local_network_access.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class LocalNetworkPermissionException implements Exception { + const LocalNetworkPermissionException(); + + @override + String toString() => 'Local network permission is required'; +} + +/// Android 17 gates LAN sockets, including SMB and local WebDAV/HTTP servers. +class LocalNetworkAccess { + LocalNetworkAccess({ + bool? isAndroid, + MethodChannel channel = const MethodChannel( + 'com.zarz.spotiflac/local_network', + ), + Future> Function(String)? lookup, + }) : _isAndroid = isAndroid ?? Platform.isAndroid, + _channel = channel, + _lookup = lookup ?? InternetAddress.lookup; + + static final instance = LocalNetworkAccess(); + final bool _isAndroid; + final MethodChannel _channel; + final Future> Function(String) _lookup; + Future? _pending; + + Future ensureAccess(Uri target) async { + if (!_isAndroid) return; + // Also returns true on older Android releases. Recheck after revocation. + if (await _channel.invokeMethod('isGranted') == true) return; + if (!await _isLocalTarget(target)) return; + await (_pending ??= _request().whenComplete(() => _pending = null)); + } + + Future _request() async { + if (await _channel.invokeMethod('request') != true) { + throw const LocalNetworkPermissionException(); + } + } + + Future _isLocalTarget(Uri target) async { + final host = target.host.toLowerCase().replaceFirst(RegExp(r'\.$'), ''); + final address = InternetAddress.tryParse(host.split('%').first); + if (address != null) return _isLocalAddress(address); + if (host == 'localhost' || host.endsWith('.localhost')) return false; + // mDNS itself needs permission, so do not resolve .local first. + if (!host.contains('.') || + host.endsWith('.local') || + host == 'home.arpa' || + host.endsWith('.home.arpa')) { + return true; + } + // A public DNS name can still point at a NAS on the local network. + return (await _lookup( + host, + ).timeout(const Duration(seconds: 5))).any(_isLocalAddress); + } + + static bool _isLocalAddress(InternetAddress address) { + if (address.isLoopback) return false; + if (address.isLinkLocal) return true; + var bytes = address.rawAddress; + if (bytes.length == 16) { + if (bytes.take(10).every((byte) => byte == 0) && + bytes[10] == 255 && + bytes[11] == 255) { + bytes = bytes.sublist(12); + } else { + return bytes[0] & 0xfe == 0xfc; + } + } + return bytes[0] == 10 || + (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) || + (bytes[0] == 192 && bytes[1] == 168) || + (bytes[0] == 169 && bytes[1] == 254) || + (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127); + } +} diff --git a/lib/services/network_storage_error.dart b/lib/services/network_storage_error.dart index 7c34d8d9..8c37f208 100644 --- a/lib/services/network_storage_error.dart +++ b/lib/services/network_storage_error.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:dart_smb2/dart_smb2.dart'; +import 'package:spotiflac_android/services/local_network_access.dart'; import 'package:spotiflac_android/services/network_certificate.dart'; enum NetworkStorageError { @@ -9,6 +10,7 @@ enum NetworkStorageError { timeout, authentication, permission, + localNetworkPermission, missingShare, incompatible, invalidAddress, @@ -18,6 +20,9 @@ enum NetworkStorageError { /// Classify transport failures without exposing URLs, usernames or passwords. NetworkStorageError classifyNetworkStorageError(Object error) { + if (error is LocalNetworkPermissionException) { + return NetworkStorageError.localNetworkPermission; + } if (error is NetworkCertificateException || error is HandshakeException) { return NetworkStorageError.certificate; } diff --git a/lib/services/network_storage_service.dart b/lib/services/network_storage_service.dart index 46ed108e..9d914839 100644 --- a/lib/services/network_storage_service.dart +++ b/lib/services/network_storage_service.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:html/parser.dart' as html; +import 'package:spotiflac_android/services/local_network_access.dart'; import 'package:spotiflac_android/services/network_certificate.dart'; import 'package:spotiflac_android/services/network_smb_client.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -193,8 +194,10 @@ class NetworkStorageService { NetworkStorageService({ Future Function()? read, Future Function(String)? write, + Future Function(Uri)? ensureAccess, }) : _read = read ?? (() => _storage.read(key: _key)), - _write = write ?? ((value) => _storage.write(key: _key, value: value)); + _write = write ?? ((value) => _storage.write(key: _key, value: value)), + _ensureAccess = ensureAccess ?? LocalNetworkAccess.instance.ensureAccess; static final instance = NetworkStorageService(); static const _storage = FlutterSecureStorage( aOptions: secureStorageAndroidOptions, @@ -202,6 +205,7 @@ class NetworkStorageService { static const _key = 'network_storage_connections_v1'; final Future Function() _read; final Future Function(String) _write; + final Future Function(Uri) _ensureAccess; static const timeout = Duration(seconds: 20); Future>? _loading; List? _connections; @@ -296,7 +300,8 @@ class NetworkStorageService { throw const FormatException('Invalid network source'); } final path = networkPath(uri.pathSegments.join('/')); - await connection(uri.host); + final c = await connection(uri.host); + await _ensureAccess(networkTarget(c, path)); _server ??= HttpServer.bind(InternetAddress.loopbackIPv4, 0).then((server) { server.listen((request) => unawaited(_serve(request))); return server; @@ -317,6 +322,7 @@ class NetworkStorageService { } Future _smb(NetworkConnection c, Uri target) async { + await _ensureAccess(target); var expired = false; final pending = NetworkSmbClient.connect( target, @@ -390,6 +396,7 @@ class NetworkStorageService { void Function()? checkpoint, void Function(int)? progress, }) async { + await _ensureAccess(uri); NetworkCertificateException? rejectedCertificate; client.badCertificateCallback = (certificate, host, port) { if (uri.scheme != 'https' || @@ -519,6 +526,7 @@ class NetworkStorageService { final target = networkTarget(c, path); if (c.protocol == NetworkProtocol.smb) { if (target.pathSegments.every((s) => s.isEmpty)) { + await _ensureAccess(target); final shares = await NetworkSmbClient.shares( target, username: c.username, diff --git a/test/local_network_access_test.dart b/test/local_network_access_test.dart new file mode 100644 index 00000000..cc8ce65e --- /dev/null +++ b/test/local_network_access_test.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/local_network_access.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/local_network'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + HttpOverrides.global = null; + + var granted = false; + var allowRequest = true; + var checks = 0; + var requests = 0; + Completer? pending; + + setUp(() { + granted = false; + allowRequest = true; + checks = 0; + requests = 0; + pending = null; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'isGranted') { + checks++; + return granted; + } + expect(call.method, 'request'); + requests++; + return pending == null ? allowRequest : await pending!.future; + }); + }); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + for (final host in [ + 'nas', + 'nas.local', + 'NAS.LOCAL.', + 'nas.home.arpa', + '10.0.0.1', + '172.16.0.1', + '172.31.255.254', + '192.168.1.2', + '169.254.1.1', + '100.64.0.1', + '[fd12::1]', + '[fe80::1]', + '[::ffff:192.168.1.2]', + ]) { + test('requests permission before local access to $host', () async { + final access = LocalNetworkAccess( + isAndroid: true, + lookup: (_) async => fail('Local names must not be resolved first'), + ); + await access.ensureAccess(Uri.parse('smb://$host/music/')); + expect(checks, 1); + expect(requests, 1); + }); + } + + for (final host in [ + 'localhost', + 'server.localhost', + '127.0.0.1', + '[::1]', + '8.8.8.8', + '172.32.0.1', + '[2001:4860:4860::8888]', + ]) { + test('does not gate loopback or public address $host', () async { + allowRequest = false; + final access = LocalNetworkAccess(isAndroid: true); + await access.ensureAccess(Uri.parse('https://$host/music/')); + expect(requests, 0); + }); + } + + test('older Android or a grant avoids DNS and permission requests', () async { + granted = true; + final access = LocalNetworkAccess( + isAndroid: true, + lookup: (_) async => fail('No DNS needed when access is allowed'), + ); + await access.ensureAccess(Uri.parse('https://music.example.test/')); + expect(requests, 0); + expect(checks, 1); + }); + + test('other platforms do not call the Android permission channel', () async { + final access = LocalNetworkAccess(isAndroid: false); + await access.ensureAccess(Uri.parse('smb://nas.local/music/')); + expect(checks, 0); + expect(requests, 0); + }); + + for (final local in [false, true]) { + test( + 'DNS names resolving to ${local ? 'LAN' : 'public'} addresses', + () async { + final access = LocalNetworkAccess( + isAndroid: true, + lookup: (host) async { + expect(host, 'music.example.test'); + return [InternetAddress(local ? '192.168.1.2' : '8.8.8.8')]; + }, + ); + await access.ensureAccess(Uri.parse('https://music.example.test/')); + expect(requests, local ? 1 : 0); + }, + ); + } + + test( + 'concurrent operations await one request and recheck revocation', + () async { + final access = LocalNetworkAccess(isAndroid: true); + final target = Uri.parse('smb://nas.local/music/'); + pending = Completer(); + final ready = Future.wait( + List.generate(6, (_) => access.ensureAccess(target)), + ); + await Future.delayed(Duration.zero); + expect(requests, 1); + granted = true; + pending!.complete(true); + await ready; + await access.ensureAccess(target); + expect(requests, 1); + granted = false; + pending = null; + allowRequest = false; + await expectLater( + access.ensureAccess(target), + throwsA(isA()), + ); + expect(requests, 2); + allowRequest = true; + await access.ensureAccess(target); + expect(requests, 3); + }, + ); + + test( + 'denial stops SMB share enumeration, reads, writes and playback', + () async { + final seen = []; + final storage = NetworkStorageService( + read: () async => null, + write: (_) async {}, + ensureAccess: (target) async { + seen.add(target); + throw const LocalNetworkPermissionException(); + }, + ); + addTearDown(storage.dispose); + const root = NetworkConnection( + id: 'nas', + name: 'NAS', + protocol: NetworkProtocol.smb, + address: 'smb://nas.local/', + ); + await storage.save(root); + final denied = throwsA(isA()); + await expectLater(storage.list(root), denied); + await expectLater(storage.list(root, 'Music/'), denied); + final source = NetworkStorageService.source('nas', 'Music/song.flac'); + await expectLater(storage.resolve(source), denied); + await expectLater(storage.deleteFile(source), denied); + await expectLater( + storage.prepareUpload( + NetworkStorageService.source('nas', 'Music/'), + '', + 'new.flac', + ), + denied, + ); + expect(seen, hasLength(5)); + }, + ); + + test('revoked permission blocks WebDAV before any server request', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + var serverRequests = 0; + server.listen((request) async { + serverRequests++; + request.response.statusCode = 404; + await request.response.close(); + }); + var allowed = true; + final storage = NetworkStorageService( + read: () async => null, + write: (_) async {}, + ensureAccess: (_) async { + if (!allowed) throw const LocalNetworkPermissionException(); + }, + ); + addTearDown(() async { + await storage.dispose(); + await server.close(force: true); + }); + final connection = NetworkConnection( + id: 'nas', + name: 'NAS', + protocol: NetworkProtocol.webdav, + address: 'http://127.0.0.1:${server.port}/music/', + ); + await storage.save(connection); + await expectLater(storage.list(connection), throwsA(isA())); + expect(serverRequests, 1); + allowed = false; + final denied = throwsA(isA()); + await expectLater(storage.list(connection), denied); + await expectLater( + storage.deleteFile(NetworkStorageService.source('nas', 'song.flac')), + denied, + ); + await expectLater( + storage.prepareUpload( + NetworkStorageService.source('nas', ''), + '', + 'new.flac', + ), + denied, + ); + expect(serverRequests, 1); + }); +} diff --git a/test/network_storage_error_test.dart b/test/network_storage_error_test.dart index 7440ca1a..7d856c7d 100644 --- a/test/network_storage_error_test.dart +++ b/test/network_storage_error_test.dart @@ -3,12 +3,15 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:dart_smb2/dart_smb2.dart'; +import 'package:spotiflac_android/services/local_network_access.dart'; import 'package:spotiflac_android/services/network_storage_error.dart'; void main() { test('network diagnostics separate transport, login and share failures', () { final cases = { const SocketException('private host'): NetworkStorageError.unreachable, + const LocalNetworkPermissionException(): + NetworkStorageError.localNetworkPermission, TimeoutException('private host'): NetworkStorageError.timeout, const Smb2Exception('STATUS_LOGON_FAILURE'): NetworkStorageError.authentication, diff --git a/test/network_storage_screen_test.dart b/test/network_storage_screen_test.dart index 3d2de9ed..8c31fc65 100644 --- a/test/network_storage_screen_test.dart +++ b/test/network_storage_screen_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/screens/network_storage_screen.dart'; +import 'package:spotiflac_android/services/local_network_access.dart'; import 'package:spotiflac_android/services/network_certificate.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -101,6 +102,44 @@ void main() { expect(await service.connections(), isEmpty); }); for (final mornye in [false, true]) { + testWidgets('local permission denial can be retried (Mornye: $mornye)', ( + tester, + ) async { + final service = _NetworkFixture() + ..error = const LocalNetworkPermissionException(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData(useMaterial3: true), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: NetworkStorageScreen( + service: service, + connection: const NetworkConnection( + id: 'nas', + name: 'NAS', + protocol: NetworkProtocol.smb, + address: 'smb://nas.local/music/', + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Allow Nearby devices'), findsOneWidget); + expect( + find.textContaining('The server rejected the sign-in'), + findsNothing, + ); + service.error = null; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.textContaining('Allow Nearby devices'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( 'certificate trust requires a tap and stays on its server (Mornye: $mornye)', (tester) async {