helpers.py 44.2 KB
Newer Older
Philipp Hörist's avatar
Philipp Hörist committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com>
#                         Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Alex Mauer <hawke AT hawkesnest.net>
# Copyright (C) 2006-2007 Travis Shirk <travis AT pobox.com>
# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
# Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net>
#                    James Newton <redshodan AT gmail.com>
#                    Julien Pivotto <roidelapluie AT gmail.com>
# Copyright (C) 2007-2008 Stephan Erb <steve-e AT h3c.de>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
#                    Jonathan Schleifer <js-gajim AT webkeks.org>
#
# 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/>.
27

Philipp Hörist's avatar
Philipp Hörist committed
28
29
30
from typing import Any  # pylint: disable=unused-import
from typing import Dict  # pylint: disable=unused-import

Alexander Cherniuk's avatar
Alexander Cherniuk committed
31
import sys
32
import re
33
import os
34
import subprocess
35
import base64
36
import hashlib
37
import shlex
38
import socket
39
import logging
40
import json
Philipp Hörist's avatar
Philipp Hörist committed
41
import copy
42
import collections
Philipp Hörist's avatar
Philipp Hörist committed
43
44
import platform
import functools
Philipp Hörist's avatar
Philipp Hörist committed
45
from collections import defaultdict
46
import random
Philipp Hörist's avatar
Philipp Hörist committed
47
import weakref
48
import inspect
49
import string
50
import webbrowser
51
from string import Template
52
53
import urllib
from urllib.parse import unquote
54
from encodings.punycode import punycode_encode
55
from functools import wraps
André's avatar
André committed
56
from pathlib import Path
57
from packaging.version import Version as V
58

Philipp Hörist's avatar
Philipp Hörist committed
59
from nbxmpp.namespaces import Namespace
60
from nbxmpp.const import Role
Philipp Hörist's avatar
Philipp Hörist committed
61
62
from nbxmpp.const import ConnectionProtocol
from nbxmpp.const import ConnectionType
63
from nbxmpp.structs import ProxyData
Philipp Hörist's avatar
Philipp Hörist committed
64
65
from nbxmpp.protocol import JID
from nbxmpp.protocol import InvalidJid
Philipp Hörist's avatar
Philipp Hörist committed
66
67
from OpenSSL.crypto import load_certificate
from OpenSSL.crypto import FILETYPE_PEM
André's avatar
André committed
68
from gi.repository import Gio
69
from gi.repository import GLib
70
import precis_i18n.codec  # pylint: disable=unused-import
71

72
from gajim.common import app
73
from gajim.common import configpaths
André's avatar
André committed
74
from gajim.common.i18n import Q_
75
from gajim.common.i18n import _
André's avatar
André committed
76
from gajim.common.i18n import ngettext
77
from gajim.common.i18n import get_rfc5646_lang
78
from gajim.common.const import ShowConstant
79
from gajim.common.const import Display
80
81
from gajim.common.const import URIType
from gajim.common.const import URIAction
82
from gajim.common.const import GIO_TLS_ERRORS
83
from gajim.common.const import SHOW_LIST
84
85
from gajim.common.regex import INVALID_XML_CHARS_REGEX
from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
86
from gajim.common.structs import URI
nkour's avatar
nkour committed
87

Philipp Hörist's avatar
Philipp Hörist committed
88

89
90
log = logging.getLogger('gajim.c.helpers')

Philipp Hörist's avatar
Philipp Hörist committed
91
92
93
94
special_groups = (_('Transports'),
                  _('Not in contact list'),
                  _('Observers'),
                  _('Group chats'))
95

96
97
98
URL_REGEX = re.compile(
    r"(www\.(?!\.)|[a-z][a-z0-9+.-]*://)[^\s<>'\"]+[^!,\.\s<>\)'\"\]]")

99

100
class InvalidFormat(Exception):
101
    pass
102

103

104
def parse_jid(jidstring):
105
    try:
106
        return str(validate_jid(jidstring))
107
108
    except Exception as error:
        raise InvalidFormat(error)
109

110
def idn_to_ascii(host):
111
112
113
114
115
116
117
118
    """
    Convert IDN (Internationalized Domain Names) to ACE (ASCII-compatible
    encoding)
    """
    from encodings import idna
    labels = idna.dots.split(host)
    converted_labels = []
    for label in labels:
119
120
121
122
        if label:
            converted_labels.append(idna.ToASCII(label).decode('utf-8'))
        else:
            converted_labels.append('')
123
    return ".".join(converted_labels)
124

125
def ascii_to_idn(host):
126
127
128
129
130
131
132
133
134
135
    """
    Convert ACE (ASCII-compatible encoding) to IDN (Internationalized Domain
    Names)
    """
    from encodings import idna
    labels = idna.dots.split(host)
    converted_labels = []
    for label in labels:
        converted_labels.append(idna.ToUnicode(label))
    return ".".join(converted_labels)
