diff --git a/gajim/chat_control.py b/gajim/chat_control.py index 7d3d3ab88..31bed9b40 100644 --- a/gajim/chat_control.py +++ b/gajim/chat_control.py @@ -55,6 +55,7 @@ from nbxmpp.protocol import NS_JINGLE_ICE_UDP, NS_JINGLE_FILE_TRANSFER_5 from nbxmpp.protocol import NS_CHATSTATES from gajim.common.connection_handlers_events import MessageOutgoingEvent from gajim.common.exceptions import GajimGeneralException +from gajim.common.const import AvatarSize from gajim.command_system.implementation.hosts import ChatCommands @@ -197,8 +198,7 @@ class ChatControl(ChatControlBase): self.handlers[id_] = message_tv_buffer widget = self.xml.get_object('avatar_eventbox') - widget.set_property('height-request', app.config.get( - 'chat_avatar_height')) + widget.set_property('height-request', AvatarSize.CHAT) id_ = widget.connect('enter-notify-event', self.on_avatar_eventbox_enter_notify_event) self.handlers[id_] = widget @@ -296,8 +296,10 @@ class ChatControl(ChatControlBase): app.ged.register_event_handler('pep-received', ged.GUI1, self._nec_pep_received) - app.ged.register_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) + if self.TYPE_ID == message_control.TYPE_CHAT: + # Dont connect this when PrivateChatControl is used + app.ged.register_event_handler('update-roster-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.register_event_handler('failed-decrypt', ged.GUI1, self._nec_failed_decrypt) app.ged.register_event_handler('chatstate-received', ged.GUI1, @@ -579,9 +581,8 @@ class ChatControl(ChatControlBase): Enter the eventbox area so we under conditions add a timeout to show a bigger avatar after 0.5 sec """ - jid = self.contact.jid - avatar_pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(jid) - if avatar_pixbuf in ('ask', None): + avatar_pixbuf = app.interface.get_avatar(self.account, self.contact.jid) + if avatar_pixbuf is None: return avatar_w = avatar_pixbuf.get_width() avatar_h = avatar_pixbuf.get_height() @@ -596,7 +597,7 @@ class ChatControl(ChatControlBase): if self.show_bigger_avatar_timeout_id is not None: GLib.source_remove(self.show_bigger_avatar_timeout_id) self.show_bigger_avatar_timeout_id = GLib.timeout_add(500, - self.show_bigger_avatar, widget) + self.show_bigger_avatar, widget, avatar_pixbuf) def on_avatar_eventbox_leave_notify_event(self, widget, event): """ @@ -614,9 +615,15 @@ class ChatControl(ChatControlBase): if event.button == 3: # right click menu = Gtk.Menu() menuitem = Gtk.MenuItem.new_with_mnemonic(_('Save _As')) + if self.TYPE_ID == message_control.TYPE_CHAT: + sha = app.contacts.get_avatar_sha( + self.account, self.contact.jid) + name = self.contact.get_shown_name() + else: + sha = self.gc_contact.avatar_sha + name = self.gc_contact.get_shown_name() id_ = menuitem.connect('activate', - gtkgui_helpers.on_avatar_save_as_menuitem_activate, - self.contact.jid, self.contact.get_shown_name()) + gtkgui_helpers.on_avatar_save_as_menuitem_activate, sha, name) self.handlers[id_] = menuitem menu.append(menuitem) menu.show_all() @@ -1076,10 +1083,8 @@ class ChatControl(ChatControlBase): jid = self.contact.jid if app.config.get('show_avatar_in_tabs'): - avatar_pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(jid) - if avatar_pixbuf not in ('ask', None): - avatar_pixbuf = gtkgui_helpers.get_scaled_pixbuf_by_size( - avatar_pixbuf, 16, 16) + avatar_pixbuf = app.contacts.get_avatar(self.account, jid, size=16) + if avatar_pixbuf is not None: return avatar_pixbuf if count_unread: @@ -1200,8 +1205,9 @@ class ChatControl(ChatControlBase): app.ged.remove_event_handler('pep-received', ged.GUI1, self._nec_pep_received) - app.ged.remove_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) + if self.TYPE_ID == message_control.TYPE_CHAT: + app.ged.remove_event_handler('update-roster-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.remove_event_handler('failed-decrypt', ged.GUI1, self._nec_failed_decrypt) app.ged.remove_event_handler('chatstate-received', ged.GUI1, @@ -1322,37 +1328,15 @@ class ChatControl(ChatControlBase): if not app.config.get('show_avatar_in_chat'): return - jid_with_resource = self.contact.get_full_jid() - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(jid_with_resource) - if pixbuf == 'ask': - # we don't have the vcard - if self.TYPE_ID == message_control.TYPE_PM: - if self.gc_contact.jid: - # We know the real jid of this contact - real_jid = self.gc_contact.jid - if self.gc_contact.resource: - real_jid += '/' + self.gc_contact.resource - else: - real_jid = jid_with_resource - app.connections[self.account].request_vcard(real_jid, - jid_with_resource) - else: - app.connections[self.account].request_vcard(jid_with_resource) - return - elif pixbuf: - scaled_pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'chat') - else: - scaled_pixbuf = None - + pixbuf = app.contacts.get_avatar( + self.account, self.contact.jid, AvatarSize.CHAT) image = self.xml.get_object('avatar_image') - image.set_from_pixbuf(scaled_pixbuf) - image.show_all() + image.set_from_pixbuf(pixbuf) - def _nec_vcard_received(self, obj): - if obj.conn.name != self.account: + def _nec_update_avatar(self, obj): + if obj.account != self.account: return - j = app.get_jid_without_resource(self.contact.jid) - if obj.jid != j: + if obj.jid != self.contact.jid: return self.show_avatar() @@ -1518,28 +1502,14 @@ class ChatControl(ChatControlBase): elif typ == 'pm': control.remove_contact(nick) - def show_bigger_avatar(self, small_avatar): + def show_bigger_avatar(self, small_avatar, avatar_pixbuf): """ Resize the avatar, if needed, so it has at max half the screen size and shows it """ - #if not small_avatar.window: - ### Tab has been closed since we hovered the avatar - #return - avatar_pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache( - self.contact.jid) - if avatar_pixbuf in ('ask', None): - return # Hide the small avatar - # this code hides the small avatar when we show a bigger one in case - # the avatar has a transparency hole in the middle - # so when we show the big one we avoid seeing the small one behind. - # It's why I set it transparent. image = self.xml.get_object('avatar_image') - pixbuf = image.get_pixbuf() - pixbuf.fill(0xffffff00) # RGBA - image.set_from_pixbuf(pixbuf) - #image.queue_draw() + image.hide() screen_w = Gdk.Screen.width() screen_h = Gdk.Screen.height() diff --git a/gajim/common/app.py b/gajim/common/app.py index 1751f452e..3b212ed5d 100644 --- a/gajim/common/app.py +++ b/gajim/common/app.py @@ -34,8 +34,11 @@ import uuid from distutils.version import LooseVersion as V import gi import nbxmpp +import hashlib -from gajim.common import config +from gi.repository import GLib + +from gajim.common import config as c_config from gajim.common import configpaths from gajim.common import ged as ged_module from gajim.common.contacts import LegacyContactsAPI @@ -43,9 +46,10 @@ from gajim.common.events import Events interface = None # The actual interface (the gtk one for the moment) thread_interface = lambda *args: None # Interface to run a thread and then a callback -config = config.Config() +config = c_config.Config() version = config.get('version') connections = {} # 'account name': 'account (connection.Connection) instance' +avatar_cache = {} ipython_window = None app = None # Gtk.Application @@ -260,7 +264,7 @@ gajim_common_features = [nbxmpp.NS_BYTESTREAM, nbxmpp.NS_SI, nbxmpp.NS_FILE, nbxmpp.NS_ROSTERX, nbxmpp.NS_SECLABEL, nbxmpp.NS_HASHES_2, nbxmpp.NS_HASHES_MD5, nbxmpp.NS_HASHES_SHA1, nbxmpp.NS_HASHES_SHA256, nbxmpp.NS_HASHES_SHA512, nbxmpp.NS_CONFERENCE, nbxmpp.NS_CORRECT, - nbxmpp.NS_EME] + nbxmpp.NS_EME, 'urn:xmpp:avatar:metadata+notify'] # Optional features gajim supports per account gajim_optional_features = {} diff --git a/gajim/common/config.py b/gajim/common/config.py index 40f0e2a3d..4da11fe47 100644 --- a/gajim/common/config.py +++ b/gajim/common/config.py @@ -220,12 +220,6 @@ class Config: 'tabs_border': [opt_bool, False, _('Show tabbed notebook border in chat windows?')], 'tabs_close_button': [opt_bool, True, _('Show close button in tab?')], 'esession_modp': [opt_str, '15,16,14', _('A list of modp groups to use in a Diffie-Hellman, highest preference first, separated by commas. Valid groups are 1, 2, 5, 14, 15, 16, 17 and 18. Higher numbers are more secure, but take longer to calculate when you start a session.')], - 'chat_avatar_width': [opt_int, 52], - 'chat_avatar_height': [opt_int, 52], - 'roster_avatar_width': [opt_int, 32], - 'roster_avatar_height': [opt_int, 32], - 'tooltip_avatar_width': [opt_int, 125], - 'tooltip_avatar_height': [opt_int, 125], 'tooltip_status_online_color': [opt_color, '#73D216'], 'tooltip_status_free_for_chat_color': [opt_color, '#3465A4'], 'tooltip_status_away_color': [opt_color, '#EDD400'], @@ -238,13 +232,9 @@ class Config: 'tooltip_affiliation_owner_color': [opt_color, '#CC0000'], 'tooltip_account_name_color': [opt_color, '#888A85'], 'tooltip_idle_color': [opt_color, '#888A85'], - 'vcard_avatar_width': [opt_int, 200], - 'vcard_avatar_height': [opt_int, 200], 'notification_preview_message': [opt_bool, True, _('Preview new messages in notification popup?')], 'notification_position_x': [opt_int, -1], 'notification_position_y': [opt_int, -1], - 'notification_avatar_width': [opt_int, 48], - 'notification_avatar_height': [opt_int, 48], 'muc_highlight_words': [opt_str, '', _('A semicolon-separated list of words that will be highlighted in group chats.')], 'quit_on_roster_x_button': [opt_bool, False, _('If True, quits Gajim when X button of Window Manager is clicked. This setting is taken into account only if notification icon is used.')], 'show_unread_tab_icon': [opt_bool, False, _('If True, Gajim will display an icon on each tab containing unread messages. Depending on the theme, this icon may be animated.')], @@ -255,7 +245,6 @@ class Config: 'show_tunes_in_roster': [opt_bool, True, '', True], 'show_location_in_roster': [opt_bool, True, '', True], 'avatar_position_in_roster': [opt_str, 'right', _('Define the position of the avatar in roster. Can be left or right'), True], - 'ask_avatars_on_startup': [opt_bool, True, _('If True, Gajim will ask for avatar each contact that did not have an avatar last time or has one cached that is too old.')], 'print_status_in_chats': [opt_bool, False, _('If False, Gajim will no longer print status line in chats when a contact changes his or her status and/or his or her status message.')], 'print_status_in_muc': [opt_str, 'none', _('Can be "none", "all" or "in_and_out". If "none", Gajim will no longer print status line in groupchats when a member changes his or her status and/or his or her status message. If "all" Gajim will print all status messages. If "in_and_out", Gajim will only print FOO enters/leaves group chat.')], 'log_contact_status_changes': [opt_bool, False], @@ -325,6 +314,7 @@ class Config: 'account_label': [ opt_str, '', '', False ], 'hostname': [ opt_str, '', '', True ], 'anonymous_auth': [ opt_bool, False ], + 'avatar_sha': [opt_str, '', '', False], 'client_cert': [ opt_str, '', '', True ], 'client_cert_encrypted': [ opt_bool, False, '', False ], 'savepass': [ opt_bool, False ], diff --git a/gajim/common/connection.py b/gajim/common/connection.py index 67cd0c8fc..4c8d4b460 100644 --- a/gajim/common/connection.py +++ b/gajim/common/connection.py @@ -779,6 +779,7 @@ class Connection(CommonConnection, ConnectionHandlers): self.connected = 0 self.time_to_reconnect = None self.privacy_rules_supported = False + self.avatar_presence_sent = False if on_purpose: self.sm = Smacks(self) if self.connection: @@ -1778,7 +1779,7 @@ class Connection(CommonConnection, ConnectionHandlers): show='invisible')) if initial: # ask our VCard - self.request_vcard(None) + self.request_vcard(self._on_own_avatar_received) # Get bookmarks from private namespace self.get_bookmarks() diff --git a/gajim/common/connection_handlers.py b/gajim/common/connection_handlers.py index 09ae05b87..d0dec13d9 100644 --- a/gajim/common/connection_handlers.py +++ b/gajim/common/connection_handlers.py @@ -44,10 +44,8 @@ from gajim.common import caps_cache as capscache from gajim.common.pep import LOCATION_DATA from gajim.common import helpers from gajim.common import app -from gajim.common import exceptions from gajim.common import dataforms from gajim.common import jingle_xtls -from gajim.common import sleepy from gajim.common.commands import ConnectionCommands from gajim.common.pubsub import ConnectionPubSub from gajim.common.protocol.caps import ConnectionCaps @@ -66,8 +64,6 @@ import logging log = logging.getLogger('gajim.c.connection_handlers') # kind of events we can wait for an answer -VCARD_PUBLISHED = 'vcard_published' -VCARD_ARRIVED = 'vcard_arrived' AGENT_REMOVED = 'agent_removed' METACONTACTS_ARRIVED = 'metacontacts_arrived' ROSTER_ARRIVED = 'roster_arrived' @@ -262,15 +258,96 @@ class ConnectionDisco: class ConnectionVcard: def __init__(self): - self.vcard_sha = None - self.vcard_shas = {} # sha of contacts - # list of gc jids so that vcard are saved in a folder + self.own_vcard = None self.room_jids = [] + self.avatar_presence_sent = False + + app.ged.register_event_handler('presence-received', ged.GUI2, + self._vcard_presence_received) + app.ged.register_event_handler('gc-presence-received', ged.GUI2, + self._vcard_gc_presence_received) + + def _vcard_presence_received(self, obj): + if obj.avatar_sha is None: + # No Avatar is advertised + return + + if self.get_own_jid().bareMatch(obj.jid): + app.log('avatar').info('Update (vCard): %s %s', + obj.jid, obj.avatar_sha) + current_sha = app.config.get_per( + 'accounts', self.name, 'avatar_sha') + if obj.avatar_sha != current_sha: + app.log('avatar').info( + 'Request (vCard): %s', obj.jid) + self.request_vcard(self._on_own_avatar_received) + return + + if obj.avatar_sha == '': + # Empty tag, means no avatar is advertised + app.log('avatar').info( + '%s has no avatar published (vCard)', obj.jid) + + # Remove avatar + app.log('avatar').info('Remove: %s', obj.jid) + app.contacts.set_avatar(self.name, obj.jid, None) + app.logger.set_avatar_sha(self.name, obj.jid, None) + app.interface.update_avatar(self.name, obj.jid) + else: + app.log('avatar').info( + 'Update (vCard): %s %s', obj.jid, obj.avatar_sha) + current_sha = app.contacts.get_avatar_sha(self.name, obj.jid) + if obj.avatar_sha != current_sha: + app.log('avatar').info( + 'Request (vCard): %s', obj.jid) + self.request_vcard(self._on_avatar_received, obj.jid) + + def _vcard_gc_presence_received(self, obj): + if obj.conn.name != self.name: + return + + server = app.get_server_from_jid(obj.room_jid) + if server.startswith('irc') or obj.avatar_sha is None: + return + + gc_contact = app.contacts.get_gc_contact( + self.name, obj.room_jid, obj.nick) + + if gc_contact is None: + app.log('avatar').error('no gc contact found: %s', obj.nick) + return + + if obj.avatar_sha == '': + # Empty tag, means no avatar is advertised, remove avatar + app.log('avatar').info( + '%s has no avatar published (vCard)', obj.nick) + app.log('avatar').info('Remove: %s', obj.nick) + gc_contact.avatar_sha = None + app.interface.update_avatar(contact=gc_contact) + else: + app.log('avatar').info( + 'Update (vCard): %s %s', obj.nick, obj.avatar_sha) + path = os.path.join(app.AVATAR_PATH, obj.avatar_sha) + if not os.path.isfile(path): + app.log('avatar').info( + 'Request (vCard): %s', obj.nick) + obj.conn.request_vcard( + self._on_avatar_received, obj.fjid, room=True) + return + + if gc_contact.avatar_sha != obj.avatar_sha: + app.log('avatar').info( + '%s changed his Avatar (vCard): %s', + obj.nick, obj.avatar_sha) + gc_contact.avatar_sha = obj.avatar_sha + app.interface.update_avatar(contact=gc_contact) def add_sha(self, p, send_caps=True): c = p.setTag('x', namespace=nbxmpp.NS_VCARD_UPDATE) - if self.vcard_sha is not None: - c.setTagData('photo', self.vcard_sha) + sha = app.config.get_per('accounts', self.name, 'avatar_sha') + app.log('avatar').info( + '%s: Send avatar presence: %s', self.name, sha or 'empty') + c.setTagData('photo', sha) if send_caps: return self._add_caps(p) return p @@ -283,6 +360,14 @@ class ConnectionVcard: c.setAttr('ver', app.caps_hash[self.name]) return p + def send_avatar_presence(self): + show = helpers.get_xmpp_show(app.SHOW_LIST[self.connected]) + p = nbxmpp.Presence(typ=None, priority=self.priority, + show=show, status=self.status) + p = self.add_sha(p) + self.connection.send(p) + app.interface.update_avatar(self.name, self.get_own_jid().getStripped()) + def _node_to_dict(self, node): dict_ = {} for info in node.getChildren(): @@ -301,99 +386,27 @@ class ConnectionVcard: dict_[name][c.getName()] = c.getData() return dict_ - def _save_vcard_to_hd(self, full_jid, card): - jid, nick = app.get_room_and_nick_from_fjid(full_jid) - puny_jid = helpers.sanitize_filename(jid) - path = os.path.join(app.VCARD_PATH, puny_jid) - if jid in self.room_jids or os.path.isdir(path): - if not nick: - return - # remove room_jid file if needed - if os.path.isfile(path): - os.remove(path) - # create folder if needed - if not os.path.isdir(path): - os.mkdir(path, 0o700) - puny_nick = helpers.sanitize_filename(nick) - path_to_file = os.path.join(app.VCARD_PATH, puny_jid, puny_nick) - else: - path_to_file = path - try: - fil = open(path_to_file, 'w', encoding='utf-8') - fil.write(str(card)) - fil.close() - except IOError as e: - app.nec.push_incoming_event(InformationEvent(None, conn=self, - level='error', pri_txt=_('Disk Write Error'), sec_txt=str(e))) - - def get_cached_vcard(self, fjid, is_fake_jid=False): - """ - Return the vcard as a dict. - Return {} if vcard was too old. - Return None if we don't have cached vcard. - """ - jid, nick = app.get_room_and_nick_from_fjid(fjid) - puny_jid = helpers.sanitize_filename(jid) - if is_fake_jid: - puny_nick = helpers.sanitize_filename(nick) - path_to_file = os.path.join(app.VCARD_PATH, puny_jid, puny_nick) - else: - path_to_file = os.path.join(app.VCARD_PATH, puny_jid) - if not os.path.isfile(path_to_file): - return None - # We have the vcard cached - f = open(path_to_file, encoding='utf-8') - c = f.read() - f.close() - try: - card = nbxmpp.Node(node=c) - except Exception: - # We are unable to parse it. Remove it - os.remove(path_to_file) - return None - vcard = self._node_to_dict(card) - if 'PHOTO' in vcard: - if not isinstance(vcard['PHOTO'], dict): - del vcard['PHOTO'] - elif 'SHA' in vcard['PHOTO']: - cached_sha = vcard['PHOTO']['SHA'] - if jid in self.vcard_shas and self.vcard_shas[jid] != \ - cached_sha: - # user change his vcard so don't use the cached one - return {} - vcard['jid'] = jid - vcard['resource'] = app.get_resource_from_jid(fjid) - return vcard - - def request_vcard(self, jid=None, groupchat_jid=None): + def request_vcard(self, callback, jid=None, room=False): """ Request the VCARD - - If groupchat_jid is not null, it means we request a vcard to a fake jid, - like in private messages in groupchat. jid can be the real jid of the - contact, but we want to consider it comes from a fake jid """ if not self.connection or self.connected < 2: return + + if room: + room_jid = app.get_room_from_fjid(jid) + if room_jid not in self.room_jids: + self.room_jids.append(room_jid) + iq = nbxmpp.Iq(typ='get') if jid: iq.setTo(jid) iq.setQuery('vCard').setNamespace(nbxmpp.NS_VCARD) - id_ = self.connection.getAnID() - iq.setID(id_) - j = jid - if not j: - j = app.get_jid_from_account(self.name) - self.awaiting_answers[id_] = (VCARD_ARRIVED, j, groupchat_jid) - if groupchat_jid: - room_jid = app.get_room_and_nick_from_fjid(groupchat_jid)[0] - if not room_jid in self.room_jids: - self.room_jids.append(room_jid) - self.groupchat_jids[id_] = groupchat_jid - self.connection.send(iq) + self.connection.SendAndCallForResponse( + iq, self._parse_vcard, {'callback': callback}) - def send_vcard(self, vcard): + def send_vcard(self, vcard, sha): if not self.connection or self.connected < 2: return iq = nbxmpp.Iq(typ='set') @@ -413,23 +426,29 @@ class ConnectionVcard: else: iq2.addChild(i).setData(vcard[i]) - id_ = self.connection.getAnID() - iq.setID(id_) - self.connection.send(iq) + self.connection.SendAndCallForResponse( + iq, self._avatar_publish_result, {'sha': sha}) - our_jid = app.get_jid_from_account(self.name) - # Add the sha of the avatar - if 'PHOTO' in vcard and isinstance(vcard['PHOTO'], dict) and \ - 'BINVAL' in vcard['PHOTO']: - photo = vcard['PHOTO']['BINVAL'] - photo_decoded = base64.b64decode(photo.encode('utf-8')) - app.interface.save_avatar_files(our_jid, photo_decoded) - avatar_sha = hashlib.sha1(photo_decoded).hexdigest() - iq2.getTag('PHOTO').setTagData('SHA', avatar_sha) - else: - app.interface.remove_avatar_files(our_jid) + def _avatar_publish_result(self, con, stanza, sha): + if stanza.getType() == 'result': + current_sha = app.config.get_per( + 'accounts', self.name, 'avatar_sha') + if (current_sha != sha and + app.SHOW_LIST[self.connected] != 'invisible'): + if not self.connection or self.connected < 2: + return + app.config.set_per( + 'accounts', self.name, 'avatar_sha', sha or '') + own_jid = self.get_own_jid().getStripped() + app.contacts.set_avatar(self.name, own_jid, sha) + self.send_avatar_presence() + app.log('avatar').info('%s: Published: %s', self.name, sha) + app.nec.push_incoming_event( + VcardPublishedEvent(None, conn=self)) - self.awaiting_answers[id_] = (VCARD_PUBLISHED, iq2) + elif stanza.getType() == 'error': + app.nec.push_incoming_event( + VcardNotPublishedEvent(None, conn=self)) def _IqCB(self, con, iq_obj): id_ = iq_obj.getID() @@ -449,63 +468,7 @@ class ConnectionVcard: if id_ not in self.awaiting_answers: return - if self.awaiting_answers[id_][0] == VCARD_PUBLISHED: - if iq_obj.getType() == 'result': - vcard_iq = self.awaiting_answers[id_][1] - # Save vcard to HD - if vcard_iq.getTag('PHOTO') and vcard_iq.getTag('PHOTO').getTag( - 'SHA'): - new_sha = vcard_iq.getTag('PHOTO').getTagData('SHA') - else: - new_sha = '' - - # Save it to file - our_jid = app.get_jid_from_account(self.name) - self._save_vcard_to_hd(our_jid, vcard_iq) - - # Send new presence if sha changed and we are not invisible - if self.vcard_sha != new_sha and app.SHOW_LIST[ - self.connected] != 'invisible': - if not self.connection or self.connected < 2: - del self.awaiting_answers[id_] - return - self.vcard_sha = new_sha - sshow = helpers.get_xmpp_show(app.SHOW_LIST[ - self.connected]) - p = nbxmpp.Presence(typ=None, priority=self.priority, - show=sshow, status=self.status) - p = self.add_sha(p) - self.connection.send(p) - app.nec.push_incoming_event(VcardPublishedEvent(None, - conn=self)) - elif iq_obj.getType() == 'error': - app.nec.push_incoming_event(VcardNotPublishedEvent(None, - conn=self)) - del self.awaiting_answers[id_] - elif self.awaiting_answers[id_][0] == VCARD_ARRIVED: - # If vcard is empty, we send to the interface an empty vcard so that - # it knows it arrived - jid = self.awaiting_answers[id_][1] - groupchat_jid = self.awaiting_answers[id_][2] - frm = jid - if groupchat_jid: - # We do as if it comes from the fake_jid - frm = groupchat_jid - our_jid = app.get_jid_from_account(self.name) - if (not iq_obj.getTag('vCard') and iq_obj.getType() == 'result') or\ - iq_obj.getType() == 'error': - if id_ in self.groupchat_jids: - frm = self.groupchat_jids[id_] - del self.groupchat_jids[id_] - if frm: - # Write an empty file - self._save_vcard_to_hd(frm, '') - jid, resource = app.get_room_and_nick_from_fjid(frm) - vcard = {'jid': jid, 'resource': resource} - app.nec.push_incoming_event(VcardReceivedEvent(None, - conn=self, vcard_dict=vcard)) - del self.awaiting_answers[id_] - elif self.awaiting_answers[id_][0] == AGENT_REMOVED: + if self.awaiting_answers[id_][0] == AGENT_REMOVED: jid = self.awaiting_answers[id_][1] app.nec.push_incoming_event(AgentRemovedEvent(None, conn=self, agent=jid)) @@ -598,97 +561,104 @@ class ConnectionVcard: app.nec.push_incoming_event(PEPConfigReceivedEvent(None, conn=self, node=node, form=form)) - def _vCardCB(self, con, vc): - """ - Called when we receive a vCard Parse the vCard and send it to plugins - """ - if not vc.getTag('vCard'): - return - if not vc.getTag('vCard').getNamespace() == nbxmpp.NS_VCARD: - return - id_ = vc.getID() - frm_iq = vc.getFrom() - our_jid = app.get_jid_from_account(self.name) - resource = '' - if id_ in self.groupchat_jids: - who = self.groupchat_jids[id_] - frm, resource = app.get_room_and_nick_from_fjid(who) - del self.groupchat_jids[id_] - elif frm_iq: - who = helpers.get_full_jid_from_iq(vc) - frm, resource = app.get_room_and_nick_from_fjid(who) - else: - who = frm = our_jid - card = vc.getChildren()[0] - vcard = self._node_to_dict(card) - photo_decoded = None - if 'PHOTO' in vcard and isinstance(vcard['PHOTO'], dict) and \ - 'BINVAL' in vcard['PHOTO']: + def get_vcard_photo(self, vcard): + try: photo = vcard['PHOTO']['BINVAL'] - try: + except (KeyError, AttributeError): + avatar_sha = None + photo_decoded = None + else: + if photo == '': + avatar_sha = None + photo_decoded = None + else: photo_decoded = base64.b64decode(photo.encode('utf-8')) avatar_sha = hashlib.sha1(photo_decoded).hexdigest() - except Exception: - avatar_sha = '' + + return avatar_sha, photo_decoded + + def _parse_vcard(self, con, stanza, callback): + frm_jid = stanza.getFrom() + room = False + if frm_jid is None: + frm_jid = self.get_own_jid() + elif frm_jid.getStripped() in self.room_jids: + room = True + + resource = frm_jid.getResource() + jid = frm_jid.getStripped() + + vcard = self._node_to_dict(stanza.getChildren()[0]) + # handle no vcard set + + if self.get_own_jid().bareMatch(jid): + if 'NICKNAME' in vcard: + app.nicks[self.name] = vcard['NICKNAME'] + elif 'FN' in vcard: + app.nicks[self.name] = vcard['FN'] + + app.nec.push_incoming_event( + VcardReceivedEvent(None, vcard_dict=vcard)) + + callback(jid, resource, room, vcard) + + def _on_own_avatar_received(self, jid, resource, room, vcard): + + avatar_sha, photo_decoded = self.get_vcard_photo(vcard) + + app.log('avatar').info( + 'Received own (vCard): %s', avatar_sha) + + self.own_vcard = vcard + if avatar_sha is None: + app.log('avatar').info('No avatar found (vCard)') + app.config.set_per('accounts', self.name, 'avatar_sha', '') + self.send_avatar_presence() + return + + current_sha = app.config.get_per('accounts', self.name, 'avatar_sha') + if current_sha == avatar_sha: + path = os.path.join(app.AVATAR_PATH, current_sha) + if not os.path.isfile(path): + app.log('avatar').info( + 'Caching (vCard): %s', current_sha) + app.interface.save_avatar(photo_decoded) + if self.avatar_presence_sent: + app.log('avatar').debug('Avatar already advertised') + return else: - avatar_sha = '' + app.interface.save_avatar(photo_decoded) - if avatar_sha: - card.getTag('PHOTO').setTagData('SHA', avatar_sha) + app.config.set_per('accounts', self.name, 'avatar_sha', avatar_sha) + if app.SHOW_LIST[self.connected] == 'invisible': + app.log('avatar').info( + 'We are invisible, not publishing avatar') + return - # Save it to file - self._save_vcard_to_hd(who, card) - # Save the decoded avatar to a separate file too, and generate files - # for dbus notifications - puny_jid = helpers.sanitize_filename(frm) - puny_nick = None - begin_path = os.path.join(app.AVATAR_PATH, puny_jid) - frm_jid = frm - if frm in self.room_jids: - puny_nick = helpers.sanitize_filename(resource) - # create folder if needed - if not os.path.isdir(begin_path): - os.mkdir(begin_path, 0o700) - begin_path = os.path.join(begin_path, puny_nick) - frm_jid += '/' + resource - if photo_decoded: - avatar_file = begin_path + '_notif_size_colored.png' - if frm_jid == our_jid and avatar_sha != self.vcard_sha: - app.interface.save_avatar_files(frm, photo_decoded, puny_nick) - elif frm_jid != our_jid and (not os.path.exists(avatar_file) or \ - frm_jid not in self.vcard_shas or \ - avatar_sha != self.vcard_shas[frm_jid]): - app.interface.save_avatar_files(frm, photo_decoded, puny_nick) - if avatar_sha: - self.vcard_shas[frm_jid] = avatar_sha - elif frm in self.vcard_shas: - del self.vcard_shas[frm] + self.send_avatar_presence() + self.avatar_presence_sent = True + + def _on_avatar_received(self, jid, resource, room, vcard): + """ + Called when we receive a vCard Parse the vCard and trigger Events + """ + avatar_sha, photo_decoded = self.get_vcard_photo(vcard) + app.interface.save_avatar(photo_decoded) + + # Received vCard from a contact + if room: + app.log('avatar').info( + 'Received (vCard): %s %s', resource, avatar_sha) + contact = app.contacts.get_gc_contact(self.name, jid, resource) + if contact is not None: + contact.avatar_sha = avatar_sha + app.interface.update_avatar(contact=contact) else: - for ext in ('.jpeg', '.png', '_notif_size_bw.png', - '_notif_size_colored.png'): - path = begin_path + ext - if os.path.isfile(path): - os.remove(path) - - vcard['jid'] = frm - vcard['resource'] = resource - app.nec.push_incoming_event(VcardReceivedEvent(None, conn=self, - vcard_dict=vcard)) - if frm_jid == our_jid: - # we re-send our presence with sha if has changed and if we are - # not invisible - if self.vcard_sha == avatar_sha: - return - self.vcard_sha = avatar_sha - if app.SHOW_LIST[self.connected] == 'invisible': - return - if not self.connection: - return - sshow = helpers.get_xmpp_show(app.SHOW_LIST[self.connected]) - p = nbxmpp.Presence(typ=None, priority=self.priority, - show=sshow, status=self.status) - p = self.add_sha(p) - self.connection.send(p) + app.log('avatar').info('Received (vCard): %s %s', jid, avatar_sha) + own_jid = self.get_own_jid().getStripped() + app.logger.set_avatar_sha(own_jid, jid, avatar_sha) + app.contacts.set_avatar(self.name, jid, avatar_sha) + app.interface.update_avatar(self.name, jid) class ConnectionPEP(object): @@ -940,18 +910,6 @@ class ConnectionHandlersBase: obj.contact = c break - if obj.avatar_sha is not None and obj.ptype != 'error': - if obj.jid not in self.vcard_shas: - cached_vcard = self.get_cached_vcard(obj.jid) - if cached_vcard and 'PHOTO' in cached_vcard and \ - 'SHA' in cached_vcard['PHOTO']: - self.vcard_shas[obj.jid] = cached_vcard['PHOTO']['SHA'] - else: - self.vcard_shas[obj.jid] = '' - if obj.avatar_sha != self.vcard_shas[obj.jid]: - # avatar has been updated - self.request_vcard(obj.jid) - if obj.contact: if obj.contact.show in statuss: obj.old_show = statuss.index(obj.contact.show) @@ -2007,6 +1965,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): if obj.conn.name != self.name: return our_jid = app.get_jid_from_account(self.name) + if self.connected > 1 and self.continue_connect_info: msg = self.continue_connect_info[1] sign_msg = self.continue_connect_info[2] @@ -2043,7 +2002,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): app.nec.push_incoming_event(RosterInfoEvent(None, conn=self, jid=jid, nickname=info['name'], sub=info['subscription'], ask=info['ask'], - groups=info['groups'])) + groups=info['groups'], avatar_sha=info['avatar_sha'])) def _send_first_presence(self, signed=''): show = self.continue_connect_info[0] @@ -2065,12 +2024,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): if show not in ['offline', 'online', 'chat', 'away', 'xa', 'dnd']: return priority = app.get_priority(self.name, sshow) - our_jid = helpers.parse_jid(app.get_jid_from_account(self.name)) - vcard = self.get_cached_vcard(our_jid) - if vcard and 'PHOTO' in vcard and 'SHA' in vcard['PHOTO']: - self.vcard_sha = vcard['PHOTO']['SHA'] p = nbxmpp.Presence(typ=None, priority=priority, show=sshow) - p = self.add_sha(p) if msg: p.setStatus(msg) if signed: @@ -2083,7 +2037,7 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): show=show)) if self.vcard_supported: # ask our VCard - self.request_vcard(None) + self.request_vcard(self._on_own_avatar_received) # Get bookmarks from private namespace self.get_bookmarks() @@ -2169,7 +2123,6 @@ ConnectionHandlersBase, ConnectionJingle, ConnectionIBBytestream): # We also don't check for namespace, else it cannot stop _messageCB to # be called con.RegisterHandler('message', self._pubsubEventCB, makefirst=True) - con.RegisterHandler('iq', self._vCardCB, 'result', nbxmpp.NS_VCARD) con.RegisterHandler('iq', self._rosterSetCB, 'set', nbxmpp.NS_ROSTER) con.RegisterHandler('iq', self._siSetCB, 'set', nbxmpp.NS_SI) con.RegisterHandler('iq', self._rosterItemExchangeCB, 'set', diff --git a/gajim/common/connection_handlers_events.py b/gajim/common/connection_handlers_events.py index 09746fa87..4147cd2a1 100644 --- a/gajim/common/connection_handlers_events.py +++ b/gajim/common/connection_handlers_events.py @@ -24,6 +24,7 @@ from calendar import timegm import datetime import hashlib +import base64 import hmac import logging import sys @@ -324,6 +325,7 @@ class RosterReceivedEvent(nec.NetworkIncomingEvent): self.conn.connection.getRoster().delItem(jid) elif jid != our_jid: # don't add our jid self.roster[j] = raw_roster[jid] + self.roster[j]['avatar_sha'] = None else: # Roster comes from DB self.received_from_server = False @@ -376,6 +378,9 @@ class RosterInfoEvent(nec.NetworkIncomingEvent): name = 'roster-info' base_network_events = [] + def init(self): + self.avatar_sha = None + class MucOwnerReceivedEvent(nec.NetworkIncomingEvent, HelperEvent): name = 'muc-owner-received' base_network_events = [] @@ -532,19 +537,13 @@ class PubsubReceivedEvent(nec.NetworkIncomingEvent): base_network_events = [] def generate(self): + self.jid = self.stanza.getFrom() self.pubsub_node = self.stanza.getTag('pubsub') if not self.pubsub_node: return self.items_node = self.pubsub_node.getTag('items') if not self.items_node: return - self.item_node = self.items_node.getTag('item') - if not self.item_node: - return - children = self.item_node.getChildren() - if not children: - return - self.node = children[0] return True class PubsubBookmarksReceivedEvent(nec.NetworkIncomingEvent, BookmarksHelper): @@ -553,13 +552,51 @@ class PubsubBookmarksReceivedEvent(nec.NetworkIncomingEvent, BookmarksHelper): def generate(self): self.conn = self.base_event.conn - self.storage_node = self.base_event.node + self.item_node = self.base_event.items_node.getTag('item') + if not self.item_node: + return + children = self.item_node.getChildren() + if not children: + return + self.storage_node = children[0] ns = self.storage_node.getNamespace() if ns != nbxmpp.NS_BOOKMARKS: return self.parse_bookmarks() return True +class PubsubAvatarReceivedEvent(nec.NetworkIncomingEvent): + name = 'pubsub-avatar-received' + base_network_events = ['pubsub-received'] + + def __init__(self, name, base_event): + ''' + Pre-Generated attributes on self: + + :conn: Connection instance + :jid: The from jid + :pubsub_node: The 'pubsub' node + :items_node: The 'items' node + ''' + self._set_base_event_vars_as_attributes(base_event) + + def generate(self): + if self.items_node.getAttr('node') != 'urn:xmpp:avatar:data': + return + item = self.items_node.getTag('item') + self.sha = item.getAttr('id') + data_tag = item.getTag('data', namespace='urn:xmpp:avatar:data') + if self.sha is None or data_tag is None: + log.warning('Received malformed avatar data via pubsub') + return + self.data = data_tag.getData() + if self.data is None: + log.warning('Received malformed avatar data via pubsub') + return + self.data = base64.b64decode(self.data.encode('utf-8')) + + return True + class SearchFormReceivedEvent(nec.NetworkIncomingEvent, HelperEvent): name = 'search-form-received' base_network_events = [] @@ -874,10 +911,8 @@ class GcPresenceReceivedEvent(nec.NetworkIncomingEvent, HelperEvent): contact_name=fjid.getResource(), message=st, show=show) - if self.avatar_sha == '': - # contact has no avatar - puny_nick = helpers.sanitize_filename(self.nick) - app.interface.remove_avatar_files(self.room_jid, puny_nick) + + # NOTE: if it's a gc presence, don't ask vcard here. # We may ask it to real jid in gui part. self.status_code = [] @@ -2004,16 +2039,20 @@ class VcardReceivedEvent(nec.NetworkIncomingEvent): base_network_events = [] def generate(self): - self.nickname = None - if 'NICKNAME' in self.vcard_dict: - self.nickname = self.vcard_dict['NICKNAME'] - elif 'FN' in self.vcard_dict: - self.nickname = self.vcard_dict['FN'] - self.jid = self.vcard_dict['jid'] - self.resource = self.vcard_dict['resource'] - self.fjid = self.jid - if self.resource: - self.fjid += '/' + self.resource + return True + +class UpdateGCAvatarEvent(nec.NetworkIncomingEvent): + name = 'update-gc-avatar' + base_network_events = [] + + def generate(self): + return True + +class UpdateRosterAvatarEvent(nec.NetworkIncomingEvent): + name = 'update-roster-avatar' + base_network_events = [] + + def generate(self): return True class PEPConfigReceivedEvent(nec.NetworkIncomingEvent): diff --git a/gajim/common/const.py b/gajim/common/const.py index 8985ced5c..3ab2706b5 100644 --- a/gajim/common/const.py +++ b/gajim/common/const.py @@ -28,6 +28,14 @@ class OptionType(IntEnum): ACTION = 3 DIALOG = 4 +class AvatarSize(IntEnum): + ROSTER = 32 + NOTIFICATION = 48 + CHAT = 52 + PROFILE = 64 + TOOLTIP = 125 + VCARD = 200 + THANKS = u"""\ Alexander Futász diff --git a/gajim/common/contacts.py b/gajim/common/contacts.py index e4a84315b..3c18892c4 100644 --- a/gajim/common/contacts.py +++ b/gajim/common/contacts.py @@ -94,7 +94,7 @@ class Contact(CommonContact): """ def __init__(self, jid, account, name='', groups=None, show='', status='', sub='', ask='', resource='', priority=0, keyID='', client_caps=None, - our_chatstate=None, chatstate=None, idle_time=None): + our_chatstate=None, chatstate=None, idle_time=None, avatar_sha=None): if not isinstance(jid, str): print('no str') if groups is None: @@ -105,6 +105,7 @@ class Contact(CommonContact): self.contact_name = '' # nick choosen by contact self.groups = [i if i else _('General') for i in set(groups)] # filter duplicate values + self.avatar_sha = avatar_sha self.sub = sub self.ask = ask @@ -182,7 +183,7 @@ class GC_Contact(CommonContact): def __init__(self, room_jid, account, name='', show='', status='', role='', affiliation='', jid='', resource='', our_chatstate=None, - chatstate=None): + chatstate=None, avatar_sha=None): CommonContact.__init__(self, jid, account, resource, show, status, name, our_chatstate, chatstate) @@ -190,6 +191,7 @@ class GC_Contact(CommonContact): self.room_jid = room_jid self.role = role self.affiliation = affiliation + self.avatar_sha = avatar_sha def get_full_jid(self): return self.room_jid + '/' + self.name @@ -197,13 +199,16 @@ class GC_Contact(CommonContact): def get_shown_name(self): return self.name + def get_avatar(self, size=None): + return common.app.interface.get_avatar(self.avatar_sha, size) + def as_contact(self): """ Create a Contact instance from this GC_Contact instance """ return Contact(jid=self.get_full_jid(), account=self.account, name=self.name, groups=[], show=self.show, status=self.status, - sub='none', client_caps=self.client_caps) + sub='none', client_caps=self.client_caps, avatar_sha=self.avatar_sha) class LegacyContactsAPI: @@ -245,7 +250,8 @@ class LegacyContactsAPI: def create_contact(self, jid, account, name='', groups=None, show='', status='', sub='', ask='', resource='', priority=0, keyID='', - client_caps=None, our_chatstate=None, chatstate=None, idle_time=None): + client_caps=None, our_chatstate=None, chatstate=None, idle_time=None, + avatar_sha=None): if groups is None: groups = [] # Use Account object if available @@ -254,7 +260,7 @@ class LegacyContactsAPI: show=show, status=status, sub=sub, ask=ask, resource=resource, priority=priority, keyID=keyID, client_caps=client_caps, our_chatstate=our_chatstate, chatstate=chatstate, - idle_time=idle_time) + idle_time=idle_time, avatar_sha=avatar_sha) def create_self_contact(self, jid, account, resource, show, status, priority, name='', keyID=''): @@ -283,7 +289,7 @@ class LegacyContactsAPI: resource=contact.resource, priority=contact.priority, keyID=contact.keyID, client_caps=contact.client_caps, our_chatstate=contact.our_chatstate, chatstate=contact.chatstate, - idle_time=contact.idle_time) + idle_time=contact.idle_time, avatar_sha=contact.avatar_sha) def add_contact(self, account, contact): if account not in self._accounts: @@ -306,6 +312,15 @@ class LegacyContactsAPI: def get_contact(self, account, jid, resource=None): return self._accounts[account].contacts.get_contact(jid, resource=resource) + def get_avatar(self, account, jid, size=None): + return self._accounts[account].contacts.get_avatar(jid, size) + + def get_avatar_sha(self, account, jid): + return self._accounts[account].contacts.get_avatar_sha(jid) + + def set_avatar(self, account, jid, sha): + self._accounts[account].contacts.set_avatar(jid, sha) + def iter_contacts(self, account): for contact in self._accounts[account].contacts.iter_contacts(): yield contact @@ -395,10 +410,10 @@ class LegacyContactsAPI: raise AttributeError(attr_name) def create_gc_contact(self, room_jid, account, name='', show='', status='', - role='', affiliation='', jid='', resource=''): + role='', affiliation='', jid='', resource='', avatar_sha=None): account = self._accounts.get(account, account) # Use Account object if available return GC_Contact(room_jid, account, name, show, status, role, affiliation, jid, - resource) + resource, avatar_sha=avatar_sha) def add_gc_contact(self, account, gc_contact): return self._accounts[account].gc_contacts.add_gc_contact(gc_contact) @@ -424,6 +439,12 @@ class LegacyContactsAPI: def get_nb_role_total_gc_contacts(self, account, room_jid, role): return self._accounts[account].gc_contacts.get_nb_role_total_gc_contacts(room_jid, role) + def set_gc_avatar(self, account, room_jid, nick, sha): + contact = self.get_gc_contact(account, room_jid, nick) + if contact is None: + return + contact.avatar_sha = sha + class Contacts(): """ @@ -490,6 +511,33 @@ class Contacts(): return c return self._contacts[jid][0] + def get_avatar(self, jid, size=None): + if jid not in self._contacts: + return None + + for resource in self._contacts[jid]: + if resource.avatar_sha is None: + continue + avatar = common.app.interface.get_avatar(resource.avatar_sha, size) + if avatar is None: + self.set_avatar(jid, None) + return avatar + + def get_avatar_sha(self, jid): + if jid not in self._contacts: + return None + + for resource in self._contacts[jid]: + if resource.avatar_sha is not None: + return resource.avatar_sha + return None + + def set_avatar(self, jid, sha): + if jid not in self._contacts: + return + for resource in self._contacts[jid]: + resource.avatar_sha = sha + def iter_contacts(self): for jid in list(self._contacts.keys()): for contact in self._contacts[jid][:]: diff --git a/gajim/common/helpers.py b/gajim/common/helpers.py index 1036b354c..2cdc33549 100644 --- a/gajim/common/helpers.py +++ b/gajim/common/helpers.py @@ -633,24 +633,6 @@ def get_account_status(account): status = reduce_chars_newlines(account['status_line'], 100, 1) return status -def get_avatar_path(prefix): - """ - Return the filename of the avatar, distinguishes between user- and contact- - provided one. Return None if no avatar was found at all. prefix is the path - to the requested avatar just before the ".png" or ".jpeg" - """ - # First, scan for a local, user-set avatar - for type_ in ('jpeg', 'png'): - file_ = prefix + '_local.' + type_ - if os.path.exists(file_): - return file_ - # If none available, scan for a contact-provided avatar - for type_ in ('jpeg', 'png'): - file_ = prefix + '.' + type_ - if os.path.exists(file_): - return file_ - return None - def datetime_tuple(timestamp): """ Convert timestamp using strptime and the format: %Y%m%dT%H:%M:%S @@ -1396,7 +1378,7 @@ def get_subscription_request_msg(account=None): if account: s = _('Hello, I am $name.') + ' ' + s our_jid = app.get_jid_from_account(account) - vcard = app.connections[account].get_cached_vcard(our_jid) + vcard = app.connections[account].own_vcard name = '' if vcard: if 'N' in vcard: diff --git a/gajim/common/logger.py b/gajim/common/logger.py index eb2cadb62..3e210982e 100644 --- a/gajim/common/logger.py +++ b/gajim/common/logger.py @@ -42,7 +42,6 @@ from enum import IntEnum, unique from gajim.common import exceptions from gajim.common import app -from gajim.common import ged import sqlite3 as sqlite @@ -998,7 +997,7 @@ class Logger: # First we fill data with roster_entry informations self.cur.execute(''' - SELECT j.jid, re.jid_id, re.name, re.subscription, re.ask + SELECT j.jid, re.jid_id, re.name, re.subscription, re.ask, re.avatar_sha FROM roster_entry re, jids j WHERE re.account_jid_id=? AND j.jid_id=re.jid_id''', (account_jid_id,)) for row in self.cur: @@ -1006,6 +1005,7 @@ class Logger: jid = row.jid name = row.name data[jid] = {} + data[jid]['avatar_sha'] = row.avatar_sha if name: data[jid]['name'] = name else: @@ -1135,3 +1135,25 @@ class Logger: self._timeout_commit() return lastrowid + + def set_avatar_sha(self, account_jid, jid, sha=None): + """ + Set the avatar sha of a jid on an account + + :param account_jid: The jid of the account + + :param jid: The jid that belongs to the avatar + + :param sha: The sha of the avatar + + """ + + account_jid_id = self.get_jid_id(account_jid) + jid_id = self.get_jid_id(jid) + + sql = ''' + UPDATE roster_entry SET avatar_sha = ? + WHERE account_jid_id = ? AND jid_id = ? + ''' + self.con.execute(sql, (sha, account_jid_id, jid_id)) + self._timeout_commit() diff --git a/gajim/common/pep.py b/gajim/common/pep.py index 004296c12..ab6c54b4b 100644 --- a/gajim/common/pep.py +++ b/gajim/common/pep.py @@ -244,6 +244,7 @@ class AbstractPEP(object): self._update_contacts(jid, account) if jid == app.get_jid_from_account(account): self._update_account(account) + self._on_receive(jid, account) def _extract_info(self, items): '''To be implemented by subclasses''' @@ -269,6 +270,10 @@ class AbstractPEP(object): '''SHOULD be implemented by subclasses''' return '' + def _on_receive(self, jid, account): + '''SHOULD be implemented by subclasses''' + pass + class UserMoodPEP(AbstractPEP): '''XEP-0107: User Mood''' @@ -469,5 +474,32 @@ class UserLocationPEP(AbstractPEP): return location_string.strip() -SUPPORTED_PERSONAL_USER_EVENTS = [UserMoodPEP, UserTunePEP, UserActivityPEP, - UserNicknamePEP, UserLocationPEP] +class AvatarNotificationPEP(AbstractPEP): + '''XEP-0084: Avatars''' + + type_ = 'avatar-notification' + namespace = 'urn:xmpp:avatar:metadata' + + def _extract_info(self, items): + avatar = None + for item in items.getTags('item'): + info = item.getTag('metadata').getTag('info') + self.avatar = info.getAttrs() + break + + return (avatar, False) + + def _on_receive(self, jid, account): + sha = app.contacts.get_avatar_sha(account, jid) + app.log('avatar').info( + 'Update (Pubsub): %s %s', jid, self.avatar['id']) + if sha == self.avatar['id']: + return + con = app.connections[account] + app.log('avatar').info('Request (Pubsub): %s', jid) + con.send_pb_retrieve(jid, 'urn:xmpp:avatar:data', self.avatar['id']) + + +SUPPORTED_PERSONAL_USER_EVENTS = [ + UserMoodPEP, UserTunePEP, UserActivityPEP, + UserNicknamePEP, UserLocationPEP, AvatarNotificationPEP] diff --git a/gajim/common/pubsub.py b/gajim/common/pubsub.py index 4718b62ff..49d021b37 100644 --- a/gajim/common/pubsub.py +++ b/gajim/common/pubsub.py @@ -27,8 +27,11 @@ from gajim.common import app #from common.connection_handlers import PEP_CONFIG PEP_CONFIG = 'pep_config' from gajim.common import ged +from gajim.common.nec import NetworkEvent from gajim.common.connection_handlers_events import PubsubReceivedEvent from gajim.common.connection_handlers_events import PubsubBookmarksReceivedEvent +from gajim.common.connection_handlers_events import PubsubAvatarReceivedEvent + import logging log = logging.getLogger('gajim.c.pubsub') @@ -36,8 +39,11 @@ class ConnectionPubSub: def __init__(self): self.__callbacks = {} app.nec.register_incoming_event(PubsubBookmarksReceivedEvent) + app.nec.register_incoming_event(PubsubAvatarReceivedEvent) app.ged.register_event_handler('pubsub-bookmarks-received', ged.CORE, self._nec_pubsub_bookmarks_received) + app.ged.register_event_handler('pubsub-avatar-received', + ged.CORE, self._nec_pubsub_avatar_received) def cleanup(self): app.ged.remove_event_handler('pubsub-bookmarks-received', @@ -97,7 +103,7 @@ class ConnectionPubSub: self.connection.send(query) - def send_pb_retrieve(self, jid, node, cb=None, *args, **kwargs): + def send_pb_retrieve(self, jid, node, item_id=None, cb=None, *args, **kwargs): """ Get items from a node """ @@ -106,6 +112,8 @@ class ConnectionPubSub: query = nbxmpp.Iq('get', to=jid) r = query.addChild('pubsub', namespace=nbxmpp.NS_PUBSUB) r = r.addChild('items', {'node': node}) + if item_id is not None: + r.addChild('item', {'id': item_id}) id_ = self.connection.send(query) if cb: @@ -202,6 +210,28 @@ class ConnectionPubSub: # We got bookmarks from pubsub, now get those from xml to merge them self.get_bookmarks(storage_type='xml') + def _nec_pubsub_avatar_received(self, obj): + if obj.conn.name != self.name: + return + + if obj.jid is None: + jid = self.get_own_jid().getStripped() + else: + jid = obj.jid.getStripped() + + app.log('avatar').info( + 'Received Avatar (Pubsub): %s %s', jid, obj.sha) + app.interface.save_avatar(obj.data) + + if self.get_own_jid().bareMatch(jid): + app.config.set_per('accounts', self.name, 'avatar_sha', obj.sha) + else: + own_jid = self.get_own_jid().getStripped() + app.logger.set_avatar_sha(own_jid, jid, obj.sha) + app.contacts.set_avatar(self.name, jid, obj.sha) + + app.interface.update_avatar(self.name, jid) + def _PubSubErrorCB(self, conn, stanza): log.debug('_PubsubErrorCB') pubsub = stanza.getTag('pubsub') diff --git a/gajim/common/zeroconf/connection_handlers_zeroconf.py b/gajim/common/zeroconf/connection_handlers_zeroconf.py index d0aeaf846..3684e4827 100644 --- a/gajim/common/zeroconf/connection_handlers_zeroconf.py +++ b/gajim/common/zeroconf/connection_handlers_zeroconf.py @@ -36,23 +36,21 @@ log = logging.getLogger('gajim.c.z.connection_handlers_zeroconf') STATUS_LIST = ['offline', 'connecting', 'online', 'chat', 'away', 'xa', 'dnd', 'invisible'] # kind of events we can wait for an answer -VCARD_PUBLISHED = 'vcard_published' -VCARD_ARRIVED = 'vcard_arrived' AGENT_REMOVED = 'agent_removed' from gajim.common import connection_handlers class ConnectionVcard(connection_handlers.ConnectionVcard): - def add_sha(self, p, send_caps = True): + def add_sha(self, p, *args): return p def add_caps(self, p): return p - def request_vcard(self, jid = None, is_fake_jid = False): + def request_vcard(self, *args): pass - def send_vcard(self, vcard): + def send_vcard(self, *args): pass diff --git a/gajim/data/gui/contact_context_menu.ui b/gajim/data/gui/contact_context_menu.ui index 98d846d40..069be648c 100644 --- a/gajim/data/gui/contact_context_menu.ui +++ b/gajim/data/gui/contact_context_menu.ui @@ -108,15 +108,6 @@ - - - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - Set Custom _Avatar... - True - - False diff --git a/gajim/groupchat_control.py b/gajim/groupchat_control.py index dc1dedc53..bd5c9402e 100644 --- a/gajim/groupchat_control.py +++ b/gajim/groupchat_control.py @@ -30,6 +30,7 @@ import os import time import locale + from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkPixbuf @@ -44,6 +45,7 @@ from gajim import config from gajim import vcard from gajim import cell_renderer_image from gajim import dataforms_widget +from gajim.common.const import AvatarSize import nbxmpp from enum import IntEnum, unique @@ -62,6 +64,7 @@ from gajim.command_system.implementation.hosts import PrivateChatCommands from gajim.command_system.implementation.hosts import GroupChatCommands from gajim.common.connection_handlers_events import GcMessageOutgoingEvent + import logging log = logging.getLogger('gajim.groupchat_control') @@ -91,8 +94,7 @@ def tree_cell_data_func(column, renderer, model, iter_, tv=None): if parent_iter and (model[iter_][Column.AVATAR] or avatar_position == \ 'left'): renderer.set_property('visible', True) - renderer.set_property('width', app.config.get( - 'roster_avatar_width')) + renderer.set_property('width', AvatarSize.ROSTER) else: renderer.set_property('visible', False) if parent_iter: @@ -141,6 +143,8 @@ class PrivateChatControl(ChatControl): self.gc_contact = gc_contact ChatControl.__init__(self, parent_win, contact, account, session) self.TYPE_ID = 'pm' + app.ged.register_event_handler('update-gc-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.register_event_handler('caps-received', ged.GUI1, self._nec_caps_received_pm) app.ged.register_event_handler('gc-presence-received', ged.GUI1, @@ -151,6 +155,8 @@ class PrivateChatControl(ChatControl): def shutdown(self): super(PrivateChatControl, self).shutdown() + app.ged.remove_event_handler('update-gc-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.remove_event_handler('caps-received', ged.GUI1, self._nec_caps_received_pm) app.ged.remove_event_handler('gc-presence-received', ged.GUI1, @@ -240,6 +246,20 @@ class PrivateChatControl(ChatControl): self.got_connected() ChatControl.update_ui(self) + def _nec_update_avatar(self, obj): + if obj.contact != self.gc_contact: + return + self.show_avatar() + + def show_avatar(self): + if not app.config.get('show_avatar_in_chat'): + return + + pixbuf = app.interface.get_avatar( + self.gc_contact.avatar_sha, AvatarSize.CHAT) + image = self.xml.get_object('avatar_image') + image.set_from_pixbuf(pixbuf) + def update_contact(self): self.contact = self.gc_contact.as_contact() @@ -502,8 +522,8 @@ class GroupchatControl(ChatControlBase): self._nec_gc_message_received) app.ged.register_event_handler('vcard-published', ged.GUI1, self._nec_vcard_published) - app.ged.register_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) + app.ged.register_event_handler('update-gc-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.register_event_handler('gc-subject-received', ged.GUI1, self._nec_gc_subject_received) app.ged.register_event_handler('gc-config-changed-received', ged.GUI1, @@ -534,7 +554,8 @@ class GroupchatControl(ChatControlBase): if widget.get_tooltip_window(): return widget.set_has_tooltip(True) - widget.set_tooltip_window(tooltips.GCTooltip(self.parent_win.window)) + widget.set_tooltip_window(tooltips.GCTooltip( + self.account, self.parent_win.window)) id_ = widget.connect('query-tooltip', self.query_tooltip) self.handlers[id_] = widget @@ -1046,12 +1067,12 @@ class GroupchatControl(ChatControlBase): status = obj.conn.status obj.conn.send_gc_status(self.nick, self.room_jid, show, status) - def _nec_vcard_received(self, obj): - if obj.conn.name != self.account: + def _nec_update_avatar(self, obj): + if obj.contact.room_jid != self.room_jid: return - if obj.jid != self.room_jid: - return - self.draw_avatar(obj.resource) + app.log('avatar').debug('Draw Groupchat Avatar: %s %s', + obj.contact.name, obj.contact.avatar_sha) + self.draw_avatar(obj.contact) def _nec_gc_message_received(self, obj): if obj.room_jid != self.room_jid or obj.conn.name != self.account: @@ -1584,21 +1605,15 @@ class GroupchatControl(ChatControlBase): self.model[iter_][Column.IMG] = image self.model[iter_][Column.TEXT] = name - def draw_avatar(self, nick): + def draw_avatar(self, gc_contact): if not app.config.get('show_avatars_in_roster'): return - iter_ = self.get_contact_iter(nick) + iter_ = self.get_contact_iter(gc_contact.name) if not iter_: return - fake_jid = self.room_jid + '/' + nick - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(fake_jid) - if pixbuf in ('ask', None): - scaled_pixbuf = empty_pixbuf - else: - scaled_pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'roster') - if not scaled_pixbuf: - scaled_pixbuf = empty_pixbuf - self.model[iter_][Column.AVATAR] = scaled_pixbuf + + pixbuf = app.interface.get_avatar(gc_contact.avatar_sha, AvatarSize.ROSTER) + self.model[iter_][Column.AVATAR] = pixbuf or empty_pixbuf def draw_role(self, role): role_iter = self.get_role_iter(role) @@ -1755,31 +1770,6 @@ class GroupchatControl(ChatControlBase): if obj.nick in self.gc_custom_colors: self.gc_custom_colors[obj.new_nick] = \ self.gc_custom_colors[obj.nick] - # rename vcard / avatar - puny_jid = helpers.sanitize_filename(self.room_jid) - puny_nick = helpers.sanitize_filename(obj.nick) - puny_new_nick = helpers.sanitize_filename(obj.new_nick) - old_path = os.path.join(app.VCARD_PATH, puny_jid, - puny_nick) - new_path = os.path.join(app.VCARD_PATH, puny_jid, - puny_new_nick) - files = {old_path: new_path} - path = os.path.join(app.AVATAR_PATH, puny_jid) - # possible extensions - for ext in ('.png', '.jpeg', '_notif_size_bw.png', - '_notif_size_colored.png'): - files[os.path.join(path, puny_nick + ext)] = \ - os.path.join(path, puny_new_nick + ext) - for old_file in files: - if os.path.exists(old_file) and old_file != \ - files[old_file]: - if os.path.exists(files[old_file]) and \ - helpers.windowsify(old_file) != helpers.windowsify( - files[old_file]): - # Windows require this, but os.remove('test') - # will also remove 'TEST' - os.remove(files[old_file]) - os.rename(old_file, files[old_file]) self.print_conversation(s, 'info', graphics=False) elif '321' in obj.status_code: s = _('%(nick)s has been removed from the room ' @@ -1831,7 +1821,7 @@ class GroupchatControl(ChatControlBase): s = _('You are now known as %s') % nick self.print_conversation(s, 'info', graphics=False) iter_ = self.add_contact_to_roster(obj.nick, obj.show, role, - affiliation, obj.status, obj.real_jid) + affiliation, obj.status, obj.real_jid, obj.avatar_sha) newly_created = True self.draw_all_roles() if obj.status_code and '201' in obj.status_code: @@ -1845,35 +1835,6 @@ class GroupchatControl(ChatControlBase): log.error('%s has an iter, but no gc_contact instance' % \ obj.nick) return - # Re-get vcard if avatar has changed - # We do that here because we may request it to the real JID if - # we knows it. connections.py doesn't know it. - con = app.connections[self.account] - if gc_c and gc_c.jid: - real_jid = gc_c.jid - else: - real_jid = obj.fjid - if obj.fjid in obj.conn.vcard_shas: - if obj.avatar_sha != obj.conn.vcard_shas[obj.fjid]: - server = app.get_server_from_jid(self.room_jid) - if not server.startswith('irc'): - obj.conn.request_vcard(real_jid, obj.fjid) - else: - cached_vcard = obj.conn.get_cached_vcard(obj.fjid, True) - if cached_vcard and 'PHOTO' in cached_vcard and \ - 'SHA' in cached_vcard['PHOTO']: - cached_sha = cached_vcard['PHOTO']['SHA'] - else: - cached_sha = '' - if cached_sha != obj.avatar_sha: - # avatar has been updated - # sha in mem will be updated later - server = app.get_server_from_jid(self.room_jid) - if not server.startswith('irc'): - obj.conn.request_vcard(real_jid, obj.fjid) - else: - # save sha in mem NOW - obj.conn.vcard_shas[obj.fjid] = obj.avatar_sha actual_affiliation = gc_c.affiliation if affiliation != actual_affiliation: @@ -1946,7 +1907,7 @@ class GroupchatControl(ChatControlBase): self.print_conversation(st, graphics=False) def add_contact_to_roster(self, nick, show, role, affiliation, status, - jid=''): + jid='', avatar_sha=None): role_name = helpers.get_uf_role(role, plural=True) resource = '' @@ -1973,22 +1934,14 @@ class GroupchatControl(ChatControlBase): gc_contact = app.contacts.create_gc_contact( room_jid=self.room_jid, account=self.account, name=nick, show=show, status=status, role=role, - affiliation=affiliation, jid=j, resource=resource) + affiliation=affiliation, jid=j, resource=resource, + avatar_sha=avatar_sha) app.contacts.add_gc_contact(self.account, gc_contact) + else: + gc_contact = app.contacts.get_gc_contact(self.account, self.room_jid, nick) self.draw_contact(nick) - self.draw_avatar(nick) - # Do not ask avatar to irc rooms as irc transports reply with messages - server = app.get_server_from_jid(self.room_jid) - if app.config.get('ask_avatars_on_startup') and \ - not server.startswith('irc'): - fake_jid = self.room_jid + '/' + nick - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(fake_jid) - if pixbuf == 'ask': - if j and not self.is_anonymous: - app.connections[self.account].request_vcard(j, fake_jid) - else: - app.connections[self.account].request_vcard(fake_jid, - fake_jid) + self.draw_avatar(gc_contact) + if nick == self.nick: # we became online self.got_connected() if self.list_treeview.get_model(): @@ -2175,8 +2128,8 @@ class GroupchatControl(ChatControlBase): self._nec_gc_message_received) app.ged.remove_event_handler('vcard-published', ged.GUI1, self._nec_vcard_published) - app.ged.remove_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) + app.ged.remove_event_handler('update-gc-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.remove_event_handler('gc-subject-received', ged.GUI1, self._nec_gc_subject_received) app.ged.remove_event_handler('gc-config-changed-received', ged.GUI1, diff --git a/gajim/gtkgui_helpers.py b/gajim/gtkgui_helpers.py index 142895ce6..c44b41e78 100644 --- a/gajim/gtkgui_helpers.py +++ b/gajim/gtkgui_helpers.py @@ -301,42 +301,33 @@ def set_unset_urgency_hint(window, unread_messages_no): else: window.props.urgency_hint = False -# feeding the image directly into the pixbuf seems possible, but is error prone and causes image distortions and segfaults. -# see http://stackoverflow.com/a/8892894/3528174 -# and https://developer.gnome.org/gdk-pixbuf/unstable/gdk-pixbuf-Image-Data-in-Memory.html#gdk-pixbuf-new-from-bytes -# to learn how this could be done (or look into the mercurial history) -def get_pixbuf_from_data(file_data, want_type = False): +def get_pixbuf_from_data(file_data): """ - Get image data and returns GdkPixbuf.Pixbuf if want_type is True it also - returns 'jpeg', 'png' etc + Get image data and returns GdkPixbuf.Pixbuf """ pixbufloader = GdkPixbuf.PixbufLoader() try: pixbufloader.write(file_data) pixbufloader.close() pixbuf = pixbufloader.get_pixbuf() - except GLib.GError: # 'unknown image format' + except GLib.GError: pixbufloader.close() - # try to open and convert this image to png using pillow (if available) - log.debug("loading avatar using pixbufloader failed, trying to convert avatar image using pillow (if available)") + log.warning('loading avatar using pixbufloader failed, trying to ' + 'convert avatar image using pillow') try: avatar = Image.open(BytesIO(file_data)).convert("RGBA") - arr = GLib.Bytes.new(avatar.tobytes()) + array = GLib.Bytes.new(avatar.tobytes()) width, height = avatar.size - pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(arr, GdkPixbuf.Colorspace.RGB, True, 8, width, height, width * 4) - except: - log.info("Could not use pillow to convert avatar image, image cannot be displayed") - if want_type: - return None, None - else: - return None + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + array, GdkPixbuf.Colorspace.RGB, + True, 8, width, height, width * 4) + except Exception: + log.warning('Could not use pillow to convert avatar image, ' + 'image cannot be displayed', exc_info=True) + return - if want_type: - typ = pixbufloader.get_format() and pixbufloader.get_format().get_name() or None - return pixbuf, typ - else: - return pixbuf + return pixbuf def get_cursor(attr): display = Gdk.Display.get_default() @@ -445,90 +436,6 @@ def get_fade_color(treeview, selected, focused): return Gdk.RGBA(bg.red*p + fg.red*q, bg.green*p + fg.green*q, bg.blue*p + fg.blue*q) -def get_scaled_pixbuf_by_size(pixbuf, width, height): - # Pixbuf size - pix_width = pixbuf.get_width() - pix_height = pixbuf.get_height() - # don't make avatars bigger than they are - if pix_width < width and pix_height < height: - return pixbuf # we don't want to make avatar bigger - - ratio = float(pix_width) / float(pix_height) - if ratio > 1: - w = width - h = int(w / ratio) - else: - h = height - w = int(h * ratio) - scaled_buf = pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.HYPER) - return scaled_buf - -def get_scaled_pixbuf(pixbuf, kind): - """ - Return scaled pixbuf, keeping ratio etc or None kind is either "chat", - "roster", "notification", "tooltip", "vcard" - """ - # resize to a width / height for the avatar not to have distortion - # (keep aspect ratio) - width = app.config.get(kind + '_avatar_width') - height = app.config.get(kind + '_avatar_height') - if width < 1 or height < 1: - return None - - return get_scaled_pixbuf_by_size(pixbuf, width, height) - -def get_avatar_pixbuf_from_cache(fjid, use_local=True): - """ - Check if jid has cached avatar and if that avatar is valid image (can be - shown) - - Returns None if there is no image in vcard/ - Returns 'ask' if cached vcard should not be used (user changed his vcard, so - we have new sha) or if we don't have the vcard - """ - jid, nick = app.get_room_and_nick_from_fjid(fjid) - if app.config.get('hide_avatar_of_transport') and\ - app.jid_is_transport(jid): - # don't show avatar for the transport itself - return None - - if any(jid in app.contacts.get_gc_list(acc) for acc in \ - app.contacts.get_accounts()): - is_groupchat_contact = True - else: - is_groupchat_contact = False - - puny_jid = helpers.sanitize_filename(jid) - if is_groupchat_contact: - puny_nick = helpers.sanitize_filename(nick) - path = os.path.join(app.VCARD_PATH, puny_jid, puny_nick) - local_avatar_basepath = os.path.join(app.AVATAR_PATH, puny_jid, - puny_nick) + '_local' - else: - path = os.path.join(app.VCARD_PATH, puny_jid) - local_avatar_basepath = os.path.join(app.AVATAR_PATH, puny_jid) + \ - '_local' - if use_local: - for extension in ('.png', '.jpeg'): - local_avatar_path = local_avatar_basepath + extension - if os.path.isfile(local_avatar_path): - avatar_file = open(local_avatar_path, 'rb') - avatar_data = avatar_file.read() - avatar_file.close() - return get_pixbuf_from_data(avatar_data) - - if not os.path.isfile(path): - return 'ask' - - vcard_dict = list(app.connections.values())[0].get_cached_vcard(fjid, - is_groupchat_contact) - if not vcard_dict: # This can happen if cached vcard is too old - return 'ask' - if 'PHOTO' not in vcard_dict: - return None - pixbuf = vcard.get_avatar_pixbuf_encoded_mime(vcard_dict['PHOTO'])[0] - return pixbuf - def make_gtk_month_python_month(month): """ GTK starts counting months from 0, so January is 0 but Python's time start @@ -558,11 +465,17 @@ def get_possible_button_event(event): def destroy_widget(widget): widget.destroy() -def on_avatar_save_as_menuitem_activate(widget, jid, default_name=''): +def on_avatar_save_as_menuitem_activate(widget, avatar, default_name=''): def on_continue(response, file_path): if response < 0: return - pixbuf = get_avatar_pixbuf_from_cache(jid) + + if isinstance(avatar, str): + # We got a SHA + pixbuf = app.interface.get_avatar(avatar) + else: + # We got a pixbuf + pixbuf = avatar extension = os.path.splitext(file_path)[1] if not extension: # Silently save as Jpeg image @@ -577,7 +490,7 @@ def on_avatar_save_as_menuitem_activate(widget, jid, default_name=''): try: pixbuf.savev(file_path, image_format, [], []) except Exception as e: - log.debug('Error saving avatar: %s' % str(e)) + log.error('Error saving avatar: %s' % str(e)) if os.path.exists(file_path): os.remove(file_path) new_file_path = '.'.join(file_path.split('.')[:-1]) + '.jpeg' diff --git a/gajim/gui_interface.py b/gajim/gui_interface.py index 10aebad7f..cc5257141 100644 --- a/gajim/gui_interface.py +++ b/gajim/gui_interface.py @@ -39,13 +39,17 @@ import sys import re import time import math -from subprocess import Popen +import hashlib from gi.repository import Gtk from gi.repository import GdkPixbuf from gi.repository import GLib -from gajim.common import i18n +try: + from PIL import Image +except: + pass + from gajim.common import app from gajim.common import events @@ -82,12 +86,14 @@ from gajim.common import socks5 from gajim.common import helpers from gajim.common import passwords from gajim.common import logging_helpers -from gajim.common.connection_handlers_events import OurShowEvent, \ - FileRequestErrorEvent, FileTransferCompletedEvent +from gajim.common.connection_handlers_events import ( + OurShowEvent, FileRequestErrorEvent, FileTransferCompletedEvent, + UpdateRosterAvatarEvent, UpdateGCAvatarEvent) from gajim.common.connection import Connection from gajim.common.file_props import FilesProp from gajim.common import pep from gajim import emoticons +from gajim.common.const import AvatarSize from gajim import roster_window from gajim import profile_window @@ -242,13 +248,13 @@ class Interface: if account in self.show_vcard_when_connect and obj.show not in ( 'offline', 'error'): self.edit_own_details(account) + self.show_vcard_when_connect.remove(self.name) def edit_own_details(self, account): jid = app.get_jid_from_account(account) if 'profile' not in self.instances[account]: self.instances[account]['profile'] = \ profile_window.ProfileWindow(account, app.interface.roster.window) - app.connections[account].request_vcard(jid) @staticmethod def handle_gc_error(gc_control, pritext, sectext): @@ -359,7 +365,6 @@ class Interface: # priority, # keyID, timestamp, contact_nickname)) # # Contact changed show - account = obj.conn.name jid = obj.jid show = obj.show @@ -558,16 +563,6 @@ class Interface: dialogs.ErrorDialog(_('Contact with "%s" cannot be established') % \ obj.agent, _('Check your connection or try again later.')) - def handle_event_vcard(self, obj): - # ('VCARD', account, data) - '''vcard holds the vcard data''' - our_jid = app.get_jid_from_account(obj.conn.name) - if obj.jid == our_jid: - if obj.nickname: - app.nicks[obj.conn.name] = obj.nickname - if obj.conn.name in self.show_vcard_when_connect: - self.show_vcard_when_connect.remove(obj.conn.name) - def handle_event_gc_config(self, obj): #('GC_CONFIG', account, (jid, form_node)) config is a dict account = obj.conn.name @@ -780,7 +775,8 @@ class Interface: keyID = attached_keys[attached_keys.index(obj.jid) + 1] contact = app.contacts.create_contact(jid=obj.jid, account=account, name=obj.nickname, groups=obj.groups, - show='offline', sub=obj.sub, ask=obj.ask, keyID=keyID) + show='offline', sub=obj.sub, ask=obj.ask, keyID=keyID, + avatar_sha=obj.avatar_sha) app.contacts.add_contact(account, contact) self.roster.add_contact(obj.jid, account) else: @@ -1554,7 +1550,6 @@ class Interface: self.handle_event_subscribed_presence], 'unsubscribed-presence-received': [ self.handle_event_unsubscribed_presence], - 'vcard-received': [self.handle_event_vcard], 'zeroconf-name-conflict': [self.handle_event_zc_name_conflict], } @@ -2334,85 +2329,94 @@ class Interface: sys.exit() @staticmethod - def save_avatar_files(jid, photo, puny_nick = None, local = False): - """ - Save an avatar to a separate file, and generate files for dbus - notifications. An avatar can be given as a pixmap directly or as an - decoded image - """ - puny_jid = helpers.sanitize_filename(jid) - path_to_file = os.path.join(app.AVATAR_PATH, puny_jid) - if puny_nick: - path_to_file = os.path.join(path_to_file, puny_nick) - # remove old avatars - for typ in ('jpeg', 'png'): - if local: - path_to_original_file = path_to_file + '_local'+ '.' + typ - else: - path_to_original_file = path_to_file + '.' + typ - if os.path.isfile(path_to_original_file): - os.remove(path_to_original_file) - if local and photo: - pixbuf = photo - typ = 'png' - extension = '_local.png' # save local avatars as png file + def update_avatar(account=None, jid=None, contact=None): + if contact is None: + app.nec.push_incoming_event( + UpdateRosterAvatarEvent(None, account=account, jid=jid)) else: - pixbuf, typ = gtkgui_helpers.get_pixbuf_from_data(photo, - want_type=True) + app.nec.push_incoming_event( + UpdateGCAvatarEvent(None, contact=contact)) + + def save_avatar(self, data, publish=False): + if data is None: + return + + if publish: + pixbuf = gtkgui_helpers.get_pixbuf_from_data(data) if pixbuf is None: return - if typ not in ('jpeg', 'png'): - log.info('gtkpixbuf cannot save other than jpeg and '\ - 'png formats. saving \'%s\' avatar as png file (originaly: %s)'\ - % (jid, typ)) - typ = 'png' - extension = '.' + typ - path_to_original_file = path_to_file + extension + pixbuf = pixbuf.scale_simple(AvatarSize.PROFILE, + AvatarSize.PROFILE, + GdkPixbuf.InterpType.BILINEAR) + publish_path = os.path.join(app.AVATAR_PATH, 'temp_publish') + pixbuf.savev(publish_path, 'png', [], []) + with open(publish_path, 'rb') as file: + data = file.read() + return self.save_avatar(data) + + sha = hashlib.sha1(data).hexdigest() + path = os.path.join(app.AVATAR_PATH, sha) try: - pixbuf.savev(path_to_original_file, typ, [], []) - except Exception as e: - log.error('Error writing avatar file %s: %s' % ( - path_to_original_file, str(e))) - # Generate and save the resized, color avatar - pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'notification') - if pixbuf: - path_to_normal_file = path_to_file + '_notif_size_colored' + \ - extension - try: - pixbuf.savev(path_to_normal_file, 'png', [], []) - except Exception as e: - log.error('Error writing avatar file %s: %s' % \ - (path_to_original_file, str(e))) - # Generate and save the resized, black and white avatar - bwbuf = gtkgui_helpers.get_scaled_pixbuf( - gtkgui_helpers.make_pixbuf_grayscale(pixbuf), 'notification') - if bwbuf: - path_to_bw_file = path_to_file + '_notif_size_bw' + extension - try: - bwbuf.savev(path_to_bw_file, 'png', [], []) - except Exception as e: - log.error('Error writing avatar file %s: %s' % \ - (path_to_original_file, str(e))) + with open(path, "wb") as output_file: + output_file.write(data) + except Exception: + app.log('avatar').error('Saving avatar failed', exc_info=True) + return + + return sha @staticmethod - def remove_avatar_files(jid, puny_nick = None, local = False): - """ - Remove avatar files of a jid - """ - puny_jid = helpers.sanitize_filename(jid) - path_to_file = os.path.join(app.AVATAR_PATH, puny_jid) - if puny_nick: - path_to_file = os.path.join(path_to_file, puny_nick) - for ext in ('.jpeg', '.png'): - if local: - ext = '_local' + ext - path_to_original_file = path_to_file + ext - if os.path.isfile(path_to_file + ext): - os.remove(path_to_file + ext) - if os.path.isfile(path_to_file + '_notif_size_colored' + ext): - os.remove(path_to_file + '_notif_size_colored' + ext) - if os.path.isfile(path_to_file + '_notif_size_bw' + ext): - os.remove(path_to_file + '_notif_size_bw' + ext) + def get_avatar(filename, size=None, publish=False): + if filename is None or '': + return + + if publish: + path = os.path.join(app.AVATAR_PATH, filename) + with open(path, 'rb') as file: + data = file.read() + return data + + try: + sha = app.avatar_cache[filename][size] + return sha + except KeyError: + pass + + path = os.path.join(app.AVATAR_PATH, filename) + if not os.path.isfile(path): + return + + pixbuf = None + try: + if size is not None: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( + path, size, size) + else: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) + except GLib.GError as error: + app.log('avatar').info( + 'loading avatar %s failed. Try to convert ' + 'avatar image using pillow', filename) + try: + avatar = Image.open(path).convert("RGBA") + except NameError: + app.log('avatar').warning('Pillow convert failed: %s', filename) + app.log('avatar').debug('Error', exc_info=True) + return + array = GLib.Bytes.new(avatar.tobytes()) + width, height = avatar.size + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes( + array, GdkPixbuf.Colorspace.RGB, True, + 8, width, height, width * 4) + if size: + pixbuf = pixbuf.scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR) + + if filename not in app.avatar_cache: + app.avatar_cache[filename] = {} + app.avatar_cache[filename][size] = pixbuf + + return pixbuf def auto_join_bookmarks(self, account): """ diff --git a/gajim/gui_menu_builder.py b/gajim/gui_menu_builder.py index ed44619c9..b07d9b45d 100644 --- a/gajim/gui_menu_builder.py +++ b/gajim/gui_menu_builder.py @@ -235,7 +235,6 @@ control=None, gc_contact=None, is_anonymous=True): unblock_menuitem = xml.get_object('unblock_menuitem') ignore_menuitem = xml.get_object('ignore_menuitem') unignore_menuitem = xml.get_object('unignore_menuitem') - set_custom_avatar_menuitem = xml.get_object('set_custom_avatar_menuitem') # Subscription submenu subscription_menuitem = xml.get_object('subscription_menuitem') send_auth_menuitem, ask_auth_menuitem, revoke_auth_menuitem = \ @@ -339,7 +338,7 @@ control=None, gc_contact=None, is_anonymous=True): if app.config.get_per('accounts', account, 'is_zeroconf'): for item in (send_custom_status_menuitem, send_single_message_menuitem, invite_menuitem, block_menuitem, unblock_menuitem, ignore_menuitem, - unignore_menuitem, set_custom_avatar_menuitem, subscription_menuitem, + unignore_menuitem, subscription_menuitem, manage_contact_menuitem, convert_to_gc_menuitem): item.set_no_show_all(True) item.hide() @@ -451,9 +450,6 @@ control=None, gc_contact=None, is_anonymous=True): add_to_roster_menuitem.set_no_show_all(True) subscription_menuitem.set_sensitive(False) - set_custom_avatar_menuitem.connect('activate', - roster.on_set_custom_avatar_activate, contact, account) - # Hide items when it's self contact row if our_jid: manage_contact_menuitem.set_sensitive(False) diff --git a/gajim/profile_window.py b/gajim/profile_window.py index 97b13de01..6bb4e4a5a 100644 --- a/gajim/profile_window.py +++ b/gajim/profile_window.py @@ -24,18 +24,18 @@ from gi.repository import Gtk from gi.repository import Gdk -from gi.repository import GdkPixbuf -from gi.repository import GObject from gi.repository import GLib +from gi.repository import GdkPixbuf import base64 -import mimetypes import os import time import logging +import hashlib from gajim import gtkgui_helpers from gajim import dialogs from gajim import vcard +from gajim.common.const import AvatarSize from gajim.common import app from gajim.common import ged @@ -61,6 +61,7 @@ class ProfileWindow: self.dialog = None self.avatar_mime_type = None self.avatar_encoded = None + self.avatar_sha = None self.message_id = self.statusbar.push(self.context_id, _('Retrieving profile…')) self.update_progressbar_timeout_id = GLib.timeout_add(100, @@ -75,10 +76,10 @@ class ProfileWindow: self._nec_vcard_published) app.ged.register_event_handler('vcard-not-published', ged.GUI1, self._nec_vcard_not_published) - app.ged.register_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) self.window.show_all() self.xml.get_object('ok_button').grab_focus() + app.connections[account].request_vcard( + self._nec_vcard_received, self.jid) def on_information_notebook_switch_page(self, widget, page, page_num): GLib.idle_add(self.xml.get_object('ok_button').grab_focus) @@ -100,8 +101,6 @@ class ProfileWindow: self._nec_vcard_published) app.ged.remove_event_handler('vcard-not-published', ged.GUI1, self._nec_vcard_not_published) - app.ged.remove_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) del app.interface.instances[self.account]['profile'] if self.dialog: # Image chooser dialog self.dialog.destroy() @@ -119,71 +118,35 @@ class ProfileWindow: text_button = self.xml.get_object('NOPHOTO_button') text_button.show() self.avatar_encoded = None + self.avatar_sha = None self.avatar_mime_type = None def on_set_avatar_button_clicked(self, widget): def on_ok(widget, path_to_file): - must_delete = False - filesize = os.path.getsize(path_to_file) # in bytes - invalid_file = False - msg = '' - if os.path.isfile(path_to_file): - stat = os.stat(path_to_file) - if stat[6] == 0: - invalid_file = True - msg = _('File is empty') - else: - invalid_file = True - msg = _('File does not exist') - if not invalid_file and filesize > 16384: # 16 kb - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(path_to_file) - # get the image at 'notification size' - # and hope that user did not specify in ACE crazy size - scaled_pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, - 'tooltip') - except GObject.GError as msg: # unknown format - # msg should be string, not object instance - msg = str(msg) - invalid_file = True - if invalid_file: - if True: # keep identation - dialogs.ErrorDialog(_('Could not load image'), msg, - transient_for=self.window) - return - if filesize > 16384: - if scaled_pixbuf: - path_to_file = os.path.join(app.TMP, - 'avatar_scaled.png') - scaled_pixbuf.savev(path_to_file, 'png', [], []) - must_delete = True - - with open(path_to_file, 'rb') as fd: - data = fd.read() - pixbuf = gtkgui_helpers.get_pixbuf_from_data(data) - try: - # rescale it - pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'vcard') - except AttributeError: # unknown format - dialogs.ErrorDialog(_('Could not load image'), - transient_for=self.window) + with open(path_to_file, 'rb') as file: + data = file.read() + sha = app.interface.save_avatar(data, publish=True) + if sha is None: + dialogs.ErrorDialog( + _('Could not load image'), transient_for=self.window) return + self.dialog.destroy() self.dialog = None + + pixbuf = app.interface.get_avatar(sha, AvatarSize.VCARD) + button = self.xml.get_object('PHOTO_button') image = button.get_image() image.set_from_pixbuf(pixbuf) button.show() text_button = self.xml.get_object('NOPHOTO_button') text_button.hide() - self.avatar_encoded = base64.b64encode(data).decode('utf-8') - # returns None if unknown type - self.avatar_mime_type = mimetypes.guess_type(path_to_file)[0] - if must_delete: - try: - os.remove(path_to_file) - except OSError: - log.debug('Cannot remove %s' % path_to_file) + + self.avatar_sha = sha + publish = app.interface.get_avatar(sha, publish=True) + self.avatar_encoded = base64.b64encode(publish).decode('utf-8') + self.avatar_mime_type = 'image/jpeg' def on_clear(widget): self.dialog.destroy() @@ -197,27 +160,25 @@ class ProfileWindow: if self.dialog: self.dialog.present() else: - self.dialog = dialogs.AvatarChooserDialog(on_response_ok = on_ok, - on_response_cancel = on_cancel, on_response_clear = on_clear) + self.dialog = dialogs.AvatarChooserDialog( + on_response_ok=on_ok, on_response_cancel=on_cancel, + on_response_clear=on_clear) def on_PHOTO_button_press_event(self, widget, event): """ If right-clicked, show popup """ - if event.button == 3 and self.avatar_encoded: # right click + pixbuf = self.xml.get_object('PHOTO_button').get_image().get_pixbuf() + if event.button == 3 and pixbuf: # right click menu = Gtk.Menu() - # Try to get pixbuf - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(self.jid, - use_local=False) - - if pixbuf not in (None, 'ask'): - nick = app.config.get_per('accounts', self.account, 'name') - menuitem = Gtk.MenuItem.new_with_mnemonic(_('Save _As')) - menuitem.connect('activate', - gtkgui_helpers.on_avatar_save_as_menuitem_activate, - self.jid, nick) - menu.append(menuitem) + nick = app.config.get_per('accounts', self.account, 'name') + sha = app.contacts.get_avatar_sha(self.account, self.jid) + menuitem = Gtk.MenuItem.new_with_mnemonic(_('Save _As')) + menuitem.connect('activate', + gtkgui_helpers.on_avatar_save_as_menuitem_activate, + sha, nick) + menu.append(menuitem) # show clear menuitem = Gtk.MenuItem.new_with_mnemonic(_('_Clear')) menuitem.connect('activate', self.on_clear_button_clicked) @@ -265,14 +226,16 @@ class ProfileWindow: text_button.show() for i in vcard_.keys(): if i == 'PHOTO': - pixbuf, self.avatar_encoded, self.avatar_mime_type = \ - vcard.get_avatar_pixbuf_encoded_mime(vcard_[i]) - if not pixbuf: - image.set_from_pixbuf(None) - button.hide() - text_button.show() + photo_encoded = vcard_[i]['BINVAL'] + if photo_encoded == '': continue - pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'vcard') + photo_decoded = base64.b64decode(photo_encoded.encode('utf-8')) + pixbuf = gtkgui_helpers.get_pixbuf_from_data(photo_decoded) + if pixbuf is None: + continue + pixbuf = pixbuf.scale_simple( + AvatarSize.PROFILE, AvatarSize.PROFILE, + GdkPixbuf.InterpType.BILINEAR) image.set_from_pixbuf(pixbuf) button.show() text_button.hide() @@ -305,12 +268,8 @@ class ProfileWindow: self.progressbar.set_fraction(0) self.update_progressbar_timeout_id = None - def _nec_vcard_received(self, obj): - if obj.conn.name != self.account: - return - if obj.jid != self.jid: - return - self.set_values(obj.vcard_dict) + def _nec_vcard_received(self, jid, resource, room, vcard_): + self.set_values(vcard_) def add_to_vcard(self, vcard_, entry, txt): """ @@ -367,7 +326,7 @@ class ProfileWindow: vcard_['PHOTO'] = {'BINVAL': self.avatar_encoded} if self.avatar_mime_type: vcard_['PHOTO']['TYPE'] = self.avatar_mime_type - return vcard_ + return vcard_, self.avatar_sha def on_ok_button_clicked(self, widget): if self.update_progressbar_timeout_id: @@ -378,7 +337,7 @@ class ProfileWindow: _('Without a connection, you can not publish your contact ' 'information.'), transient_for=self.window) return - vcard_ = self.make_vcard() + vcard_, sha = self.make_vcard() nick = '' if 'NICKNAME' in vcard_: nick = vcard_['NICKNAME'] @@ -387,7 +346,7 @@ class ProfileWindow: app.connections[self.account].retract_nickname() nick = app.config.get_per('accounts', self.account, 'name') app.nicks[self.account] = nick - app.connections[self.account].send_vcard(vcard_) + app.connections[self.account].send_vcard(vcard_, sha) self.message_id = self.statusbar.push(self.context_id, _('Sending profile…')) self.progressbar.show() diff --git a/gajim/remote_control.py b/gajim/remote_control.py index c9ce5f36c..6ca4651aa 100644 --- a/gajim/remote_control.py +++ b/gajim/remote_control.py @@ -592,24 +592,6 @@ class SignalObject(dbus.service.Object): return app.interface.handle_event(account, jid, event.type_) - @dbus.service.method(INTERFACE, in_signature='s', out_signature='a{sv}') - def contact_info(self, jid): - """ - Get vcard info for a contact. Return cached value of the vcard - """ - if not isinstance(jid, str): - jid = str(jid) - if not jid: - raise dbus_support.MissingArgument() - jid = self._get_real_jid(jid) - - cached_vcard = list(app.connections.values())[0].get_cached_vcard(jid) - if cached_vcard: - return get_dbus_struct(cached_vcard) - - # return empty dict - return DBUS_DICT_SV() - @dbus.service.method(INTERFACE, in_signature='', out_signature='as') def list_accounts(self): """ @@ -891,17 +873,22 @@ class SignalObject(dbus.service.Object): if not invalid_file and filesize < 16384: with open(picture, 'rb') as fd: data = fd.read() + sha = app.interface.save_avatar(data, publish=True) + if sha is None: + return + app.config.set_per('accounts', self.name, 'avatar_sha', sha) + data = app.interface.get_avatar(sha, publish=True) avatar = base64.b64encode(data).decode('utf-8') avatar_mime_type = mimetypes.guess_type(picture)[0] - vcard={} + vcard = {} vcard['PHOTO'] = {'BINVAL': avatar} if avatar_mime_type: vcard['PHOTO']['TYPE'] = avatar_mime_type if account: - app.connections[account].send_vcard(vcard) + app.connections[account].send_vcard(vcard, sha) else: for acc in app.connections: - app.connections[acc].send_vcard(vcard) + app.connections[acc].send_vcard(vcard, sha) @dbus.service.method(INTERFACE, in_signature='ssss', out_signature='') def join_room(self, room_jid, nick, password, account): diff --git a/gajim/roster_window.py b/gajim/roster_window.py index 10844afbd..84ae098ba 100644 --- a/gajim/roster_window.py +++ b/gajim/roster_window.py @@ -42,6 +42,7 @@ from gi.repository import Gio import os import time import locale +import hashlib from enum import IntEnum, unique @@ -57,6 +58,7 @@ from gajim import cell_renderer_image from gajim import tooltips from gajim import message_control from gajim import adhoc_commands +from gajim.common.const import AvatarSize from gajim.common import app from gajim.common import helpers @@ -1361,13 +1363,12 @@ class RosterWindow: if not iters or not app.config.get('show_avatars_in_roster'): return jid = self.model[iters[0]][Column.JID] - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(jid) - if pixbuf in (None, 'ask'): - scaled_pixbuf = empty_pixbuf - else: - scaled_pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'roster') + + pixbuf = app.contacts.get_avatar(account, jid, size=AvatarSize.ROSTER) + if pixbuf is None: + pixbuf = empty_pixbuf for child_iter in iters: - self.model[child_iter][Column.AVATAR_PIXBUF] = scaled_pixbuf + self.model[child_iter][Column.AVATAR_PIXBUF] = pixbuf return False def draw_completely(self, jid, account): @@ -1885,6 +1886,7 @@ class RosterWindow: array[self_jid] = {'name': app.nicks[account], 'groups': ['self_contact'], 'subscription': 'both', 'ask': 'none'} + # .keys() is needed for jid in list(array.keys()): # Remove the contact in roster. It might has changed @@ -1918,25 +1920,6 @@ class RosterWindow: ask=array[jid]['ask'], resource=resource, keyID=keyID) app.contacts.add_contact(account, contact1) - if app.config.get('ask_avatars_on_startup'): - pixbuf = gtkgui_helpers.get_avatar_pixbuf_from_cache(ji) - if pixbuf == 'ask': - transport = app.get_transport_name_from_jid(contact1.jid) - if not transport or app.jid_is_transport(contact1.jid): - jid_with_resource = contact1.jid - if contact1.resource: - jid_with_resource += '/' + contact1.resource - app.connections[account].request_vcard( - jid_with_resource) - else: - host = app.get_server_from_jid(contact1.jid) - if host not in app.transport_avatar[account]: - app.transport_avatar[account][host] = \ - [contact1.jid] - else: - app.transport_avatar[account][host].append( - contact1.jid) - # If we already have chat windows opened, update them with new # contact instance chat_control = app.interface.msg_win_mgr.get_control(ji, account) @@ -2591,11 +2574,6 @@ class RosterWindow: # Update existing iter and group counting self.draw_contact(jid, account) self.draw_group(_('Transports'), account) - if obj.new_show > 1 and jid in app.transport_avatar[account]: - # transport just signed in. - # request avatars - for jid_ in app.transport_avatar[account][jid]: - obj.conn.request_vcard(jid_) if obj.contact: self.chg_contact_status(obj.contact, obj.show, obj.status, account) @@ -2638,10 +2616,11 @@ class RosterWindow: resource = '' if app.connections[account].server_resource: resource = app.connections[account].server_resource + sha = app.config.get_per('accounts', account, 'avatar_sha') contact = app.contacts.create_contact(jid=self_jid, account=account, name=app.nicks[account], groups=['self_contact'], show='offline', sub='both', - ask='none', resource=resource) + ask='none', resource=resource, avatar_sha=sha) app.contacts.add_contact(account, contact) self.add_contact(self_jid, account) if app.config.get('remember_opened_chat_controls'): @@ -2697,11 +2676,9 @@ class RosterWindow: else: self.draw_pep(obj.jid, obj.conn.name, obj.pep_type) - def _nec_vcard_received(self, obj): - if obj.resource: - # it's a muc occupant vcard - return - self.draw_avatar(obj.jid, obj.conn.name) + def _nec_update_avatar(self, obj): + app.log('avatar').debug('Draw roster avatar: %s', obj.jid) + self.draw_avatar(obj.jid, obj.account) def _nec_gc_subject_received(self, obj): contact = app.contacts.get_contact_with_highest_priority( @@ -3053,46 +3030,6 @@ class RosterWindow: _('Select a key to apply to the contact'), public_keys, on_key_selected, selected=keyID, transient_for=self.window) - def on_set_custom_avatar_activate(self, widget, contact, account): - def on_ok(widget, path_to_file): - filesize = os.path.getsize(path_to_file) # in bytes - invalid_file = False - msg = '' - if os.path.isfile(path_to_file): - stat = os.stat(path_to_file) - if stat[6] == 0: - invalid_file = True - msg = _('File is empty') - else: - invalid_file = True - msg = _('File does not exist') - if invalid_file: - dialogs.ErrorDialog(_('Could not load image'), msg) - return - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(path_to_file) - if filesize > 16384: # 16 kb - # get the image at 'tooltip size' - # and hope that user did not specify in ACE crazy size - pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'tooltip') - except GObject.GError as msg: # unknown format - # msg should be string, not object instance - msg = str(msg) - dialogs.ErrorDialog(_('Could not load image'), msg) - return - app.interface.save_avatar_files(contact.jid, pixbuf, local=True) - dlg.destroy() - self.update_avatar_in_gui(contact.jid, account) - - def on_clear(widget): - dlg.destroy() - # Delete file: - app.interface.remove_avatar_files(contact.jid, local=True) - self.update_avatar_in_gui(contact.jid, account) - - dlg = dialogs.AvatarChooserDialog(on_response_ok=on_ok, - on_response_clear=on_clear) - def on_edit_groups(self, widget, list_): dialogs.EditGroupsDialog(list_) @@ -3429,7 +3366,7 @@ class RosterWindow: type_ = model[path][Column.TYPE] # x_min is the x start position of status icon column if app.config.get('avatar_position_in_roster') == 'left': - x_min = app.config.get('roster_avatar_width') + x_min = AvatarSize.ROSTER else: x_min = 0 if app.single_click and not event.get_state() & Gdk.ModifierType.SHIFT_MASK and \ @@ -4805,15 +4742,6 @@ class RosterWindow: for ctrl in list(app.interface.minimized_controls[account].values()): ctrl.repaint_themed_widgets() - def update_avatar_in_gui(self, jid, account): - # Update roster - self.draw_avatar(jid, account) - # Update chat window - - ctrl = app.interface.msg_win_mgr.get_control(jid, account) - if ctrl: - ctrl.show_avatar() - def _iconCellDataFunc(self, column, renderer, model, titer, data=None): """ When a row is added, set properties for icon renderer @@ -4966,8 +4894,7 @@ class RosterWindow: renderer.set_property('visible', False) if app.config.get('avatar_position_in_roster') == 'left': - renderer.set_property('width', app.config.get( - 'roster_avatar_width')) + renderer.set_property('width', AvatarSize.ROSTER) renderer.set_property('xalign', 0.5) else: renderer.set_property('xalign', 1) # align pixbuf to the right @@ -6063,8 +5990,8 @@ class RosterWindow: self._nec_agent_removed) app.ged.register_event_handler('pep-received', ged.GUI1, self._nec_pep_received) - app.ged.register_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) + app.ged.register_event_handler('update-roster-avatar', ged.GUI1, + self._nec_update_avatar) app.ged.register_event_handler('gc-subject-received', ged.GUI1, self._nec_gc_subject_received) app.ged.register_event_handler('metacontacts-received', ged.GUI2, diff --git a/gajim/tooltips.py b/gajim/tooltips.py index 8a7cb0e55..bd8482d34 100644 --- a/gajim/tooltips.py +++ b/gajim/tooltips.py @@ -38,7 +38,7 @@ from datetime import datetime from datetime import timedelta from gajim import gtkgui_helpers - +from gajim.common.const import AvatarSize from gajim.common import app from gajim.common import helpers from gajim.common.i18n import Q_ @@ -311,8 +311,9 @@ class NotificationAreaTooltip(BaseTooltip, StatusTable): class GCTooltip(Gtk.Window): # pylint: disable=E1101 - def __init__(self, parent): + def __init__(self, account, parent): Gtk.Window.__init__(self, type=Gtk.WindowType.POPUP, transient_for=parent) + self.account = account self.row = None self.set_title('tooltip') self.set_border_width(3) @@ -379,17 +380,14 @@ class GCTooltip(Gtk.Window): self.affiliation.show() # Avatar - puny_name = helpers.sanitize_filename(contact.name) - puny_room = helpers.sanitize_filename(contact.room_jid) - file_ = helpers.get_avatar_path(os.path.join(app.AVATAR_PATH, - puny_room, puny_name)) - if file_: - with open(file_, 'rb') as file_data: - pix = gtkgui_helpers.get_pixbuf_from_data(file_data.read()) - pix = gtkgui_helpers.get_scaled_pixbuf(pix, 'tooltip') - self.avatar.set_from_pixbuf(pix) - self.avatar.show() - self.fillelement.show() + if contact.avatar_sha is not None: + app.log('avatar').debug( + 'Load GCTooltip: %s %s', contact.name, contact.avatar_sha) + pixbuf = app.interface.get_avatar(contact.avatar_sha, AvatarSize.TOOLTIP) + if pixbuf is not None: + self.avatar.set_from_pixbuf(pixbuf) + self.avatar.show() + self.fillelement.show() @staticmethod def colorize_affiliation(affiliation): @@ -662,25 +660,22 @@ class RosterTooltip(Gtk.Window, StatusTable): self._set_idle_time(contact) # Avatar - puny_jid = helpers.sanitize_filename(self.prim_contact.jid) - file_ = helpers.get_avatar_path(os.path.join(app.AVATAR_PATH, - puny_jid)) - if file_: - with open(file_, 'rb') as file_data: - pix = gtkgui_helpers.get_pixbuf_from_data(file_data.read()) - pix = gtkgui_helpers.get_scaled_pixbuf(pix, 'tooltip') - self.avatar.set_from_pixbuf(pix) - self.avatar.show() + pixbuf = app.contacts.get_avatar( + account, self.prim_contact.jid, AvatarSize.TOOLTIP) + if pixbuf is None: + return + self.avatar.set_from_pixbuf(pixbuf) + self.avatar.show() - # Sets the Widget that is at the bottom to expand. - # This is needed in case the Picture takes more Space then the Labels - i = 1 - while i < 15: - if self.tooltip_grid.get_child_at(0, i): - if self.tooltip_grid.get_child_at(0, i).get_visible(): - self.last_widget = self.tooltip_grid.get_child_at(0, i) - i += 1 - self.last_widget.set_vexpand(True) + # Sets the Widget that is at the bottom to expand. + # This is needed in case the Picture takes more Space then the Labels + i = 1 + while i < 15: + if self.tooltip_grid.get_child_at(0, i): + if self.tooltip_grid.get_child_at(0, i).get_visible(): + self.last_widget = self.tooltip_grid.get_child_at(0, i) + i += 1 + self.last_widget.set_vexpand(True) def _append_pep_info(self, contact): """ diff --git a/gajim/vcard.py b/gajim/vcard.py index 3791f2d86..bc237d722 100644 --- a/gajim/vcard.py +++ b/gajim/vcard.py @@ -34,6 +34,7 @@ from gi.repository import Gtk from gi.repository import GLib from gi.repository import Gdk +from gi.repository import GdkPixbuf import base64 import time import locale @@ -46,38 +47,10 @@ from gajim.common import helpers from gajim.common import app from gajim.common import ged from gajim.common.i18n import Q_ +from gajim.common.const import AvatarSize # log = logging.getLogger('gajim.vcard') -def get_avatar_pixbuf_encoded_mime(photo): - """ - Return the pixbuf of the image - - Photo is a dictionary containing PHOTO information. - """ - if not isinstance(photo, dict): - return None, None, None - img_decoded = None - avatar_encoded = None - avatar_mime_type = None - if 'BINVAL' in photo: - img_encoded = photo['BINVAL'] - avatar_encoded = img_encoded - try: - img_decoded = base64.b64decode(img_encoded.encode('utf-8')) - except Exception: - pass - if img_decoded: - if 'TYPE' in photo: - avatar_mime_type = photo['TYPE'] - pixbuf = gtkgui_helpers.get_pixbuf_from_data(img_decoded) - else: - pixbuf, avatar_mime_type = gtkgui_helpers.get_pixbuf_from_data( - img_decoded, want_type=True) - else: - pixbuf = None - return pixbuf, avatar_encoded, avatar_mime_type - class VcardWindow: """ Class for contact's information window @@ -92,6 +65,7 @@ class VcardWindow: self.contact = contact self.account = account self.gc_contact = gc_contact + self.avatar = None # Get real jid if gc_contact: @@ -122,8 +96,6 @@ class VcardWindow: image.show() self.xml.get_object('custom_avatar_label').show() break - self.avatar_mime_type = None - self.avatar_encoded = None self.vcard_arrived = False self.os_info_arrived = False self.entity_time_arrived = False @@ -136,8 +108,6 @@ class VcardWindow: self.set_os_info) app.ged.register_event_handler('time-result-received', ged.GUI1, self.set_entity_time) - app.ged.register_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) self.fill_jabber_page() annotations = app.connections[self.account].annotations @@ -181,8 +151,6 @@ class VcardWindow: self.set_os_info) app.ged.remove_event_handler('time-result-received', ged.GUI1, self.set_entity_time) - app.ged.remove_event_handler('vcard-received', ged.GUI1, - self._nec_vcard_received) def on_vcard_information_window_key_press_event(self, widget, event): if event.keyval == Gdk.KEY_Escape: @@ -198,9 +166,17 @@ class VcardWindow: if event.button == 3: # right click menu = Gtk.Menu() menuitem = Gtk.MenuItem.new_with_mnemonic(_('Save _As')) + if self.gc_contact: + sha = self.gc_contact.avatar_sha + name = self.gc_contact.get_shown_name() + else: + sha = app.contacts.get_avatar_sha( + self.account, self.contact.jid) + name = self.contact.get_shown_name() + if sha is None: + sha = self.avatar menuitem.connect('activate', - gtkgui_helpers.on_avatar_save_as_menuitem_activate, - self.contact.jid, self.contact.get_shown_name()) + gtkgui_helpers.on_avatar_save_as_menuitem_activate, sha, name) menu.append(menuitem) menu.connect('selection-done', lambda w:w.destroy()) # show the menu @@ -229,17 +205,23 @@ class VcardWindow: for i in vcard.keys(): if i == 'PHOTO' and self.xml.get_object('information_notebook').\ get_n_pages() > 4: - pixbuf, self.avatar_encoded, self.avatar_mime_type = \ - get_avatar_pixbuf_encoded_mime(vcard[i]) - image = self.xml.get_object('PHOTO_image') - image.show() - self.xml.get_object('user_avatar_label').show() - if not pixbuf: - image.set_from_icon_name('stock_person', - Gtk.IconSize.DIALOG) + if 'BINVAL' not in vcard[i]: continue - pixbuf = gtkgui_helpers.get_scaled_pixbuf(pixbuf, 'vcard') + photo_encoded = vcard[i]['BINVAL'] + if photo_encoded == '': + continue + photo_decoded = base64.b64decode(photo_encoded.encode('utf-8')) + pixbuf = gtkgui_helpers.get_pixbuf_from_data(photo_decoded) + if pixbuf is None: + continue + pixbuf = pixbuf.scale_simple( + AvatarSize.PROFILE, AvatarSize.PROFILE, + GdkPixbuf.InterpType.BILINEAR) + image = self.xml.get_object('PHOTO_image') image.set_from_pixbuf(pixbuf) + image.show() + self.avatar = pixbuf + self.xml.get_object('user_avatar_label').show() continue if i in ('ADR', 'TEL', 'EMAIL'): for entry in vcard[i]: @@ -276,19 +258,9 @@ class VcardWindow: widget.set_text('') self.xml.get_object('DESC_textview').get_buffer().set_text('') - - def _nec_vcard_received(self, obj): - if obj.conn.name != self.account: - return - if obj.resource: - # It's a muc occupant vcard - if obj.fjid != self.contact.jid: - return - else: - if obj.jid != self.contact.jid: - return + def _nec_vcard_received(self, jid, resource, room, vcard): self.clear_values() - self.set_values(obj.vcard_dict) + self.set_values(vcard) def set_os_info(self, obj): if obj.conn.name != self.account: @@ -492,12 +464,12 @@ class VcardWindow: self.fill_status_label() + con = app.connections[self.account] if self.gc_contact: - # If we know the real jid, remove the resource from vcard request - app.connections[self.account].request_vcard(self.real_jid_for_vcard, - self.gc_contact.get_full_jid()) + con.request_vcard(self._nec_vcard_received, + self.gc_contact.get_full_jid(), room=True) else: - app.connections[self.account].request_vcard(self.contact.jid) + con.request_vcard(self._nec_vcard_received, self.contact.jid) def on_close_button_clicked(self, widget): self.window.destroy() @@ -513,9 +485,6 @@ class ZeroconfVcardWindow: self.account = account self.is_fake = is_fake - # self.avatar_mime_type = None - # self.avatar_encoded = None - self.fill_contact_page() self.fill_personal_page() @@ -538,7 +507,7 @@ class ZeroconfVcardWindow: menuitem = Gtk.MenuItem.new_with_mnemonic(_('Save _As')) menuitem.connect('activate', gtkgui_helpers.on_avatar_save_as_menuitem_activate, - self.contact.jid, self.contact.get_shown_name()) + self.contact.avatar_sha, self.contact.get_shown_name()) menu.append(menuitem) menu.connect('selection-done', lambda w:w.destroy()) # show the menu diff --git a/plugins/dbus_plugin/plugin.py b/plugins/dbus_plugin/plugin.py index 22d8a853f..7e1ffd353 100644 --- a/plugins/dbus_plugin/plugin.py +++ b/plugins/dbus_plugin/plugin.py @@ -397,24 +397,6 @@ if dbus_support.supported: if gajim.events.get_nb_events(): gajim.interface.systray.handle_first_event() - @dbus.service.method(INTERFACE, in_signature='s', out_signature='a{sv}') - def contact_info(self, jid): - '''get vcard info for a contact. Return cached value of the vcard. - ''' - if not isinstance(jid, unicode): - jid = unicode(jid) - if not jid: - raise MissingArgument - return DBUS_DICT_SV() - jid = self._get_real_jid(jid) - - cached_vcard = list(gajim.connections.values())[0].get_cached_vcard(jid) - if cached_vcard: - return get_dbus_struct(cached_vcard) - - # return empty dict - return DBUS_DICT_SV() - @dbus.service.method(INTERFACE, in_signature='', out_signature='as') def list_accounts(self): '''list register accounts''' diff --git a/test/lib/gajim_mocks.py b/test/lib/gajim_mocks.py index 812aac724..1d7cc6d76 100644 --- a/test/lib/gajim_mocks.py +++ b/test/lib/gajim_mocks.py @@ -47,7 +47,7 @@ class MockConnection(Mock, ConnectionHandlers): app.connections[account] = self - def request_vcard(self, jid): + def request_vcard(self, *args): pass class MockWindow(Mock):