mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
fix(network): request Android 17 local network access before NAS connections
This commit is contained in:
1 parent
6debfccba2
commit
3d11169f21
10 files changed
+490
-2
No files matched your search
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user