136

137
138
139
140
def puny_encode_url(url):
    _url = url
    if '//' not in _url:
        _url = '//' + _url
141
142
    try:
        o = urllib.parse.urlparse(_url)
André's avatar
André committed
143
        p_loc = idn_to_ascii(o.hostname)
144
145
146
    except Exception:
        log.debug('urlparse failed: %s', url)
        return False
André's avatar
André committed
147
    return url.replace(o.hostname, p_loc)
148

149
def parse_resource(resource):
150
151
152
    """
    Perform stringprep on resource and return it
    """
Philipp Hörist's avatar
Philipp Hörist committed
153
154
155
156
157
158
159
    if not resource:
        return None

    try:
        return resource.encode('OpaqueString').decode('utf-8')
    except UnicodeError:
        raise InvalidFormat('Invalid character in resource.')
160

Philipp Hörist's avatar
Philipp Hörist committed
161
def windowsify(word):
162
    if os.name == 'nt':
Philipp Hörist's avatar
Philipp Hörist committed
163
164
        return word.capitalize()
    return word
165

166
def get_uf_show(show, use_mnemonic=False):
167
168
169
170
171
172
173
    """
    Return a userfriendly string for dnd/xa/chat and make all strings
    translatable

    If use_mnemonic is True, it adds _ so GUI should call with True for
    accessibility issues
    """
174
175
176
    if isinstance(show, ShowConstant):
        show = show.name.lower()

177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
    if show == 'dnd':
        if use_mnemonic:
            uf_show = _('_Busy')
        else:
            uf_show = _('Busy')
    elif show == 'xa':
        if use_mnemonic:
            uf_show = _('_Not Available')
        else:
            uf_show = _('Not Available')
    elif show == 'chat':
        if use_mnemonic:
            uf_show = _('_Free for Chat')
        else:
            uf_show = _('Free for Chat')
    elif show == 'online':
        if use_mnemonic:
194
            uf_show = Q_('?user status:_Available')
195
        else:
196
            uf_show = Q_('?user status:Available')
197
198
199
200
201
202
203
204
205
206
207
208
209
    elif show == 'connecting':
        uf_show = _('Connecting')
    elif show == 'away':
        if use_mnemonic:
            uf_show = _('A_way')
        else:
            uf_show = _('Away')
    elif show == 'offline':
        if use_mnemonic:
            uf_show = _('_Offline')
        else:
            uf_show = _('Offline')
    elif show == 'not in roster':
210
        uf_show = _('Not in contact list')
211
212
213
214
    elif show == 'requested':
        uf_show = Q_('?contact has status:Unknown')
    else:
        uf_show = Q_('?contact has status:Has errors')
215
    return uf_show
nkour's avatar
nkour committed
216

217
def get_uf_sub(sub):
218
219
220
221
222
223
224
225
226
    if sub == 'none':
        uf_sub = Q_('?Subscription we already have:None')
    elif sub == 'to':
        uf_sub = _('To')
    elif sub == 'from':
        uf_sub = _('From')
    elif sub == 'both':
        uf_sub = _('Both')
    else:
227
        uf_sub = _('Unknown')
228

229
    return uf_sub
nkour's avatar
nkour committed
230

231
def get_uf_ask(ask):
232
233
234
235
236
237
    if ask is None:
        uf_ask = Q_('?Ask (for Subscription):None')
    elif ask == 'subscribe':
        uf_ask = _('Subscribe')
    else:
        uf_ask = ask
nkour's avatar
nkour committed
238

239
    return uf_ask
240

241
def get_uf_role(role, plural=False):
242
    ''' plural determines if you get Moderators or Moderator'''
243
244
245
246
    if not isinstance(role, str):
        role = role.value

    if role == 'none':
247
        role_name = Q_('?Group Chat Contact Role:None')
248
    elif role == 'moderator':
249
250
251
252
        if plural:
            role_name = _('Moderators')
        else:
            role_name = _('Moderator')
253
    elif role == 'participant':
254
255
256
257
        if plural:
            role_name = _('Participants')
        else:
            role_name = _('Participant')
258
    elif role == 'visitor':
259
260
261
262
263
        if plural:
            role_name = _('Visitors')
        else:
            role_name = _('Visitor')
    return role_name
264

265
def get_uf_affiliation(affiliation, plural=False):
266
    '''Get a nice and translated affilition for muc'''
267
268
269
270
    if not isinstance(affiliation, str):
        affiliation = affiliation.value

    if affiliation == 'none':
