import 'dart:io'; import 'package:path/path.dart' as p; import '../core/download_detect.dart'; import 'settings/settings_group.dart'; import 'settings/general_settings.dart'; import 'settings/playback_settings.dart'; import 'settings/mkv_settings.dart'; import 'settings/display_settings.dart'; import 'settings/remote_settings.dart'; import 'settings/explorer_tool_settings.dart'; import 'settings/vault_settings.dart'; import 'settings/run_settings.dart'; import 'settings/browser_settings.dart'; import 'settings/download_settings.dart'; import 'settings/reader_settings.dart'; import 'settings/ai_image_settings.dart'; import 'settings/copy_settings.dart'; import 'settings/tor_settings.dart'; import 'settings/network_settings.dart'; import 'settings/explorer_settings.dart'; export 'settings/records.dart'; export 'settings/settings_group.dart'; export 'settings/settings_store.dart'; export 'settings/general_settings.dart'; export 'settings/playback_settings.dart'; export 'settings/mkv_settings.dart'; export 'settings/display_settings.dart'; export 'settings/remote_settings.dart'; export 'settings/explorer_tool_settings.dart'; export 'settings/vault_settings.dart'; export 'settings/run_settings.dart'; export 'settings/browser_settings.dart'; export 'settings/download_settings.dart'; export 'settings/reader_settings.dart'; export 'settings/ai_image_settings.dart'; export 'settings/copy_settings.dart'; export 'settings/tor_settings.dart'; export 'settings/network_settings.dart'; export 'settings/explorer_settings.dart'; /// 환경 설정 (settings.json 에 저장) class AppSettings { /// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음 String webViewDataDir = ''; AppSettings(); /// 파일 탐색기 (X-plore 참고) final explorer = ExplorerSettings(); /// 네트워크 · DNS (사용자 요청 10/10) final network = NetworkSettings(); /// Tor 브라우저 (사용자 요청 10/10) final tor = TorBrowserSettings(); /// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) final copy = CopySettings(); /// AI 그림 · 해상도 올리기 (123 · 121) final aiImage = AiImageSettings(); /// 보기 화면 (그림 · 만화 · PDF · 문서) final reader = ReaderSettings(); /// 다운로드 · YouTube (yt-dlp · aria2) final download = DownloadSettings(); /// 웹 브라우저 (앱 안 · 외부) final browser = BrowserSettings(); /// 실행 · 종료 · 업데이트 final run = RunSettings(); /// 암호 보관함 final vault = VaultSettings(); /// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기 final tools = ExplorerToolSettings(); /// 원격 서버 (WebDAV · SFTP · SMB · FTP) final remote = RemoteSettings(); /// 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기) final display = DisplaySettings(); /// MKV · 자막 만들기 (저장 위치 · 이동 버튼 · 인코딩 · AI 자막 · OpenSubtitles) final mkv = MkvSettings(); /// 재생 (동영상 열기 · 재생 목록 · 외부 플레이어 · 자막 크기 · 기본 자막 언어) final playback = PlaybackSettings(); /// 일반 (즐겨찾기 · 저장 공간 정리 · 바뀐 기본값 알림 · 안쪽 → 바깥 동기화 · 비밀번호 기억 · 휴지통 · 모든 파일 안내) final general = GeneralSettings(); /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display, mkv, playback, general]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { // 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40) final j = {...stripSecrets(extra)}; for (final g in groups) { g.write(j, plainSecrets: plainSecrets); } return j; } factory AppSettings.fromJson(Map j) { final s = AppSettings(); // 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다 for (final g in s.groups) { g.read(j); } s.migrated = _migrated(j, s); s.extra = Map.of(j); return s; } /// 업데이트하면서 예전 기본값을 새 기본값으로 옮긴 것 (예전 설정 파일에 그 값이 실제로 있었을 때만). /// 앱이 켜질 때 한 번 알리고 (무엇이 바뀌었는지 · 환경 설정에서 되돌릴 수 있다는 것) 비운다. 저장하지 않는다. List migrated = []; /// 읽은 설정 파일 그대로 (이 판이 모르는 항목을 저장할 때 지우지 않게) Map extra = {}; /// 설정 파일에 평문으로 두면 안 되는 항목 (54) static const secretKeys = ['openSubtitlesKey', 'openSubtitlesUser', 'openSubtitlesPassword']; /// 설정 JSON 에서 비밀 값을 뺀 것 (.bak · 모르는 항목 보존용) static Map stripSecrets(Map j) { final r = Map.of(j); for (final k in secretKeys) { r.remove(k); } final servers = r['webdavServers']; if (servers is List) { r['webdavServers'] = [ for (final x in servers) x is Map ? (Map.of(x)..remove('password')) : x, ]; } final sftp = r['sftpServers']; if (sftp is List) { r['sftpServers'] = [ for (final x in sftp) x is Map ? (Map.of(x)..remove('password')..remove('privateKey')..remove('keyPassphrase')) : x, ]; } final smb = r['smbServers']; if (smb is List) { r['smbServers'] = [ for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x, ]; } final ftp = r['ftpServers']; if (ftp is List) { r['ftpServers'] = [ for (final x in ftp) x is Map ? (Map.of(x)..remove('password')) : x, ]; } final ai = r['aiServices']; if (ai is List) { r['aiServices'] = [ for (final x in ai) x is Map ? (Map.of(x)..remove('apiKey')) : x, ]; } return r; } static List _migrated(Map j, AppSettings s) => [ if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.display.orientation == 'auto') 'orientation', if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorer.layout == 'auto') 'explorerLayout', if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorer.click == 'open') 'explorerClick', if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation', if (j['aiImageV1'] != true && j['components'] is List && !(j['components'] as List).contains('aiimage')) 'aiImage', // 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다 if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies) 'cookieScope', if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.reader.zipComic) 'zipComic', if (j['backgroundV2'] != true && j['runInBackground'] == false && s.run.inBackground && Platform.isAndroid) 'background', // 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화. // 예전 판에는 키가 없어 위의 "예전 값 → 새 값" 조건에 걸리지 않았다 (P11: 탐색기에서 한 번 눌렀는데 바로 열려 놀람) if (j.isNotEmpty && !j.containsKey('explorerClick') && s.explorer.click == 'open') 'explorerClickNew', // E3 (10/22): 선택 규칙의 처음 값으로 옮긴 것 - 규칙 이전 판에서 올라온 사람에게 한 번 if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerAlwaysMarks'] != false) 'explorerMarksRule', if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerRetapCollapse'] != true) 'explorerRetapRule', if (j.isNotEmpty && j['explorerRulesV1'] != true && Platform.isAndroid && j['explorerClick'] != 'select') 'explorerClickRule', if (j.isNotEmpty && j['explorerRulesV1'] != true && (j['explorerLayout'] ?? 'auto') == 'auto') 'explorerLayoutRule', if (j.isNotEmpty && !j.containsKey('screenOrientation') && s.display.orientation == 'auto' && Platform.isAndroid) 'orientation', if (j.isNotEmpty && !j.containsKey('swipeNav') && s.display.swipeNav) 'swipeNav', if (j.isNotEmpty && !j.containsKey('runInBackground') && s.run.inBackground && Platform.isAndroid) 'background', // DNS 강제 (사용자 요청 10/10, 처음 값 직접 지정 8.8.8.8 · 8.8.4.4) 가 없던 판에서 올라오면 이름을 묻는 서버가 바뀐다 - 193 과 같이 한 번 알림 if (j.isNotEmpty && !j.containsKey('dnsMode') && s.network.mode == 'custom') 'dnsForced', // 193 · 229: 예전 판을 쓰던 사람 (설정 파일이 있는데 이 항목이 없음) 에게 복사 · 이동이 받는 쪽의 더 새 파일을 건너뛰게 바뀐 것을 한 번 if (j.isNotEmpty && !j.containsKey('copySkipNewer')) 'copySkipNewer', // 193: robocopy 를 쓰던 사람 (Windows) 에게 링크 · junction 을 따라가지 않게 (/XJ) 바뀐 것을 한 번 if (j['robocopyLinksV1'] != true && Platform.isWindows && _usedRobocopy(j)) 'robocopyLinks', ]; /// 193: 예전 설정에서 robocopy 를 쓰고 있었는지 (복사 방법 · 기억한 복사 · 실시간 동기화 쌍) static bool _usedRobocopy(Map j) { bool robo(Object? x) => x is Map && x['method'] == 'robocopy'; return j['copyMethodFile'] == 'robocopy' || j['copyMethodFolder'] == 'robocopy' || ((j['copyTasks'] as List?) ?? const []).any(robo) || ((j['liveSyncPairs'] as List?) ?? const []).any(robo); } /// 다운로드 기본 위치: 프로그램 폴더 (쓸 수 없으면 사용자 다운로드 폴더) String resolvedDownloadRoot() { if (download.root != null && download.root!.isNotEmpty) return download.root!; // Android: 프로그램 폴더가 없으므로 내장 저장소의 Download\JJ_MKVMaker if (Platform.isAndroid) return '/storage/emulated/0/Download/JJ_MKVMaker'; final appDir = p.dirname(Platform.resolvedExecutable); try { final probe = File(p.join(appDir, '.jj_write_test')); probe.writeAsStringSync('x'); probe.deleteSync(); return appDir; } catch (_) { final home = Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'] ?? appDir; return p.join(home, 'Downloads'); } } String get ytDlpDir => p.join(resolvedDownloadRoot(), 'jj_yt-dlp'); /// 앱 안 브라우저가 내보낸 YouTube · Google 쿠키 (cookies.txt). 없으면 빈 값 String get internalCookieFile { if (webViewDataDir.isEmpty) return ''; final f = p.join(webViewDataDir, 'cookies_youtube.txt'); return File(f).existsSync() ? f : ''; } /// 앱 안 브라우저의 쿠키가 있는 프로필 폴더 (로그인한 적이 없으면 빈 값) String get internalCookieProfile { if (webViewDataDir.isEmpty) return ''; final profile = p.join(webViewDataDir, 'EBWebView', 'Default'); final hasCookies = File(p.join(profile, 'Network', 'Cookies')).existsSync() || File(p.join(profile, 'Cookies')).existsSync(); return hasCookies ? profile : ''; } String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2'); }