fix(network): request Android 17 local network access before NAS connections

This commit is contained in:
zarzet committed 2026-10-17 16:18:41 +07:00
1 parent 6debfccba2
commit 3d11169f21
10 files changed
+490 -2

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
@@ -0,0 +1,113 @@
package com.zarz.spotiflac
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
/** Shared-engine permission bridge; ongoing transfers never own an Activity. */
class LocalNetworkPlugin : FlutterPlugin, ActivityAware, MethodChannel.MethodCallHandler,
PluginRegistry.RequestPermissionsResultListener {
private lateinit var channel: MethodChannel
private lateinit var context: Context
private var activityBinding: ActivityPluginBinding? = null
private val pending = mutableListOf<MethodChannel.Result>()
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<out String>,
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
}
}
@@ -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())
}
+2
View File
@@ -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,
+81
View File
@@ -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<List<InternetAddress>> 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<List<InternetAddress>> Function(String) _lookup;
Future<void>? _pending;
Future<void> ensureAccess(Uri target) async {
if (!_isAndroid) return;
// Also returns true on older Android releases. Recheck after revocation.
if (await _channel.invokeMethod<bool>('isGranted') == true) return;
if (!await _isLocalTarget(target)) return;
await (_pending ??= _request().whenComplete(() => _pending = null));
}
Future<void> _request() async {
if (await _channel.invokeMethod<bool>('request') != true) {
throw const LocalNetworkPermissionException();
}
}
Future<bool> _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);
}
}
+5
View File
@@ -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;
}
+10 -2
View File
@@ -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<String?> Function()? read,
Future<void> Function(String)? write,
Future<void> 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<String?> Function() _read;
final Future<void> Function(String) _write;
final Future<void> Function(Uri) _ensureAccess;
static const timeout = Duration(seconds: 20);
Future<List<NetworkConnection>>? _loading;
List<NetworkConnection>? _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<NetworkSmbClient> _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,
+233
View File
@@ -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<bool>? 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<bool>();
final ready = Future.wait(
List.generate(6, (_) => access.ensureAccess(target)),
);
await Future<void>.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<LocalNetworkPermissionException>()),
);
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 = <Uri>[];
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<LocalNetworkPermissionException>());
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<HttpException>()));
expect(serverRequests, 1);
allowed = false;
final denied = throwsA(isA<LocalNetworkPermissionException>());
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);
});
}
+3
View File
@@ -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 = <Object, NetworkStorageError>{
const SocketException('private host'): NetworkStorageError.unreachable,
const LocalNetworkPermissionException():
NetworkStorageError.localNetworkPermission,
TimeoutException('private host'): NetworkStorageError.timeout,
const Smb2Exception('STATUS_LOGON_FAILURE'):
NetworkStorageError.authentication,
+39
View File
@@ -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 {