271
        affiliation_name = Q_('?Group Chat Contact Affiliation:None')
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
    elif affiliation == 'owner':
        if plural:
            affiliation_name = _('Owners')
        else:
            affiliation_name = _('Owner')
    elif affiliation == 'admin':
        if plural:
            affiliation_name = _('Administrators')
        else:
            affiliation_name = _('Administrator')
    elif affiliation == 'member':
        if plural:
            affiliation_name = _('Members')
        else:
            affiliation_name = _('Member')
287
    return affiliation_name
288

289
def get_sorted_keys(adict):
290
291
    keys = sorted(adict.keys())
    return keys
292
293

def to_one_line(msg):
294
295
296
297
298
299
300
301
    msg = msg.replace('\\', '\\\\')
    msg = msg.replace('\n', '\\n')
    # s1 = 'test\ntest\\ntest'
    # s11 = s1.replace('\\', '\\\\')
    # s12 = s11.replace('\n', '\\n')
    # s12
    # 'test\\ntest\\\\ntest'
    return msg
302
303

def from_one_line(msg):
304
305
306
307
308
309
310
311
312
313
314
315
316
    # (?<!\\) is a lookbehind assertion which asks anything but '\'
    # to match the regexp that follows it

    # So here match '\\n' but not if you have a '\' before that
    expr = re.compile(r'(?<!\\)\\n')
    msg = expr.sub('\n', msg)
    msg = msg.replace('\\\\', '\\')
    # s12 = 'test\\ntest\\\\ntest'
    # s13 = re.sub('\n', s12)
    # s14 s13.replace('\\\\', '\\')
    # s14
    # 'test\ntest\\ntest'
    return msg
317
318

def get_uf_chatstate(chatstate):
319
320
321
322
323
    """
    Remove chatstate jargon and returns user friendly messages
    """
    if chatstate == 'active':
        return _('is paying attention to the conversation')
324
    if chatstate == 'inactive':
325
        return _('is doing something else')
326
    if chatstate == 'composing':
327
        return _('is composing a message…')
328
    if chatstate == 'paused':
329
330
        #paused means he or she was composing but has stopped for a while
        return _('paused composing a message')
331
    if chatstate == 'gone':
332
333
        return _('has closed the chat window or tab')
    return ''
334

335
def exec_command(command, use_shell=False, posix=True):
336
337
338
339
340
341
342
343
    """
    execute a command. if use_shell is True, we run the command as is it was
    typed in a console. So it may be dangerous if you are not sure about what
    is executed.
    """
    if use_shell:
        subprocess.Popen('%s &' % command, shell=True).wait()
    else:
344
        args = shlex.split(command, posix=posix)
Philipp Hörist's avatar
Philipp Hörist committed
345
346
        process = subprocess.Popen(args)
        app.thread_interface(process.wait)
347
348

def build_command(executable, parameter):
349
350
351
352
353
    # we add to the parameter (can hold path with spaces)
    # "" so we have good parsing from shell
    parameter = parameter.replace('"', '\\"') # but first escape "
    command = '%s "%s"' % (executable, parameter)
    return command
354

355
def get_file_path_from_dnd_dropped_uri(uri):
Yann Leboulanger's avatar
Yann Leboulanger committed
356
    path = urllib.parse.unquote(uri) # escape special chars
357
358
359
360
361
362
363
364
365
    path = path.strip('\r\n\x00') # remove \r\n and NULL
    # get the path to file
    if re.match('^file:///[a-zA-Z]:/', path): # windows
        path = path[8:] # 8 is len('file:///')
    elif path.startswith('file://'): # nautilus, rox
        path = path[7:] # 7 is len('file://')
    elif path.startswith('file:'): # xffm
        path = path[5:] # 5 is len('file:')
    return path
366
367

def sanitize_filename(filename):
368
369
370
371
372
373
    """
    Make sure the filename we will write does contain only acceptable and latin
    characters, and is not too long (in that case hash it)
    """
    # 48 is the limit
    if len(filename) > 48:
374
375
        hash_ = hashlib.md5(filename.encode('utf-8'))
        filename = base64.b64encode(hash_.digest()).decode('utf-8')
376

377
378
    # make it latin chars only
    filename = punycode_encode(filename).decode('utf-8')
379
380
381
382
383
384
385
    filename = filename.replace('/', '_')
    if os.name == 'nt':
        filename = filename.replace('?', '_').replace(':', '_')\
                .replace('\\', '_').replace('"', "'").replace('|', '_')\
                .replace('*', '_').replace('<', '_').replace('>', '_')

    return filename
386

387
def reduce_chars_newlines(text, max_chars=0, max_lines=0):
388
389
390
391
392
393
394
    """
    Cut the chars after 'max_chars' on each line and show only the first
    'max_lines'

    If any of the params is not present (None or 0) the action on it is not
    performed
    """
