Line data Source code
1 : import 'dart:io';
2 : import 'dart:math';
3 :
4 : import 'package:cwtch/config.dart';
5 : import 'package:cwtch/cwtch_icons_icons.dart';
6 : import 'package:cwtch/models/contact.dart';
7 : import 'package:cwtch/models/filedownloadprogress.dart';
8 : import 'package:cwtch/models/message.dart';
9 : import 'package:cwtch/models/profile.dart';
10 : import 'package:cwtch/themes/opaque.dart';
11 : import 'package:cwtch/widgets/malformedbubble.dart';
12 : import 'package:cwtch/widgets/messageBubbleWidgetHelpers.dart';
13 : import 'package:file_picker/file_picker.dart';
14 : import 'package:flutter/material.dart';
15 : import 'package:provider/provider.dart';
16 : import '../main.dart';
17 :
18 : import 'package:flutter_gen/gen_l10n/app_localizations.dart';
19 :
20 : import '../models/redaction.dart';
21 : import '../settings.dart';
22 : import 'messagebubbledecorations.dart';
23 :
24 : // Like MessageBubble but for displaying chat overlay 100/101 invitations
25 : // Offers the user an accept/reject button if they don't have a matching contact already
26 : class FileBubble extends StatefulWidget {
27 : final String nameSuggestion;
28 : final String rootHash;
29 : final String nonce;
30 : final int fileSize;
31 : final bool interactive;
32 : final bool isAuto;
33 : final bool isPreview;
34 :
35 0 : FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize, {this.isAuto = false, this.interactive = true, this.isPreview = false});
36 :
37 0 : @override
38 0 : FileBubbleState createState() => FileBubbleState();
39 :
40 0 : String fileKey() {
41 0 : return this.rootHash + "." + this.nonce;
42 : }
43 : }
44 :
45 : class FileBubbleState extends State<FileBubble> {
46 : File? myFile;
47 :
48 0 : @override
49 : void initState() {
50 0 : super.initState();
51 : }
52 :
53 0 : Widget getPreview(context) {
54 0 : return Container(
55 0 : constraints: BoxConstraints(maxHeight: min(MediaQuery.of(context).size.height, 150)),
56 0 : child: Image.file(
57 0 : myFile!,
58 : // limit the amount of space the image can decode too, we keep this high-ish to allow quality previews...
59 : cacheWidth: 1024,
60 : cacheHeight: 1024,
61 : filterQuality: FilterQuality.medium,
62 : fit: BoxFit.scaleDown,
63 : alignment: Alignment.center,
64 : isAntiAlias: false,
65 0 : errorBuilder: (context, error, stackTrace) {
66 0 : return MalformedBubble();
67 : },
68 : ));
69 : }
70 :
71 0 : @override
72 : Widget build(BuildContext context) {
73 0 : var fromMe = Provider.of<MessageMetadata>(context).senderHandle == Provider.of<ProfileInfoState>(context).onion;
74 0 : var flagStarted = Provider.of<MessageMetadata>(context).attributes["file-downloaded"] == "true";
75 : var borderRadius = 15.0;
76 0 : var showFileSharing = Provider.of<Settings>(context).isExperimentEnabled(FileSharingExperiment);
77 0 : var showImages = Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment);
78 0 : DateTime messageDate = Provider.of<MessageMetadata>(context).timestamp;
79 :
80 0 : var metadata = Provider.of<MessageMetadata>(context);
81 0 : var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey());
82 :
83 : // If we haven't stored the filepath in message attributes then save it
84 0 : if (metadata.attributes["filepath"] != null && metadata.attributes["filepath"].toString().isNotEmpty) {
85 0 : path = metadata.attributes["filepath"];
86 0 : } else if (path != null && metadata.attributes["filepath"] == null) {
87 0 : Provider.of<FlwtchState>(context).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", path);
88 : }
89 :
90 : // the file is downloaded when it is from the sender AND the path is known OR when we get an explicit downloadComplete
91 0 : var downloadComplete = (fromMe && path != null) || Provider.of<ProfileInfoState>(context).downloadComplete(widget.fileKey());
92 0 : var downloadInterrupted = Provider.of<ProfileInfoState>(context).downloadInterrupted(widget.fileKey());
93 :
94 : var isImagePreview = false;
95 : if (path != null) {
96 0 : isImagePreview = Provider.of<Settings>(context).isImage(path);
97 : }
98 :
99 : if (downloadComplete && path != null) {
100 : if (isImagePreview) {
101 0 : if (myFile == null || myFile?.path != path) {
102 0 : myFile = new File(path);
103 : // reset
104 0 : if (myFile?.existsSync() == false) {
105 0 : myFile = null;
106 : //Provider.of<ProfileInfoState>(context, listen: false).downloadReset(widget.fileKey());
107 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["filepath"] = null;
108 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-downloaded"] = "false";
109 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-missing"] = "true";
110 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-downloaded", "false");
111 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", "");
112 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "true");
113 : } else {
114 0 : Provider.of<MessageMetadata>(context, listen: false).attributes["file-missing"] = "false";
115 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "file-missing", "false");
116 0 : setState(() {});
117 : }
118 : }
119 : }
120 : }
121 :
122 0 : var downloadActive = Provider.of<ProfileInfoState>(context).downloadActive(widget.fileKey());
123 0 : var downloadGotManifest = Provider.of<ProfileInfoState>(context).downloadGotManifest(widget.fileKey());
124 :
125 0 : var messageStatusWidget = MessageBubbleDecoration(ackd: metadata.ackd, errored: metadata.error, messageDate: messageDate, fromMe: fromMe);
126 :
127 : // If the sender is not us, then we want to give them a nickname...
128 : var senderDisplayStr = "";
129 : var senderIsContact = false;
130 : if (!fromMe) {
131 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context).contactList.findContact(Provider.of<MessageMetadata>(context).senderHandle);
132 : if (contact != null) {
133 0 : senderDisplayStr = redactedNick(context, contact.onion, contact.nickname);
134 : senderIsContact = true;
135 : } else {
136 0 : senderDisplayStr = Provider.of<MessageMetadata>(context).senderHandle;
137 : }
138 : }
139 :
140 : // if we should show a preview i.e. we are in a quote bubble
141 : // then do that here...
142 0 : if (showImages && isImagePreview && widget.isPreview && myFile != null) {
143 : // if the image exists then just show the image as a preview
144 0 : return getPreview(context);
145 0 : } else if (showFileSharing && widget.isPreview) {
146 : // otherwise just show a summary...
147 0 : return Row(
148 0 : children: [
149 0 : Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromMeTextColor),
150 0 : Flexible(child: Text(widget.nameSuggestion, style: TextStyle(fontWeight: FontWeight.bold, fontFamily: "Inter", color: Provider.of<Settings>(context).theme.messageFromMeTextColor)))
151 : ],
152 : );
153 : }
154 :
155 0 : var wdgSender = Visibility(
156 0 : visible: widget.interactive,
157 0 : child: Container(
158 0 : height: 14 * Provider.of<Settings>(context).fontScaling, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: compileSenderWidget(context, null, fromMe, senderDisplayStr)));
159 : var isPreview = false;
160 : var wdgMessage = !showFileSharing
161 0 : ? Text(AppLocalizations.of(context)!.messageEnableFileSharing, style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle))
162 : : fromMe
163 0 : ? senderFileChrome(AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash)
164 0 : : (fileChrome(AppLocalizations.of(context)!.messageFileOffered + ":", widget.nameSuggestion, widget.rootHash, widget.fileSize,
165 0 : Provider.of<ProfileInfoState>(context).downloadSpeed(widget.fileKey())));
166 : Widget wdgDecorations;
167 :
168 : if (!showFileSharing) {
169 0 : wdgDecorations = Text('\u202F');
170 : } else if ((fromMe || downloadComplete) && path != null) {
171 : // in this case, whatever marked download.complete would have also set the path
172 0 : if (myFile != null && Provider.of<Settings>(context).shouldPreview(path)) {
173 : isPreview = true;
174 0 : wdgDecorations = Center(
175 : widthFactor: 1.0,
176 0 : child: MouseRegion(
177 : cursor: SystemMouseCursors.click,
178 0 : child: GestureDetector(
179 0 : child: Padding(padding: EdgeInsets.all(1.0), child: getPreview(context)),
180 0 : onTap: () {
181 0 : pop(context, myFile!, widget.nameSuggestion);
182 : },
183 : )));
184 : } else if (fromMe) {
185 0 : wdgDecorations = Text('\u202F');
186 : } else {
187 0 : wdgDecorations = Visibility(
188 0 : visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.fileSavedTo + ': ' + path + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)));
189 : }
190 : } else if (downloadActive) {
191 : if (!downloadGotManifest) {
192 0 : wdgDecorations = Visibility(
193 0 : visible: widget.interactive, child: SelectableText(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)));
194 : } else {
195 0 : wdgDecorations = Visibility(
196 0 : visible: widget.interactive,
197 0 : child: LinearProgressIndicator(
198 0 : value: Provider.of<ProfileInfoState>(context).downloadProgress(widget.fileKey()),
199 0 : color: Provider.of<Settings>(context).theme.defaultButtonActiveColor,
200 : ));
201 : }
202 : } else if (flagStarted) {
203 : // in this case, the download was done in a previous application launch,
204 : // so we probably have to request an info lookup
205 : if (!downloadInterrupted) {
206 0 : wdgDecorations = Text(AppLocalizations.of(context)!.fileCheckingStatus + '...' + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle));
207 : // We should have already requested this...
208 : } else {
209 0 : var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey()) ?? "";
210 0 : wdgDecorations = Visibility(
211 0 : visible: widget.interactive,
212 0 : child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
213 0 : Text(AppLocalizations.of(context)!.fileInterrupted + ': ' + path + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle)),
214 0 : ElevatedButton(onPressed: _btnResume, child: Text(AppLocalizations.of(context)!.verfiyResumeButton, style: Provider.of<Settings>(context).scaleFonts(defaultTextButtonStyle)))
215 : ]));
216 : }
217 : } else if (!senderIsContact) {
218 0 : wdgDecorations = Text(AppLocalizations.of(context)!.msgAddToAccept, style: Provider.of<Settings>(context).scaleFonts(defaultTextStyle));
219 0 : } else if (!widget.isAuto || Provider.of<MessageMetadata>(context).attributes["file-missing"] == "false") {
220 : //Note: we need this second case to account for scenarios where a user deletes the downloaded file, we won't automatically
221 : // fetch it again, so we need to offer the user the ability to restart..
222 0 : wdgDecorations = Visibility(
223 0 : visible: widget.interactive,
224 0 : child: Center(
225 : widthFactor: 1,
226 0 : child: Wrap(children: [
227 0 : Padding(
228 0 : padding: EdgeInsets.all(5),
229 0 : child: ElevatedButton(
230 0 : child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F', style: Provider.of<Settings>(context).scaleFonts(defaultTextButtonStyle)), onPressed: _btnAccept)),
231 : ])));
232 : } else {
233 0 : wdgDecorations = Container();
234 : }
235 :
236 0 : return Container(
237 0 : constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3),
238 0 : decoration: BoxDecoration(
239 0 : color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor,
240 0 : border: Border.all(color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor, width: 1),
241 0 : borderRadius: BorderRadius.only(
242 0 : topLeft: Radius.circular(borderRadius),
243 0 : topRight: Radius.circular(borderRadius),
244 0 : bottomLeft: fromMe ? Radius.circular(borderRadius) : Radius.zero,
245 0 : bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadius),
246 : ),
247 : ),
248 0 : child: Theme(
249 0 : data: Theme.of(context).copyWith(
250 0 : textSelectionTheme: TextSelectionThemeData(
251 0 : cursorColor: Provider.of<Settings>(context).theme.messageSelectionColor,
252 0 : selectionColor: Provider.of<Settings>(context).theme.messageSelectionColor,
253 0 : selectionHandleColor: Provider.of<Settings>(context).theme.messageSelectionColor),
254 :
255 : // Horrifying Hack: Flutter doesn't give us direct control over system menus but instead picks BG color from TextButtonThemeData ¯\_(ツ)_/¯
256 0 : textButtonTheme: TextButtonThemeData(
257 0 : style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Provider.of<Settings>(context).theme.menuBackgroundColor)),
258 : ),
259 : ),
260 0 : child: Padding(
261 0 : padding: EdgeInsets.all(9.0),
262 0 : child: Column(
263 : crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
264 : mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start,
265 : mainAxisSize: MainAxisSize.min,
266 0 : children: [
267 : wdgSender,
268 : isPreview
269 0 : ? Container(
270 : width: 0,
271 : padding: EdgeInsets.zero,
272 : margin: EdgeInsets.zero,
273 : )
274 : : wdgMessage,
275 : wdgDecorations,
276 : messageStatusWidget
277 : ]),
278 : )));
279 : }
280 :
281 0 : void _btnAccept() async {
282 : String? selectedFileName;
283 : File? file;
284 0 : var profileOnion = Provider.of<ProfileInfoState>(context, listen: false).onion;
285 0 : var conversation = Provider.of<ContactInfoState>(context, listen: false).identifier;
286 0 : var idx = Provider.of<MessageMetadata>(context, listen: false).messageID;
287 :
288 0 : if (Platform.isAndroid) {
289 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
290 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true");
291 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context, listen: false).contactList.findContact(Provider.of<MessageMetadata>(context, listen: false).senderHandle);
292 : if (contact != null) {
293 0 : var manifestPath = Provider.of<Settings>(context, listen: false).downloadPath + "/" + widget.fileKey() + ".manifest";
294 :
295 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, contact.identifier, widget.nameSuggestion, widget.fileKey(), manifestPath);
296 : }
297 : } else {
298 : try {
299 0 : selectedFileName = await FilePicker.platform.saveFile(
300 0 : fileName: widget.nameSuggestion,
301 : lockParentWindow: true,
302 : );
303 : if (selectedFileName != null) {
304 0 : file = File(selectedFileName);
305 0 : EnvironmentConfig.debugLog("saving to " + file.path);
306 0 : var manifestPath = file.path + ".manifest";
307 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
308 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true");
309 0 : ContactInfoState? contact = Provider.of<ProfileInfoState>(context, listen: false).contactList.findContact(Provider.of<MessageMetadata>(context, listen: false).senderHandle);
310 : if (contact != null) {
311 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, contact.identifier, file.path, manifestPath, widget.fileKey());
312 : }
313 : }
314 : } catch (e) {
315 0 : print(e);
316 : }
317 : }
318 : }
319 :
320 0 : void _btnResume() async {
321 0 : var profileOnion = Provider.of<ProfileInfoState>(context, listen: false).onion;
322 0 : var handle = Provider.of<MessageMetadata>(context, listen: false).conversationIdentifier;
323 0 : Provider.of<ProfileInfoState>(context, listen: false).downloadMarkResumed(widget.fileKey());
324 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.VerifyOrResumeDownload(profileOnion, handle, widget.fileKey());
325 : }
326 :
327 : // Construct an file chrome for the sender
328 0 : Widget senderFileChrome(String chrome, String fileName, String rootHash) {
329 0 : var settings = Provider.of<Settings>(context);
330 0 : return ListTile(
331 : visualDensity: VisualDensity.compact,
332 0 : contentPadding: EdgeInsets.all(1.0),
333 0 : title: SelectableText(
334 0 : fileName + '\u202F',
335 : style:
336 0 : settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of<Settings>(context).theme.messageFromMeTextColor)),
337 : textAlign: TextAlign.left,
338 : textWidthBasis: TextWidthBasis.longestLine,
339 : maxLines: 2,
340 : ),
341 0 : subtitle: SelectableText(
342 0 : prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F',
343 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of<Settings>(context).theme.messageFromMeTextColor)),
344 : textAlign: TextAlign.left,
345 : maxLines: 4,
346 : textWidthBasis: TextWidthBasis.longestLine,
347 : ),
348 0 : leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)));
349 : }
350 :
351 : // Construct an file chrome
352 0 : Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) {
353 0 : var settings = Provider.of<Settings>(context);
354 0 : return ListTile(
355 : visualDensity: VisualDensity.compact,
356 0 : contentPadding: EdgeInsets.all(1.0),
357 0 : title: SelectableText(
358 0 : fileName + '\u202F',
359 : style:
360 0 : settings.scaleFonts(defaultMessageTextStyle.copyWith(overflow: TextOverflow.ellipsis, fontWeight: FontWeight.bold, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
361 : textAlign: TextAlign.left,
362 : textWidthBasis: TextWidthBasis.longestLine,
363 : maxLines: 2,
364 : ),
365 0 : subtitle: SelectableText(
366 0 : prettyBytes(widget.fileSize) + '\u202F' + '\n' + 'sha512: ' + rootHash + '\u202F',
367 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(fontFamily: "RobotoMono", color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
368 : textAlign: TextAlign.left,
369 : maxLines: 4,
370 : textWidthBasis: TextWidthBasis.longestLine,
371 : ),
372 0 : leading: FittedBox(child: Icon(CwtchIcons.attached_file_3, size: 32, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
373 : // Note: not using Visible here because we want to shrink this to nothing when not in use...
374 0 : trailing: speed == "0 B/s"
375 : ? null
376 0 : : SelectableText(
377 0 : speed + '\u202F',
378 0 : style: settings.scaleFonts(defaultSmallTextStyle.copyWith(color: Provider.of<Settings>(context).theme.messageFromOtherTextColor)),
379 : textAlign: TextAlign.left,
380 : maxLines: 1,
381 : textWidthBasis: TextWidthBasis.longestLine,
382 : ),
383 : );
384 : }
385 :
386 0 : void pop(context, File myFile, String meta) async {
387 0 : await showDialog(
388 : context: context,
389 0 : builder: (bcontext) => Dialog(
390 : alignment: Alignment.topCenter,
391 0 : child: SingleChildScrollView(
392 0 : controller: ScrollController(),
393 0 : child: Container(
394 0 : padding: EdgeInsets.all(10),
395 0 : child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [
396 0 : ListTile(
397 0 : leading: Icon(CwtchIcons.attached_file_3),
398 0 : title: Text(meta),
399 0 : trailing: IconButton(
400 0 : icon: Icon(Icons.close),
401 0 : color: Provider.of<Settings>(bcontext, listen: false).theme.mainTextColor,
402 : iconSize: 32,
403 0 : onPressed: () {
404 0 : Navigator.pop(bcontext, true);
405 : })),
406 0 : Padding(
407 0 : padding: EdgeInsets.all(10),
408 0 : child: Image.file(
409 : myFile,
410 0 : cacheWidth: (MediaQuery.of(bcontext).size.width * 0.6).floor(),
411 0 : width: (MediaQuery.of(bcontext).size.width * 0.6),
412 0 : height: (MediaQuery.of(bcontext).size.height * 0.6),
413 : fit: BoxFit.scaleDown,
414 : )),
415 0 : Visibility(visible: !Platform.isAndroid, maintainSize: false, child: Text(myFile.path, textAlign: TextAlign.center)),
416 0 : Visibility(
417 0 : visible: Platform.isAndroid,
418 : maintainSize: false,
419 0 : child: Padding(
420 0 : padding: EdgeInsets.all(10),
421 0 : child: ElevatedButton.icon(
422 0 : icon: Icon(Icons.arrow_downward),
423 0 : onPressed: androidExport,
424 0 : label: Text(
425 0 : AppLocalizations.of(bcontext)!.saveBtn,
426 : )))),
427 : ]),
428 : ))));
429 : }
430 :
431 0 : void androidExport() async {
432 0 : if (myFile != null) {
433 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.ExportPreviewedFile(myFile!.path, widget.nameSuggestion);
434 : }
435 : }
436 : }
|