diff options
author | fiaxh <git@lightrise.org> | 2024-11-02 22:24:59 +0100 |
---|---|---|
committer | fiaxh <git@lightrise.org> | 2024-11-15 14:40:08 -0600 |
commit | 79f792e090330a05753f9edb27332a946eb0840d (patch) | |
tree | 5a6f1ad3ac0af0beea44ca9e83e7a9b052263025 | |
parent | aaf4542e6208460c305db4be36b15dc832ddc95a (diff) | |
download | dino-79f792e090330a05753f9edb27332a946eb0840d.tar.gz dino-79f792e090330a05753f9edb27332a946eb0840d.zip |
Fix and improve stateless file-sharing
40 files changed, 1289 insertions, 1022 deletions
diff --git a/crypto-vala/src/cipher_converter.vala b/crypto-vala/src/cipher_converter.vala index b2b52c5a..5fb1f548 100644 --- a/crypto-vala/src/cipher_converter.vala +++ b/crypto-vala/src/cipher_converter.vala @@ -55,7 +55,7 @@ public class SymmetricCipherEncrypter : SymmetricCipherConverter { } return ConverterResult.CONVERTED; } catch (Crypto.Error e) { - throw new IOError.FAILED(@"$(e.domain) error while decrypting: $(e.message)"); + throw new IOError.FAILED(@"$(e.domain) error while encrypting: $(e.message)"); } } } diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 34cf9575..c7c4a521 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -64,10 +64,13 @@ SOURCES src/service/registration.vala src/service/roster_manager.vala src/service/search_processor.vala + src/service/sfs_metadata.vala + src/service/stateless_file_sharing.vala src/service/stream_interactor.vala src/service/util.vala src/util/display_name.vala + src/util/limit_input_stream.vala src/util/send_message.vala src/util/util.vala src/util/weak_map.vala diff --git a/libdino/meson.build b/libdino/meson.build index 559a81b5..85487d48 100644 --- a/libdino/meson.build +++ b/libdino/meson.build @@ -71,9 +71,12 @@ sources = files( 'src/service/registration.vala', 'src/service/roster_manager.vala', 'src/service/search_processor.vala', + 'src/service/sfs_metadata.vala', + 'src/service/stateless_file_sharing.vala', 'src/service/stream_interactor.vala', 'src/service/util.vala', 'src/util/display_name.vala', + 'src/util/limit_input_stream.vala', 'src/util/send_message.vala', 'src/util/util.vala', 'src/util/weak_map.vala', diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 0fcee731..4aa4b8ad 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -58,6 +58,7 @@ public interface Application : GLib.Application { Replies.start(stream_interactor, db); FallbackBody.start(stream_interactor, db); ContactModels.start(stream_interactor); + StatelessFileSharing.start(stream_interactor, db); create_actions(); diff --git a/libdino/src/entity/file_transfer.vala b/libdino/src/entity/file_transfer.vala index c9c7916e..49ff2aa3 100644 --- a/libdino/src/entity/file_transfer.vala +++ b/libdino/src/entity/file_transfer.vala @@ -4,6 +4,8 @@ namespace Dino.Entities { public class FileTransfer : Object { + public signal void sources_changed(); + public const bool DIRECTION_SENT = true; public const bool DIRECTION_RECEIVED = false; @@ -14,23 +16,8 @@ public class FileTransfer : Object { FAILED } - public class SerializedSfsSource: Object { - public string type; - public string data; - - public SerializedSfsSource.from_sfs_source(Xep.StatelessFileSharing.SfsSource source) { - this.type = source.type(); - this.data = source.serialize(); - } - - public async Xep.StatelessFileSharing.SfsSource to_sfs_source() { - assert(this.type == Xep.StatelessFileSharing.HttpSource.SOURCE_TYPE); - Xep.StatelessFileSharing.HttpSource http_source = yield Xep.StatelessFileSharing.HttpSource.deserialize(this.data); - return http_source; - } - } - public int id { get; set; default=-1; } + public string? file_sharing_id { get; set; } public Account account { get; set; } public Jid counterpart { get; set; } public Jid ourpart { get; set; } @@ -85,13 +72,45 @@ public class FileTransfer : Object { public int provider { get; set; } public string info { get; set; } public Cancellable cancellable { get; default=new Cancellable(); } + + // This value is not persisted + public int64 transferred_bytes { get; set; } + + public Xep.FileMetadataElement.FileMetadata file_metadata { + owned get { + return new Xep.FileMetadataElement.FileMetadata() { + name = this.file_name, + mime_type = this.mime_type, + size = this.size, + desc = this.desc, + date = this.modification_date, + width = this.width, + height = this.height, + length = this.length, + hashes = this.hashes, + thumbnails = this.thumbnails + }; + } + set { + this.file_name = value.name; + this.mime_type = value.mime_type; + this.size = value.size; + this.desc = value.desc; + this.modification_date = value.date; + this.width = value.width; + this.height = value.height; + this.length = value.length; + this.hashes = value.hashes; + this.thumbnails = value.thumbnails; + } + } public string? desc { get; set; } public DateTime? modification_date { get; set; } public int width { get; set; default=-1; } public int height { get; set; default=-1; } public int64 length { get; set; default=-1; } - public Xep.CryptographicHashes.Hashes hashes { get; set; default=new Xep.CryptographicHashes.Hashes();} - public ListStore sfs_sources { get; set; default=new ListStore(typeof(SerializedSfsSource)); } + public Gee.List<Xep.CryptographicHashes.Hash> hashes = new Gee.ArrayList<Xep.CryptographicHashes.Hash>(); + public Gee.List<Xep.StatelessFileSharing.Source> sfs_sources = new Gee.ArrayList<Xep.StatelessFileSharing.Source>(Xep.StatelessFileSharing.Source.equals_func); public Gee.List<Xep.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>(); private Database? db; @@ -102,6 +121,7 @@ public class FileTransfer : Object { this.storage_dir = storage_dir; id = row[db.file_transfer.id]; + file_sharing_id = row[db.file_transfer.file_sharing_id]; account = db.get_account_by_id(row[db.file_transfer.account_id]); // TODO don’t have to generate acc new counterpart = db.get_jid_by_id(row[db.file_transfer.counterpart_id]); @@ -130,11 +150,12 @@ public class FileTransfer : Object { height = row[db.file_transfer.height]; length = (int64) row[db.file_transfer.length]; + // TODO put those into the initial query foreach(var hash_row in db.file_hashes.select().with(db.file_hashes.id, "=", id)) { Xep.CryptographicHashes.Hash hash = new Xep.CryptographicHashes.Hash(); hash.algo = hash_row[db.file_hashes.algo]; hash.val = hash_row[db.file_hashes.value]; - hashes.hashes.add(hash); + hashes.add(hash); } foreach(var thumbnail_row in db.file_thumbnails.select().with(db.file_thumbnails.id, "=", id)) { @@ -146,11 +167,10 @@ public class FileTransfer : Object { thumbnails.add(thumbnail); } - foreach(Qlite.Row source_row in db.sfs_sources.select().with(db.sfs_sources.id, "=", id)) { - SerializedSfsSource source = new SerializedSfsSource(); - source.type = source_row[db.sfs_sources.type]; - source.data = source_row[db.sfs_sources.data]; - sfs_sources.append(source as Object); + foreach(Qlite.Row source_row in db.sfs_sources.select().with(db.sfs_sources.file_transfer_id, "=", id)) { + if (source_row[db.sfs_sources.type] == "http") { + sfs_sources.add(new Xep.StatelessFileSharing.HttpSource() { url=source_row[db.sfs_sources.data] }); + } } notify.connect(on_update); @@ -175,6 +195,7 @@ public class FileTransfer : Object { .value(db.file_transfer.provider, provider) .value(db.file_transfer.info, info); + if (file_sharing_id != null) builder.value(db.file_transfer.file_sharing_id, file_sharing_id); if (path != null) builder.value(db.file_transfer.path, path); if (mime_type != null) builder.value(db.file_transfer.mime_type, mime_type); if (path != null) builder.value(db.file_transfer.path, path); @@ -185,7 +206,7 @@ public class FileTransfer : Object { id = (int) builder.perform(); - foreach (Xep.CryptographicHashes.Hash hash in hashes.hashes) { + foreach (Xep.CryptographicHashes.Hash hash in hashes) { db.file_hashes.insert() .value(db.file_hashes.id, id) .value(db.file_hashes.algo, hash.algo) @@ -202,29 +223,40 @@ public class FileTransfer : Object { .perform(); } - for(int i = 0; i < sfs_sources.get_n_items(); i++) { - Object source_object = sfs_sources.get_item(i); - SerializedSfsSource source = source_object as SerializedSfsSource; + foreach (Xep.StatelessFileSharing.Source source in sfs_sources) { + add_sfs_source(source); + } + + notify.connect(on_update); + } + + public void add_sfs_source(Xep.StatelessFileSharing.Source source) { + if (sfs_sources.contains(source)) return; // Don't add the same source twice. Might happen due to MAM and lacking deduplication. + + sfs_sources.add(source); + + Xep.StatelessFileSharing.HttpSource? http_source = source as Xep.StatelessFileSharing.HttpSource; + if (http_source != null) { db.sfs_sources.insert() - .value(db.sfs_sources.id, id) - .value(db.sfs_sources.type, source.type) - .value(db.sfs_sources.data, source.data) + .value(db.sfs_sources.file_transfer_id, id) + .value(db.sfs_sources.type, "http") + .value(db.sfs_sources.data, http_source.url) .perform(); } - notify.connect(on_update); - sfs_sources.items_changed.connect((position, removed, added) => { - on_update_sources_items(this, position, removed, added); - }); + sources_changed(); } - public File get_file() { + public File? get_file() { + if (path == null) return null; return File.new_for_path(Path.build_filename(Dino.get_storage_dir(), "files", path)); } private void on_update(Object o, ParamSpec sp) { Qlite.UpdateBuilder update_builder = db.file_transfer.update().with(db.file_transfer.id, "=", id); switch (sp.name) { + case "file-sharing-id": + update_builder.set(db.file_transfer.file_sharing_id, file_sharing_id); break; case "counterpart": update_builder.set(db.file_transfer.counterpart_id, db.get_jid_id(counterpart)); update_builder.set(db.file_transfer.counterpart_resource, counterpart.resourcepart); break; @@ -264,58 +296,6 @@ public class FileTransfer : Object { } update_builder.perform(); } - - private void on_update_sources_items(FileTransfer file_transfer, uint position, uint removed, uint added) { - for(uint i = position; i < position + added; i++) { - Object source_object = file_transfer.sfs_sources.get_item(i); - SerializedSfsSource source = source_object as SerializedSfsSource; - db.sfs_sources.insert() - .value(db.sfs_sources.id, id) - .value(db.sfs_sources.type, source.type) - .value(db.sfs_sources.data, source.data) - .perform(); - } - } - - public Xep.FileMetadataElement.FileMetadata to_metadata_element() { - Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); - metadata.name = this.file_name; - metadata.mime_type = this.mime_type; - metadata.size = this.size; - metadata.desc = this.desc; - metadata.date = this.modification_date; - metadata.width = this.width; - metadata.height = this.height; - metadata.length = this.length; - metadata.hashes = this.hashes; - metadata.thumbnails = this.thumbnails; - return metadata; - } - - public async Xep.StatelessFileSharing.SfsElement to_sfs_element() { - Xep.StatelessFileSharing.SfsElement sfs_element = new Xep.StatelessFileSharing.SfsElement(); - sfs_element.metadata = this.to_metadata_element(); - for(int i = 0; i < sfs_sources.get_n_items(); i++) { - Object source_object = sfs_sources.get_item(i); - SerializedSfsSource source = source_object as SerializedSfsSource; - sfs_element.sources.add(yield source.to_sfs_source()); - } - - return sfs_element; - } - - public void with_metadata_element(Xep.FileMetadataElement.FileMetadata metadata) { - this.file_name = metadata.name; - this.mime_type = metadata.mime_type; - this.size = metadata.size; - this.desc = metadata.desc; - this.modification_date = metadata.date; - this.width = metadata.width; - this.height = metadata.height; - this.length = metadata.length; - this.hashes = metadata.hashes; - this.thumbnails = metadata.thumbnails; - } } } diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index d48f999b..e5aad25f 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -1,6 +1,5 @@ using Gee; using Xmpp; -using Xmpp.Xep; namespace Dino.Entities { diff --git a/libdino/src/service/content_item_store.vala b/libdino/src/service/content_item_store.vala index 740dc2a9..7d7ed1fb 100644 --- a/libdino/src/service/content_item_store.vala +++ b/libdino/src/service/content_item_store.vala @@ -121,13 +121,7 @@ public class ContentItemStore : StreamInteractionModule, Object { Message? message = get_message_for_content_item(conversation, content_item); if (message == null) return null; - if (message.edit_to != null) return message.edit_to; - - if (conversation.type_ == Conversation.Type.CHAT) { - return message.stanza_id; - } else { - return message.server_id; - } + return MessageStorage.get_reference_id(message); } public Jid? get_message_sender_for_content_item(Conversation conversation, ContentItem content_item) { diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 30d6d55f..bfa76890 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -180,6 +180,7 @@ public class Database : Qlite.Database { public class FileTransferTable : Table { public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column<string> file_sharing_id = new Column.Text("file_sharing_id") { min_version=28 }; public Column<int> account_id = new Column.Integer("account_id") { not_null = true }; public Column<int> counterpart_id = new Column.Integer("counterpart_id") { not_null = true }; public Column<string> counterpart_resource = new Column.Text("counterpart_resource"); @@ -191,7 +192,7 @@ public class Database : Qlite.Database { public Column<string> file_name = new Column.Text("file_name"); public Column<string> path = new Column.Text("path"); public Column<string> mime_type = new Column.Text("mime_type"); - public Column<long> size = new Column.Long("size") { default = "-1", min_version=28 }; + public Column<long> size = new Column.Long("size"); public Column<int> state = new Column.Integer("state"); public Column<int> provider = new Column.Integer("provider"); public Column<string> info = new Column.Text("info"); @@ -202,9 +203,9 @@ public class Database : Qlite.Database { internal FileTransferTable(Database db) { base(db, "file_transfer"); - init({id, account_id, counterpart_id, counterpart_resource, our_resource, direction, time, local_time, - encryption, file_name, path, mime_type, size, state, provider, info, modification_date, width, height, - length}); + init({id, file_sharing_id, account_id, counterpart_id, counterpart_resource, our_resource, direction, + time, local_time, encryption, file_name, path, mime_type, size, state, provider, info, modification_date, + width, height, length}); } } @@ -233,15 +234,15 @@ public class Database : Qlite.Database { } } - public class SfsSourcesTable : Table { - public Column<int> id = new Column.Integer("id"); + public class SourcesTable : Table { + public Column<int> file_transfer_id = new Column.Integer("file_transfer_id"); public Column<string> type = new Column.Text("type") { not_null = true }; public Column<string> data = new Column.Text("data") { not_null = true }; - internal SfsSourcesTable(Database db) { + internal SourcesTable(Database db) { base(db, "sfs_sources"); - init({id, type, data}); - unique({id, type, data}, "REPLACE"); + init({file_transfer_id, type, data}); + index("sfs_sources_file_transfer_id_idx", {file_transfer_id}); } } @@ -445,7 +446,7 @@ public class Database : Qlite.Database { public FileTransferTable file_transfer { get; private set; } public FileHashesTable file_hashes { get; private set; } public FileThumbnailsTable file_thumbnails { get; private set; } - public SfsSourcesTable sfs_sources { get; private set; } + public SourcesTable sfs_sources { get; private set; } public CallTable call { get; private set; } public CallCounterpartTable call_counterpart { get; private set; } public ConversationTable conversation { get; private set; } @@ -478,7 +479,7 @@ public class Database : Qlite.Database { file_transfer = new FileTransferTable(this); file_hashes = new FileHashesTable(this); file_thumbnails = new FileThumbnailsTable(this); - sfs_sources = new SfsSourcesTable(this); + sfs_sources = new SourcesTable(this); call = new CallTable(this); call_counterpart = new CallCounterpartTable(this); conversation = new ConversationTable(this); diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala index 32cf23c4..2a665e1e 100644 --- a/libdino/src/service/file_manager.vala +++ b/libdino/src/service/file_manager.vala @@ -22,6 +22,11 @@ public class FileManager : StreamInteractionModule, Object { private Gee.List<FileProvider> file_providers = new ArrayList<FileProvider>(); private Gee.List<FileMetadataProvider> file_metadata_providers = new ArrayList<FileMetadataProvider>(); + public StatelessFileSharing sfs { + owned get { return stream_interactor.get_module(StatelessFileSharing.IDENTITY); } + private set { } + } + public static void start(StreamInteractor stream_interactor, Database db) { FileManager m = new FileManager(stream_interactor, db); stream_interactor.add_module(m); @@ -40,15 +45,12 @@ public class FileManager : StreamInteractionModule, Object { this.add_sender(new JingleFileSender(stream_interactor)); this.add_metadata_provider(new GenericFileMetadataProvider()); this.add_metadata_provider(new ImageFileMetadataProvider()); - this.stream_interactor.account_added.connect((account) => { - on_account_added(account); - }); } public const int HTTP_PROVIDER_ID = 0; public const int SFS_PROVIDER_ID = 2; - private FileProvider? select_file_provider(FileTransfer file_transfer) { + public FileProvider? select_file_provider(FileTransfer file_transfer) { bool http_usable = file_transfer.provider == SFS_PROVIDER_ID; foreach (FileProvider file_provider in this.file_providers) { if (file_transfer.provider == file_provider.get_id()) { @@ -61,98 +63,7 @@ public class FileManager : StreamInteractionModule, Object { return null; } - // For receiving out of band data as sfs - private async void on_backwards_compatible_sfs(FileProvider file_provider, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { - Xep.StatelessFileSharing.SfsElement sfs_element = new Xep.StatelessFileSharing.SfsElement(); - - Xep.StatelessFileSharing.HttpSource source = new Xep.StatelessFileSharing.HttpSource(); - HttpFileReceiveData http_receive_data = receive_data as HttpFileReceiveData; - source.url = http_receive_data.url; - sfs_element.sources.add(source); - - FileTransfer file_transfer = new FileTransfer(); - - if (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("Can't accept oob data as stateless file sharing due to failed http request\n"); - } - } - - sfs_element.metadata.size = file_meta.size; - sfs_element.metadata.name = file_meta.file_name; - sfs_element.metadata.mime_type = file_meta.mime_type; - // Encryption unused in http file transfers - - yield on_receive_sfs(from, conversation, sfs_element, null); - } - - private async void on_receive_sfs(Jid from, Conversation conversation, Xep.StatelessFileSharing.SfsElement sfs_element, string? id) { - FileTransfer file_transfer = new FileTransfer(); - file_transfer.account = conversation.account; - file_transfer.counterpart = file_transfer.direction == FileTransfer.DIRECTION_RECEIVED ? from : 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; - file_transfer.direction = from.equals(file_transfer.ourpart) ? FileTransfer.DIRECTION_SENT : FileTransfer.DIRECTION_RECEIVED; - } else { - file_transfer.ourpart = conversation.account.full_jid; - file_transfer.direction = from.equals_bare(file_transfer.ourpart) ? FileTransfer.DIRECTION_SENT : FileTransfer.DIRECTION_RECEIVED; - } - file_transfer.time = new DateTime.now_utc(); - // TODO: get time from message - file_transfer.local_time = new DateTime.now_utc(); - file_transfer.provider = SFS_PROVIDER_ID; - file_transfer.with_metadata_element(sfs_element.metadata); - foreach (Xep.StatelessFileSharing.SfsSource source in sfs_element.sources) { - file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); - } - // FileTransfer.info stores the id of the MessageStanza for future SfsSourceAttachments - // Prior to sfs, info stored the id of the Message entity for oob - file_transfer.info = id; - - stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); - - if (is_sender_trustworthy(file_transfer, conversation)) { - if (file_transfer.size >= 0 && file_transfer.size < 500) { - FileProvider? file_provider = this.select_file_provider(file_transfer); - download_file_internal.begin(file_provider, file_transfer, conversation, (_, res) => { - download_file_internal.end(res); - }); - } - } - - conversation.last_active = file_transfer.time; - received_file(file_transfer, conversation); - } - - private void on_receive_sfs_attachment(Jid from, Conversation conversation, Xep.StatelessFileSharing.SfsSourceAttachment attachment) { - foreach (Qlite.Row file_transfer_row in this.db.file_transfer.select() - .with(db.file_transfer.info, "=", attachment.sfs_id)) { - FileTransfer file_transfer = new FileTransfer.from_row(this.db, file_transfer_row, FileManager.get_storage_dir()); - if (file_transfer.hashes.supported_hashes().is_empty) { - return; - } - foreach (StatelessFileSharing.SfsSource source in attachment.sources) { - file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); - } - } - - } - - private void on_account_added(Account account) { - Xep.StatelessFileSharing.Module fsf_module = stream_interactor.module_manager.get_module(account, Xep.StatelessFileSharing.Module.IDENTITY); - fsf_module.received_sfs.connect((from, to, sfs_element, message) => { - Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from, to, account, message.type_); - on_receive_sfs(from, conversation, sfs_element, message.id); - }); - fsf_module.received_sfs_attachment.connect((from, to, sfs_attachment, message) => { - Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from, to, account, message.type_); - on_receive_sfs_attachment(from, conversation, sfs_attachment); - }); - } - - public async HashMap<int, long> get_file_size_limits(Conversation conversation) { + public async HashMap<int, long> get_file_size_limits(Conversation conversation) { HashMap<int, long> ret = new HashMap<int, long>(); foreach (FileSender sender in file_senders) { ret[sender.get_id()] = yield sender.get_file_size_limit(conversation); @@ -180,7 +91,7 @@ public class FileManager : StreamInteractionModule, Object { yield file_metadata_provider.fill_metadata(file, metadata); } } - file_transfer.with_metadata_element(metadata); + file_transfer.file_metadata = metadata; try { file_transfer.input_stream = yield file.read_async(); @@ -237,7 +148,16 @@ public class FileManager : StreamInteractionModule, Object { file_send_data = file_encryptor.preprocess_send_file(conversation, file_transfer, file_send_data, file_meta); } + file_transfer.state = FileTransfer.State.IN_PROGRESS; + + // Update current download progress in the FileTransfer + LimitInputStream? limit_stream = file_transfer.input_stream as LimitInputStream; + if (limit_stream != null) { + limit_stream.bind_property("retrieved-bytes", file_transfer, "transferred-bytes", BindingFlags.SYNC_CREATE); + } + yield file_sender.send_file(conversation, file_transfer, file_send_data, file_meta); + file_transfer.state = FileTransfer.State.COMPLETE; } catch (Error e) { warning("Send file error: %s", e.message); @@ -265,12 +185,7 @@ public class FileManager : StreamInteractionModule, Object { public void add_provider(FileProvider file_provider) { file_providers.add(file_provider); file_provider.file_incoming.connect((info, from, time, local_time, conversation, receive_data, file_meta) => { - if (receive_data is HttpFileReceiveData) { - printerr("Handling oob data as stateless file sharing"); - this.on_backwards_compatible_sfs.begin(file_provider, from, time, local_time, conversation, receive_data, file_meta); - } else { - handle_incoming_file.begin(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); - } + handle_incoming_file.begin(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); }); } @@ -296,10 +211,12 @@ public class FileManager : StreamInteractionModule, Object { file_metadata_providers.add(file_metadata_provider); } - private bool is_jid_trustworthy(Jid from, Conversation conversation) { + 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(from, conversation.account); + relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(file_transfer.from, conversation.account); } if (relevant_jid == null) return false; @@ -307,12 +224,6 @@ public class FileManager : StreamInteractionModule, Object { return in_roster; } - public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) { - if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true; - - return is_jid_trustworthy(file_transfer.from, conversation); - } - 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); @@ -337,7 +248,11 @@ public class FileManager : StreamInteractionModule, Object { private async void download_file_internal(FileProvider file_provider, FileTransfer file_transfer, Conversation conversation) { try { // Get meta info - FileReceiveData receive_data = yield file_provider.get_file_receive_data(file_transfer); + FileReceiveData? receive_data = file_provider.get_file_receive_data(file_transfer); + if (receive_data == null) { + warning("Don't have download data (yet)"); + return; + } FileDecryptor? file_decryptor = null; foreach (FileDecryptor decryptor in file_decryptors) { if (decryptor.can_decrypt_file(conversation, file_transfer, receive_data)) { @@ -367,10 +282,17 @@ public class FileManager : StreamInteractionModule, Object { input_stream = yield file_decryptor.decrypt_file(input_stream, conversation, file_transfer, receive_data); } + // Update current download progress in the FileTransfer + LimitInputStream? limit_stream = input_stream as LimitInputStream; + if (limit_stream != null) { + limit_stream.bind_property("retrieved-bytes", file_transfer, "transferred-bytes", BindingFlags.SYNC_CREATE); + } + // Save file 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)); + // libsoup doesn't properly support splicing OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); uint8[] buffer = new uint8[1024]; ssize_t read; @@ -381,20 +303,49 @@ public class FileManager : StreamInteractionModule, Object { } yield input_stream.close_async(Priority.LOW, file_transfer.cancellable); yield os.close_async(Priority.LOW, file_transfer.cancellable); + + // Verify the hash of the downloaded file, if it is known + var supported_hashes = Xep.CryptographicHashes.get_supported_hashes(file_transfer.hashes); + if (!supported_hashes.is_empty) { + var checksum_types = new ArrayList<ChecksumType>(); + var hashes = new HashMap<ChecksumType, string>(); + foreach (var hash in supported_hashes) { + var checksum_type = Xep.CryptographicHashes.hash_string_to_type(hash.algo); + checksum_types.add(checksum_type); + hashes[checksum_type] = hash.val; + } + + var computed_hashes = yield compute_file_hashes(file, checksum_types); + foreach (var checksum_type in hashes.keys) { + if (hashes[checksum_type] != computed_hashes[checksum_type]) { + warning("Hash of downloaded file does not equal advertised hash, discarding: %s. %s should be %s, was %s", + file_transfer.file_name, checksum_type.to_string(), hashes[checksum_type], computed_hashes[checksum_type]); + FileUtils.remove(file.get_path()); + file_transfer.state = FileTransfer.State.FAILED; + return; + } + } + } + file_transfer.path = file.get_basename(); - file_transfer.input_stream = yield file.read_async(); FileInfo file_info = file_transfer.get_file().query_info("*", FileQueryInfoFlags.NONE); file_transfer.mime_type = file_info.get_content_type(); file_transfer.state = FileTransfer.State.COMPLETE; + } catch (IOError.CANCELLED e) { + print("cancelled\n"); } catch (Error e) { warning("Error downloading file: %s", e.message); - file_transfer.state = FileTransfer.State.FAILED; + if (file_transfer.provider == 0 || file_transfer.provider == FileManager.SFS_PROVIDER_ID) { + file_transfer.state = FileTransfer.State.NOT_STARTED; + } else { + file_transfer.state = FileTransfer.State.FAILED; + } } } - private async void handle_incoming_file(FileProvider file_provider, string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { + public FileTransfer create_file_transfer_from_provider_incoming(FileProvider file_provider, string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { FileTransfer file_transfer = new FileTransfer(); file_transfer.account = conversation.account; file_transfer.counterpart = file_transfer.direction == FileTransfer.DIRECTION_RECEIVED ? from : conversation.counterpart; @@ -426,6 +377,11 @@ public class FileManager : StreamInteractionModule, Object { } } + return file_transfer; + } + + private async void handle_incoming_file(FileProvider file_provider, string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { + 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)) { @@ -451,10 +407,10 @@ public class FileManager : StreamInteractionModule, Object { 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); + 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 = yield file.read_async(); + 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)); } @@ -467,10 +423,10 @@ public errordomain FileSendError { SAVE_FAILED } +// Get rid of this Error and pass IoErrors instead - DOWNLOAD_FAILED already removed public errordomain FileReceiveError { GET_METADATA_FAILED, - DECRYPTION_FAILED, - DOWNLOAD_FAILED + DECRYPTION_FAILED } public class FileMeta { @@ -505,10 +461,10 @@ public interface FileProvider : Object { public abstract Encryption get_encryption(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta); public abstract FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError; - public abstract async FileReceiveData? get_file_receive_data(FileTransfer file_transfer); + public abstract FileReceiveData? get_file_receive_data(FileTransfer file_transfer); public abstract async FileMeta get_meta_info(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError; - public abstract async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError; + public abstract async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws IOError; public abstract int get_id(); } @@ -541,107 +497,4 @@ public interface FileDecryptor : Object { public abstract async InputStream decrypt_file(InputStream encrypted_stream, Conversation conversation, FileTransfer file_transfer, FileReceiveData receive_data) throws FileReceiveError; } -public interface FileMetadataProvider : Object { - public abstract bool supports_file(File file); - - public abstract async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata); -} - -class GenericFileMetadataProvider: Dino.FileMetadataProvider, Object { - public bool supports_file(File file) { - return true; - } - - public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { - FileInfo info = file.query_info("*", FileQueryInfoFlags.NONE); - - metadata.name = info.get_display_name(); - metadata.mime_type = info.get_content_type(); - metadata.size = info.get_size(); - metadata.date = info.get_modification_date_time(); - - Bytes file_data = file.load_bytes(); - metadata.hashes.hashes.add(new CryptographicHashes.Hash.from_data(GLib.ChecksumType.SHA256, file_data.get_data())); - metadata.hashes.hashes.add(new CryptographicHashes.Hash.from_data(GLib.ChecksumType.SHA512, file_data.get_data())); - } -} - -public class ImageFileMetadataProvider: Dino.FileMetadataProvider, Object { - public bool supports_file(File file) { - return file.query_info("*", FileQueryInfoFlags.NONE).get_content_type().has_prefix("image"); - } - - private const int[] THUMBNAIL_DIMS = { 1, 2, 3, 4, 8 }; - private const string IMAGE_TYPE = "png"; - private const string MIME_TYPE = "image/png"; - - public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { - Pixbuf pixbuf = new Pixbuf.from_stream(yield file.read_async()); - metadata.width = pixbuf.get_width(); - metadata.height = pixbuf.get_height(); - float ratio = (float)metadata.width / (float) metadata.height; - - int thumbnail_width = -1; - int thumbnail_height = -1; - float diff = float.INFINITY; - for (int i = 0; i < THUMBNAIL_DIMS.length; i++) { - int test_width = THUMBNAIL_DIMS[i]; - int test_height = THUMBNAIL_DIMS[THUMBNAIL_DIMS.length - 1 - i]; - float test_ratio = (float)test_width / (float)test_height; - float test_diff = (test_ratio - ratio).abs(); - if (test_diff < diff) { - thumbnail_width = test_width; - thumbnail_height = test_height; - diff = test_diff; - } - } - - Pixbuf thumbnail_pixbuf = pixbuf.scale_simple(thumbnail_width, thumbnail_height, InterpType.BILINEAR); - uint8[] buffer; - thumbnail_pixbuf.save_to_buffer(out buffer, IMAGE_TYPE); - string base_64 = GLib.Base64.encode(buffer); - string uri = @"data:$MIME_TYPE;base64,$base_64"; - Xep.JingleContentThumbnails.Thumbnail thumbnail = new Xep.JingleContentThumbnails.Thumbnail(); - thumbnail.uri = uri; - thumbnail.media_type = MIME_TYPE; - thumbnail.width = thumbnail_width; - thumbnail.height = thumbnail_height; - metadata.thumbnails.add(thumbnail); - } - - public static Pixbuf? parse_thumbnail(Xep.JingleContentThumbnails.Thumbnail thumbnail) { - string[] splits = thumbnail.uri.split(":", 2); - if (splits.length != 2) { - printerr("Thumbnail parsing error: ':' not found"); - return null; - } - if (splits[0] != "data") { - printerr("Unsupported thumbnail: unimplemented uri type\n"); - return null; - } - splits = splits[1].split(";", 2); - if (splits.length != 2) { - printerr("Thumbnail parsing error: ';' not found"); - return null; - } - if (splits[0] != MIME_TYPE) { - printerr("Unsupported thumbnail: unsupported mime-type\n"); - return null; - } - splits = splits[1].split(",", 2); - if (splits.length != 2) { - printerr("Thumbnail parsing error: ',' not found"); - return null; - } - if (splits[0] != "base64") { - printerr("Unsupported thumbnail: data is not base64 encoded\n"); - return null; - } - uint8[] data = Base64.decode(splits[1]); - MemoryInputStream input_stream = new MemoryInputStream.from_data(data); - Pixbuf pixbuf = new Pixbuf.from_stream(input_stream); - return pixbuf; - } -} - } diff --git a/libdino/src/service/file_transfer_storage.vala b/libdino/src/service/file_transfer_storage.vala index 1cc62403..64bb6b81 100644 --- a/libdino/src/service/file_transfer_storage.vala +++ b/libdino/src/service/file_transfer_storage.vala @@ -14,6 +14,8 @@ namespace Dino { private Database db; private WeakMap<int, FileTransfer> files_by_db_id = new WeakMap<int, FileTransfer>(); + private WeakMap<int, FileTransfer> files_by_message_id = new WeakMap<int, FileTransfer>(); + private WeakMap<string, FileTransfer> files_by_message_and_file_id = new WeakMap<string, FileTransfer>(); public static void start(StreamInteractor stream_interactor, Database db) { FileTransferStorage m = new FileTransferStorage(stream_interactor, db); @@ -41,6 +43,42 @@ namespace Dino { return create_file_from_row_opt(row_option, conversation); } + // Http file transfers store the corresponding message id in the `info` field + public FileTransfer? get_file_by_message_id(int id, Conversation conversation) { + FileTransfer? file_transfer = files_by_message_id[id]; + if (file_transfer != null) { + return file_transfer; + } + + RowOption row_option = db.file_transfer.select() + .with(db.file_transfer.info, "=", id.to_string()) + .single() + .row(); + + return create_file_from_row_opt(row_option, conversation); + } + + 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]; + + if (file_transfer == null) { + RowOption row_option = db.file_transfer.select() + .with(db.file_transfer.info, "=", message_id.to_string()) + .with(db.file_transfer.file_sharing_id, "=", file_sharing_id) + .single() + .row(); + + file_transfer = create_file_from_row_opt(row_option, conversation); + } + + // There can be collisions in the combined identifier, check it's the correct FileTransfer + if (file_transfer != null && file_transfer.info == message_id.to_string() && file_transfer.file_sharing_id == file_sharing_id) { + return file_transfer; + } + return null; + } + private FileTransfer? create_file_from_row_opt(RowOption row_opt, Conversation conversation) { if (!row_opt.is_present()) return null; @@ -61,6 +99,15 @@ namespace Dino { private void cache_file(FileTransfer file_transfer) { files_by_db_id[file_transfer.id] = file_transfer; + + if (file_transfer.info != null && file_transfer.info != "") { + files_by_message_id[int.parse(file_transfer.info)] = file_transfer; + + if (file_transfer.file_sharing_id != null && file_transfer.info != null) { + string combined_identifier = file_transfer.info + file_transfer.file_sharing_id; + files_by_message_and_file_id[combined_identifier] = file_transfer; + } + } } } }
\ No newline at end of file diff --git a/libdino/src/service/jingle_file_transfers.vala b/libdino/src/service/jingle_file_transfers.vala index daccd309..e0d3fce1 100644 --- a/libdino/src/service/jingle_file_transfers.vala +++ b/libdino/src/service/jingle_file_transfers.vala @@ -74,7 +74,7 @@ public class JingleFileProvider : FileProvider, Object { return file_meta; } - public async FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + public FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { return new FileReceiveData(); } @@ -95,18 +95,14 @@ public class JingleFileProvider : FileProvider, Object { return Encryption.NONE; } - public async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError { + public async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws IOError { // TODO(hrxi) What should happen if `stream == null`? XmppStream? stream = stream_interactor.get_stream(file_transfer.account); Xmpp.Xep.JingleFileTransfer.FileTransfer? jingle_file_transfer = file_transfers[file_transfer.info]; if (jingle_file_transfer == null) { - throw new FileReceiveError.DOWNLOAD_FAILED("Transfer data not available anymore"); - } - try { - yield jingle_file_transfer.accept(stream); - } catch (IOError e) { - throw new FileReceiveError.DOWNLOAD_FAILED("Establishing connection did not work"); + throw new IOError.FAILED("Transfer data not available anymore"); } + yield jingle_file_transfer.accept(stream); return jingle_file_transfer.stream; } diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index d8ea3e2d..e8a43b05 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -307,7 +307,8 @@ public class MessageProcessor : StreamInteractionModule, Object { public override string[] after_actions { get { return after_actions_const; } } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - return (message.body == null); + return message.body == null && + Xep.StatelessFileSharing.get_file_shares(stanza) == null; } } @@ -326,8 +327,6 @@ public class MessageProcessor : StreamInteractionModule, Object { } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - if (message.body == null || outer.is_duplicate(message, stanza, conversation)) return true; - stream_interactor.get_module(MessageStorage.IDENTITY).add_message(message, conversation); return false; } @@ -371,7 +370,7 @@ public class MessageProcessor : StreamInteractionModule, Object { } } - public Entities.Message create_out_message(string text, Conversation conversation) { + public Entities.Message create_out_message(string? text, Conversation conversation) { Entities.Message message = new Entities.Message(text); message.type_ = Util.get_message_type_for_conversation(conversation); message.stanza_id = random_uuid(); diff --git a/libdino/src/service/message_storage.vala b/libdino/src/service/message_storage.vala index 3dadab7b..81df46d5 100644 --- a/libdino/src/service/message_storage.vala +++ b/libdino/src/service/message_storage.vala @@ -99,6 +99,14 @@ public class MessageStorage : StreamInteractionModule, Object { return create_message_from_row_opt(row_option, conversation); } + public Message? get_message_by_referencing_id(string id, Conversation conversation) { + if (conversation.type_ == Conversation.Type.CHAT) { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(id, conversation); + } else { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(id, conversation); + } + } + public Message? get_message_by_stanza_id(string stanza_id, Conversation conversation) { if (messages_by_stanza_id.has_key(conversation)) { Message? message = messages_by_stanza_id[conversation][stanza_id]; @@ -191,6 +199,16 @@ public class MessageStorage : StreamInteractionModule, Object { message_refs.remove_at(message_refs.size - 1); } } + + public static string? get_reference_id(Message message) { + if (message.edit_to != null) return message.edit_to; + + if (message.type_ == Message.Type.CHAT) { + return message.stanza_id; + } else { + return message.server_id; + } + } } } diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index 28ccf8ef..eb9e4fbc 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -86,7 +86,6 @@ public class ModuleManager { module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.CallInvites.Module()); module_map[account].add(new Xep.Coin.Module()); - module_map[account].add(new Xep.StatelessFileSharing.Module()); initialize_account_modules(account, module_map[account]); } } diff --git a/libdino/src/service/sfs_metadata.vala b/libdino/src/service/sfs_metadata.vala new file mode 100644 index 00000000..ff2a57b4 --- /dev/null +++ b/libdino/src/service/sfs_metadata.vala @@ -0,0 +1,81 @@ +using Gdk; +using GLib; +using Gee; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + + +namespace Dino { + public interface FileMetadataProvider : Object { + public abstract bool supports_file(File file); + public abstract async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata); + } + + class GenericFileMetadataProvider: Dino.FileMetadataProvider, Object { + public bool supports_file(File file) { + return true; + } + + public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { + FileInfo info = file.query_info("*", FileQueryInfoFlags.NONE); + + metadata.name = info.get_display_name(); + metadata.mime_type = info.get_content_type(); + metadata.size = info.get_size(); + metadata.date = info.get_modification_date_time(); + + var checksum_types = new ArrayList<ChecksumType>.wrap(new ChecksumType[] { ChecksumType.SHA256, ChecksumType.SHA512 }); + var file_hashes = yield compute_file_hashes(file, checksum_types); + + metadata.hashes.add(new CryptographicHashes.Hash.with_checksum(ChecksumType.SHA256, file_hashes[ChecksumType.SHA256])); + metadata.hashes.add(new CryptographicHashes.Hash.with_checksum(ChecksumType.SHA512, file_hashes[ChecksumType.SHA512])); + } + } + + public class ImageFileMetadataProvider: Dino.FileMetadataProvider, Object { + public bool supports_file(File file) { + return file.query_info("*", FileQueryInfoFlags.NONE).get_content_type().has_prefix("image"); + } + + private const int[] THUMBNAIL_DIMS = { 1, 2, 3, 4, 8 }; + private const string IMAGE_TYPE = "png"; + private const string MIME_TYPE = "image/png"; + + public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { + Pixbuf pixbuf = new Pixbuf.from_stream(yield file.read_async()); + metadata.width = pixbuf.get_width(); + metadata.height = pixbuf.get_height(); + float ratio = (float)metadata.width / (float) metadata.height; + + int thumbnail_width = -1; + int thumbnail_height = -1; + float diff = float.INFINITY; + for (int i = 0; i < THUMBNAIL_DIMS.length; i++) { + int test_width = THUMBNAIL_DIMS[i]; + int test_height = THUMBNAIL_DIMS[THUMBNAIL_DIMS.length - 1 - i]; + float test_ratio = (float)test_width / (float)test_height; + float test_diff = (test_ratio - ratio).abs(); + if (test_diff < diff) { + thumbnail_width = test_width; + thumbnail_height = test_height; + diff = test_diff; + } + } + + Pixbuf thumbnail_pixbuf = pixbuf.scale_simple(thumbnail_width, thumbnail_height, InterpType.BILINEAR); + uint8[] buffer; + thumbnail_pixbuf.save_to_buffer(out buffer, IMAGE_TYPE); + string base_64 = GLib.Base64.encode(buffer); + string uri = @"data:$MIME_TYPE;base64,$base_64"; + Xep.JingleContentThumbnails.Thumbnail thumbnail = new Xep.JingleContentThumbnails.Thumbnail(); + thumbnail.uri = uri; + thumbnail.media_type = MIME_TYPE; + thumbnail.width = thumbnail_width; + thumbnail.height = thumbnail_height; + metadata.thumbnails.add(thumbnail); + } + } +} + diff --git a/libdino/src/service/stateless_file_sharing.vala b/libdino/src/service/stateless_file_sharing.vala new file mode 100644 index 00000000..9d3c53ab --- /dev/null +++ b/libdino/src/service/stateless_file_sharing.vala @@ -0,0 +1,162 @@ +using Gdk; +using Gee; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +public class Dino.StatelessFileSharing : StreamInteractionModule, Object { + public static ModuleIdentity<StatelessFileSharing> IDENTITY = new ModuleIdentity<StatelessFileSharing>("sfs"); + public string id { get { return IDENTITY.id; } } + + public const int SFS_PROVIDER_ID = 2; + + public StreamInteractor stream_interactor { + owned get { return Application.get_default().stream_interactor; } + private set { } + } + + public FileManager file_manager { + owned get { return stream_interactor.get_module(FileManager.IDENTITY); } + private set { } + } + + public Database db { + owned get { return Application.get_default().db; } + private set { } + } + + private StatelessFileSharing(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(new ReceivedMessageListener(this)); + } + + public static void start(StreamInteractor stream_interactor, Database db) { + StatelessFileSharing m = new StatelessFileSharing(stream_interactor, db); + 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<Xep.StatelessFileSharing.Source>? sources) { + FileTransfer file_transfer = new FileTransfer(); + file_transfer.file_sharing_id = file_sharing_id; + file_transfer.account = message.account; + file_transfer.counterpart = message.counterpart; + file_transfer.ourpart = message.ourpart; + file_transfer.direction = message.direction; + file_transfer.time = message.time; + file_transfer.local_time = message.local_time; + file_transfer.provider = SFS_PROVIDER_ID; + file_transfer.file_metadata = metadata; + file_transfer.info = message.id.to_string(); + if (sources != null) { + file_transfer.sfs_sources = sources; + } + + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); + + conversation.last_active = file_transfer.time; + file_manager.received_file(file_transfer, conversation); + } + + public void on_received_sources(Jid from, Conversation conversation, string attach_to_message_id, string? attach_to_file_id, Gee.List<Xep.StatelessFileSharing.Source> sources) { + Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_referencing_id(attach_to_message_id, conversation); + if (message == null) return; + + FileTransfer? file_transfer = null; + if (attach_to_file_id != null) { + file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_files_by_message_and_file_id(message.id, attach_to_file_id, conversation); + } else { + file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_message_id(message.id, conversation); + } + if (file_transfer == null) return; + + // "If no <hash/> is provided or the <hash/> 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"); + return; + } + + foreach (var source in sources) { + 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); + } + } + + /* + 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; + + var sources = new ArrayList<Xep.StatelessFileSharing.Source>(); + 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); + } + } + + 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 { + + 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; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Gee.List<Xep.StatelessFileSharing.FileShare> 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; + } + } + return false; + } + } +}
\ No newline at end of file diff --git a/libdino/src/util/limit_input_stream.vala b/libdino/src/util/limit_input_stream.vala new file mode 100644 index 00000000..5569d778 --- /dev/null +++ b/libdino/src/util/limit_input_stream.vala @@ -0,0 +1,73 @@ +public class Dino.LimitInputStream : InputStream, PollableInputStream { + private InputStream inner; + public int64 max_bytes { public get; private set; } + public int64 retrieved_bytes { public get; private set; } + + public int64 remaining_bytes { get { + return max_bytes < 0 ? -1 : max_bytes - retrieved_bytes; + }} + + public LimitInputStream(InputStream inner, int64 max_bytes) { + this.inner = inner; + this.max_bytes = max_bytes; + } + + public bool can_poll() { + return inner is PollableInputStream && ((PollableInputStream)inner).can_poll(); + } + + public PollableSource create_source(Cancellable? cancellable = null) { + if (!can_poll()) throw new IOError.NOT_SUPPORTED("Stream is not pollable"); + return ((PollableInputStream)inner).create_source(cancellable); + } + + public bool is_readable() { + if (!can_poll()) throw new IOError.NOT_SUPPORTED("Stream is not pollable"); + return remaining_bytes == 0 || ((PollableInputStream)inner).is_readable(); + } + + private ssize_t check_limit(ssize_t read) throws IOError { + if (remaining_bytes - (int64) read < 0) throw new IOError.FAILED("Stream length exceeded limit"); + this.retrieved_bytes += read; + return read; + } + + public override ssize_t read(uint8[] buffer, Cancellable? cancellable = null) throws IOError { + if (remaining_bytes == 0) return 0; + int original_buffer_length = buffer.length; + if (remaining_bytes != -1 && (int64) buffer.length > remaining_bytes) { + // Never read more than remaining_bytes by limiting the buffer length + buffer.length = (int) remaining_bytes; + } + ssize_t read_bytes = inner.read(buffer, cancellable); + this.retrieved_bytes += read_bytes; + buffer.length = original_buffer_length; + return read_bytes; + } + + public override async ssize_t read_async(uint8[]? buffer, int io_priority = GLib.Priority.DEFAULT, Cancellable? cancellable = null) throws IOError { + if (remaining_bytes == 0) return 0; + int original_buffer_length = buffer.length; + if (remaining_bytes != -1 && (int64) buffer.length > remaining_bytes) { + // Never read more than remaining_bytes by limiting the buffer length + buffer.length = (int) remaining_bytes; + } + ssize_t read_bytes = yield inner.read_async(buffer, io_priority, cancellable); + this.retrieved_bytes += read_bytes; + buffer.length = original_buffer_length; + return read_bytes; + } + + public ssize_t read_nonblocking_fn(uint8[] buffer) throws Error { + if (!is_readable()) throw new IOError.WOULD_BLOCK("Stream is not readable"); + return read(buffer); + } + + public override bool close(Cancellable? cancellable = null) throws IOError { + return inner.close(cancellable); + } + + public override async bool close_async(int io_priority = GLib.Priority.DEFAULT, Cancellable? cancellable = null) throws IOError { + return yield inner.close_async(io_priority, cancellable); + } +}
\ No newline at end of file diff --git a/libdino/src/util/util.vala b/libdino/src/util/util.vala index 9f7ae45f..31f4c105 100644 --- a/libdino/src/util/util.vala +++ b/libdino/src/util/util.vala @@ -1,3 +1,6 @@ +using Gee; +using Xmpp; + namespace Dino { private extern const string SYSTEM_LIBDIR_NAME; @@ -90,4 +93,32 @@ public static void internationalize(string gettext_package, string locales_dir) Intl.bindtextdomain(gettext_package, locales_dir); } +public static async HashMap<ChecksumType, string> compute_file_hashes(File file, Gee.List<ChecksumType> checksum_types) { + var checksums = new Checksum[checksum_types.size]; + + for (int i = 0; i < checksum_types.size; i++) { + checksums[i] = new Checksum(checksum_types.get(i)); + } + + FileInputStream stream = yield file.read_async(); + uint8 fbuf[1024]; + size_t size; + while ((size = yield stream.read_async(fbuf)) > 0) { + for (int i = 0; i < checksum_types.size; i++) { + checksums[i].update(fbuf, size); + } + } + + var ret = new HashMap<ChecksumType, string>(); + for (int i = 0; i < checksum_types.size; i++) { + var checksum_type = checksum_types.get(i); + uint8[] digest = new uint8[64]; + size_t length = digest.length; + checksums[i].get_digest(digest, ref length); + string computed_hash = GLib.Base64.encode(digest[0:length]); + ret[checksum_type] = computed_hash; + } + return ret; +} + } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index fa5f56e8..70665903 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -19,6 +19,7 @@ set(RESOURCE_LIST icons/scalable/actions/dino-emoticon-add-symbolic.svg icons/scalable/actions/dino-qr-code-symbolic.svg + icons/scalable/actions/small-x-symbolic.svg icons/scalable/apps/im.dino.Dino.svg icons/scalable/apps/im.dino.Dino-symbolic.svg @@ -185,7 +186,7 @@ SOURCES src/ui/conversation_content_view/date_separator_populator.vala src/ui/conversation_content_view/file_default_widget.vala src/ui/conversation_content_view/file_image_widget.vala - src/ui/conversation_content_view/file_preview_widget.vala + src/ui/conversation_content_view/file_transmission_progress.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/data/gresource.xml b/main/data/gresource.xml index edceea3e..661185ab 100644 --- a/main/data/gresource.xml +++ b/main/data/gresource.xml @@ -24,6 +24,7 @@ <file>gtk/help-overlay.ui</file> <file>icons/scalable/actions/dino-emoticon-add-symbolic.svg</file> <file>icons/scalable/actions/dino-qr-code-symbolic.svg</file> + <file>icons/scalable/actions/small-x-symbolic.svg</file> <file>icons/scalable/apps/im.dino.Dino-symbolic.svg</file> <file>icons/scalable/apps/im.dino.Dino.svg</file> <file>icons/scalable/devices/dino-device-desktop-symbolic.svg</file> diff --git a/main/data/icons/scalable/actions/small-x-symbolic.svg b/main/data/icons/scalable/actions/small-x-symbolic.svg new file mode 100644 index 00000000..aadc5d1e --- /dev/null +++ b/main/data/icons/scalable/actions/small-x-symbolic.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 4 4 h 1 h 0.03125 c 0.253906 0.011719 0.511719 0.128906 0.6875 0.3125 l 2.28125 2.28125 l 2.3125 -2.28125 c 0.265625 -0.230469 0.445312 -0.304688 0.6875 -0.3125 h 1 v 1 c 0 0.285156 -0.035156 0.550781 -0.25 0.75 l -2.28125 2.28125 l 2.25 2.25 c 0.1875 0.1875 0.28125 0.453125 0.28125 0.71875 v 1 h -1 c -0.265625 0 -0.53125 -0.09375 -0.71875 -0.28125 l -2.28125 -2.28125 l -2.28125 2.28125 c -0.1875 0.1875 -0.453125 0.28125 -0.71875 0.28125 h -1 v -1 c 0 -0.265625 0.09375 -0.53125 0.28125 -0.71875 l 2.28125 -2.25 l -2.28125 -2.28125 c -0.210938 -0.195312 -0.304688 -0.46875 -0.28125 -0.75 z m 0 0" fill="#222222"/></svg> diff --git a/main/data/style.css b/main/data/style.css index 5b9da21d..0909f8a0 100644 --- a/main/data/style.css +++ b/main/data/style.css @@ -181,6 +181,28 @@ window.dino-main .file-image-widget .file-box-outer button:hover { border-radius: 6px; } +.dino-main .file-details { + color: white; + background: alpha(black, 0.3); + border-radius: 5px; + padding: 5px 10px; +} + +.dino-main .circular-loading-indicator { + border-radius: 50%; + padding: 5px; + transition: background-image 0.5s linear; +} + +.dino-main .circular-loading-indicator > * { + border-radius: 50%; + background: @theme_bg_color; +} + +.dino-main .circular-loading-indicator button { + padding: 2px; +} + /* Call widget */ window.dino-main .call-box-outer.incoming { diff --git a/main/meson.build b/main/meson.build index 30a28032..2dcf751b 100644 --- a/main/meson.build +++ b/main/meson.build @@ -47,6 +47,7 @@ sources = files( 'src/ui/conversation_content_view/date_separator_populator.vala', '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_widget.vala', 'src/ui/conversation_content_view/item_actions.vala', 'src/ui/conversation_content_view/message_widget.vala', @@ -77,6 +78,7 @@ sources = files( 'src/ui/util/accounts_combo_box.vala', 'src/ui/util/config.vala', 'src/ui/util/data_forms.vala', + 'src/ui/util/file_metadata_providers.vala', 'src/ui/util/helper.vala', 'src/ui/util/label_hybrid.vala', 'src/ui/util/preference_group.vala', 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 352c8d7a..02249b3f 100644 --- a/main/src/ui/conversation_content_view/file_default_widget.vala +++ b/main/src/ui/conversation_content_view/file_default_widget.vala @@ -39,7 +39,7 @@ public class FileDefaultWidget : Box { }); } - public void update_file_info(string? mime_type, FileTransfer.State state, long size) { + public void update_file_info(string? mime_type, FileTransfer.State state, int64 size) { this.state = state; spinner.stop(); // A hidden spinning spinner still uses CPU. Deactivate asap @@ -132,7 +132,7 @@ public class FileDefaultWidget : Box { } } - private static string get_size_string(long size) { + public static string get_size_string(int64 size) { if (size < 1024) { return @"$(size) B"; } else if (size < 1000 * 1000) { 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 a3579185..d5415f31 100644 --- a/main/src/ui/conversation_content_view/file_image_widget.vala +++ b/main/src/ui/conversation_content_view/file_image_widget.vala @@ -1,51 +1,37 @@ using Gee; using Gdk; using Gtk; +using Xmpp; using Dino.Entities; namespace Dino.Ui { public class FileImageWidget : Box { + enum State { + EMPTY, + PREVIEW, + IMAGE + } + private State state = State.EMPTY; - public FileImageWidget() { - this.halign = Align.START; + private Stack stack = new Stack() { transition_duration=600, transition_type=StackTransitionType.CROSSFADE }; + private Overlay overlay = new Overlay(); - this.add_css_class("file-image-widget"); - this.set_cursor_from_name("zoom-in"); - } + private bool show_image_overlay_toolbar = false; + 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 }; - public async void load_from_file(File file, string file_name, int MAX_WIDTH=600, int MAX_HEIGHT=300) throws GLib.Error { - Gtk.Box image_overlay_toolbar = new Gtk.Box(Orientation.HORIZONTAL, 0) { halign=Gtk.Align.END, valign=Gtk.Align.START, margin_top=10, margin_start=10, margin_end=10, margin_bottom=10, vexpand=false, visible=false }; - image_overlay_toolbar.add_css_class("card"); - image_overlay_toolbar.add_css_class("toolbar"); - image_overlay_toolbar.add_css_class("overlay-toolbar"); - image_overlay_toolbar.set_cursor_from_name("default"); + private FileTransfer file_transfer; - FixedRatioPicture image = new FixedRatioPicture() { min_width=100, min_height=100, max_width=MAX_WIDTH, max_height=MAX_HEIGHT, file=file }; - GestureClick gesture_click_controller = new GestureClick(); - gesture_click_controller.button = 1; // listen for left clicks - gesture_click_controller.released.connect((n_press, x, y) => { - switch (gesture_click_controller.get_device().source) { - case Gdk.InputSource.TOUCHSCREEN: - case Gdk.InputSource.PEN: - if (n_press == 1) { - image_overlay_toolbar.visible = !image_overlay_toolbar.visible; - } else if (n_press == 2) { - this.activate_action("file.open", null); - image_overlay_toolbar.visible = false; - } - break; - default: - this.activate_action("file.open", null); - image_overlay_toolbar.visible = false; - break; - } - }); - image.add_controller(gesture_click_controller); + private FileTransmissionProgress transmission_progress = new FileTransmissionProgress() { halign=Align.CENTER, valign=Align.CENTER, visible=false }; + + public FileImageWidget(int MAX_WIDTH=600, int MAX_HEIGHT=300) { + this.halign = Align.START; - FileInfo file_info = file.query_info("*", FileQueryInfoFlags.NONE); + this.add_css_class("file-image-widget"); + // Setup menu button overlay MenuButton button = new MenuButton(); button.icon_name = "view-more"; Menu menu_model = new Menu(); @@ -53,27 +39,216 @@ public class FileImageWidget : Box { 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("overlay-toolbar"); + image_overlay_toolbar.set_cursor_from_name("default"); - Overlay overlay = new Overlay(); - overlay.set_child(image); + file_size_label.add_css_class("file-details"); + + overlay.set_child(stack); + overlay.set_measure_overlay(stack, true); + overlay.add_overlay(file_size_label); + overlay.add_overlay(transmission_progress); overlay.add_overlay(image_overlay_toolbar); - overlay.set_measure_overlay(image, true); overlay.set_clip_overlay(image_overlay_toolbar, true); + this.append(overlay); + + GestureClick gesture_click_controller = new GestureClick(); + gesture_click_controller.button = 1; // listen for left clicks + gesture_click_controller.released.connect(on_image_clicked); + stack.add_controller(gesture_click_controller); + EventControllerMotion this_motion_events = new EventControllerMotion(); this.add_controller(this_motion_events); this_motion_events.enter.connect(() => { - image_overlay_toolbar.visible = true; + image_overlay_toolbar.visible = show_image_overlay_toolbar; + 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; }); this_motion_events.leave.connect(() => { if (button.popover != null && button.popover.visible) return; image_overlay_toolbar.visible = false; + file_size_label.visible = false; }); + } - this.append(overlay); + 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, (_, from_value, ref to_value) => { + to_value = FileDefaultWidget.get_size_string((int64) from_value); + return true; + }); + 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 void refresh_state() { + if ((state == EMPTY || state == PREVIEW) && file_transfer.path != null) { + if (state == EMPTY) { + load_from_file.begin(file_transfer.get_file(), file_transfer.file_name); + show_image_overlay_toolbar = true; + } if (state == PREVIEW) { + Timeout.add(500, () => { + load_from_file.begin(file_transfer.get_file(), file_transfer.file_name); + show_image_overlay_toolbar = true; + return false; + }); + } + this.set_cursor_from_name("zoom-in"); + + state = IMAGE; + } else if (state == EMPTY && file_transfer.thumbnails.size > 0) { + load_from_thumbnail.begin(file_transfer); + + transmission_progress.visible = true; + show_image_overlay_toolbar = false; + + state = PREVIEW; + } + + if (file_transfer.state == IN_PROGRESS || file_transfer.state == NOT_STARTED || file_transfer.state == FAILED) { + transmission_progress.visible = true; + show_image_overlay_toolbar = false; + } else if (transmission_progress.visible) { + Timeout.add(500, () => { + transmission_progress.transferred_size = transmission_progress.file_size; + transmission_progress.visible = false; + show_image_overlay_toolbar = true; + return false; + }); + } + + if (file_transfer.state == FileTransfer.State.IN_PROGRESS) { + if (file_transfer.direction == FileTransfer.DIRECTION_RECEIVED) { + transmission_progress.state = FileTransmissionProgress.State.DOWNLOADING; + } else { + transmission_progress.state = FileTransmissionProgress.State.UPLOADING; + } + } else if (file_transfer.sfs_sources.is_empty) { + transmission_progress.state = UNKNOWN_SOURCE; + } else if (file_transfer.state == NOT_STARTED && file_transfer.direction == FileTransfer.DIRECTION_RECEIVED) { + transmission_progress.state = DOWNLOAD_NOT_STARTED; + } else if (file_transfer.state == FileTransfer.State.FAILED) { + transmission_progress.state = DOWNLOAD_NOT_STARTED_FAILED_BEFORE; + } + } + + 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 }; + image.file = file; + stack.add_child(image); + stack.set_visible_child(image); + } + + public async void load_from_thumbnail(FileTransfer file_transfer) throws GLib.Error { + this.file_transfer = file_transfer; + + Gdk.Pixbuf? pixbuf = null; + foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in file_transfer.thumbnails) { + pixbuf = parse_thumbnail(thumbnail); + if (pixbuf != null) { + break; + } + } + if (pixbuf == null) { + warning("Can't load thumbnails of file %s", file_transfer.file_name); + throw new Error(-1, 0, "Error loading preview image"); + } + // TODO: should this be executed? If yes, before or after scaling + pixbuf = pixbuf.apply_embedded_orientation(); + + if (file_transfer.width > 0 && file_transfer.height > 0) { + pixbuf = pixbuf.scale_simple(file_transfer.width, file_transfer.height, InterpType.BILINEAR); + } else { + warning("Preview: Not scaling image, width: %d, height: %d\n", file_transfer.width, file_transfer.height); + } + if (pixbuf == null) { + warning("Can't scale thumbnail %s", file_transfer.file_name); + 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 }; + image.paintable = Texture.for_pixbuf(pixbuf); + stack.add_child(image); + stack.set_visible_child(image); + } + + public void on_image_clicked(GestureClick gesture_click_controller, int n_press, double x, double y) { + if (this.file_transfer.state != COMPLETE) return; + + switch (gesture_click_controller.get_device().source) { + case Gdk.InputSource.TOUCHSCREEN: + case Gdk.InputSource.PEN: + if (n_press == 1) { + image_overlay_toolbar.visible = !image_overlay_toolbar.visible; + } else if (n_press == 2) { + this.activate_action("file.open", null); + image_overlay_toolbar.visible = false; + } + break; + default: + this.activate_action("file.open", null); + image_overlay_toolbar.visible = false; + break; + } + } + + public static Pixbuf? parse_thumbnail(Xep.JingleContentThumbnails.Thumbnail thumbnail) { + string[] splits = thumbnail.uri.split(":", 2); + if (splits.length != 2) { + warning("Thumbnail parsing error: ':' not found"); + return null; + } + if (splits[0] != "data") { + warning("Unsupported thumbnail: unimplemented uri type\n"); + return null; + } + splits = splits[1].split(";", 2); + if (splits.length != 2) { + warning("Thumbnail parsing error: ';' not found"); + return null; + } + if (splits[0] != "image/png") { + warning("Unsupported thumbnail: unsupported mime-type\n"); + return null; + } + splits = splits[1].split(",", 2); + if (splits.length != 2) { + warning("Thumbnail parsing error: ',' not found"); + return null; + } + if (splits[0] != "base64") { + warning("Unsupported thumbnail: data is not base64 encoded\n"); + return null; + } + uint8[] data = Base64.decode(splits[1]); + MemoryInputStream input_stream = new MemoryInputStream.from_data(data); + Pixbuf pixbuf = new Pixbuf.from_stream(input_stream); + return pixbuf; + } + + public static bool can_display(FileTransfer file_transfer) { + return file_transfer.mime_type != null && is_pixbuf_supported_mime_type(file_transfer.mime_type) && + (file_transfer.state == FileTransfer.State.COMPLETE || file_transfer.thumbnails.size > 0); + } + + public static bool is_pixbuf_supported_mime_type(string mime_type) { + if (mime_type == null) return false; + + foreach (PixbufFormat pixbuf_format in Pixbuf.get_formats()) { + foreach (string pixbuf_mime in pixbuf_format.get_mime_types()) { + if (pixbuf_mime == mime_type) return true; + } + } + return false; } } diff --git a/main/src/ui/conversation_content_view/file_preview_widget.vala b/main/src/ui/conversation_content_view/file_preview_widget.vala deleted file mode 100644 index e7cee93a..00000000 --- a/main/src/ui/conversation_content_view/file_preview_widget.vala +++ /dev/null @@ -1,90 +0,0 @@ -using Gee; -using Gdk; -using Gtk; -using Xmpp; - -using Dino.Entities; - -namespace Dino.Ui { - - public class FilePreviewWidget : Box { - - private ScalingImage image; - FileDefaultWidget file_default_widget; - FileDefaultWidgetController file_default_widget_controller; - - public FilePreviewWidget() { - this.halign = Align.START; - - this.add_css_class("file-preview-widget"); - } - - public async void load_from_thumbnail(FileTransfer file_transfer, StreamInteractor stream_interactor, int MAX_WIDTH=600, int MAX_HEIGHT=300) throws GLib.Error { - Thread<ScalingImage?> thread = new Thread<ScalingImage?> (null, () => { - ScalingImage image = new ScalingImage() { halign=Align.START, visible = true, max_width = MAX_WIDTH, max_height = MAX_HEIGHT }; - Gdk.Pixbuf? pixbuf = null; - foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in file_transfer.thumbnails) { - pixbuf = ImageFileMetadataProvider.parse_thumbnail(thumbnail); - if (pixbuf != null) { - break; - } - } - if (pixbuf == null) { - warning("Can't load thumbnails of file %s", file_transfer.file_name); - Idle.add(load_from_thumbnail.callback); - throw new Error(-1, 0, "Error loading preview image"); - } - // TODO: should this be executed? If yes, before or after scaling - pixbuf = pixbuf.apply_embedded_orientation(); - - if (file_transfer.width > 0 && file_transfer.height > 0) { - pixbuf = pixbuf.scale_simple(file_transfer.width, file_transfer.height, InterpType.BILINEAR); - } else { - warning("Preview: Not scaling image, width: %d, height: %d\n", file_transfer.width, file_transfer.height); - } - if (pixbuf == null) { - warning("Can't scale thumbnail %s", file_transfer.file_name); - throw new Error(-1, 0, "Error scaling preview image"); - } - - image.load(pixbuf); - Idle.add(load_from_thumbnail.callback); - return image; - }); - yield; - this.image = thread.join(); - - file_default_widget = new FileDefaultWidget() { valign=Align.END, vexpand=false, visible=false }; - file_default_widget.image_stack.visible = false; - file_default_widget_controller = new FileDefaultWidgetController(file_default_widget); - file_default_widget_controller.set_file_transfer(file_transfer, stream_interactor); - - Overlay overlay = new Overlay(); - overlay.set_child(image); - overlay.add_overlay(file_default_widget); - overlay.set_measure_overlay(image, true); - overlay.set_clip_overlay(file_default_widget, true); - - EventControllerMotion this_motion_events = new EventControllerMotion(); - this.add_controller(this_motion_events); - this_motion_events.enter.connect(() => { - file_default_widget.visible = true; - }); - this_motion_events.leave.connect(() => { - if (file_default_widget.file_menu.popover != null && file_default_widget.file_menu.popover.visible) return; - - file_default_widget.visible = false; - }); - GestureClick gesture_click_controller = new GestureClick(); - gesture_click_controller.set_button(1); // listen for left clicks - this.add_controller(gesture_click_controller); - gesture_click_controller.pressed.connect((n_press, x, y) => { - // Check whether the click was inside the file menu. Otherwise, open the file. - this.file_default_widget.clicked(); - }); - - this.append(overlay); - } - } - -} diff --git a/main/src/ui/conversation_content_view/file_transmission_progress.vala b/main/src/ui/conversation_content_view/file_transmission_progress.vala new file mode 100644 index 00000000..bf3f377b --- /dev/null +++ b/main/src/ui/conversation_content_view/file_transmission_progress.vala @@ -0,0 +1,120 @@ +using Gee; +using Gdk; +using Gtk; +using Xmpp; + +using Dino.Entities; + +namespace Dino.Ui { + + public class FileTransmissionProgress : Adw.Bin { + + public enum State { + UNKNOWN_SOURCE, + DOWNLOAD_NOT_STARTED, + DOWNLOAD_NOT_STARTED_FAILED_BEFORE, + DOWNLOADING, + UPLOADING + } + + public int64 file_size { get; set; } + public int64 transferred_size { get; set; } + public State state { get; set; } + + private CssProvider css_provider = new CssProvider(); + private Button button = new Button(); + + private uint64 next_update_time = 0; + private int64 last_progress_percent = 0; + private uint update_progress_timeout_id = -1; + + construct { + add_css_class("circular-loading-indicator"); + + button.add_css_class("circular"); + Adw.Bin holder = new Adw.Bin(); + holder.set_child(button); + this.set_child(holder); + + this.button.clicked.connect(on_button_clicked); + + this.notify["transferred-size"].connect(on_transferred_size_update); + this.notify["state"].connect(on_state_changed); + on_state_changed(); + } + + private void on_transferred_size_update() { + if (update_progress_timeout_id == -1) { + int64 progress_percent = transferred_size * 100 / file_size; + if (progress_percent != last_progress_percent) { + uint64 time_now = get_monotonic_time() / 1000; + if (next_update_time > time_now) { + update_progress_timeout_id = Timeout.add((uint) (next_update_time - time_now), () => { + update_progress(); + update_progress_timeout_id = -1; + return Source.REMOVE; + }); + } else { + update_progress(); + } + } + } + } + + private void on_state_changed() { + sensitive = state != UNKNOWN_SOURCE; + + switch (this.state) { + case UNKNOWN_SOURCE: + case DOWNLOAD_NOT_STARTED: + button.icon_name = "document-save-symbolic"; + break; + case DOWNLOADING: + case UPLOADING: + button.icon_name = "small-x-symbolic"; + break; + case DOWNLOAD_NOT_STARTED_FAILED_BEFORE: + button.icon_name = "dialog-warning-symbolic"; + break; + } + } + + private void update_progress() { + this.get_style_context().remove_provider(css_provider); + css_provider = new CssProvider(); + int64 progress_percent = transferred_size * 100 / file_size; + + css_provider.load_from_string(@" + .circular-loading-indicator { + background-image: conic-gradient(@accent_color $(progress_percent)%, transparent $(progress_percent)%); + } + "); + + this.get_style_context().add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + next_update_time = get_monotonic_time() / 1000 + 500; + last_progress_percent = progress_percent; + } + + private void on_button_clicked() { + switch (this.state) { + case UNKNOWN_SOURCE: + break; + case DOWNLOAD_NOT_STARTED_FAILED_BEFORE: + case DOWNLOAD_NOT_STARTED: + this.activate_action("file.download", null); + break; + case DOWNLOADING: + case UPLOADING: + this.activate_action("file.cancel", null); + break; + } + } + + public override void dispose() { + if (update_progress_timeout_id != -1) { + Source.remove(update_progress_timeout_id); + update_progress_timeout_id = -1; + } + } + } +}
\ No newline at end of file diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index 69781f30..583609d2 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -27,7 +27,7 @@ public class FileMetaItem : ConversationSummary.ContentMetaItem { } public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) { - if (file_transfer.provider != 0 || file_transfer.info == null) return null; + if ((file_transfer.provider != FileManager.HTTP_PROVIDER_ID && file_transfer.provider != FileManager.SFS_PROVIDER_ID) || file_transfer.info == null) return null; Gee.List<Plugins.MessageAction> actions = new ArrayList<Plugins.MessageAction>(); @@ -43,7 +43,6 @@ public class FileWidget : SizeRequestBox { enum State { IMAGE, - IMAGE_PREVIEW, DEFAULT } @@ -90,13 +89,15 @@ public class FileWidget : SizeRequestBox { } private async void update_widget() { - if (show_image() && state != State.IMAGE) { + bool show_image = FileImageWidget.can_display(file_transfer); + + if (show_image && state != State.IMAGE) { var content_bak = content; FileImageWidget file_image_widget = null; try { file_image_widget = new FileImageWidget(); - yield file_image_widget.load_from_file(file_transfer.get_file(), file_transfer.file_name); + yield file_image_widget.set_file_transfer(file_transfer); // If the widget changed in the meanwhile, stop if (content != content_bak) return; @@ -109,26 +110,7 @@ public class FileWidget : SizeRequestBox { } catch (Error e) { } } - if (show_preview() && state != State.IMAGE_PREVIEW) { - var content_bak = content; - - FilePreviewWidget file_preview_widget = null; - try { - file_preview_widget = new FilePreviewWidget() { visible=true }; - yield file_preview_widget.load_from_thumbnail(file_transfer, stream_interactor); - - // If the widget changed in the meanwhile, stop - if (content != content_bak) return; - - if (content != null) this.remove(content); - content = file_preview_widget; - state = State.IMAGE_PREVIEW; - this.append(content); - return; - } catch (Error e) { } - } - - if (!show_image() && state != State.DEFAULT && state != State.IMAGE_PREVIEW) { + if (!show_image && state != State.DEFAULT) { if (content != null) this.remove(content); FileDefaultWidget default_file_widget = new FileDefaultWidget(); default_widget_controller = new FileDefaultWidgetController(default_file_widget); @@ -139,31 +121,6 @@ public class FileWidget : SizeRequestBox { } } - private bool show_image() { - if (file_transfer.mime_type == null) return false; - - // If the image is being sent by this client, we already have the file - bool in_progress_from_us = file_transfer.direction == FileTransfer.DIRECTION_SENT && - file_transfer.ourpart.equals(file_transfer.account.full_jid) && - file_transfer.state == FileTransfer.State.IN_PROGRESS; - if (file_transfer.state != FileTransfer.State.COMPLETE && !in_progress_from_us) { - return false; - } - - foreach (PixbufFormat pixbuf_format in Pixbuf.get_formats()) { - foreach (string mime_type in pixbuf_format.get_mime_types()) { - if (mime_type == file_transfer.mime_type) { - return true; - } - } - } - return false; - } - - private bool show_preview() { - return !this.file_transfer.thumbnails.is_empty; - } - public override void dispose() { if (default_widget_controller != null) default_widget_controller.dispose(); default_widget_controller = null; diff --git a/main/src/ui/widgets/avatar_picture.vala b/main/src/ui/widgets/avatar_picture.vala index fb254915..ef1462ae 100644 --- a/main/src/ui/widgets/avatar_picture.vala +++ b/main/src/ui/widgets/avatar_picture.vala @@ -285,7 +285,7 @@ public class Dino.Ui.CompatAvatarDrawer { private Cairo.Surface sub_surface_idx(Cairo.Context ctx, int idx, int width, int height, int font_factor = 1) { ViewModel.AvatarPictureTileModel tile = (ViewModel.AvatarPictureTileModel) this.model.tiles.get_item(idx); - Gdk.Pixbuf? avatar = new Gdk.Pixbuf.from_file(tile.image_file.get_path()); + Gdk.Pixbuf? avatar = tile.image_file != null ? new Gdk.Pixbuf.from_file(tile.image_file.get_path()) : null; string? name = idx >= 0 ? tile.display_text : ""; Gdk.RGBA hex_color = tile.background_color; return sub_surface(ctx, font_family, avatar, name, hex_color, width, height, font_factor); diff --git a/plugins/http-files/src/file_provider.vala b/plugins/http-files/src/file_provider.vala index bbee8e50..420e041d 100644 --- a/plugins/http-files/src/file_provider.vala +++ b/plugins/http-files/src/file_provider.vala @@ -38,9 +38,10 @@ public class FileProvider : Dino.FileProvider, Object { } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - if (Xep.StatelessFileSharing.MessageFlag.get_flag(stanza) != null) { - return true; + if (Xep.StatelessFileSharing.get_file_shares(stanza) != null || Xep.StatelessFileSharing.get_source_attachments(stanza) != null) { + return false; } + string? oob_url = Xmpp.Xep.OutOfBandData.get_url_from_message(stanza); bool normal_file = oob_url != null && oob_url == message.body && FileProvider.http_url_regex.match(message.body); bool omemo_file = FileProvider.omemo_url_regex.match(message.body); @@ -52,57 +53,6 @@ public class FileProvider : Dino.FileProvider, Object { } } - private class LimitInputStream : InputStream, PollableInputStream { - InputStream inner; - int64 remaining_size; - - public LimitInputStream(InputStream inner, int64 max_size) { - this.inner = inner; - this.remaining_size = max_size; - } - - public bool can_poll() { - return inner is PollableInputStream && ((PollableInputStream)inner).can_poll(); - } - - public PollableSource create_source(Cancellable? cancellable = null) { - if (!can_poll()) throw new IOError.NOT_SUPPORTED("Stream is not pollable"); - return ((PollableInputStream)inner).create_source(cancellable); - } - - public bool is_readable() { - if (!can_poll()) throw new IOError.NOT_SUPPORTED("Stream is not pollable"); - return remaining_size <= 0 || ((PollableInputStream)inner).is_readable(); - } - - private ssize_t check_limit(ssize_t read) throws IOError { - this.remaining_size -= read; - if (remaining_size < 0) throw new IOError.FAILED("Stream length exceeded limit"); - return read; - } - - public override ssize_t read(uint8[] buffer, Cancellable? cancellable = null) throws IOError { - return check_limit(inner.read(buffer, cancellable)); - } - - public override async ssize_t read_async(uint8[]? buffer, int io_priority = GLib.Priority.DEFAULT, Cancellable? cancellable = null) throws IOError { - return check_limit(yield inner.read_async(buffer, io_priority, cancellable)); - } - - public ssize_t read_nonblocking_fn(uint8[] buffer) throws Error { - if (!is_readable()) throw new IOError.WOULD_BLOCK("Stream is not readable"); - return read(buffer); - } - - public override bool close(Cancellable? cancellable = null) throws IOError { - return inner.close(cancellable); - } - - public override async bool close_async(int io_priority = GLib.Priority.DEFAULT, Cancellable? cancellable = null) throws IOError { - return yield inner.close_async(io_priority, cancellable); - } - } - private void on_file_message(Entities.Message message, Conversation conversation) { var additional_info = message.id.to_string(); @@ -155,7 +105,7 @@ public class FileProvider : Dino.FileProvider, Object { return Encryption.NONE; } - public async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError { + public async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws IOError { HttpFileReceiveData? http_receive_data = receive_data as HttpFileReceiveData; if (http_receive_data == null) assert(false); @@ -166,25 +116,20 @@ public class FileProvider : Dino.FileProvider, Object { get_message.accept_certificate.connect((peer_cert, errors) => { return ConnectionManager.on_invalid_certificate(transfer_host, peer_cert, errors); }); #endif - try { #if SOUP_3_0 - InputStream stream = yield session.send_async(get_message, GLib.Priority.LOW, file_transfer.cancellable); + InputStream stream = yield session.send_async(get_message, GLib.Priority.LOW, file_transfer.cancellable); #else - InputStream stream = yield session.send_async(get_message, file_transfer.cancellable); + InputStream stream = yield session.send_async(get_message, file_transfer.cancellable); #endif - if (file_meta.size != -1) { - return new LimitInputStream(stream, file_meta.size); - } else { - return stream; - } - } catch (Error e) { - throw new FileReceiveError.DOWNLOAD_FAILED("Downloading file error: %s".printf(e.message)); + if (file_meta.size != -1) { + return new LimitInputStream(stream, file_meta.size); + } else { + return stream; } } public FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError { - // TODO: replace '2' with constant? - if (file_transfer.provider == 2) { + if (file_transfer.provider == FileManager.SFS_PROVIDER_ID) { var file_meta = new HttpFileMeta(); file_meta.size = file_transfer.size; file_meta.mime_type = file_transfer.mime_type; @@ -210,26 +155,19 @@ public class FileProvider : Dino.FileProvider, Object { return file_meta; } - public async FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { - // TODO: replace '2' with constant? - if (file_transfer.provider == 2) { - Xep.StatelessFileSharing.HttpSource http_source = null; - for(int i = 0; i < file_transfer.sfs_sources.get_n_items(); i++) { - Object source_object = file_transfer.sfs_sources.get_item(i); - FileTransfer.SerializedSfsSource source = source_object as FileTransfer.SerializedSfsSource; - if (source.type == Xep.StatelessFileSharing.HttpSource.SOURCE_TYPE) { - http_source = yield Xep.StatelessFileSharing.HttpSource.deserialize(source.data); - assert(source != null); + public FileReceiveData? 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; } } - if (http_source == null) { - printerr("Sfs file transfer has no http sources attached!"); - return 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; diff --git a/plugins/http-files/src/file_sender.vala b/plugins/http-files/src/file_sender.vala index 91b50944..02336b43 100644 --- a/plugins/http-files/src/file_sender.vala +++ b/plugins/http-files/src/file_sender.vala @@ -17,7 +17,7 @@ public class HttpFileSender : FileSender, Object { session.user_agent = @"Dino/$(Dino.get_short_version()) "; stream_interactor.stream_negotiated.connect(on_stream_negotiated); - stream_interactor.get_module(MessageProcessor.IDENTITY).build_message_stanza.connect(check_add_oob); + stream_interactor.get_module(MessageProcessor.IDENTITY).build_message_stanza.connect(check_add_sfs_element); } public async FileSendData? prepare_send_file(Conversation conversation, FileTransfer file_transfer, FileMeta file_meta) throws FileSendError { @@ -43,39 +43,55 @@ public class HttpFileSender : FileSender, Object { HttpFileSendData? send_data = file_send_data as HttpFileSendData; if (send_data == null) return; - yield upload(file_transfer, send_data, file_meta); + bool can_reference_element = !conversation.type_.is_muc_semantic() || stream_interactor.get_module(EntityInfo.IDENTITY).has_feature_cached(conversation.account, conversation.counterpart, Xep.UniqueStableStanzaIDs.NS_URI); + + // Share unencrypted files via SFS (only if we'll be able to reference messages) + if (conversation.encryption == Encryption.NONE && can_reference_element) { + // Announce the file share + Entities.Message file_share_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(null, conversation); + 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); + + // Upload file + yield upload(file_transfer, send_data, file_meta); + + // 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 (file_share_message.server_id == null) { + ulong server_id_notify_id = file_share_message.notify["server-id"].connect(() => { + Idle.add(send_file.callback); + }); + yield; + file_share_message.disconnect(server_id_notify_id); + } + } + + file_transfer.sfs_sources.add(new Xep.StatelessFileSharing.HttpSource() { url=send_data.url_down } ); + + // Send source attachment + MessageStanza stanza = new MessageStanza() { to = conversation.counterpart, type_ = conversation.type_ == GROUPCHAT ? MessageStanza.TYPE_GROUPCHAT : MessageStanza.TYPE_CHAT }; + stanza.body = send_data.url_down; + Xep.OutOfBandData.add_url_to_message(stanza, send_data.url_down); + var sources = new ArrayList<Xep.StatelessFileSharing.Source>(); + sources.add(new Xep.StatelessFileSharing.HttpSource() { url = send_data.url_down }); + string attach_to_id = MessageStorage.get_reference_id(file_share_message); + Xep.StatelessFileSharing.set_sfs_attachment(stanza, attach_to_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); + } + // Share encrypted files without SFS + else { + yield upload(file_transfer, send_data, file_meta); - if (send_data.encrypt_message && conversation.encryption != Encryption.NONE) { Entities.Message message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(send_data.url_down, conversation); file_transfer.info = message.id.to_string(); - message.encryption = conversation.encryption; + message.encryption = send_data.encrypt_message ? conversation.encryption : Encryption.NONE; stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(message, conversation); - } else { - // Use stateless file sharing for unencrypted file sharing - Xep.StatelessFileSharing.HttpSource source = new Xep.StatelessFileSharing.HttpSource(); - source.url = send_data.url_down; - file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); - this.db.sfs_sources.insert() - .value(db.sfs_sources.id, file_transfer.id) - .value(db.sfs_sources.type, source.type()) - .value(db.sfs_sources.data, source.serialize()) - .perform(); - XmppStream stream = stream_interactor.get_stream(conversation.account); - Xep.StatelessFileSharing.Module sfs_module = stream.get_module(Xep.StatelessFileSharing.Module.IDENTITY); - string message_type; - if (conversation.type_ == Conversation.Type.GROUPCHAT) { - message_type = MessageStanza.TYPE_GROUPCHAT; - } else { - message_type = MessageStanza.TYPE_CHAT; - } - MessageStanza sfs_message = new MessageStanza() { to=conversation.counterpart, type_=message_type }; - // TODO: is this the correct way of adding out-of-band-data? - sfs_message.body = source.url; - Xep.OutOfBandData.add_url_to_message(sfs_message, source.url); - // TODO: message hint correct? - Xep.MessageProcessingHints.set_message_hint(sfs_message, Xep.MessageProcessingHints.HINT_STORE); - sfs_module.send_stateless_file_transfer(stream, sfs_message, yield file_transfer.to_sfs_element()); } } @@ -159,10 +175,15 @@ public class HttpFileSender : FileSender, Object { }); } - private void check_add_oob(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { - if (message.encryption == Encryption.NONE && message.body.has_prefix("http") && message_is_file(db, message)) { - Xep.OutOfBandData.add_url_to_message(message_stanza, message_stanza.body); - } + private void check_add_sfs_element(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { + if (message.encryption != Encryption.NONE) return; + + FileTransfer? file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_message_id(message.id, conversation); + if (file_transfer == null) return; + + Xep.StatelessFileSharing.set_sfs_element(message_stanza, file_transfer.file_sharing_id, file_transfer.file_metadata, file_transfer.sfs_sources); + + Xep.MessageProcessingHints.set_message_hint(message_stanza, Xep.MessageProcessingHints.HINT_STORE); } public int get_id() { return 0; } diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 824b667e..1fc5e436 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -81,6 +81,7 @@ SOURCES "src/module/xep/0082_date_time_profiles.vala" "src/module/xep/0084_user_avatars.vala" "src/module/xep/0085_chat_state_notifications.vala" + "src/module/xep/0104_http_scheme_url_data.vala" "src/module/xep/0115_entity_capabilities.vala" "src/module/xep/0166_jingle/content.vala" @@ -139,6 +140,7 @@ SOURCES "src/module/xep/0353_jingle_message_initiation.vala" "src/module/xep/0359_unique_stable_stanza_ids.vala" "src/module/xep/0363_http_file_upload.vala" + "src/module/xep/0367_message_attaching.vala" "src/module/xep/0380_explicit_encryption.vala" "src/module/xep/0391_jingle_encrypted_transports.vala" "src/module/xep/0410_muc_self_ping.vala" diff --git a/xmpp-vala/meson.build b/xmpp-vala/meson.build index 4d8c0493..fdcb54dd 100644 --- a/xmpp-vala/meson.build +++ b/xmpp-vala/meson.build @@ -66,6 +66,7 @@ sources = files( 'src/module/xep/0082_date_time_profiles.vala', 'src/module/xep/0084_user_avatars.vala', 'src/module/xep/0085_chat_state_notifications.vala', + 'src/module/xep/0104_http_scheme_url_data.vala', 'src/module/xep/0115_entity_capabilities.vala', 'src/module/xep/0166_jingle/component.vala', 'src/module/xep/0166_jingle/content.vala', @@ -99,10 +100,12 @@ sources = files( 'src/module/xep/0249_direct_muc_invitations.vala', 'src/module/xep/0260_jingle_socks5_bytestreams.vala', 'src/module/xep/0261_jingle_in_band_bytestreams.vala', + 'src/module/xep/0264_jingle_content_thumbnails.vala', 'src/module/xep/0272_muji.vala', 'src/module/xep/0280_message_carbons.vala', 'src/module/xep/0297_stanza_forwarding.vala', 'src/module/xep/0298_coin.vala', + 'src/module/xep/0300_cryptographic_hashes.vala', 'src/module/xep/0308_last_message_correction.vala', 'src/module/xep/0313_2_message_archive_management.vala', 'src/module/xep/0313_message_archive_management.vala', @@ -111,6 +114,7 @@ sources = files( 'src/module/xep/0353_jingle_message_initiation.vala', 'src/module/xep/0359_unique_stable_stanza_ids.vala', 'src/module/xep/0363_http_file_upload.vala', + 'src/module/xep/0367_message_attaching.vala', 'src/module/xep/0380_explicit_encryption.vala', 'src/module/xep/0384_omemo/omemo_decryptor.vala', 'src/module/xep/0384_omemo/omemo_encryptor.vala', @@ -122,6 +126,8 @@ sources = files( 'src/module/xep/0421_occupant_ids.vala', 'src/module/xep/0428_fallback_indication.vala', 'src/module/xep/0444_reactions.vala', + 'src/module/xep/0446_file_metadata_element.vala', + 'src/module/xep/0447_stateless_file_sharing.vala', 'src/module/xep/0461_replies.vala', 'src/module/xep/0482_call_invites.vala', 'src/module/xep/pixbuf_storage.vala', diff --git a/xmpp-vala/src/module/message/stanza.vala b/xmpp-vala/src/module/message/stanza.vala index 053c44dd..cb07ab2a 100644 --- a/xmpp-vala/src/module/message/stanza.vala +++ b/xmpp-vala/src/module/message/stanza.vala @@ -15,13 +15,17 @@ public class MessageStanza : Xmpp.Stanza { public bool rerun_parsing = false; private ArrayList<MessageFlag> flags = new ArrayList<MessageFlag>(); - public string body { + public string? body { get { StanzaNode? body_node = stanza.get_subnode(NODE_BODY); return body_node == null ? null : body_node.get_string_content(); } set { StanzaNode? body_node = stanza.get_subnode(NODE_BODY); + if (value == null) { + if (body_node != null) stanza.sub_nodes.remove(body_node); + return; + } if (body_node == null) { body_node = new StanzaNode.build(NODE_BODY); stanza.put_node(body_node); diff --git a/xmpp-vala/src/module/xep/0104_http_scheme_url_data.vala b/xmpp-vala/src/module/xep/0104_http_scheme_url_data.vala new file mode 100644 index 00000000..b177a1ef --- /dev/null +++ b/xmpp-vala/src/module/xep/0104_http_scheme_url_data.vala @@ -0,0 +1,19 @@ +using Xmpp; + +namespace Xmpp.Xep.HttpSchemeForUrlData { + public const string NS_URI = "http://jabber.org/protocol/url-data"; + + // If there are multiple URLs, this will only return the first one + public static string? get_url(StanzaNode node) { + StanzaNode? url_data_node = node.get_subnode("url-data", NS_URI); + if (url_data_node == null) return null; + + return url_data_node.get_attribute("target"); + } + + public static StanzaNode to_stanza_node(string url) { + return new StanzaNode.build("url-data", NS_URI).add_self_xmlns() + .put_attribute("target", url, NS_URI); + + } +}
\ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala index cb281f80..053fb7d5 100644 --- a/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala +++ b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala @@ -1,6 +1,16 @@ namespace Xmpp.Xep.JingleContentThumbnails { public const string NS_URI = "urn:xmpp:thumbs:1"; - public const string STANZA_NAME = "thumbnail"; + + public Gee.List<Thumbnail> get_thumbnails(StanzaNode node) { + var thumbnails = new Gee.ArrayList<Thumbnail>(); + foreach (StanzaNode thumbnail_node in node.get_subnodes("thumbnail", Xep.JingleContentThumbnails.NS_URI)) { + var thumbnail = Thumbnail.from_stanza_node(thumbnail_node); + if (thumbnail != null) { + thumbnails.add(thumbnail); + } + } + return thumbnails; + } public class Thumbnail { public string uri; @@ -8,41 +18,30 @@ namespace Xmpp.Xep.JingleContentThumbnails { public int width; public int height; - const string URI_ATTRIBUTE = "uri"; - const string MIME_ATTRIBUTE = "media-type"; - const string WIDTH_ATTRIBUTE = "width"; - const string HEIGHT_ATTRIBUTE = "height"; - public StanzaNode to_stanza_node() { - StanzaNode node = new StanzaNode.build(STANZA_NAME, NS_URI).add_self_xmlns() - .put_attribute(URI_ATTRIBUTE, this.uri); + StanzaNode node = new StanzaNode.build("thumbnail", NS_URI).add_self_xmlns() + .put_attribute("uri", this.uri); if (this.media_type != null) { - node.put_attribute(MIME_ATTRIBUTE, this.media_type); + node.put_attribute("media-type", this.media_type); } if (this.width != -1) { - node.put_attribute(WIDTH_ATTRIBUTE, this.width.to_string()); + node.put_attribute("width", this.width.to_string()); } if (this.height != -1) { - node.put_attribute(HEIGHT_ATTRIBUTE, this.height.to_string()); + node.put_attribute("height", this.height.to_string()); } return node; } public static Thumbnail? from_stanza_node(StanzaNode node) { Thumbnail thumbnail = new Thumbnail(); - thumbnail.uri = node.get_attribute(URI_ATTRIBUTE); + thumbnail.uri = node.get_attribute("uri"); if (thumbnail.uri == null) { return null; } - thumbnail.media_type = node.get_attribute(MIME_ATTRIBUTE); - string? width = node.get_attribute(WIDTH_ATTRIBUTE); - if (width != null) { - thumbnail.width = int.parse(width); - } - string? height = node.get_attribute(HEIGHT_ATTRIBUTE); - if (height != null) { - thumbnail.height = int.parse(height); - } + thumbnail.media_type = node.get_attribute("media-type"); + thumbnail.width = node.get_attribute_int("width"); + thumbnail.height = node.get_attribute_int("height"); return thumbnail; } } diff --git a/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala index 00f9e2ee..b73e63a5 100644 --- a/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala +++ b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala @@ -4,69 +4,54 @@ using Gee; namespace Xmpp.Xep.CryptographicHashes { public const string NS_URI = "urn:xmpp:hashes:2"; - public enum HashCmp { - Match, - Mismatch, - None, + public Gee.List<Hash> get_hashes(StanzaNode node) { + var hashes = new ArrayList<Hash>(); + foreach (StanzaNode hash_node in node.get_subnodes("hash", NS_URI)) { + hashes.add(new Hash.from_stanza_node(hash_node)); + } + return hashes; + } + + public Gee.List<Hash> get_supported_hashes(Gee.List<Hash> hashes) { + var ret = new ArrayList<Hash>(); + foreach (Hash hash in hashes) { + ChecksumType? hash_type = hash_string_to_type(hash.algo); + if (hash_type != null) { + ret.add(hash); + } + } + return ret; + } + + public bool has_supported_hashes(Gee.List<Hash> hashes) { + foreach (Hash hash in hashes) { + ChecksumType? hash_type = hash_string_to_type(hash.algo); + if (hash_type != null) return true; + } + return false; } - public class Hash { + public class Hash : Object { public string algo; // hash encoded in Base64 public string val; - public static string hash_name(ChecksumType type) { - switch(type) { - case ChecksumType.MD5: - return "md5"; - case ChecksumType.SHA1: - return "sha-1"; - case ChecksumType.SHA256: - return "sha-256"; - case ChecksumType.SHA384: - return "sha-384"; - case ChecksumType.SHA512: - return "sha-512"; - } - return "(null)"; - } - - public static ChecksumType? supported_hash(string hash) { - switch (hash) { - case "sha-1": - return ChecksumType.SHA1; - case "sha-256": - return ChecksumType.SHA256; - case "sha-384": - return ChecksumType.SHA384; - case "sha-512": - return ChecksumType.SHA512; - } - return null; + public Hash.with_checksum(ChecksumType checksum_type, string hash) { + algo = hash_type_to_string(checksum_type); + val = hash; } - public Hash.from_data(GLib.ChecksumType type, uint8[] data) { + public Hash.compute(GLib.ChecksumType type, uint8[] data) { GLib.Checksum checksum = new GLib.Checksum(type); checksum.update(data, data.length); // 64 * 8 = 512 (sha-512 is the longest hash variant) uint8[] digest = new uint8[64]; size_t length = digest.length; checksum.get_digest(digest, ref length); - this.algo = hash_name(type); + this.algo = hash_type_to_string(type); this.val = GLib.Base64.encode(digest[0:length]); } - public HashCmp compare(Hash other) { - if (this.algo != other.algo) { - return HashCmp.None; - } - if (this.val == other.val) { - return HashCmp.Match; - } else { - return HashCmp.Mismatch; - } - } - public StanzaNode to_stanza_node() { return new StanzaNode.build("hash", NS_URI).add_self_xmlns() .put_attribute("algo", this.algo) @@ -79,58 +64,33 @@ namespace Xmpp.Xep.CryptographicHashes { } } - public class Hashes { - public Gee.List<Hash> hashes = new ArrayList<Hash>(); - - public Gee.List<ChecksumType> supported_hashes() { - Gee.List<ChecksumType> supported = new ArrayList<ChecksumType>(); - foreach (Hash hash in this.hashes) { - ChecksumType? hash_type = Hash.supported_hash(hash.algo); - if (hash_type != null) { - supported.add(hash_type); - } - } - return supported; - } - - public Hashes.from_data(Gee.List<ChecksumType> types, uint8[] data) { - foreach (ChecksumType type in types) { - this.hashes.add(new Hash.from_data(type, data)); - } - } - - public HashCmp compare(Hashes other) { - HashCmp cmp = HashCmp.None; - foreach (Hash this_hash in this.hashes) { - foreach (Hash other_hash in other.hashes) { - switch (this_hash.compare(other_hash)) { - case HashCmp.Mismatch: - return HashCmp.Mismatch; - case HashCmp.Match: - cmp = HashCmp.Match; - break; - case HashCmp.None: - continue; - } - } - } - return cmp; - } - - public Gee.List<StanzaNode> to_stanza_nodes() { - Gee.List<StanzaNode> nodes = new ArrayList<StanzaNode>(); - foreach (Hash hash in this.hashes) { - nodes.add(hash.to_stanza_node()); - } - return nodes; + public static string hash_type_to_string(ChecksumType type) { + switch(type) { + case ChecksumType.MD5: + return "md5"; + case ChecksumType.SHA1: + return "sha-1"; + case ChecksumType.SHA256: + return "sha-256"; + case ChecksumType.SHA384: + return "sha-384"; + case ChecksumType.SHA512: + return "sha-512"; } + return "(null)"; + } - public Hashes.from_stanza_subnodes(StanzaNode node) { - Gee.List<StanzaNode> subnodes = node.get_subnodes("hash", NS_URI); - this.hashes = new ArrayList<Hash>(); - foreach (StanzaNode subnode in subnodes) { - this.hashes.add(new Hash.from_stanza_node(subnode)); - } + public static ChecksumType? hash_string_to_type(string hash) { + switch (hash) { + case "sha-1": + return ChecksumType.SHA1; + case "sha-256": + return ChecksumType.SHA256; + case "sha-384": + return ChecksumType.SHA384; + case "sha-512": + return ChecksumType.SHA512; } + return null; } } diff --git a/xmpp-vala/src/module/xep/0367_message_attaching.vala b/xmpp-vala/src/module/xep/0367_message_attaching.vala new file mode 100644 index 00000000..7441cd19 --- /dev/null +++ b/xmpp-vala/src/module/xep/0367_message_attaching.vala @@ -0,0 +1,15 @@ +namespace Xmpp.Xep.MessageAttaching { + public const string NS_URI = "urn:xmpp:message-attaching:1"; + + public static string? get_attach_to(StanzaNode node) { + StanzaNode? attach_to = node.get_subnode("attach-to", NS_URI); + if (attach_to == null) return null; + + return attach_to.get_attribute("id", NS_URI); + } + + public static StanzaNode to_stanza_node(string id) { + return new StanzaNode.build("attach-to", NS_URI).add_self_xmlns() + .put_attribute("id", id, NS_URI); + } +}
\ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala index d7fbb06f..56b3d379 100644 --- a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala +++ b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala @@ -1,23 +1,24 @@ -using Xmpp.Xep.CryptographicHashes; - namespace Xmpp.Xep.FileMetadataElement { public const string NS_URI = "urn:xmpp:file:metadata:0"; public class FileMetadata { - public string name { get; set; } + public string? name { get; set; } public string? mime_type { get; set; } public int64 size { get; set; default=-1; } public string? desc { get; set; } public DateTime? date { get; set; } public int width { get; set; default=-1; } // Width of image in pixels - public int height { get; set; default=-1; } // Height of image in pixels - public CryptographicHashes.Hashes hashes = new CryptographicHashes.Hashes(); - public int64 length { get; set; default=-1; } // Length of audio/video in milliseconds - public Gee.List<Xep.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>(); + public int height { get; set; default=-1; } // Height of image in pixels + public Gee.List<CryptographicHashes.Hash> hashes = new Gee.ArrayList<CryptographicHashes.Hash>(); + public int64 length { get; set; default=-1; } // Length of audio/video in milliseconds + public Gee.List<Xep.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>(); public StanzaNode to_stanza_node() { - StanzaNode node = new StanzaNode.build("file", NS_URI).add_self_xmlns() - .put_node(new StanzaNode.build("name", NS_URI).put_node(new StanzaNode.text(this.name))); + StanzaNode node = new StanzaNode.build("file", NS_URI).add_self_xmlns(); + + if (this.name != null) { + node.put_node(new StanzaNode.build("name", NS_URI).put_node(new StanzaNode.text(this.name))); + } if (this.mime_type != null) { node.put_node(new StanzaNode.build("media_type", NS_URI).put_node(new StanzaNode.text(this.mime_type))); } @@ -39,82 +40,57 @@ namespace Xmpp.Xep.FileMetadataElement { if (this.length != -1) { node.put_node(new StanzaNode.build("length", NS_URI).put_node(new StanzaNode.text(this.length.to_string()))); } - node.sub_nodes.add_all(this.hashes.to_stanza_nodes()); + foreach (var hash in hashes) { + node.put_node(hash.to_stanza_node()); + } foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in this.thumbnails) { node.put_node(thumbnail.to_stanza_node()); } return node; } + } - public void add_to_message(MessageStanza message) { - StanzaNode node = this.to_stanza_node(); - printerr("Attaching file metadata:\n"); - printerr("%s\n", node.to_ansi_string(true)); - message.stanza.put_node(node); - } + public static FileMetadata? get_file_metadata(StanzaNode node) { + StanzaNode? file_node = node.get_subnode("file", Xep.FileMetadataElement.NS_URI); + if (file_node == null) return null; - public static FileMetadata? from_stanza_node(StanzaNode node) { - FileMetadata metadata = new FileMetadata(); - // TODO: null checks on final values - StanzaNode? name_node = node.get_subnode("name"); - if (name_node == null || name_node.get_string_content() == null) { - return null; - } else { - metadata.name = name_node.get_string_content(); - } - StanzaNode? desc_node = node.get_subnode("desc"); - if (desc_node != null && desc_node.get_string_content() != null) { - metadata.desc = desc_node.get_string_content(); - } - StanzaNode? mime_node = node.get_subnode("media_type"); - if (mime_node != null && mime_node.get_string_content() != null) { - metadata.mime_type = mime_node.get_string_content(); - } - StanzaNode? size_node = node.get_subnode("size"); - if (size_node != null && size_node.get_string_content() != null) { - metadata.size = int64.parse(size_node.get_string_content()); - } - StanzaNode? date_node = node.get_subnode("date"); - if (date_node != null && date_node.get_string_content() != null) { - metadata.date = new DateTime.from_iso8601(date_node.get_string_content(), null); - } - StanzaNode? width_node = node.get_subnode("width"); - if (width_node != null && width_node.get_string_content() != null) { - metadata.width = int.parse(width_node.get_string_content()); - } - StanzaNode? height_node = node.get_subnode("height"); - if (height_node != null && height_node.get_string_content() != null) { - metadata.height = int.parse(height_node.get_string_content()); - } - StanzaNode? length_node = node.get_subnode("length"); - if (length_node != null && length_node.get_string_content() != null) { - metadata.length = int.parse(length_node.get_string_content()); - } - foreach (StanzaNode thumbnail_node in node.get_subnodes(Xep.JingleContentThumbnails.STANZA_NAME, Xep.JingleContentThumbnails.NS_URI)) { - Xep.JingleContentThumbnails.Thumbnail? thumbnail = Xep.JingleContentThumbnails.Thumbnail.from_stanza_node(thumbnail_node); - if (thumbnail != null) { - metadata.thumbnails.add(thumbnail); - } - } - metadata.hashes = new CryptographicHashes.Hashes.from_stanza_subnodes(node); - return metadata; + FileMetadata metadata = new FileMetadata(); + + StanzaNode? name_node = file_node.get_subnode("name"); + if (name_node != null && name_node.get_string_content() != null) { + metadata.name = name_node.get_string_content(); } - public static FileMetadata? from_message(MessageStanza message) { - StanzaNode? node = message.stanza.get_subnode("file", NS_URI); - if (node == null) { - return null; - } - printerr("Parsing metadata from message:\n"); - printerr("%s\n", node.to_xml()); - FileMetadata metadata = FileMetadata.from_stanza_node(node); - if (metadata != null) { - printerr("Parsed metadata:\n"); - printerr("%s\n", metadata.to_stanza_node().to_ansi_string(true)); - } else { - printerr("Failed to parse metadata!\n"); - } - return FileMetadata.from_stanza_node(node); + StanzaNode? desc_node = file_node.get_subnode("desc"); + if (desc_node != null && desc_node.get_string_content() != null) { + metadata.desc = desc_node.get_string_content(); + } + StanzaNode? mime_node = file_node.get_subnode("media_type"); + if (mime_node != null && mime_node.get_string_content() != null) { + metadata.mime_type = mime_node.get_string_content(); + } + StanzaNode? size_node = file_node.get_subnode("size"); + if (size_node != null && size_node.get_string_content() != null) { + metadata.size = int64.parse(size_node.get_string_content()); + } + StanzaNode? date_node = file_node.get_subnode("date"); + if (date_node != null && date_node.get_string_content() != null) { + metadata.date = new DateTime.from_iso8601(date_node.get_string_content(), null); + } + StanzaNode? width_node = file_node.get_subnode("width"); + if (width_node != null && width_node.get_string_content() != null) { + metadata.width = int.parse(width_node.get_string_content()); + } + StanzaNode? height_node = file_node.get_subnode("height"); + if (height_node != null && height_node.get_string_content() != null) { + metadata.height = int.parse(height_node.get_string_content()); + } + StanzaNode? length_node = file_node.get_subnode("length"); + if (length_node != null && length_node.get_string_content() != null) { + metadata.length = int.parse(length_node.get_string_content()); } + metadata.thumbnails = Xep.JingleContentThumbnails.get_thumbnails(file_node); + metadata.hashes = CryptographicHashes.get_hashes(file_node); + return metadata; } } diff --git a/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala index 285bfe78..c63f7ea6 100644 --- a/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala +++ b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala @@ -1,225 +1,122 @@ +using Gee; using Xmpp; namespace Xmpp.Xep.StatelessFileSharing { + public const string NS_URI = "urn:xmpp:sfs:0"; - public const string STANZA_NAME = "file-transfer"; + public static Gee.List<FileShare> get_file_shares(MessageStanza message) { + var ret = new ArrayList<FileShare>(); + foreach (StanzaNode file_sharing_node in message.stanza.get_subnodes("file-sharing", NS_URI)) { + var metadata = Xep.FileMetadataElement.get_file_metadata(file_sharing_node); + if (metadata == null) continue; - public interface SfsSource: Object { - public abstract string type(); - public abstract string serialize(); - - public abstract StanzaNode to_stanza_node(); - } - - public class HttpSource: Object, SfsSource { - public string url; + var sources_node = message.stanza.get_subnode("sources", NS_URI); - public const string HTTP_NS_URI = "http://jabber.org/protocol/url-data"; - public const string HTTP_STANZA_NAME = "url-data"; - public const string HTTP_URL_ATTRIBUTE = "target"; - public const string SOURCE_TYPE = "http"; - - public string type() { - return SOURCE_TYPE; + ret.add(new FileShare() { + id = file_sharing_node.get_attribute("id", NS_URI), + metadata = Xep.FileMetadataElement.get_file_metadata(file_sharing_node), + sources = sources_node != null ? get_sources(sources_node) : null + }); } - public string serialize() { - return this.to_stanza_node().to_xml(); - } + if (ret.size == 0) return null; - public StanzaNode to_stanza_node() { - StanzaNode node = new StanzaNode.build(HTTP_STANZA_NAME, HTTP_NS_URI).add_self_xmlns(); - node.put_attribute(HTTP_URL_ATTRIBUTE, this.url); - return node; - } + return ret; + } - public static async HttpSource deserialize(string data) { - StanzaNode node = yield new StanzaReader.for_string(data).read_stanza_node(); - HttpSource source = HttpSource.from_stanza_node(node); - assert(source != null); - return source; - } + public static Gee.List<SourceAttachment>? get_source_attachments(MessageStanza message) { + Gee.List<StanzaNode> sources_nodes = message.stanza.get_subnodes("sources", NS_URI); + if (sources_nodes.is_empty) return null; - public static HttpSource? from_stanza_node(StanzaNode node) { - string? url = node.get_attribute(HTTP_URL_ATTRIBUTE); - if (url == null) { - return null; - } - HttpSource source = new HttpSource(); - source.url = url; - return source; - } + string? attach_to_id = MessageAttaching.get_attach_to(message.stanza); + if (attach_to_id == null) return null; - public static Gee.List<HttpSource> extract_sources(StanzaNode node) { - Gee.List<HttpSource> sources = new Gee.ArrayList<HttpSource>(); - foreach (StanzaNode http_node in node.get_subnodes(HTTP_STANZA_NAME, HTTP_NS_URI)) { - HttpSource? source = HttpSource.from_stanza_node(http_node); - if (source != null) { - sources.add(source); - } - } - return sources; - } - } + var ret = new ArrayList<SourceAttachment>(); - public class SfsElement { - public Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); - public Gee.List<SfsSource> sources = new Gee.ArrayList<SfsSource>(); - - public static SfsElement? from_stanza_node(StanzaNode node) { - SfsElement element = new SfsElement(); - StanzaNode? metadata_node = node.get_subnode("file", Xep.FileMetadataElement.NS_URI); - if (metadata_node == null) { - return null; - } - Xep.FileMetadataElement.FileMetadata metadata = Xep.FileMetadataElement.FileMetadata.from_stanza_node(metadata_node); - if (metadata == null) { - return null; - } - element.metadata = metadata; - StanzaNode? sources_node = node.get_subnode("sources"); - if (sources_node == null) { - return null; - } - Gee.List<HttpSource> sources = HttpSource.extract_sources(sources_node); - if (sources.is_empty) { - return null; - } - element.sources = sources; - return element; - } - - public StanzaNode to_stanza_node() { - StanzaNode node = new StanzaNode.build(STANZA_NAME, NS_URI).add_self_xmlns(); - node.put_node(this.metadata.to_stanza_node()); - StanzaNode sources_node = new StanzaNode.build("sources", NS_URI); - Gee.List<StanzaNode> sources = new Gee.ArrayList<StanzaNode>(); - foreach (SfsSource source in this.sources) { - sources.add(source.to_stanza_node()); - } - sources_node.sub_nodes = sources; - node.put_node(sources_node); - return node; + foreach (StanzaNode sources_node in sources_nodes) { + ret.add(new SourceAttachment() { + to_message_id = attach_to_id, + to_file_transfer_id = sources_node.get_attribute("id", NS_URI), + sources = get_sources(sources_node) + }); } + return ret; } - public class SfsSourceAttachment { - public string sfs_id; - public Gee.List<SfsSource> sources = new Gee.ArrayList<SfsSource>(); - - public const string ATTACHMENT_NS_URI = "urn:xmpp:message-attaching:1"; - public const string ATTACH_TO_STANZA_NAME = "attach-to"; - public const string SOURCES_STANZA_NAME = "sources"; - public const string ID_ATTRIBUTE_NAME = "id"; - - - public static SfsSourceAttachment? from_message_stanza(MessageStanza stanza) { - StanzaNode? attach_to = stanza.stanza.get_subnode(ATTACH_TO_STANZA_NAME, ATTACHMENT_NS_URI); - StanzaNode? sources = stanza.stanza.get_subnode(SOURCES_STANZA_NAME, NS_URI); - if (attach_to == null || sources == null) { - return null; - } - string? id = attach_to.get_attribute(ID_ATTRIBUTE_NAME, ATTACHMENT_NS_URI); - if (id == null) { - return null; - } - SfsSourceAttachment attachment = new SfsSourceAttachment(); - attachment.sfs_id = id; - Gee.List<HttpSource> http_sources = HttpSource.extract_sources(sources); - if (http_sources.is_empty) { - return null; - } - attachment.sources = http_sources; - return attachment; - } + // Currently only returns a single http source + private static Gee.List<Source>? get_sources(StanzaNode sources_node) { + string? url = HttpSchemeForUrlData.get_url(sources_node); + if (url == null) return null; - public MessageStanza to_message_stanza(Jid to, string message_type) { - MessageStanza stanza = new MessageStanza() { to=to, type_=message_type }; - Xep.MessageProcessingHints.set_message_hint(stanza, Xep.MessageProcessingHints.HINT_STORE); + var http_source = new HttpSource() { url=url }; + var sources = new Gee.ArrayList<Source>(); + sources.add(http_source); - StanzaNode attach_to = new StanzaNode.build(ATTACH_TO_STANZA_NAME, ATTACHMENT_NS_URI); - attach_to.add_attribute(new StanzaAttribute.build(ATTACHMENT_NS_URI, "id", this.sfs_id)); - stanza.stanza.put_node(attach_to); - - StanzaNode sources = new StanzaNode.build(SOURCES_STANZA_NAME, NS_URI); - Gee.List<StanzaNode> sources_nodes = new Gee.ArrayList<StanzaNode>(); - foreach (SfsSource source in this.sources) { - sources_nodes.add(source.to_stanza_node()); - } - sources.sub_nodes = sources_nodes; - stanza.stanza.put_node(sources); - - return stanza; - } + return sources; } - public class MessageFlag : Xmpp.MessageFlag { - public const string ID = "stateless_file_sharing"; - - public static MessageFlag? get_flag(MessageStanza message) { - return (MessageFlag) message.get_flag(NS_URI, ID); + public static void set_sfs_element(MessageStanza message, string file_sharing_id, FileMetadataElement.FileMetadata metadata, Gee.List<Xep.StatelessFileSharing.Source>? sources) { + var file_sharing_node = new StanzaNode.build("file-sharing", NS_URI).add_self_xmlns() + .put_attribute("id", file_sharing_id, NS_URI) + .put_node(metadata.to_stanza_node()); + if (sources != null && !sources.is_empty) { + file_sharing_node.put_node(create_sources_node(file_sharing_id, sources)); } + message.stanza.put_node(file_sharing_node); + } - public override string get_ns() { - return NS_URI; - } + public static void set_sfs_attachment(MessageStanza message, string attach_to_id, string attach_to_file_id, Gee.List<Xep.StatelessFileSharing.Source> sources) { + message.stanza.put_node(MessageAttaching.to_stanza_node(attach_to_id)); + message.stanza.put_node(create_sources_node(attach_to_file_id, sources).add_self_xmlns()); + } - public override string get_id() { - return ID; + private static StanzaNode create_sources_node(string file_sharing_id, Gee.List<Xep.StatelessFileSharing.Source> sources) { + StanzaNode sources_node = new StanzaNode.build("sources", NS_URI) + .put_attribute("id", file_sharing_id, NS_URI); + foreach (var source in sources) { + sources_node.put_node(source.to_stanza_node()); } + return sources_node; } - public class Module : XmppStreamModule { - public static ModuleIdentity<Module> IDENTITY = new ModuleIdentity<Module>(NS_URI, "stateless_file_sharing"); - - public signal void received_sfs(Jid from, Jid to, SfsElement sfs_element, MessageStanza message); - public signal void received_sfs_attachment(Jid from, Jid to, SfsSourceAttachment attachment, MessageStanza message); - - public void send_stateless_file_transfer(XmppStream stream, MessageStanza sfs_message, SfsElement sfs_element) { - StanzaNode sfs_node = sfs_element.to_stanza_node(); - printerr(sfs_node.to_ansi_string(true)); + public class FileShare : Object { + public string? id { get; set; } + public Xep.FileMetadataElement.FileMetadata metadata { get; set; } + public Gee.List<Source>? sources { get; set; } + } - sfs_message.stanza.put_node(sfs_node); - printerr("Sending message:\n"); - printerr(sfs_message.stanza.to_ansi_string(true)); - stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, sfs_message); - } + public class SourceAttachment : Object { + public string to_message_id { get; set; } + public string? to_file_transfer_id { get; set; } + public Gee.List<Source>? sources { get; set; } + } - public void send_stateless_file_transfer_attachment(XmppStream stream, Jid to, string message_type, SfsSourceAttachment attachment) { - MessageStanza message = attachment.to_message_stanza(to, message_type); + public interface Source: Object { + public abstract string type(); + public abstract StanzaNode to_stanza_node(); + public abstract bool equals(Source source); - printerr("Sending message:\n"); - printerr(message.stanza.to_ansi_string(true)); - stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, message); + public static bool equals_func(Source s1, Source s2) { + return s1.equals(s2); } + } - private void on_received_message(XmppStream stream, MessageStanza message) { - StanzaNode? sfs_node = message.stanza.get_subnode(STANZA_NAME, NS_URI); - if (sfs_node != null) { - SfsElement? sfs_element = SfsElement.from_stanza_node(sfs_node); - if (sfs_element == null) { - return; - } - message.add_flag(new MessageFlag()); - received_sfs(message.from, message.to, sfs_element, message); - } - SfsSourceAttachment? attachment = SfsSourceAttachment.from_message_stanza(message); - if (attachment != null) { - received_sfs_attachment(message.from, message.to, attachment, message); - } + public class HttpSource : Object, Source { + public string url { get; set; } + public string type() { + return "http"; } - public override void attach(XmppStream stream) { - stream.get_module(MessageModule.IDENTITY).received_message.connect(on_received_message); + public StanzaNode to_stanza_node() { + return HttpSchemeForUrlData.to_stanza_node(url); } - public override void detach(XmppStream stream) { - stream.get_module(MessageModule.IDENTITY).received_message.disconnect(on_received_message); + public bool equals(Source source) { + HttpSource? http_source = source as HttpSource; + if (http_source == null) return false; + return http_source.url == this.url; } - - public override string get_ns() { return NS_URI; } - public override string get_id() { return IDENTITY.id; } } } |