aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatiga <dev@patiga.eu>2022-06-28 12:11:17 +0200
committerfiaxh <git@lightrise.org>2024-11-14 10:20:12 -0600
commitaaf4542e6208460c305db4be36b15dc832ddc95a (patch)
treeec7b60b0f0ea74e21403788e8345336bd0f3939b
parent909f569318835d11703c49fba7dbe49996759f38 (diff)
downloaddino-aaf4542e6208460c305db4be36b15dc832ddc95a.tar.gz
dino-aaf4542e6208460c305db4be36b15dc832ddc95a.zip
Implement XEP-0447: Stateless file sharing
-rw-r--r--libdino/src/entity/file_transfer.vala158
-rw-r--r--libdino/src/entity/message.vala1
-rw-r--r--libdino/src/service/database.vala56
-rw-r--r--libdino/src/service/file_manager.vala263
-rw-r--r--libdino/src/service/jingle_file_transfers.vala2
-rw-r--r--libdino/src/service/module_manager.vala1
-rw-r--r--main/CMakeLists.txt2
-rw-r--r--main/src/ui/application.vala1
-rw-r--r--main/src/ui/conversation_content_view/file_preview_widget.vala90
-rw-r--r--main/src/ui/conversation_content_view/file_widget.vala26
-rw-r--r--main/src/ui/util/file_metadata_providers.vala27
-rw-r--r--plugins/http-files/src/file_provider.vala34
-rw-r--r--plugins/http-files/src/file_sender.vala37
-rw-r--r--xmpp-vala/CMakeLists.txt4
-rw-r--r--xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala49
-rw-r--r--xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala136
-rw-r--r--xmpp-vala/src/module/xep/0446_file_metadata_element.vala120
-rw-r--r--xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala225
18 files changed, 1196 insertions, 36 deletions
diff --git a/libdino/src/entity/file_transfer.vala b/libdino/src/entity/file_transfer.vala
index 20bc1a7a..c9c7916e 100644
--- a/libdino/src/entity/file_transfer.vala
+++ b/libdino/src/entity/file_transfer.vala
@@ -14,6 +14,22 @@ 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 Account account { get; set; }
public Jid counterpart { get; set; }
@@ -64,13 +80,19 @@ public class FileTransfer : Object {
}
public string path { get; set; }
public string? mime_type { get; set; }
- // TODO(hrxi): expand to 64 bit
- public int size { get; set; default=-1; }
-
+ public int64 size { get; set; }
public State state { get; set; default=State.NOT_STARTED; }
public int provider { get; set; }
public string info { get; set; }
public Cancellable cancellable { get; default=new Cancellable(); }
+ 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.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>();
private Database? db;
private string storage_dir;
@@ -99,10 +121,37 @@ public class FileTransfer : Object {
file_name = row[db.file_transfer.file_name];
path = row[db.file_transfer.path];
mime_type = row[db.file_transfer.mime_type];
- size = row[db.file_transfer.size];
+ size = (int64) row[db.file_transfer.size];
state = (State) row[db.file_transfer.state];
provider = row[db.file_transfer.provider];
info = row[db.file_transfer.info];
+ modification_date = new DateTime.from_unix_utc(row[db.file_transfer.modification_date]);
+ width = row[db.file_transfer.width];
+ height = row[db.file_transfer.height];
+ length = (int64) row[db.file_transfer.length];
+
+ 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);
+ }
+
+ foreach(var thumbnail_row in db.file_thumbnails.select().with(db.file_thumbnails.id, "=", id)) {
+ Xep.JingleContentThumbnails.Thumbnail thumbnail = new Xep.JingleContentThumbnails.Thumbnail();
+ thumbnail.uri = thumbnail_row[db.file_thumbnails.uri];
+ thumbnail.media_type = thumbnail_row[db.file_thumbnails.mime_type];
+ thumbnail.width = thumbnail_row[db.file_thumbnails.width];
+ thumbnail.height = thumbnail_row[db.file_thumbnails.height];
+ 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);
+ }
notify.connect(on_update);
}
@@ -121,17 +170,52 @@ public class FileTransfer : Object {
.value(db.file_transfer.local_time, (long) local_time.to_unix())
.value(db.file_transfer.encryption, encryption)
.value(db.file_transfer.file_name, file_name)
- .value(db.file_transfer.size, size)
+ .value(db.file_transfer.size, (long) size)
.value(db.file_transfer.state, state)
.value(db.file_transfer.provider, provider)
.value(db.file_transfer.info, info);
- if (file_name != null) builder.value(db.file_transfer.file_name, file_name);
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);
+ if (modification_date != null) builder.value(db.file_transfer.modification_date, (long) modification_date.to_unix());
+ if (width != -1) builder.value(db.file_transfer.width, width);
+ if (height != -1) builder.value(db.file_transfer.height, height);
+ if (length != -1) builder.value(db.file_transfer.length, (long) length);
id = (int) builder.perform();
+
+ foreach (Xep.CryptographicHashes.Hash hash in hashes.hashes) {
+ db.file_hashes.insert()
+ .value(db.file_hashes.id, id)
+ .value(db.file_hashes.algo, hash.algo)
+ .value(db.file_hashes.value, hash.val)
+ .perform();
+ }
+ foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in thumbnails) {
+ db.file_thumbnails.insert()
+ .value(db.file_thumbnails.id, id)
+ .value(db.file_thumbnails.uri, thumbnail.uri)
+ .value(db.file_thumbnails.mime_type, thumbnail.media_type)
+ .value(db.file_thumbnails.width, thumbnail.width)
+ .value(db.file_thumbnails.height, thumbnail.height)
+ .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;
+ 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();
+ }
+
notify.connect(on_update);
+ sfs_sources.items_changed.connect((position, removed, added) => {
+ on_update_sources_items(this, position, removed, added);
+ });
}
public File get_file() {
@@ -161,7 +245,7 @@ public class FileTransfer : Object {
case "mime-type":
update_builder.set(db.file_transfer.mime_type, mime_type); break;
case "size":
- update_builder.set(db.file_transfer.size, size); break;
+ update_builder.set(db.file_transfer.size, (long) size); break;
case "state":
if (state == State.IN_PROGRESS) return;
update_builder.set(db.file_transfer.state, state); break;
@@ -169,9 +253,69 @@ public class FileTransfer : Object {
update_builder.set(db.file_transfer.provider, provider); break;
case "info":
update_builder.set(db.file_transfer.info, info); break;
+ case "modification-date":
+ update_builder.set(db.file_transfer.modification_date, (long) modification_date.to_unix()); break;
+ case "width":
+ update_builder.set(db.file_transfer.width, width); break;
+ case "height":
+ update_builder.set(db.file_transfer.height, height); break;
+ case "length":
+ update_builder.set(db.file_transfer.length, (long) length); break;
}
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 e5aad25f..d48f999b 100644
--- a/libdino/src/entity/message.vala
+++ b/libdino/src/entity/message.vala
@@ -1,5 +1,6 @@
using Gee;
using Xmpp;
+using Xmpp.Xep;
namespace Dino.Entities {
diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala
index eba8b7ca..30d6d55f 100644
--- a/libdino/src/service/database.vala
+++ b/libdino/src/service/database.vala
@@ -7,7 +7,7 @@ using Dino.Entities;
namespace Dino {
public class Database : Qlite.Database {
- private const int VERSION = 27;
+ private const int VERSION = 28;
public class AccountTable : Table {
public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true };
@@ -191,15 +191,57 @@ 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<int> size = new Column.Integer("size");
+ public Column<long> size = new Column.Long("size") { default = "-1", min_version=28 };
public Column<int> state = new Column.Integer("state");
public Column<int> provider = new Column.Integer("provider");
public Column<string> info = new Column.Text("info");
+ public Column<long> modification_date = new Column.Long("modification_date") { default = "-1", min_version=28 };
+ public Column<int> width = new Column.Integer("width") { default = "-1", min_version=28 };
+ public Column<int> height = new Column.Integer("height") { default = "-1", min_version=28 };
+ public Column<long> length = new Column.Integer("length") { default = "-1", min_version=28 };
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});
+ encryption, file_name, path, mime_type, size, state, provider, info, modification_date, width, height,
+ length});
+ }
+ }
+
+ public class FileHashesTable : Table {
+ public Column<int> id = new Column.Integer("id");
+ public Column<string> algo = new Column.Text("algo") { not_null = true };
+ public Column<string> value = new Column.Text("value") { not_null = true };
+
+ internal FileHashesTable(Database db) {
+ base(db, "file_hashes");
+ init({id, algo, value});
+ unique({id, algo}, "REPLACE");
+ }
+ }
+
+ public class FileThumbnailsTable : Table {
+ public Column<int> id = new Column.Integer("id");
+ public Column<string> uri = new Column.Text("uri") { not_null = true };
+ public Column<string> mime_type = new Column.Text("mime_type");
+ public Column<int> width = new Column.Integer("width");
+ public Column<int> height = new Column.Integer("height");
+
+ internal FileThumbnailsTable(Database db) {
+ base(db, "file_thumbnails");
+ init({id, uri, mime_type, width, height});
+ }
+ }
+
+ public class SfsSourcesTable : Table {
+ public Column<int> id = new Column.Integer("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) {
+ base(db, "sfs_sources");
+ init({id, type, data});
+ unique({id, type, data}, "REPLACE");
}
}
@@ -401,6 +443,9 @@ public class Database : Qlite.Database {
public RealJidTable real_jid { get; private set; }
public OccupantIdTable occupantid { get; private set; }
public FileTransferTable file_transfer { get; private set; }
+ public FileHashesTable file_hashes { get; private set; }
+ public FileThumbnailsTable file_thumbnails { get; private set; }
+ public SfsSourcesTable sfs_sources { get; private set; }
public CallTable call { get; private set; }
public CallCounterpartTable call_counterpart { get; private set; }
public ConversationTable conversation { get; private set; }
@@ -431,6 +476,9 @@ public class Database : Qlite.Database {
occupantid = new OccupantIdTable(this);
real_jid = new RealJidTable(this);
file_transfer = new FileTransferTable(this);
+ file_hashes = new FileHashesTable(this);
+ file_thumbnails = new FileThumbnailsTable(this);
+ sfs_sources = new SfsSourcesTable(this);
call = new CallTable(this);
call_counterpart = new CallCounterpartTable(this);
conversation = new ConversationTable(this);
@@ -443,7 +491,7 @@ public class Database : Qlite.Database {
settings = new SettingsTable(this);
account_settings = new AccountSettingsTable(this);
conversation_settings = new ConversationSettingsTable(this);
- init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, account_settings, conversation_settings });
+ init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, file_hashes, file_thumbnails, sfs_sources, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, account_settings, conversation_settings });
try {
exec("PRAGMA journal_mode = WAL");
diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala
index 984fe5fd..32cf23c4 100644
--- a/libdino/src/service/file_manager.vala
+++ b/libdino/src/service/file_manager.vala
@@ -2,6 +2,7 @@ using Gdk;
using Gee;
using Xmpp;
+using Xmpp.Xep;
using Dino.Entities;
namespace Dino {
@@ -19,6 +20,7 @@ public class FileManager : StreamInteractionModule, Object {
private Gee.List<FileEncryptor> file_encryptors = new ArrayList<FileEncryptor>();
private Gee.List<FileDecryptor> file_decryptors = new ArrayList<FileDecryptor>();
private Gee.List<FileProvider> file_providers = new ArrayList<FileProvider>();
+ private Gee.List<FileMetadataProvider> file_metadata_providers = new ArrayList<FileMetadataProvider>();
public static void start(StreamInteractor stream_interactor, Database db) {
FileManager m = new FileManager(stream_interactor, db);
@@ -36,9 +38,121 @@ public class FileManager : StreamInteractionModule, Object {
this.add_provider(new JingleFileProvider(stream_interactor));
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) {
+ 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()) {
+ return file_provider;
+ }
+ if (http_usable && file_provider.get_id() == HTTP_PROVIDER_ID) {
+ return file_provider;
+ }
+ }
+ return null;
}
- public async HashMap<int, long> get_file_size_limits(Conversation conversation) {
+ // 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) {
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);
@@ -60,11 +174,15 @@ public class FileManager : StreamInteractionModule, Object {
file_transfer.local_time = new DateTime.now_utc();
file_transfer.encryption = conversation.encryption;
+ Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata();
+ foreach (FileMetadataProvider file_metadata_provider in this.file_metadata_providers) {
+ if (file_metadata_provider.supports_file(file)) {
+ yield file_metadata_provider.fill_metadata(file, metadata);
+ }
+ }
+ file_transfer.with_metadata_element(metadata);
+
try {
- FileInfo file_info = file.query_info("*", FileQueryInfoFlags.NONE);
- file_transfer.file_name = file_info.get_display_name();
- file_transfer.mime_type = file_info.get_content_type();
- file_transfer.size = (int)file_info.get_size();
file_transfer.input_stream = yield file.read_async();
yield save_file(file_transfer);
@@ -130,12 +248,7 @@ public class FileManager : StreamInteractionModule, Object {
public async void download_file(FileTransfer file_transfer) {
Conversation conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account);
- FileProvider? file_provider = null;
- foreach (FileProvider fp in file_providers) {
- if (file_transfer.provider == fp.get_id()) {
- file_provider = fp;
- }
- }
+ FileProvider? file_provider = this.select_file_provider(file_transfer);
yield download_file_internal(file_provider, file_transfer, conversation);
}
@@ -152,7 +265,12 @@ 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) => {
- handle_incoming_file.begin(file_provider, 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);
+ }
});
}
@@ -174,12 +292,14 @@ public class FileManager : StreamInteractionModule, Object {
file_decryptors.add(decryptor);
}
- public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) {
- if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true;
+ public void add_metadata_provider(FileMetadataProvider file_metadata_provider) {
+ file_metadata_providers.add(file_metadata_provider);
+ }
+ private bool is_jid_trustworthy(Jid from, Conversation conversation) {
Jid relevant_jid = conversation.counterpart;
if (conversation.type_ == Conversation.Type.GROUPCHAT) {
- relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(file_transfer.from, conversation.account);
+ relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(from, conversation.account);
}
if (relevant_jid == null) return false;
@@ -187,6 +307,12 @@ 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);
@@ -211,7 +337,7 @@ 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 = file_provider.get_file_receive_data(file_transfer);
+ FileReceiveData receive_data = yield file_provider.get_file_receive_data(file_transfer);
FileDecryptor? file_decryptor = null;
foreach (FileDecryptor decryptor in file_decryptors) {
if (decryptor.can_decrypt_file(conversation, file_transfer, receive_data)) {
@@ -379,7 +505,7 @@ 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 FileReceiveData? get_file_receive_data(FileTransfer file_transfer);
+ public abstract async 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;
@@ -415,4 +541,107 @@ 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/jingle_file_transfers.vala b/libdino/src/service/jingle_file_transfers.vala
index 624be607..daccd309 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 FileReceiveData? get_file_receive_data(FileTransfer file_transfer) {
+ public async FileReceiveData? get_file_receive_data(FileTransfer file_transfer) {
return new FileReceiveData();
}
diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala
index eb9e4fbc..28ccf8ef 100644
--- a/libdino/src/service/module_manager.vala
+++ b/libdino/src/service/module_manager.vala
@@ -86,6 +86,7 @@ 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/main/CMakeLists.txt b/main/CMakeLists.txt
index fe7528cf..fa5f56e8 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -185,6 +185,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_widget.vala
src/ui/conversation_content_view/item_actions.vala
src/ui/conversation_content_view/message_widget.vala
@@ -221,6 +222,7 @@ SOURCES
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/application.vala b/main/src/ui/application.vala
index 918fb26c..3c816a77 100644
--- a/main/src/ui/application.vala
+++ b/main/src/ui/application.vala
@@ -69,6 +69,7 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application {
}
}
});
+ stream_interactor.get_module(FileManager.IDENTITY).add_metadata_provider(new Util.AudioVideoFileMetadataProvider());
});
activate.connect(() => {
diff --git a/main/src/ui/conversation_content_view/file_preview_widget.vala b/main/src/ui/conversation_content_view/file_preview_widget.vala
new file mode 100644
index 00000000..e7cee93a
--- /dev/null
+++ b/main/src/ui/conversation_content_view/file_preview_widget.vala
@@ -0,0 +1,90 @@
+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_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala
index 02c9407a..69781f30 100644
--- a/main/src/ui/conversation_content_view/file_widget.vala
+++ b/main/src/ui/conversation_content_view/file_widget.vala
@@ -43,6 +43,7 @@ public class FileWidget : SizeRequestBox {
enum State {
IMAGE,
+ IMAGE_PREVIEW,
DEFAULT
}
@@ -108,7 +109,26 @@ public class FileWidget : SizeRequestBox {
} catch (Error e) { }
}
- if (!show_image() && state != State.DEFAULT) {
+ 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 (content != null) this.remove(content);
FileDefaultWidget default_file_widget = new FileDefaultWidget();
default_widget_controller = new FileDefaultWidgetController(default_file_widget);
@@ -140,6 +160,10 @@ public class FileWidget : SizeRequestBox {
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/util/file_metadata_providers.vala b/main/src/ui/util/file_metadata_providers.vala
new file mode 100644
index 00000000..9e8d9640
--- /dev/null
+++ b/main/src/ui/util/file_metadata_providers.vala
@@ -0,0 +1,27 @@
+using Dino.Entities;
+using Xmpp;
+using Xmpp.Xep;
+using Gtk;
+
+namespace Dino.Ui.Util {
+
+public class AudioVideoFileMetadataProvider: Dino.FileMetadataProvider, Object {
+ public bool supports_file(File file) {
+ string? mime_type = file.query_info("*", FileQueryInfoFlags.NONE).get_content_type();
+ if (mime_type == null) {
+ return false;
+ }
+ return mime_type.has_prefix("audio") || mime_type.has_prefix("video");
+ }
+
+ public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) {
+ MediaFile media = MediaFile.for_file(file);
+ media.notify["prepared"].connect((object, pspec) => {
+ Idle.add(fill_metadata.callback);
+ });
+ yield;
+ metadata.length = media.duration / 1000;
+ }
+}
+
+} \ No newline at end of file
diff --git a/plugins/http-files/src/file_provider.vala b/plugins/http-files/src/file_provider.vala
index d62e3561..bbee8e50 100644
--- a/plugins/http-files/src/file_provider.vala
+++ b/plugins/http-files/src/file_provider.vala
@@ -38,6 +38,9 @@ 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;
+ }
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);
@@ -180,6 +183,16 @@ public class FileProvider : Dino.FileProvider, Object {
}
public FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError {
+ // TODO: replace '2' with constant?
+ if (file_transfer.provider == 2) {
+ var file_meta = new HttpFileMeta();
+ file_meta.size = file_transfer.size;
+ file_meta.mime_type = file_transfer.mime_type;
+ file_meta.file_name = file_transfer.file_name;
+ file_meta.message = null;
+ return file_meta;
+ }
+
Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account);
if (conversation == null) throw new FileReceiveError.GET_METADATA_FAILED("No conversation");
@@ -197,7 +210,26 @@ public class FileProvider : Dino.FileProvider, Object {
return file_meta;
}
- public FileReceiveData? get_file_receive_data(FileTransfer file_transfer) {
+ 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);
+ }
+ }
+ 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;
+ }
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 a2f4769b..91b50944 100644
--- a/plugins/http-files/src/file_sender.vala
+++ b/plugins/http-files/src/file_sender.vala
@@ -45,11 +45,38 @@ public class HttpFileSender : FileSender, Object {
yield upload(file_transfer, send_data, file_meta);
- 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 = send_data.encrypt_message ? conversation.encryption : Encryption.NONE;
- stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(message, conversation);
+ 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;
+ 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());
+ }
}
public async bool can_send(Conversation conversation, FileTransfer file_transfer) {
diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt
index 4a213fda..824b667e 100644
--- a/xmpp-vala/CMakeLists.txt
+++ b/xmpp-vala/CMakeLists.txt
@@ -125,10 +125,12 @@ SOURCES
"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_message_archive_management.vala"
"src/module/xep/0313_2_message_archive_management.vala"
@@ -143,6 +145,8 @@ SOURCES
"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/xep/0264_jingle_content_thumbnails.vala b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala
new file mode 100644
index 00000000..cb281f80
--- /dev/null
+++ b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala
@@ -0,0 +1,49 @@
+namespace Xmpp.Xep.JingleContentThumbnails {
+ public const string NS_URI = "urn:xmpp:thumbs:1";
+ public const string STANZA_NAME = "thumbnail";
+
+ public class Thumbnail {
+ public string uri;
+ public string? media_type;
+ 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);
+ if (this.media_type != null) {
+ node.put_attribute(MIME_ATTRIBUTE, this.media_type);
+ }
+ if (this.width != -1) {
+ node.put_attribute(WIDTH_ATTRIBUTE, this.width.to_string());
+ }
+ if (this.height != -1) {
+ node.put_attribute(HEIGHT_ATTRIBUTE, 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);
+ 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);
+ }
+ return thumbnail;
+ }
+ }
+} \ No newline at end of file
diff --git a/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala
new file mode 100644
index 00000000..00f9e2ee
--- /dev/null
+++ b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala
@@ -0,0 +1,136 @@
+using GLib;
+using Gee;
+
+namespace Xmpp.Xep.CryptographicHashes {
+ public const string NS_URI = "urn:xmpp:hashes:2";
+
+ public enum HashCmp {
+ Match,
+ Mismatch,
+ None,
+ }
+
+ public class Hash {
+ 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.from_data(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.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)
+ .put_node(new StanzaNode.text(this.val));
+ }
+
+ public Hash.from_stanza_node(StanzaNode node) {
+ this.algo = node.get_attribute("algo");
+ this.val = node.get_string_content();
+ }
+ }
+
+ 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 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));
+ }
+ }
+ }
+}
diff --git a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala
new file mode 100644
index 00000000..d7fbb06f
--- /dev/null
+++ b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala
@@ -0,0 +1,120 @@
+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? 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 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)));
+ if (this.mime_type != null) {
+ node.put_node(new StanzaNode.build("media_type", NS_URI).put_node(new StanzaNode.text(this.mime_type)));
+ }
+ if (this.size != -1) {
+ node.put_node(new StanzaNode.build("size", NS_URI).put_node(new StanzaNode.text(this.size.to_string())));
+ }
+ if (this.date != null) {
+ node.put_node(new StanzaNode.build("date", NS_URI).put_node(new StanzaNode.text(this.date.to_string())));
+ }
+ if (this.desc != null) {
+ node.put_node(new StanzaNode.build("desc", NS_URI).put_node(new StanzaNode.text(this.desc)));
+ }
+ if (this.width != -1) {
+ node.put_node(new StanzaNode.build("width", NS_URI).put_node(new StanzaNode.text(this.width.to_string())));
+ }
+ if (this.height != -1) {
+ node.put_node(new StanzaNode.build("height", NS_URI).put_node(new StanzaNode.text(this.height.to_string())));
+ }
+ 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 (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? 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;
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala
new file mode 100644
index 00000000..285bfe78
--- /dev/null
+++ b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala
@@ -0,0 +1,225 @@
+using Xmpp;
+
+namespace Xmpp.Xep.StatelessFileSharing {
+
+
+ public const string STANZA_NAME = "file-transfer";
+
+ 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;
+
+ 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;
+ }
+
+ public string serialize() {
+ return this.to_stanza_node().to_xml();
+ }
+
+ 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;
+ }
+
+ 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 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;
+ }
+
+ 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;
+ }
+ }
+
+ 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;
+ }
+ }
+
+ 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;
+ }
+
+ 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);
+
+ 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;
+ }
+ }
+
+ 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 override string get_ns() {
+ return NS_URI;
+ }
+
+ public override string get_id() {
+ return ID;
+ }
+ }
+
+ 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));
+
+ 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 void send_stateless_file_transfer_attachment(XmppStream stream, Jid to, string message_type, SfsSourceAttachment attachment) {
+ MessageStanza message = attachment.to_message_stanza(to, message_type);
+
+ printerr("Sending message:\n");
+ printerr(message.stanza.to_ansi_string(true));
+ stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, message);
+ }
+
+ 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 override void attach(XmppStream stream) {
+ stream.get_module(MessageModule.IDENTITY).received_message.connect(on_received_message);
+ }
+
+ public override void detach(XmppStream stream) {
+ stream.get_module(MessageModule.IDENTITY).received_message.disconnect(on_received_message);
+ }
+
+ public override string get_ns() { return NS_URI; }
+ public override string get_id() { return IDENTITY.id; }
+ }
+}