395
396
397
398
    def _cut_if_long(string_):
        if len(string_) > max_chars:
            string_ = string_[:max_chars - 3] + '…'
        return string_
399
400
401
402
403
404
405
406
407
408
409

    if max_lines == 0:
        lines = text.split('\n')
    else:
        lines = text.split('\n', max_lines)[:max_lines]
    if max_chars > 0:
        if lines:
            lines = [_cut_if_long(e) for e in lines]
    if lines:
        reduced_text = '\n'.join(lines)
        if reduced_text != text:
410
            reduced_text += '…'
411
412
413
    else:
        reduced_text = ''
    return reduced_text
414
415

def get_account_status(account):
416
417
    status = reduce_chars_newlines(account['status_line'], 100, 1)
    return status
418
419

def get_contact_dict_for_account(account):
420
421
422
423
424
425
    """
    Create a dict of jid, nick -> contact with all contacts of account.

    Can be used for completion lists
    """
    contacts_dict = {}
426
    for jid in app.contacts.get_jid_list(account):
Philipp Hörist's avatar
Philipp Hörist committed
427
        contact = app.contacts.get_contact_with_highest_priority(account, jid)
428
429
430
431
432
433
434
        contacts_dict[jid] = contact
        name = contact.name
        if name in contacts_dict:
            contact1 = contacts_dict[name]
            del contacts_dict[name]
            contacts_dict['%s (%s)' % (name, contact1.jid)] = contact1
            contacts_dict['%s (%s)' % (name, jid)] = contact
435
        elif contact.name:
436
            if contact.name == app.get_nick_from_jid(jid):
437
438
439
                del contacts_dict[jid]
            contacts_dict[name] = contact
    return contacts_dict
440

nkour's avatar
nkour committed
441
def play_sound(event):
Philipp Hörist's avatar
Philipp Hörist committed
442
    if not app.settings.get('sounds_on'):
443
        return
444
    play_sound_file(app.settings.get_soundevent_settings(event)['path'])
445

Philipp Hörist's avatar
Philipp Hörist committed
446
def check_soundfile_path(file_, dirs=None):
447
448
449
    """
    Check if the sound file exists

Yann Leboulanger's avatar
Yann Leboulanger committed
450
    :param file_: the file to check, absolute or relative to 'dirs' path
451
452
453
454
    :param dirs: list of knows paths to fallback if the file doesn't exists
                                     (eg: ~/.gajim/sounds/, DATADIR/sounds...).
    :return      the path to file or None if it doesn't exists.
    """
André's avatar
André committed
455
456
457
458
459
    if not file_:
        return None
    if Path(file_).exists():
        return Path(file_)

Philipp Hörist's avatar
Philipp Hörist committed
460
    if dirs is None:
461
462
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
463

Philipp Hörist's avatar
Philipp Hörist committed
464
    for dir_ in dirs:
André's avatar
André committed
465
466
        dir_ = dir_ / 'sounds' / file_
        if dir_.exists():
Philipp Hörist's avatar
Philipp Hörist committed
467
            return dir_
468
    return None
469

470
def strip_soundfile_path(file_, dirs=None, abs_=True):
471
472
473
    """
    Remove knowns paths from a sound file

Philipp Hörist's avatar
Philipp Hörist committed
474
475
476
477
478
479
480
    Filechooser returns an absolute path.
    If path is a known fallback path, we remove it.
    So config has no hardcoded path to DATA_DIR and text in textfield is
    shorther.
    param: file_: the filename to strip
    param: dirs: list of knowns paths from which the filename should be stripped
    param: abs_: force absolute path on dirs
481
    """
Philipp Hörist's avatar
Philipp Hörist committed
482

Yann Leboulanger's avatar
Yann Leboulanger committed
483
    if not file_:
484
485
        return None

Philipp Hörist's avatar
Philipp Hörist committed
486
    if dirs is None:
487
488
        dirs = [configpaths.get('MY_DATA'),
                configpaths.get('DATA')]
Philipp Hörist's avatar
Philipp Hörist committed
489

André's avatar
André committed
490
491
    file_ = Path(file_)
    name = file_.name
Philipp Hörist's avatar
Philipp Hörist committed
492
    for dir_ in dirs:
André's avatar
André committed
493
        dir_ = dir_ / 'sounds' / name
494
        if abs_:
André's avatar
André committed
495
            dir_ = dir_.absolute()
Philipp Hörist's avatar
Philipp Hörist committed
496
        if file_ == dir_:
497
            return name
Yann Leboulanger's avatar
Yann Leboulanger committed
498
    return file_
499

500
def play_sound_file(path_to_soundfile):
501
502
503
    path_to_soundfile = check_soundfile_path(path_to_soundfile)
    if path_to_soundfile is None:
        return
504

