import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../app/app_controller.dart'; import '../app/master_lock.dart'; import '../core/file_ops.dart' show formatSize; import '../core/playlist.dart' show isAudioFile, isVideoFile; import '../core/reader_sources.dart'; import '../core/secret_gate.dart'; import '../core/vault_crypto.dart'; import '../core/vault_store.dart'; import '../core/vault_view.dart'; import '../core/vfs.dart' show vDisplay; import '../l10n/tr.dart'; import 'player_page.dart'; import 'reader_page.dart' show openReader; import 'theme.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) Future openVault(BuildContext context, AppController c, {List add = const [], Future Function(List paths, {required bool permanent})? deleteOriginals}) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals))); /// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈 Future Function() vaultDirOf = () async { if (Platform.isAndroid) { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot') ?? '/storage/emulated/0'; return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault')); } return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault')); }; /// 꺼낼 폴더 고르기 (시험에서 바꿈) Future Function() vaultPickFolder = () => FilePicker.getDirectoryPath(); /// 시험: PBKDF2 반복 수 (새 보관함 · 비밀번호 바꾸기) int vaultIterationsForTest = VaultCrypto.defaultIterations; class VaultPage extends StatefulWidget { final AppController c; /// 열면서 넣을 파일 (탐색기 [보관함에 넣기]) final List add; /// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통) final Future Function(List paths, {required bool permanent})? deleteOriginals; const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals}); @override State createState() => _VaultPageState(); } enum _Stage { checking, create, locked, opening, open } class _VaultPageState extends State with WidgetsBindingObserver { _Stage _stage = _Stage.checking; Directory? _dir; Vault? _vault; String? _error; int _fails = 0; DateTime? _waitUntil, _hiddenAt; final _pw = TextEditingController(), _pw2 = TextEditingController(); bool _understood = false; bool _busy = false; late List _pendingAdd = [...widget.add]; String _sort = 'added'; AppController get c => widget.c; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); unawaited(_check()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _lock(closeViews: false); _pw.dispose(); _pw2.dispose(); super.dispose(); } /// 앱이 뒤로 간 뒤 정한 시간이 지나 돌아오면 다시 잠금 @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { _hiddenAt ??= DateTime.now(); } else if (state == AppLifecycleState.resumed) { final at = _hiddenAt; _hiddenAt = null; if (at != null && _vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) { setState(_lock); } } } /// 그림은 메모리에서 바로 (파일로 풀지 않음) static const _imageMax = 256 * 1024 * 1024; /// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고) ModalRoute? _route; /// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21) void _lock({bool closeViews = true}) { _vault = null; if (_stage == _Stage.open) _stage = _Stage.locked; final r = _route; if (closeViews && r != null && mounted && !r.isCurrent) Navigator.of(context).popUntil((x) => x == r); unawaited(_clearViewDir()); } /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) Directory? _session; Future _viewDir() async { final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); await d.create(recursive: true); return d; } Future _clearViewDir() async { final d = _session; _session = null; if (d == null) return; // Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 - 몇 번 더 (남으면 다음에 켤 때 지움) for (var i = 0; i < 10; i++) { try { if (await d.exists()) await d.delete(recursive: true); return; } catch (_) { await Future.delayed(const Duration(milliseconds: 200)); } } } Future _check() async { try { final d = await vaultDirOf(); final has = await Vault.exists(d); if (mounted) { setState(() { _dir = d; _stage = has ? _Stage.locked : _Stage.create; }); } } catch (e) { if (mounted) setState(() => _error = trf('보관함 폴더를 열 수 없습니다: {0}', [e])); } } void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t))); Future _create() async { if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return; setState(() { _busy = true; _stage = _Stage.opening; }); try { final v = await Vault.create(_dir!, _pw.text, iterations: vaultIterationsForTest); _pw.clear(); _pw2.clear(); if (!mounted) return; setState(() { _vault = v; _stage = _Stage.open; }); await _addPending(); } catch (e) { if (mounted) { setState(() { _stage = _Stage.create; _error = trf('보관함을 만들지 못했습니다: {0}', [e]); }); } } finally { if (mounted) setState(() => _busy = false); } } Future _unlock() async { final wait = _waitUntil; if (wait != null && DateTime.now().isBefore(wait)) return; setState(() { _stage = _Stage.opening; _error = null; }); try { final v = await Vault.unlock(_dir!, _pw.text); _pw.clear(); _fails = 0; if (!mounted) return; setState(() { _vault = v; _stage = _Stage.open; }); await _addPending(); } on VaultAuthException { _fails++; if (_fails >= 5) { _waitUntil = DateTime.now().add(const Duration(seconds: 30)); _fails = 0; Timer(const Duration(seconds: 30), () => mounted ? setState(() => _waitUntil = null) : null); } if (mounted) { setState(() { _stage = _Stage.locked; _error = _waitUntil != null ? tr('비밀번호가 여러 번 틀려 30초 뒤에 다시 넣을 수 있습니다') : tr('비밀번호가 틀렸습니다'); }); } } on VaultFormatException { if (mounted) { setState(() { _stage = _Stage.locked; _error = tr('이 판이 모르는 보관함 형식입니다 - 앱을 새 판으로 올려 주세요 (보관함은 그대로 둡니다)'); }); } } catch (e) { if (mounted) { setState(() { _stage = _Stage.locked; _error = trf('보관함을 열지 못했습니다: {0}', [e]); }); } } } /// [마스터로도 열기]: 마스터 비밀번호 확인 뒤 안전 저장소의 열쇠로 Future _unlockWithMaster() async { final lock = MasterLock.instance; if (lock == null || !await SecretGate.pass(force: true)) return; final key = (await lock.secrets.readAll())[MasterLock.vaultCopyKey]; if (key == null) { _snack(tr('마스터로 여는 열쇠가 없습니다 (마스터를 초기화했으면 없어집니다) - 보관함 비밀번호로 여세요')); return; } setState(() => _stage = _Stage.opening); try { final v = await Vault.unlockWithKey(_dir!, base64.decode(key)); if (!mounted) return; setState(() { _vault = v; _stage = _Stage.open; }); await _addPending(); } catch (e) { if (mounted) { setState(() { _stage = _Stage.locked; _error = trf('보관함을 열지 못했습니다: {0}', [e]); }); } } } /// 탐색기에서 고른 것 넣기 - [원본 지우기 (이동)] 체크 (처음 끔) Future _addPending() async { final paths = _pendingAdd; if (paths.isEmpty || _vault == null) return; _pendingAdd = []; var move = false; // 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게) var permanent = false; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(trf('보관함에 넣기 · {0}개', [paths.length])), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final x in paths.take(8)) Text('· ${p.basename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis), if (paths.length > 8) Text(trf('… 외 {0}개', [paths.length - 8])), const SizedBox(height: 8), CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, value: move, onChanged: widget.deleteOriginals == null ? null : (v) => set(() => move = v ?? false), title: Text(tr('원본 지우기 (이동)')), subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')), ), if (move) ...[ Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), RadioGroup( groupValue: permanent, onChanged: (v) => set(() => permanent = v ?? false), child: Column(mainAxisSize: MainAxisSize.min, children: [ RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))), RadioListTile( dense: true, contentPadding: EdgeInsets.zero, value: true, title: Text(tr('영구 삭제')), subtitle: Text(tr('휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다'))), ]), ), ], ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('넣기'))), ], ), ), ); if (go != true || !mounted) return; setState(() => _busy = true); final done = []; try { for (final x in paths) { await _vault!.add(x); done.add(x); } } catch (e) { _snack(trf('넣지 못했습니다: {0}', [e])); } finally { if (mounted) setState(() => _busy = false); } if (done.isNotEmpty) _snack(trf('{0}개를 보관함에 넣었습니다', [done.length])); if (move && done.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(done, permanent: permanent); } Future _view(VaultEntry e) async { final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); // 그림: 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21) if (c.settings.imageExts.contains(ext) && e.size <= _imageMax) { try { setState(() => _busy = true); final bytes = await _vault!.readBytes(e, maxBytes: _imageMax); if (mounted) setState(() => _busy = false); if (!mounted) return; await openReader(context, c, _VaultImageSource(e.name, bytes)); } catch (err) { if (mounted) setState(() => _busy = false); _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('열 수 없습니다: {0}', [err])); } return; } if (!isVideoFile(e.name) && !isAudioFile(e.name)) { _snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); return; } try { // 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다 final d = await _viewDir(); final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); setState(() => _busy = true); await _vault!.export(e, out); if (mounted) setState(() => _busy = false); if (!mounted) return; { final create = c.services.createMediaPlayer; if (create != null) { await Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); } } // 본 뒤 풀어 둔 것은 지운다 await File(out).delete().catchError((_) => File(out)); } catch (err) { if (mounted) setState(() => _busy = false); _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('열 수 없습니다: {0}', [err])); } } Future _export(VaultEntry e) async { final dir = await vaultPickFolder(); if (dir == null || !mounted) return; var out = p.join(dir, e.name); for (var i = 2; await File(out).exists(); i++) { out = p.join(dir, '${p.basenameWithoutExtension(e.name)} ($i)${p.extension(e.name)}'); } setState(() => _busy = true); try { await _vault!.export(e, out); _snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); } catch (err) { _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('꺼내지 못했습니다: {0}', [err])); } finally { if (mounted) setState(() => _busy = false); } } Future _remove(VaultEntry e) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('보관함에서 지우기')), content: Text(trf('"{0}" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', [e.name])), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), ], ), ); if (ok != true) return; await _vault!.remove(e); if (mounted) setState(() {}); } Future _rename(VaultEntry e) async { final ctl = TextEditingController(text: e.name); final name = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('이름 변경')), content: TextField(controller: ctl, autofocus: true), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, ctl.text.trim()), child: Text(tr('확인'))), ], ), ); ctl.dispose(); if (name == null || name.isEmpty || name == e.name) return; await _vault!.rename(e, name); if (mounted) setState(() {}); } Future _changePassword() async { final a = TextEditingController(), b = TextEditingController(); final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( title: Text(tr('보관함 비밀번호 바꾸기')), content: Column(mainAxisSize: MainAxisSize.min, children: [ TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})), TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: a.text.isNotEmpty && a.text == b.text ? () => Navigator.pop(ctx, true) : null, child: Text(tr('바꾸기'))), ], ), ), ); if (ok == true && mounted) { setState(() => _busy = true); try { await _vault!.changePassword(a.text, iterations: vaultIterationsForTest); _snack(tr('보관함 비밀번호를 바꿨습니다')); } finally { if (mounted) setState(() => _busy = false); } } a.dispose(); b.dispose(); } /// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬) Future _toggleMasterCopy(bool on) async { final lock = MasterLock.instance; if (lock == null) return; if (on) { if (!lock.hasMaster) { _snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요')); return; } final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('마스터로도 열기')), content: Text(tr('보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. ' '보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. ' '마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('켜기'))), ], ), ); if (ok != true || !await SecretGate.pass(force: true)) return; await lock.secrets.write(MasterLock.vaultCopyKey, base64.encode(_vault!.keyForMasterCopy)); } else { await lock.secrets.delete(MasterLock.vaultCopyKey); } await c.updateSettings((x) => x.vaultMasterCopy = on); if (mounted) setState(() {}); } @override Widget build(BuildContext context) { _route ??= ModalRoute.of(context); return _scaffold(); } Widget _scaffold() => Scaffold( appBar: AppBar( title: Text(tr('암호 보관함')), actions: [ if (_stage == _Stage.open) ...[ IconButton(tooltip: tr('잠그기'), icon: const Icon(Icons.lock_outline), onPressed: () => setState(_lock)), PopupMenuButton( tooltip: tr('더 보기'), onSelected: (w) => switch (w) { 'pw' => _changePassword(), 'master' => _toggleMasterCopy(!c.settings.vaultMasterCopy), _ => null, }, itemBuilder: (_) => [ PopupMenuItem(value: 'pw', child: Text(tr('보관함 비밀번호 바꾸기'))), CheckedPopupMenuItem(value: 'master', checked: c.settings.vaultMasterCopy, child: Text(tr('마스터로도 열기'))), ], ), ], ], ), body: Stack(children: [ switch (_stage) { _Stage.checking => const Center(child: CircularProgressIndicator()), _Stage.create => _createForm(), _Stage.locked => _unlockForm(), _Stage.opening => Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 12), Text(tr('보관함을 여는 중… (비밀번호 확인)')), ]), ), _Stage.open => _list(), }, if (_busy && _stage == _Stage.open) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator()), ]), ); Widget _note(String t, {Color? color}) => Padding(padding: const EdgeInsets.only(top: 6), child: Text(t, style: TextStyle(fontSize: 12.5, color: color ?? JjColors.textDim))); Widget _createForm() => ListView(padding: const EdgeInsets.all(16), children: [ Text(tr('새 보관함 만들기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), _note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])), _note(tr('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다 (내용 · 이름은 보이지 않습니다)')), _note(tr('이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)')), _note(tr('비밀번호를 잊으면 되살릴 수 없습니다 (뒷문 없음)'), color: JjColors.danger), const SizedBox(height: 12), TextField( controller: _pw, obscureText: true, decoration: InputDecoration(labelText: tr('보관함 비밀번호')), onChanged: (_) => setState(() {})), TextField(controller: _pw2, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => setState(() {})), if (_pw2.text.isNotEmpty && _pw.text != _pw2.text) _note(tr('두 번 넣은 비밀번호가 다릅니다'), color: JjColors.danger), CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, value: _understood, onChanged: (v) => setState(() => _understood = v ?? false), title: Text(tr('비밀번호를 잊으면 되살릴 수 없음을 압니다')), ), if (_error != null) _note(_error!, color: JjColors.danger), const SizedBox(height: 8), FilledButton( onPressed: !_busy && _understood && _pw.text.isNotEmpty && _pw.text == _pw2.text ? _create : null, child: Text(tr('만들기')), ), ]); Widget _unlockForm() { final waiting = _waitUntil != null && DateTime.now().isBefore(_waitUntil!); return ListView(padding: const EdgeInsets.all(16), children: [ const Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), const SizedBox(height: 8), TextField( controller: _pw, obscureText: true, autofocus: true, enabled: !waiting, decoration: InputDecoration(labelText: tr('보관함 비밀번호')), onSubmitted: (_) => _unlock(), ), if (_error != null) _note(_error!, color: JjColors.danger), const SizedBox(height: 12), FilledButton(onPressed: waiting ? null : _unlock, child: Text(tr('열기'))), if (c.settings.vaultMasterCopy) TextButton(onPressed: waiting ? null : _unlockWithMaster, child: Text(tr('마스터 비밀번호로 열기'))), _note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])), ]); } String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; Widget _list() { final list = [..._vault!.entries]; if (_sort == 'name') { list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } else { list.sort((a, b) => b.added.compareTo(a.added)); } return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row(children: [ Expanded( child: Text(tr('넣기는 파일 탐색기에서 파일을 길게 눌러 [보관함에 넣기]'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), DropdownButton( value: _sort, items: [ DropdownMenuItem(value: 'added', child: Text(tr('넣은 날'))), DropdownMenuItem(value: 'name', child: Text(tr('이름'))), ], onChanged: (v) => setState(() => _sort = v!), ), ]), ), if (list.isEmpty) Expanded(child: Center(child: Text(tr('보관함이 비어 있습니다')))), if (list.isNotEmpty) Expanded( child: ListView.builder( itemCount: list.length, itemBuilder: (_, i) { final e = list[i]; return ListTile( leading: const Icon(Icons.lock, color: JjColors.accent), title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'), onTap: _busy ? null : () => _view(e), trailing: PopupMenuButton( tooltip: tr('더 보기'), enabled: !_busy, onSelected: (w) => switch (w) { 'view' => _view(e), 'export' => _export(e), 'rename' => _rename(e), 'remove' => _remove(e), _ => null, }, itemBuilder: (_) => [ PopupMenuItem(value: 'view', child: Text(tr('보기'))), PopupMenuItem(value: 'export', child: Text(tr('꺼내기'))), PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))), PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))), ], ), ); }, ), ), ]); } } /// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) class _VaultImageSource extends ReaderSource { final String name; final Uint8List bytes; _VaultImageSource(this.name, this.bytes); @override String get title => name; @override int get length => 1; @override String pageName(int i) => name; @override Future load(int i, {required int maxWidth}) async => ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); }