AI 모델 받기: 이어 받기 · 화면이 꺼져도 계속 · 쉬운 실패 문구 (149) · 진행 · 차례 · 공간 표시 (121 받기 ①②③)

- 149: 받다 만 조각을 남겨 Range 로 받은 곳부터 이어 받고 (SHA-256 은 조각까지 합쳐 확인),
  끊기면 저절로 몇 번 다시 잇는다. 실패는 "연결이 끊겼습니다 · [이어 받기]" 한 줄 (서명 주소는 보이지 않음).
  Android 작업 알림에 받는 진행 (화면이 꺼지거나 다른 앱으로 가도 계속). 라이선스 동의는 한 번만.
- ① % · 받은 크기 / 전체 · 남은 시간, ② 한꺼번에 받을 때 "차례 기다림", ③ 필요한 공간 · 남은 공간을 늘 한 줄로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 14:58:22 +09:00
parent 7d6c653d5f
commit 92556f62ed
8 changed files with 354 additions and 42 deletions

View File

@ -64,6 +64,7 @@
"AI 그림 저장 폴더": "Folder for AI images",
"AI 그림: {0}": "AI images: {0}",
"AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)",
"AI 모델 받는 중: {0}": "Downloading AI model: {0}",
"AI 번역": "AI translation",
"AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services",
"AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}",
@ -559,6 +560,7 @@
"다운로딩 {0}{1}": "Downloading {0}{1}",
"다음": "Next",
"다음 (N)": "Next (N)",
"다음 {0}개 기다림": "{0} more waiting",
"다음 페이지 (Alt+→)": "Next page (Alt+→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)",
"단계": "Steps",
@ -766,6 +768,7 @@
"받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}",
"받음": "Downloaded",
"받음 {0}": "Received {0}",
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment",
"받지 못했습니다: {0}": "Could not download: {0}",
"밝게": "Brighter",
"밝기": "Brightness",
@ -1064,6 +1067,8 @@
"연결 실패: {0}": "Connection failed: {0}",
"연결 확인": "Test connection",
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "The connection was lost. Tap [Resume] to continue from where it stopped",
"연도": "Year",
"열 수 없습니다: {0}": "Cannot open: {0}",
"열기": "Open",
@ -1268,6 +1273,7 @@
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].",
"이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update",
"이어 받기": "Resume",
"이전": "Previous",
"이전 (P)": "Previous (P)",
"이전 설정을 되살렸습니다": "Previous settings restored",
@ -1510,6 +1516,7 @@
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.",
"차례 기다림": "Waiting in queue",
"창": "Panes",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)",
"창 배치": "Pane layout",
@ -1684,6 +1691,8 @@
"필수 프로그램 점검": "Check required programs",
"필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.",
"필요한 것 모두 받기": "Download everything needed",
"필요한 공간 {0}": "Space needed {0}",
"필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}",
"하위 폴더가 없습니다": "No subfolders",
"한 번 더": "Once more",
"한 번: 바로 실행": "Single: open right away",

View File