André's avatar
André committed
505
    path_to_soundfile = str(path_to_soundfile)
506
507
    if sys.platform == 'win32':
        import winsound
508
509
        try:
            winsound.PlaySound(path_to_soundfile,
510
                               winsound.SND_FILENAME|winsound.SND_ASYNC)
511
        except Exception:
512
            log.exception('Sound Playback Error')
513

514
    elif sys.platform == 'darwin':
Philipp Hörist's avatar
Philipp Hörist committed
515
516
517
518
        try:
            from AppKit import NSSound
        except ImportError:
            log.exception('Sound Playback Error')
519
            return
Philipp Hörist's avatar
Philipp Hörist committed
520

Philipp Hörist's avatar
Philipp Hörist committed
521
522
523
        sound = NSSound.alloc()
        sound.initWithContentsOfFile_byReference_(path_to_soundfile, True)
        sound.play()
524

André's avatar
André committed
525
    elif app.is_installed('GSOUND'):
Philipp Hörist's avatar
Philipp Hörist committed
526
        try:
André's avatar
André committed
527
528
529
            app.gsound_ctx.play_simple({'media.filename' : path_to_soundfile})
        except GLib.Error as error:
            log.error('Could not play sound: %s', error.message)
530

Philipp Hörist's avatar
Philipp Hörist committed
531
532
533
534
535
def get_connection_status(account):
    con = app.connections[account]
    if con.state.is_reconnect_scheduled:
        return 'error'

536
    if con.state.is_connecting or con.state.is_connected:
Philipp Hörist's avatar
Philipp Hörist committed
537
538
539
540
541
542
        return 'connecting'

    if con.state.is_disconnected:
        return 'offline'
    return con.status

543
def get_global_show():
544
    maxi = 0
545
    for account in app.connections:
546
547
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
548
            continue
Philipp Hörist's avatar
Philipp Hörist committed
549
        status = get_connection_status(account)
550
        index = SHOW_LIST.index(status)
Philipp Hörist's avatar
Philipp Hörist committed
551
552
        if index > maxi:
            maxi = index
553
    return SHOW_LIST[maxi]
nkour's avatar
nkour committed
554

Philipp Hörist's avatar
Philipp Hörist committed
555
def get_global_status_message():
556
    maxi = 0
Philipp Hörist's avatar
Philipp Hörist committed
557
    for account, con in app.connections.items():
558
559
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
560
            continue
Philipp Hörist's avatar
Philipp Hörist committed
561
        index = SHOW_LIST.index(con.status)
Philipp Hörist's avatar
Philipp Hörist committed
562
563
        if index > maxi:
            maxi = index
Philipp Hörist's avatar
Philipp Hörist committed
564
            status_message = con.status_message
Philipp Hörist's avatar
Philipp Hörist committed
565
    return status_message
566

567
def statuses_unified():
568
569
570
571
    """
    Test if all statuses are the same
    """
    reference = None
Philipp Hörist's avatar
Philipp Hörist committed
572
    for account, con in app.connections.items():
573
574
        if not app.settings.get_account_setting(account,
                                                'sync_with_global_status'):
575
576
            continue
        if reference is None:
Philipp Hörist's avatar
Philipp Hörist committed
577
578
            reference = con.status
        elif reference != con.status:
579
580
            return False
    return True
581

582
def get_icon_name_to_show(contact, account=None):
583
584
585
    """
    Get the icon name to show in online, away, requested, etc
    """
586
    if account and app.events.get_nb_roster_events(account, contact.jid):
587
        return 'event'
Philipp Hörist's avatar
Philipp Hörist committed
588
589
    if account and app.events.get_nb_roster_events(account,
                                                   contact.get_full_jid()):
590
        return 'event'
591
592
    if account and account in app.interface.minimized_controls and \
    contact.jid in app.interface.minimized_controls[account] and app.interface.\
593
594
            minimized_controls[account][contact.jid].get_nb_unread_pm() > 0:
        return 'event'
595
596
    if account and contact.jid in app.gc_connected[account]:
        if app.gc_connected[account][contact.jid]:
597
598
            return 'muc-active'
        return 'muc-inactive'
599
600
601
602
603
604
    if contact.jid.find('@') <= 0: # if not '@' or '@' starts the jid ==> agent
        return contact.show
    if contact.sub in ('both', 'to'):
        return contact.show
    if contact.ask == 'subscribe':
        return 'requested'
605
    transport = app.get_transport_name_from_jid(contact.jid)
606
607
    if transport:
        return contact.show
Philipp Hörist's avatar
Philipp Hörist committed
608
    if contact.show in SHOW_LIST:
609
        return contact.show
610
    return 'notinroster'
nkour's avatar
nkour committed
611

