250 lines
12 KiB
Dart
250 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/file_ops.dart';
|
|
import '../../core/ftp.dart' show FtpRegistry;
|
|
import '../../core/secret_gate.dart';
|
|
import '../../core/sftp.dart' show SftpRegistry;
|
|
import '../../core/shizuku.dart';
|
|
import '../../core/smb.dart' show SmbRegistry;
|
|
import '../../core/vfs.dart';
|
|
import '../../core/webdav.dart' show DavRegistry;
|
|
import '../../l10n/tr.dart';
|
|
import '../../platform/android/android_storage.dart';
|
|
import '../file_error.dart';
|
|
import '../ftp_settings.dart';
|
|
import '../notice_band.dart';
|
|
import '../sftp_settings.dart';
|
|
import '../theme.dart';
|
|
import 'access_flow.dart';
|
|
import 'explorer_controller.dart';
|
|
import 'explorer_pane.dart';
|
|
import 'server_flows.dart';
|
|
|
|
/// 약속 (E5d-6): 창 목록 위의 안내 상자들 ([of] - 없는 것은 null). 시스템 폴더 · 루트를 보는 동안 (읽기 전용) · 권한이 없을 때
|
|
/// (허용 화면 · 다시 확인) · 읽지 못할 때 (까닭 · [다시 시도] · [서버 설정 고치기] · 처음 보는 서버 키 · 인증서 지문) ·
|
|
/// 만들다 만 파일 (지우기). 안내의 단추가 하는 일은 [ExplorerAccess] · [ExplorerServers] · [ExplorerController]
|
|
class ExplorerNotices {
|
|
ExplorerNotices(this.ctl, this.access, this.servers);
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerAccess access;
|
|
final ExplorerServers servers;
|
|
|
|
List<Widget?> of(BuildContext context, ExplorerPane pane, String dir) =>
|
|
[_shizuku(pane), _access(pane), _error(context, pane, dir), _partial(context, pane, dir)];
|
|
|
|
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
|
|
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
|
|
Widget? _shizuku(ExplorerPane pane) {
|
|
if (!isShizuku(pane.current)) return null;
|
|
final root = isRootPath(pane.current);
|
|
return _notice(
|
|
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
|
|
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
|
|
body: root
|
|
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
|
|
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
|
|
actions: const [],
|
|
color: JjColors.warning,
|
|
);
|
|
}
|
|
|
|
Widget? _access(ExplorerPane pane) {
|
|
if (!Platform.isAndroid || access.allFiles != false || isDav(pane.root)) return null;
|
|
return _notice(
|
|
icon: Icons.lock_outline,
|
|
title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'),
|
|
body: access.dualApp
|
|
? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
|
|
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.')
|
|
: tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'),
|
|
actions: [
|
|
FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))),
|
|
TextButton(onPressed: () => access.checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 목록 위 안내 상자 (공용 NoticeBand - W)
|
|
Widget _notice({required IconData icon, required String title, required String body, required List<Widget> actions, Color? color}) =>
|
|
NoticeBand(icon: icon, title: title, body: body, actions: actions, color: color);
|
|
|
|
// ───────── 만들다 만 파일 (71) ─────────
|
|
|
|
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
|
|
List<FileEntry> _partials(ExplorerPane pane, String dir) {
|
|
final old = DateTime.now().subtract(const Duration(minutes: 2));
|
|
return [
|
|
for (final e in pane.cache[dir] ?? const <FileEntry>[])
|
|
if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e,
|
|
];
|
|
}
|
|
|
|
Widget? _partial(BuildContext context, ExplorerPane pane, String dir) {
|
|
final list = _partials(pane, dir);
|
|
if (list.isEmpty) return null;
|
|
return _notice(
|
|
icon: Icons.broken_image_outlined,
|
|
color: JjColors.warning,
|
|
title: trf('만들다 만 파일 {0}개', [list.length]),
|
|
body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. '
|
|
'같은 파일을 다시 복사하면 새로 만듭니다.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])),
|
|
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)),
|
|
if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
final failed = <String>[];
|
|
for (final e in list) {
|
|
try {
|
|
await vDelete(e.path);
|
|
} catch (_) {
|
|
failed.add(e.name);
|
|
}
|
|
}
|
|
// 같은 폴더를 보는 다른 창도 새로
|
|
for (final q in ctl.panes) {
|
|
if (q == pane || q.cache.containsKey(dir)) await ctl.load(q, dir, force: true);
|
|
}
|
|
if (failed.isNotEmpty) ctl.host.snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
|
|
},
|
|
child: Text(tr('지우기')),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ───────── 읽을 수 없을 때 (12) ─────────
|
|
|
|
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
|
|
Widget? _error(BuildContext context, ExplorerPane pane, String dir) {
|
|
final err = pane.errors[dir] ?? pane.errors[pane.root];
|
|
if (err == null) return null;
|
|
final where = pane.errors[dir] != null ? dir : pane.root;
|
|
final dav = isDav(where);
|
|
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
|
|
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
|
|
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
|
|
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
|
|
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
|
|
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
|
|
if (ftp != null && cert != null) {
|
|
return _notice(
|
|
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
|
|
title: '${ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
|
|
body: cert.message,
|
|
actions: [
|
|
if (!cert.changed)
|
|
FilledButton.tonal(
|
|
onPressed: () async {
|
|
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
|
|
await trustFtpCert(ctl.c, ftp.id, cert);
|
|
pane.certErrors.remove(where);
|
|
pane.errors.remove(where);
|
|
pane.changed();
|
|
await ctl.load(pane, where, force: true);
|
|
},
|
|
child: Text(tr('지문 확인하고 연결')),
|
|
),
|
|
TextButton(onPressed: () => servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
|
],
|
|
);
|
|
}
|
|
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
|
|
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
|
|
if (sftp != null && hostKey != null) {
|
|
return _notice(
|
|
icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
|
|
title: '${ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}',
|
|
body: hostKey.message,
|
|
actions: [
|
|
if (!hostKey.changed)
|
|
FilledButton.tonal(
|
|
onPressed: () async {
|
|
if (!await askTrustHostKey(context, sftp.label, hostKey)) return;
|
|
await trustSftpHostKey(ctl.c, sftp.id, hostKey);
|
|
pane.hostKeyErrors.remove(where);
|
|
pane.errors.remove(where);
|
|
pane.changed();
|
|
await ctl.load(pane, where, force: true);
|
|
},
|
|
child: Text(tr('지문 확인하고 연결')),
|
|
),
|
|
TextButton(onPressed: () => servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
|
],
|
|
);
|
|
}
|
|
final (title, body) = explainFileError(err,
|
|
dav: dav,
|
|
noPassword: (server != null && server.password.isEmpty) ||
|
|
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
|
|
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
|
|
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
|
|
final locked = isLockedError(err);
|
|
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
|
|
if (err == androidRestrictedError) {
|
|
return _notice(
|
|
icon: Icons.lock_outline,
|
|
title: '${ctl.displayPath(where)}: $title',
|
|
body: body,
|
|
actions: [
|
|
FilledButton.tonal(
|
|
onPressed: () {
|
|
pane.errors.remove(where);
|
|
ctl.goTo(pane, vDirname(where));
|
|
},
|
|
child: Text(tr('위 폴더로')),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return _notice(
|
|
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
|
|
title: '${ctl.displayPath(where)}: $title',
|
|
body: body,
|
|
actions: [
|
|
FilledButton.tonal(
|
|
onPressed: () async {
|
|
// 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다
|
|
if (locked && !await SecretGate.pass(force: true)) return;
|
|
pane.errors.remove(where);
|
|
pane.changed();
|
|
await ctl.load(pane, where, force: true);
|
|
for (final d in pane.expanded.toList()) {
|
|
if (isSameOrInside(d, where) && d != where) await ctl.load(pane, d, force: true);
|
|
}
|
|
},
|
|
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
|
|
),
|
|
// 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다
|
|
if (dav && !locked && !isShizuku(where))
|
|
TextButton(
|
|
onPressed: () => sftp != null
|
|
? servers.editSftp(context, pane, pane.root)
|
|
: smb != null
|
|
? servers.editSmb(context, pane, pane.root)
|
|
: ftp != null
|
|
? servers.editFtp(context, pane, pane.root)
|
|
: servers.editDav(context, pane, pane.root),
|
|
child: Text(tr('서버 설정 고치기'))),
|
|
],
|
|
);
|
|
}
|
|
}
|