Commit b2aa7352 authored by Philipp Hörist's avatar Philipp Hörist

Refactor avatar storage code

parent 26c441dc
......@@ -294,7 +294,6 @@ class Config:
'show_chatstate_in_banner': [opt_bool, True, _('Shows a text in the banner that describes the current chatstate of the contact')],
'send_chatstate_default': [opt_str, 'composing_only', _('Chat state notifications that are sent to contacts. Possible values: all, composing_only, disabled')],
'send_chatstate_muc_default': [opt_str, 'composing_only', _('Chat state notifications that are sent to the group chat. Possible values: all, composing_only, disabled')],
'avatar_clipping': [opt_str, 'rounded_corners', _('How to display avatars:\n\'rounded_corners\' - Display avatars with cropped corners.\n\'circle\' - Display round avatars.\n\'no_clipping\' - Display avatar as-is.')],
}, {}) # type: Tuple[Dict[str, List[Any]], Dict[Any, Any]]
__options_per_key = {
......
......@@ -594,7 +594,7 @@ class Contacts():
if contacts[0].is_groupchat():
return contacts[0]
def get_avatar(self, jid, size=None, scale=None):
def get_avatar(self, jid, size, scale):
if jid not in self._contacts:
return None
......
......@@ -740,15 +740,15 @@ class GroupchatControl(ChatControlBase):
def _on_upload_avatar(self, action, param):
def _on_accept(filename):
sha = app.interface.save_avatar(filename, publish=True)
data, sha = app.interface.avatar_storage.prepare_for_publish(
filename)
if sha is None:
ErrorDialog(
_('Could not load image'),
transient_for=self.parent_win.window)
return
publish = app.interface.get_avatar_from_storage(sha, publish=True)
avatar = base64.b64encode(publish).decode('utf-8')
avatar = base64.b64encode(data).decode('utf-8')
con = app.connections[self.account]
con.get_module('VCardTemp').upload_room_avatar(
self.room_jid, avatar)
......
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
import os
import logging
import hashlib
from math import pi
from functools import lru_cache
from collections import defaultdict
from gi.repository import Gdk
from gi.repository import GdkPixbuf
import cairo
from gajim.common import configpaths
from gajim.common.helpers import Singleton
from gajim.common.const import AvatarSize
from gajim.gtk.util import load_pixbuf
from gajim.gtk.util import text_to_color
from gajim.gtk.util import scale_with_ratio
log = logging.getLogger('gajim.gtk.avatar')
@lru_cache(maxsize=1024)
def generate_avatar(letters, color, size, scale):
# Get color for nickname with XEP-0392
color_r, color_g, color_b = color
# Set up colors and size
if scale is not None:
size = size * scale
width = size
height = size
font_size = size * 0.5
# Set up surface
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
context = cairo.Context(surface)
context.set_source_rgb(color_r, color_g, color_b)
context.rectangle(0, 0, width, height)
context.fill()
# Draw letters
context.select_font_face('sans-serif',
cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_NORMAL)
context.set_font_size(font_size)
extends = context.text_extents(letters)
if isinstance(extends, tuple):
# For cairo < 1.15
x_bearing, y_bearing, ex_width, ex_height = extends[0:4]
else:
x_bearing = extends.x_bearing
y_bearing = extends.y_bearing
ex_width = extends.width
ex_height = extends.height
x_pos = width / 2 - (ex_width / 2 + x_bearing)
y_pos = height / 2 - (ex_height / 2 + y_bearing)
context.move_to(x_pos, y_pos)
context.set_source_rgb(0.95, 0.95, 0.95)
# use cairo.OPERATOR_OVER legacy constant because its
# compatible with cairo < 1.13
context.set_operator(cairo.OPERATOR_OVER)
context.show_text(letters)
return context.get_target()
def clip_circle(surface):
new_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
surface.get_width(),
surface.get_height())
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
width = surface.get_width()
height = surface.get_height()
radius = width / 2
context.arc(width / 2, height / 2, radius, 0, 2 * pi)
context.clip()
context.paint()
return context.get_target()
class AvatarStorage(metaclass=Singleton):
def __init__(self):
self._cache = defaultdict(dict)
def invalidate_cache(self, jid):
self._cache.pop(jid, None)
def get_pixbuf(self, contact, size, scale):
surface = self.get_surface(contact, size, scale)
return Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size)
def get_surface(self, contact, size, scale):
jid = contact.jid
if contact.is_gc_contact:
jid = contact.get_full_jid()
surface = self._cache[jid].get((size, scale))
if surface is not None:
return surface
surface = self._get_avatar_from_storage(contact, size, scale)
if surface is not None:
self._cache[jid][(size, scale)] = surface
return surface
surface = self._generate_default_avatar(contact, size, scale)
self._cache[jid][(size, scale)] = surface
return surface
def prepare_for_publish(self, path):
success, data = self._load_for_publish(path)
if not success:
return None, None
sha = self.save_avatar(data)
if sha is None:
return None, None
return data, sha
@staticmethod
def _load_for_publish(path):
pixbuf = load_pixbuf(path)
if pixbuf is None:
return None
width = pixbuf.get_width()
height = pixbuf.get_height()
if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH:
# Scale only down, never up
width, height = scale_with_ratio(AvatarSize.PUBLISH, width, height)
pixbuf = pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf.save_to_bufferv('png', [], [])
@staticmethod
def save_avatar(data):
"""
Save an avatar to the harddisk
:param data: bytes
returns SHA1 value of the avatar or None on error
"""
if data is None:
return None
sha = hashlib.sha1(data).hexdigest()
path = os.path.join(configpaths.get('AVATAR'), sha)
try:
with open(path, 'wb') as output_file:
output_file.write(data)
except Exception:
log.error('Storing avatar failed', exc_info=True)
return None
return sha
@staticmethod
def get_avatar_path(filename):
path = os.path.join(configpaths.get('AVATAR'), filename)
if not os.path.isfile(path):
return None
return path
def pixbuf_from_filename(self, filename):
path = self.get_avatar_path(filename)
if path is None:
return None
return load_pixbuf(path)
def surface_from_filename(self, filename, size, scale):
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
def _load_surface_from_storage(self, contact, size, scale):
filename = contact.avatar_sha
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
def _get_avatar_from_storage(self, contact, size, scale):
if contact.avatar_sha is None:
return None
surface = self._load_surface_from_storage(contact, size, scale)
if surface is None:
return None
return clip_circle(surface)
@staticmethod
def _generate_default_avatar(contact, size, scale):
# Get initial from name
name = contact.get_shown_name()
letter = name[0].capitalize()
# Use nickname for group chats and bare JID for single contacts
if contact.is_gc_contact:
color_string = contact.name
else:
color_string = contact.jid
color = text_to_color(color_string)
surface = generate_avatar(letter, color, size, scale)
return clip_circle(surface)
......@@ -128,14 +128,16 @@ class ProfileWindow(Gtk.ApplicationWindow):
def on_set_avatar_button_clicked(self, widget):
def on_ok(path_to_file):
sha = app.interface.save_avatar(path_to_file, publish=True)
data, sha = app.interface.avatar_storage.prepare_for_publish(
path_to_file)
if sha is None:
ErrorDialog(
_('Could not load image'), transient_for=self)
return
scale = self.get_scale_factor()
surface = app.interface.get_avatar_from_storage(sha, AvatarSize.VCARD, scale)
surface = app.interface.avatar_storage.surface_from_filename(
sha, AvatarSize.VCARD, scale)
button = self.xml.get_object('PHOTO_button')
image = self.xml.get_object('PHOTO_image')
......@@ -145,8 +147,7 @@ class ProfileWindow(Gtk.ApplicationWindow):
text_button.hide()
self.avatar_sha = sha
publish = app.interface.get_avatar_from_storage(sha, publish=True)
self.avatar_encoded = base64.b64encode(publish).decode('utf-8')
self.avatar_encoded = base64.b64encode(data).decode('utf-8')
self.avatar_mime_type = 'image/png'
AvatarChooserDialog(on_ok, transient_for=self)
......@@ -212,7 +213,7 @@ class ProfileWindow(Gtk.ApplicationWindow):
self.avatar_mime_type = vcard_[i]['TYPE']
scale = self.get_scale_factor()
surface = app.interface.get_avatar_from_storage(
surface = app.interface.avatar_storage.surface_from_filename(
self.avatar_sha, AvatarSize.VCARD, scale)
if surface is None:
pixbuf = gtkgui_helpers.scale_pixbuf_from_data(
......
......@@ -26,14 +26,18 @@ import logging
import textwrap
import xml.etree.ElementTree as ET
from pathlib import Path
from functools import lru_cache
from functools import wraps
from math import pi
try:
from PIL import Image
except Exception:
pass
from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import GLib
from gi.repository import Pango
from gi.repository import GdkPixbuf
import nbxmpp
import cairo
......@@ -620,98 +624,6 @@ def find_widget(name, container):
return find_widget(name, child)
@lru_cache(maxsize=1024)
def generate_avatar(letters, color, size, scale):
# Get color for nickname with XEP-0392
color_r, color_g, color_b = color
# Set up colors and size
if scale is not None:
size = size * scale
width = size
height = size
font_size = size * 0.5
# Set up surface
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
context = cairo.Context(surface)
context.set_source_rgb(color_r, color_g, color_b)
context.rectangle(0, 0, width, height)
context.fill()
# Draw letters
context.select_font_face('sans-serif',
cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_NORMAL)
context.set_font_size(font_size)
extends = context.text_extents(letters)
if isinstance(extends, tuple):
# For cairo < 1.15
x_bearing, y_bearing, ex_width, ex_height = extends[0:4]
else:
x_bearing = extends.x_bearing
y_bearing = extends.y_bearing
ex_width = extends.width
ex_height = extends.height
x_pos = width / 2 - (ex_width / 2 + x_bearing)
y_pos = height / 2 - (ex_height / 2 + y_bearing)
context.move_to(x_pos, y_pos)
context.set_source_rgb(0.95, 0.95, 0.95)
# use cairo.OPERATOR_OVER legacy constant because its
# compatible with cairo < 1.13
context.set_operator(cairo.OPERATOR_OVER)
context.show_text(letters)
return context.get_target()
def clip_circle(surface):
new_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
surface.get_width(),
surface.get_height())
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
width = surface.get_width()
height = surface.get_height()
radius = width / 2
context.arc(width / 2, height / 2, radius, 0, 2 * pi)
context.clip()
context.paint()
return context.get_target()
def clip_rounded_corners(surface):
new_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
surface.get_width(),
surface.get_height())
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
width = surface.get_width()
height = surface.get_height()
radius = width * 0.1
deg = pi / 180.0
context.new_sub_path()
context.arc(width - radius, radius, radius, -90 * deg, 0)
context.arc(width - radius, height - radius, radius, 0, 90 * deg)
context.arc(radius, height - radius, radius, 90 * deg, 180 * deg)
context.arc(radius, radius, radius, 180 * deg, 270 * deg)
context.close_path()
context.clip()
context.paint()
return context.get_target()
class MultiLineLabel(Gtk.Label):
def __init__(self, *args, **kwargs):
Gtk.Label.__init__(self, *args, **kwargs)
......@@ -744,3 +656,45 @@ def text_to_color(text):
else:
background = (1, 1, 1) # RGB (255, 255, 255) white
return nbxmpp.util.text_to_color(text, background)
def scale_with_ratio(size, width, height):
if height == width:
return size, size
if height > width:
ratio = height / float(width)
return int(size / ratio), size
ratio = width / float(height)
return size, int(size / ratio)
def load_pixbuf(path, size=None):
try:
if size is None:
return GdkPixbuf.Pixbuf.new_from_file(path)
return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, size, size, True)
except GLib.GError:
log.warning('loading pixbuf failed. Try to convert '
'image with pillow: %s', path)
try:
with open(path, 'rb') as im_handle:
img = Image.open(im_handle)
avatar = img.convert("RGBA")
except (NameError, OSError):
log.warning('Pillow convert failed: %s', path)
log.debug('Error', exc_info=True)
return None
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 is not None:
width, height = scale_with_ratio(size, width, height)
return pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf
......@@ -159,7 +159,7 @@ def on_avatar_save_as_menuitem_activate(widget, avatar, default_name=''):
app.config.set('last_save_dir', os.path.dirname(file_path))
if isinstance(avatar, str):
# We got a SHA
pixbuf = app.interface.get_avatar_from_storage(avatar)
pixbuf = app.interface.avatar_storage.pixbuf_from_filename(avatar)
else:
# We got a pixbuf
pixbuf = avatar
......
......@@ -35,26 +35,18 @@ import os
import sys
import re
import time
import hashlib
import logging
from functools import partial
from threading import Thread
from gi.repository import Gtk
from gi.repository import GdkPixbuf
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gdk
from nbxmpp import idlequeue
from nbxmpp import Hashes2
from nbxmpp.structs import TuneData
import OpenSSL
try:
from PIL import Image
except Exception:
pass
from gajim.common import app
from gajim.common import events
from gajim.common.dbus import screensaver
......@@ -62,7 +54,6 @@ from gajim.common.dbus import location
from gajim.common.dbus import music_track
from gajim.common.dbus import logind
from gajim import gtkgui_helpers
from gajim import gui_menu_builder
from gajim import dialogs
from gajim import message_control
......@@ -94,7 +85,6 @@ from gajim.common.connection_handlers_events import (
from gajim.common.modules.httpupload import HTTPUploadProgressEvent
from gajim.common.connection import Connection
from gajim.common.file_props import FilesProp
from gajim.common.const import AvatarSize
from gajim.common.const import SSLError
from gajim import roster_window
......@@ -103,6 +93,7 @@ from gajim.common.caps_cache import muc_caps_cache
from gajim.common import configpaths
from gajim.common import optparser
from gajim.gtk.avatar import AvatarStorage
from gajim.gtk.notification import Notification
from gajim.gtk.dialogs import ErrorDialog
from gajim.gtk.dialogs import WarningDialog
......@@ -125,10 +116,6 @@ from gajim.gtk.roster_item_exchange import RosterItemExchangeWindow
from gajim.gtk.subscription_request import SubscriptionRequestWindow
from gajim.gtk.util import get_show_in_roster
from gajim.gtk.util import get_show_in_systray
from gajim.gtk.util import generate_avatar
from gajim.gtk.util import clip_circle
from gajim.gtk.util import clip_rounded_corners
from gajim.gtk.util import text_to_color
parser = optparser.OptionsParser(configpaths.get('CONFIG_FILE'))
......@@ -1988,8 +1975,9 @@ class Interface:
_('Could not save your settings and preferences'))
error_dialog.run()
@staticmethod
def update_avatar(account=None, jid=None, contact=None, room_avatar=False):
def update_avatar(self, account=None, jid=None,
contact=None, room_avatar=False):
self.avatar_storage.invalidate_cache(jid or contact.jid)
if room_avatar:
app.nec.push_incoming_event(
NetworkEvent('update-room-avatar', account=account, jid=jid))
......@@ -2000,157 +1988,16 @@ class Interface:
app.nec.push_incoming_event(
NetworkEvent('update-gc-avatar', contact=contact))
def save_avatar(self, data, publish=False):
"""
Save an avatar to the harddisk
:param data: publish=False data must be bytes
publish=True data must be a path to a file
:param publish: If publish is True, the method scales the file
to AvatarSize.PUBLISH size before saving
returns SHA1 value of the avatar or None on error
"""
if data is None:
return
if publish:
with open(data, 'rb') as file:
data = file.read()
pixbuf = gtkgui_helpers.get_pixbuf_from_data(data)
if pixbuf is None:
return
def save_avatar(self, data):
return self.avatar_storage.save_avatar(data)
width = pixbuf.get_width()
height = pixbuf.get_height()
if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH:
# Scale only down, never up
width, height = gtkgui_helpers.scale_with_ratio(
AvatarSize.PUBLISH, width, height)
pixbuf = pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
publish_path = os.path.join(
configpaths.get('AVATAR'), '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(configpaths.get('AVATAR'), sha)
try:
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
def get_avatar(self, contact, size=None, scale=None, publish=False):
surface = self.get_avatar_from_storage(contact.avatar_sha,
size,
scale,
publish)
if surface is None:
# No avatar found, generate one
# Get initial from name
name = contact.get_shown_name()
letter = name[0].capitalize()
# Use nickname for group chats and bare JID for single contacts
if contact.is_gc_contact:
color_string = contact.name
else:
color_string = contact.jid
color = text_to_color(color_string)
surface = generate_avatar(letter, color, size, scale)
def get_avatar(self, contact, size, scale, pixbuf=False):
if pixbuf:
return self.avatar_storage.get_pixbuf(contact, size, scale)
return self.avatar_storage.get_surface(contact, size, scale)
# Clip avatar
clip_setting = app.config.get('avatar_clipping')
if clip_setting == 'circle':
return clip_circle(surface)
if clip_setting == 'rounded_corners':
return clip_rounded_corners(surface)
return surface
def get_avatar_from_storage(self, filename, size=None, scale=None, publish=False):
if filename is None or '':
return
if size is None and scale is not None:
raise ValueError
if scale is not None:
size = size * scale
if publish:
path = os.path.join(configpaths.get('AVATAR'), filename)
with open(path, 'rb') as file:
data = file.read()
return data
try:
pixbuf = app.avatar_cache[filename][size]
if scale is None:
return pixbuf
return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
except KeyError:
pass
path = os.path.join(configpaths.get('AVATAR'), filename)
if not os.path.isfile(path):
return
pixbuf = None
try:
if size is not None:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
path, size, size, True)
else:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(path)
except GLib.GError:
app.log('avatar').info(
'loading avatar %s failed. Try to convert '
'avatar image using pillow', filename)
try:
with open(path, 'rb') as im_handle:
img = Image.open(im_handle)
avatar = img.convert("RGBA")
except (NameError, OSError):
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:
width, height = gtkgui_helpers.scale_with_ratio(
size, width, height)
pixbuf = pixbuf.scale_simple(
width, height, GdkPixbuf.InterpType.BILINEAR)
if filename not in app.avatar_cache:
app.avatar_cache[filename] = {}
app.avatar_cache[filename][size] = pixbuf
if scale is None:
return pixbuf
return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
@staticmethod
def avatar_exists(filename):
path = os.path.join(configpaths.get('AVATAR'), filename)
if not os.path.isfile(path):
return False
return True