@ -64,6 +64,7 @@
"AI 그림 저장 폴더": "AI 画像の保存フォルダー",
"AI 그림: {0}": "AI 画像: {0}",
"AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)",
"AI 모델 받는 중: {0}": "AI モデルをダウンロード中: {0}",
"AI 번역": "AI 翻訳",
"AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加",
"AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}",
@ -559,6 +560,7 @@
"다운로딩 {0}{1}": "ダウンロード中 {0}{1}",
"다음": "次へ",
"다음 (N)": "次へ(N)",
"다음 {0}개 기다림": "次の {0} 件が待機中",
"다음 페이지 (Alt+→)": "次のページ(Alt+→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)",
"단계": "ステップ",
@ -766,6 +768,7 @@
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
"받음": "ダウンロード済み",
"받음 {0}": "受信 {0}",
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください",
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
"밝게": "明るく",
"밝기": "明るさ",
@ -1064,6 +1067,8 @@
"연결 실패: {0}": "接続失敗: {0}",
"연결 확인": "接続テスト",
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "接続が切れました。[続きをダウンロード] を押すと続きからダウンロードします",
"연도": "年",
"열 수 없습니다: {0}": "開けません: {0}",
"열기": "開く",
@ -1268,6 +1273,7 @@
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。",
"이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定",
"이어 받기": "続きをダウンロード",
"이전": "前へ",
"이전 (P)": "前へ(P)",
"이전 설정을 되살렸습니다": "以前の設定を復元しました",
@ -1510,6 +1516,7 @@
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
"차례 기다림": "順番待ち",
"창": "ペイン",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",
"창 배치": "ペインの配置",
@ -1684,6 +1691,8 @@
"필수 프로그램 점검": "必須プログラムの点検",
"필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。",
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
"필요한 공간 {0}": "必要な容量 {0}",
"필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}",
"하위 폴더가 없습니다": "サブフォルダーがありません",
"한 번 더": "もう一度",
"한 번: 바로 실행": "1 回: すぐに開く",

View File

@ -64,6 +64,7 @@
"AI 그림 저장 폴더": "AI 图片保存文件夹",
"AI 그림: {0}": "AI 图片: {0}",
"AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)",
"AI 모델 받는 중: {0}": "正在下载 AI 模型: {0}",
"AI 번역": "AI 翻译",
"AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务",
"AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}",
@ -559,6 +560,7 @@
"다운로딩 {0}{1}": "正在下载 {0}{1}",
"다음": "下一个",
"다음 (N)": "下一个(N)",
"다음 {0}개 기다림": "另有 {0} 个等待中",
"다음 페이지 (Alt+→)": "下一页(Alt+→)",
"다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)",
"단계": "步数",
@ -766,6 +768,7 @@
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
"받음": "已下载",
"받음 {0}": "已接收 {0}",
"받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]",
"받지 못했습니다: {0}": "无法下载: {0}",
"밝게": "调亮",
"밝기": "亮度",
@ -1064,6 +1067,8 @@
"연결 실패: {0}": "连接失败: {0}",
"연결 확인": "测试连接",
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "连接已中断。点按 [继续下载] 将从中断处继续",
"연도": "年份",
"열 수 없습니다: {0}": "无法打开: {0}",
"열기": "打开",
@ -1268,6 +1273,7 @@
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
"이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。",
"이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置",
"이어 받기": "继续下载",
"이전": "上一个",
"이전 (P)": "上一个(P)",
"이전 설정을 되살렸습니다": "已恢复之前的设置",
@ -1510,6 +1516,7 @@
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
"차례 기다림": "排队等待",
"창": "窗格",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",
"창 배치": "窗格布局",
@ -1684,6 +1691,8 @@
"필수 프로그램 점검": "检查必需程序",
"필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。",
"필요한 것 모두 받기": "下载全部所需文件",
"필요한 공간 {0}": "所需空间 {0}",
"필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}",
"하위 폴더가 없습니다": "没有子文件夹",
"한 번 더": "再输入一次",
"한 번: 바로 실행": "单击: 立即打开",

View File

