diff --git a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt index cf207e99..44ba19f3 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt @@ -77,7 +77,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : } val loader = FlutterInjector.instance().flutterLoader() - val key = loader.getLookupKeyForAsset("assets/" + data.getString("Picture"))//"assets/profiles/001-centaur.png") + val key = loader.getLookupKeyForAsset("assets/" + data.getString("picture"))//"assets/profiles/001-centaur.png") val fh = applicationContext.assets.open(key) diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index fee09984..cd0d564f 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -106,7 +106,7 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"], blocked: false, // we created accepted: true, // we created - imagePath: data["PicturePath"], + imagePath: data["picture"], nickname: data["GroupName"], status: status, server: data["GroupServer"], @@ -147,7 +147,7 @@ class CwtchNotifier { var messageID = int.parse(data["Index"]); var timestamp = DateTime.tryParse(data['TimestampReceived'])!; var senderHandle = data['RemotePeer']; - var senderImage = data['Picture']; + var senderImage = data['picture']; var isAuto = data['Auto'] == "true"; String? contenthash = data['ContentHash']; var selectedProfile = appState.selectedProfile == data["ProfileOnion"]; @@ -199,7 +199,7 @@ class CwtchNotifier { if (data["ProfileOnion"] != data["RemotePeer"]) { var idx = int.parse(data["Index"]); var senderHandle = data['RemotePeer']; - var senderImage = data['Picture']; + var senderImage = data['picture']; var timestampSent = DateTime.tryParse(data['TimestampSent'])!; var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier); var currentTotal = contact!.totalMessages; @@ -301,7 +301,7 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"], blocked: false, // NewGroup only issued on accepting invite accepted: true, // NewGroup only issued on accepting invite - imagePath: data["PicturePath"], + imagePath: data["picture"], nickname: groupInvite["GroupName"], server: groupInvite["ServerHost"], status: status, @@ -322,15 +322,28 @@ class CwtchNotifier { profileCN.getProfile(data["ProfileOnion"])?.contactList.resort(); break; case "NewRetValMessageFromPeer": - if (data["Path"] == "profile.name") { + if (data["Path"] == "profile.name" && data["Exists"] == "true") { if (data["Data"].toString().trim().length > 0) { // Update locally on the UI... if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) { profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"]; } } - } else if (data['Path'] == "profile.picture") { - // Not yet.. + } else if (data['Path'] == "profile.custom-profile-image" && data["Exists"] == "true") { + EnvironmentConfig.debugLog("received ret val of custom profile image: $data"); + String fileKey = data['Data']; + String filePath = data['FilePath']; + bool downloaded = data['FileDownloadFinished'] == "true"; + if (downloaded) { + if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) { + profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.imagePath = filePath; + } + } else { + var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]); + if (contact != null) { + profileCN.getProfile(data["ProfileOnion"])?.waitForDownloadComplete(contact.identifier, fileKey); + } + } } else { EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}"); } diff --git a/lib/models/profile.dart b/lib/models/profile.dart index ee10ec47..75b37f0b 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -17,6 +17,7 @@ class ProfileInfoState extends ChangeNotifier { int _unreadMessages = 0; bool _online = false; Map _downloads = Map(); + Map _downloadTriggers = Map(); // assume profiles are encrypted...this will be set to false // in the constructor if the profile is encrypted with the defacto password. @@ -178,22 +179,14 @@ class ProfileInfoState extends ChangeNotifier { this._contacts.resort(); } - void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) { + void newMessage( + int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) { if (!selectedProfile) { unreadMessages++; notifyListeners(); } - contactList.newMessage( - identifier, - messageID, - timestamp, - senderHandle, - senderImage, - isAuto, - data, - contenthash, - selectedConversation); + contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation); } void downloadInit(String fileKey, int numChunks) { @@ -232,6 +225,15 @@ class ProfileInfoState extends ChangeNotifier { // so setting numChunks correctly shouldn't matter this.downloadInit(fileKey, 1); } + + // Update the contact with a custom profile image if we are + // waiting for one... + if (this._downloadTriggers.containsKey(fileKey)) { + int identifier = this._downloadTriggers[fileKey]!; + this.contactList.getContact(identifier)!.imagePath = finalPath; + notifyListeners(); + } + // only update if different if (!this._downloads[fileKey]!.complete) { this._downloads[fileKey]!.timeEnd = DateTime.now(); @@ -308,4 +310,9 @@ class ProfileInfoState extends ChangeNotifier { } return prettyBytes((bytes / seconds).round()) + "/s"; } + + void waitForDownloadComplete(int identifier, String fileKey) { + _downloadTriggers[fileKey] = identifier; + notifyListeners(); + } } diff --git a/lib/models/profilelist.dart b/lib/models/profilelist.dart index 8cae18b5..e3766e01 100644 --- a/lib/models/profilelist.dart +++ b/lib/models/profilelist.dart @@ -28,7 +28,5 @@ class ProfileListState extends ChangeNotifier { notifyListeners(); } - int generateUnreadCount(String selectedProfile) => _profiles.where( (p) => p.onion != selectedProfile ).fold(0, (i, p) => i + p.unreadMessages); - - + int generateUnreadCount(String selectedProfile) => _profiles.where((p) => p.onion != selectedProfile).fold(0, (i, p) => i + p.unreadMessages); } diff --git a/lib/views/addeditprofileview.dart b/lib/views/addeditprofileview.dart index 9841712b..07b2968c 100644 --- a/lib/views/addeditprofileview.dart +++ b/lib/views/addeditprofileview.dart @@ -4,7 +4,9 @@ import 'dart:math'; import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch/cwtch.dart'; +import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/profile.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; @@ -66,6 +68,37 @@ class _AddEditProfileViewState extends State { }); } + void _showFilePicker(BuildContext ctx) async { + // only allow one file picker at a time + // note: ideally we would destroy file picker when leaving a conversation + // but we don't currently have that option. + // we need to store AppState in a variable because ctx might be destroyed + // while awaiting for pickFiles. + var appstate = Provider.of(ctx, listen: false); + appstate.disableFilePicker = true; + // currently lockParentWindow only works on Windows... + FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true); + appstate.disableFilePicker = false; + if (result != null && result.files.first.path != null) { + File file = File(result.files.first.path!); + // We have a maximum number of bytes we can represent in terms of + // a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25) + if (file.lengthSync() <= 10737418240) { + var profile = Provider.of(context, listen: false).onion; + // Share this image publically (conversation handle == -1) + Provider.of(context, listen: false).cwtch.ShareFile(profile, -1, file.path); + // update the image cache locally + Provider.of(context, listen: false).imagePath = file.path; + } else { + final snackBar = SnackBar( + content: Text(AppLocalizations.of(context)!.msgFileTooBig), + duration: Duration(seconds: 4), + ); + ScaffoldMessenger.of(ctx).showSnackBar(snackBar); + } + } + } + // A few implementation notes // We use Visibility to hide optional structures when they are not requested. // We used SizedBox for inter-widget height padding in columns, otherwise elements can render a little too close together. @@ -89,14 +122,20 @@ class _AddEditProfileViewState extends State { Visibility( visible: Provider.of(context).onion.isNotEmpty, child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - ProfileImage( - imagePath: Provider.of(context).imagePath, - diameter: 120, - maskOut: false, - border: theme.theme.portraitOnlineBorderColor, - badgeTextColor: Colors.red, - badgeColor: Colors.red, - ) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + _showFilePicker(context); + }, + child: ProfileImage( + imagePath: Provider.of(context).imagePath, + diameter: 120, + maskOut: false, + border: theme.theme.portraitOnlineBorderColor, + badgeTextColor: Colors.red, + badgeColor: Colors.red, + ))) ])), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel), diff --git a/lib/views/contactsview.dart b/lib/views/contactsview.dart index d48ffada..317a63b6 100644 --- a/lib/views/contactsview.dart +++ b/lib/views/contactsview.dart @@ -86,11 +86,10 @@ class _ContactsViewState extends State { Navigator.of(context).pop(); }, ), - StreamBuilder( stream: Provider.of(context).getUnreadProfileNotifyStream(), builder: (BuildContext context, AsyncSnapshot unreadCountSnapshot) { - int unreadCount = Provider.of(context).generateUnreadCount(Provider.of(context).selectedProfile ?? "") ; + int unreadCount = Provider.of(context).generateUnreadCount(Provider.of(context).selectedProfile ?? ""); return Visibility( visible: unreadCount > 0, diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index 577afb4f..feffa1d2 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -152,6 +152,7 @@ class MessageRowState extends State with SingleTickerProviderStateMi ]; } else { var contact = Provider.of(context); + ContactInfoState? sender = Provider.of(context).contactList.findContact(Provider.of(context).senderHandle); Widget wdgPortrait = GestureDetector( onTap: !isGroup ? null @@ -162,7 +163,8 @@ class MessageRowState extends State with SingleTickerProviderStateMi padding: EdgeInsets.all(4.0), child: ProfileImage( diameter: 48.0, - imagePath: Provider.of(context).senderImage ?? contact.imagePath, + // default to the contact image...otherwise use a derived sender image... + imagePath: sender?.imagePath ?? Provider.of(context).senderImage!, border: contact.status == "Authenticated" ? Provider.of(context).theme.portraitOnlineBorderColor : Provider.of(context).theme.portraitOfflineBorderColor, badgeTextColor: Colors.red, badgeColor: Colors.red, diff --git a/lib/widgets/profileimage.dart b/lib/widgets/profileimage.dart index f790a520..8cea2c9c 100644 --- a/lib/widgets/profileimage.dart +++ b/lib/widgets/profileimage.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:cwtch/themes/opaque.dart'; import 'package:provider/provider.dart'; @@ -23,8 +25,11 @@ class ProfileImage extends StatefulWidget { class _ProfileImageState extends State { @override Widget build(BuildContext context) { - var image = Image( - image: AssetImage("assets/" + widget.imagePath), + var file = new File(widget.imagePath); + var image = Image.file( + file, + cacheWidth: 512, + cacheHeight: 512, filterQuality: FilterQuality.medium, // We need some theme specific blending here...we might want to consider making this a theme level attribute colorBlendMode: !widget.maskOut @@ -36,6 +41,21 @@ class _ProfileImageState extends State { isAntiAlias: true, width: widget.diameter, height: widget.diameter, + errorBuilder: (context, error, stackTrace) { + // on android the above will fail for asset images, in which case try to load them the original way + return Image.asset(widget.imagePath, + filterQuality: FilterQuality.medium, + // We need some theme specific blending here...we might want to consider making this a theme level attribute + colorBlendMode: !widget.maskOut + ? Provider.of(context).theme.mode == mode_dark + ? BlendMode.softLight + : BlendMode.darken + : BlendMode.srcOut, + color: Provider.of(context).theme.portraitBackgroundColor, + isAntiAlias: true, + width: widget.diameter, + height: widget.diameter); + }, ); return RepaintBoundary( diff --git a/lib/widgets/profilerow.dart b/lib/widgets/profilerow.dart index 770dbe54..6808009d 100644 --- a/lib/widgets/profilerow.dart +++ b/lib/widgets/profilerow.dart @@ -105,11 +105,12 @@ class _ProfileRowState extends State { void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) { Provider.of(context, listen: false).reset(); Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) { + builder: (BuildContext bcontext) { + var profile = Provider.of(bcontext).profs.getProfile(onion)!; return MultiProvider( providers: [ - ChangeNotifierProvider( - create: (_) => ProfileInfoState(onion: onion, nickname: displayName, imagePath: profileImage, encrypted: encrypted), + ChangeNotifierProvider.value( + value: profile, ), ], builder: (context, widget) => AddEditProfileView(), diff --git a/lib/widgets/textfield.dart b/lib/widgets/textfield.dart index 793077ff..0a9ffa6d 100644 --- a/lib/widgets/textfield.dart +++ b/lib/widgets/textfield.dart @@ -8,7 +8,8 @@ doNothing(String x) {} // Provides a styled Text Field for use in Form Widgets. // Callers must provide a text controller, label helper text and a validator. class CwtchTextField extends StatefulWidget { - CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey}); + CwtchTextField( + {required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey}); final TextEditingController controller; final String hintText; final FormFieldValidator? validator;