From 638d81d67ecd6c7c8be7fd67aeaf15d16486f8e9 Mon Sep 17 00:00:00 2001 From: Marvin W Date: Tue, 19 Jun 2018 12:52:00 +0200 Subject: More cleanup and database modifications --- libdino/src/plugin/interfaces.vala | 9 ++++ libdino/src/plugin/registry.vala | 11 +++++ .../ui/conversation_summary/conversation_view.vala | 14 ++++-- plugins/omemo/data/contact_details_dialog.ui | 40 ++++++++++++++++ plugins/omemo/src/account_settings_dialog.vala | 2 +- plugins/omemo/src/contact_details_dialog.vala | 56 +++++++++++++++++++--- plugins/omemo/src/contact_details_provider.vala | 5 +- plugins/omemo/src/database.vala | 4 +- .../omemo/src/device_notification_populator.vala | 34 ++++++------- plugins/omemo/src/manager.vala | 31 +++++------- plugins/omemo/src/plugin.vala | 2 +- plugins/omemo/src/stream_module.vala | 14 ++++-- qlite/src/query_builder.vala | 10 +++- 13 files changed, 176 insertions(+), 56 deletions(-) diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index 1bd3676b..09d4d921 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -80,6 +80,12 @@ public abstract interface ConversationItemPopulator : Object { public abstract void close(Conversation conversation); } +public abstract interface NotificationPopulator : Object { + public abstract string id { get; } + public abstract void init(Conversation conversation, NotificationCollection summary, WidgetType type); + public abstract void close(Conversation conversation); +} + public abstract class MetaConversationItem : Object { public virtual Jid? jid { get; set; default=null; } public virtual string color { get; set; default=null; } @@ -105,6 +111,9 @@ public abstract class MetaConversationNotification : Object { public interface ConversationItemCollection : Object { public signal void insert_item(MetaConversationItem item); public signal void remove_item(MetaConversationItem item); +} + +public interface NotificationCollection : Object { public signal void add_meta_notification(MetaConversationNotification item); public signal void remove_meta_notification(MetaConversationNotification item); } diff --git a/libdino/src/plugin/registry.vala b/libdino/src/plugin/registry.vala index 7b4410aa..fbdf2c5c 100644 --- a/libdino/src/plugin/registry.vala +++ b/libdino/src/plugin/registry.vala @@ -9,6 +9,7 @@ public class Registry { internal Map text_commands = new HashMap(); internal Gee.List message_displays = new ArrayList(); internal Gee.List conversation_item_populators = new ArrayList(); + internal Gee.List notification_populators = new ArrayList(); internal Gee.Collection conversation_titlebar_entries = new Gee.TreeSet((a, b) => { if (a.order < b.order) { return -1; @@ -89,6 +90,16 @@ public class Registry { return true; } } + + public bool register_notification_populator(NotificationPopulator populator) { + lock (notification_populators) { + foreach(NotificationPopulator p in notification_populators) { + if (p.id == populator.id) return false; + } + notification_populators.add(populator); + return true; + } + } } } diff --git a/main/src/ui/conversation_summary/conversation_view.vala b/main/src/ui/conversation_summary/conversation_view.vala index 87a2dd34..b4a34f3b 100644 --- a/main/src/ui/conversation_summary/conversation_view.vala +++ b/main/src/ui/conversation_summary/conversation_view.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino.Ui.ConversationSummary { [GtkTemplate (ui = "/im/dino/Dino/conversation_summary/view.ui")] -public class ConversationView : Box, Plugins.ConversationItemCollection { +public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins.NotificationCollection { public Conversation? conversation { get; private set; } @@ -83,6 +83,9 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_item_populators) { populator.close(conversation); } + foreach (Plugins.NotificationPopulator populator in app.plugin_registry.notification_populators) { + populator.close(conversation); + } } this.conversation = conversation; stack.set_visible_child_name("void"); @@ -95,6 +98,9 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_item_populators) { populator.init(conversation, this, Plugins.WidgetType.GTK); } + foreach (Plugins.NotificationPopulator populator in app.plugin_registry.notification_populators) { + populator.init(conversation, this, Plugins.WidgetType.GTK); + } message_item_populator.init(conversation, this); message_item_populator.populate_latest(conversation, 40); Idle.add(() => { on_value_notify(); return false; }); @@ -130,14 +136,16 @@ public class ConversationView : Box, Plugins.ConversationItemCollection { public void on_add_meta_notification(Plugins.MetaConversationNotification notification) { Widget? widget = (Widget) notification.get_widget(Plugins.WidgetType.GTK); - if(widget != null) + if (widget != null) { add_notification(widget); + } } public void on_remove_meta_notification(Plugins.MetaConversationNotification notification){ Widget? widget = (Widget) notification.get_widget(Plugins.WidgetType.GTK); - if(widget != null) + if (widget != null) { remove_notification(widget); + } } public void add_notification(Widget widget) { diff --git a/plugins/omemo/data/contact_details_dialog.ui b/plugins/omemo/data/contact_details_dialog.ui index e26f79af..ceac2179 100644 --- a/plugins/omemo/data/contact_details_dialog.ui +++ b/plugins/omemo/data/contact_details_dialog.ui @@ -49,6 +49,46 @@ + + + 12 + horizontal + False + + + True + start + center + True + Verified Devices + 2 + + + + + + + + + + False + 18 + + + never + automatic + True + True + + + True + True + + + + + + 12 diff --git a/plugins/omemo/src/account_settings_dialog.vala b/plugins/omemo/src/account_settings_dialog.vala index f0262c31..76357dbb 100644 --- a/plugins/omemo/src/account_settings_dialog.vala +++ b/plugins/omemo/src/account_settings_dialog.vala @@ -26,7 +26,7 @@ public class AccountSettingsDialog : Gtk.Dialog { int own_id = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.device_id]; int i = 0; - foreach (Row row in plugin.db.identity_meta.with_address(account.bare_jid.to_string())) { + foreach (Row row in plugin.db.identity_meta.with_address(account.id, account.bare_jid.to_string())) { if (row[plugin.db.identity_meta.device_id] == own_id) continue; if (i == 0) { other_list.foreach((widget) => { widget.destroy(); }); diff --git a/plugins/omemo/src/contact_details_dialog.vala b/plugins/omemo/src/contact_details_dialog.vala index 3ee6380a..6bf2c83e 100644 --- a/plugins/omemo/src/contact_details_dialog.vala +++ b/plugins/omemo/src/contact_details_dialog.vala @@ -17,6 +17,9 @@ public class ContactDetailsDialog : Gtk.Dialog { [GtkChild] private Box fingerprints_prompt_label; [GtkChild] private Frame fingerprints_prompt_container; [GtkChild] private Grid fingerprints_prompt; + [GtkChild] private Box fingerprints_verified_label; + [GtkChild] private Frame fingerprints_verified_container; + [GtkChild] private Grid fingerprints_verified; private void set_device_trust(Row device, bool trust) { @@ -57,9 +60,10 @@ public class ContactDetailsDialog : Gtk.Dialog { this.jid = jid; int i = 0; - foreach (Row device in plugin.db.identity_meta.with_address(jid.to_string()).with(plugin.db.identity_meta.trusted_identity, "!=", Database.IdentityMetaTable.TrustLevel.UNKNOWN).with(plugin.db.identity_meta.identity_id, "=", account.id)) { - if(device[plugin.db.identity_meta.identity_key_public_base64] == null) + foreach (Row device in plugin.db.identity_meta.with_address(account.id, jid.to_string()).with(plugin.db.identity_meta.trusted_identity, "!=", Database.IdentityMetaTable.TrustLevel.UNKNOWN).with(plugin.db.identity_meta.trusted_identity, "!=", Database.IdentityMetaTable.TrustLevel.VERIFIED)) { + if (device[plugin.db.identity_meta.identity_key_public_base64] == null) { continue; + } add_fingerprint(device, i, (Database.IdentityMetaTable.TrustLevel) device[plugin.db.identity_meta.trusted_identity]); i++; @@ -67,9 +71,10 @@ public class ContactDetailsDialog : Gtk.Dialog { } int j = 0; - foreach (Row device in plugin.db.identity_meta.with_address(jid.to_string()).with(plugin.db.identity_meta.trusted_identity, "=", Database.IdentityMetaTable.TrustLevel.UNKNOWN).with(plugin.db.identity_meta.identity_id, "=", account.id)) { - if(device[plugin.db.identity_meta.identity_key_public_base64] == null) + foreach (Row device in plugin.db.identity_meta.with_address(account.id, jid.to_string()).with(plugin.db.identity_meta.trusted_identity, "=", Database.IdentityMetaTable.TrustLevel.UNKNOWN)) { + if (device[plugin.db.identity_meta.identity_key_public_base64] == null) { continue; + } string res = fingerprint_markup(fingerprint_from_base64(device[plugin.db.identity_meta.identity_key_public_base64])); Label lbl = new Label(res) @@ -90,8 +95,9 @@ public class ContactDetailsDialog : Gtk.Dialog { add_fingerprint(device, i, Database.IdentityMetaTable.TrustLevel.TRUSTED); i++; - if(j == 0) + if (j == 0) { fingerprints_prompt.attach(new Label("No more new devices") { visible = true, valign = Align.CENTER, halign = Align.CENTER, margin = 8, hexpand = true }, 0, 0); + } }); Button no = new Button() { visible = true, valign = Align.CENTER, hexpand = true}; @@ -107,8 +113,9 @@ public class ContactDetailsDialog : Gtk.Dialog { add_fingerprint(device, i, Database.IdentityMetaTable.TrustLevel.UNTRUSTED); i++; - if(j == 0) + if (j == 0) { fingerprints_prompt.attach(new Label("No more new devices") { visible = true, valign = Align.CENTER, halign = Align.CENTER, margin = 8, hexpand = true }, 0, 0); + } }); box.pack_start(yes); @@ -125,6 +132,43 @@ public class ContactDetailsDialog : Gtk.Dialog { fingerprints_prompt_container.visible = true; } + int k = 0; + foreach (Row device in plugin.db.identity_meta.with_address(account.id, jid.to_string()).without_null(plugin.db.identity_meta.identity_key_public_base64).with(plugin.db.identity_meta.trusted_identity, "=", Database.IdentityMetaTable.TrustLevel.VERIFIED)) { + string res = fingerprint_markup(fingerprint_from_base64(device[plugin.db.identity_meta.identity_key_public_base64])); + Label lbl = new Label(res) + { use_markup=true, justify=Justification.RIGHT, visible=true, margin = 8, halign = Align.START }; + + Box box = new Box(Gtk.Orientation.HORIZONTAL, 0) { visible = true, valign = Align.CENTER, hexpand = true, margin = 8 }; + + Button no = new Button() { visible = true, valign = Align.CENTER, halign = Align.END, hexpand = false }; + no.image = new Image.from_icon_name("list-remove-symbolic", IconSize.BUTTON); + + no.clicked.connect(() => { + set_device_trust(device, false); + + fingerprints_verified.remove(no); + fingerprints_verified.remove(lbl); + k--; + + add_fingerprint(device, i, Database.IdentityMetaTable.TrustLevel.UNTRUSTED); + i++; + + if (k == 0) { + fingerprints_verified.attach(new Label("No more new devices") { visible = true, valign = Align.CENTER, halign = Align.CENTER, margin = 8, hexpand = true }, 0, 0); + } + }); + + box.pack_end(no); + + fingerprints_verified.attach(lbl, 0, k); + fingerprints_verified.attach(box, 1, k); + k++; + } + + if( k > 0 ){ + fingerprints_verified_label.visible = true; + fingerprints_verified_container.visible = true; + } } } diff --git a/plugins/omemo/src/contact_details_provider.vala b/plugins/omemo/src/contact_details_provider.vala index 2c6c4944..1cf635c2 100644 --- a/plugins/omemo/src/contact_details_provider.vala +++ b/plugins/omemo/src/contact_details_provider.vala @@ -18,7 +18,7 @@ public class ContactDetailsProvider : Plugins.ContactDetailsProvider, Object { if (conversation.type_ == Conversation.Type.CHAT && type == WidgetType.GTK) { int i = 0; - foreach (Row row in plugin.db.identity_meta.with_address(conversation.counterpart.to_string())) { + foreach (Row row in plugin.db.identity_meta.with_address(conversation.account.id, conversation.counterpart.to_string())) { if (row[plugin.db.identity_meta.identity_key_public_base64] != null) { i++; } @@ -34,6 +34,9 @@ public class ContactDetailsProvider : Plugins.ContactDetailsProvider, Object { btn.activate(); ContactDetailsDialog dialog = new ContactDetailsDialog(plugin, conversation.account, conversation.counterpart); dialog.set_transient_for((Window) btn.get_toplevel()); + dialog.response.connect((response_type) => { + plugin.device_notification_populator.should_hide(); + }); dialog.present(); }); diff --git a/plugins/omemo/src/database.vala b/plugins/omemo/src/database.vala index 48fddde2..3f24e9f6 100644 --- a/plugins/omemo/src/database.vala +++ b/plugins/omemo/src/database.vala @@ -37,8 +37,8 @@ public class Database : Qlite.Database { index("identity_meta_list_idx", {identity_id, address_name}); } - public QueryBuilder with_address(string address_name) { - return select().with(this.address_name, "=", address_name); + public QueryBuilder with_address(int identity_id, string address_name) { + return select().with(this.identity_id, "=", identity_id).with(this.address_name, "=", address_name); } public void insert_device_list(int32 identity_id, string address_name, ArrayList devices) { diff --git a/plugins/omemo/src/device_notification_populator.vala b/plugins/omemo/src/device_notification_populator.vala index 6b3e668b..8549e052 100644 --- a/plugins/omemo/src/device_notification_populator.vala +++ b/plugins/omemo/src/device_notification_populator.vala @@ -4,14 +4,14 @@ using Gtk; namespace Dino.Plugins.Omemo { -public class DeviceNotificationPopulator : ConversationItemPopulator, Object { +public class DeviceNotificationPopulator : NotificationPopulator, Object { public string id { get { return "device_notification"; } } private StreamInteractor? stream_interactor; private Plugin plugin; private Conversation? current_conversation; - private ConversationItemCollection? item_collection; + private NotificationCollection? notification_collection; private ConversationNotification notification; public DeviceNotificationPopulator(Plugin plugin, StreamInteractor stream_interactor) { @@ -20,37 +20,37 @@ public class DeviceNotificationPopulator : ConversationItemPopulator, Object { } public bool has_new_devices(Jid jid) { - return plugin.db.identity_meta.with_address(jid.bare_jid.to_string()).with(plugin.db.identity_meta.identity_id, "=", current_conversation.account.id).with_null(plugin.db.identity_meta.trusted_identity).without_null(plugin.db.identity_meta.identity_key_public_base64).count() > 0; + return plugin.db.identity_meta.with_address(current_conversation.account.id, jid.bare_jid.to_string()).with(plugin.db.identity_meta.trusted_identity, "=", Database.IdentityMetaTable.TrustLevel.UNKNOWN).without_null(plugin.db.identity_meta.identity_key_public_base64).count() > 0; } - public void init(Conversation conversation, ConversationItemCollection item_collection, Plugins.WidgetType type) { + public void init(Conversation conversation, NotificationCollection notification_collection, Plugins.WidgetType type) { current_conversation = conversation; - this.item_collection = item_collection; + this.notification_collection = notification_collection; stream_interactor.module_manager.get_module(conversation.account, StreamModule.IDENTITY).device_list_loaded.connect((jid) => { - if(jid == conversation.counterpart && has_new_devices(conversation.counterpart) && conversation.type_ == Conversation.Type.CHAT) + if (jid == conversation.counterpart && has_new_devices(conversation.counterpart) && conversation.type_ == Conversation.Type.CHAT) { display_notification(); + } }); - if (has_new_devices(conversation.counterpart) && conversation.type_ == Conversation.Type.CHAT) - display_notification(); + if (has_new_devices(conversation.counterpart) && conversation.type_ == Conversation.Type.CHAT) { + display_notification(); + } } - public void close(Conversation conversation) { } - - public void populate_timestamp(Conversation conversation, DateTime from, DateTime to) { } - - public void populate_between_widgets(Conversation conversation, DateTime from, DateTime to) { } + public void close(Conversation conversation) { + notification = null; + } private void display_notification() { if(notification == null) { notification = new ConversationNotification(plugin, current_conversation.account, current_conversation.counterpart); notification.should_hide.connect(should_hide); - item_collection.add_meta_notification(notification); + notification_collection.add_meta_notification(notification); } } - private void should_hide() { - if (!has_new_devices(current_conversation.counterpart)){ - item_collection.remove_meta_notification(notification); + public void should_hide() { + if (!has_new_devices(current_conversation.counterpart) && notification != null){ + notification_collection.remove_meta_notification(notification); notification = null; } } diff --git a/plugins/omemo/src/manager.vala b/plugins/omemo/src/manager.vala index dbb016a4..938c8631 100644 --- a/plugins/omemo/src/manager.vala +++ b/plugins/omemo/src/manager.vala @@ -181,7 +181,7 @@ public class Manager : StreamInteractionModule, Object { ArrayList device_list = module.get_device_list(jid); db.identity_meta.insert_device_list(account.id, jid.bare_jid.to_string(), device_list); int inc = 0; - foreach (Row row in db.identity_meta.with_address(jid.bare_jid.to_string()).with_null(db.identity_meta.identity_key_public_base64)) { + foreach (Row row in db.identity_meta.with_address(account.id, jid.bare_jid.to_string()).with_null(db.identity_meta.identity_key_public_base64)) { module.fetch_bundle(stream, Jid.parse(row[db.identity_meta.address_name]), row[db.identity_meta.device_id]); inc++; } @@ -189,32 +189,26 @@ public class Manager : StreamInteractionModule, Object { if (Plugin.DEBUG) print(@"OMEMO: new bundles $inc/$(device_list.size) for $jid\n"); } - if(db.trust.select().with(db.trust.identity_id, "=", account.id).with(db.trust.address_name, " =", jid.bare_jid.to_string()).count() == 0) - db.trust.insert().value(db.trust.identity_id, account.id).value(db.trust.address_name, jid .bare_jid.to_string()).value(db.trust.blind_trust, true).perform(); - + if (db.trust.select().with(db.trust.identity_id, "=", account.id).with(db.trust.address_name, "=", jid.bare_jid.to_string()).count() == 0) { + db.trust.insert().value(db.trust.identity_id, account.id).value(db.trust.address_name, jid.bare_jid.to_string()).value(db.trust.blind_trust, true).perform(); + } } public void on_bundle_fetched(Account account, Jid jid, int32 device_id, Bundle bundle) { bool blind_trust = db.trust.get_blind_trust(account.id, jid.bare_jid.to_string()); - bool untrust = !(blind_trust || db.identity_meta.with_address(jid.bare_jid.to_string()) - .with(db.identity_meta.identity_id, "=", account.id) + bool untrust = !(blind_trust || db.identity_meta.with_address(account.id, jid.bare_jid.to_string()) .with(db.identity_meta.device_id, "=", device_id) .with(db.identity_meta.identity_key_public_base64, "=", Base64.encode(bundle.identity_key.serialize())) - .count() > 0); - - Database.IdentityMetaTable.TrustLevel trusted = Database.IdentityMetaTable.TrustLevel.UNKNOWN; - foreach(Row row in db.identity_meta.with_address(jid.bare_jid.to_string()) - .with(db.identity_meta.identity_id, "=", account.id) - .with(db.identity_meta.device_id, "=", device_id)) { - trusted = (Database.IdentityMetaTable.TrustLevel) row[db.identity_meta.trusted_identity]; - break; - } + .single().row().is_present()); - if(untrust) + Database.IdentityMetaTable.TrustLevel trusted = (Database.IdentityMetaTable.TrustLevel) db.identity_meta.with_address(account.id, jid.bare_jid.to_string()).with(db.identity_meta.device_id, "=", device_id).single()[db.identity_meta.trusted_identity, Database.IdentityMetaTable.TrustLevel.UNKNOWN]; + + if(untrust) { trusted = Database.IdentityMetaTable.TrustLevel.UNKNOWN; - else if (blind_trust && trusted == Database.IdentityMetaTable.TrustLevel.UNKNOWN) + } else if (blind_trust && trusted == Database.IdentityMetaTable.TrustLevel.UNKNOWN) { trusted = Database.IdentityMetaTable.TrustLevel.TRUSTED; + } db.identity_meta.insert_device_bundle(account.id, jid.bare_jid.to_string(), device_id, bundle, trusted); @@ -234,8 +228,9 @@ public class Manager : StreamInteractionModule, Object { if (trusted != Database.IdentityMetaTable.TrustLevel.TRUSTED && trusted != Database.IdentityMetaTable.TrustLevel.VERIFIED) { module.untrust_device(jid, device_id); } else { - if(account.bare_jid.equals(jid) || (msg.counterpart != null && msg.counterpart.equals_bare(jid))) + if(account.bare_jid.equals(jid) || (msg.counterpart != null && msg.counterpart.equals_bare(jid))) { session_created = module.start_session(stream, jid, device_id, bundle); + } } if (account.bare_jid.equals(jid) && session_created) { state.waiting_own_sessions--; diff --git a/plugins/omemo/src/plugin.vala b/plugins/omemo/src/plugin.vala index 2da35934..79e6a5eb 100644 --- a/plugins/omemo/src/plugin.vala +++ b/plugins/omemo/src/plugin.vala @@ -41,7 +41,7 @@ public class Plugin : RootInterface, Object { this.app.plugin_registry.register_encryption_list_entry(list_entry); this.app.plugin_registry.register_account_settings_entry(settings_entry); this.app.plugin_registry.register_contact_details_entry(contact_details_provider); - this.app.plugin_registry.register_conversation_item_populator(device_notification_populator); + this.app.plugin_registry.register_notification_populator(device_notification_populator); this.app.stream_interactor.module_manager.initialize_account_modules.connect((account, list) => { list.add(new StreamModule()); }); diff --git a/plugins/omemo/src/stream_module.vala b/plugins/omemo/src/stream_module.vala index 83a3dd54..2c792a2f 100644 --- a/plugins/omemo/src/stream_module.vala +++ b/plugins/omemo/src/stream_module.vala @@ -102,20 +102,24 @@ public class StreamModule : XmppStreamModule { } public void untrust_device(Jid jid, int device_id) { - if(device_lists.has_key(jid) && device_lists[jid].contains(device_id)) + if (device_lists.has_key(jid) && device_lists[jid].contains(device_id)) { device_lists[jid].remove(device_id); - if(store.contains_session(new Address(jid.bare_jid.to_string(), device_id))) + } + if (store.contains_session(new Address(jid.bare_jid.to_string(), device_id))) { store.delete_session(new Address(jid.bare_jid.to_string(), device_id)); + } } public void trust_device(Jid jid, int device_id) { - if(is_ignored_device(jid, device_id)){ + if (is_ignored_device(jid, device_id)){ ignored_devices[jid].remove(device_id); } - if(!device_lists.has_key(jid)) + if (!device_lists.has_key(jid)) { device_lists[jid] = new ArrayList(); - if(!device_lists[jid].contains(device_id)) + } + if (!device_lists[jid].contains(device_id)) { device_lists[jid].add(device_id); + } } private StanzaNode create_encrypted_key(uint8[] key, Address address) throws GLib.Error { diff --git a/qlite/src/query_builder.vala b/qlite/src/query_builder.vala index dbfdef2a..75fe0499 100644 --- a/qlite/src/query_builder.vala +++ b/qlite/src/query_builder.vala @@ -98,10 +98,16 @@ public class QueryBuilder : StatementBuilder { } public QueryBuilder limit(int limit) { + if (this.limit_val != 0 && limit > this.limit_val) error("tried to increase an existing limit"); this.limit_val = limit; return this; } + public QueryBuilder single() { + this.single_result = true; + return limit(1); + } + public int64 count() { this.column_selector = @"COUNT($column_selector) AS count"; this.single_result = true; @@ -117,8 +123,8 @@ public class QueryBuilder : StatementBuilder { return new RowOption(row_()); } - public T get(Column field) { - return row()[field]; + public T get(Column field, T def = null) { + return row().get(field, def); } internal override Statement prepare() { -- cgit v1.2.3-70-g09d2