import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/web_proxy.dart'; import 'package:jj_mkvmaker/core/dns_resolver.dart'; /// Android 웹 뷰 프록시 문 (관리자 결정 10/11 A+B): 인증 없이, 실행마다 고른 루프백 주소에, /// 웹 뷰 화면이 보이는 동안만 연다. 앱이 뒤로 가거나 가려지면 닫고 웹 뷰는 닫힌 포트로 (직접 나가지 않음) void main() { final calls = <(String, bool)>[]; setUp(() async { await WebProxy.debugReset(); WebProxy.debugAndroid = true; WebProxy.debugSetProxy = (url, tor) async => calls.add((url, tor)); WebProxy.torOpen.value = false; NetGuard.instance.config = () => const DnsConfig(mode: 'custom', primary: '8.8.8.8', secondary: '8.8.4.4', dohUrl: ''); calls.clear(); }); tearDown(() async { WebProxy.torOpen.value = false; await WebProxy.debugReset(); WebProxy.debugAndroid = false; WebProxy.debugSetProxy = null; NetGuard.instance.config = () => const DnsConfig.system(); }); /// 그 주소로 CONNECT 를 보내 첫 줄 (닫혀 있으면 null) Future probe(String url) async { final u = Uri.parse(url); final Socket s; try { s = await Socket.connect(u.host, u.port, timeout: const Duration(seconds: 2)); } on SocketException { return null; } // 아무도 듣지 않는 곳 (127.0.0.1:1) 으로: 인증을 묻지 않으면 위쪽 연결 실패 (502) 가 온다 s.write('CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n'); final first = await s.cast>().transform(latin1.decoder).first.timeout(const Duration(seconds: 10)); s.destroy(); return first.split('\r\n').first; } test('보이는 웹 뷰가 없으면 문 없이 닫힌 포트로 · 보이면 숨긴 루프백 주소에 인증 없는 문', () async { expect(await WebProxy.apply(), WebProxy.closedProxy); expect(WebProxy.dnsGateUrl, isNull); final url = await WebProxy.show(); expect(url, WebProxy.dnsGateUrl); final host = Uri.parse(url).host; expect(InternetAddress(host).isLoopback, isTrue); expect(calls.last, (url, false)); // 인증을 묻지 않는다 (Android 웹 뷰는 407 에 답하지 못함) - 위쪽에 잇지 못해 502 expect(await probe(url), startsWith('HTTP/1.1 502')); // 화면이 가려지면 닫는다 expect(await WebProxy.hide(), WebProxy.closedProxy); expect(WebProxy.dnsGateUrl, isNull); expect(await probe(url), isNull, reason: '닫힌 문'); }); test('앱이 뒤로 가면 닫고 돌아오면 다시 연다 (같은 실행에서는 같은 주소)', () async { final url = await WebProxy.show(); expect(await WebProxy.setForeground(false), WebProxy.closedProxy); expect(await probe(url), isNull); final again = await WebProxy.setForeground(true); expect(again, isNotNull); expect(Uri.parse(again).host, Uri.parse(url).host); expect(await probe(again), startsWith('HTTP/1.1 502')); }); test('Tor 화면: Tor 문만 열고 DNS 문은 닫는다 · Tor 가 준비되지 않았으면 직접 잇지 않음 · 닫으면 Tor 문도 닫힘', () async { final dnsUrl = await WebProxy.show(); WebProxy.torOpen.value = true; final torUrl = await WebProxy.apply(); expect(torUrl, WebProxy.torGateUrl); expect(calls.last, (torUrl, true), reason: 'Tor 는 localhost 까지 Tor 로 (removeImplicitRules)'); expect(WebProxy.dnsGateUrl, isNull); expect(await probe(dnsUrl), isNull); expect(await probe(torUrl), startsWith('HTTP/1.1 502')); WebProxy.torOpen.value = false; await WebProxy.hide(); expect(WebProxy.torGateUrl, isNull); expect(await probe(torUrl), isNull); }); test('"다른 화면으로 가면 소리 멈춤" 이 꺼져 있으면 일반 브라우저가 살아 있는 동안 DNS 문 유지 (Tor 는 아님)', () async { var keepPlaying = false; WebProxy.keepPlayingWhenHidden = () => keepPlaying; WebProxy.browserViewCreated(); final url = await WebProxy.show(); // 설정 켜짐 (처음 값): 앱이 뒤로 가면 닫힘 expect(await WebProxy.setForeground(false), WebProxy.closedProxy); expect(await probe(url), isNull); await WebProxy.setForeground(true); // 설정 꺼짐: 앱이 뒤로 가도 · 다른 화면이 위로 와도 열어 둔다 (다음 조각 · 다음 곡) keepPlaying = true; final kept = await WebProxy.setForeground(false); expect(kept, WebProxy.dnsGateUrl); expect(await probe(kept), startsWith('HTTP/1.1 502')); expect(await WebProxy.hide(), kept); // 일반 브라우저가 없어지면 닫는다 WebProxy.browserViewDisposed(); await WebProxy.apply(); expect(WebProxy.dnsGateUrl, isNull); expect(await probe(kept), isNull); // Tor 는 설정과 상관없이 보이는 동안만 WebProxy.browserViewCreated(); await WebProxy.show(); WebProxy.torOpen.value = true; await WebProxy.setForeground(true); expect(WebProxy.torGateUrl, isNotNull); expect(await WebProxy.setForeground(false), WebProxy.closedProxy); expect(WebProxy.torGateUrl, isNull); WebProxy.torOpen.value = false; }); test('DNS 강제가 꺼져 있고 Tor 도 아니면 프록시 없음 (문도 없음)', () async { NetGuard.instance.config = () => const DnsConfig.system(); expect(await WebProxy.show(), ''); expect(WebProxy.dnsGateUrl, isNull); expect(calls.where((c) => c.$1.isNotEmpty), isEmpty, reason: '프록시를 걸지 않음'); }); testWidgets('웹 뷰 화면 위에 다른 화면이 오면 닫고, 돌아오면 다시 연다', (t) async { final nav = GlobalKey(); await t.pumpWidget(MaterialApp(navigatorKey: nav, home: const _WebScreen())); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); expect(WebProxy.shownForTest, 1); nav.currentState!.push(MaterialPageRoute(builder: (_) => const Text('other'))); await t.pumpAndSettle(); expect(WebProxy.shownForTest, 0); nav.currentState!.pop(); await t.pumpAndSettle(); expect(WebProxy.shownForTest, 1); await t.pumpWidget(const SizedBox()); expect(WebProxy.shownForTest, 0); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); }); } class _WebScreen extends StatefulWidget { const _WebScreen(); @override State<_WebScreen> createState() => _WebScreenState(); } class _WebScreenState extends State<_WebScreen> with WebProxyVisibility { @override Widget build(BuildContext context) => const Text('web'); }