import 'dart:io'; import 'dart:math'; import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/filedownloadprogress.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/profile.dart'; import 'package:cwtch/themes/opaque.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/messageBubbleWidgetHelpers.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../main.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../models/redaction.dart'; import '../settings.dart'; import 'messagebubbledecorations.dart'; // Like MessageBubble but for displaying chat overlay 100/101 invitations // Offers the user an accept/reject button if they don't have a matching contact already class FileBubble extends StatefulWidget { final String nameSuggestion; final String rootHash; final String nonce; final int fileSize; final bool interactive; final bool isAuto; final bool isPreview; FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize, {this.isAuto = false, this.interactive = true, this.isPreview = false}); @override FileBubbleState createState() => FileBubbleState(); String fileKey() { return this.rootHash + "." + this.nonce; } } class FileBubbleState extends State { File? myFile; @override void initState() { super.initState(); } Widget getPreview(context) { return Container( constraints: BoxConstraints(maxHeight: min(MediaQuery.of(context).size.height, 150)), child: Image.file( myFile!, // limit the amount of space the image can decode too, we keep this high-ish to allow quality previews... cacheWidth: 1024, cacheHeight: 1024, filterQuality: FilterQuality.medium, fit: BoxFit.scaleDown, alignment: Alignment.center, isAntiAlias: false, errorBuilder: (context, error, stackTrace) { return MalformedBubble(); }, )); } @override Widget build(BuildContext context) { var fromMe = Provider.of(context).senderHandle == Provider.of(context).onion; var flagStarted = Provider.of(context).attributes["file-downloaded"] == "true"; var borderRadius = 15.0; var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); var showImages = Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment); DateTime messageDate = Provider.of(context).timestamp; var metadata = Provider.of(context); var path = Provider.of(context).downloadFinalPath(widget.fileKey()); // If we haven't stored the filepath in message attributes then save it if (metadata.attributes["filepath"] != null && metadata.attributes["filepath"].toString().isNotEmpty) { path = metadata.attributes["filepath"]; } else if (path != null && metadata.attributes["filepath"] == null) { Provider.of(context).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", path); } // the file is downloaded when it is from the sender AND the path is known OR when we get an explicit downloadComplete var downloadComplete = (fromMe && path != null) || Provider.of(context).downloadComplete(widget.fileKey()); var downloadInterrupted = Provider.of(context).downloadInterrupted(widget.fileKey()); var isImagePreview = false; if (path != null) { isImagePreview = Provider.of(context).isImage(path); } if (downloadComplete && path != null) { if (isImagePreview) { if (myFile == null || myFile?.path != path) { myFile = new File(path); // reset if (myFile?.existsSync() == false) { myFile = null; //Provider.of(context, listen: false).downloadReset(widget.fileKey()); Provider.of(context, listen: false).attributes["filepath"] = null; Provider.of(context, listen: false).attributes["file-downloaded"] = "false"; Provider.of(context, listen: false).attributes["file-missing"] = "true"; Provider.of(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-downloaded", "false"); Provider.of(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", ""); Provider.of(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "true"); } else { Provider.of(context, listen: false).attributes["file-missing"] = "false"; Provider.of(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "false"); setState(() {}); } } } } var downloadActive = Provider.of(context).downloadActive(widget.fileKey()); var downloadGotManifest = Provider.of(context).downloadGotManifest(widget.fileKey()); var messageStatusWidget = MessageBubbleDecoration(ackd: metadata.ackd, errored: metadata.error, messageDate: messageDate, fromMe: fromMe); // If the sender is not us, then we want to give them a nickname... var senderDisplayStr = ""; var senderIsContact = false; if (!fromMe) { ContactInfoState? contact = Provider.of(context).contactList.findContact(Provider.of(context).senderHandle); if (contact != null) { senderDisplayStr = redactedNick(context, contact.onion, contact.nickname); senderIsContact = true; } else { senderDisplayStr = Provider.of(context).senderHandle; } } // if we should show a preview i.e. we are in a quote bubble // then do that here... if (showImages && isImagePreview && widget.isPreview && myFile != null) { // if the image exists then just show the image as a preview return getPreview(context); } else if (showFileSharing && widget.isPreview) { // otherwise just show a summary... return Row( children: [ Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of(context).theme.messageFromMeTextColor), Flexible(child: Text(widget.nameSuggestion, style: TextStyle(fontWeight: FontWeight.bold, fontFamily: "Inter", color: Provider.of(context).theme.messageFromMeTextColor))) ], ); } var wdgSender = Visibility( visible: widget.interactive, child: Container( height: 14 * Provider.of(context).fontScaling, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: compileSenderWidget(context, null, fromMe, senderDisplayStr))); var isPreview = false; var wdgMessage = !showFileSharing ? Text(AppLocalizations.of(context)!.messageEnableFileSharing, style: Provider.of(context).scaleFonts(defaultTextStyle)) : fromMe ? senderFileChrome(AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash) : (fileChrome(AppLocalizations.of(context)!.messageFileOffered + ":", widget.nameSuggestion, widget.rootHash, widget.fileSize, Provider.of(context).downloadSpeed(widget.fileKey()))); Widget wdgDecorations; if (!showFileSharing) { wdgDecorations = Text('\u202F'); } else if ((fromMe || downloadComplete) && path != null) { // in this case, whatever marked download.complete would have also set the path if (myFile != null && Provider.of(context).shouldPreview(path)) { isPreview = true; wdgDecorations = Center( widthFactor: 1.0, child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( child: Padding(padding: EdgeInsets.all(1.0), child: getPreview(context)), onTap: () { pop(context, myFile!, widget.nameSuggestion); }, ))); } else if (fromMe) { wdgDecorations = Text('\u202F'); } else { wdgDecorations = Visibility( visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.fileSavedTo + ': ' + path + '\u202F', style: Provider.of(context).scaleFonts(defaultTextStyle))); } } else if (downloadActive) { if (!downloadGotManifest) { wdgDecorations = Visibility( visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F', style: Provider.of(context).scaleFonts(defaultTextStyle))); } else { wdgDecorations = Visibility( visible: widget.interactive, child: LinearProgressIndicator( value: Provider.of(context).downloadProgress(widget.fileKey()), color: Provider.of(context).theme.defaultButtonActiveColor, )); } } else if (flagStarted) { // in this case, the download was done in a previous application launch, // so we probably have to request an info lookup if (!downloadInterrupted) { wdgDecorations = Text(AppLocalizations.of(context)!.fileCheckingStatus + '...' + '\u202F', style: Provider.of(context).scaleFonts(defaultTextStyle)); // We should have already requested this... } else { var path = Provider.of(context).downloadFinalPath(widget.fileKey()) ?? ""; wdgDecorations = Visibility( visible: widget.interactive, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(AppLocalizations.of(context)!.fileInterrupted + ': ' + path + '\u202F', style: Provider.of(context).scaleFonts(defaultTextStyle)), ElevatedButton(onPressed: _btnResume, child: Text(AppLocalizations.of(context)!.verfiyResumeButton, style: Provider.of(context).scaleFonts(defaultTextButtonStyle))) ])); } } else if (!senderIsContact) { wdgDecorations = Text(AppLocalizations.of(context)!.msgAddToAccept, style: Provider.of(context).scaleFonts(defaultTextStyle)); } else if (!widget.isAuto || Provider.of(context).attributes["file-missing"] == "false") { //Note: we need this second case to account for scenarios where a user deletes the downloaded file, we won't automatically // fetch it again, so we need to offer the user the ability to restart.. wdgDecorations = Visibility( visible: widget.interactive, child: Center( widthFactor: 1, child: Wrap(children: [ Padding( padding: EdgeInsets.all(5), child: ElevatedButton( child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F', style: Provider.of(context).scaleFonts(defaultTextButtonStyle)), onPressed: _btnAccept)), ]))); } else { wdgDecorations = Container(); } return Container( constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3), decoration: BoxDecoration( color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor : Provider.of(context).theme.messageFromOtherBackgroundColor, border: Border.all(color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor : Provider.of(context).theme.messageFromOtherBackgroundColor, width: 1), borderRadius: BorderRadius.only( topLeft: Radius.circular(borderRadius), topRight: Radius.circular(borderRadius), bottomLeft: fromMe ? Radius.circular(borderRadius) : Radius.zero, bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadius), ), ), child: Theme( data: Theme.of(context).copyWith( textSelectionTheme: TextSelectionThemeData( cursorColor: Provider.of(context).theme.messageSelectionColor, selectionColor: Provider.of(context).theme.messageSelectionColor, selectionHandleColor: Provider.of(context).theme.messageSelectionColor), // Horrifying Hack: Flutter doesn't give us direct control over system menus but instead picks BG color from TextButtonThemeData ¯\_(ツ)_/¯ textButtonTheme: TextButtonThemeData( style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Provider.of(context).theme.menuBackgroundColor)), ), ), child: Padding( padding: EdgeInsets.all(9.0), child: Column( crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ wdgSender, isPreview ? Container( width: 0, padding: EdgeInsets.zero, margin: EdgeInsets.zero, ) : wdgMessage, wdgDecorations, messageStatusWidget ]), ))); } void _btnAccept() async { String? selectedFileName; File? file; var profileOnion = Provider.of(context, listen: false).onion; var conversation = Provider.of(context, listen: false).identifier; var idx = Provider.of(context, listen: false).messageID; if (Platform.isAndroid) { Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); Provider.of(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true"); ContactInfoState? contact = Provider.of(context, listen: false).contactList.findContact(Provider.of(context, listen: false).senderHandle); if (contact != null) { var manifestPath = Provider.of(context, listen: false).downloadPath + "/" + widget.fileKey() + ".manifest"; Provider.of(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, contact.identifier, widget.nameSuggestion, widget.fileKey(), manifestPath); } } else { try { selectedFileName = await FilePicker.platform.saveFile( fileName: widget.nameSuggestion, lockParentWindow: true, ); if (selectedFileName != null) { file = File(selectedFileName); EnvironmentConfig.debugLog("saving to " + file.path); var manifestPath = file.path + ".manifest"; Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); Provider.of(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true"); ContactInfoState? contact = Provider.of(context, listen: false).contactList.findContact(Provider.of(context, listen: false).senderHandle); if (contact != null) { Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, contact.identifier, file.path, manifestPath, widget.fileKey()); } } } catch (e) { print(e); } } } void _btnResume() async { var profileOnion = Provider.of(context, listen: false).onion; var handle = Provider.of(context, listen: false).conversationIdentifier; Provider.of(context, listen: false).downloadMarkResumed(widget.fileKey()); Provider.of(context, listen: false).cwtch.VerifyOrResumeDownload(profileOnion, handle, widget.fileKey()); } // Construct an file chrome for the sender Widget senderFileChrome(String chrome, String fileName, String rootHash) { var settings = Provider.of(context); return ListTile( visualDensity: VisualDensity.compact, contentPadding: EdgeInsets.all(1.0), title: SelectableText( fileName + '\u202F', style: settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of(context).theme.messageFromMeTextColor)), textAlign: TextAlign.left, textWidthBasis: TextWidthBasis.longestLine, maxLines: 2, ), subtitle: SelectableText( prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F', style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of(context).theme.messageFromMeTextColor)), textAlign: TextAlign.left, maxLines: 4, textWidthBasis: TextWidthBasis.longestLine, ), leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of(context).theme.messageFromOtherTextColor))); } // Construct an file chrome Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) { var settings = Provider.of(context); return ListTile( visualDensity: VisualDensity.compact, contentPadding: EdgeInsets.all(1.0), title: SelectableText( fileName + '\u202F', style: settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of(context).theme.messageFromOtherTextColor)), textAlign: TextAlign.left, textWidthBasis: TextWidthBasis.longestLine, maxLines: 2, ), subtitle: SelectableText( prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F', style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of(context).theme.messageFromOtherTextColor)), textAlign: TextAlign.left, maxLines: 4, textWidthBasis: TextWidthBasis.longestLine, ), leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of(context).theme.messageFromOtherTextColor)), // Note: not using Visible here because we want to shrink this to nothing when not in use... trailing: speed == "0 B/s" ? null : SelectableText( speed + '\u202F', style: settings.scaleFonts(defaultSmallTextStyle.copyWith(color: Provider.of(context).theme.messageFromOtherTextColor)), textAlign: TextAlign.left, maxLines: 1, textWidthBasis: TextWidthBasis.longestLine, ), ); } void pop(context, File myFile, String meta) async { await showDialog( context: context, builder: (bcontext) => Dialog( alignment: Alignment.topCenter, child: SingleChildScrollView( controller: ScrollController(), child: Container( padding: EdgeInsets.all(10), child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ ListTile( leading: Icon(CwtchIcons.attached_file_3), title: Text(meta), trailing: IconButton( icon: Icon(Icons.close), color: Provider.of(bcontext, listen: false).theme.mainTextColor, iconSize: 32, onPressed: () { Navigator.pop(bcontext, true); })), Padding( padding: EdgeInsets.all(10), child: Image.file( myFile, cacheWidth: (MediaQuery.of(bcontext).size.width * 0.6).floor(), width: (MediaQuery.of(bcontext).size.width * 0.6), height: (MediaQuery.of(bcontext).size.height * 0.6), fit: BoxFit.scaleDown, )), Visibility(visible: !Platform.isAndroid, maintainSize: false, child: Text(myFile.path, textAlign: TextAlign.center)), Visibility( visible: Platform.isAndroid, maintainSize: false, child: Padding( padding: EdgeInsets.all(10), child: ElevatedButton.icon( icon: Icon(Icons.arrow_downward), onPressed: androidExport, label: Text( AppLocalizations.of(bcontext)!.saveBtn, )))), ]), )))); } void androidExport() async { if (myFile != null) { Provider.of(context, listen: false).cwtch.ExportPreviewedFile(myFile!.path, widget.nameSuggestion); } } }