import 'dart:io'; import 'package:path/path.dart' as p; import 'components.dart'; import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; import '../core/ftp.dart'; import '../core/sftp.dart'; import '../core/smb.dart'; import '../core/webdav.dart'; import '../services/app_shell.dart' show appIconOf; import '../core/remote_idle.dart'; import 'settings/records.dart'; import 'settings/settings_group.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/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 { /// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv String? mkvOutputRoot; /// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로. /// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다. List moveTargets = []; /// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝) List folderBookmarks = []; /// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작) bool askAiOptions = true; /// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남) String openSubtitlesKey = ''; String openSubtitlesUser = ''; String openSubtitlesPassword = ''; /// 동시에 만드는 MKV 수 (0 = 무제한) int maxParallelJobs = 5; /// 메인 화면 아래 작업 기록 창 보이기 bool showLog = true; /// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17) String mkvCompactList = 'auto'; /// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([browser.homeUrl]), 'files' = 파일 탐색기 String startScreen = 'home'; /// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈), /// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감) double uiScale = 1.0; double uiScaleDefault = 1.0; // 18: 50 ~ 250% (사용자 결정) static const double uiScaleMin = 0.5, uiScaleMax = 2.5; /// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라. /// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게 String uiBrightness = 'dark'; static const uiBrightnessModes = ['dark', 'light', 'system']; static double clampUiScale(num? v) => ((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20; /// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가 String openFileAction = 'play'; /// Android 화면 방향: 'auto' 기기 방향 따라 (기본) / 'landscape' 가로 고정 / 'portrait' 세로 고정. /// 값이 없거나 틀리면 자동 (제한 없는 쪽). 고정은 사용자가 환경 설정에서 고를 때만. String screenOrientation = 'auto'; /// 화면 언어 (ko · en · ja · zh-Hans, 또는 더한 언어) String uiLanguage = 'system'; /// 앱 아이콘 (appIconIds 중 하나: 'yellow' 기본 · 'black' · 'film_jj' · 'film' · 'blue') String appIcon = 'yellow'; /// 더한 화면 언어 (AI 로 자동 번역한 사전이 설정 폴더 l10n 에 있음) List uiLanguagesAdded = []; /// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창 String openFileWindow = 'same'; // ── 네트워크 · DNS (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로", 기본 8.8.8.8 · 보조 8.8.4.4 - 관리자 판단) ── // ── Tor 브라우저 (사용자 요청 10/10) ── // ── 파일 탐색기 (X-plore 참고) ── // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) String mkvExisting = 'ask'; /// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두 List components = AppComponent.defaultInstalled; /// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로 List navOrder = AppComponent.defaultOrder; /// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로 bool swipeNav = true; /// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다) List imageExts = [...defaultImageExtensions]; // ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ── /// 66: 좌우로 밀 때 끝에서 처음으로 (끄면 끝에서 멈춤) bool swipeWrap = true; /// 저장 공간 정리 창에서 미리 체크할 묶음 (작업 임시 파일 · 받다 만 다운로드) List cleanupPrechecked = ['work', 'download']; /// 19: 업데이트로 바뀐 기본값을 켤 때 한 번 알림 bool migrationNotice = true; /// 34: Rsync · 동기화에서 원본이 대상 안 (안쪽 → 바깥) 을 허용 (지우기가 있을 때만 막음). 끄면 늘 막음 bool allowInnerToOuter = true; /// 40 · 54: 비밀번호를 이 기기의 안전 저장소에 기억 (끄면 저장하지 않고 앱을 켤 때마다 다시 넣음) bool rememberPasswords = true; /// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다) bool recycleOnDelete = true; /// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함) String tripleTapAction = 'move'; /// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …) String preferredSubtitleLanguage = ''; /// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로) String davExternalOpen = 'ask'; /// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로) bool allFilesHintShown = false; /// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1) double subtitleScale = 1.0; /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. List webdavServers = []; /// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) List sftpServers = []; /// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다 List smbServers = []; /// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서). /// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠ bool smbRequireSecure = false; /// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) List ftpServers = []; /// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초). /// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60 int remoteIdleSeconds = RemoteIdle.defaultSeconds; // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── // 121: 해상도 올리기 /// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음 String webViewDataDir = ''; /// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈) PlaylistMode playlistMode = PlaylistMode.series; /// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로 Map externalPlayers = {}; /// 마지막 인코딩 설정 EncodeSettings encode = const EncodeSettings(); /// 마지막 AI 자막 설정 String aiSource = 'und'; List aiTargets = ['ko', 'en', 'ja']; String aiWhisper = 'whisper-base'; 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(); /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { final j = { // 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40) ...stripSecrets(extra), 'mkvOutputRoot': mkvOutputRoot, 'moveTargets': [for (final t in moveTargets) t.toJson()], 'folderBookmarks': [for (final b in folderBookmarks) b.toJson()], 'askAiOptions': askAiOptions, // 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만 'openSubtitlesSaved': { 'key': openSubtitlesKey.isNotEmpty, 'user': openSubtitlesUser.isNotEmpty, 'password': openSubtitlesPassword.isNotEmpty, }, if (plainSecrets) ...{ 'openSubtitlesKey': openSubtitlesKey, 'openSubtitlesUser': openSubtitlesUser, 'openSubtitlesPassword': openSubtitlesPassword, }, 'cookieScopeV2': true, 'maxParallelJobs': maxParallelJobs, 'showLog': showLog, 'mkvCompactList': mkvCompactList, 'startScreen': startScreen, 'uiScale': uiScale, 'uiScaleDefault': uiScaleDefault, 'uiBrightness': uiBrightness, 'openFileAction': openFileAction, 'screenOrientation': screenOrientation, // 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정) 'orientationV2': true, 'uiLanguage': uiLanguage, 'appIcon': appIcon, 'uiLanguagesAdded': uiLanguagesAdded, 'openFileWindow': openFileWindow, 'mkvExisting': mkvExisting, 'components': components, 'aiImageV1': true, 'robocopyLinksV1': true, 'navOrder': navOrder, 'swipeNav': swipeNav, 'imageExts': imageExts, 'swipeWrap': swipeWrap, 'cleanupPrechecked': cleanupPrechecked, 'migrationNotice': migrationNotice, 'allowInnerToOuter': allowInnerToOuter, 'rememberPasswords': rememberPasswords, 'recycleOnDelete': recycleOnDelete, 'tripleTapAction': tripleTapAction, 'preferredSubtitleLanguage': preferredSubtitleLanguage, 'davExternalOpen': davExternalOpen, // 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더 'allFilesHint2': allFilesHintShown, 'subtitleScale': subtitleScale, 'webdavServers': [ for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, ], // 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음 'sftpServers': [for (final x in sftpServers) x.toJson()], 'smbServers': [for (final x in smbServers) x.toJson()], 'smbRequireSecure': smbRequireSecure, 'remoteIdleSeconds': remoteIdleSeconds, 'ftpServers': [for (final x in ftpServers) x.toJson()], 'playlistMode': playlistMode.name, 'externalPlayers': externalPlayers, 'encode': { 'codec': encode.codec.name, 'resolution': encode.resolution.name, 'quality': encode.quality.name, 'frame': encode.frame.name, 'fit': encode.fit.name, 'rotate': encode.rotate.name, 'brightness': encode.brightness, 'contrast': encode.contrast, 'saturation': encode.saturation, 'temperature': encode.temperature, }, 'aiSource': aiSource, 'aiTargets': aiTargets, 'aiWhisper': aiWhisper, }; for (final g in groups) { g.write(j, plainSecrets: plainSecrets); } return j; } /// 색 보정 값 (-100 ~ 100) static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); factory AppSettings.fromJson(Map j) { T pick(List values, Object? name, T fallback) => values.firstWhere((v) => v.name == name, orElse: () => fallback); final e = (j['encode'] as Map?) ?? const {}; final s = AppSettings() ..mkvOutputRoot = j['mkvOutputRoot'] as String? ..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)] ..moveTargets = [ for (final x in (j['moveTargets'] as List?) ?? const []) if (x is Map && x['dir'] is String) MoveTarget.fromJson(x), // 예전 판의 이동 폴더 하나 → "이동" 버튼으로 if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String), ] ..askAiOptions = j['askAiOptions'] as bool? ?? true ..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? '' ..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? '' ..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? '' ..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5 ..showLog = j['showLog'] as bool? ?? true ..mkvCompactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto' ..startScreen = j['startScreen'] as String? ?? 'home' ..uiScale = clampUiScale(j['uiScale'] as num?) ..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?) ..uiBrightness = uiBrightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark' ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play' ..uiLanguage = j['uiLanguage'] as String? ?? 'ko' ..appIcon = appIconOf(j['appIcon'] as String?) ..uiLanguagesAdded = [for (final x in (j['uiLanguagesAdded'] as List?) ?? const []) '$x'] ..screenOrientation = j['orientationV2'] == true && const ['landscape', 'portrait', 'auto'].contains(j['screenOrientation']) ? j['screenOrientation'] as String : 'auto' ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' ..components = j['components'] is List ? [ // 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙). // 화면 · 버튼은 아는 것만 쓴다 (AppComponent.pages · contains) for (final x in j['components'] as List) if (x is String && x.isNotEmpty) x, // 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트) if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage', ] : AppComponent.defaultInstalled ..navOrder = j['navOrder'] is List // 225: 모르는 화면 id 도 순서에 그대로 (그 판으로 돌아가면 사용자가 정한 순서대로) ? [for (final x in j['navOrder'] as List) if (x is String && x.isNotEmpty) x] : AppComponent.defaultOrder ..swipeNav = j['swipeNav'] as bool? ?? true ..imageExts = j['imageExts'] is List ? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()] : [...defaultImageExtensions] ..swipeWrap = j['swipeWrap'] != false ..cleanupPrechecked = j['cleanupPrechecked'] is List ? [for (final x in j['cleanupPrechecked'] as List) '$x'] : ['work', 'download'] ..migrationNotice = j['migrationNotice'] != false ..allowInnerToOuter = j['allowInnerToOuter'] != false ..rememberPasswords = j['rememberPasswords'] != false ..recycleOnDelete = j['recycleOnDelete'] != false ..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move' ..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : '' ..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask' ..allFilesHintShown = j['allFilesHint2'] == true ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..webdavServers = [ for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), ] ..sftpServers = [ for (final x in (j['sftpServers'] as List?) ?? const []) if (x is Map) SftpServer.fromJson(x), ] ..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false ..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds ..smbServers = [ for (final x in (j['smbServers'] as List?) ?? const []) if (x is Map) SmbServer.fromJson(x), ] ..ftpServers = [ for (final x in (j['ftpServers'] as List?) ?? const []) if (x is Map) FtpServer.fromJson(x), ] ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series) ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast() ..encode = EncodeSettings( codec: pick(VideoCodecChoice.values, e['codec'], VideoCodecChoice.copy), resolution: pick(ResolutionChoice.values, e['resolution'], ResolutionChoice.original), quality: pick(QualityChoice.values, e['quality'], QualityChoice.normal), frame: pick(FrameChoice.values, e['frame'], FrameChoice.original), fit: pick(FitChoice.values, e['fit'], FitChoice.fill), rotate: pick(RotateChoice.values, e['rotate'], RotateChoice.none), brightness: _adj(e['brightness']), contrast: _adj(e['contrast']), saturation: _adj(e['saturation']), temperature: _adj(e['temperature']), ) ..aiSource = j['aiSource'] as String? ?? 'und' ..aiTargets = (j['aiTargets'] as List?)?.cast() ?? ['ko', 'en', 'ja'] ..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base'; // 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_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.screenOrientation == '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.screenOrientation == 'auto' && Platform.isAndroid) 'orientation', if (j.isNotEmpty && !j.containsKey('swipeNav') && s.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'); }