M1-1: "Android 인 척" 스위치 · 실패 흉내 플래그를 주입으로 (PlatformInfo · WebViewProxyTarget)

- MemorySecretStore.failRead/failWrite 를 없앰 → 시험 쪽 test/support/flaky_secret_store.dart
- WebProxy.debugAndroid/debugSetProxy 를 없앰 → WebViewProxyTarget 주입 (Android=ProxyController, 그 밖=없음)
- ExitGuard.forceAndroid 를 없앰 → PlatformServices.platform (PlatformInfo)
- JJ_NLLB_LOW_MEMORY 환경 변수를 없앰 → NllbTranslator(lowMemory:), 처음 값은 PlatformInfo.host
- archive_ops · rsync_launch · file_error 의 기본값을 PlatformInfo.host 로 모음
- 이름 바꿈: WebProxy.shownForTest → shown, debugReset → reset
- 보안: 남은 Tor 웹 뷰가 있는 동안에도 removeImplicitRules (localhost 까지 Tor 로)
- 약속 시험 platform_info_contract_test 3개

확인: analyze 0 · 단위 1470 ×2 · 통합 6/6 ×2 · base10 214장 0px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 20:05:31 +09:00
parent 39d81f1a73
commit 2199d7c15f
17 changed files with 194 additions and 103 deletions

View File