612
def get_full_jid_from_iq(iq_obj):
613
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
614
    Return the full jid (with resource) from an iq
615
    """
Philipp Hörist's avatar
Philipp Hörist committed
616
617
618
    jid = iq_obj.getFrom()
    if jid is None:
        return None
Yann Leboulanger's avatar
Yann Leboulanger committed
619
    return parse_jid(str(iq_obj.getFrom()))
620
621

def get_jid_from_iq(iq_obj):
622
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
623
    Return the jid (without resource) from an iq
624
625
    """
    jid = get_full_jid_from_iq(iq_obj)
626
    return app.get_jid_without_resource(jid)
627
628

def get_auth_sha(sid, initiator, target):
629
630
631
    """
    Return sha of sid + initiator + target used for proxy auth
    """
Yann Leboulanger's avatar
Yann Leboulanger committed
632
633
    return hashlib.sha1(("%s%s%s" % (sid, initiator, target)).encode('utf-8')).\
        hexdigest()
634

635
636
def remove_invalid_xml_chars(string_):
    if string_:
637
        string_ = re.sub(INVALID_XML_CHARS_REGEX, '', string_)
638
    return string_
639

Philipp Hörist's avatar
Philipp Hörist committed
640
def get_random_string(count=16):
641
    """
Philipp Hörist's avatar
Philipp Hörist committed
642
643
644
    Create random string of count length

    WARNING: Don't use this for security purposes
645
    """
Philipp Hörist's avatar
Philipp Hörist committed
646
647
    allowed = string.ascii_uppercase + string.digits
    return ''.join(random.choice(allowed) for char in range(count))
648

Philipp Hörist's avatar
Philipp Hörist committed
649
@functools.lru_cache(maxsize=1)
650
def get_os_info():
Philipp Hörist's avatar
Philipp Hörist committed
651
652
653
654
655
    info = 'N/A'
    if sys.platform in ('win32', 'darwin'):
        info = f'{platform.system()} {platform.release()}'

    elif sys.platform == 'linux':
656
657
        try:
            import distro
Philipp Hörist's avatar
Philipp Hörist committed
658
            info = distro.name(pretty=True)
659
        except ImportError:
Philipp Hörist's avatar
Philipp Hörist committed
660
661
            info = platform.system()
    return info
662

663
def allow_showing_notification(account):
664
    if not app.settings.get('show_notifications'):
665
        return False
Philipp Hörist's avatar
Philipp Hörist committed
666
    if app.settings.get('autopopupaway'):
667
        return True
668
    if app.account_is_available(account):
669
670
        return True
    return False
671

672
def allow_popup_window(account):
673
674
675
    """
    Is it allowed to popup windows?
    """
Philipp Hörist's avatar
Philipp Hörist committed
676
677
    autopopup = app.settings.get('autopopup')
    autopopupaway = app.settings.get('autopopupaway')
678
    if autopopup and (autopopupaway or \
Philipp Hörist's avatar
Philipp Hörist committed
679
    app.connections[account].status in ('online', 'chat')):
680
681
        return True
    return False
682

683
def allow_sound_notification(account, sound_event):
Philipp Hörist's avatar
Philipp Hörist committed
684
    if (app.settings.get('sounddnd') or
Philipp Hörist's avatar
Philipp Hörist committed
685
            app.connections[account].status != 'dnd' and
686
            app.settings.get_soundevent_settings(sound_event)['enabled']):
687
688
        return True
    return False
689

690
def get_notification_icon_tooltip_dict():
691
692
693
694
    """
    Return a dict of the form {acct: {'show': show, 'message': message,
    'event_lines': [list of text lines to show in tooltip]}
    """
Philipp Hörist's avatar
Philipp Hörist committed
695
    # How many events before we show summarized, not per-user
696
697
698
699
700
701
702
703
704
    max_ungrouped_events = 10

    accounts = get_accounts_info()

    # Gather events. (With accounts, when there are more.)
    for account in accounts:
        account_name = account['name']
        account['event_lines'] = []
        # Gather events per-account
705
        pending_events = app.events.get_events(account=account_name)
Philipp Hörist's avatar
Philipp Hörist committed
706
707
        messages, non_messages = {}, {}
        total_messages, total_non_messages = 0, 0
708
709
710
711
712
713
714
715
716
717
718
719
720
        for jid in pending_events:
            for event in pending_events[jid]:
                if event.type_.count('file') > 0:
                    # This is a non-messagee event.
                    messages[jid] = non_messages.get(jid, 0) + 1
                    total_non_messages = total_non_messages + 1
                else:
                    # This is a message.
                    messages[jid] = messages.get(jid, 0) + 1
                    total_messages = total_messages + 1
        # Display unread messages numbers, if any
        if total_messages > 0:
            if total_messages > max_ungrouped_events:
