import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:cryptography/cryptography.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart' show DateFormat; import 'package:path_provider/path_provider.dart'; import '../../core/app_controller.dart'; import '../../core/failure.dart'; import '../../l10n/strings.dart'; import '../../models/server_profile.dart'; import '../../services/editor/inspect.dart'; import '../../services/editor/languages.dart'; import '../../services/editor/sources.dart'; import '../../services/metrics_format.dart'; import '../../services/sftp_service.dart'; import '../../services/ssh_service.dart'; import '../../services/vault.dart'; import '../common.dart'; import '../export_sheet.dart'; import 'editor_page.dart'; import 'save_as.dart'; /// Opens [source] with the right tool: the editor for text, a viewer for /// everything else. Nothing is executed: HTML and SVG are never run as /// pages, and documents are shown by the system viewer from a private /// temporary copy. Future openSource( BuildContext context, AppController c, FileSource source, { Draft? draft, }) async { unawaited(recordRecent(c, source)); final kind = draft != null ? FileKind.text : fileKind(source.name); final page = kind == FileKind.text ? EditorPage(c, source, draft: draft) : ViewerPage(c, source, kind: kind); await Navigator.push(context, MaterialPageRoute(builder: (_) => page)); } class ViewerPage extends StatefulWidget { final AppController controller; final FileSource source; final FileKind kind; const ViewerPage( this.controller, this.source, { required this.kind, super.key, }); @override State createState() => _ViewerPageState(); } class _ViewerPageState extends State { Uint8List? bytes; String? error; late FileKind kind = widget.kind; AppController get c => widget.controller; @override void initState() { super.initState(); unawaited(_load()); } Future _load() async { try { final b = await widget.source.load(maxBytes: maxViewBytes); if (!mounted) return; setState(() { bytes = b; if (kind == FileKind.text) kind = fileKind(widget.source.name, head: b); }); } catch (e) { if (mounted) setState(() => error = failureCode(e)); } } void _asText() => Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => EditorPage(c, widget.source, bytes: bytes), ), ); Future _tempCopy() async { final temp = await getTemporaryDirectory(); final dir = await Directory('${temp.path}/sm-transfer-${newId()}').create(); final file = File('${dir.path}/${safeName(widget.source.name)}'); await file.writeAsBytes(bytes!, flush: true); return file.path; } /// The system viewer (Quick Look on iOS, an app the user picks on /// Android) shows a private temporary copy; the copy is removed when the /// Vault locks or the app restarts. Future _system() async { try { final path = await _tempCopy(); final shown = await c.withSystemDialog( () => c.documents.preview(path, mime: mimeOf(widget.source.name)), ); if (!shown && mounted) { showFailure(context, const Failure('noViewer')); } } catch (e) { if (mounted) showFailure(context, e); } } Future _share() async { final box = context.findRenderObject() as RenderBox?; final o = box?.localToGlobal(Offset.zero) ?? Offset.zero; try { await shareBytes( c, widget.source.name, bytes!, Rect.fromLTWH(o.dx, o.dy, 1, 1), ); } catch (e) { if (mounted) showFailure(context, e); } } Future _saveCopy() async { try { await saveAsDialog(context, c, name: widget.source.name, bytes: bytes!); } catch (e) { if (mounted) showFailure(context, e); } } @override Widget build(BuildContext context) { final b = bytes; return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.source.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( widget.source.location, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ if (b != null) PopupMenuButton( onSelected: (v) => switch (v) { 'text' => _asText(), 'system' => _system(), 'share' => _share(), 'save' => _saveCopy(), _ => null, }, itemBuilder: (ctx) => [ if (b.length <= maxEditableBytes) PopupMenuItem( value: 'text', child: Text(ctx.tr('openAsText')), ), PopupMenuItem( value: 'system', child: Text(ctx.tr('openInSystemViewer')), ), PopupMenuItem(value: 'save', child: Text(ctx.tr('saveAs'))), PopupMenuItem(value: 'share', child: Text(ctx.tr('shareFile'))), ], ), ], ), body: SafeArea( child: error != null ? PageMessage(error!, icon: Icons.error_outline) : b == null ? const Center(child: CircularProgressIndicator()) : switch (kind) { FileKind.image => _ImageView(b), FileKind.svg => _SvgView(b, onSource: _asText), FileKind.pdf when Platform.isAndroid => _PdfView(c, _tempCopy), FileKind.pdf || FileKind.office || FileKind.audio || FileKind.video => _SystemView(kind, b.length, onOpen: _system), FileKind.archive => _ArchiveView(widget.source.name, b), FileKind.certificate => _CryptoView(b, onText: _asText), FileKind.notebook => _NotebookView(b, onJson: _asText), FileKind.binary || FileKind.text => _HexView(b, onText: _asText), }, ), ); } } class _Info extends StatelessWidget { final IconData icon; final String title; final String? detail; final List actions; const _Info(this.icon, this.title, {this.detail, this.actions = const []}); @override Widget build(BuildContext context) => Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 56, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 16), Text( title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), if (detail != null) ...[ const SizedBox(height: 8), Text(detail!, textAlign: TextAlign.center), ], const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: actions, ), ], ), ), ), ); } class _ImageView extends StatelessWidget { final Uint8List bytes; const _ImageView(this.bytes); @override Widget build(BuildContext context) => InteractiveViewer( maxScale: 12, child: Center( child: Image.memory( bytes, filterQuality: FilterQuality.medium, errorBuilder: (ctx, _, _) => _Info(Icons.broken_image_outlined, ctx.tr('imageUnsupported')), ), ), ); } /// SVG is drawn as a picture: no scripts, no animation, no external /// resources, no links. class _SvgView extends StatelessWidget { final Uint8List bytes; final VoidCallback onSource; const _SvgView(this.bytes, {required this.onSource}); @override Widget build(BuildContext context) => Column( children: [ MaterialBanner( content: Text(context.tr('svgSafe')), actions: [ TextButton( onPressed: onSource, child: Text(context.tr('openAsText')), ), ], ), Expanded( child: InteractiveViewer( maxScale: 12, child: Center( child: SvgPicture.memory( bytes, errorBuilder: (ctx, _, _) => _Info( Icons.broken_image_outlined, ctx.tr('imageUnsupported'), ), ), ), ), ), ], ); } class _SystemView extends StatelessWidget { final FileKind kind; final int size; final VoidCallback onOpen; const _SystemView(this.kind, this.size, {required this.onOpen}); @override Widget build(BuildContext context) => _Info( switch (kind) { FileKind.audio => Icons.audiotrack, FileKind.video => Icons.movie_outlined, FileKind.pdf => Icons.picture_as_pdf_outlined, _ => Icons.description_outlined, }, context.tr('systemViewerTitle'), detail: '${context.tr(Platform.isIOS ? 'systemViewerIos' : 'systemViewerAndroid')}\n' '${context.tr('size')}: ${formatBytes(size, (v, d) => v.toStringAsFixed(d))}', actions: [ FilledButton.icon( onPressed: onOpen, icon: const Icon(Icons.open_in_new), label: Text(context.tr('openInSystemViewer')), ), ], ); } /// Android: PDF pages rendered by the system PdfRenderer. class _PdfView extends StatefulWidget { final AppController controller; final Future Function() copy; const _PdfView(this.controller, this.copy); @override State<_PdfView> createState() => _PdfViewState(); } class _PdfViewState extends State<_PdfView> { String? path, error; int pages = 0; final cache = >{}; @override void initState() { super.initState(); unawaited(() async { try { final p = await widget.copy(); final n = await widget.controller.documents.pdfPageCount(p); if (mounted) { setState(() { path = p; pages = n; }); } } catch (e) { if (mounted) setState(() => error = failureCode(e)); } }()); } @override Widget build(BuildContext context) { if (error != null) return PageMessage(error!, icon: Icons.error_outline); if (path == null) return const Center(child: CircularProgressIndicator()); return LayoutBuilder( builder: (context, constraints) { final width = (constraints.maxWidth * MediaQuery.devicePixelRatioOf(context)) .clamp(400, 2400) .round(); return InteractiveViewer( maxScale: 6, child: ListView.builder( itemCount: pages, itemBuilder: (context, i) => Padding( padding: const EdgeInsets.all(8), child: FutureBuilder( future: cache[i] ??= widget.controller.documents.pdfPage( path!, i, width, ), builder: (context, snap) => snap.hasData ? Image.memory(snap.data!) : AspectRatio( aspectRatio: 1 / 1.414, child: Center( child: snap.hasError ? Text(context.tr('fileError')) : const CircularProgressIndicator(), ), ), ), ), ), ); }, ); } } class _ArchiveView extends StatelessWidget { final String name; final Uint8List bytes; const _ArchiveView(this.name, this.bytes); @override Widget build(BuildContext context) { final ArchiveListing listing; try { listing = listArchive(name, bytes); } on ArchiveUnsupported catch (e) { return _Info( Icons.archive_outlined, e.format == 'tooLarge' ? context.tr('archiveTooLarge') : '${context.tr('archiveUnsupported')} (${e.format})', ); } catch (_) { return _Info(Icons.archive_outlined, context.tr('archiveDamaged')); } String size(int v) => formatBytes(v, (x, d) => x.toStringAsFixed(d)); final date = DateFormat.yMd( Localizations.localeOf(context).toString(), ).add_Hm(); final files = listing.entries.where((e) => !e.directory).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( title: Text(listing.format), subtitle: Text( '${files.length} ${context.tr('filesCount')} · ' '${size(files.fold(0, (a, e) => a + e.size))}' '${listing.uncompressed == null ? '' : ' · ${context.tr('uncompressed')}: ${size(listing.uncompressed!)}'}' '\n${context.tr('archiveListOnly')}', ), ), const Divider(height: 1), Expanded( child: ListView.builder( itemCount: listing.entries.length, itemBuilder: (context, i) { final e = listing.entries[i]; return ListTile( dense: true, leading: Icon( e.directory ? Icons.folder_outlined : Icons.insert_drive_file_outlined, ), title: Text(e.name), subtitle: e.directory ? null : Text( [ size(e.size), if (e.modified != null) date.format(e.modified!.toLocal()), ].join(' · '), ), ); }, ), ), ], ); } } class _CryptoView extends StatelessWidget { final Uint8List bytes; final VoidCallback onText; const _CryptoView(this.bytes, {required this.onText}); @override Widget build(BuildContext context) => FutureBuilder>( future: inspectCrypto(bytes), builder: (context, snap) { if (!snap.hasData) { return snap.hasError ? _Info(Icons.key_off, context.tr('cryptoUnreadable')) : const Center(child: CircularProgressIndicator()); } final items = snap.data!; return ListView( padding: const EdgeInsets.all(16), children: [ Text( context.tr('cryptoNote'), style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), if (items.isEmpty) Text(context.tr('cryptoUnreadable')), for (final item in items) Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( item.secret ? Icons.vpn_key : Icons.verified_outlined, ), const SizedBox(width: 8), Expanded( child: Text( item.type, style: Theme.of(context).textTheme.titleMedium, ), ), ], ), if (item.secret) Padding( padding: const EdgeInsets.only(top: 8), child: Text( context.tr('privateKeyHidden'), style: TextStyle( color: Theme.of(context).colorScheme.error, ), ), ), for (final MapEntry(:key, :value) in item.fields.entries) Padding( padding: const EdgeInsets.only(top: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( key, style: Theme.of(context).textTheme.bodySmall, ), SelectableText(value), ], ), ), ], ), ), ), const SizedBox(height: 8), OutlinedButton( onPressed: onText, child: Text(context.tr('openAsText')), ), ], ); }, ); } class _NotebookView extends StatelessWidget { final Uint8List bytes; final VoidCallback onJson; const _NotebookView(this.bytes, {required this.onJson}); @override Widget build(BuildContext context) { final List cells; try { cells = parseNotebook( String.fromCharCodes(bytes).contains('Ã') ? const Utf8Decoder(allowMalformed: true).convert(bytes) : const Utf8Decoder(allowMalformed: true).convert(bytes), ); } catch (_) { return _Info( Icons.data_object, context.tr('notebookUnreadable'), actions: [ FilledButton( onPressed: onJson, child: Text(context.tr('openAsText')), ), ], ); } final mono = TextStyle( fontFamily: Platform.isIOS ? 'Menlo' : 'monospace', fontSize: 13, ); return ListView( padding: const EdgeInsets.all(12), children: [ Align( alignment: AlignmentDirectional.centerEnd, child: TextButton( onPressed: onJson, child: Text(context.tr('openAsText')), ), ), for (final cell in cells) Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( cell.type, style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 4), SelectableText( cell.source, style: cell.type == 'code' ? mono : null, ), for (final o in cell.outputs) ...[ const Divider(), SelectableText(o, style: mono), ], for (final img in cell.images) ...[ const Divider(), Image.memory( img, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ], ], ), ), ), ], ); } } /// Size, SHA-256 and a hexadecimal view of any file. class _HexView extends StatefulWidget { final Uint8List bytes; final VoidCallback onText; const _HexView(this.bytes, {required this.onText}); @override State<_HexView> createState() => _HexViewState(); } class _HexViewState extends State<_HexView> { String? sha256; @override void initState() { super.initState(); unawaited(() async { final h = await Sha256().hash(widget.bytes); if (mounted) { setState( () => sha256 = h.bytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(), ); } }()); } @override Widget build(BuildContext context) { final b = widget.bytes; final rows = (b.length / 16).ceil(); final mono = TextStyle( fontFamily: Platform.isIOS ? 'Menlo' : 'monospace', fontSize: 12, fontFeatures: const [FontFeature.tabularFigures()], ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( title: Text(context.tr('binaryFile')), subtitle: SelectableText( '${context.tr('size')}: ${b.length} B\nSHA-256: ${sha256 ?? '…'}', ), trailing: b.length <= maxEditableBytes ? TextButton( onPressed: widget.onText, child: Text(context.tr('openAsText')), ) : null, ), const Divider(height: 1), Expanded( child: Scrollbar( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( width: 640, child: ListView.builder( itemCount: rows, itemExtent: 20, itemBuilder: (context, r) { final start = r * 16, end = math.min(start + 16, b.length); final hex = StringBuffer(), text = StringBuffer(); for (var i = start; i < start + 16; i++) { if (i < end) { hex.write('${b[i].toRadixString(16).padLeft(2, '0')} '); text.writeCharCode( b[i] >= 0x20 && b[i] < 0x7f ? b[i] : 0x2e, ); } else { hex.write(' '); } if (i == start + 7) hex.write(' '); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( '${start.toRadixString(16).padLeft(8, '0')} $hex $text', style: mono, ), ); }, ), ), ), ), ), ], ); } }