@ -3,12 +3,12 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../core/local_proxy.dart';
import '../l10n/tr.dart';
import 'net_guard.dart';
import 'tor_service.dart';
import '../services/platform_info.dart';
import 'web_view_proxy_target.dart';
/// Android 웹 뷰 프록시 (androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다).
/// - Tor 화면이 열려 있으면: 모든 웹 뷰가 Tor 로 (관리자 결정 10/10 - 일반 브라우저에는 안내 띠)
@ -63,7 +63,7 @@ class WebProxy {
/// 보이는 웹 뷰 화면 수 ([show] · [hide]) 와 앱이 앞에 있는지
static int _shown = 0;
static bool _foreground = true;
static int get shownForTest => _shown;
static int get shown => _shown;
/// 웹 뷰 문 (인증 없음): 정한 DNS 로 잇는 것 · Tor 로 잇는 것 (따로 - Tor 화면을 닫으면 Tor 문도 닫혀 다른 길로 새지 않게)
static LocalProxy? _dnsGate;
@ -155,12 +155,9 @@ class WebProxy {
}
}
/// 시험용: Android 처럼 돌리고 ([debugAndroid]), 웹 뷰에 프록시를 거는 대신 기록 ([debugSetProxy], '' = 없앰)
@visibleForTesting
static bool debugAndroid = false;
@visibleForTesting
static Future<void> Function(String url, bool removeImplicitRules)? debugSetProxy;
static bool get _android => Platform.isAndroid || debugAndroid;
/// 웹 뷰에 프록시를 거는 곳 (이 기기에 맞는 것 - Windows 는 쓰지 않음). 조립 · 시험이 바꿔 끼운다 (M1)
static WebViewProxyTarget target = WebViewProxyTarget.forPlatform(PlatformInfo.host);
static bool get _android => target.active;
/// 지금 상태에 맞게 다시 건다 (차례로 - 겹치지 않게). 끝나면 걸린 프록시
static Future<String> apply() {
@ -191,32 +188,16 @@ class WebProxy {
await closeUnused();
return _applied;
}
if (debugSetProxy case final f?) {
await f(want, tor);
_applied = want;
await closeUnused();
return want;
}
final pc = ProxyController.instance();
if (want.isEmpty) {
await pc.clearProxyOverride();
} else {
await pc.setProxyOverride(
settings: ProxySettings(
proxyRules: [ProxyRule(url: want)],
// Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접)
removeImplicitRules: torOpen.value,
));
}
// Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접)
await target.set(want, removeImplicitRules: tor);
_applied = want;
debugPrint(trf('웹 뷰 프록시: {0}', [want.isEmpty ? tr('없음') : want]));
await closeUnused();
return want;
}
/// 시험용: 처음 상태로
@visibleForTesting
static Future<void> debugReset() async {
/// 처음 상태로 (문을 모두 닫고 수를 0 으로)
static Future<void> reset() async {
_shown = 0;
_foreground = true;
_applied = '';

View File

@ -0,0 +1,46 @@
/// 약속: 앱의 모든 웹 뷰에 프록시를 거는 곳 ([WebProxy] 가 부른다).
/// 입력 = 프록시 주소 ('' = 없앰) · Tor 처럼 localhost 까지 프록시로 보낼지. 출력 없음. 오류 = 기기가 거절하면 그 예외.
/// [active] 가 false 인 기기 (Windows - 웹 뷰마다 따로 건다) 에서는 [WebProxy] 가 아무것도 하지 않는다
library;
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../services/platform_info.dart';
abstract class WebViewProxyTarget {
/// 이 기기에서 앱 전체 웹 뷰 프록시를 쓰는지
bool get active;
Future<void> set(String url, {required bool removeImplicitRules});
/// 이 기기에 맞는 것 (Android: androidx.webkit ProxyController, 아니면 쓰지 않음)
factory WebViewProxyTarget.forPlatform(PlatformInfo platform) =>
platform.isAndroid ? const AndroidWebViewProxy() : const NoWebViewProxy();
}
/// Android: androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다
class AndroidWebViewProxy implements WebViewProxyTarget {
const AndroidWebViewProxy();
@override
bool get active => true;
@override
Future<void> set(String url, {required bool removeImplicitRules}) async {
final pc = ProxyController.instance();
if (url.isEmpty) {
await pc.clearProxyOverride();
} else {
await pc.setProxyOverride(
settings: ProxySettings(proxyRules: [ProxyRule(url: url)], removeImplicitRules: removeImplicitRules));
}
}
}
/// 앱 전체 웹 뷰 프록시가 없는 기기 (Windows)
class NoWebViewProxy implements WebViewProxyTarget {
const NoWebViewProxy();
@override
bool get active => false;
@override
Future<void> set(String url, {required bool removeImplicitRules}) async {}
}

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'file_ops.dart' show NameConflict, nameProblem;
import '../services/platform_info.dart';
/// 86: 압축 종류 (확장자로)
enum ArchiveKind { zip, sevenZip, rar }
@ -82,7 +83,7 @@ class ExtractPlan {
/// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만
/// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다
ExtractPlan planExtract(List<ArchiveEntry> entries, {List<String> names = const [], bool? windowsRules}) {
final win = windowsRules ?? Platform.isWindows;
final win = windowsRules ?? PlatformInfo.host.isWindows;
bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/'));
final files = <ArchiveEntry>[];
final unsafe = <(String, String)>[];

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import '../services/platform_info.dart';
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
@ -32,7 +33,7 @@ List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.c
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
if (!(windows ?? PlatformInfo.host.isWindows)) return RsyncLaunch._(args, null, null, null);
final bad = msysUnsafeArgs(args);
if (bad.isNotEmpty) {
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));

View File

@ -11,6 +11,7 @@ import 'package:path/path.dart' as p;
import '../../core/nllb_tokenizer.dart';
import '../../services/ai_services.dart';
import '../../services/platform_info.dart';
import 'onnx_external.dart';
import 'ort_tensors.dart';
import '../../l10n/tr.dart';
@ -29,7 +30,10 @@ class NllbTranslator implements Translator {
/// 한 번에 번역할 줄 수
final int batchSize;
NllbTranslator({this.batchSize = 8});
/// 메모리를 아끼는 방식 (모델을 하나씩 · 외부 데이터로 연다). 처음 값 = 이 기기 ([PlatformInfo.lowMemory])
final bool lowMemory;
NllbTranslator({this.batchSize = 8, bool? lowMemory}) : lowMemory = lowMemory ?? PlatformInfo.host.lowMemory;
Isolate? _isolate;
SendPort? _toWorker;
@ -76,7 +80,7 @@ class NllbTranslator implements Translator {
final threads = math.max(1, Platform.numberOfProcessors ~/ 2);
_isolate = await Isolate.spawn(
_workerMain,
[rp.sendPort, modelDir, threads, _cancelFlag.address, batchSize],
[rp.sendPort, modelDir, threads, _cancelFlag.address, batchSize, lowMemory],
debugName: 'nllb',
);
try {
@ -143,13 +147,14 @@ void _workerMain(List<Object?> args) {
final threads = args[2] as int;
final cancel = ffi.Pointer<ffi.Int32>.fromAddress(args[3] as int);
final batch = args[4] as int;
final low = args[5] as bool;
final inbox = ReceivePort();
main.send({'type': 'port', 'port': inbox.sendPort});
_NllbEngine engine;
try {
engine = _NllbEngine.load(modelDir, threads);
engine = _NllbEngine.load(modelDir, threads, low);
} catch (e) {
main.send({'type': 'fatal', 'error': '$e'});
inbox.close();
@ -204,12 +209,10 @@ class _NllbEngine {
_NllbEngine(this.tok, this.dir, this.threads, this.lowMemory);
factory _NllbEngine.load(String dir, int threads) {
factory _NllbEngine.load(String dir, int threads, bool low) {
OrtEnv.instance.init();
final tok = NllbTokenizer.fromJson(
File(p.join(dir, 'tokenizer.json')).readAsStringSync());
// JJ_NLLB_LOW_MEMORY=1: PC 에서 Android 방식을 시험할 때
final low = Platform.isAndroid || Platform.environment['JJ_NLLB_LOW_MEMORY'] == '1';
final e = _NllbEngine(tok, dir, threads, low);
// 넉넉한 기기는 미리 둘 다 연다 (모델 파일이 깨졌으면 여기서 알 수 있게)
if (!e.lowMemory) {

View File

@ -0,0 +1,22 @@
/// 약속: 앱이 도는 기기가 무엇인지 (Android · Windows) 와 메모리를 아껴야 하는지 - 한곳에서만 정한다.
/// 입력 없음 ([PlatformInfo.host] 는 이 기기를 그대로 읽는다), 출력 = 세 값, 오류 없음.
/// 기기에 따라 다르게 도는 곳은 이것을 받아 (생성자 · 조립) 쓴다 - "Android 인 척" 하는 스위치를 따로 두지 않는다 (M1)
library;
import 'dart:io';
class PlatformInfo {
final bool isAndroid;
final bool isWindows;
/// 메모리를 아끼는 방식 (모델을 나눠 열기 등). 처음 값은 Android
final bool lowMemory;
const PlatformInfo({required this.isAndroid, required this.isWindows, bool? lowMemory}) : lowMemory = lowMemory ?? isAndroid;
/// 이 기기
static final PlatformInfo host = PlatformInfo(isAndroid: Platform.isAndroid, isWindows: Platform.isWindows);
static const android = PlatformInfo(isAndroid: true, isWindows: false);
static const windows = PlatformInfo(isAndroid: false, isWindows: true);
}

View File

@ -27,6 +27,7 @@ import 'downloader.dart';
import 'media_player.dart';
import 'media_tool.dart';
import 'model_store.dart';
import 'platform_info.dart';
import 'preview_player.dart';
import 'storage_service.dart';
import 'subtitle_provider.dart';
@ -63,6 +64,9 @@ class PlatformServices {
/// PC 의 CPU · 메모리 사용량 (없으면 표시 숨김)
final SystemUsage? usage;
/// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기)
final PlatformInfo platform;
/// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다)
final List<SubtitleProvider> Function(AppSettings Function() settings)? createSubtitleProviders;
@ -79,7 +83,9 @@ class PlatformServices {
this.usage,
ModelStore? models,
AppShell? shell,
PlatformInfo? platform,
}) : models = models ?? ModelStore(),
platform = platform ?? PlatformInfo.host,
shell = shell ?? NoopShell();
factory PlatformServices.create() {

View File

@ -165,24 +165,14 @@ Uint8List _dpapi(Uint8List data, {required bool protect}) {
class MemorySecretStore implements SecretStore {
final Map<String, String> values = {};
/// 시험: 읽기 · 쓰기 실패 흉내
bool failRead = false;
bool failWrite = false;
@override
String? lostCopy;
@override
Future<Map<String, String>> readAll() async {
if (failRead) throw StateError('secret store unavailable');
return Map.of(values);
}
Future<Map<String, String>> readAll() async => Map.of(values);
@override
Future<void> write(String key, String value) async {
if (failWrite) throw StateError('secret store write failed');
values[key] = value;
}
Future<void> write(String key, String value) async => values[key] = value;
@override
Future<void> delete(String key) async => values.remove(key);

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -44,10 +42,6 @@ class ExitGuard extends StatelessWidget {
static const _ch = MethodChannel('jj_mkvmaker/android');
/// 시험용: Android 가 아니어도 Android 처럼
@visibleForTesting
static bool forceAndroid = false;
/// 돌고 있는 작업 (글로)
List<String> _running() {
final live = LiveSync.instance;
@ -61,7 +55,7 @@ class ExitGuard extends StatelessWidget {
];
}
bool get _guard => (Platform.isAndroid || forceAndroid) && !c.settings.run.inBackground && _running().isNotEmpty;
bool get _guard => c.services.platform.isAndroid && !c.settings.run.inBackground && _running().isNotEmpty;
@override
Widget build(BuildContext context) => ListenableBuilder(

View File

@ -2,6 +2,7 @@ import 'dart:io';
import '../core/secret_gate.dart';
import '../l10n/tr.dart';
import '../services/platform_info.dart';
/// 파일 · WebDAV 오류를 사람이 읽을 말로 (제목, 할 일). 원문 예외는 알아볼 수 없을 때만 그대로.
/// 파일 탐색기 · 폴더 고르기 · 그 밖에 WebDAV 를 읽는 화면이 함께 쓴다 (12 · 134).
@ -10,7 +11,7 @@ const androidRestrictedError = 'ANDROID_RESTRICTED_FOLDER';
/// 50: Android 11 부터 어떤 파일 앱도 다른 앱의 데이터 폴더 (Android/data · Android/obb) 를 볼 수 없다
bool isAndroidRestricted(String path, {bool? android}) =>
(android ?? Platform.isAndroid) && RegExp(r'/Android/(data|obb)(/|$)', caseSensitive: false).hasMatch(path);
(android ?? PlatformInfo.host.isAndroid) && RegExp(r'/Android/(data|obb)(/|$)', caseSensitive: false).hasMatch(path);
(String, String) explainFileError(String e, {required bool dav, bool noPassword = false}) {
if (e == androidRestrictedError) {

View File

@ -3,15 +3,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_info.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/exit_guard.dart';
void main() {
setUp(() => ExitGuard.forceAndroid = true);
tearDown(() => ExitGuard.forceAndroid = false);
Future<AppController> pump(WidgetTester tester, {required bool background, required bool busy}) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
c.settings.run.inBackground = background;
c.busy = busy;
c.currentJob = busy ? 'MKV 만들기 3/10' : null;

View File

@ -1,32 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/master_lock.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
/// 읽기가 될 때와 안 될 때를 바꿀 수 있는 안전 저장소 (기기 저장소가 잠깐 잠긴 때처럼)
class _FlakyStore implements SecretStore {
final Map<String, String> values = {};
bool readable = true;
@override
String? lostCopy;
@override
Future<Map<String, String>> readAll() async {
if (!readable) throw StateError('안전 저장소를 읽을 수 없음');
return Map.of(values);
}
@override
Future<void> write(String key, String value) async => values[key] = value;
@override
Future<void> delete(String key) async => values.remove(key);
}
import 'support/flaky_secret_store.dart';
/// M0 약속: 안전 저장소를 읽지 못하면 마스터 잠금은 '알 수 없음' - 문을 열지 않고 (한 번 다시 읽어 봄), 마스터 · 최상 · 묻는 때를 바꾸지 않는다.
/// 다시 읽히면 평소대로 (마스터가 있으면 묻는다)
void main() {
late _FlakyStore store;
late FlakySecretStore store;
late int asked;
setUp(() async {
store = _FlakyStore();
store = FlakySecretStore();
// 마스터가 정해진 기기
final setup = MasterLock(store);
await setup.load();
@ -40,7 +23,7 @@ void main() {
tearDown(() => MasterLock.prompt = null);
test('읽지 못하면 문을 열지 않는다 (예전: 마스터가 없는 것으로 보여 열렸다)', () async {
store.readable = false;
store.failRead = true;
final lock = MasterLock(store);
await lock.load(); // 예외를 내지 않는다 - 앱은 켜진다
expect([lock.storeUnreadable, lock.hasMaster], [true, false]);
@ -51,7 +34,7 @@ void main() {
test('알 수 없는 동안 마스터 · 최상 · 묻는 때를 바꾸지 않는다 (진짜 마스터를 덮어쓰지 않게)', () async {
final before = Map.of(store.values);
store.readable = false;
store.failRead = true;
final lock = MasterLock(store);
await lock.load();
await expectLater(lock.setMaster('new-pass'), throwsA(isA<MasterStoreUnreadable>()));
@ -62,20 +45,20 @@ void main() {
});
test('다시 읽히면 평소대로: 문을 열 때 한 번 다시 읽고, 마스터가 있으면 묻는다', () async {
store.readable = false;
store.failRead = true;
final lock = MasterLock(store);
await lock.load();
store.readable = true;
store.failRead = false;
expect(await lock.ensure(), isTrue);
expect([lock.storeUnreadable, lock.hasMaster, asked], [false, true, 1]);
});
test('reload: 읽혔는지 돌려준다', () async {
store.readable = false;
store.failRead = true;
final lock = MasterLock(store);
await lock.load();
expect(await lock.reload(), isFalse);
store.readable = true;
store.failRead = false;
expect(await lock.reload(), isTrue);
expect(lock.hasMaster, isTrue);
});

View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/web_view_proxy_target.dart';
import 'package:jj_mkvmaker/services/platform_info.dart';
/// M1 약속: 기기 판단은 PlatformInfo 한곳 - 이 기기는 그대로 읽고, 메모리 아끼기 처음 값은 Android,
/// 앱 전체 웹 뷰 프록시는 Android 에서만 (Windows 는 웹 뷰마다 따로)
void main() {
test('host 는 이 기기 그대로', () {
expect([PlatformInfo.host.isAndroid, PlatformInfo.host.isWindows], [Platform.isAndroid, Platform.isWindows]);
});
test('lowMemory 처음 값 = Android, 준 값은 그대로', () {
expect([PlatformInfo.android.lowMemory, PlatformInfo.windows.lowMemory], [true, false]);
expect(const PlatformInfo(isAndroid: false, isWindows: true, lowMemory: true).lowMemory, isTrue);
expect(const PlatformInfo(isAndroid: true, isWindows: false, lowMemory: false).lowMemory, isFalse);
});
test('웹 뷰 프록시: Android 만 쓰고, 아닌 기기는 걸어도 아무 일 없음', () async {
expect(WebViewProxyTarget.forPlatform(PlatformInfo.android), isA<AndroidWebViewProxy>());
final none = WebViewProxyTarget.forPlatform(PlatformInfo.windows);
expect(none.active, isFalse);
await none.set('http://127.0.0.1:9', removeImplicitRules: true);
});
}

View File

@ -8,16 +8,17 @@ import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:jj_mkvmaker/ui/secret_issue.dart';
import 'package:path/path.dart' as p;
import 'support/flaky_secret_store.dart';
/// 40-1 · 40-3: 비밀번호를 저장하지 못했으면 화면 위에 분명히 알리고 [다시 시도] 로 저장한다
void main() {
testWidgets('저장 실패 알림 → [다시 시도] 로 저장되면 알림이 사라진다', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_secret_ui_');
addTearDown(() => dir.deleteSync(recursive: true));
final secrets = MemorySecretStore()..failWrite = true;
final secrets = FlakySecretStore()..failWrite = true;
// 저장 순서용 Future 가 시험의 가짜 시간 영역에 묶이지 않게 실제 영역에서 만든다
late SettingsStore store;
late AppController c;
@ -51,7 +52,7 @@ void main() {
testWidgets('90 · 91: 예전 비밀번호로 돌아간다고 알림 · [닫기] 하면 같은 상황에서는 다시 안 뜸 · [다시 시도] 실패도 알림', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_secret_ui2_');
addTearDown(() => dir.deleteSync(recursive: true));
final secrets = MemorySecretStore()..values['dav.pw.d1'] = 'old';
final secrets = FlakySecretStore()..values['dav.pw.d1'] = 'old';
late SettingsStore store;
late AppController c;
await tester.runAsync(() async {

View File

@ -5,18 +5,19 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/version_snapshot.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:path/path.dart' as p;
import 'support/flaky_secret_store.dart';
/// 40 · 54: 비밀번호는 설정 파일에 평문으로 쓰지 않고 안전 저장소에. 한 번 넣으면 업데이트 · 예전 판 · 되살리기 뒤에도 남는다
void main() {
late Directory dir;
late String file;
late MemorySecretStore secrets;
late FlakySecretStore secrets;
setUp(() {
dir = Directory.systemTemp.createTempSync('jj_secret_');
file = p.join(dir.path, 'settings.json');
secrets = MemorySecretStore();
secrets = FlakySecretStore();
});
tearDown(() => dir.deleteSync(recursive: true));

View File

@ -0,0 +1,27 @@
import 'package:jj_mkvmaker/services/secret_store.dart';
/// 읽기 · 쓰기가 될 때와 안 될 때를 바꿀 수 있는 안전 저장소 (기기 저장소가 잠깐 잠긴 때처럼).
/// 시험에만 있다 - lib 의 저장소에는 실패 흉내 플래그를 두지 않는다 (M1)
class FlakySecretStore implements SecretStore {
final Map<String, String> values = {};
bool failRead = false;
bool failWrite = false;
@override
String? lostCopy;
@override
Future<Map<String, String>> readAll() async {
if (failRead) throw StateError('secret store unavailable');
return Map.of(values);
}
@override
Future<void> write(String key, String value) async {
if (failWrite) throw StateError('secret store write failed');
values[key] = value;
}
@override
Future<void> delete(String key) async => values.remove(key);
}

View File

@ -5,6 +5,7 @@ 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/app/web_view_proxy_target.dart';
import 'package:jj_mkvmaker/core/dns_resolver.dart';
/// Android 웹 뷰 프록시 문 (관리자 결정 10/11 A+B): 인증 없이, 실행마다 고른 루프백 주소에,
@ -12,18 +13,16 @@ import 'package:jj_mkvmaker/core/dns_resolver.dart';
void main() {
final calls = <(String, bool)>[];
setUp(() async {
await WebProxy.debugReset();
WebProxy.debugAndroid = true;
WebProxy.debugSetProxy = (url, tor) async => calls.add((url, tor));
await WebProxy.reset();
WebProxy.target = _RecordingProxy(calls);
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;
await WebProxy.reset();
WebProxy.target = const NoWebViewProxy();
NetGuard.instance.config = () => const DnsConfig.system();
});
@ -126,15 +125,15 @@ void main() {
final nav = GlobalKey<NavigatorState>();
await t.pumpWidget(MaterialApp(navigatorKey: nav, home: const _WebScreen()));
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
expect(WebProxy.shownForTest, 1);
expect(WebProxy.shown, 1);
nav.currentState!.push(MaterialPageRoute<void>(builder: (_) => const Text('other')));
await t.pumpAndSettle();
expect(WebProxy.shownForTest, 0);
expect(WebProxy.shown, 0);
nav.currentState!.pop();
await t.pumpAndSettle();
expect(WebProxy.shownForTest, 1);
expect(WebProxy.shown, 1);
await t.pumpWidget(const SizedBox());
expect(WebProxy.shownForTest, 0);
expect(WebProxy.shown, 0);
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
});
}
@ -149,3 +148,13 @@ class _WebScreenState extends State<_WebScreen> with WebProxyVisibility {
@override
Widget build(BuildContext context) => const Text('web');
}
/// Android 처럼 앱 전체 웹 뷰 프록시를 쓰는 기기 - 거는 대신 기록 (시험 파일 안의 것, lib 에 흉내 장치 없음)
class _RecordingProxy implements WebViewProxyTarget {
final List<(String, bool)> calls;
_RecordingProxy(this.calls);
@override
bool get active => true;
@override
Future<void> set(String url, {required bool removeImplicitRules}) async => calls.add((url, removeImplicitRules));
}