@ -39,36 +39,96 @@ class AiStore extends ChangeNotifier {
bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound;
/// 받는 중인 것 (id → 0~1, 모르면 null)
/// 받는 중인 것 (id → 0~1, 모르면 null)
final progress = <String, double?>{};
/// 받은 바이트 · 전체 · 이번에 받기 시작한 때와 그때 이미 있던 바이트 (속도 · 남은 시간)
final received = <String, int>{};
final totals = <String, int>{};
final _started = <String, (DateTime, int)>{};
/// [installAll] 로 차례를 기다리는 것 (149-②: "차례 기다림")
final queued = <String>{};
/// 연결이 끊겨 다시 잇는 중 (몇 번째)
final retrying = <String, int>{};
/// 마지막 실패 (id → 쉬운 말). 다시 받으면 지운다
final failures = <String, String>{};
final _cancel = <String>{};
void cancel(String id) => _cancel.add(id);
void cancel(String id) {
_cancel.add(id);
queued.remove(id);
notifyListeners();
}
/// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다.
Future<void> install(String id) => installFile(aiFile(id));
/// [install] 의 본체 (시험은 카탈로그 밖의 파일로)
Future<void> installFile(AiFile f) async {
/// 여러 개를 차례로 (기다리는 것은 [queued] 로 보인다). 하나가 실패하면 멈추고 알린다
Future<void> installAll(List<AiFile> files) async {
final todo = [for (final f in files) if (!isFileInstalled(f) && !progress.containsKey(f.id)) f];
queued.addAll(todo.map((f) => f.id));
notifyListeners();
try {
for (final f in todo) {
if (!queued.contains(f.id)) continue; // 기다리는 동안 취소
queued.remove(f.id);
await installFile(f);
}
} finally {
queued.removeAll(todo.map((f) => f.id));
notifyListeners();
}
}
/// 받을 때 필요한 공간 (zip 은 푸는 자리까지) - 이미 받아 둔 조각만큼은 빼고
int neededSpace(AiFile f) {
final part = _partOf(f);
final have = part.existsSync() ? part.lengthSync() : 0;
return (f.kind == 'engine' ? f.size * 3 : f.size) - have;
}
File _partOf(AiFile f) => File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}');
/// [install] 의 본체 (시험은 카탈로그 밖의 파일로).
/// 149: 끊기면 받은 곳부터 이어 받고 (조각 파일을 남겨 둠), 몇 번 저절로 다시 잇는다. 실패는 쉬운 말로 (주소는 보이지 않음).
Future<void> installFile(AiFile f, {int retries = 5, Duration retryWait = const Duration(seconds: 3)}) async {
final id = f.id;
if (progress.containsKey(id)) return;
_cancel.remove(id);
failures.remove(id);
await Directory(root).create(recursive: true);
final free = await diskSpace(root);
// 받는 파일 + 푸는 자리 (zip 은 두 배쯤) 와 여유 200MB
final need = (f.kind == 'engine' ? f.size * 3 : f.size) + (200 << 20);
final need = neededSpace(f) + (200 << 20); // 여유 200MB
if (free != null && free.$1 < need) {
throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)]));
}
final part = _partOf(f);
progress[id] = 0;
notifyListeners();
final part = File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}');
try {
await part.parent.create(recursive: true);
await _download(f, part, (d) {
progress[id] = d;
for (var attempt = 0;; attempt++) {
try {
await _download(f, part);
break;
} on ImageAiException {
rethrow; // 취소 · 손상 (다시 받아도 같음)
} catch (e) {
if (_cancel.contains(id) || attempt >= retries) {
final msg = tr('연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다');
failures[id] = msg;
throw ImageAiException(msg);
}
retrying[id] = attempt + 1;
notifyListeners();
});
await Future<void>.delayed(retryWait * (attempt + 1));
}
}
retrying.remove(id);
if (f.kind == 'engine') {
final dir = Directory(pathOf(f));
if (await dir.exists()) await dir.delete(recursive: true);
@ -81,13 +141,12 @@ class AiStore extends ChangeNotifier {
}
await _mark(f).parent.create(recursive: true);
await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()}));
} catch (_) {
try {
if (await part.exists()) await part.delete();
} catch (_) {}
rethrow;
} finally {
progress.remove(id);
received.remove(id);
totals.remove(id);
_started.remove(id);
retrying.remove(id);
notifyListeners();
}
}
@ -100,33 +159,108 @@ class AiStore extends ChangeNotifier {
final t = FileSystemEntity.typeSync(pathOf(f));
if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true);
if (t == FileSystemEntityType.file) await File(pathOf(f)).delete();
try {
final part = _partOf(f);
if (part.existsSync()) part.deleteSync();
} catch (_) {}
notifyListeners();
}
static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB';
static String sizeText(int b) => _gb(b);
Future<void> _download(AiFile f, File out, void Function(double? d) onProgress) async {
/// 진행 한 줄: "58% · 2.3GB / 4.0GB · 남은 약 5분" (149-①)
String? progressText(String id) {
if (queued.contains(id)) return tr('차례 기다림');
if (!progress.containsKey(id)) return null;
final got = received[id] ?? 0, total = totals[id] ?? 0;
final retry = retrying[id];
final left = eta(id);
return [
if (total > 0) '${(got * 100 / total).floor()}%',
if (total > 0) '${_gb(got)} / ${_gb(total)}',
if (left != null) trf('남은 약 {0}', [AiJobs.durationText(left)]),
if (retry != null) trf('연결이 끊겨 다시 잇는 중 ({0}번째)', [retry]),
].join(' · ');
}
/// 남은 시간 (이번에 받은 속도로)
Duration? eta(String id) {
final s = _started[id];
final got = received[id], total = totals[id];
if (s == null || got == null || total == null || total <= 0) return null;
final secs = DateTime.now().difference(s.$1).inMilliseconds / 1000;
final rate = (got - s.$2) / (secs <= 0 ? 1 : secs);
if (secs < 3 || rate <= 0) return null;
return Duration(seconds: ((total - got) / rate).round());
}
/// 받는 중인 것을 한 줄로 (작업 알림 - 화면이 꺼져도 계속 받는 동안)
String? get statusLine {
if (progress.isEmpty) return null;
final id = progress.keys.first;
final name = aiCatalog.where((f) => f.id == id).firstOrNull?.name ?? id;
final more = queued.length;
return '${trf('AI 모델 받는 중: {0}', [tr(name)])} · ${progressText(id) ?? ''}'
'${more > 0 ? ' · ${trf('다음 {0}개 기다림', [more])}' : ''}';
}
/// 조각 파일 [out] 이 있으면 그 뒤부터 (Range) 받는다. 서버가 이어 주지 않으면 처음부터.
/// SHA-256 은 이미 받은 조각까지 합쳐 확인한다.
Future<void> _download(AiFile f, File out) async {
var have = out.existsSync() ? out.lengthSync() : 0;
if (have > f.size) {
out.deleteSync();
have = 0;
}
final req = await _http.getUrl(Uri.parse(f.url));
if (have > 0) req.headers.set(HttpHeaders.rangeHeader, 'bytes=$have-');
final res = await req.close();
if (res.statusCode != 200) throw ImageAiException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, f.url]));
final total = res.contentLength > 0 ? res.contentLength : f.size;
final sink = out.openWrite();
final whole = res.statusCode == 416 && have == f.size; // 이미 다 받아 둠 - 확인만
if (whole) {
await res.drain<void>();
} else if (res.statusCode == 200) {
have = 0; // 이어 주지 않음 - 처음부터
} else if (res.statusCode != 206) {
await res.drain<void>();
throw HttpException('status ${res.statusCode}'); // 다시 시도 대상 (주소는 사용자에게 보이지 않음)
}
final total = f.size;
final hash = AccumulatorSink<Digest>();
final hasher = sha256.startChunkedConversion(hash);
var done = 0;
if (have > 0) {
await for (final c in out.openRead(0, have)) {
hasher.add(c);
}
}
received[f.id] = have;
totals[f.id] = total;
_started[f.id] = (DateTime.now(), have);
notifyListeners();
final sink = out.openWrite(mode: have > 0 ? FileMode.append : FileMode.write);
var done = have;
var lastNotify = DateTime.now();
try {
if (!whole) {
await for (final chunk in res) {
if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다'));
sink.add(chunk);
hasher.add(chunk);
done += chunk.length;
onProgress(total > 0 ? done / total : null);
received[f.id] = done;
progress[f.id] = total > 0 ? done / total : null;
if (DateTime.now().difference(lastNotify).inMilliseconds > 250) {
lastNotify = DateTime.now();
notifyListeners();
}
}
}
} finally {
await sink.flush();
await sink.close();
hasher.close();
}
if (done < total) throw HttpException('short read $done/$total'); // 끊김 - 이어 받기
if (hash.events.single.toString() != f.sha256) {
await out.delete();
throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name]));
@ -408,6 +542,8 @@ class AiJobs extends ChangeNotifier {
return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left';
}
static String durationText(Duration d) => _dur(d);
static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]);
}

