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 =