diff libervia/frontends/quick_frontend/quick_app.py @ 4074:26b7ed2817da

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents sat_frontends/quick_frontend/quick_app.py@4b842c1fb686
children 6b581d4c249f
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_app.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1387 @@
+#!/usr/bin/env python3
+
+# helper class for making a SAT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program 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 Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools import trigger
+from libervia.backend.tools.common import data_format
+
+from libervia.frontends.tools import jid
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend import quick_menus
+from libervia.frontends.quick_frontend import quick_blog
+from libervia.frontends.quick_frontend import quick_chat, quick_games
+from libervia.frontends.quick_frontend import quick_contact_list
+from libervia.frontends.quick_frontend.constants import Const as C
+
+import sys
+import time
+
+
+log = getLogger(__name__)
+
+
+class ProfileManager(object):
+    """Class managing all data relative to one profile, and plugging in mechanism"""
+
+    # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
+    #       and a way to keep some XMLUI request between sessions is expected in backend
+    host = None
+    bridge = None
+    cache_keys_to_get = ['avatar', 'nicknames']
+
+    def __init__(self, profile):
+        self.profile = profile
+        self.connected = False
+        self.whoami = None
+        self.notifications = {}  # key: bare jid or '' for general, value: notif data
+
+    @property
+    def autodisconnect(self):
+        try:
+            autodisconnect = self._autodisconnect
+        except AttributeError:
+            autodisconnect = False
+        return autodisconnect
+
+    def plug(self):
+        """Plug the profile to the host"""
+        # first of all we create the contact lists
+        self.host.contact_lists.add_profile(self.profile)
+
+        # we get the essential params
+        self.bridge.param_get_a_async(
+            "JabberID",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_jid,
+            errback=self._get_param_error,
+        )
+
+    def _plug_profile_jid(self, jid_s):
+        self.whoami = jid.JID(jid_s)  # resource might change after the connection
+        log.info(f"Our current jid is: {self.whoami}")
+        self.bridge.is_connected(self.profile, callback=self._plug_profile_isconnected)
+
+    def _autodisconnect_eb(self, failure_):
+        # XXX: we ignore error on this parameter, as Libervia can't access it
+        log.warning(
+            _("Error while trying to get autodisconnect param, ignoring: {}").format(
+                failure_
+            )
+        )
+        self._plug_profile_autodisconnect("false")
+
+    def _plug_profile_isconnected(self, connected):
+        self.connected = connected
+        if connected:
+            self.host.profile_connected(self.profile)
+        self.bridge.param_get_a_async(
+            "autodisconnect",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_autodisconnect,
+            errback=self._autodisconnect_eb,
+        )
+
+    def _plug_profile_autodisconnect(self, autodisconnect):
+        if C.bool(autodisconnect):
+            self._autodisconnect = True
+        self.bridge.param_get_a_async(
+            "autoconnect",
+            "Connection",
+            profile_key=self.profile,
+            callback=self._plug_profile_autoconnect,
+            errback=self._get_param_error,
+        )
+
+    def _plug_profile_autoconnect(self, value_str):
+        autoconnect = C.bool(value_str)
+        if autoconnect and not self.connected:
+            self.host.connect(
+                self.profile, callback=lambda __: self._plug_profile_afterconnect()
+            )
+        else:
+            self._plug_profile_afterconnect()
+
+    def _plug_profile_afterconnect(self):
+        # Profile can be connected or not
+        # we get cached data
+        self.connected = True
+        self.host.bridge.features_get(
+            profile_key=self.profile,
+            callback=self._plug_profile_get_features_cb,
+            errback=self._plug_profile_get_features_eb,
+        )
+
+    def _plug_profile_get_features_eb(self, failure):
+        log.error("Couldn't get features: {}".format(failure))
+        self._plug_profile_get_features_cb({})
+
+    def _plug_profile_get_features_cb(self, features):
+        self.host.features = features
+        self.host.bridge.entities_data_get([], ProfileManager.cache_keys_to_get,
+                                         profile=self.profile,
+                                         callback=self._plug_profile_got_cached_values,
+                                         errback=self._plug_profile_failed_cached_values)
+
+    def _plug_profile_failed_cached_values(self, failure):
+        log.error("Couldn't get cached values: {}".format(failure))
+        self._plug_profile_got_cached_values({})
+
+    def _plug_profile_got_cached_values(self, cached_values):
+        contact_list = self.host.contact_lists[self.profile]
+        # add the contact list and its listener
+        for entity_s, data in cached_values.items():
+            for key, value in data.items():
+                self.host.entity_data_updated_handler(entity_s, key, value, self.profile)
+
+        if not self.connected:
+            self.host.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=self.profile)
+        else:
+
+            contact_list.fill()
+            self.host.set_presence_status(profile=self.profile)
+
+            # The waiting subscription requests
+            self.bridge.sub_waiting_get(
+                self.profile, callback=self._plug_profile_got_waiting_sub
+            )
+
+    def _plug_profile_got_waiting_sub(self, waiting_sub):
+        for sub in waiting_sub:
+            self.host.subscribe_handler(waiting_sub[sub], sub, self.profile)
+
+        self.bridge.muc_get_rooms_joined(
+            self.profile, callback=self._plug_profile_got_rooms_joined
+        )
+
+    def _plug_profile_got_rooms_joined(self, rooms_args):
+        # Now we open the MUC window where we already are:
+        for room_args in rooms_args:
+            self.host.muc_room_joined_handler(*room_args, profile=self.profile)
+        # Presence must be requested after rooms are filled
+        self.host.bridge.presence_statuses_get(
+            self.profile, callback=self._plug_profile_got_presences
+        )
+
+    def _plug_profile_got_presences(self, presences):
+        for contact in presences:
+            for res in presences[contact]:
+                jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact
+                show = presences[contact][res][0]
+                priority = presences[contact][res][1]
+                statuses = presences[contact][res][2]
+                self.host.presence_update_handler(
+                    jabber_id, show, priority, statuses, self.profile
+                )
+
+        # At this point, profile should be fully plugged
+        # and we launch frontend specific method
+        self.host.profile_plugged(self.profile)
+
+    def _get_param_error(self, failure):
+        log.error(_("Can't get profile parameter: {msg}").format(msg=failure))
+
+
+class ProfilesManager(object):
+    """Class managing collection of profiles"""
+
+    def __init__(self):
+        self._profiles = {}
+
+    def __contains__(self, profile):
+        return profile in self._profiles
+
+    def __iter__(self):
+        return iter(self._profiles.keys())
+
+    def __getitem__(self, profile):
+        return self._profiles[profile]
+
+    def __len__(self):
+        return len(self._profiles)
+
+    def items(self):
+        return self._profiles.items()
+
+    def values(self):
+        return self._profiles.values()
+
+    def plug(self, profile):
+        if profile in self._profiles:
+            raise exceptions.ConflictError(
+                "A profile of the name [{}] is already plugged".format(profile)
+            )
+        self._profiles[profile] = ProfileManager(profile)
+        self._profiles[profile].plug()
+
+    def unplug(self, profile):
+        if profile not in self._profiles:
+            raise ValueError("The profile [{}] is not plugged".format(profile))
+
+        # remove the contact list and its listener
+        host = self._profiles[profile].host
+        host.contact_lists[profile].unplug()
+
+        del self._profiles[profile]
+
+    def choose_one_profile(self):
+        return list(self._profiles.keys())[0]
+
+
+class QuickApp(object):
+    """This class contain the main methods needed for the frontend"""
+
+    MB_HANDLER = True  #: Set to False if the frontend doesn't manage microblog
+    AVATARS_HANDLER = True  #: set to False if avatars are not used
+    ENCRYPTION_HANDLERS = True  #: set to False if encryption is handled separatly
+    #: if True, QuickApp will call resync itself, on all widgets at the same time
+    #: if False, frontend must call resync itself when suitable (e.g. widget is being
+    #: visible)
+    AUTO_RESYNC = True
+
+    def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True):
+        """Create a frontend application
+
+        @param bridge_factory: method to use to create the bridge
+        @param xmlui: xmlui module
+        @param check_options: method to call to check options (usually command line
+            arguments)
+        """
+        self.xmlui = xmlui
+        self.menus = quick_menus.QuickMenusManager(self)
+        ProfileManager.host = self
+        self.profiles = ProfilesManager()
+        # profiles currently being plugged, used to (un)lock contact list updates
+        self._plugs_in_progress = set()
+        self.ready_profiles = set()  # profiles which are connected and ready
+        self.signals_cache = {}  # used to keep signal received between start of
+                                 # plug_profile and when the profile is actualy ready
+        self.contact_lists = quick_contact_list.QuickContactListHandler(self)
+        self.widgets = quick_widgets.QuickWidgetsManager(self)
+        if check_options is not None:
+            self.options = check_options()
+        else:
+            self.options = None
+
+        # see selected_widget setter and getter
+        self._selected_widget = None
+
+        # listeners are callable watching events
+        self._listeners = {}  # key: listener type ("avatar", "selected", etc),
+                              # value: list of callbacks
+
+        # triggers
+        self.trigger = (
+            trigger.TriggerManager()
+        )  # trigger are used to change the default behaviour
+
+        ## bridge ##
+        self.bridge = bridge_factory()
+        ProfileManager.bridge = self.bridge
+        if connect_bridge:
+            self.connect_bridge()
+
+        # frontend notifications
+        self._notif_id = 0
+        self._notifications = {}
+        # watched progresses and associated callbacks
+        self._progress_ids = {}
+        # available features
+        # FIXME: features are profile specific, to be checked
+        self.features = None
+        #: map of short name to namespaces
+        self.ns_map = {}
+        #: available encryptions
+        self.encryption_plugins = []
+        # state of synchronisation with backend
+        self._sync = True
+
+    def connect_bridge(self):
+        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
+
+    def _namespaces_get_cb(self, ns_map):
+        self.ns_map = ns_map
+
+    def _namespaces_get_eb(self, failure_):
+        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
+
+    def _encryption_plugins_get_cb(self, plugins_ser):
+        self.encryption_plugins = data_format.deserialise(plugins_ser, type_check=list)
+
+    def _encryption_plugins_get_eb(self, failure_):
+        log.warning(_("Can't retrieve encryption plugins: {msg}").format(msg=failure_))
+
+    def on_bridge_connected(self):
+        self.bridge.ready_get(self.on_backend_ready)
+
+    def _bridge_cb(self):
+        self.register_signal("connected")
+        self.register_signal("disconnected")
+        self.register_signal("action_new")
+        self.register_signal("contact_new")
+        self.register_signal("message_new")
+        if self.ENCRYPTION_HANDLERS:
+            self.register_signal("message_encryption_started")
+            self.register_signal("message_encryption_stopped")
+        self.register_signal("presence_update")
+        self.register_signal("subscribe")
+        self.register_signal("param_update")
+        self.register_signal("contact_deleted")
+        self.register_signal("entity_data_updated")
+        self.register_signal("progress_started")
+        self.register_signal("progress_finished")
+        self.register_signal("progress_error")
+        self.register_signal("muc_room_joined", iface="plugin")
+        self.register_signal("muc_room_left", iface="plugin")
+        self.register_signal("muc_room_user_changed_nick", iface="plugin")
+        self.register_signal("muc_room_new_subject", iface="plugin")
+        self.register_signal("chat_state_received", iface="plugin")
+        self.register_signal("message_state", iface="plugin")
+        self.register_signal("ps_event", iface="plugin")
+        # useful for debugging
+        self.register_signal("_debug", iface="core")
+
+        # FIXME: do it dynamically
+        quick_games.Tarot.register_signals(self)
+        quick_games.Quiz.register_signals(self)
+        quick_games.Radiocol.register_signals(self)
+        self.on_bridge_connected()
+
+    def _bridge_eb(self, failure):
+        if isinstance(failure, exceptions.BridgeExceptionNoService):
+            print((_("Can't connect to SàT backend, are you sure it's launched ?")))
+            sys.exit(C.EXIT_BACKEND_NOT_FOUND)
+        elif isinstance(failure, exceptions.BridgeInitError):
+            print((_("Can't init bridge")))
+            sys.exit(C.EXIT_BRIDGE_ERROR)
+        else:
+            print((_("Error while initialising bridge: {}".format(failure))))
+
+    def on_backend_ready(self):
+        log.info("backend is ready")
+        self.bridge.namespaces_get(
+            callback=self._namespaces_get_cb, errback=self._namespaces_get_eb)
+        # we cache available encryption plugins, as we'll use them on each
+        # new chat widget
+        self.bridge.encryption_plugins_get(
+            callback=self._encryption_plugins_get_cb,
+            errback=self._encryption_plugins_get_eb)
+
+
+    @property
+    def current_profile(self):
+        """Profile that a user would expect to use"""
+        try:
+            return self.selected_widget.profile
+        except (TypeError, AttributeError):
+            return self.profiles.choose_one_profile()
+
+    @property
+    def visible_widgets(self):
+        """Widgets currently visible
+
+        This must be implemented by frontend
+        @return (iter[object]): iterable on visible widgets
+            widgets can be QuickWidgets or not
+        """
+        raise NotImplementedError
+
+    @property
+    def visible_quick_widgets(self):
+        """QuickWidgets currently visible
+
+        This generator iterate only on QuickWidgets, discarding other kinds of
+        widget the frontend may have.
+        @return (iter[object]): iterable on visible widgets
+        """
+        for w in self.visisble_widgets:
+            if isinstance(w, quick_widgets.QuickWidget):
+                return w
+
+    @property
+    def selected_widget(self):
+        """widget currently selected
+
+        This must be set by frontend using setter.
+        """
+        return self._selected_widget
+
+    @selected_widget.setter
+    def selected_widget(self, wid):
+        """Set the currently selected widget
+
+        Must be set by frontend
+        """
+        if self._selected_widget == wid:
+            return
+        self._selected_widget = wid
+        try:
+            on_selected = wid.on_selected
+        except AttributeError:
+            pass
+        else:
+            on_selected()
+
+        self.call_listeners("selected", wid)
+
+    # backend state management
+
+    @property
+    def sync(self):
+        """Synchronization flag
+
+        True if this frontend is synchronised with backend
+        """
+        return self._sync
+
+    @sync.setter
+    def sync(self, state):
+        """Called when backend is desynchronised or resynchronising
+
+        @param state(bool): True: if the backend is resynchronising
+            False when we lose synchronisation, for instance if frontend is going to sleep
+            or if connection has been lost and a reconnection is needed
+        """
+        if state:
+            log.debug("we are synchronised with server")
+            if self.AUTO_RESYNC:
+                # we are resynchronising all widgets
+                log.debug("doing a full widgets resynchronisation")
+                for w in self.widgets:
+                    try:
+                        resync = w.resync
+                    except AttributeError:
+                        pass
+                    else:
+                        resync()
+                self.contact_lists.fill()
+
+            self._sync = state
+        else:
+            log.debug("we have lost synchronisation with server")
+            self._sync = state
+            # we've lost synchronisation, all widgets must be notified
+            # note: this is always called independently of AUTO_RESYNC
+            for w in self.widgets:
+                try:
+                    w.sync = False
+                except AttributeError:
+                    pass
+
+    def register_signal(
+        self, function_name, handler=None, iface="core", with_profile=True
+    ):
+        """Register a handler for a signal
+
+        @param function_name (str): name of the signal to handle
+        @param handler (instancemethod): method to call when the signal arrive,
+            None for calling an automatically named handler (function_name + 'Handler')
+        @param iface (str): interface of the bridge to use ('core' or 'plugin')
+        @param with_profile (boolean): True if the signal concerns a specific profile,
+            in that case the profile name has to be passed by the caller
+        """
+        log.debug("registering signal {name}".format(name=function_name))
+        if handler is None:
+            handler = getattr(self, "{}{}".format(function_name, "_handler"))
+        if not with_profile:
+            self.bridge.register_signal(function_name, handler, iface)
+            return
+
+        def signal_received(*args, **kwargs):
+            profile = kwargs.get("profile")
+            if profile is None:
+                if not args:
+                    raise exceptions.ProfileNotSetError
+                profile = args[-1]
+            if profile is not None:
+                if not self.check_profile(profile):
+                    if profile in self.profiles:
+                        # profile is not ready but is in self.profiles, that's mean that
+                        # it's being connecting and we need to cache the signal
+                        self.signals_cache.setdefault(profile, []).append(
+                            (function_name, handler, args, kwargs)
+                        )
+                    return  # we ignore signal for profiles we don't manage
+            handler(*args, **kwargs)
+
+        self.bridge.register_signal(function_name, signal_received, iface)
+
+    def addListener(self, type_, callback, profiles_filter=None):
+        """Add a listener for an event
+
+        /!\ don't forget to remove listener when not used anymore (e.g. if you delete a
+            widget)
+        @param type_: type of event, can be:
+            - contactsFilled: called when contact have been fully filled for a profiles
+                kwargs: profile
+            - avatar: called when avatar data is updated
+                args: (entity, avatar_data, profile)
+            - nicknames: called when nicknames data is updated
+                args: (entity, nicknames, profile)
+            - presence: called when a presence is received
+                args: (entity, show, priority, statuses, profile)
+            - selected: called when a widget is selected
+                args: (selected_widget,)
+            - notification: called when a new notification is emited
+                args: (entity, notification_data, profile)
+            - notificationsClear: called when notifications are cleared
+                args: (entity, type_, profile)
+            - widgetNew: a new QuickWidget has been created
+                args: (widget,)
+            - widgetDeleted: all instances of a widget with specific hash have been
+                deleted
+                args: (widget_deleted,)
+            - menu: called when a menu item is added or removed
+                args: (type_, path, path_i18n, item) were values are:
+                    type_: same as in [sat.core.sat_main.SAT.import_menu]
+                    path: same as in [sat.core.sat_main.SAT.import_menu]
+                    path_i18n: translated path (or None if the item is removed)
+                    item: instance of quick_menus.MenuItemBase or None if the item is
+                          removed
+            - gotMenus: called only once when menu are available (no arg)
+            - progress_finished: called when a progressing action has just finished
+                args:  (progress_id, metadata, profile)
+            - progress_error: called when a progressing action failed
+                args: (progress_id, error_msg, profile):
+        @param callback: method to call on event
+        @param profiles_filter (set[unicode]): if set and not empty, the
+            listener will be callable only by one of the given profiles.
+        """
+        assert type_ in C.LISTENERS
+        self._listeners.setdefault(type_, {})[callback] = profiles_filter
+
+    def removeListener(self, type_, callback, ignore_missing=False):
+        """Remove a callback from listeners
+
+        @param type_(str): same as for [addListener]
+        @param callback(callable): callback to remove
+        @param ignore_missing(bool): if True, don't log error if the listener doesn't
+            exist
+        """
+        assert type_ in C.LISTENERS
+        try:
+            self._listeners[type_].pop(callback)
+        except KeyError:
+            if not ignore_missing:
+                log.error(
+                    f"Trying to remove an inexisting listener (type = {type_}): "
+                    f"{callback}")
+
+    def call_listeners(self, type_, *args, **kwargs):
+        """Call the methods which listen type_ event. If a profiles filter has
+        been register with a listener and profile argument is not None, the
+        listener will be called only if profile is in the profiles filter list.
+
+        @param type_: same as for [addListener]
+        @param *args: arguments sent to callback
+        @param **kwargs: keywords argument, mainly used to pass "profile" when needed
+        """
+        assert type_ in C.LISTENERS
+        try:
+            listeners = self._listeners[type_]
+        except KeyError:
+            pass
+        else:
+            profile = kwargs.get("profile")
+            for listener, profiles_filter in list(listeners.items()):
+                if profile is None or not profiles_filter or profile in profiles_filter:
+                    listener(*args, **kwargs)
+
+    def check_profile(self, profile):
+        """Tell if the profile is currently followed by the application, and ready"""
+        return profile in self.ready_profiles
+
+    def post_init(self, profile_manager):
+        """Must be called after initialization is done, do all automatic task
+
+        (auto plug profile)
+        @param profile_manager: instance of a subclass of
+            Quick_frontend.QuickProfileManager
+        """
+        if self.options and self.options.profile:
+            profile_manager.autoconnect([self.options.profile])
+
+    def profile_plugged(self, profile):
+        """Method called when the profile is fully plugged
+
+        This will launch frontend specific workflow
+
+        /!\ if you override the method and don't call the parent, be sure to add the
+            profile to ready_profiles ! if you don't, all signals will stay in cache
+
+        @param profile(unicode): %(doc_profile)s
+        """
+        self._plugs_in_progress.remove(profile)
+        self.ready_profiles.add(profile)
+
+        # profile is ready, we can call send signals that where is cache
+        cached_signals = self.signals_cache.pop(profile, [])
+        for function_name, handler, args, kwargs in cached_signals:
+            log.debug(
+                "Calling cached signal [%s] with args %s and kwargs %s"
+                % (function_name, args, kwargs)
+            )
+            handler(*args, **kwargs)
+
+        self.call_listeners("profile_plugged", profile=profile)
+        if not self._plugs_in_progress:
+            self.contact_lists.lock_update(False)
+
+    def profile_connected(self, profile):
+        """Called when a plugged profile is connected
+
+        it is called independently of profile_plugged (may be called before or after
+        profile_plugged)
+        """
+        pass
+
+    def connect(self, profile, callback=None, errback=None):
+        if not callback:
+            callback = lambda __: None
+        if not errback:
+
+            def errback(failure):
+                log.error(_("Can't connect profile [%s]") % failure)
+                try:
+                    module = failure.module
+                except AttributeError:
+                    module = ""
+                try:
+                    message = failure.message
+                except AttributeError:
+                    message = "error"
+                try:
+                    fullname = failure.fullname
+                except AttributeError:
+                    fullname = "error"
+                if (
+                    module.startswith("twisted.words.protocols.jabber")
+                    and failure.condition == "not-authorized"
+                ):
+                    self.action_launch(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
+                else:
+                    self.show_dialog(message, fullname, "error")
+
+        self.bridge.connect(profile, callback=callback, errback=errback)
+
+    def plug_profiles(self, profiles):
+        """Tell application which profiles must be used
+
+        @param profiles: list of valid profile names
+        """
+        self.contact_lists.lock_update()
+        self._plugs_in_progress.update(profiles)
+        self.plugging_profiles()
+        for profile in profiles:
+            self.profiles.plug(profile)
+
+    def plugging_profiles(self):
+        """Method to subclass to manage frontend specific things to do
+
+        will be called when profiles are choosen and are to be plugged soon
+        """
+        pass
+
+    def unplug_profile(self, profile):
+        """Tell the application to not follow anymore the profile"""
+        if not profile in self.profiles:
+            raise ValueError("The profile [{}] is not plugged".format(profile))
+        self.profiles.unplug(profile)
+
+    def clear_profile(self):
+        self.profiles.clear()
+
+    def new_widget(self, widget):
+        raise NotImplementedError
+
+    # bridge signals hanlers
+
+    def connected_handler(self, jid_s, profile):
+        """Called when the connection is made.
+
+        @param jid_s (unicode): the JID that we were assigned by the server,
+            as the resource might differ from the JID we asked for.
+        """
+        log.debug(_("Connected"))
+        self.profiles[profile].whoami = jid.JID(jid_s)
+        self.set_presence_status(profile=profile)
+        # FIXME: fill() is already called for all profiles when doing self.sync = True
+        #        a per-profile fill() should be done once, see below note
+        self.contact_lists[profile].fill()
+        # if we were already displaying widgets, they must be resynchronized
+        # FIXME: self.sync is for all profiles
+        #        while (dis)connection is per-profile.
+        #        A mechanism similar to sync should be available
+        #        on a per-profile basis
+        self.sync = True
+        self.profile_connected(profile)
+
+    def disconnected_handler(self, profile):
+        """called when the connection is closed"""
+        log.debug(_("Disconnected"))
+        self.contact_lists[profile].disconnect()
+        # FIXME: see note on connected_handler
+        self.sync = False
+        self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
+
+    def action_new_handler(self, action_data_s, id_, security_limit, profile):
+        self.action_manager(
+            data_format.deserialise(action_data_s), user_action=False, profile=profile
+        )
+
+    def contact_new_handler(self, jid_s, attributes, groups, profile):
+        entity = jid.JID(jid_s)
+        groups = list(groups)
+        self.contact_lists[profile].set_contact(entity, groups, attributes, in_roster=True)
+
+    def message_new_handler(
+            self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra_s,
+            profile):
+        from_jid = jid.JID(from_jid_s)
+        to_jid = jid.JID(to_jid_s)
+        extra = data_format.deserialise(extra_s)
+        if not self.trigger.point(
+            "messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_,
+            extra, profile=profile,):
+            return
+
+        from_me = from_jid.bare == self.profiles[profile].whoami.bare
+        mess_to_jid = to_jid if from_me else from_jid
+        target = mess_to_jid.bare
+        contact_list = self.contact_lists[profile]
+
+        try:
+            is_room = contact_list.is_room(target)
+        except exceptions.NotFound:
+            is_room = False
+
+        if target.resource and not is_room:
+            # we avoid resource locking, but we must keep resource for private MUC
+            # messages
+            target = target
+        # we want to be sure to have at least one QuickChat instance
+        self.widgets.get_or_create_widget(
+            quick_chat.QuickChat,
+            target,
+            type_ = C.CHAT_GROUP if is_room else C.CHAT_ONE2ONE,
+            on_new_widget = None,
+            profile = profile,
+        )
+
+        if (
+            not from_jid in contact_list
+            and from_jid.bare != self.profiles[profile].whoami.bare
+        ):
+            # XXX: needed to show entities which haven't sent any
+            #     presence information and which are not in roster
+            contact_list.set_contact(from_jid)
+
+        # we dispatch the message in the widgets
+        for widget in self.widgets.get_widgets(
+            quick_chat.QuickChat, target=target, profiles=(profile,)
+        ):
+            widget.message_new(
+                uid, timestamp, from_jid, mess_to_jid, msg, subject, type_, extra, profile
+            )
+
+    def message_encryption_started_handler(self, destinee_jid_s, plugin_data, profile):
+        destinee_jid = jid.JID(destinee_jid_s)
+        plugin_data = data_format.deserialise(plugin_data)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
+                                              target=destinee_jid.bare,
+                                              profiles=(profile,)):
+            widget.message_encryption_started(plugin_data)
+
+    def message_encryption_stopped_handler(self, destinee_jid_s, plugin_data, profile):
+        destinee_jid = jid.JID(destinee_jid_s)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat,
+                                              target=destinee_jid.bare,
+                                              profiles=(profile,)):
+            widget.message_encryption_stopped(plugin_data)
+
+    def message_state_handler(self, uid, status, profile):
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
+            widget.on_message_state(uid, status, profile)
+
+    def message_send(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE):
+        if not subject and not extra and (not message or message == {'': ''}):
+            log.debug("Not sending empty message")
+            return
+
+        if subject is None:
+            subject = {}
+        if extra is None:
+            extra = {}
+        if callback is None:
+            callback = (
+                lambda __=None: None
+            )  # FIXME: optional argument is here because pyjamas doesn't support callback
+               #        without arg with json proxy
+        if errback is None:
+            errback = lambda failure: self.show_dialog(
+                message=failure.message, title=failure.fullname, type="error"
+            )
+
+        if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key):
+            return
+
+        self.bridge.message_send(
+            str(to_jid),
+            message,
+            subject,
+            mess_type,
+            data_format.serialise(extra),
+            profile_key,
+            callback=callback,
+            errback=errback,
+        )
+
+    def set_presence_status(self, show="", status=None, profile=C.PROF_KEY_NONE):
+        raise NotImplementedError
+
+    def presence_update_handler(self, entity_s, show, priority, statuses, profile):
+        # XXX: this log is commented because it's really too verbose even for DEBUG logs
+        #      but it is kept here as it may still be useful for troubleshooting
+        # log.debug(
+        #     _(
+        #         u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, "
+        #         u"statuses=%(statuses)s) [profile:%(profile)s]"
+        #     )
+        #     % {
+        #         "entity": entity_s,
+        #         C.PRESENCE_SHOW: show,
+        #         C.PRESENCE_PRIORITY: priority,
+        #         C.PRESENCE_STATUSES: statuses,
+        #         "profile": profile,
+        #     }
+        # )
+        entity = jid.JID(entity_s)
+
+        if entity == self.profiles[profile].whoami:
+            if show == C.PRESENCE_UNAVAILABLE:
+                self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
+            else:
+                # FIXME: try to retrieve user language status before fallback to default
+                status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
+                self.set_presence_status(show, status, profile=profile)
+            return
+
+        self.call_listeners("presence", entity, show, priority, statuses, profile=profile)
+
+    def muc_room_joined_handler(
+            self, room_jid_s, occupants, user_nick, subject, statuses, profile):
+        """Called when a MUC room is joined"""
+        log.debug(
+            "Room [{room_jid}] joined by {profile}, users presents:{users}".format(
+                room_jid=room_jid_s, profile=profile, users=list(occupants.keys())
+            )
+        )
+        room_jid = jid.JID(room_jid_s)
+        self.contact_lists[profile].set_special(room_jid, C.CONTACT_SPECIAL_GROUP)
+        self.widgets.get_or_create_widget(
+            quick_chat.QuickChat,
+            room_jid,
+            type_=C.CHAT_GROUP,
+            nick=user_nick,
+            occupants=occupants,
+            subject=subject,
+            statuses=statuses,
+            profile=profile,
+        )
+
+    def muc_room_left_handler(self, room_jid_s, profile):
+        """Called when a MUC room is left"""
+        log.debug(
+            "Room [%(room_jid)s] left by %(profile)s"
+            % {"room_jid": room_jid_s, "profile": profile}
+        )
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
+        if chat_widget:
+            self.widgets.delete_widget(
+                chat_widget, all_instances=True, explicit_close=True)
+        self.contact_lists[profile].remove_contact(room_jid)
+
+    def muc_room_user_changed_nick_handler(self, room_jid_s, old_nick, new_nick, profile):
+        """Called when an user joined a MUC room"""
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_or_create_widget(
+            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
+        )
+        chat_widget.change_user_nick(old_nick, new_nick)
+        log.debug(
+            "user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]"
+            % {"old_nick": old_nick, "new_nick": new_nick, "room_jid": room_jid}
+        )
+
+    def muc_room_new_subject_handler(self, room_jid_s, subject, profile):
+        """Called when subject of MUC room change"""
+        room_jid = jid.JID(room_jid_s)
+        chat_widget = self.widgets.get_or_create_widget(
+            quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
+        )
+        chat_widget.set_subject(subject)
+        log.debug(
+            "new subject for room [%(room_jid)s]: %(subject)s"
+            % {"room_jid": room_jid, "subject": subject}
+        )
+
+    def chat_state_received_handler(self, from_jid_s, state, profile):
+        """Called when a new chat state (XEP-0085) is received.
+
+        @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
+        @param state (unicode): new state
+        @param profile (unicode): current profile
+        """
+        from_jid = jid.JID(from_jid_s)
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat, target=from_jid.bare,
+                                              profiles=(profile,)):
+            widget.on_chat_state(from_jid, state, profile)
+
+    def notify(self, type_, entity=None, message=None, subject=None, callback=None,
+               cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
+        """Trigger an event notification
+
+        @param type_(unicode): notifation kind,
+            one of C.NOTIFY_* constant or any custom type specific to frontend
+        @param entity(jid.JID, None): entity involved in the notification
+            if entity is in contact list, a indicator may be added in front of it
+        @param message(unicode, None): message of the notification
+        @param subject(unicode, None): subject of the notification
+        @param callback(callable, None): method to call when notification is selected
+        @param cb_args(list, None): list of args for callback
+        @param widget(object, None): widget where the notification happened
+        """
+        assert type_ in C.NOTIFY_ALL
+        notif_dict = self.profiles[profile].notifications
+        key = "" if entity is None else entity.bare
+        type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
+        notif_data = {
+            "id": self._notif_id,
+            "time": time.time(),
+            "entity": entity,
+            "callback": callback,
+            "cb_args": cb_args,
+            "message": message,
+            "subject": subject,
+        }
+        if widget is not None:
+            notif_data[widget] = widget
+        type_notifs.append(notif_data)
+        self._notifications[self._notif_id] = notif_data
+        self._notif_id += 1
+        self.call_listeners("notification", entity, notif_data, profile=profile)
+
+    def get_notifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE):
+        """return notifications for given entity
+
+        @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
+            bare jid to get all notifications, full jid to filter on resource
+            None to get general notifications
+            C.ENTITY_ALL to get all notifications
+        @param type_(unicode, None): notification type to filter
+            None to get all notifications
+        @param exact_jid(bool, None): if True, only return notifications from
+            exact entity jid (i.e. not including other resources)
+            None for automatic selection (True for full jid, False else)
+            False to get resources notifications
+            False doesn't do anything if entity is not a bare jid
+        @return (iter[dict]): notifications
+        """
+        main_notif_dict = self.profiles[profile].notifications
+
+        if entity is C.ENTITY_ALL:
+            selected_notifs = iter(main_notif_dict.values())
+            exact_jid = False
+        else:
+            if entity is None:
+                key = ""
+                exact_jid = False
+            else:
+                key = entity.bare
+                if exact_jid is None:
+                    exact_jid = bool(entity.resource)
+            selected_notifs = (main_notif_dict.setdefault(key, {}),)
+
+        for notifs_from_select in selected_notifs:
+
+            if type_ is None:
+                type_notifs = iter(notifs_from_select.values())
+            else:
+                type_notifs = (notifs_from_select.get(type_, []),)
+
+            for notifs in type_notifs:
+                for notif in notifs:
+                    if exact_jid and notif["entity"] != entity:
+                        continue
+                    yield notif
+
+    def clear_notifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
+        """return notifications for given entity
+
+        @param entity(jid.JID, None): bare jid of the entity to check
+            None to clear general notifications (but keep entities ones)
+        @param type_(unicode, None): notification type to filter
+            None to clear all notifications
+        @return (list[dict]): list of notifications
+        """
+        notif_dict = self.profiles[profile].notifications
+        key = "" if entity is None else entity.bare
+        try:
+            if type_ is None:
+                del notif_dict[key]
+            else:
+                del notif_dict[key][type_]
+        except KeyError:
+            return
+        self.call_listeners("notificationsClear", entity, type_, profile=profile)
+
+    def ps_event_handler(self, category, service_s, node, event_type, data, profile):
+        """Called when a PubSub event is received.
+
+        @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
+        @param service_s (unicode): pubsub service
+        @param node (unicode): pubsub node
+        @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
+        @param data (serialised_dict): event data
+        """
+        data = data_format.deserialise(data)
+        service_s = jid.JID(service_s)
+
+        if category == C.PS_MICROBLOG and self.MB_HANDLER:
+            if event_type == C.PS_PUBLISH:
+                if not "content" in data:
+                    log.warning("No content found in microblog data")
+                    return
+
+                # FIXME: check if [] make sense (instead of None)
+                _groups = data.get("group")
+
+                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
+                    wid.add_entry_if_accepted(service_s, node, data, _groups, profile)
+
+                try:
+                    comments_node, comments_service = (
+                        data["comments_node"],
+                        data["comments_service"],
+                    )
+                except KeyError:
+                    pass
+                else:
+                    self.bridge.mb_get(
+                        comments_service,
+                        comments_node,
+                        C.NO_LIMIT,
+                        [],
+                        {"subscribe": C.BOOL_TRUE},
+                        profile=profile,
+                    )
+            elif event_type == C.PS_RETRACT:
+                for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
+                    wid.delete_entry_if_present(service_s, node, data["id"], profile)
+                pass
+            else:
+                log.warning("Unmanaged PubSub event type {}".format(event_type))
+
+    def register_progress_cbs(self, progress_id, callback, errback):
+        """Register progression callbacks
+
+        @param progress_id(unicode): id of the progression to check
+        @param callback(callable, None): method to call when progressing action
+            successfuly finished.
+            None to ignore
+        @param errback(callable, None): method to call when progressions action failed
+            None to ignore
+        """
+        callbacks = self._progress_ids.setdefault(progress_id, [])
+        callbacks.append((callback, errback))
+
+    def progress_started_handler(self, pid, metadata, profile):
+        log.info("Progress {} started".format(pid))
+
+    def progress_finished_handler(self, pid, metadata, profile):
+        log.info("Progress {} finished".format(pid))
+        try:
+            callbacks = self._progress_ids.pop(pid)
+        except KeyError:
+            pass
+        else:
+            for callback, __ in callbacks:
+                if callback is not None:
+                    callback(metadata, profile=profile)
+        self.call_listeners("progress_finished", pid, metadata, profile=profile)
+
+    def progress_error_handler(self, pid, err_msg, profile):
+        log.warning("Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
+        try:
+            callbacks = self._progress_ids.pop(pid)
+        except KeyError:
+            pass
+        else:
+            for __, errback in callbacks:
+                if errback is not None:
+                    errback(err_msg, profile=profile)
+        self.call_listeners("progress_error", pid, err_msg, profile=profile)
+
+    def _subscribe_cb(self, answer, data):
+        entity, profile = data
+        type_ = "subscribed" if answer else "unsubscribed"
+        self.bridge.subscription(type_, str(entity.bare), profile_key=profile)
+
+    def subscribe_handler(self, type, raw_jid, profile):
+        """Called when a subsciption management signal is received"""
+        entity = jid.JID(raw_jid)
+        if type == "subscribed":
+            # this is a subscription confirmation, we just have to inform user
+            # TODO: call self.getEntityMBlog to add the new contact blogs
+            self.show_dialog(
+                _("The contact {contact} has accepted your subscription").format(
+                    contact=entity.bare
+                ),
+                _("Subscription confirmation"),
+            )
+        elif type == "unsubscribed":
+            # this is a subscription refusal, we just have to inform user
+            self.show_dialog(
+                _("The contact {contact} has refused your subscription").format(
+                    contact=entity.bare
+                ),
+                _("Subscription refusal"),
+                "error",
+            )
+        elif type == "subscribe":
+            # this is a subscriptionn request, we have to ask for user confirmation
+            # TODO: use sat.stdui.ui_contact_list to display the groups selector
+            self.show_dialog(
+                _(
+                    "The contact {contact} wants to subscribe to your presence"
+                    ".\nDo you accept ?"
+                ).format(contact=entity.bare),
+                _("Subscription confirmation"),
+                "yes/no",
+                answer_cb=self._subscribe_cb,
+                answer_data=(entity, profile),
+            )
+
+    def _debug_handler(self, action, parameters, profile):
+        if action == "widgets_dump":
+            from pprint import pformat
+            log.info("Widgets dump:\n{data}".format(data=pformat(self.widgets._widgets)))
+        else:
+            log.warning("Unknown debug action: {action}".format(action=action))
+
+
+    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
+        """Show a dialog to user
+
+        Frontends must override this method
+        @param message(unicode): body of the dialog
+        @param title(unicode): title of the dialog
+        @param type(unicode): one of:
+            - "info": information dialog (callbacks not used)
+            - "warning": important information to notice (callbacks not used)
+            - "error": something went wrong (callbacks not used)
+            - "yes/no": a dialog with 2 choices (yes and no)
+        @param answer_cb(callable): method to call on answer.
+            Arguments depend on dialog type:
+            - "yes/no": argument is a boolean (True for yes)
+        @param answer_data(object): data to link on callback
+        """
+        # FIXME: misnamed method + types are not well chosen. Need to be rethought
+        raise NotImplementedError
+
+    def show_alert(self, message):
+        # FIXME: doesn't seems used anymore, to remove?
+        pass  # FIXME
+
+    def dialog_failure(self, failure):
+        log.warning("Failure: {}".format(failure))
+
+    def progress_id_handler(self, progress_id, profile):
+        """Callback used when an action result in a progress id"""
+        log.info("Progress ID received: {}".format(progress_id))
+
+    def is_hidden(self):
+        """Tells if the frontend window is hidden.
+
+        @return bool
+        """
+        raise NotImplementedError
+
+    def param_update_handler(self, name, value, namespace, profile):
+        log.debug(
+            _("param update: [%(namespace)s] %(name)s = %(value)s")
+            % {"namespace": namespace, "name": name, "value": value}
+        )
+        if (namespace, name) == ("Connection", "JabberID"):
+            log.debug(_("Changing JID to %s") % value)
+            self.profiles[profile].whoami = jid.JID(value)
+        elif (namespace, name) == ("General", C.SHOW_OFFLINE_CONTACTS):
+            self.contact_lists[profile].show_offline_contacts(C.bool(value))
+        elif (namespace, name) == ("General", C.SHOW_EMPTY_GROUPS):
+            self.contact_lists[profile].show_empty_groups(C.bool(value))
+
+    def contact_deleted_handler(self, jid_s, profile):
+        target = jid.JID(jid_s)
+        self.contact_lists[profile].remove_contact(target)
+
+    def entity_data_updated_handler(self, entity_s, key, value_raw, profile):
+        entity = jid.JID(entity_s)
+        value = data_format.deserialise(value_raw, type_check=None)
+        if key == "nicknames":
+            assert isinstance(value, list) or value is None
+            if entity in self.contact_lists[profile]:
+                self.contact_lists[profile].set_cache(entity, "nicknames", value)
+                self.call_listeners("nicknames", entity, value, profile=profile)
+        elif key == "avatar" and self.AVATARS_HANDLER:
+            assert isinstance(value, dict) or value is None
+            self.contact_lists[profile].set_cache(entity, "avatar", value)
+            self.call_listeners("avatar", entity, value, profile=profile)
+
+    def action_manager(self, action_data, callback=None, ui_show_cb=None, user_action=True,
+                      progress_cb=None, progress_eb=None, profile=C.PROF_KEY_NONE):
+        """Handle backend action
+
+        @param action_data(dict): action dict as sent by action_launch or returned by an
+            UI action
+        @param callback(None, callback): if not None, callback to use on XMLUI answer
+        @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI
+        @param user_action(bool): if True, the action is a result of a user interaction
+            else the action come from backend direclty (i.e. action_new).
+            This is useful to know if the frontend can display a popup immediately (if
+            True) or if it should add it to a queue that the user can activate later.
+        @param progress_cb(None, callable): method to call when progression is finished.
+            Only make sense if a progress is expected in this action
+        @param progress_eb(None, callable): method to call when something went wrong
+            during progression.
+            Only make sense if a progress is expected in this action
+        """
+        try:
+            xmlui = action_data.pop("xmlui")
+        except KeyError:
+            pass
+        else:
+            ui = self.xmlui.create(
+                self,
+                xml_data=xmlui,
+                flags=("FROM_BACKEND",) if not user_action else None,
+                callback=callback,
+                profile=profile,
+            )
+            if ui_show_cb is None:
+                ui.show()
+            else:
+                ui_show_cb(ui)
+
+        try:
+            progress_id = action_data.pop("progress")
+        except KeyError:
+            pass
+        else:
+            if progress_cb or progress_eb:
+                self.register_progress_cbs(progress_id, progress_cb, progress_eb)
+            self.progress_id_handler(progress_id, profile)
+
+    def _action_cb(self, data, callback, callback_id, profile):
+        if callback is None:
+            self.action_manager(data, profile=profile)
+        else:
+            callback(data=data, cb_id=callback_id, profile=profile)
+
+    def action_launch(
+        self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE
+    ):
+        """Launch a dynamic action
+
+        @param callback_id: id of the action to launch
+        @param data: data needed only for certain actions
+        @param callback(callable, None): will be called with the resut
+            if None, self.action_manager will be called
+            else the callable will be called with the following kw parameters:
+                - data: action_data
+                - cb_id: callback id
+                - profile: %(doc_profile)s
+        @param profile: %(doc_profile)s
+
+        """
+        if data is None:
+            data = dict()
+        action_cb = lambda data: self._action_cb(
+            data_format.deserialise(data), callback, callback_id, profile
+        )
+        self.bridge.action_launch(
+            callback_id, data_format.serialise(data), profile, callback=action_cb,
+            errback=self.dialog_failure
+        )
+
+    def launch_menu(
+        self,
+        menu_type,
+        path,
+        data=None,
+        callback=None,
+        security_limit=C.SECURITY_LIMIT_MAX,
+        profile=C.PROF_KEY_NONE,
+    ):
+        """Launch a menu manually
+
+        @param menu_type(unicode): type of the menu to launch
+        @param path(iterable[unicode]): path to the menu
+        @param data: data needed only for certain actions
+        @param callback(callable, None): will be called with the resut
+            if None, self.action_manager will be called
+            else the callable will be called with the following kw parameters:
+                - data: action_data
+                - cb_id: (menu_type, path) tuple
+                - profile: %(doc_profile)s
+        @param profile: %(doc_profile)s
+
+        """
+        if data is None:
+            data = dict()
+        action_cb = lambda data: self._action_cb(
+            data, callback, (menu_type, path), profile
+        )
+        self.bridge.menu_launch(
+            menu_type,
+            path,
+            data,
+            security_limit,
+            profile,
+            callback=action_cb,
+            errback=self.dialog_failure,
+        )
+
+    def disconnect(self, profile):
+        log.info("disconnecting")
+        self.call_listeners("disconnect", profile=profile)
+        self.bridge.disconnect(profile)
+
+    def on_exit(self):
+        """Must be called when the frontend is terminating"""
+        to_unplug = []
+        for profile, profile_manager in self.profiles.items():
+            if profile_manager.connected and profile_manager.autodisconnect:
+                # The user wants autodisconnection
+                self.disconnect(profile)
+            to_unplug.append(profile)
+        for profile in to_unplug:
+            self.unplug_profile(profile)