From 5fc0435cc1227bf445d06a3931343020faaecd10 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Thu, 9 Mar 2017 15:34:32 +0100 Subject: Save unsent messages (acc offline etc) and send later; don't send pgp messages if pgp error --- client/src/entity/conversation.vala | 18 ++-- client/src/entity/message.vala | 4 +- client/src/service/chat_interaction.vala | 4 +- client/src/service/conversation_manager.vala | 13 ++- client/src/service/database.vala | 55 +++++----- client/src/service/message_manager.vala | 113 +++++++++++++-------- client/src/service/muc_manager.vala | 4 +- client/src/service/stream_interactor.vala | 2 + client/src/ui/conversation_selector/list.vala | 2 +- .../conversation_summary/merged_message_item.vala | 8 +- client/src/ui/conversation_summary/view.vala | 3 +- client/src/ui/conversation_titlebar.vala | 20 ++-- 12 files changed, 151 insertions(+), 95 deletions(-) (limited to 'client/src') diff --git a/client/src/entity/conversation.vala b/client/src/entity/conversation.vala index d5c861d9..2da6dce3 100644 --- a/client/src/entity/conversation.vala +++ b/client/src/entity/conversation.vala @@ -3,19 +3,23 @@ public class Conversation : Object { public signal void object_updated(Conversation conversation); - public const int ENCRYPTION_UNENCRYPTED = 0; - public const int ENCRYPTION_PGP = 1; + public enum Encryption { + UNENCRYPTED, + PGP + } - public const int TYPE_CHAT = 0; - public const int TYPE_GROUPCHAT = 1; + public enum Type { + CHAT, + GROUPCHAT + } public int id { get; set; } public Account account { get; private set; } public Jid counterpart { get; private set; } public bool active { get; set; } public DateTime last_active { get; set; } - public int encryption { get; set; } - public int? type_ { get; set; } + public Encryption encryption { get; set; } + public Type? type_ { get; set; } public Message read_up_to { get; set; } public Conversation(Jid jid, Account account) { @@ -23,7 +27,7 @@ public class Conversation : Object { this.account = account; this.active = false; this.last_active = new DateTime.from_unix_utc(0); - this.encryption = ENCRYPTION_UNENCRYPTED; + this.encryption = Encryption.UNENCRYPTED; } public Conversation.with_id(Jid jid, Account account, int id) { diff --git a/client/src/entity/message.vala b/client/src/entity/message.vala index 042166b0..65d05bdf 100644 --- a/client/src/entity/message.vala +++ b/client/src/entity/message.vala @@ -11,7 +11,9 @@ public class Dino.Entities.Message : Object { NONE, RECEIVED, READ, - ACKNOWLEDGED + ACKNOWLEDGED, + UNSENT, + WONTSEND } public enum Encryption { diff --git a/client/src/service/chat_interaction.vala b/client/src/service/chat_interaction.vala index ed805a93..cd6907fa 100644 --- a/client/src/service/chat_interaction.vala +++ b/client/src/service/chat_interaction.vala @@ -47,7 +47,7 @@ public class ChatInteraction : StreamInteractionModule, Object { public void on_message_entered(Conversation conversation) { if (Settings.instance().send_read) { - if (!last_input_interaction.has_key(conversation) && conversation.type_ != Conversation.TYPE_GROUPCHAT) { + if (!last_input_interaction.has_key(conversation) && conversation.type_ != Conversation.Type.GROUPCHAT) { send_chat_state_notification(conversation, Xep.ChatStateNotifications.STATE_COMPOSING); } } @@ -82,7 +82,7 @@ public class ChatInteraction : StreamInteractionModule, Object { } private void check_send_read() { - if (selected_conversation == null || selected_conversation.type_ == Conversation.TYPE_GROUPCHAT) return; + if (selected_conversation == null || selected_conversation.type_ == Conversation.Type.GROUPCHAT) return; Entities.Message? message = MessageManager.get_instance(stream_interactor).get_last_message(selected_conversation); if (message != null && message.direction == Entities.Message.DIRECTION_RECEIVED && message.stanza != null && !message.equals(selected_conversation.read_up_to)) { diff --git a/client/src/service/conversation_manager.vala b/client/src/service/conversation_manager.vala index 5337f007..716c9b39 100644 --- a/client/src/service/conversation_manager.vala +++ b/client/src/service/conversation_manager.vala @@ -27,6 +27,7 @@ public class ConversationManager : StreamInteractionModule, Object { stream_interactor.account_added.connect(on_account_added); MucManager.get_instance(stream_interactor).groupchat_joined.connect(on_groupchat_joined); MessageManager.get_instance(stream_interactor).pre_message_received.connect(on_message_received); + MessageManager.get_instance(stream_interactor).message_sent.connect(on_message_sent); } public Conversation? get_conversation(Jid jid, Account account) { @@ -37,12 +38,12 @@ public class ConversationManager : StreamInteractionModule, Object { } public Conversation get_add_conversation(Jid jid, Account account) { - ensure_add_conversation(jid, account, Conversation.TYPE_CHAT); + ensure_add_conversation(jid, account, Conversation.Type.CHAT); return get_conversation(jid, account); } public void ensure_start_conversation(Jid jid, Account account) { - ensure_add_conversation(jid, account, Conversation.TYPE_CHAT); + ensure_add_conversation(jid, account, Conversation.Type.CHAT); Conversation? conversation = get_conversation(jid, account); if (conversation != null) { conversation.last_active = new DateTime.now_utc(); @@ -73,12 +74,16 @@ public class ConversationManager : StreamInteractionModule, Object { ensure_start_conversation(conversation.counterpart, conversation.account); } + private void on_message_sent(Entities.Message message, Conversation conversation) { + conversation.last_active = message.time; + } + private void on_groupchat_joined(Account account, Jid jid, string nick) { - ensure_add_conversation(jid, account, Conversation.TYPE_GROUPCHAT); + ensure_add_conversation(jid, account, Conversation.Type.GROUPCHAT); ensure_start_conversation(jid, account); } - private void ensure_add_conversation(Jid jid, Account account, int type) { + private void ensure_add_conversation(Jid jid, Account account, Conversation.Type type) { if (conversations.has_key(account) && !conversations[account].has_key(jid)) { Conversation conversation = new Conversation(jid, account); conversation.type_ = type; diff --git a/client/src/service/database.vala b/client/src/service/database.vala index 6428d83f..13be6222 100644 --- a/client/src/service/database.vala +++ b/client/src/service/database.vala @@ -36,11 +36,11 @@ public class Database : Qlite.Database { public class MessageTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column stanza_id = new Column.Text("stanza_id"); - public Column account_id = new Column.Integer("account_id"); - public Column counterpart_id = new Column.Integer("counterpart_id"); + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column counterpart_id = new Column.Integer("counterpart_id") { not_null = true }; public Column counterpart_resource = new Column.Text("counterpart_resource"); public Column our_resource = new Column.Text("our_resource"); - public Column direction = new Column.BoolInt("direction"); + public Column direction = new Column.BoolInt("direction") { not_null = true }; public Column type_ = new Column.Integer("type"); public Column time = new Column.Long("time"); public Column local_time = new Column.Long("local_time"); @@ -205,24 +205,20 @@ public class Database : Qlite.Database { } public void add_message(Message new_message, Account account) { - if (new_message.body == null || new_message.stanza_id == null) { - return; - } - - new_message.id = (int) message.insert() - .value(message.stanza_id, new_message.stanza_id) - .value(message.account_id, new_message.account.id) - .value(message.counterpart_id, get_jid_id(new_message.counterpart)) - .value(message.counterpart_resource, new_message.counterpart.resourcepart) - .value(message.our_resource, new_message.ourpart.resourcepart) - .value(message.direction, new_message.direction) - .value(message.type_, new_message.type_) - .value(message.time, (long) new_message.time.to_unix()) - .value(message.local_time, (long) new_message.local_time.to_unix()) - .value(message.body, new_message.body) - .value(message.encryption, new_message.encryption) - .value(message.marked, new_message.marked) - .perform(); + InsertBuilder builder = message.insert() + .value(message.account_id, new_message.account.id) + .value(message.counterpart_id, get_jid_id(new_message.counterpart)) + .value(message.counterpart_resource, new_message.counterpart.resourcepart) + .value(message.our_resource, new_message.ourpart.resourcepart) + .value(message.direction, new_message.direction) + .value(message.type_, new_message.type_) + .value(message.time, (long) new_message.time.to_unix()) + .value(message.local_time, (long) new_message.local_time.to_unix()) + .value(message.body, new_message.body) + .value(message.encryption, new_message.encryption) + .value(message.marked, new_message.marked); + if (new_message.stanza_id != null) builder.value(message.stanza_id, new_message.stanza_id); + new_message.id = (int) builder.perform(); if (new_message.real_jid != null) { real_jid.insert() @@ -288,6 +284,14 @@ public class Database : Qlite.Database { return ret; } + public Gee.List get_unsend_messages(Account account) { + Gee.List ret = new ArrayList(); + foreach (Row row in message.select().with(message.marked, "=", (int) Message.Marked.UNSENT)) { + ret.add(get_message_from_row(row)); + } + return ret; + } + public bool contains_message(Message query_message, Account account) { int jid_id = get_jid_id(query_message.counterpart); return message.select() @@ -295,6 +299,9 @@ public class Database : Qlite.Database { .with(message.stanza_id, "=", query_message.stanza_id) .with(message.counterpart_id, "=", jid_id) .with(message.counterpart_resource, "=", query_message.counterpart.resourcepart) + .with(message.body, "=", query_message.body) + .with(message.time, "<", (long) query_message.time.add_minutes(1).to_unix()) + .with(message.time, ">", (long) query_message.time.add_minutes(-1).to_unix()) .count() > 0; } @@ -332,6 +339,8 @@ public class Database : Qlite.Database { new_message.marked = (Message.Marked) row[message.marked]; new_message.encryption = (Message.Encryption) row[message.encryption]; new_message.real_jid = get_real_jid_for_message(new_message); + + new_message.notify.connect(on_message_update); return new_message; } @@ -386,8 +395,8 @@ public class Database : Qlite.Database { new_conversation.active = row[conversation.active]; int64? last_active = row[conversation.last_active]; if (last_active != null) new_conversation.last_active = new DateTime.from_unix_utc(last_active); - new_conversation.type_ = row[conversation.type_]; - new_conversation.encryption = row[conversation.encryption]; + new_conversation.type_ = (Conversation.Type) row[conversation.type_]; + new_conversation.encryption = (Conversation.Encryption) row[conversation.encryption]; int? read_up_to = row[conversation.read_up_to]; if (read_up_to != null) new_conversation.read_up_to = get_message_by_id(read_up_to); diff --git a/client/src/service/message_manager.vala b/client/src/service/message_manager.vala index a268e619..054db518 100644 --- a/client/src/service/message_manager.vala +++ b/client/src/service/message_manager.vala @@ -6,7 +6,7 @@ using Dino.Entities; namespace Dino { public class MessageManager : StreamInteractionModule, Object { - public const string id = "message_manager"; + public const string ID = "message_manager"; public signal void pre_message_received(Entities.Message message, Conversation conversation); public signal void message_received(Entities.Message message, Conversation conversation); @@ -25,46 +25,16 @@ public class MessageManager : StreamInteractionModule, Object { this.stream_interactor = stream_interactor; this.db = db; stream_interactor.account_added.connect(on_account_added); + stream_interactor.connection_manager.connection_state_changed.connect((account, state) => { + if (state == ConnectionManager.ConnectionState.CONNECTED) send_unsent_messages(account); + }); } public void send_message(string text, Conversation conversation) { - Entities.Message message = new Entities.Message(); - message.account = conversation.account; - message.body = text; - message.time = new DateTime.now_utc(); - message.local_time = new DateTime.now_utc(); - message.direction = Entities.Message.DIRECTION_SENT; - message.counterpart = conversation.counterpart; - message.ourpart = new Jid(conversation.account.bare_jid.to_string() + "/" + conversation.account.resourcepart); - - Core.XmppStream stream = stream_interactor.get_stream(conversation.account); - - if (stream != null) { - Xmpp.Message.Stanza new_message = new Xmpp.Message.Stanza(); - new_message.to = message.counterpart.to_string(); - new_message.body = message.body; - if (conversation.type_ == Conversation.TYPE_GROUPCHAT) { - new_message.type_ = Xmpp.Message.Stanza.TYPE_GROUPCHAT; - } else { - new_message.type_ = Xmpp.Message.Stanza.TYPE_CHAT; - } - if (conversation.encryption == Conversation.ENCRYPTION_PGP) { - string? key_id = PgpManager.get_instance(stream_interactor).get_key_id(conversation.account, message.counterpart); - if (key_id != null) { - bool encrypted = Xep.Pgp.Module.get_module(stream).encrypt(new_message, key_id); - if (encrypted) message.encryption = Entities.Message.Encryption.PGP; - } - } - Xmpp.Message.Module.get_module(stream).send_message(stream, new_message); - message.stanza_id = new_message.id; - message.stanza = new_message; - db.add_message(message, conversation.account); - } else { - // save for resend - } - - conversation.last_active = message.time; + Entities.Message message = create_out_message(text, conversation); add_message(message, conversation); + db.add_message(message, conversation.account); + send_xmpp_message(message, conversation); message_sent(message, conversation); } @@ -97,17 +67,26 @@ public class MessageManager : StreamInteractionModule, Object { } public string get_id() { - return id; + return ID; } public static MessageManager? get_instance(StreamInteractor stream_interactor) { - return (MessageManager) stream_interactor.get_module(id); + return (MessageManager) stream_interactor.get_module(ID); } private void on_account_added(Account account) { stream_interactor.module_manager.message_modules[account].received_message.connect( (stream, message) => { on_message_received(account, message); }); + stream_interactor.stream_negotiated.connect(send_unsent_messages); + } + + private void send_unsent_messages(Account account) { + Gee.List unsend_messages = db.get_unsend_messages(account); + foreach (Entities.Message message in unsend_messages) { + Conversation conversation = ConversationManager.get_instance(stream_interactor).get_conversation(message.counterpart, account); + send_xmpp_message(message, conversation, true); + } } private void on_message_received(Account account, Xmpp.Message.Stanza message) { @@ -128,10 +107,8 @@ public class MessageManager : StreamInteractionModule, Object { new_message.body = message.body; new_message.stanza = message; new_message.set_type_string(message.type_); - new_message.time = Xep.DelayedDelivery.Module.get_send_time(message); - if (new_message.time == null) { - new_message.time = new DateTime.now_utc(); - } + Xep.DelayedDelivery.MessageFlag? deleyed_delivery_flag = Xep.DelayedDelivery.MessageFlag.get_flag(message); + new_message.time = deleyed_delivery_flag != null ? deleyed_delivery_flag.datetime : new DateTime.now_utc(); new_message.local_time = new DateTime.now_utc(); if (Xep.Pgp.MessageFlag.get_flag(message) != null) { new_message.encryption = Entities.Message.Encryption.PGP; @@ -161,6 +138,56 @@ public class MessageManager : StreamInteractionModule, Object { } messages[conversation].add(message); } + + private Entities.Message create_out_message(string text, Conversation conversation) { + Entities.Message message = new Entities.Message(); + message.stanza_id = UUID.generate_random_unparsed(); + message.account = conversation.account; + message.body = text; + message.time = new DateTime.now_utc(); + message.local_time = new DateTime.now_utc(); + message.direction = Entities.Message.DIRECTION_SENT; + message.counterpart = conversation.counterpart; + message.ourpart = new Jid(conversation.account.bare_jid.to_string() + "/" + conversation.account.resourcepart); + + if (conversation.encryption == Conversation.Encryption.PGP) { + message.encryption = Entities.Message.Encryption.PGP; + } + return message; + } + + private void send_xmpp_message(Entities.Message message, Conversation conversation, bool delayed = false) { + Core.XmppStream stream = stream_interactor.get_stream(conversation.account); + message.marked = Entities.Message.Marked.NONE; + if (stream != null) { + Xmpp.Message.Stanza new_message = new Xmpp.Message.Stanza(message.stanza_id); + new_message.to = message.counterpart.to_string(); + new_message.body = message.body; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + new_message.type_ = Xmpp.Message.Stanza.TYPE_GROUPCHAT; + } else { + new_message.type_ = Xmpp.Message.Stanza.TYPE_CHAT; + } + if (message.encryption == Entities.Message.Encryption.PGP) { + string? key_id = PgpManager.get_instance(stream_interactor).get_key_id(conversation.account, message.counterpart); + if (key_id != null) { + bool encrypted = Xep.Pgp.Module.get_module(stream).encrypt(new_message, key_id); + if (!encrypted) { + message.marked = Entities.Message.Marked.WONTSEND; + return; + } + } + } + if (delayed) { + Xmpp.Xep.DelayedDelivery.Module.get_module(stream).set_message_delay(new_message, message.time); + } + Xmpp.Message.Module.get_module(stream).send_message(stream, new_message); + message.stanza_id = new_message.id; + message.stanza = new_message; + } else { + message.marked = Entities.Message.Marked.UNSENT; + } + } } } \ No newline at end of file diff --git a/client/src/service/muc_manager.vala b/client/src/service/muc_manager.vala index 8f6339a5..be23d391 100644 --- a/client/src/service/muc_manager.vala +++ b/client/src/service/muc_manager.vala @@ -66,7 +66,7 @@ public class MucManager : StreamInteractionModule, Object { public bool is_groupchat(Jid jid, Account account) { Conversation? conversation = ConversationManager.get_instance(stream_interactor).get_conversation(jid, account); - return !jid.is_full() && conversation != null && conversation.type_ == Conversation.TYPE_GROUPCHAT; + return !jid.is_full() && conversation != null && conversation.type_ == Conversation.Type.GROUPCHAT; } public bool is_groupchat_occupant(Jid jid, Account account) { @@ -162,7 +162,7 @@ public class MucManager : StreamInteractionModule, Object { } private void on_pre_message_received(Entities.Message message, Conversation conversation) { - if (conversation.type_ != Conversation.TYPE_GROUPCHAT) return; + if (conversation.type_ != Conversation.Type.GROUPCHAT) return; Core.XmppStream stream = stream_interactor.get_stream(conversation.account); if (stream == null) return; if (Xep.DelayedDelivery.MessageFlag.get_flag(message.stanza) == null) { diff --git a/client/src/service/stream_interactor.vala b/client/src/service/stream_interactor.vala index 56591cf0..f3859e3b 100644 --- a/client/src/service/stream_interactor.vala +++ b/client/src/service/stream_interactor.vala @@ -4,6 +4,7 @@ using Xmpp; using Dino.Entities; namespace Dino { + public class StreamInteractor { public signal void account_added(Account account); @@ -65,4 +66,5 @@ public class StreamInteractor { public interface StreamInteractionModule : Object { internal abstract string get_id(); } + } \ No newline at end of file diff --git a/client/src/ui/conversation_selector/list.vala b/client/src/ui/conversation_selector/list.vala index b114c3fa..e6a5231c 100644 --- a/client/src/ui/conversation_selector/list.vala +++ b/client/src/ui/conversation_selector/list.vala @@ -95,7 +95,7 @@ public class List : ListBox { public void add_conversation(Conversation conversation) { ConversationRow row; if (!rows.has_key(conversation)) { - if (conversation.type_ == Conversation.TYPE_GROUPCHAT) { + if (conversation.type_ == Conversation.Type.GROUPCHAT) { row = new GroupchatRow(stream_interactor, conversation); } else { row = new ChatRow(stream_interactor, conversation); diff --git a/client/src/ui/conversation_summary/merged_message_item.vala b/client/src/ui/conversation_summary/merged_message_item.vala index b1e99d3e..b73e8b4f 100644 --- a/client/src/ui/conversation_summary/merged_message_item.vala +++ b/client/src/ui/conversation_summary/merged_message_item.vala @@ -68,10 +68,16 @@ public class MergedMessageItem : Grid { } private void update_received() { + received_image.visible = true; bool all_received = true; bool all_read = true; foreach (Message message in messages) { - if (message.marked != Message.Marked.READ) { + if (message.marked == Message.Marked.WONTSEND) { + Gtk.IconTheme icon_theme = Gtk.IconTheme.get_default(); + Gtk.IconInfo? icon_info = icon_theme.lookup_icon("dialog-warning-symbolic", IconSize.SMALL_TOOLBAR, 0); + received_image.set_from_pixbuf(icon_info.load_symbolic({1,0,0,1})); + return; + } else if (message.marked != Message.Marked.READ) { all_read = false; if (message.marked != Message.Marked.RECEIVED) { all_received = false; diff --git a/client/src/ui/conversation_summary/view.vala b/client/src/ui/conversation_summary/view.vala index 0ea1a32c..59cf88aa 100644 --- a/client/src/ui/conversation_summary/view.vala +++ b/client/src/ui/conversation_summary/view.vala @@ -203,7 +203,8 @@ public class View : Box { return message_item != null && message_item.from.equals(message.from) && message_item.messages.get(0).encryption == message.encryption && - message.time.difference(message_item.initial_time) < TimeSpan.MINUTE; + message.time.difference(message_item.initial_time) < TimeSpan.MINUTE && + (message_item.messages.get(0).marked == Entities.Message.Marked.WONTSEND) == (message.marked == Entities.Message.Marked.WONTSEND); } private void force_alloc_width(Widget widget, int width) { diff --git a/client/src/ui/conversation_titlebar.vala b/client/src/ui/conversation_titlebar.vala index cd21353c..25304e1a 100644 --- a/client/src/ui/conversation_titlebar.vala +++ b/client/src/ui/conversation_titlebar.vala @@ -41,19 +41,19 @@ public class Dino.Ui.ConversationTitlebar : Gtk.HeaderBar { string? pgp_id = PgpManager.get_instance(stream_interactor).get_key_id(conversation.account, conversation.counterpart); button_pgp.set_sensitive(pgp_id != null); switch (conversation.encryption) { - case Conversation.ENCRYPTION_UNENCRYPTED: + case Conversation.Encryption.UNENCRYPTED: button_unencrypted.set_active(true); break; - case Conversation.ENCRYPTION_PGP: + case Conversation.Encryption.PGP: button_pgp.set_active(true); break; } } private void update_encryption_menu_icon() { - encryption_button.visible = conversation.type_ == Conversation.TYPE_CHAT; - if (conversation.type_ == Conversation.TYPE_CHAT) { - if (conversation.encryption == Conversation.ENCRYPTION_UNENCRYPTED) { + encryption_button.visible = (conversation.type_ == Conversation.Type.CHAT); + if (conversation.type_ == Conversation.Type.CHAT) { + if (conversation.encryption == Conversation.Encryption.UNENCRYPTED) { encryption_button.set_image(new Image.from_icon_name("changes-allow-symbolic", IconSize.BUTTON)); } else { encryption_button.set_image(new Image.from_icon_name("changes-prevent-symbolic", IconSize.BUTTON)); @@ -62,8 +62,8 @@ public class Dino.Ui.ConversationTitlebar : Gtk.HeaderBar { } private void update_groupchat_menu() { - groupchat_button.visible = conversation.type_ == Conversation.TYPE_GROUPCHAT; - if (conversation.type_ == Conversation.TYPE_GROUPCHAT) { + groupchat_button.visible = conversation.type_ == Conversation.Type.GROUPCHAT; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { groupchat_button.set_use_popover(true); Popover popover = new Popover(null); OccupantList occupant_list = new OccupantList(stream_interactor, conversation); @@ -80,7 +80,7 @@ public class Dino.Ui.ConversationTitlebar : Gtk.HeaderBar { private void update_subtitle(string? subtitle = null) { if (subtitle != null) { set_subtitle(subtitle); - } else if (conversation.type_ == Conversation.TYPE_GROUPCHAT) { + } else if (conversation.type_ == Conversation.Type.GROUPCHAT) { string subject = MucManager.get_instance(stream_interactor).get_groupchat_subject(conversation.counterpart, conversation.account); set_subtitle(subject != "" ? subject : null); } else { @@ -106,9 +106,9 @@ public class Dino.Ui.ConversationTitlebar : Gtk.HeaderBar { button_unencrypted.toggled.connect(() => { if (conversation != null) { if (button_unencrypted.get_active()) { - conversation.encryption = Conversation.ENCRYPTION_UNENCRYPTED; + conversation.encryption = Conversation.Encryption.UNENCRYPTED; } else if (button_pgp.get_active()) { - conversation.encryption = Conversation.ENCRYPTION_PGP; + conversation.encryption = Conversation.Encryption.PGP; } update_encryption_menu_icon(); } -- cgit v1.2.3-70-g09d2