From 8d49d83b3b45ab22d7b9d945c4b32296b07cb49e Mon Sep 17 00:00:00 2001 From: fiaxh Date: Fri, 14 Aug 2026 11:45:08 +0200 Subject: Add initial support for multi-file transfers --- libdino/meson.build | 4 + libdino/src/application.vala | 31 ++ libdino/src/entity/file_transfer_group.vala | 53 ++++ libdino/src/file_transfer/file_manager2.vala | 252 ++++++++++++++++ libdino/src/file_transfer/http_transfer.vala | 319 +++++++++++++++++++++ libdino/src/service/content_item_store.vala | 34 +++ libdino/src/service/database.vala | 30 +- libdino/src/service/file_manager.vala | 44 +-- libdino/src/service/file_transfer_storage.vala | 57 +++- libdino/src/service/stateless_file_sharing.vala | 177 +++++++----- main/data/style.css | 5 + main/meson.build | 1 + main/src/ui/application.vala | 10 + main/src/ui/chat_input/chat_input_controller.vala | 8 +- .../content_populator.vala | 2 + .../file_default_widget.vala | 40 +-- .../file_group_widget.vala | 120 ++++++++ .../file_image_widget.vala | 105 ++++--- .../file_transmission_progress.vala | 6 +- .../ui/conversation_content_view/file_widget.vala | 71 +---- .../conversation_selector_row.vala | 19 ++ main/src/ui/conversation_view.vala | 21 ++ main/src/ui/conversation_view_controller.vala | 4 - plugins/http-files/src/plugin.vala | 14 +- 24 files changed, 1181 insertions(+), 246 deletions(-) create mode 100644 libdino/src/entity/file_transfer_group.vala create mode 100644 libdino/src/file_transfer/file_manager2.vala create mode 100644 libdino/src/file_transfer/http_transfer.vala create mode 100644 main/src/ui/conversation_content_view/file_group_widget.vala diff --git a/libdino/meson.build b/libdino/meson.build index f83878e4..bf29dbf9 100644 --- a/libdino/meson.build +++ b/libdino/meson.build @@ -20,6 +20,7 @@ dependencies = [ dep_gio, dep_glib, dep_gmodule, + dep_libsoup, dep_qlite, dep_xmpp_vala ] @@ -33,8 +34,11 @@ sources = files( 'src/entity/conversation.vala', 'src/entity/encryption.vala', 'src/entity/file_transfer.vala', + 'src/entity/file_transfer_group.vala', 'src/entity/message.vala', 'src/entity/settings.vala', + 'src/file_transfer/file_manager2.vala', + 'src/file_transfer/http_transfer.vala', 'src/plugin/interfaces.vala', 'src/plugin/loader.vala', 'src/plugin/registry.vala', diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 8571120e..35b706a4 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -46,6 +46,7 @@ public interface Application : GLib.Application { AvatarManager.start(stream_interactor, db); RosterManager.start(stream_interactor, db); FileManager.start(stream_interactor, db); + FileManager2.start(stream_interactor, db); CallStore.start(stream_interactor, db); ContentItemStore.start(stream_interactor, db); ChatInteraction.start(stream_interactor); @@ -131,6 +132,36 @@ public interface Application : GLib.Application { stream_interactor.get_module(PresenceManager.IDENTITY).request_subscription(conversation.account, conversation.counterpart); }); add_action(accept_subscription_action); + + SimpleAction open_file_action = new SimpleAction("file_open_externally", VariantType.INT32); + open_file_action.activate.connect((variant) => { + int file_transfer_id = variant.get_int32(); + FileTransfer file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_id(file_transfer_id, null); + + try{ + AppInfo.launch_default_for_uri(file_transfer.get_file().get_uri(), null); + } catch (Error err) { + warning("Failed to open %s - %s", file_transfer.get_file().get_uri(), err.message); + } + }); + add_action(open_file_action); + + SimpleAction download_file_action = new SimpleAction("file_start_download", VariantType.INT32); + download_file_action.activate.connect((variant) => { + int file_transfer_id = variant.get_int32(); + FileTransfer file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_id(file_transfer_id, null); + + stream_interactor.get_module(FileManager.IDENTITY).download_file.begin(file_transfer); + }); + add_action(download_file_action); + + SimpleAction cancel_file_action = new SimpleAction("file_cancel_download", VariantType.INT32); + cancel_file_action.activate.connect((variant) => { + int file_transfer_id = variant.get_int32(); + FileTransfer file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_id(file_transfer_id, null); + file_transfer.cancellable.cancel(); + }); + add_action(cancel_file_action); } protected void add_connection(Account account) { diff --git a/libdino/src/entity/file_transfer_group.vala b/libdino/src/entity/file_transfer_group.vala new file mode 100644 index 00000000..94b9ccb6 --- /dev/null +++ b/libdino/src/entity/file_transfer_group.vala @@ -0,0 +1,53 @@ +using Xmpp; + +namespace Dino.Entities { + + public class FileTransferGroup : Object { + public Gee.ArrayList file_transfers = new Gee.ArrayList(); + + public int id { get; set; default=-1; } + public int message_id { get; set; default=-1; } + + private Database? db; + + public FileTransferGroup.from_row(Database db, Qlite.Row row, Gee.ArrayList file_transfers) throws InvalidJidError { + this.db = db; + this.file_transfers = file_transfers; + + id = row[db.file_transfer_group.id]; + message_id = row[db.file_transfer_group.message_id]; + + notify.connect(on_update); + } + + public void persist(Database db) { + if (id != -1) return; + + this.db = db; + + id = (int) db.file_transfer_group.insert() + .value(db.file_transfer_group.message_id, message_id) + .perform(); + + + Qlite.InsertBuilder builder = db.file_transfer_group_file_transfer.insert(); + foreach (FileTransfer file_transfer in file_transfers) { + db.file_transfer_group_file_transfer.insert() + .value(db.file_transfer_group_file_transfer.file_transfer_group_id, id) + .value(db.file_transfer_group_file_transfer.file_transfer_id, file_transfer.id) + .perform(); + } + + notify.connect(on_update); + } + + private void on_update(Object o, ParamSpec sp) { + Qlite.UpdateBuilder update_builder = db.file_transfer_group.update().with(db.file_transfer_group.id, "=", id); + switch (sp.name) { + case "message-id": + update_builder.set(db.file_transfer_group.message_id, message_id); break; + } + update_builder.perform(); + } + } +} \ No newline at end of file diff --git a/libdino/src/file_transfer/file_manager2.vala b/libdino/src/file_transfer/file_manager2.vala new file mode 100644 index 00000000..104a170e --- /dev/null +++ b/libdino/src/file_transfer/file_manager2.vala @@ -0,0 +1,252 @@ +using Gdk; +using Gee; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +namespace Dino { + + public class FileManager2 : StreamInteractionModule, Object { + public static ModuleIdentity IDENTITY = new ModuleIdentity("file2"); + public string id { get { return IDENTITY.id; } } + + public signal void received_file(FileTransfer file_group, Conversation conversation); + public signal void received_file_group(FileTransferGroup file_group, Conversation conversation); + + private StreamInteractor stream_interactor; + private Database db; + + public const int HTTP_PROVIDER_ID = 0; + public const int SFS_PROVIDER_ID = 2; + + public StatelessFileSharing sfs { + owned get { return stream_interactor.get_module(StatelessFileSharing.IDENTITY); } + private set {} + } + + public FileManager fm { + owned get { return stream_interactor.get_module(FileManager.IDENTITY); } + private set {} + } + + public HttpFileTransfers http_ft = null; + + public static void start(StreamInteractor stream_interactor, Database db) { + FileManager2 m = new FileManager2(stream_interactor, db); + stream_interactor.add_module(m); + } + + public static string get_storage_dir() { + return Path.build_filename(Dino.get_storage_dir(), "files"); + } + + private FileManager2(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + this.http_ft = new HttpFileTransfers(stream_interactor, db); + + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(new ReceivedMessageListener(this)); + } + + public async void send_files(Gee.List files, Conversation conversation) { + bool can_reference_element = conversation.type_ == Conversation.Type.CHAT || ( + // The stable stanza ID XEP is not clear about an announcing MUC having to attach stanza-ids, thus we also check for MAM, which requires this. + stream_interactor.get_module(EntityInfo.IDENTITY).has_feature_cached(conversation.account, conversation.counterpart, Xep.UniqueStableStanzaIDs.NS_URI) && + stream_interactor.get_module(EntityInfo.IDENTITY).has_feature_cached(conversation.account, conversation.counterpart, Xmpp.MessageArchiveManagement.NS_URI) + ); + + // Share unencrypted files via SFS (only if we'll be able to reference messages) + if (conversation.encryption == Encryption.NONE && can_reference_element) { + yield send_unencrypted_sfs(files, conversation); + } else { + foreach (File file in files) { + fm.send_file(file, conversation); + } + } + } + +// private async void send_file(File file, Conversation conversation) { +// FileTransfer file_transfer = yield create_init_outgoing_file_transfer(file, conversation); +// received_file(file_transfer, conversation); +// +// try { +// string file_name = file_transfer.file_name; +// int64 file_size = file_transfer.size; +// FileContentType? file_content_type = file_transfer.content_type; +// print(@"$(file_name) $(file_size) $(file_content_type.get_mime_type())\n"); +// +// Omemo.OmemoHttpFileMeta? encrypted_file_meta = null; +// +// if (conversation.encryption == Encryption.OMEMO) { +// encrypted_file_meta = omemo_file_encryption.encrypt_file(conversation, file_transfer); +//// file_name = encrypted_file_meta.file_name; +// file_size = encrypted_file_meta.size; +// file_content_type = encrypted_file_meta.content_type; +// } +// HttpFileSlot? upload_slot = yield http_ft.request_upload_slot(conversation.account, file_name, file_size, file_content_type); +// yield http_ft.upload(file_transfer, upload_slot.url_up, upload_slot.headers, file_size); +// +// string url_down = upload_slot.url_down; +// +// if (conversation.encryption == Encryption.OMEMO) { +// url_down = omemo_file_encryption.generate_aesgcm_url(upload_slot.url_down, encrypted_file_meta.iv, encrypted_file_meta.key); +// } +// +// // Update current upload progress in the FileTransfer +// LimitInputStream? limit_stream = file_transfer.input_stream as LimitInputStream; +// if (limit_stream == null) { +// limit_stream = new LimitInputStream(file_transfer.input_stream, file_size); +// file_transfer.input_stream = limit_stream; +// } +// if (limit_stream != null) { +// limit_stream.bind_property("retrieved-bytes", file_transfer, "transferred-bytes", BindingFlags.SYNC_CREATE); +// } +// +// file_transfer.state = FileTransfer.State.IN_PROGRESS; +// +// http_ft.send_http_file_message(conversation, file_transfer, url_down, true); // TODO true +// +// file_transfer.state = FileTransfer.State.COMPLETE; +// } catch (Error e) { +// warning("Send file error: %s", e.message); +// file_transfer.state = FileTransfer.State.FAILED; +// } +// } + + private async void send_unencrypted_sfs(Gee.List files, Conversation conversation) { + FileTransferGroup file_transfer_group = new FileTransferGroup(); + foreach (var file in files) { + var file_transfer = yield create_init_outgoing_file_transfer(file, conversation); + file_transfer_group.file_transfers.add(file_transfer); + } + + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file_group(file_transfer_group); + received_file_group(file_transfer_group, conversation); + + // TODO message-id not known here -> not cached by it + var sfs_message = yield sfs.announce_files(file_transfer_group, conversation); + + foreach (var file_transfer in file_transfer_group.file_transfers) { + upload_unencrypted_sfs_file.begin(file_transfer, conversation, sfs_message); + } + } + + private async void upload_unencrypted_sfs_file(FileTransfer file_transfer, Conversation conversation, Message sfs_message) { + var upload_slot_data = yield http_ft.request_upload_slot(conversation.account, file_transfer.server_file_name, file_transfer.size, file_transfer.content_type); + + // Upload file + yield http_ft.upload(file_transfer, upload_slot_data.url_up, upload_slot_data.headers, file_transfer.size); + + // Wait until we know the server id of the file share message (in MUCs; we get that from the reflected message) + if (conversation.type_.is_muc_semantic()) { + if (sfs_message.server_id == null) { + ulong server_id_notify_id = sfs_message.notify["server-id"].connect(() => { + Idle.add(upload_unencrypted_sfs_file.callback); + }); + yield; + sfs_message.disconnect(server_id_notify_id); + } + } + + file_transfer.sfs_sources.add(new Xep.StatelessFileSharing.HttpSource() { url=upload_slot_data.url_down } ); + + string attach_to_id = stream_interactor.get_module(MessageStorage.IDENTITY).get_reference_id(sfs_message, conversation); + sfs.attach_source(file_transfer, conversation, upload_slot_data.url_down, attach_to_id); + } + + private async FileTransfer? create_init_outgoing_file_transfer(File file, Conversation conversation) { + FileTransfer file_transfer = yield create_outgoing_file_transfer(file, conversation); + + try { + file_transfer.input_stream = yield file.read_async(); + yield save_file(file_transfer); + + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); + conversation.last_active = file_transfer.time; + + return file_transfer; + } catch (Error e) { + file_transfer.state = FileTransfer.State.FAILED; + warning("Error saving outgoing file: %s", e.message); + return null; + } + } + + private async FileTransfer create_outgoing_file_transfer(File file, Conversation conversation) { + FileTransfer file_transfer = new FileTransfer(); + file_transfer.account = conversation.account; + file_transfer.counterpart = conversation.counterpart; + if (conversation.type_.is_muc_semantic()) { + file_transfer.ourpart = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account) ?? conversation.account.bare_jid; + } else { + file_transfer.ourpart = conversation.account.full_jid; + } + file_transfer.direction = FileTransfer.DIRECTION_SENT; + file_transfer.time = new DateTime.now_utc(); + file_transfer.local_time = new DateTime.now_utc(); + file_transfer.encryption = conversation.encryption; + + Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); + foreach (FileMetadataProvider file_metadata_provider in fm.file_metadata_providers) { + if (file_metadata_provider.supports_file(file)) { + yield file_metadata_provider.fill_metadata(file, metadata); + } + } + file_transfer.file_metadata = metadata; + return file_transfer; + } + + public async void save_file(FileTransfer file_transfer) throws FileSendError { + try { + string filename = Random.next_int().to_string("%x") + "_" + file_transfer.file_name; + File file = File.new_for_path(Path.build_filename(get_storage_dir(), filename)); + OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); + yield os.splice_async(file_transfer.input_stream, OutputStreamSpliceFlags.CLOSE_SOURCE | OutputStreamSpliceFlags.CLOSE_TARGET); + file_transfer.state = FileTransfer.State.COMPLETE; + file_transfer.path = filename; + file_transfer.input_stream = new LimitInputStream(yield file.read_async(), file_transfer.size); + } catch (Error e) { + throw new FileSendError.SAVE_FAILED("Saving file error: %s".printf(e.message)); + } + } + + public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) { + if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true; + + Jid relevant_jid = conversation.counterpart; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(file_transfer.from, conversation.account); + } + if (relevant_jid == null) return false; + + bool in_roster = stream_interactor.get_module(RosterManager.IDENTITY).get_roster_item(conversation.account, relevant_jid) != null; + return in_roster; + } + + private class ReceivedMessageListener : MessageListener { + + public string[] after_actions_const = new string[]{ "STORE" }; + public override string action_group { get { return "MESSAGE_REINTERPRETING"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private FileManager2 outer; + private StreamInteractor stream_interactor; + + public ReceivedMessageListener(FileManager2 outer) { + this.outer = outer; + this.stream_interactor = outer.stream_interactor; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + bool is_sfs = yield outer.sfs.try_process_sfs(message, stanza, conversation); + if (is_sfs) return true; + + bool is_http = outer.http_ft.try_parse_http_transfer(message, stanza, conversation); + if (is_http) return true; + + return false; + } + } + } +} \ No newline at end of file diff --git a/libdino/src/file_transfer/http_transfer.vala b/libdino/src/file_transfer/http_transfer.vala new file mode 100644 index 00000000..ec97dfb3 --- /dev/null +++ b/libdino/src/file_transfer/http_transfer.vala @@ -0,0 +1,319 @@ +using Gee; + +using Dino.Entities; +using Xmpp; + +namespace Dino { + + public class HttpFileSlot : FileSendData { + public string url_down { get; set; } + public string url_up { get; set; } + public HashMap headers { get; set; } + } + + public class HttpFileTransfers : Object { + + private StreamInteractor stream_interactor; + private Dino.Database dino_db; + private Soup.Session session; + private HttpFileProvider http_file_provider; + private HttpFileSender http_file_sender; + public static Regex http_url_regex = /^https?:\/\/([^\s#]*)$/; // Spaces are invalid in URLs and we can't use fragments for downloads + public static Regex omemo_url_regex = /^aesgcm:\/\/(.*)#(([A-Fa-f0-9]{2}){48}|([A-Fa-f0-9]{2}){44})$/; + public HashMap max_file_sizes = new HashMap(Account.hash_func, Account.equals_func); + + public FileManager fm { + owned get { return stream_interactor.get_module(FileManager.IDENTITY); } + private set {} + } + + public HttpFileTransfers(StreamInteractor stream_interactor, Dino.Database dino_db) { + this.stream_interactor = stream_interactor; + this.dino_db = dino_db; + this.session = new Soup.Session(); + this.http_file_provider = new HttpFileProvider(this, stream_interactor); + this.http_file_sender = new HttpFileSender(stream_interactor, this); + fm.add_provider(http_file_provider); + fm.add_sender(http_file_sender); + + session.user_agent = @"Dino/$(Dino.get_short_version()) "; + + stream_interactor.stream_negotiated.connect(on_stream_negotiated); + } + + public HttpFileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + if (file_transfer.provider == FileManager.SFS_PROVIDER_ID) { + if (!file_transfer.sfs_sources.is_empty) { + var http_source = file_transfer.sfs_sources.get(0) as Xep.StatelessFileSharing.HttpSource; + if (http_source != null) { + var receive_data = new HttpFileReceiveData(); + receive_data.url = http_source.url; + return receive_data; + } + } + return null; + } + + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account); + if (conversation == null) return null; + + Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(int.parse(file_transfer.info), conversation); + if (message == null) return null; + + var receive_data = new HttpFileReceiveData(); + receive_data.url = message.body; + + return receive_data; + } + + public async InputStream download(FileTransfer file_transfer, HttpFileReceiveData receive_data, int64 file_size = -1) throws IOError { + var get_message = new Soup.Message("GET", receive_data.url); + + string transfer_host = Uri.parse(receive_data.url, UriFlags.NONE).get_host(); + get_message.accept_certificate.connect((peer_cert, errors) => { return ConnectionManager.on_invalid_certificate(transfer_host, peer_cert, errors); }); + InputStream stream = yield session.send_async(get_message, GLib.Priority.LOW, file_transfer.cancellable); + + if (file_size != -1) { + return new LimitInputStream(stream, file_size); + } else { + return stream; + } + } + + public bool try_parse_http_transfer(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + string? oob_url = Xmpp.Xep.OutOfBandData.get_url_from_message(stanza); + bool normal_file = oob_url != null && oob_url == message.body && HttpFileTransfers.http_url_regex.match(message.body); + bool omemo_file = HttpFileTransfers.omemo_url_regex.match(message.body); + if (normal_file || omemo_file) { + http_file_provider.on_file_message(message, conversation); + return true; + } + return false; + } + + public async HttpFileSlot? request_upload_slot(Account account, string file_name, int64 file_size, FileContentType file_content_type) throws FileSendError { + HttpFileSlot upload_slot = new HttpFileSlot(); + if (upload_slot == null) return null; + + Xmpp.XmppStream? stream = stream_interactor.get_stream(account); + if (stream == null) return null; + + try { + var slot_result = yield stream_interactor.module_manager.get_module(account, Xmpp.Xep.HttpFileUpload.Module.IDENTITY).request_slot(stream, file_name, file_size, file_content_type); + upload_slot.url_down = slot_result.url_get; + upload_slot.url_up = slot_result.url_put; + upload_slot.headers = slot_result.headers; + } catch (Xep.HttpFileUpload.HttpFileTransferError e) { + throw new FileSendError.UPLOAD_FAILED("Http file upload XMPP error: %s".printf(e.message)); + } + + return upload_slot; + } + + public async void upload(FileTransfer file_transfer, string upload_url, HashMap header_fields, int64 upload_size) throws FileSendError { + Xmpp.XmppStream? stream = stream_interactor.get_stream(file_transfer.account); + if (stream == null) return; + + var put_message = new Soup.Message("PUT", upload_url); + + string transfer_host = Uri.parse(upload_url, UriFlags.NONE).get_host(); + put_message.accept_certificate.connect((peer_cert, errors) => { return ConnectionManager.on_invalid_certificate(transfer_host, peer_cert, errors); }); + put_message.set_request_body(file_transfer.content_type.get_mime_type(), file_transfer.input_stream, (ssize_t) upload_size); + + foreach (var entry in header_fields) { + put_message.request_headers.append(entry.key, entry.value); + } + try { + yield session.send_async(put_message, GLib.Priority.LOW, file_transfer.cancellable); + + if (put_message.status_code < 200 || put_message.status_code >= 300) { + throw new FileSendError.UPLOAD_FAILED("HTTP status code %s".printf(put_message.status_code.to_string())); + } + } catch (Error e) { + throw new FileSendError.UPLOAD_FAILED("HTTP upload error: %s".printf(e.message)); + } + } + + /** @param use_conversation_encryption With OpenPGP, the file on the server is encrypted and the file transfer is safe even if the message is not encrypted. + * With OMEMO, the decryption key is part of the message, thus it has to be encrypted. + */ + public void send_http_file_message(Conversation conversation, FileTransfer file_transfer, string download_url, bool use_conversation_encryption = true) { + Entities.Message message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(download_url, conversation); + file_transfer.info = message.id.to_string(); + + message.encryption = use_conversation_encryption ? conversation.encryption : Encryption.NONE; + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(message, conversation); + } + + public async FileMeta request_meta_data(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError { + HttpFileReceiveData? http_receive_data = receive_data as HttpFileReceiveData; + if (http_receive_data == null) return file_meta; + + var head_message = new Soup.Message("HEAD", http_receive_data.url); + head_message.request_headers.append("Accept-Encoding", "identity"); + string transfer_host = Uri.parse(http_receive_data.url, UriFlags.NONE).get_host(); + head_message.accept_certificate.connect((peer_cert, errors) => { return ConnectionManager.on_invalid_certificate(transfer_host, peer_cert, errors); }); + + try { + yield session.send_async(head_message, GLib.Priority.LOW, null); + } catch (Error e) { + throw new FileReceiveError.GET_METADATA_FAILED("HEAD request failed"); + } + + string? content_type = null, content_length = null; + head_message.response_headers.foreach((name, val) => { + if (name.down() == "content-type") content_type = val; + if (name.down() == "content-length") content_length = val; + }); + file_meta.content_type = new FileContentType.from_mime_type(content_type); + if (content_length != null) { + file_meta.size = int64.parse(content_length); + } + + return file_meta; + } + + private void on_stream_negotiated(Account account, XmppStream stream) { + stream_interactor.module_manager.get_module(account, Xmpp.Xep.HttpFileUpload.Module.IDENTITY).feature_available.connect((stream, max_file_size) => { + max_file_sizes[account] = max_file_size; + http_file_sender.upload_available(account); + }); + } + + public string extract_file_name_from_url(string url) { + string ret = url; + if (ret.contains("#")) { + ret = ret.substring(0, ret.last_index_of("#")); + } + ret = Uri.unescape_string(ret.substring(ret.last_index_of("/") + 1)); + return ret; + } + } + + public class HttpFileProvider : Dino.FileProvider, Object { + + private HttpFileTransfers http_ft; + private StreamInteractor stream_interactor; + + public HttpFileProvider(HttpFileTransfers http_ft, StreamInteractor stream_interactor) { + this.http_ft = http_ft; + this.stream_interactor = stream_interactor; + } + + public async FileMeta get_meta_info(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError { + return yield http_ft.request_meta_data(file_transfer, receive_data, file_meta); + } + + public Encryption get_encryption(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) { + return Encryption.NONE; + } + + public async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws IOError { + return yield http_ft.download(file_transfer, (HttpFileReceiveData)receive_data, file_transfer.size); + } + + public FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError { + if (file_transfer.provider == FileManager.SFS_PROVIDER_ID) { + var file_meta = new HttpFileMeta(); + file_meta.size = file_transfer.size; + file_meta.content_type = file_transfer.content_type; + file_meta.file_name = file_transfer.file_name; + file_meta.message = null; + return file_meta; + } + + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account); + if (conversation == null) throw new FileReceiveError.GET_METADATA_FAILED("No conversation"); + + Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(int.parse(file_transfer.info), conversation); + if (message == null) throw new FileReceiveError.GET_METADATA_FAILED("No message"); + + var file_meta = new HttpFileMeta(); + file_meta.size = file_transfer.size; + file_meta.content_type = file_transfer.content_type; + + file_meta.file_name = http_ft.extract_file_name_from_url(message.body); + + file_meta.message = message; + + return file_meta; + } + + public FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + return http_ft.get_file_receive_data(file_transfer); + } + + public int get_id() { return 0; } + + public void on_file_message(Entities.Message message, Conversation conversation) { + var additional_info = message.id.to_string(); + + var receive_data = new HttpFileReceiveData(); + receive_data.url = message.body; + + var file_meta = new HttpFileMeta(); + file_meta.file_name = http_ft.extract_file_name_from_url(message.body); + file_meta.message = message; + + file_incoming(additional_info, message.from, message.time, message.local_time, conversation, receive_data, file_meta); + } + } + + public class HttpFileSender : FileSender, Object { + private StreamInteractor stream_interactor; + private HttpFileTransfers http_ft; + + public HttpFileSender(StreamInteractor stream_interactor, HttpFileTransfers http_ft) { + this.http_ft = http_ft; + } + + public async FileSendData? prepare_send_file(Conversation conversation, FileTransfer file_transfer, FileMeta file_meta) throws FileSendError { + HttpFileSendData send_data = new HttpFileSendData(); + if (send_data == null) return null; + + try { + var slot_result = yield http_ft.request_upload_slot(conversation.account, file_transfer.server_file_name, file_meta.size, file_transfer.content_type); + send_data.url_down = slot_result.url_down; + send_data.url_up = slot_result.url_up; + send_data.headers = slot_result.headers; + } catch (Xep.HttpFileUpload.HttpFileTransferError e) { + throw new FileSendError.UPLOAD_FAILED("Http file upload XMPP error: %s".printf(e.message)); + } + + return send_data; + } + + public async void send_file(Conversation conversation, FileTransfer file_transfer, FileSendData file_send_data, FileMeta file_meta) throws FileSendError { + HttpFileSendData http_file_send_data = file_send_data as HttpFileSendData; + yield http_ft.upload(file_transfer, http_file_send_data.url_up, http_file_send_data.headers, file_meta.size); + + http_ft.send_http_file_message(conversation, file_transfer, http_file_send_data.url_down, true); + } + + public async bool can_send(Conversation conversation, FileTransfer file_transfer) { + if (!http_ft.max_file_sizes.has_key(conversation.account)) return false; + + return file_transfer.size < http_ft.max_file_sizes[conversation.account]; + } + + public async long get_file_size_limit(Conversation conversation) { + long? max_size = http_ft.max_file_sizes[conversation.account]; + if (max_size != null) { + return max_size; + } + return -1; + } + + public async bool can_encrypt(Conversation conversation, FileTransfer file_transfer) { + return false; + } + + public async bool is_upload_available(Conversation conversation) { + return http_ft.max_file_sizes.has_key(conversation.account); + } + + public int get_id() { return 0; } + + public float get_priority() { return 100; } + } +} diff --git a/libdino/src/service/content_item_store.vala b/libdino/src/service/content_item_store.vala index d74f9b05..cf4c6975 100644 --- a/libdino/src/service/content_item_store.vala +++ b/libdino/src/service/content_item_store.vala @@ -26,6 +26,8 @@ public class ContentItemStore : StreamInteractionModule, Object { this.db = db; stream_interactor.get_module(FileManager.IDENTITY).received_file.connect(insert_file_transfer); + stream_interactor.get_module(FileManager2.IDENTITY).received_file.connect(insert_file_transfer); + stream_interactor.get_module(FileManager2.IDENTITY).received_file_group.connect(insert_file_group); stream_interactor.get_module(MessageProcessor.IDENTITY).message_received.connect(announce_message); stream_interactor.get_module(MessageProcessor.IDENTITY).message_sent.connect(announce_message); stream_interactor.get_module(Calls.IDENTITY).call_incoming.connect(insert_call); @@ -88,6 +90,13 @@ public class ContentItemStore : StreamInteractionModule, Object { return call_item; } break; + case 4: + FileTransferGroup? file_group = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_group_by_id(foreign_id, conversation); + if (file_group != null) { + var file_group_item = new FileGroupItem(file_group, conversation, id); + return file_group_item; + } + break; default: warning("Unknown content item type: %i", content_type); break; @@ -300,6 +309,16 @@ public class ContentItemStore : StreamInteractionModule, Object { new_item(item, conversation); } + private void insert_file_group(FileTransferGroup file_group, Conversation conversation) { + FileGroupItem item = new FileGroupItem(file_group, conversation, file_group.id); + // TODO fix local time + item.id = db.add_content_item(conversation, item.time, item.time, 4, file_group.id, false); + if (collection_conversations.has_key(conversation)) { + collection_conversations.get(conversation).insert_item(item); + } + new_item(item, conversation); + } + private void insert_call(Call call, CallState call_state, Conversation conversation) { CallItem item = new CallItem(call, conversation, -1); item.id = db.add_content_item(conversation, call.time, call.local_time, 3, call.id, false); @@ -415,6 +434,21 @@ public class FileItem : ContentItem { } } +public class FileGroupItem : ContentItem { + public const string TYPE = "file_group"; + + public FileTransferGroup file_group; + public Conversation conversation; + + public FileGroupItem(FileTransferGroup file_group, Conversation conversation, int id) { + var file_transfer = file_group.file_transfers.get(0); + base(id, TYPE, file_transfer.from, file_transfer.time, file_transfer.encryption, Message.Marked.NONE); + + this.file_group = file_group; + this.conversation = conversation; + } +} + public class CallItem : ContentItem { public const string TYPE = "call"; diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index b38ccfca..8432ff40 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 33; + private const int VERSION = 34; public class AccountTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -225,6 +225,28 @@ public class Database : Qlite.Database { } } + public class FileTransferGroupTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column message_id = new Column.Integer("message_id"); + + internal FileTransferGroupTable(Database db) { + base(db, "file_transfer_group"); + init({id, message_id}); + index("file_transfer_group_message_id_idx", {message_id}); + } + } + + public class FileTransferGroupFileTransferTable : Table { + public Column file_transfer_group_id = new Column.Integer("file_transfer_group_id"); + public Column file_transfer_id = new Column.Integer("file_transfer_id"); + + internal FileTransferGroupFileTransferTable(Database db) { + base(db, "file_transfer_group_file_transfer"); + init({file_transfer_group_id, file_transfer_id}); + index("file_transfer_group_file_transfer_id_idx", {file_transfer_group_id}); + } + } + public class FileHashesTable : Table { public Column id = new Column.Integer("id"); public Column algo = new Column.Text("algo") { not_null = true }; @@ -489,6 +511,8 @@ public class Database : Qlite.Database { public RealJidTable real_jid { get; private set; } public OccupantIdTable occupantid { get; private set; } public FileTransferTable file_transfer { get; private set; } + public FileTransferGroupTable file_transfer_group { get; private set; } + public FileTransferGroupFileTransferTable file_transfer_group_file_transfer { get; private set; } public FileHashesTable file_hashes { get; private set; } public FileThumbnailsTable file_thumbnails { get; private set; } public SourcesTable sfs_sources { get; private set; } @@ -525,6 +549,8 @@ public class Database : Qlite.Database { occupantid = new OccupantIdTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); + file_transfer_group = new FileTransferGroupTable(this); + file_transfer_group_file_transfer = new FileTransferGroupFileTransferTable(this); file_hashes = new FileHashesTable(this); file_thumbnails = new FileThumbnailsTable(this); sfs_sources = new SourcesTable(this); @@ -542,7 +568,7 @@ public class Database : Qlite.Database { settings = new SettingsTable(this); account_settings = new AccountSettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, message_occupant_id, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, file_hashes, file_thumbnails, sfs_sources, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, muc_affiliation, muc_affiliation_version, settings, account_settings, conversation_settings }); + init({ account, jid, entity, content_item, message, message_occupant_id, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, file_transfer_group, file_transfer_group_file_transfer, file_hashes, file_thumbnails, sfs_sources, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, muc_affiliation, muc_affiliation_version, settings, account_settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala index 964278a5..60b7fafa 100644 --- a/libdino/src/service/file_manager.vala +++ b/libdino/src/service/file_manager.vala @@ -16,17 +16,22 @@ public class FileManager : StreamInteractionModule, Object { private StreamInteractor stream_interactor; private Database db; - private Gee.List file_senders = new ArrayList(); + public Gee.List file_senders = new ArrayList(); private Gee.List file_encryptors = new ArrayList(); - private Gee.List file_decryptors = new ArrayList(); + public Gee.List file_decryptors = new ArrayList(); private Gee.List file_providers = new ArrayList(); - private Gee.List file_metadata_providers = new ArrayList(); + public Gee.List file_metadata_providers = new ArrayList(); public StatelessFileSharing sfs { owned get { return stream_interactor.get_module(StatelessFileSharing.IDENTITY); } private set { } } + public FileManager2 fm2 { + owned get { return stream_interactor.get_module(FileManager2.IDENTITY); } + private set { } + } + public static void start(StreamInteractor stream_interactor, Database db) { FileManager m = new FileManager(stream_interactor, db); stream_interactor.add_module(m); @@ -97,7 +102,7 @@ public class FileManager : StreamInteractionModule, Object { try { file_transfer.input_stream = yield file.read_async(); - yield save_file(file_transfer); + yield fm2.save_file(file_transfer); stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); conversation.last_active = file_transfer.time; @@ -151,7 +156,7 @@ public class FileManager : StreamInteractionModule, Object { file_transfer.state = FileTransfer.State.IN_PROGRESS; - // Update current download progress in the FileTransfer + // Update current upload progress in the FileTransfer LimitInputStream? limit_stream = file_transfer.input_stream as LimitInputStream; if (limit_stream == null) { limit_stream = new LimitInputStream(file_transfer.input_stream, file_meta.size); @@ -216,19 +221,6 @@ public class FileManager : StreamInteractionModule, Object { file_metadata_providers.add(file_metadata_provider); } - public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) { - if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true; - - Jid relevant_jid = conversation.counterpart; - if (conversation.type_ == Conversation.Type.GROUPCHAT) { - relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(file_transfer.from, conversation.account); - } - if (relevant_jid == null) return false; - - bool in_roster = stream_interactor.get_module(RosterManager.IDENTITY).get_roster_item(conversation.account, relevant_jid) != null; - return in_roster; - } - private async FileMeta get_file_meta(FileProvider file_provider, FileTransfer file_transfer, Conversation conversation, FileReceiveData receive_data_) throws FileReceiveError { FileReceiveData receive_data = receive_data_; FileMeta file_meta = file_provider.get_file_meta(file_transfer); @@ -390,7 +382,7 @@ public class FileManager : StreamInteractionModule, Object { FileTransfer file_transfer = create_file_transfer_from_provider_incoming(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); - if (is_sender_trustworthy(file_transfer, conversation)) { + if (fm2.is_sender_trustworthy(file_transfer, conversation)) { try { yield get_file_meta(file_provider, file_transfer, conversation, receive_data); } catch (Error e) { @@ -408,20 +400,6 @@ public class FileManager : StreamInteractionModule, Object { received_file(file_transfer, conversation); } - private async void save_file(FileTransfer file_transfer) throws FileSendError { - try { - string filename = Random.next_int().to_string("%x") + "_" + file_transfer.file_name; - File file = File.new_for_path(Path.build_filename(get_storage_dir(), filename)); - OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); - yield os.splice_async(file_transfer.input_stream, OutputStreamSpliceFlags.CLOSE_SOURCE | OutputStreamSpliceFlags.CLOSE_TARGET); - file_transfer.state = FileTransfer.State.COMPLETE; - file_transfer.path = filename; - file_transfer.input_stream = new LimitInputStream(yield file.read_async(), file_transfer.size); - } catch (Error e) { - throw new FileSendError.SAVE_FAILED("Saving file error: %s".printf(e.message)); - } - } - public Message? get_message_for_file_transfer(FileTransfer file_transfer, Conversation conversation) { if ((file_transfer.provider == 0 || file_transfer.provider == 2) && file_transfer.info != null) { return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(int.parse(file_transfer.info), conversation); diff --git a/libdino/src/service/file_transfer_storage.vala b/libdino/src/service/file_transfer_storage.vala index 64bb6b81..b4a4db3e 100644 --- a/libdino/src/service/file_transfer_storage.vala +++ b/libdino/src/service/file_transfer_storage.vala @@ -15,6 +15,8 @@ namespace Dino { private WeakMap files_by_db_id = new WeakMap(); private WeakMap files_by_message_id = new WeakMap(); + private WeakMap file_group_by_db_id = new WeakMap(); + private WeakMap file_group_by_message_id = new WeakMap(); private WeakMap files_by_message_and_file_id = new WeakMap(); public static void start(StreamInteractor stream_interactor, Database db) { @@ -32,7 +34,13 @@ namespace Dino { cache_file(file_transfer); } - public FileTransfer? get_file_by_id(int id, Conversation conversation) { + public void add_file_group(FileTransferGroup file_group) { + file_group.persist(db); + cache_file_group(file_group); + } + + // `conversation` should be provided, otherwise the FileTransfer.ourpart might be wrong + public FileTransfer? get_file_by_id(int id, Conversation? conversation) { FileTransfer? file_transfer = files_by_db_id[id]; if (file_transfer != null) { return file_transfer; @@ -58,6 +66,29 @@ namespace Dino { return create_file_from_row_opt(row_option, conversation); } + public FileTransferGroup? get_file_group_by_id(int id, Conversation conversation) { + FileTransferGroup? file_group = file_group_by_db_id[id]; + if (file_group != null) { + return file_group; + } + + RowOption row_option = db.file_transfer_group.select().with(db.file_transfer_group.id, "=", id).row(); + return row_option.is_present() ? create_file_group_from_row(row_option.inner, conversation) : null; + } + + public FileTransferGroup get_file_group_by_message_id(int id, Conversation conversation) { + FileTransferGroup? file_group = file_group_by_message_id[id]; + if (file_group != null) { + return file_group; + } + + foreach (Row row in db.file_transfer_group.select().with(db.file_transfer_group.message_id, "=", id)) { + return create_file_group_from_row(row, conversation); + } + + return null; + } + public FileTransfer get_files_by_message_and_file_id(int message_id, string file_sharing_id, Conversation conversation) { string combined_identifier = message_id.to_string() + file_sharing_id; FileTransfer? file_transfer = files_by_message_and_file_id[combined_identifier]; @@ -79,13 +110,13 @@ namespace Dino { return null; } - private FileTransfer? create_file_from_row_opt(RowOption row_opt, Conversation conversation) { + private FileTransfer? create_file_from_row_opt(RowOption row_opt, Conversation? conversation) { if (!row_opt.is_present()) return null; try { FileTransfer file_transfer = new FileTransfer.from_row(db, row_opt.inner, FileManager.get_storage_dir()); - if (conversation.type_.is_muc_semantic()) { + if (conversation != null && conversation.type_.is_muc_semantic()) { file_transfer.ourpart = conversation.counterpart.with_resource(file_transfer.ourpart.resourcepart); } @@ -97,6 +128,21 @@ namespace Dino { return null; } + private FileTransferGroup? create_file_group_from_row(Row file_transfer_group_row, Conversation conversation) { + QueryBuilder query = db.file_transfer_group_file_transfer.select() + .with(db.file_transfer_group_file_transfer.file_transfer_group_id, "=", file_transfer_group_row[db.file_transfer_group.id]); + var file_transfers = new ArrayList(); + foreach (Qlite.Row row in query) { + FileTransfer file_transfer = get_file_by_id(row[db.file_transfer_group_file_transfer.file_transfer_id], conversation); + file_transfers.add(file_transfer); + } + if (file_transfers.size == 0) return null; + + FileTransferGroup file_group = new FileTransferGroup.from_row(db, file_transfer_group_row, file_transfers); + cache_file_group(file_group); + return file_group; + } + private void cache_file(FileTransfer file_transfer) { files_by_db_id[file_transfer.id] = file_transfer; @@ -109,5 +155,10 @@ namespace Dino { } } } + + private void cache_file_group(FileTransferGroup file_group) { + file_group_by_db_id[file_group.id] = file_group; +// file_group_by_message_id[file_group.message_id] = file_group; + } } } \ No newline at end of file diff --git a/libdino/src/service/stateless_file_sharing.vala b/libdino/src/service/stateless_file_sharing.vala index 15e1ddfe..6e97a5ce 100644 --- a/libdino/src/service/stateless_file_sharing.vala +++ b/libdino/src/service/stateless_file_sharing.vala @@ -16,11 +16,16 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { private set { } } - public FileManager file_manager { + public FileManager fm { owned get { return stream_interactor.get_module(FileManager.IDENTITY); } private set { } } + public FileManager2 fm2 { + owned get { return stream_interactor.get_module(FileManager2.IDENTITY); } + private set { } + } + public Database db { owned get { return Application.get_default().db; } private set { } @@ -30,7 +35,7 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { this.stream_interactor = stream_interactor; this.db = db; - stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(new ReceivedMessageListener(this)); + stream_interactor.get_module(MessageProcessor.IDENTITY).build_message_stanza.connect(check_add_sfs_element); } public static void start(StreamInteractor stream_interactor, Database db) { @@ -38,7 +43,81 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { stream_interactor.add_module(m); } - public async void create_file_transfer(Conversation conversation, Message message, string? file_sharing_id, Xep.FileMetadataElement.FileMetadata metadata, Gee.List? sources) { + public async Entities.Message announce_files(FileTransferGroup file_group, Conversation conversation) { + Entities.Message file_share_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(null, conversation); + stream_interactor.get_module(MessageStorage.IDENTITY).add_message(file_share_message, conversation); + file_group.message_id = file_share_message.id; + + foreach (var file_transfer in file_group.file_transfers) { + file_transfer.provider = SFS_PROVIDER_ID; + file_transfer.info = file_share_message.id.to_string(); + file_transfer.file_sharing_id = Xmpp.random_uuid(); + } + + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(file_share_message, conversation); + + return file_share_message; + } + + public async void attach_source(FileTransfer file_transfer, Conversation conversation, string http_url, string to_sfs_id) { + MessageStanza stanza = new MessageStanza() { to = conversation.counterpart, type_ = conversation.type_ == GROUPCHAT ? MessageStanza.TYPE_GROUPCHAT : MessageStanza.TYPE_CHAT }; + stanza.body = http_url; + Xep.OutOfBandData.add_url_to_message(stanza, http_url); + var sources = new ArrayList(); + sources.add(new Xep.StatelessFileSharing.HttpSource() { url = http_url }); + Xep.StatelessFileSharing.set_sfs_attachment(stanza, to_sfs_id, file_transfer.file_sharing_id, sources); + + var stream = stream_interactor.get_stream(conversation.account); + if (stream == null) throw new FileSendError.UPLOAD_FAILED("No stream"); + + stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, stanza); + } + + public async bool try_process_sfs(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Gee.List file_shares = Xep.StatelessFileSharing.get_file_shares(stanza); + if (file_shares != null) { + // For now, only accept file shares that have at least one supported hash + foreach (Xep.StatelessFileSharing.FileShare file_share in file_shares) { + if (!Xep.CryptographicHashes.has_supported_hashes(file_share.metadata.hashes)) { + return false; + } + } + + if (file_shares.size > 0) { + var file_transfers = new ArrayList(); + foreach (Xep.StatelessFileSharing.FileShare file_share in file_shares) { + var file_transfer = yield create_incoming_file_transfer(conversation, message, file_share.id, file_share.metadata, file_share.sources); + file_transfers.add(file_transfer); + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); + } + var file_group = new FileTransferGroup() { + message_id = message.id, + file_transfers = file_transfers + }; + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file_group(file_group); + + fm2.received_file_group(file_group, conversation); + } + return true; + } + + var source_attachments = Xep.StatelessFileSharing.get_source_attachments(stanza); + if (source_attachments != null) { + foreach (var source_attachment in source_attachments) { + on_received_sources(stanza.from, conversation, source_attachment.to_message_id, source_attachment.to_file_transfer_id, source_attachment.sources); + return true; + } + } + + // Don't process messages that are fallback for legacy clients + if (Xep.StatelessFileSharing.is_sfs_fallback_message(stanza)) { + return true; + } + + return false; + } + + public async FileTransfer create_incoming_file_transfer(Conversation conversation, Message message, string? file_sharing_id, Xep.FileMetadataElement.FileMetadata metadata, Gee.List? sources) { FileTransfer file_transfer = new FileTransfer(); file_transfer.file_sharing_id = file_sharing_id; file_transfer.account = message.account; @@ -57,7 +136,8 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); conversation.last_active = file_transfer.time; - file_manager.received_file(file_transfer, conversation); + + return file_transfer; } public void on_received_sources(Jid from, Conversation conversation, string attach_to_message_id, string? attach_to_file_id, Gee.List sources) { @@ -74,9 +154,9 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { // "If no is provided or the elements provided use unsupported algorithms, receiving clients MUST ignore // any attached sources from other senders and only obtain the file from the sources announced by the original sender." - // For now we only allow the original sender - if (from.equals(file_transfer.from) && Xep.CryptographicHashes.get_supported_hashes(file_transfer.hashes).is_empty) { - warning("Ignoring sfs source: Not from original sender or no known file hashes"); + // For now we don't allow source attachments for file shares without hashes. TODO extend this + if (Xep.CryptographicHashes.get_supported_hashes(file_transfer.hashes).is_empty) { + warning("Ignoring sfs source: No known file hashes"); return; } @@ -84,85 +164,26 @@ public class Dino.StatelessFileSharing : StreamInteractionModule, Object { file_transfer.add_sfs_source(source); } - if (file_manager.is_sender_trustworthy(file_transfer, conversation) && file_transfer.state == FileTransfer.State.NOT_STARTED && file_transfer.size >= 0 && file_transfer.size < 5000000) { - file_manager.download_file(file_transfer); + if (fm2.is_sender_trustworthy(file_transfer, conversation) && file_transfer.state == FileTransfer.State.NOT_STARTED && file_transfer.size >= 0 && file_transfer.size < 5000000) { + fm.download_file(file_transfer); } } - /* - public async void create_sfs_for_legacy_transfer(FileProvider file_provider, string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { - FileTransfer file_transfer = file_manager.create_file_transfer_from_provider_incoming(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); - - HttpFileReceiveData? http_receive_data = receive_data as HttpFileReceiveData; - if (http_receive_data == null) return; + private void check_add_sfs_element(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { + if (message.encryption != Encryption.NONE) return; - var sources = new ArrayList(); - Xep.StatelessFileSharing.HttpSource source = new Xep.StatelessFileSharing.HttpSource(); - source.url = http_receive_data.url; - sources.add(source); - - if (file_manager.is_jid_trustworthy(from, conversation)) { - try { - file_meta = yield file_provider.get_meta_info(file_transfer, http_receive_data, file_meta); - } catch (Error e) { - warning("Http meta request failed: %s", e.message); + // Check if it's a FileTransferGroup + FileTransferGroup? file_group = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_group_by_message_id(message.id, conversation); + if (file_group != null) { + print("check added file group\n"); + foreach (FileTransfer file_transfer in file_group.file_transfers) { + Xep.StatelessFileSharing.set_sfs_element(message_stanza, file_transfer.file_sharing_id, file_transfer.file_metadata, file_transfer.sfs_sources); } - } - - var metadata = new Xep.FileMetadataElement.FileMetadata(); - metadata.size = file_meta.size; - metadata.name = file_meta.file_name; - metadata.mime_type = file_meta.mime_type; - file_transfer.provider = SFS_PROVIDER_ID; - file_transfer.file_metadata = metadata; - file_transfer.sfs_sources = sources; - } - */ - - private class ReceivedMessageListener : MessageListener { + Xep.MessageProcessingHints.set_message_hint(message_stanza, Xep.MessageProcessingHints.HINT_STORE); - public string[] after_actions_const = new string[]{ "STORE" }; - public override string action_group { get { return "MESSAGE_REINTERPRETING"; } } - public override string[] after_actions { get { return after_actions_const; } } - - private StatelessFileSharing outer; - private StreamInteractor stream_interactor; - - public ReceivedMessageListener(StatelessFileSharing outer) { - this.outer = outer; - this.stream_interactor = outer.stream_interactor; + return; } - public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - Gee.List file_shares = Xep.StatelessFileSharing.get_file_shares(stanza); - if (file_shares != null) { - // For now, only accept file shares that have at least one supported hash - foreach (Xep.StatelessFileSharing.FileShare file_share in file_shares) { - if (!Xep.CryptographicHashes.has_supported_hashes(file_share.metadata.hashes)) { - return false; - } - } - foreach (Xep.StatelessFileSharing.FileShare file_share in file_shares) { - outer.create_file_transfer(conversation, message, file_share.id, file_share.metadata, file_share.sources); - } - return true; - } - - var source_attachments = Xep.StatelessFileSharing.get_source_attachments(stanza); - if (source_attachments != null) { - foreach (var source_attachment in source_attachments) { - outer.on_received_sources(stanza.from, conversation, source_attachment.to_message_id, source_attachment.to_file_transfer_id, source_attachment.sources); - return true; - } - } - - // Don't process messages that are fallback for legacy clients - if (Xep.StatelessFileSharing.is_sfs_fallback_message(stanza)) { - return true; - } - - return false; - } } } \ No newline at end of file diff --git a/main/data/style.css b/main/data/style.css index 1700a624..2c6d3d03 100644 --- a/main/data/style.css +++ b/main/data/style.css @@ -188,6 +188,11 @@ window.dino-main .file-image-widget .file-box-outer button:hover { border-radius: 100%; } +/* Image file group */ +.dino-main .image-group { + border-radius: 8px; +} + /* Call widget */ window.dino-main .call-box-outer.incoming { diff --git a/main/meson.build b/main/meson.build index 38c1a733..5b1aab08 100644 --- a/main/meson.build +++ b/main/meson.build @@ -49,6 +49,7 @@ sources = files( 'src/ui/conversation_content_view/file_default_widget.vala', 'src/ui/conversation_content_view/file_image_widget.vala', 'src/ui/conversation_content_view/file_transmission_progress.vala', + 'src/ui/conversation_content_view/file_group_widget.vala', 'src/ui/conversation_content_view/file_widget.vala', 'src/ui/conversation_content_view/item_actions.vala', 'src/ui/conversation_content_view/message_widget.vala', diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 1b4dc049..62495871 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -244,6 +244,16 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { call_state.reject(); }); add_action(deny_call_action); + + SimpleAction save_file_action = new SimpleAction("file_open_save_dialog", VariantType.INT32); + save_file_action.activate.connect((variant) => { + print("action open save dialog\n"); + int file_transfer_id = variant.get_int32(); + FileTransfer? file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_id(file_transfer_id, null); + + window.conversation_view.open_save_file_dialog(file_transfer); + }); + add_action(save_file_action); } private void show_preferences_window() { diff --git a/main/src/ui/chat_input/chat_input_controller.vala b/main/src/ui/chat_input/chat_input_controller.vala index c93a9e22..afbef637 100644 --- a/main/src/ui/chat_input/chat_input_controller.vala +++ b/main/src/ui/chat_input/chat_input_controller.vala @@ -245,11 +245,11 @@ public class ChatInputController : Object { send_text(); } - foreach (File file in files_to_send) { - stream_interactor.get_module(FileManager.IDENTITY).send_file.begin(file, conversation); + if (!files_to_send.is_empty) { + stream_interactor.get_module(FileManager2.IDENTITY).send_files.begin(files_to_send, conversation); + files_to_send = new ArrayList(); + chat_input.clear_files(); } - files_to_send = new ArrayList(); - chat_input.clear_files(); } private void on_text_input_changed() { diff --git a/main/src/ui/conversation_content_view/content_populator.vala b/main/src/ui/conversation_content_view/content_populator.vala index ef859bde..4d1b8c90 100644 --- a/main/src/ui/conversation_content_view/content_populator.vala +++ b/main/src/ui/conversation_content_view/content_populator.vala @@ -70,6 +70,8 @@ public class ContentProvider : ContentItemCollection, Object { return new FileMetaItem(content_item, stream_interactor); } else if (content_item.type_ == CallItem.TYPE) { return new CallMetaItem(content_item, stream_interactor); + } else if (content_item.type_ == FileGroupItem.TYPE) { + return new FileGroupMetaItem(content_item, stream_interactor); } critical("Got unknown content item type %s", content_item.type_); return null; diff --git a/main/src/ui/conversation_content_view/file_default_widget.vala b/main/src/ui/conversation_content_view/file_default_widget.vala index a21e2afb..1ad3d6c2 100644 --- a/main/src/ui/conversation_content_view/file_default_widget.vala +++ b/main/src/ui/conversation_content_view/file_default_widget.vala @@ -51,13 +51,13 @@ public class FileDefaultWidget : Box { mime_label.label = content_type != null ? content_type.get_description() : null; } - public void update_file_info(Xmpp.FileContentType? content_type, FileTransfer.State state, bool direction, int64 size, int64 transferred_bytes) { - this.state = state; + public void update_file_info(FileTransfer file_transfer) { + this.state = file_transfer.state; spinner.stop(); // A hidden spinning spinner still uses CPU. Deactivate asap - content_type_image.icon_name = get_file_icon_name(content_type); - string? mime_description = content_type != null ? content_type.get_description() : null; + content_type_image.icon_name = get_file_icon_name(file_transfer.content_type); + string? mime_description = file_transfer.content_type != null ? file_transfer.content_type.get_description() : null; switch (state) { case FileTransfer.State.COMPLETE: @@ -66,23 +66,31 @@ public class FileDefaultWidget : Box { // Create a menu Menu menu_model = new Menu(); - menu_model.append(_("Open"), "file.open"); - menu_model.append(_("Save as…"), "file.save_as"); + + MenuItem open_file_item = new MenuItem(_("Open"), "app.file_open_externally"); + open_file_item.set_action_and_target_value("app.file_open_externally", new Variant.int32(file_transfer.id)); + + MenuItem save_as_item = new MenuItem(_("Save as…"), "app.file_save_as"); + save_as_item.set_action_and_target_value("app.file_open_save_dialog", new Variant.int32(file_transfer.id)); + + menu_model.append_item(open_file_item); + menu_model.append_item(save_as_item); + Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model); file_menu.popover = popover_menu; popover_menu.closed.connect(on_pointer_left); break; case FileTransfer.State.IN_PROGRESS: - if (direction == FileTransfer.DIRECTION_RECEIVED) { - if (size > 0) { - int64 progress = transferred_bytes * 100 / size; - mime_label.label = _("Downloading %s… (%u%%)").printf(get_size_string(size), progress); + if (file_transfer.direction == FileTransfer.DIRECTION_RECEIVED) { + if (file_transfer.size > 0) { + int64 progress = file_transfer.transferred_bytes * 100 / file_transfer.size; + mime_label.label = _("Downloading %s… (%u%%)").printf(get_size_string(file_transfer.size), progress); } else { - mime_label.label = _("Downloading %s…").printf(get_size_string(size)); + mime_label.label = _("Downloading %s…").printf(get_size_string(file_transfer.size)); } } else { - int64 progress = transferred_bytes * 100 / size; - mime_label.label = _("Uploading %s… (%u%%)").printf(get_size_string(size), progress); + int64 progress = file_transfer.transferred_bytes * 100 / file_transfer.size; + mime_label.label = _("Uploading %s… (%u%%)").printf(get_size_string(file_transfer.size), progress); } spinner.start(); image_stack.set_visible_child_name("spinner"); @@ -96,9 +104,9 @@ public class FileDefaultWidget : Box { break; case FileTransfer.State.NOT_STARTED: if (mime_description != null) { - mime_label.label = _("%s offered: %s").printf(mime_description, get_size_string(size)); - } else if (size != -1) { - mime_label.label = _("File offered: %s").printf(get_size_string(size)); + mime_label.label = _("%s offered: %s").printf(mime_description, get_size_string(file_transfer.size)); + } else if (file_transfer.size != -1) { + mime_label.label = _("File offered: %s").printf(get_size_string(file_transfer.size)); } else { mime_label.label = _("File offered"); } diff --git a/main/src/ui/conversation_content_view/file_group_widget.vala b/main/src/ui/conversation_content_view/file_group_widget.vala new file mode 100644 index 00000000..80c41a7a --- /dev/null +++ b/main/src/ui/conversation_content_view/file_group_widget.vala @@ -0,0 +1,120 @@ +using Gee; +using Gdk; +using Gtk; +using Pango; +using Xmpp; + +using Dino.Entities; + +namespace Dino.Ui { + + public class FileGroupMetaItem : ConversationSummary.ContentMetaItem { + + private StreamInteractor stream_interactor; + private FileGroupItem file_group_item; + + public FileGroupMetaItem(ContentItem content_item, StreamInteractor stream_interactor) { + base(content_item); + this.stream_interactor = stream_interactor; + this.file_group_item = content_item as FileGroupItem; + } + + public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) { + var file_transfers = file_group_item.file_group.file_transfers; + + if (file_transfers.size > 1 && all_files_images(file_transfers)) { + return new ImageGroupWidget(file_transfers); + } else { + Box box = new Box(Orientation.VERTICAL, 6); + foreach (FileTransfer file_transfer in file_transfers) { + FileWidget widget = new FileWidget(file_transfer) { halign = Align.START }; + widget.add_css_class("file-image-widget"); + FileWidgetController widget_controller = new FileWidgetController(widget, file_transfer, stream_interactor); + box.append(widget); + } + return box; + } + } + + public override Gee.List? get_item_actions(Plugins.WidgetType type) { + Gee.List actions = new ArrayList(); + return actions; + } + } + + public class ImageGroupWidget : Box { + + private Gee.ArrayList file_transfers; + + public ImageGroupWidget(Gee.ArrayList file_transfers) { + this.file_transfers = file_transfers; + + if (file_transfers.size == 3) { + Box box = new Box(Orientation.HORIZONTAL, 4) { + overflow = Overflow.HIDDEN, + halign = Align.START + }; + box.add_css_class("image-group"); + + var picture1 = new FileImageWidget.from_file_transfer(file_transfers[0], 200); + box.append(picture1); + + Box box1 = new Box(Orientation.VERTICAL, 4); + + var picture2 = new FileImageWidget.from_file_transfer(file_transfers[1], 100); + box1.append(picture2); + var picture3 = new FileImageWidget.from_file_transfer(file_transfers[2], 100); + box1.append(picture3); + box.append(box1); + + this.append(box); + } else { + Box box = new Box(Orientation.VERTICAL, 4) { + overflow = Overflow.HIDDEN, + halign = Align.START + }; + box.add_css_class("image-group"); + + Box row_upper_row = new Box(Orientation.HORIZONTAL, 4); + + var picture1 = new FileImageWidget.from_file_transfer(file_transfers[0], 150); + row_upper_row.append(picture1); + + var picture2 = new FileImageWidget.from_file_transfer(file_transfers[1], 150); + row_upper_row.append(picture2); + box.append(row_upper_row); + + if (file_transfers.size >= 4) { + + int size = 150; + if (file_transfers.size == 5) size = 99; + + Box row_lower_row = new Box(Orientation.HORIZONTAL, 4); + + var picture3 = new FileImageWidget.from_file_transfer(file_transfers[2], size); + row_lower_row.append(picture3); + + var picture4 = new FileImageWidget.from_file_transfer(file_transfers[3], size); + row_lower_row.append(picture4); + + if (file_transfers.size == 5) { + var picture5 = new FileImageWidget.from_file_transfer(file_transfers[4], size); + row_lower_row.append(picture5); + } + + box.append(row_lower_row); + } + this.append(box); + } + } + } + + public static bool all_files_images(Gee.ArrayList file_transfers) { + foreach (FileTransfer file_transfer in file_transfers) { + if (!Dino.Util.is_pixbuf_supported_content_type(file_transfer.content_type)) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/main/src/ui/conversation_content_view/file_image_widget.vala b/main/src/ui/conversation_content_view/file_image_widget.vala index e936ca25..9f12f892 100644 --- a/main/src/ui/conversation_content_view/file_image_widget.vala +++ b/main/src/ui/conversation_content_view/file_image_widget.vala @@ -22,6 +22,8 @@ public class FileImageWidget : Widget { private Gtk.Box image_overlay_toolbar = new Gtk.Box(Orientation.VERTICAL, 0) { halign=Align.END, valign=Align.START, margin_top=10, margin_start=10, margin_end=10, margin_bottom=10, vexpand=false, visible=false }; private Label file_size_label = new Label(null) { halign=Align.START, valign=Align.END, margin_bottom=4, margin_start=4, visible=false }; + private int size = -1; + private FileTransfer file_transfer; private FileTransmissionProgress transmission_progress = new FileTransmissionProgress() { halign=Align.CENTER, valign=Align.CENTER, visible=false }; @@ -34,22 +36,6 @@ public class FileImageWidget : Widget { public FileImageWidget(int MAX_WIDTH=600, int MAX_HEIGHT=300) { this.halign = Align.START; - this.add_css_class("file-image-widget"); - - // Setup menu button overlay - MenuButton button = new MenuButton(); - button.icon_name = "dino-view-more"; - Menu menu_model = new Menu(); - menu_model.append(_("Open"), "file.open"); - menu_model.append(_("Save as…"), "file.save_as"); - Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model); - button.popover = popover_menu; - image_overlay_toolbar.append(button); - image_overlay_toolbar.add_css_class("card"); - image_overlay_toolbar.add_css_class("toolbar"); - image_overlay_toolbar.add_css_class("compact-card-toolbar"); - image_overlay_toolbar.set_cursor_from_name("default"); - file_size_label.add_css_class("file-details"); overlay.set_child(stack); @@ -65,12 +51,65 @@ public class FileImageWidget : Widget { gesture_click_controller.button = 1; // listen for left clicks gesture_click_controller.released.connect(on_image_clicked); stack.add_controller(gesture_click_controller); + } + + public FileImageWidget.from_file_transfer(FileTransfer file_transfer, int size = -1) { + this(); + + this.file_transfer = file_transfer; + this.size = size; + + this.file_transfer.bind_property("size", file_size_label, "label", BindingFlags.SYNC_CREATE, file_size_label_transform); + this.file_transfer.bind_property("size", transmission_progress, "file-size", BindingFlags.SYNC_CREATE); + this.file_transfer.bind_property("transferred-bytes", transmission_progress, "transferred-size"); + + setup_signals(transmission_progress, file_transfer); + setup_menu_button(); + + file_transfer.notify["state"].connect(refresh_state); + file_transfer.sources_changed.connect(refresh_state); + refresh_state(); + } + + private static void setup_signals(FileTransmissionProgress transmission_progress, FileTransfer file_transfer) { + var file_transfer_id = file_transfer.id; + + transmission_progress.start_download.connect(() => { + Dino.Application.get_default().activate_action("file_start_download", new Variant.int32(file_transfer_id)); + }); + transmission_progress.cancel_transfer.connect(() => { + Dino.Application.get_default().activate_action("file_cancel_transfer", new Variant.int32(file_transfer_id)); + }); + } + + private void setup_menu_button() { + MenuButton button = new MenuButton(); + button.icon_name = "dino-view-more"; + Menu menu_model = new Menu(); + + MenuItem open_file_item = new MenuItem(_("Open"), "app.file_open_externally"); + open_file_item.set_action_and_target_value("app.file_open_externally", new Variant.int32(file_transfer.id)); + + MenuItem save_as_item = new MenuItem(_("Save as…"), "app.file_save_as"); + save_as_item.set_action_and_target_value("app.file_open_save_dialog", new Variant.int32(file_transfer.id)); + + menu_model.append_item(open_file_item); + menu_model.append_item(save_as_item); + + Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model); + button.popover = popover_menu; + image_overlay_toolbar.append(button); + image_overlay_toolbar.add_css_class("card"); + image_overlay_toolbar.add_css_class("toolbar"); + image_overlay_toolbar.add_css_class("compact-card-toolbar"); + image_overlay_toolbar.set_cursor_from_name("default"); EventControllerMotion this_motion_events = new EventControllerMotion(); this.add_controller(this_motion_events); this_motion_events.enter.connect((controller, x, y) => { (controller.widget as FileImageWidget).on_motion_event_enter(); }); + attach_on_motion_event_leave(this_motion_events, button); } @@ -88,18 +127,6 @@ public class FileImageWidget : Widget { file_size_label.visible = file_transfer != null && file_transfer.direction == FileTransfer.DIRECTION_RECEIVED && file_transfer.state == FileTransfer.State.NOT_STARTED && !file_transfer.sfs_sources.is_empty; } - public async void set_file_transfer(FileTransfer file_transfer) { - this.file_transfer = file_transfer; - - this.file_transfer.bind_property("size", file_size_label, "label", BindingFlags.SYNC_CREATE, file_size_label_transform); - this.file_transfer.bind_property("size", transmission_progress, "file-size", BindingFlags.SYNC_CREATE); - this.file_transfer.bind_property("transferred-bytes", transmission_progress, "transferred-size"); - - file_transfer.notify["state"].connect(refresh_state); - file_transfer.sources_changed.connect(refresh_state); - refresh_state(); - } - private static bool file_size_label_transform(Binding binding, Value from_value, ref Value to_value) { to_value = FileDefaultWidget.get_size_string((int64) from_value); return true; @@ -153,7 +180,7 @@ public class FileImageWidget : Widget { } public async void load_from_file(File file, string file_name) throws GLib.Error { - FixedRatioPicture image = new FixedRatioPicture() { min_width=100, min_height=100, max_width=600, max_height=300 }; + FixedRatioPicture image = create_picture(); FileInputStream file_stream = null; try { @@ -201,7 +228,7 @@ public class FileImageWidget : Widget { throw new Error(-1, 0, "Error scaling preview image"); } - FixedRatioPicture image = new FixedRatioPicture() { min_width=100, min_height=100, max_width=600, max_height=300 }; + FixedRatioPicture image = create_picture(); image.paintable = Texture.for_pixbuf(pixbuf); stack.add_child(image); stack.set_visible_child(image); @@ -216,12 +243,12 @@ public class FileImageWidget : Widget { if (n_press == 1) { image_overlay_toolbar.visible = !image_overlay_toolbar.visible; } else if (n_press == 2) { - this.activate_action("file.open", null); + Dino.Application.get_default().activate_action("file_open_externally", new Variant.int32(file_transfer.id)); image_overlay_toolbar.visible = false; } break; default: - this.activate_action("file.open", null); + Dino.Application.get_default().activate_action("file_open_externally", new Variant.int32(file_transfer.id)); image_overlay_toolbar.visible = false; break; } @@ -237,6 +264,20 @@ public class FileImageWidget : Widget { (file_transfer.state == FileTransfer.State.COMPLETE || file_transfer.thumbnails.size > 0); } + private FixedRatioPicture create_picture() { + if (size == -1) { + return new FixedRatioPicture() { + min_width=100, max_width=600, + min_height=100, max_height=300 + }; + } else { + return new FixedRatioPicture() { + height_request = size, width_request = size, + content_fit = ContentFit.COVER, halign = Align.START + }; + } + } + public override void dispose() { if (overlay != null && overlay.parent != null) overlay.unparent(); base.dispose(); diff --git a/main/src/ui/conversation_content_view/file_transmission_progress.vala b/main/src/ui/conversation_content_view/file_transmission_progress.vala index e3f33448..43885c1b 100644 --- a/main/src/ui/conversation_content_view/file_transmission_progress.vala +++ b/main/src/ui/conversation_content_view/file_transmission_progress.vala @@ -9,6 +9,9 @@ namespace Dino.Ui { public class FileTransmissionProgress : Adw.Bin { + public signal void start_download(); + public signal void cancel_transfer(); + public enum State { UNKNOWN_SOURCE, DOWNLOAD_NOT_STARTED, @@ -124,11 +127,12 @@ namespace Dino.Ui { break; case DOWNLOAD_NOT_STARTED_FAILED_BEFORE: case DOWNLOAD_NOT_STARTED: + start_download(); this.activate_action("file.download", null); break; case DOWNLOADING: case UPLOADING: - this.activate_action("file.cancel", null); + cancel_transfer(); break; } } diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index ecc2d3e9..04185343 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -57,18 +57,6 @@ public class FileWidget : SizeRequestBin { private FileDefaultWidgetController default_widget_controller; private Widget? content = null; - public signal void open_file(); - public signal void save_file_as(); - public signal void start_download(); - public signal void cancel_download(); - - class construct { - install_action("file.open", null, (widget, action_name) => { ((FileWidget) widget).open_file(); }); - install_action("file.save_as", null, (widget, action_name) => { ((FileWidget) widget).save_file_as(); }); - install_action("file.download", null, (widget, action_name) => { ((FileWidget) widget).start_download(); }); - install_action("file.cancel", null, (widget, action_name) => { ((FileWidget) widget).cancel_download(); }); - } - construct { margin_top = 4; size_request_mode = SizeRequestMode.HEIGHT_FOR_WIDTH; @@ -79,11 +67,6 @@ public class FileWidget : SizeRequestBin { this.file_transfer = file_transfer; update_widget.begin(); -// size_allocate.connect((allocation) => { -// if (allocation.height > parent.get_allocated_height()) { -// Idle.add(() => { parent.queue_resize(); return false; }); -// } -// }); file_transfer.bind_property("state", this, "file-transfer-state"); file_transfer.bind_property("content-type", this, "file-transfer-content-type"); @@ -100,8 +83,8 @@ public class FileWidget : SizeRequestBin { FileImageWidget file_image_widget = null; try { - file_image_widget = new FileImageWidget(); - yield file_image_widget.set_file_transfer(file_transfer); + file_image_widget = new FileImageWidget.from_file_transfer(file_transfer); + file_image_widget.add_css_class("file-image-widget"); // If the widget changed in the meanwhile, stop if (content != content_bak) return; @@ -152,50 +135,6 @@ public class FileWidgetController : Object { }); this.file_transfer = file_transfer; this.stream_interactor = stream_interactor; - - widget.open_file.connect(open_file); - widget.save_file_as.connect(save_file); - widget.start_download.connect(start_download); - widget.cancel_download.connect(cancel_download); - } - - private void open_file() { - try{ - AppInfo.launch_default_for_uri(file_transfer.get_file().get_uri(), null); - } catch (Error err) { - warning("Failed to open %s - %s", file_transfer.get_file().get_uri(), err.message); - } - } - - private void save_file() { - var save_dialog = new FileChooserNative(_("Save as…"), widget.get_root() as Gtk.Window, FileChooserAction.SAVE, null, null); - save_dialog.set_modal(true); - save_dialog.set_current_name(file_transfer.file_name); - - save_dialog.response.connect(() => { - File? target_file = save_dialog.get_file(); - if (target_file == null) { - warning("No file returned from save dialog."); - return; - } - try { - file_transfer.get_file().copy(save_dialog.get_file(), GLib.FileCopyFlags.OVERWRITE, null); - } catch (Error err) { - warning("Failed copy file %s - %s", file_transfer.get_file().get_uri(), err.message); - } - }); - - save_dialog.show(); - } - - private void start_download() { - if (stream_interactor != null) { - stream_interactor.get_module(FileManager.IDENTITY).download_file.begin(file_transfer); - } - } - - private void cancel_download() { - file_transfer.cancellable.cancel(); } } @@ -234,16 +173,16 @@ public class FileDefaultWidgetController : Object { private void update_file_info() { state = file_transfer.state; - widget.update_file_info(file_transfer.content_type, file_transfer.state, file_transfer.direction, file_transfer.size, file_transfer.transferred_bytes); + widget.update_file_info(file_transfer); } private void on_clicked() { switch (state) { case FileTransfer.State.COMPLETE: - widget.activate_action("file.open", null); + Dino.Application.get_default().activate_action("file_open_externally", new GLib.Variant.int32(file_transfer.id)); break; case FileTransfer.State.NOT_STARTED: - widget.activate_action("file.download", null); + Dino.Application.get_default().activate_action("file_start_download", new GLib.Variant.int32(file_transfer.id)); break; default: // Clicking doesn't do anything in FAILED and IN_PROGRESS states diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index fd5814fa..0e565c84 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -226,6 +226,25 @@ public class ConversationSelectorRow : ListBoxRow { message_label.label = (file_is_image ? _("Image received") : _("File received") ); } break; + case FileGroupItem.TYPE: + FileGroupItem file_group_item = last_content_item as FileGroupItem; + FileTransfer first_transfer = file_group_item.file_group.file_transfers.get(0); + + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + // TODO properly display nick for oneself + nick_label.label = Util.get_participant_display_name(stream_interactor, conversation, first_transfer.from, true) + ": "; + } else { + nick_label.label = first_transfer.direction == Message.DIRECTION_SENT ? _("Me") + ": " : ""; + } + + bool all_files_images = Ui.all_files_images(file_group_item.file_group.file_transfers); + change_label_attribute(message_label, attr_style_new(Pango.Style.ITALIC)); + if (first_transfer.direction == Message.DIRECTION_SENT) { + message_label.label = (all_files_images ? _("Images sent") : _("Files sent") ); + } else { + message_label.label = (all_files_images ? _("Images received") : _("Files received") ); + } + break; case CallItem.TYPE: CallItem call_item = (CallItem) last_content_item; Call call = call_item.call; diff --git a/main/src/ui/conversation_view.vala b/main/src/ui/conversation_view.vala index 26043666..4bb1f537 100644 --- a/main/src/ui/conversation_view.vala +++ b/main/src/ui/conversation_view.vala @@ -17,6 +17,27 @@ public class ConversationView : Widget { construct { this.layout_manager = new BinLayout(); } + + public void open_save_file_dialog(FileTransfer file_transfer) { + var save_dialog = new FileChooserNative(_("Save as…"), (Gtk.Window) this.get_root(), FileChooserAction.SAVE, null, null); + save_dialog.set_modal(true); + save_dialog.set_current_name(file_transfer.file_name); + + save_dialog.response.connect(() => { + File? target_file = save_dialog.get_file(); + if (target_file == null) { + warning("No file returned from save dialog."); + return; + } + try { + file_transfer.get_file().copy(save_dialog.get_file(), GLib.FileCopyFlags.OVERWRITE, null); + } catch (Error err) { + warning("Failed copy file %s - %s", file_transfer.get_file().get_uri(), err.message); + } + }); + + save_dialog.show(); + } } } diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index ef13b026..9626a39d 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -216,10 +216,6 @@ public class ConversationViewController : Object { chooser.show(); } - private void send_file(File file) { - stream_interactor.get_module(FileManager.IDENTITY).send_file.begin(file, conversation); - } - private bool forward_key_press_to_chat_input(EventControllerKey key_controller, uint keyval, uint keycode, Gdk.ModifierType state) { if (view.get_root().get_focus() is TextView) { return false; diff --git a/plugins/http-files/src/plugin.vala b/plugins/http-files/src/plugin.vala index 67b32f65..649d1ceb 100644 --- a/plugins/http-files/src/plugin.vala +++ b/plugins/http-files/src/plugin.vala @@ -10,13 +10,13 @@ public class Plugin : RootInterface, Object { public FileSender file_sender; public void registered(Dino.Application app) { - this.app = app; - - file_provider = new FileProvider(app.stream_interactor, app.db); - file_sender = new HttpFileSender(app.stream_interactor, app.db); - - app.stream_interactor.get_module(FileManager.IDENTITY).add_provider(file_provider); - app.stream_interactor.get_module(FileManager.IDENTITY).add_sender(file_sender); +// this.app = app; +// +// file_provider = new FileProvider(app.stream_interactor, app.db); +// file_sender = new HttpFileSender(app.stream_interactor, app.db); +// +// app.stream_interactor.get_module(FileManager.IDENTITY).add_provider(file_provider); +// app.stream_interactor.get_module(FileManager.IDENTITY).add_sender(file_sender); } public void shutdown() { -- cgit v1.3