View File

@ -471,6 +471,9 @@ class AppSettings {
/// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에)
List<AiService> aiServices = [];
/// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음)
List<String> aiAgreed = [];
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
@ -640,6 +643,7 @@ class AppSettings {
'aiStrength': aiStrength,
'aiNegative': aiNegative,
'aiSaveDir': aiSaveDir,
'aiAgreed': aiAgreed,
'aiServices': [
for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey},
],
@ -818,6 +822,7 @@ class AppSettings {
..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1)
..aiNegative = j['aiNegative'] as String? ?? ''
..aiSaveDir = j['aiSaveDir'] as String? ?? ''
..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x']
..aiServices = [
for (final x in (j['aiServices'] as List?) ?? const [])
if (x is Map) AiService.fromJson(x),

View File

@ -32,6 +32,7 @@ class AndroidKeepAlive {
LiveSync? _live;
CopyCenter? _copies;
AiStore? _ai;
bool? _background;
/// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다
@ -64,14 +65,21 @@ class AndroidKeepAlive {
_copies = copies?..addListener(_changed);
}
final copying = copies?.activeCount ?? 0;
final ai = AiJobs.instance.statusLine;
// 149: AI 모델 받기도 화면을 끄거나 다른 앱으로 가도 계속 (진행 알림)
final store = AiStore.instance;
if (store != _ai) {
_ai?.removeListener(_changed);
_ai = store?..addListener(_changed);
}
final ai = [AiJobs.instance.statusLine, store?.statusLine].nonNulls.join(' · ').trim();
var (text, progress) =
status(c.currentJob, c.busy, running, downloads.overallProgress,
syncs: syncs, syncing: live?.anyRunning ?? false, copies: copying, stopped: live?.problems.length ?? 0);
if (ai != null) {
if (ai.isNotEmpty) {
// 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정)
text = text == null ? ai : '$ai · $text';
if (!c.busy && running == 0) progress = ((AiJobs.instance.progress ?? 0) * 100).round().clamp(0, 100);
final p = AiJobs.instance.progress ?? store?.progress.values.firstOrNull;
if (!c.busy && running == 0 && p != null) progress = (p * 100).round().clamp(0, 100);
}
try {
if (text == null) {

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../app/ai_local.dart';
import '../app/app_controller.dart';
import '../app/copy_center.dart' show diskSpace;
import '../core/ai_catalog.dart';
import '../core/secret_gate.dart';
import '../l10n/tr.dart';
@ -76,7 +77,8 @@ class _AiSettingsState extends State<AiSettings> {
/// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다
Future<bool> _agree(AiFile f) async {
if (f.terms == null) return true;
// 149-④: 한 번 동의했으면 다시 묻지 않는다
if (f.terms == null || c.settings.aiAgreed.contains(f.id)) return true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@ -96,19 +98,26 @@ class _AiSettingsState extends State<AiSettings> {
],
),
);
if (ok == true) await c.updateSettings((x) => x.aiAgreed = {...x.aiAgreed, f.id}.toList());
return ok == true;
}
/// 받기: 조건 동의를 먼저 모두 받은 뒤 차례로 (기다리는 것은 "차례 기다림")
Future<void> _install(List<AiFile> files) async {
final agreed = <AiFile>[];
for (final f in files) {
if (store.isInstalled(f.id) || !await _agree(f)) continue;
if (store.isFileInstalled(f)) continue;
if (await _agree(f)) agreed.add(f);
}
if (agreed.isEmpty) return;
try {
setState(() => _error = null);
await store.install(f.id);
} catch (e) {
if (mounted) setState(() => _error = '$e');
return;
}
await store.installAll(agreed);
} on ImageAiException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (_) {
// 쉬운 말로만 (원문 · 주소는 보이지 않게)
if (mounted) setState(() => _error = tr('받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요'));
}
await _refresh();
}
@ -132,6 +141,24 @@ class _AiSettingsState extends State<AiSettings> {
? null
: FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))),
),
// 149-③: 받기 전에 필요한 공간과 지금 남은 공간을 늘 한 줄로
if (missing.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: FutureBuilder<(int, int)?>(
future: diskSpace(store.root),
builder: (context, snap) {
final need = missing.fold<int>(0, (a, f) => a + store.neededSpace(f));
final free = snap.data?.$1;
return Text(
free == null
? trf('필요한 공간 {0}', [AiStore.sizeText(need)])
: trf('필요한 공간 {0} · 남은 공간 {1}', [AiStore.sizeText(need), AiStore.sizeText(free)]),
style: TextStyle(fontSize: 12, color: free != null && free < need ? Colors.redAccent : JjColors.textDim),
);
},
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@ -232,6 +259,9 @@ class _AiSettingsState extends State<AiSettings> {
final installed = store.isInstalled(f.id);
final prog = store.progress[f.id];
final busy = store.progress.containsKey(f.id);
final waiting = store.queued.contains(f.id);
// 받다가 끊겨 조각이 남아 있으면 [이어 받기]
final partial = !installed && !busy && store.neededSpace(f) < (f.kind == 'engine' ? f.size * 3 : f.size);
return Padding(
padding: const EdgeInsets.only(left: 16),
child: SettingTile(
@ -239,10 +269,21 @@ class _AiSettingsState extends State<AiSettings> {
leading: Icon(installed ? Icons.check_circle : Icons.download_outlined,
color: installed ? JjColors.success : JjColors.textDim, size: 20),
title: Text(tr(f.name)),
// 149-①②: 진행 막대 옆에 % · 받은 크기 / 전체 · 남은 시간, 기다리는 것은 "차례 기다림"
subtitle: busy
? LinearProgressIndicator(value: prog)
: Text('${AiStore.sizeText(f.size)} · ${f.license}${installed ? ' · ${tr('받음')}' : ''}'),
trailing: busy
? Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
LinearProgressIndicator(value: prog),
const SizedBox(height: 2),
Text(store.progressText(f.id) ?? '', style: const TextStyle(fontSize: 12)),
])
: Text([
AiStore.sizeText(f.size),
f.license,
if (installed) tr('받음'),
if (waiting) tr('차례 기다림'),
?store.failures[f.id],
].join(' · ')),
trailing: busy || waiting
? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소')))
: installed
? TextButton(
@ -255,7 +296,7 @@ class _AiSettingsState extends State<AiSettings> {
}
},
child: Text(tr('지우기')))
: OutlinedButton(onPressed: () => _install([f]), child: Text(tr('받기'))),
: OutlinedButton(onPressed: () => _install([f]), child: Text(partial ? tr('이어 받기') : tr('받기'))),
),
);
}

View File

@ -150,6 +150,101 @@ void main() {
expect(tmp.listSync(recursive: true).whereType<File>().where((f) => f.path.contains('.part')), isEmpty);
});
test('149: 끊기면 받은 곳부터 이어 받고 (Range), 서버가 이어 주지 않으면 처음부터 · 진행 글', () async {
final store = AiStore(tmp.path);
final body = List<int>.generate(300000, (i) => i * 7 % 251);
final f = file('big', '/big', body);
// 첫 요청은 120000 바이트만 보내고 연결을 끊는다, 그다음은 Range 로 나머지
final port = server.port;
await server.close(force: true);
final ranges = <String?>[];
server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
server.listen((req) async {
final range = req.headers.value(HttpHeaders.rangeHeader);
ranges.add(range);
if (range == null) {
final sock = await req.response.detachSocket(writeHeaders: false);
sock.add(utf8.encode('HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n'));
sock.add(body.sublist(0, 120000));
await sock.flush();
sock.destroy();
return;
}
final from = int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!);
req.response
..statusCode = 206
..headers.set(HttpHeaders.contentRangeHeader, 'bytes $from-${body.length - 1}/${body.length}')
..add(body.sublist(from));
await req.response.close();
});
final texts = <String>[];
void listen() {
final t = store.progressText('big');
if (t != null && t.isNotEmpty) texts.add(t);
}
store.addListener(listen);
await store.installFile(f, retryWait: const Duration(milliseconds: 10));
store.removeListener(listen);
expect(store.isFileInstalled(f), isTrue);
expect(File(store.pathOf(f)).readAsBytesSync(), body);
expect(ranges.first, isNull);
expect(ranges.last, 'bytes=120000-', reason: '받은 곳부터');
expect(texts.any((t) => t.contains('%') && t.contains(' / ')), isTrue);
});
test('149: 여러 번 끊기면 쉬운 말 (주소 없음) 로 알리고 조각은 남겨 [이어 받기] 로 이어 받음', () async {
final store = AiStore(tmp.path);
final body = List<int>.generate(50000, (i) => i % 256);
final f = file('drop', '/drop', body);
var calls = 0;
final port = server.port;
await server.close(force: true);
server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
var allow = false;
server.listen((req) async {
calls++;
final range = req.headers.value(HttpHeaders.rangeHeader);
final from = range == null ? 0 : int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!);
if (!allow) {
final sock = await req.response.detachSocket(writeHeaders: false);
final n = (from + 5000).clamp(0, body.length);
sock.add(utf8.encode('HTTP/1.1 ${range == null ? '200 OK' : '206 Partial Content'}\r\nContent-Length: ${body.length - from}\r\n\r\n'));
sock.add(body.sublist(from, n));
await sock.flush();
sock.destroy();
return;
}
req.response
..statusCode = range == null ? 200 : 206
..add(body.sublist(from));
await req.response.close();
});
await expectLater(store.installFile(f, retries: 2, retryWait: const Duration(milliseconds: 10)),
throwsA(isA<ImageAiException>().having((e) => e.message, 'message', allOf(contains('이어 받기'), isNot(contains('http'))))));
expect(calls, 3);
expect(store.failures['drop'], contains('연결이 끊겼습니다'));
expect(store.neededSpace(f), lessThan(body.length), reason: '받은 조각을 남김');
allow = true;
await store.installFile(f, retryWait: const Duration(milliseconds: 10));
expect(File(store.pathOf(f)).readAsBytesSync(), body);
expect(store.failures, isEmpty);
});
test('149: 한꺼번에 받기 - 기다리는 것은 "차례 기다림"', () async {
final store = AiStore(tmp.path);
final a = file('qa', '/qa', List.filled(1000, 1)), b2 = file('qb', '/qb', List.filled(1000, 2));
final seen = <String>{};
void listen() {
if (store.progress.containsKey('qa') && store.queued.contains('qb')) seen.add(store.progressText('qb')!);
}
store.addListener(listen);
await store.installAll([a, b2]);
store.removeListener(listen);
expect(seen, contains('차례 기다림'));
expect(store.isFileInstalled(a) && store.isFileInstalled(b2), isTrue);
expect(store.queued, isEmpty);
});
test('실행 파일 zip 은 풀어 둔다', () async {
final store = AiStore(tmp.path);
final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake')));