Philipp Hörist's avatar
Philipp Hörist committed
721
722
723
724
725
                text = ngettext('%d message pending',
                                '%d messages pending',
                                total_messages,
                                total_messages,
                                total_messages)
726
727
                account['event_lines'].append(text)
            else:
728
                for jid, msg in messages.items():
Philipp Hörist's avatar
Philipp Hörist committed
729
730
                    text = ngettext('%d message pending',
                                    '%d messages pending',
731
                                    msg, msg, msg)
732
                    contact = app.contacts.get_first_contact_from_jid(
Philipp Hörist's avatar
Philipp Hörist committed
733
                        account['name'], jid)
Yann Leboulanger's avatar
Yann Leboulanger committed
734
                    text += ' '
735
                    if jid in app.gc_connected[account['name']]:
736
                        text += _('from group chat %s') % (jid)
737
738
                    elif contact:
                        name = contact.get_shown_name()
Yann Leboulanger's avatar
Yann Leboulanger committed
739
                        text += _('from user %s') % (name)
740
                    else:
Yann Leboulanger's avatar
Yann Leboulanger committed
741
                        text += _('from %s') % (jid)
742
743
744
745
746
                    account['event_lines'].append(text)

        # Display unseen events numbers, if any
        if total_non_messages > 0:
            if total_non_messages > max_ungrouped_events:
Philipp Hörist's avatar
Philipp Hörist committed
747
748
749
750
751
                text = ngettext('%d event pending',
                                '%d events pending',
                                total_non_messages,
                                total_non_messages,
                                total_non_messages)
752
753
                account['event_lines'].append(text)
            else:
754
                for jid, msg in non_messages.items():
Philipp Hörist's avatar
Philipp Hörist committed
755
756
                    text = ngettext('%d event pending',
                                    '%d events pending',
757
                                    msg, msg, msg)
Yann Leboulanger's avatar
Yann Leboulanger committed
758
                    text += ' ' + _('from user %s') % (jid)
759
760
761
                    account[account]['event_lines'].append(text)

    return accounts
762

nkour's avatar
nkour committed
763
def get_accounts_info():
764
765
766
767
    """
    Helper for notification icon tooltip
    """
    accounts = []
768
    accounts_list = sorted(app.contacts.get_accounts())
769
    for account in accounts_list:
770
771

        status = get_connection_status(account)
Philipp Hörist's avatar
Philipp Hörist committed
772
        message = app.connections[account].status_message
773
774
775
776
777
778
779
        single_line = get_uf_show(status)
        if message is None:
            message = ''
        else:
            message = message.strip()
        if message != '':
            single_line += ': ' + message
780
        account_label = app.get_account_label(account)
781
        accounts.append({'name': account,
782
                         'account_label': account_label,
783
784
785
                         'status_line': single_line,
                         'show': status,
                         'message': message})
786
    return accounts
787

788
789
790
def get_current_show(account):
    if account not in app.connections:
        return 'offline'
Philipp Hörist's avatar
Philipp Hörist committed
791
    return app.connections[account].status
steve-e's avatar
steve-e committed
792

793
794
def get_optional_features(account):
    features = []
795
796

    if app.settings.get_account_setting(account, 'request_user_data'):
Philipp Hörist's avatar
Philipp Hörist committed
797
798
799
        features.append(Namespace.MOOD + '+notify')
        features.append(Namespace.ACTIVITY + '+notify')
        features.append(Namespace.TUNE + '+notify')
Philipp Hörist's avatar
Philipp Hörist committed
800
        features.append(Namespace.LOCATION + '+notify')
801
802
803

    features.append(Namespace.NICK + '+notify')

804
805
806
    if app.connections[account].get_module('Bookmarks').nativ_bookmarks_used:
        features.append(Namespace.BOOKMARKS_1 + '+notify')
    elif app.connections[account].get_module('Bookmarks').pep_bookmarks_used:
Philipp Hörist's avatar
Philipp Hörist committed
807
        features.append(Namespace.BOOKMARKS + '+notify')
808
    if app.is_installed('AV'):
Philipp Hörist's avatar
Philipp Hörist committed
809
810
811
812
        features.append(Namespace.JINGLE_RTP)
        features.append(Namespace.JINGLE_RTP_AUDIO)
        features.append(Namespace.JINGLE_RTP_VIDEO)
        features.append(Namespace.JINGLE_ICE_UDP)
813
814
815
816

    # Give plugins the possibility to add their features
    app.plugin_manager.extension_point('update_caps', account, features)
    return features
817

818
def jid_is_blocked(account, jid):
819
    con = app.connections[account]
Philipp Hörist's avatar
Philipp Hörist committed
820
    return jid in con.get_module('Blocking').blocked
821

822
def get_subscription_request_msg(account=None):
823
    s = app.settings.get_account_setting(account, 'subscription_request_msg')
824
825
826
827
828
    if s:
        return s
    s = _('I would like to add you to my contact list.')
    if account:
        s = _('Hello, I am $name.') + ' ' + s
829
        s = Template(s).safe_substitute({'name': app.nicks[account]})
830
        return s
831

832
def get_user_proxy(account):
833
    proxy_name = app.settings.get_account_setting(account, 'proxy')
834
835
    if not proxy_name:
        return None
836
    return get_proxy(proxy_name)
837

838
def get_proxy(proxy_name):
839
840
841
    try:
        settings = app.settings.get_proxy_settings(proxy_name)
    except ValueError:
842
843
844
        return None

    username, password = None, None
845
846
    if settings['useauth']:
        username, password = settings['user'], settings['pass']
847

848
849
    return ProxyData(type=settings['type'],
                     host='%s:%s' % (settings['host'], settings['port']),
850
851
852
                     username=username,
                     password=password)

Philipp Hörist's avatar
Philipp Hörist committed
853
854
855
856
def version_condition(current_version, required_version):
    if V(current_version) < V(required_version):
        return False
    return True
857
858

def get_available_emoticon_themes():
Philipp Hörist's avatar
Philipp Hörist committed
859
    files = []
André's avatar
André committed
860
    for folder in configpaths.get('EMOTICONS').iterdir():
861
862
        if not folder.is_dir():
            continue
André's avatar
André committed
863
864
865
866
867
868
869
870
871
        files += [theme for theme in folder.iterdir() if theme.is_file()]

    my_emots = configpaths.get('MY_EMOTS')
    if my_emots.is_dir():
        files += list(my_emots.iterdir())

    emoticons_themes = ['font']
    emoticons_themes += [file.stem for file in files if file.suffix == '.png']
    return sorted(emoticons_themes)
872

Philipp Hörist's avatar
Philipp Hörist committed
873
874
875
def call_counter(func):
    def helper(self, restart=False):
        if restart:
Philipp Hörist's avatar
Philipp Hörist committed
876
877
            self._connect_machine_calls = 0
        self._connect_machine_calls += 1
Philipp Hörist's avatar
Philipp Hörist committed
878
        return func(self)
Philipp Hörist's avatar
Philipp Hörist committed
879
    return helper
880

881
882
def load_json(path, key=None, default=None):
    try:
883
        with path.open('r') as file:
884
885
886
887
888
889
890
891
            json_dict = json.loads(file.read())
    except Exception:
        log.exception('Parsing error')
        return default

    if key is None:
        return json_dict
    return json_dict.get(key, default)
892

Philipp Hörist's avatar
Philipp Hörist committed
893
894
895
def ignore_contact(account, jid):
    jid = str(jid)
    known_contact = app.contacts.get_contacts(account, jid)
896
897
    ignore = app.settings.get_account_setting(account,
                                              'ignore_unknown_contacts')
Philipp Hörist's avatar
Philipp Hörist committed
898
899
900
901
902
    if ignore and not known_contact:
        log.info('Ignore unknown contact %s', jid)
        return True
    return False

903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
class AdditionalDataDict(collections.UserDict):
    def __init__(self, initialdata=None):
        collections.UserDict.__init__(self, initialdata)

    @staticmethod
    def _get_path_childs(full_path):
        path_childs = [full_path]
        if ':' in full_path:
            path_childs = full_path.split(':')
        return path_childs

    def set_value(self, full_path, key, value):
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                _dict[path] = {}
                _dict = _dict[path]
        _dict[key] = value

    def get_value(self, full_path, key, default=None):
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                return default
        try:
            return _dict[key]
        except KeyError:
            return default

    def remove_value(self, full_path, key):
        path_childs = self._get_path_childs(full_path)
        _dict = self.data
        for path in path_childs:
            try:
                _dict = _dict[path]
            except KeyError:
                return
        try:
            del _dict[key]
        except KeyError:
            return
950

Philipp Hörist's avatar
Philipp Hörist committed
951
952
953
    def copy(self):
        return copy.deepcopy(self)

954
955

def save_roster_position(window):
Philipp Hörist's avatar
Philipp Hörist committed
956
    if not app.settings.get('save-roster-position'):
957
958
959
960
961
        return
    if app.is_display(Display.WAYLAND):
        return
    x_pos, y_pos = window.get_position()
    log.debug('Save roster position: %s %s', x_pos, y_pos)
Philipp Hörist's avatar
Philipp Hörist committed
962
963
    app.settings.set('roster_x-position', x_pos)
    app.settings.set('roster_y-position', y_pos)
Philipp Hörist's avatar
Philipp Hörist committed
964
965
966


class Singleton(type):