changeset 493:b3cedbee561d

refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 18:26:16 +0200
parents 5114bbb5daa3
children a4a5565e7026
files cagou/VERSION cagou/__init__.py cagou/core/__init__.py cagou/core/behaviors.py cagou/core/cagou_main.py cagou/core/cagou_widget.py cagou/core/common.py cagou/core/common_widgets.py cagou/core/config.py cagou/core/constants.py cagou/core/dialog.py cagou/core/image.py cagou/core/kivy_hack.py cagou/core/menu.py cagou/core/patches.py cagou/core/platform_/__init__.py cagou/core/platform_/android.py cagou/core/platform_/base.py cagou/core/profile_manager.py cagou/core/share_widget.py cagou/core/simple_xhtml.py cagou/core/widgets_handler.py cagou/core/xmlui.py cagou/kv/__init__.py cagou/kv/base.kv cagou/kv/behaviors.kv cagou/kv/cagou_widget.kv cagou/kv/common.kv cagou/kv/common_widgets.kv cagou/kv/dialog.kv cagou/kv/menu.kv cagou/kv/profile_manager.kv cagou/kv/root_widget.kv cagou/kv/share_widget.kv cagou/kv/simple_xhtml.kv cagou/kv/widgets_handler.kv cagou/kv/xmlui.kv cagou/plugins/__init__.py cagou/plugins/plugin_transfer_android_gallery.py cagou/plugins/plugin_transfer_android_photo.py cagou/plugins/plugin_transfer_android_video.py cagou/plugins/plugin_transfer_file.kv cagou/plugins/plugin_transfer_file.py cagou/plugins/plugin_transfer_voice.kv cagou/plugins/plugin_transfer_voice.py cagou/plugins/plugin_wid_blog.kv cagou/plugins/plugin_wid_blog.py cagou/plugins/plugin_wid_chat.kv cagou/plugins/plugin_wid_chat.py cagou/plugins/plugin_wid_contact_list.kv cagou/plugins/plugin_wid_contact_list.py cagou/plugins/plugin_wid_file_sharing.kv cagou/plugins/plugin_wid_file_sharing.py cagou/plugins/plugin_wid_remote.kv cagou/plugins/plugin_wid_remote.py cagou/plugins/plugin_wid_settings.kv cagou/plugins/plugin_wid_settings.py cagou/plugins/plugin_wid_widget_selector.kv cagou/plugins/plugin_wid_widget_selector.py doc/conf.py doc/index.rst doc/installation.rst libervia/desktop_kivy/VERSION libervia/desktop_kivy/__init__.py libervia/desktop_kivy/core/__init__.py libervia/desktop_kivy/core/behaviors.py libervia/desktop_kivy/core/cagou_main.py libervia/desktop_kivy/core/cagou_widget.py libervia/desktop_kivy/core/common.py libervia/desktop_kivy/core/common_widgets.py libervia/desktop_kivy/core/config.py libervia/desktop_kivy/core/constants.py libervia/desktop_kivy/core/dialog.py libervia/desktop_kivy/core/image.py libervia/desktop_kivy/core/kivy_hack.py libervia/desktop_kivy/core/menu.py libervia/desktop_kivy/core/patches.py libervia/desktop_kivy/core/platform_/__init__.py libervia/desktop_kivy/core/platform_/android.py libervia/desktop_kivy/core/platform_/base.py libervia/desktop_kivy/core/profile_manager.py libervia/desktop_kivy/core/share_widget.py libervia/desktop_kivy/core/simple_xhtml.py libervia/desktop_kivy/core/widgets_handler.py libervia/desktop_kivy/core/xmlui.py libervia/desktop_kivy/kv/__init__.py libervia/desktop_kivy/kv/base.kv libervia/desktop_kivy/kv/behaviors.kv libervia/desktop_kivy/kv/cagou_widget.kv libervia/desktop_kivy/kv/common.kv libervia/desktop_kivy/kv/common_widgets.kv libervia/desktop_kivy/kv/dialog.kv libervia/desktop_kivy/kv/menu.kv libervia/desktop_kivy/kv/profile_manager.kv libervia/desktop_kivy/kv/root_widget.kv libervia/desktop_kivy/kv/share_widget.kv libervia/desktop_kivy/kv/simple_xhtml.kv libervia/desktop_kivy/kv/widgets_handler.kv libervia/desktop_kivy/kv/xmlui.kv libervia/desktop_kivy/plugins/__init__.py libervia/desktop_kivy/plugins/plugin_transfer_android_gallery.py libervia/desktop_kivy/plugins/plugin_transfer_android_photo.py libervia/desktop_kivy/plugins/plugin_transfer_android_video.py libervia/desktop_kivy/plugins/plugin_transfer_file.kv libervia/desktop_kivy/plugins/plugin_transfer_file.py libervia/desktop_kivy/plugins/plugin_transfer_voice.kv libervia/desktop_kivy/plugins/plugin_transfer_voice.py libervia/desktop_kivy/plugins/plugin_wid_blog.kv libervia/desktop_kivy/plugins/plugin_wid_blog.py libervia/desktop_kivy/plugins/plugin_wid_chat.kv libervia/desktop_kivy/plugins/plugin_wid_chat.py libervia/desktop_kivy/plugins/plugin_wid_contact_list.kv libervia/desktop_kivy/plugins/plugin_wid_contact_list.py libervia/desktop_kivy/plugins/plugin_wid_file_sharing.kv libervia/desktop_kivy/plugins/plugin_wid_file_sharing.py libervia/desktop_kivy/plugins/plugin_wid_remote.kv libervia/desktop_kivy/plugins/plugin_wid_remote.py libervia/desktop_kivy/plugins/plugin_wid_settings.kv libervia/desktop_kivy/plugins/plugin_wid_settings.py libervia/desktop_kivy/plugins/plugin_wid_widget_selector.kv libervia/desktop_kivy/plugins/plugin_wid_widget_selector.py main.py service/main.py setup.py
diffstat 118 files changed, 10855 insertions(+), 10875 deletions(-) [+]
line wrap: on
line diff
--- a/cagou/VERSION	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-0.9.0D
--- a/cagou/__init__.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-import os.path
-
-
-version_file = os.path.join(os.path.dirname(__file__), 'VERSION')
-with open(version_file) as f:
-    __version__ = f.read().strip()
-
-class Global(object):
-    @property
-    def host(self):
-        return self._host
-G = Global()
-
-# this import must be done after G is created
-from .core import cagou_main
-
-def run():
-    host = G._host = cagou_main.Cagou()
-    G.local_platform = cagou_main.local_platform
-    host.run()
--- a/cagou/core/behaviors.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,173 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 kivy import properties
-from kivy.animation import Animation
-from kivy.clock import Clock
-from kivy_garden import modernmenu
-from functools import partial
-
-
-class TouchMenu(modernmenu.ModernMenu):
-    pass
-
-
-class TouchMenuItemBehavior:
-    """Class to use on every item where a menu may appear
-
-    main_wid attribute must be set to the class inheriting from TouchMenuBehavior
-    do_item_action is the method called on simple click
-    get_menu_choices must return a list of menus for long press
-        menus there are dict as expected by ModernMenu
-        (translated text, index and callback)
-    """
-    main_wid = properties.ObjectProperty()
-    click_timeout = properties.NumericProperty(0.4)
-
-    def on_touch_down(self, touch):
-        if not self.collide_point(*touch.pos):
-            return
-        t = partial(self.open_menu, touch)
-        touch.ud['menu_timeout'] = t
-        Clock.schedule_once(t, self.click_timeout)
-        return super(TouchMenuItemBehavior, self).on_touch_down(touch)
-
-    def do_item_action(self, touch):
-        pass
-
-    def on_touch_up(self, touch):
-        if touch.ud.get('menu_timeout'):
-            Clock.unschedule(touch.ud['menu_timeout'])
-            if self.collide_point(*touch.pos) and self.main_wid.menu is None:
-                self.do_item_action(touch)
-        return super(TouchMenuItemBehavior, self).on_touch_up(touch)
-
-    def open_menu(self, touch, dt):
-        self.main_wid.open_menu(self, touch)
-        del touch.ud['menu_timeout']
-
-    def get_menu_choices(self):
-        """return choice adapted to selected item
-
-        @return (list[dict]): choices ad expected by ModernMenu
-        """
-        return []
-
-
-class TouchMenuBehavior:
-    """Class to handle a menu appearing on long press on items
-
-    classes using this behaviour need to have a float_layout property
-    pointing the main FloatLayout.
-    """
-    float_layout = properties.ObjectProperty()
-
-    def __init__(self, *args, **kwargs):
-        super(TouchMenuBehavior, self).__init__(*args, **kwargs)
-        self.menu = None
-        self.menu_item = None
-
-    ## menu methods ##
-
-    def clean_fl_children(self, layout, children):
-        """insure that self.menu and self.menu_item are None when menu is dimissed"""
-        if self.menu is not None and self.menu not in children:
-            self.menu = self.menu_item = None
-
-    def clear_menu(self):
-        """remove menu if there is one"""
-        if self.menu is not None:
-            self.menu.dismiss()
-            self.menu = None
-            self.menu_item = None
-
-    def open_menu(self, item, touch):
-        """open menu for item
-
-        @param item(PathWidget): item when the menu has been requested
-        @param touch(kivy.input.MotionEvent): touch data
-        """
-        if self.menu_item == item:
-            return
-        self.clear_menu()
-        pos = self.to_widget(*touch.pos)
-        choices = item.get_menu_choices()
-        if not choices:
-            return
-        self.menu = TouchMenu(choices=choices,
-                              center=pos,
-                              size_hint=(None, None))
-        self.float_layout.add_widget(self.menu)
-        self.menu.start_display(touch)
-        self.menu_item = item
-
-    def on_float_layout(self, wid, float_layout):
-        float_layout.bind(children=self.clean_fl_children)
-
-
-class FilterBehavior(object):
-    """class to handle items filtering with animation"""
-
-    def __init__(self, *args, **kwargs):
-        super(FilterBehavior, self).__init__(*args, **kwargs)
-        self._filter_last = {}
-        self._filter_anim = Animation(width = 0,
-                                      height = 0,
-                                      opacity = 0,
-                                      d = 0.5)
-
-    def do_filter(self, parent, text, get_child_text, width_cb, height_cb,
-                  continue_tests=None):
-        """filter the children
-
-        filtered children will have a animation to set width, height and opacity to 0
-        @param parent(kivy.uix.widget.Widget): parent layout of the widgets to filter
-        @param text(unicode): filter text (if this text is not present in a child,
-            the child is filtered out)
-        @param get_child_text(callable): must retrieve child text
-            child is used as sole argument
-        @param width_cb(callable, int, None): method to retrieve width when opened
-            child is used as sole argument, int can be used instead of callable
-        @param height_cb(callable, int, None): method to retrieve height when opened
-            child is used as sole argument, int can be used instead of callable
-        @param continue_tests(list[callable]): list of test to skip the item
-            all callables take child as sole argument.
-            if any of the callable return True, the child is skipped (i.e. not filtered)
-        """
-        text = text.strip().lower()
-        filtering = len(text)>len(self._filter_last.get(parent, ''))
-        self._filter_last[parent] = text
-        for child in parent.children:
-            if continue_tests is not None and any((t(child) for t in continue_tests)):
-                continue
-            if text in get_child_text(child).lower():
-                self._filter_anim.cancel(child)
-                for key, method in (('width', width_cb),
-                                    ('height', height_cb),
-                                    ('opacity', lambda c: 1)):
-                    try:
-                        setattr(child, key, method(child))
-                    except TypeError:
-                        # method is not a callable, must be an int
-                        setattr(child, key, method)
-            elif (filtering
-                  and child.opacity > 0
-                  and not self._filter_anim.have_properties_to_animate(child)):
-                self._filter_anim.start(child)
--- a/cagou/core/cagou_main.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1176 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-import os.path
-import glob
-import sys
-from pathlib import Path
-from urllib import parse as urlparse
-from functools import partial
-from sat.core.i18n import _
-from . import kivy_hack
-kivy_hack.do_hack()
-from .constants import Const as C
-from sat.core import log as logging
-from sat.core import exceptions
-from sat_frontends.quick_frontend.quick_app import QuickApp
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend import quick_chat
-from sat_frontends.quick_frontend import quick_utils
-from sat_frontends.tools import jid
-from sat.tools import utils as sat_utils
-from sat.tools import config
-from sat.tools.common import data_format
-from sat.tools.common import dynamic_import
-from sat.tools.common import files_utils
-import kivy
-kivy.require('1.11.0')
-import kivy.support
-main_config = config.parse_main_conf(log_filenames=True)
-bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
-# FIXME: event loop is choosen according to bridge_name, a better way should be used
-if 'dbus' in bridge_name:
-    kivy.support.install_gobject_iteration()
-elif bridge_name in ('pb', 'embedded'):
-    kivy.support.install_twisted_reactor()
-from kivy.app import App
-from kivy.lang import Builder
-from kivy import properties
-from . import xmlui
-from .profile_manager import ProfileManager
-from kivy.clock import Clock
-from kivy.uix.label import Label
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.floatlayout import FloatLayout
-from kivy.uix.screenmanager import (ScreenManager, Screen,
-                                    FallOutTransition, RiseInTransition)
-from kivy.uix.dropdown import DropDown
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.core.window import Window
-from kivy.animation import Animation
-from kivy.metrics import dp
-from .cagou_widget import CagouWidget
-from .share_widget import ShareWidget
-from . import widgets_handler
-from .common import IconButton
-from . import dialog
-from importlib import import_module
-import sat
-import cagou
-import cagou.plugins
-import cagou.kv
-
-
-log = logging.getLogger(__name__)
-
-
-try:
-    from plyer import notification
-except ImportError:
-    notification = None
-    log.warning(_("Can't import plyer, some features disabled"))
-
-
-## platform specific settings ##
-
-from . import platform_
-local_platform = platform_.create()
-local_platform.init_platform()
-
-
-## General Configuration ##
-
-# we want white background by default
-Window.clearcolor = (1, 1, 1, 1)
-
-
-class NotifsIcon(IconButton):
-    notifs = properties.ListProperty()
-
-    def on_release(self):
-        callback, args, kwargs = self.notifs.pop(0)
-        callback(*args, **kwargs)
-
-    def add_notif(self, callback, *args, **kwargs):
-        self.notifs.append((callback, args, kwargs))
-
-
-class Note(Label):
-    title = properties.StringProperty()
-    message = properties.StringProperty()
-    level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT,
-                                      options=list(C.XMLUI_DATA_LVLS))
-    symbol = properties.StringProperty()
-    action = properties.ObjectProperty()
-
-
-class NoteDrop(ButtonBehavior, BoxLayout):
-    title = properties.StringProperty()
-    message = properties.StringProperty()
-    level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT,
-                                      options=list(C.XMLUI_DATA_LVLS))
-    symbol = properties.StringProperty()
-    action = properties.ObjectProperty()
-
-    def on_press(self):
-        if self.action is not None:
-            self.parent.parent.select(self.action)
-
-
-class NotesDrop(DropDown):
-    clear_btn = properties.ObjectProperty()
-
-    def __init__(self, notes):
-        super(NotesDrop, self).__init__()
-        self.notes = notes
-
-    def open(self, widget):
-        self.clear_widgets()
-        for n in self.notes:
-            kwargs = {
-                'title': n.title,
-                'message': n.message,
-                'level': n.level
-            }
-            if n.symbol is not None:
-                kwargs['symbol'] = n.symbol
-            if n.action is not None:
-                kwargs['action'] = n.action
-            self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level,
-                                     symbol=n.symbol, action=n.action))
-        self.add_widget(self.clear_btn)
-        super(NotesDrop, self).open(widget)
-
-    def on_select(self, action_kwargs):
-        app = App.get_running_app()
-        app.host.do_action(**action_kwargs)
-
-
-class RootHeadWidget(BoxLayout):
-    """Notifications widget"""
-    manager = properties.ObjectProperty()
-    notifs_icon = properties.ObjectProperty()
-    notes = properties.ListProperty()
-    HEIGHT = dp(35)
-
-    def __init__(self):
-        super(RootHeadWidget, self).__init__()
-        self.notes_last = None
-        self.notes_event = None
-        self.notes_drop = NotesDrop(self.notes)
-
-    def add_notif(self, callback, *args, **kwargs):
-        """add a notification with a callback attached
-
-        when notification is pressed, callback is called
-        @param *args, **kwargs: arguments of callback
-        """
-        self.notifs_icon.add_notif(callback, *args, **kwargs)
-
-    def add_note(self, title, message, level, symbol, action):
-        kwargs = {
-            'title': title,
-            'message': message,
-            'level': level
-        }
-        if symbol is not None:
-            kwargs['symbol'] = symbol
-        if action is not None:
-            kwargs['action'] = action
-        note = Note(**kwargs)
-        self.notes.append(note)
-        if self.notes_event is None:
-            self.notes_event = Clock.schedule_interval(self._display_next_note, 5)
-            self._display_next_note()
-
-    def add_notif_ui(self, ui):
-        self.notifs_icon.add_notif(ui.show, force=True)
-
-    def add_notif_widget(self, widget):
-        app = App.get_running_app()
-        self.notifs_icon.add_notif(app.host.show_extra_ui, widget=widget)
-
-    def _display_next_note(self, __=None):
-        screen = Screen()
-        try:
-            idx = self.notes.index(self.notes_last) + 1
-        except ValueError:
-            idx = 0
-        try:
-            note = self.notes_last = self.notes[idx]
-        except IndexError:
-            self.notes_event.cancel()
-            self.notes_event = None
-        else:
-            screen.add_widget(note)
-        self.manager.switch_to(screen)
-
-
-class RootBody(BoxLayout):
-    pass
-
-
-class CagouRootWidget(FloatLayout):
-    root_body = properties.ObjectProperty
-
-    def __init__(self, main_widget):
-        super(CagouRootWidget, self).__init__()
-        # header
-        self.head_widget = RootHeadWidget()
-        self.root_body.add_widget(self.head_widget)
-        # body
-        self._manager = ScreenManager()
-        # main widgets
-        main_screen = Screen(name='main')
-        main_screen.add_widget(main_widget)
-        self._manager.add_widget(main_screen)
-        # backend XMLUI (popups, forms, etc)
-        xmlui_screen = Screen(name='xmlui')
-        self._manager.add_widget(xmlui_screen)
-        # extra (file chooser, audio record, etc)
-        extra_screen = Screen(name='extra')
-        self._manager.add_widget(extra_screen)
-        self.root_body.add_widget(self._manager)
-
-    def change_widget(self, widget, screen_name="main"):
-        """change main widget"""
-        if self._manager.transition.is_active:
-            # FIXME: workaround for what seems a Kivy bug
-            # TODO: report this upstream
-            self._manager.transition.stop()
-        screen = self._manager.get_screen(screen_name)
-        screen.clear_widgets()
-        screen.add_widget(widget)
-
-    def show(self, screen="main"):
-        if self._manager.transition.is_active:
-            # FIXME: workaround for what seems a Kivy bug
-            # TODO: report this upstream
-            self._manager.transition.stop()
-        if self._manager.current == screen:
-            return
-        if screen == "main":
-            self._manager.transition = FallOutTransition()
-        else:
-            self._manager.transition = RiseInTransition()
-        self._manager.current = screen
-
-    def new_action(self, handler, action_data, id_, security_limit, profile):
-        """Add a notification for an action"""
-        self.head_widget.add_notif(handler, action_data, id_, security_limit, profile)
-
-    def add_note(self, title, message, level, symbol, action):
-        self.head_widget.add_note(title, message, level, symbol, action)
-
-    def add_notif_ui(self, ui):
-        self.head_widget.add_notif_ui(ui)
-
-    def add_notif_widget(self, widget):
-        self.head_widget.add_notif_widget(widget)
-
-
-class CagouApp(App):
-    """Kivy App for Cagou"""
-    c_prim = properties.ListProperty(C.COLOR_PRIM)
-    c_prim_light = properties.ListProperty(C.COLOR_PRIM_LIGHT)
-    c_prim_dark = properties.ListProperty(C.COLOR_PRIM_DARK)
-    c_sec = properties.ListProperty(C.COLOR_SEC)
-    c_sec_light = properties.ListProperty(C.COLOR_SEC_LIGHT)
-    c_sec_dark = properties.ListProperty(C.COLOR_SEC_DARK)
-    connected = properties.BooleanProperty(False)
-    # we have to put those constants here and not in core/constants.py
-    # because of the use of dp(), which would import Kivy too early
-    # and prevent the log hack
-    MARGIN_LEFT = MARGIN_RIGHT = dp(10)
-
-    def _install_settings_keys(self, window):
-        # we don't want default Kivy's behaviour of displaying
-        # a settings screen when pressing F1 or platform specific key
-        return
-
-    def build(self):
-        Window.bind(on_keyboard=self.key_input)
-        Window.bind(on_dropfile=self.on_dropfile)
-        wid = CagouRootWidget(Label(text=_("Loading please wait")))
-        local_platform.on_app_build(wid)
-        return wid
-
-    def show_profile_manager(self):
-        self._profile_manager = ProfileManager()
-        self.root.change_widget(self._profile_manager)
-
-    def expand(self, path, *args, **kwargs):
-        """expand path and replace known values
-
-        useful in kv. Values which can be used:
-            - {media}: media dir
-        @param path(unicode): path to expand
-        @param *args: additional arguments used in format
-        @param **kwargs: additional keyword arguments used in format
-        """
-        return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs)
-
-    def init_frontend_state(self):
-        """Init state to handle paused/stopped/running on mobile OSes"""
-        local_platform.on_init_frontend_state()
-
-    def on_pause(self):
-        return local_platform.on_pause()
-
-    def on_resume(self):
-        return local_platform.on_resume()
-
-    def on_stop(self):
-        return local_platform.on_stop()
-
-    def show_head_widget(self, show=None, animation=True):
-        """Show/Hide the head widget
-
-        @param show(bool, None): True to show, False to hide, None to switch
-        @param animation(bool): animate the show/hide if True
-        """
-        head = self.root.head_widget
-        if bool(self.root.head_widget.height) == show:
-            return
-        if head.height:
-            if animation:
-                Animation(height=0, opacity=0, duration=0.3).start(head)
-            else:
-                head.height = head.opacity = 0
-        else:
-            if animation:
-                Animation(height=head.HEIGHT, opacity=1, duration=0.3).start(head)
-            else:
-                head.height = head.HEIGHT
-                head.opacity = 1
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-
-        # we first check if selected widget handles the key
-        if ((self.host.selected_widget is not None
-             and hasattr(self.host.selected_widget, 'key_input')
-             and self.host.selected_widget.key_input(window, key, scancode, codepoint,
-                 modifier))):
-            return True
-
-        if key == 27:
-            if ((self.host.selected_widget is None
-                 or self.host.selected_widget.__class__ == self.host.default_class)):
-                # we are on root widget, or nothing is selected
-                return local_platform.on_key_back_root()
-
-            # we disable [esc] handling, because default action is to quit app
-            return True
-        elif key == 292:
-            # F11: full screen
-            if not Window.fullscreen:
-                Window.fullscreen = 'auto'
-            else:
-                Window.fullscreen = False
-            return True
-        elif key == 110 and 'alt' in modifier:
-            # M-n we hide/show notifications
-            self.show_head_widget()
-            return True
-        else:
-            return False
-
-    def on_dropfile(self, __, file_path):
-        if self.host.selected_widget is not None:
-            try:
-                on_drop_file = self.host.selected_widget.on_drop_file
-            except AttributeError:
-                log.info(
-                    f"Select widget {self.host.selected_widget} doesn't handle file "
-                    f"dropping")
-            else:
-                on_drop_file(Path(file_path.decode()))
-
-
-class Cagou(QuickApp):
-    MB_HANDLE = False
-    AUTO_RESYNC = False
-
-    def __init__(self):
-        if bridge_name == 'embedded':
-            from sat.core import sat_main
-            self.sat = sat_main.SAT()
-
-        bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge')
-        if bridge_module is None:
-            log.error(f"Can't import {bridge_name} bridge")
-            sys.exit(3)
-        else:
-            log.debug(f"Loading {bridge_name} bridge")
-        super(Cagou, self).__init__(bridge_factory=bridge_module.bridge,
-                                    xmlui=xmlui,
-                                    check_options=quick_utils.check_options,
-                                    connect_bridge=False)
-        self._import_kv()
-        self.app = CagouApp()
-        self.app.host = self
-        self.media_dir = self.app.media_dir = config.config_get(main_config, '',
-                                                               'media_dir')
-        self.downloads_dir = self.app.downloads_dir = config.config_get(main_config, '',
-                                                                       'downloads_dir')
-        if not os.path.exists(self.downloads_dir):
-            try:
-                os.makedirs(self.downloads_dir)
-            except OSError as e:
-                log.warnings(_("Can't create downloads dir: {reason}").format(reason=e))
-        self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png")
-        self.app.icon = os.path.join(
-            self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png"
-        )
-        self.app.title = C.APP_NAME
-        # main widgets plugins
-        self._plg_wids = []
-        # transfer widgets plugins
-        self._plg_wids_transfer = []
-        self._import_plugins()
-        # visible widgets by classes
-        self._visible_widgets = {}
-        # used to keep track of last selected widget in "main" screen when changing
-        # root screen
-        self._selected_widget_main = None
-        self.backend_version = sat.__version__  # will be replaced by version_get()
-        if C.APP_VERSION.endswith('D'):
-            self.version = "{} {}".format(
-                C.APP_VERSION,
-                sat_utils.get_repository_data(cagou)
-            )
-        else:
-            self.version = C.APP_VERSION
-
-        self.tls_validation =  not C.bool(config.config_get(main_config,
-                                                           C.CONFIG_SECTION,
-                                                           'no_certificate_validation',
-                                                           C.BOOL_FALSE))
-        if not self.tls_validation:
-            from cagou.core import patches
-            patches.disable_tls_validation()
-            log.warning("SSL certificate validation is disabled, this is unsecure!")
-
-        local_platform.on_host_init(self)
-
-    @property
-    def visible_widgets(self):
-        for w_list in self._visible_widgets.values():
-            for w in w_list:
-                yield w
-
-    @property
-    def default_class(self):
-        if self.default_wid is None:
-            return None
-        return self.default_wid['main']
-
-    @QuickApp.sync.setter
-    def sync(self, state):
-        QuickApp.sync.fset(self, state)
-        # widget are resynchronised in on_visible event,
-        # so we must call resync for widgets which are already visible
-        if state:
-            for w in self.visible_widgets:
-                try:
-                    resync = w.resync
-                except AttributeError:
-                    pass
-                else:
-                    resync()
-            self.contact_lists.fill()
-
-    def config_get(self, section, name, default=None):
-        return config.config_get(main_config, section, name, default)
-
-    def on_bridge_connected(self):
-        super(Cagou, self).on_bridge_connected()
-        self.register_signal("otr_state", iface="plugin")
-
-    def _bridge_eb(self, failure):
-        if bridge_name == "pb" and sys.platform == "android":
-            try:
-                self.retried += 1
-            except AttributeError:
-                self.retried = 1
-            if ((isinstance(failure, exceptions.BridgeExceptionNoService)
-                 and self.retried < 100)):
-                if self.retried % 20 == 0:
-                    log.debug("backend not ready, retrying ({})".format(self.retried))
-                Clock.schedule_once(lambda __: self.connect_bridge(), 0.05)
-                return
-        super(Cagou, self)._bridge_eb(failure)
-
-    def run(self):
-        self.connect_bridge()
-        self.app.bind(on_stop=self.onStop)
-        self.app.run()
-
-    def onStop(self, obj):
-        try:
-            sat_instance = self.sat
-        except AttributeError:
-            pass
-        else:
-            sat_instance.stopService()
-
-    def _get_version_cb(self, version):
-        self.backend_version = version
-
-    def on_backend_ready(self):
-        super().on_backend_ready()
-        self.app.show_profile_manager()
-        self.bridge.version_get(callback=self._get_version_cb)
-        self.app.init_frontend_state()
-        if local_platform.do_post_init():
-            self.post_init()
-
-    def post_init(self, __=None):
-        # FIXME: resize doesn't work with SDL2 on android, so we use below_target for now
-        self.app.root_window.softinput_mode = "below_target"
-        profile_manager = self.app._profile_manager
-        del self.app._profile_manager
-        super(Cagou, self).post_init(profile_manager)
-
-    def profile_plugged(self, profile):
-        super().profile_plugged(profile)
-        # FIXME: this won't work with multiple profiles
-        self.app.connected = self.profiles[profile].connected
-
-    def _bookmarks_list_cb(self, bookmarks_dict, profile):
-        bookmarks = set()
-        for data in bookmarks_dict.values():
-            bookmarks.update({jid.JID(k) for k in data.keys()})
-        self.profiles[profile]._bookmarks = sorted(bookmarks)
-
-    def profile_connected(self, profile):
-        self.bridge.bookmarks_list(
-            "muc", "all", profile,
-            callback=partial(self._bookmarks_list_cb, profile=profile),
-            errback=partial(self.errback, title=_("Bookmark error")))
-
-    def _default_factory_main(self, plugin_info, target, profiles):
-        """default factory used to create main widgets instances
-
-        used when PLUGIN_INFO["factory"] is not set
-        @param plugin_info(dict): plugin datas
-        @param target: QuickWidget target
-        @param profiles(iterable): list of profiles
-        """
-        main_cls = plugin_info['main']
-        return self.widgets.get_or_create_widget(main_cls,
-                                              target,
-                                              on_new_widget=None,
-                                              profiles=iter(self.profiles))
-
-    def _default_factory_transfer(self, plugin_info, callback, cancel_cb, profiles):
-        """default factory used to create transfer widgets instances
-
-        @param plugin_info(dict): plugin datas
-        @param callback(callable): method to call with path to file to transfer
-        @param cancel_cb(callable): call when transfer is cancelled
-            transfer widget must be used as first argument
-        @param profiles(iterable): list of profiles
-            None if not specified
-        """
-        main_cls = plugin_info['main']
-        return main_cls(callback=callback, cancel_cb=cancel_cb)
-
-    ## plugins & kv import ##
-
-    def _import_kv(self):
-        """import all kv files in cagou.kv"""
-        path = os.path.dirname(cagou.kv.__file__)
-        kv_files = glob.glob(os.path.join(path, "*.kv"))
-        # we want to be sure that base.kv is loaded first
-        # as it override some Kivy widgets properties
-        for kv_file in kv_files:
-            if kv_file.endswith('base.kv'):
-                kv_files.remove(kv_file)
-                kv_files.insert(0, kv_file)
-                break
-        else:
-            raise exceptions.InternalError("base.kv is missing")
-
-        for kv_file in kv_files:
-            Builder.load_file(kv_file)
-            log.debug(f"kv file {kv_file} loaded")
-
-    def _import_plugins(self):
-        """import all plugins"""
-        self.default_wid = None
-        plugins_path = os.path.dirname(cagou.plugins.__file__)
-        plugin_glob = "plugin*." + C.PLUGIN_EXT
-        plug_lst = [os.path.splitext(p)[0] for p in
-                    map(os.path.basename, glob.glob(os.path.join(plugins_path,
-                                                                 plugin_glob)))]
-
-        imported_names_main = set()  # used to avoid loading 2 times
-                                     # plugin with same import name
-        imported_names_transfer = set()
-        for plug in plug_lst:
-            plugin_path = 'cagou.plugins.' + plug
-
-            # we get type from plugin name
-            suff = plug[7:]
-            if '_' not in suff:
-                log.error("invalid plugin name: {}, skipping".format(plug))
-                continue
-            plugin_type = suff[:suff.find('_')]
-
-            # and select the variable to use according to type
-            if plugin_type == C.PLUG_TYPE_WID:
-                imported_names = imported_names_main
-                default_factory = self._default_factory_main
-            elif plugin_type == C.PLUG_TYPE_TRANSFER:
-                imported_names = imported_names_transfer
-                default_factory = self._default_factory_transfer
-            else:
-                log.error("unknown plugin type {type_} for plugin {file_}, skipping"
-                    .format(
-                    type_ = plugin_type,
-                    file_ = plug
-                    ))
-                continue
-            plugins_set = self._get_plugins_set(plugin_type)
-
-            mod = import_module(plugin_path)
-            try:
-                plugin_info = mod.PLUGIN_INFO
-            except AttributeError:
-                plugin_info = {}
-
-            plugin_info['plugin_file'] = plug
-            plugin_info['plugin_type'] = plugin_type
-
-            if 'platforms' in plugin_info:
-                if sys.platform not in plugin_info['platforms']:
-                    log.info("{plugin_file} is not used on this platform, skipping"
-                             .format(**plugin_info))
-                    continue
-
-            # import name is used to differentiate plugins
-            if 'import_name' not in plugin_info:
-                plugin_info['import_name'] = plug
-            if plugin_info['import_name'] in imported_names:
-                log.warning(_("there is already a plugin named {}, "
-                              "ignoring new one").format(plugin_info['import_name']))
-                continue
-            if plugin_info['import_name'] == C.WID_SELECTOR:
-                if plugin_type != C.PLUG_TYPE_WID:
-                    log.error("{import_name} import name can only be used with {type_} "
-                              "type, skipping {name}".format(type_=C.PLUG_TYPE_WID,
-                                                              **plugin_info))
-                    continue
-                # if WidgetSelector exists, it will be our default widget
-                self.default_wid = plugin_info
-
-            # we want everything optional, so we use plugin file name
-            # if actual name is not found
-            if 'name' not in plugin_info:
-                name_start = 8 + len(plugin_type)
-                plugin_info['name'] = plug[name_start:]
-
-            # we need to load the kv file
-            if 'kv_file' not in plugin_info:
-                plugin_info['kv_file'] = '{}.kv'.format(plug)
-            kv_path = os.path.join(plugins_path, plugin_info['kv_file'])
-            if not os.path.exists(kv_path):
-                log.debug("no kv found for {plugin_file}".format(**plugin_info))
-            else:
-                Builder.load_file(kv_path)
-
-            # what is the main class ?
-            main_cls = getattr(mod, plugin_info['main'])
-            plugin_info['main'] = main_cls
-
-            # factory is used to create the instance
-            # if not found, we use a defaut one with get_or_create_widget
-            if 'factory' not in plugin_info:
-                plugin_info['factory'] = default_factory
-
-            # icons
-            for size in ('small', 'medium'):
-                key = 'icon_{}'.format(size)
-                try:
-                    path = plugin_info[key]
-                except KeyError:
-                    path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
-                else:
-                    path = path.format(media=self.media_dir)
-                    if not os.path.isfile(path):
-                        path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
-                plugin_info[key] = path
-
-            plugins_set.append(plugin_info)
-        if not self._plg_wids:
-            log.error(_("no widget plugin found"))
-            return
-
-        # we want widgets sorted by names
-        self._plg_wids.sort(key=lambda p: p['name'].lower())
-        self._plg_wids_transfer.sort(key=lambda p: p['name'].lower())
-
-        if self.default_wid is None:
-            # we have no selector widget, we use the first widget as default
-            self.default_wid = self._plg_wids[0]
-
-    def _get_plugins_set(self, type_):
-        if type_ == C.PLUG_TYPE_WID:
-            return self._plg_wids
-        elif type_ == C.PLUG_TYPE_TRANSFER:
-            return self._plg_wids_transfer
-        else:
-            raise KeyError("{} plugin type is unknown".format(type_))
-
-    def get_plugged_widgets(self, type_=C.PLUG_TYPE_WID, except_cls=None):
-        """get available widgets plugin infos
-
-        @param type_(unicode): type of widgets to get
-            one of C.PLUG_TYPE_* constant
-        @param except_cls(None, class): if not None,
-            widgets from this class will be excluded
-        @return (iter[dict]): available widgets plugin infos
-        """
-        plugins_set = self._get_plugins_set(type_)
-        for plugin_data in plugins_set:
-            if plugin_data['main'] == except_cls:
-                continue
-            yield plugin_data
-
-    def get_plugin_info(self, type_=C.PLUG_TYPE_WID, **kwargs):
-        """get first plugin info corresponding to filters
-
-        @param type_(unicode): type of widgets to get
-            one of C.PLUG_TYPE_* constant
-        @param **kwargs: filter(s) to use, each key present here must also
-            exist and be of the same value in requested plugin info
-        @return (dict, None): found plugin info or None
-        """
-        plugins_set = self._get_plugins_set(type_)
-        for plugin_info in plugins_set:
-            for k, w in kwargs.items():
-                try:
-                    if plugin_info[k] != w:
-                        continue
-                except KeyError:
-                    continue
-                return plugin_info
-
-    ## widgets handling
-
-    def new_widget(self, widget):
-        log.debug("new widget created: {}".format(widget))
-        if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP:
-            self.add_note("", _("room {} has been joined").format(widget.target))
-
-    def switch_widget(self, old, new=None):
-        """Replace old widget by new one
-
-        @param old(CagouWidget, None): CagouWidget instance or a child
-            None to select automatically widget to switch
-        @param new(CagouWidget): new widget instance
-            None to use default widget
-        @return (CagouWidget): new widget
-        """
-        if old is None:
-            old = self.get_widget_to_switch()
-        if new is None:
-            factory = self.default_wid['factory']
-            try:
-                profiles = old.profiles
-            except AttributeError:
-                profiles = None
-            new = factory(self.default_wid, None, profiles=profiles)
-        to_change = None
-        if isinstance(old, CagouWidget):
-            to_change = old
-        else:
-            for w in old.walk_reverse():
-                if isinstance(w, CagouWidget):
-                    to_change = w
-                    break
-
-        if to_change is None:
-            raise exceptions.InternalError("no CagouWidget found when "
-                                           "trying to switch widget")
-
-        # selected_widget can be modified in change_widget, so we need to set it before
-        self.selected_widget = new
-        if to_change == new:
-            log.debug("switch_widget called with old==new, nothing to do")
-            return new
-        to_change.whwrapper.change_widget(new)
-        return new
-
-    def _add_visible_widget(self, widget):
-        """declare a widget visible
-
-        for internal use only!
-        """
-        assert isinstance(widget, CagouWidget)
-        log.debug(f"Visible widget: {widget}")
-        self._visible_widgets.setdefault(widget.__class__, set()).add(widget)
-        log.debug(f"visible widgets list: {self.get_visible_list(None)}")
-        widget.on_visible()
-
-    def _remove_visible_widget(self, widget, ignore_missing=False):
-        """declare a widget not visible anymore
-
-        for internal use only!
-        """
-        log.debug(f"Widget not visible anymore: {widget}")
-        try:
-            self._visible_widgets[widget.__class__].remove(widget)
-        except KeyError as e:
-            if not ignore_missing:
-                log.error(f"trying to remove a not visible widget ({widget}): {e}")
-            return
-        log.debug(f"visible widgets list: {self.get_visible_list(None)}")
-        if isinstance(widget, CagouWidget):
-            widget.on_not_visible()
-        if isinstance(widget, quick_widgets.QuickWidget):
-            self.widgets.delete_widget(widget)
-
-    def get_visible_list(self, cls):
-        """get list of visible widgets for a given class
-
-        @param cls(type): type of widgets to get
-            None to get all visible widgets
-        @return (set[type]): visible widgets of this class
-        """
-        if cls is None:
-            ret = set()
-            for widgets in self._visible_widgets.values():
-                for w in widgets:
-                    ret.add(w)
-            return ret
-        try:
-            return self._visible_widgets[cls]
-        except KeyError:
-            return set()
-
-    def delete_unused_widget_instances(self, widget):
-        """Delete instance of this widget which are not attached to a WHWrapper
-
-        @param widget(quick_widgets.QuickWidget): reference widget
-            other instance of this widget will be deleted if they have no parent
-        """
-        to_delete = []
-        if isinstance(widget, quick_widgets.QuickWidget):
-            for w in self.widgets.get_widget_instances(widget):
-                if w.whwrapper is None and w != widget:
-                    to_delete.append(w)
-            for w in to_delete:
-                log.debug("cleaning widget: {wid}".format(wid=w))
-                self.widgets.delete_widget(w)
-
-    def get_or_clone(self, widget, **kwargs):
-        """Get a QuickWidget if it is not in a WHWrapper, else clone it
-
-        if an other instance of this widget exist without being in a WHWrapper
-        (i.e. if it is not already in use) it will be used.
-        """
-        if widget.whwrapper is None:
-            if widget.parent is not None:
-                widget.parent.remove_widget(widget)
-            self.delete_unused_widget_instances(widget)
-            return widget
-        for w in self.widgets.get_widget_instances(widget):
-            if w.whwrapper is None:
-                if w.parent is not None:
-                    w.parent.remove_widget(w)
-                self.delete_unused_widget_instances(w)
-                return w
-        targets = list(widget.targets)
-        w = self.widgets.get_or_create_widget(widget.__class__,
-                                           targets[0],
-                                           on_new_widget=None,
-                                           on_existing_widget=C.WIDGET_RECREATE,
-                                           profiles=widget.profiles,
-                                           **kwargs)
-        for t in targets[1:]:
-            w.add_target(t)
-        return w
-
-    def get_widget_to_switch(self):
-        """Choose best candidate when we need to switch widget and old is not specified
-
-        @return (CagouWidget): widget to switch
-        """
-        if (self._selected_widget_main is not None
-            and self._selected_widget_main.whwrapper is not None):
-            # we are not on the main screen, we want to switch a widget from main screen
-            return self._selected_widget_main
-        elif (self.selected_widget is not None
-              and isinstance(self.selected_widget, CagouWidget)
-              and self.selected_widget.whwrapper is not None):
-            return self.selected_widget
-        # no widget is selected we check if we have any default widget
-        default_cls = self.default_class
-        for w in self.visible_widgets:
-            if isinstance(w, default_cls):
-                return w
-
-        # no default widget found, we return the first widget
-        return next(iter(self.visible_widgets))
-
-    def do_action(self, action, target, profiles):
-        """Launch an action handler by a plugin
-
-        @param action(unicode): action to do, can be:
-            - chat: open a chat widget
-        @param target(unicode): target of the action
-        @param profiles(list[unicode]): profiles to use
-        @return (CagouWidget, None): new widget
-        """
-        try:
-            # FIXME: Q&D way to get chat plugin, should be replaced by a clean method
-            #        in host
-            plg_infos = [p for p in self.get_plugged_widgets()
-                         if action in p['import_name']][0]
-        except IndexError:
-            log.warning("No plugin widget found to do {action}".format(action=action))
-        else:
-            try:
-                # does the widget already exist?
-                wid = next(self.widgets.get_widgets(
-                    plg_infos['main'],
-                    target=target,
-                    profiles=profiles))
-            except StopIteration:
-                # no, let's create a new one
-                factory = plg_infos['factory']
-                wid = factory(plg_infos, target=target, profiles=profiles)
-
-            return self.switch_widget(None, wid)
-
-    ## bridge handlers ##
-
-    def otr_state_handler(self, state, dest_jid, profile):
-        """OTR state has changed for on destinee"""
-        # XXX: this method could be in QuickApp but it's here as
-        #      it's only used by Cagou so far
-        dest_jid = jid.JID(dest_jid)
-        bare_jid = dest_jid.bare
-        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
-            if widget.type == C.CHAT_ONE2ONE and widget.target == bare_jid:
-                widget.on_otr_state(state, dest_jid, profile)
-
-    def _debug_handler(self, action, parameters, profile):
-        if action == "visible_widgets_dump":
-            from pprint import pformat
-            log.info("Visible widgets dump:\n{data}".format(
-                data=pformat(self._visible_widgets)))
-        else:
-            return super(Cagou, self)._debug_handler(action, parameters, profile)
-
-    def connected_handler(self, jid_s, profile):
-        # FIXME: this won't work with multiple profiles
-        super().connected_handler(jid_s, profile)
-        self.app.connected = True
-
-    def disconnected_handler(self, profile):
-        # FIXME: this won't work with multiple profiles
-        super().disconnected_handler(profile)
-        self.app.connected = False
-
-    ## misc ##
-
-    def plugging_profiles(self):
-        self.widgets_handler = widgets_handler.WidgetsHandler()
-        self.app.root.change_widget(self.widgets_handler)
-
-    def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE):
-        log.info("Profile presence status set to {show}/{status}".format(show=show,
-                                                                          status=status))
-
-    def errback(self, failure_, title=_('error'),
-                message=_('error while processing: {msg}')):
-        self.add_note(title, message.format(msg=failure_), level=C.XMLUI_DATA_LVL_WARNING)
-
-    def add_note(self, title, message, level=C.XMLUI_DATA_LVL_INFO, symbol=None,
-        action=None):
-        """add a note (message which disappear) to root widget's header"""
-        self.app.root.add_note(title, message, level, symbol, action)
-
-    def add_notif_ui(self, ui):
-        """add a notification with a XMLUI attached
-
-        @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected
-        """
-        self.app.root.add_notif_ui(ui)
-
-    def add_notif_widget(self, widget):
-        """add a notification with a Kivy widget attached
-
-        @param widget(kivy.uix.Widget): widget to attach to notification
-        """
-        self.app.root.add_notif_widget(widget)
-
-    def show_ui(self, ui):
-        """show a XMLUI"""
-        self.app.root.change_widget(ui, "xmlui")
-        self.app.root.show("xmlui")
-        self._selected_widget_main = self.selected_widget
-        self.selected_widget = ui
-
-    def show_extra_ui(self, widget):
-        """show any extra widget"""
-        self.app.root.change_widget(widget, "extra")
-        self.app.root.show("extra")
-        self._selected_widget_main = self.selected_widget
-        self.selected_widget = widget
-
-    def close_ui(self):
-        self.app.root.show()
-        self.selected_widget = self._selected_widget_main
-        self._selected_widget_main = None
-        screen = self.app.root._manager.get_screen("extra")
-        screen.clear_widgets()
-
-    def get_default_avatar(self, entity=None):
-        return self.app.default_avatar
-
-    def _dialog_cb(self, cb, *args, **kwargs):
-        """generic dialog callback
-
-        close dialog then call the callback with given arguments
-        """
-        def callback():
-            self.close_ui()
-            cb(*args, **kwargs)
-        return callback
-
-    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
-        if type in ('info', 'warning', 'error'):
-            self.add_note(title, message, type)
-        elif type == "yes/no":
-            wid = dialog.ConfirmDialog(title=title, message=message,
-                                       yes_cb=self._dialog_cb(answer_cb,
-                                                              True,
-                                                              answer_data),
-                                       no_cb=self._dialog_cb(answer_cb,
-                                                             False,
-                                                             answer_data)
-                                       )
-            self.add_notif_widget(wid)
-        else:
-            log.warning(_("unknown dialog type: {dialog_type}").format(dialog_type=type))
-
-    def share(self, media_type, data):
-        share_wid = ShareWidget(media_type=media_type, data=data)
-        try:
-            self.show_extra_ui(share_wid)
-        except Exception as e:
-            log.error(e)
-            self.close_ui()
-
-    def download_url(
-        self, url, callback, errback=None, options=None, dest=C.FILE_DEST_DOWNLOAD,
-        profile=C.PROF_KEY_NONE):
-        """Download an URL (decrypt it if necessary)
-
-        @param url(str, parse.SplitResult): url to download
-        @param callback(callable): method to call when download is complete
-        @param errback(callable, None): method to call in case of error
-            if None, default errback will be called
-        @param dest(str): where the file should be downloaded:
-            - C.FILE_DEST_DOWNLOAD: in platform download directory
-            - C.FILE_DEST_CACHE: in SàT cache
-        @param options(dict, None): options to pass to bridge.file_download_complete
-        """
-        if not isinstance(url, urlparse.ParseResult):
-            url = urlparse.urlparse(url)
-        if errback is None:
-            errback = partial(
-                self.errback,
-                title=_("Download error"),
-                message=_("Error while downloading {url}: {{msg}}").format(url=url.geturl()))
-        name = Path(url.path).name.strip() or C.FILE_DEFAULT_NAME
-        log.info(f"downloading/decrypting file {name!r}")
-        if dest == C.FILE_DEST_DOWNLOAD:
-            dest_path = files_utils.get_unique_name(Path(self.downloads_dir)/name)
-        elif dest == C.FILE_DEST_CACHE:
-            dest_path = ''
-        else:
-            raise exceptions.InternalError(f"Invalid dest_path: {dest_path!r}")
-        self.bridge.file_download_complete(
-            data_format.serialise({"uri": url.geturl()}),
-            str(dest_path),
-            '' if not options else data_format.serialise(options),
-            profile,
-            callback=callback,
-            errback=errback
-        )
-
-    def notify(self, type_, entity=None, message=None, subject=None, callback=None,
-               cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
-        super().notify(
-            type_=type_, entity=entity, message=message, subject=subject,
-            callback=callback, cb_args=cb_args, widget=widget, profile=profile)
-        self.desktop_notif(message, title=subject)
-
-    def desktop_notif(self, message, title='', duration=5):
-        global notification
-        if notification is not None:
-            try:
-                log.debug(
-                    f"sending desktop notification (duration: {duration}):\n"
-                    f"{title}\n"
-                    f"{message}"
-                )
-                notification.notify(title=title,
-                                    message=message,
-                                    app_name=C.APP_NAME,
-                                    app_icon=self.app.icon,
-                                    timeout=duration)
-            except Exception as e:
-                log.warning(_("Can't use notifications, disabling: {msg}").format(
-                    msg = e))
-                notification = None
-
-    def get_parent_wh_wrapper(self, wid):
-        """Retrieve parent WHWrapper instance managing a widget
-
-        @param wid(Widget): widget to check
-        @return (WHWrapper, None): found instance if any, else None
-        """
-        wh = self.get_ancestor_widget(wid, widgets_handler.WHWrapper)
-        if wh is None:
-            # we may have a screen
-            try:
-                sm = wid.screen_manager
-            except (exceptions.InternalError, exceptions.NotFound):
-                return None
-            else:
-                wh = self.get_ancestor_widget(sm, widgets_handler.WHWrapper)
-        return wh
-
-    def get_ancestor_widget(self, wid, cls):
-        """Retrieve an ancestor of given class
-
-        @param wid(Widget): current widget
-        @param cls(type): class of the ancestor to retrieve
-        @return (Widget, None): found instance or None
-        """
-        parent = wid.parent
-        while parent and not isinstance(parent, cls):
-            parent = parent.parent
-        return parent
--- a/cagou/core/cagou_widget.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,199 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 functools import total_ordering
-from sat.core import log as logging
-from sat.core import exceptions
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.dropdown import DropDown
-from kivy.uix.screenmanager import Screen
-from kivy.uix.textinput import TextInput
-from kivy import properties
-from cagou import G
-from .common import ActionIcon
-from . import menu
-
-
-log = logging.getLogger(__name__)
-
-
-class HeaderChoice(ButtonBehavior, BoxLayout):
-    pass
-
-
-class HeaderChoiceWidget(HeaderChoice):
-    cagou_widget = properties.ObjectProperty()
-    plugin_info = properties.ObjectProperty()
-
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-        self.bind(on_release=lambda btn: self.cagou_widget.switch_widget(
-            self.plugin_info))
-
-
-class HeaderChoiceExtraMenu(HeaderChoice):
-    pass
-
-
-class HeaderWidgetCurrent(ButtonBehavior, ActionIcon):
-    pass
-
-
-class HeaderWidgetSelector(DropDown):
-
-    def __init__(self, cagou_widget):
-        super(HeaderWidgetSelector, self).__init__()
-        plg_info_cls = cagou_widget.plugin_info_class or cagou_widget.__class__
-        for plugin_info in G.host.get_plugged_widgets(except_cls=plg_info_cls):
-            choice = HeaderChoiceWidget(
-                cagou_widget=cagou_widget,
-                plugin_info=plugin_info,
-            )
-            self.add_widget(choice)
-        main_menu = HeaderChoiceExtraMenu(on_press=self.on_extra_menu)
-        self.add_widget(main_menu)
-
-    def add_widget(self, *args):
-        widget = args[0]
-        widget.bind(minimum_width=self.set_width)
-        return super(HeaderWidgetSelector, self).add_widget(*args)
-
-    def set_width(self, choice, minimum_width):
-        self.width = max([c.minimum_width for c in self.container.children])
-
-    def on_extra_menu(self, *args):
-        self.dismiss()
-        menu.ExtraSideMenu().show()
-
-
-@total_ordering
-class CagouWidget(BoxLayout):
-    main_container = properties.ObjectProperty(None)
-    header_input = properties.ObjectProperty(None)
-    header_box = properties.ObjectProperty(None)
-    use_header_input = False
-    # set to True if you want to be able to switch between visible widgets of this
-    # class using a carousel
-    collection_carousel = False
-    # set to True if you a global ScreenManager global to all widgets of this class.
-    # The screen manager is created in WHWrapper
-    global_screen_manager = False
-    # override this if a specific class (i.e. not self.__class__) must be used for
-    # plugin info. Useful when a CagouWidget is used with global_screen_manager.
-    plugin_info_class = None
-
-    def __init__(self, **kwargs):
-        plg_info_cls = self.plugin_info_class or self.__class__
-        for p in G.host.get_plugged_widgets():
-            if p['main'] == plg_info_cls:
-                self.plugin_info = p
-                break
-        super().__init__(**kwargs)
-        self.selector = HeaderWidgetSelector(self)
-        if self.use_header_input:
-            self.header_input = TextInput(
-                background_normal=G.host.app.expand(
-                    '{media}/misc/borders/border_hollow_light.png'),
-                multiline=False,
-            )
-            self.header_input.bind(
-                on_text_validate=lambda *args: self.on_header_wid_input(),
-                text=self.on_header_wid_input_complete,
-            )
-            self.header_box.add_widget(self.header_input)
-
-    def __lt__(self, other):
-        # XXX: sorting is notably used when collection_carousel is set
-        try:
-            target = str(self.target)
-        except AttributeError:
-            target = str(list(self.targets)[0])
-            other_target = str(list(other.targets)[0])
-        else:
-            other_target = str(other.target)
-        return target < other_target
-
-    @property
-    def screen_manager(self):
-        if ((not self.global_screen_manager
-             and not (self.plugin_info_class is not None
-                      and self.plugin_info_class.global_screen_manager))):
-            raise exceptions.InternalError(
-                "screen_manager property can't be used if global_screen_manager is not "
-                "set")
-        screen = self.get_ancestor(Screen)
-        if screen is None:
-            raise exceptions.NotFound("Can't find parent Screen")
-        if screen.manager is None:
-            raise exceptions.NotFound("Can't find parent ScreenManager")
-        return screen.manager
-
-    @property
-    def whwrapper(self):
-        """Retrieve parent widget handler"""
-        return G.host.get_parent_wh_wrapper(self)
-
-    def screen_manager_init(self, screen_manager):
-        """Override this method to do init when ScreenManager is instantiated
-
-        This is only called once even if collection_carousel is used.
-        """
-        if not self.global_screen_manager:
-            raise exceptions.InternalError("screen_manager_init should not be called")
-
-    def get_ancestor(self, cls):
-        """Helper method to use host.get_ancestor_widget with self"""
-        return G.host.get_ancestor_widget(self, cls)
-
-    def switch_widget(self, plugin_info):
-        self.selector.dismiss()
-        factory = plugin_info["factory"]
-        new_widget = factory(plugin_info, None, iter(G.host.profiles))
-        G.host.switch_widget(self, new_widget)
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            # we go back to root screen
-            G.host.switch_widget(self)
-            return True
-
-    def on_header_wid_input(self):
-        log.info("header input text entered")
-
-    def on_header_wid_input_complete(self, wid, text):
-        return
-
-    def on_touch_down(self, touch):
-        if self.collide_point(*touch.pos):
-            G.host.selected_widget = self
-        return super(CagouWidget, self).on_touch_down(touch)
-
-    def header_input_add_extra(self, widget):
-        """add a widget on the right of header input"""
-        self.header_box.add_widget(widget)
-
-    def on_visible(self):
-        pass
-        # log.debug(u"{self} is visible".format(self=self))
-
-    def on_not_visible(self):
-        pass
-        # log.debug(u"{self} is not visible anymore".format(self=self))
--- a/cagou/core/common.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,481 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-"""common simple widgets"""
-
-import json
-from functools import partial, total_ordering
-from kivy.uix.widget import Widget
-from kivy.uix.label import Label
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.uix.behaviors import ToggleButtonBehavior
-from kivy.uix.stacklayout import StackLayout
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.scrollview import ScrollView
-from kivy.event import EventDispatcher
-from kivy.metrics import dp
-from kivy import properties
-from sat.core.i18n import _
-from sat.core import log as logging
-from sat.tools.common import data_format
-from sat_frontends.quick_frontend import quick_chat
-from .constants import Const as C
-from .common_widgets import CategorySeparator
-from .image import Image, AsyncImage
-from cagou import G
-
-log = logging.getLogger(__name__)
-
-UNKNOWN_SYMBOL = 'Unknown symbol name'
-
-
-class IconButton(ButtonBehavior, Image):
-    pass
-
-
-class Avatar(Image):
-    data = properties.DictProperty(allownone=True)
-
-    def on_kv_post(self, __):
-        if not self.source:
-            self.source = G.host.get_default_avatar()
-
-    def on_data(self, __, data):
-        if data is None:
-            self.source = G.host.get_default_avatar()
-        else:
-            self.source = data['path']
-
-
-class NotifLabel(Label):
-    pass
-
-@total_ordering
-class ContactItem(BoxLayout):
-    """An item from ContactList
-
-    The item will drawn as an icon (JID avatar) with its jid below.
-    If "badge_text" is set, a label with the text will be drawn above the avatar.
-    """
-    base_width = dp(150)
-    avatar_layout = properties.ObjectProperty()
-    avatar = properties.ObjectProperty()
-    badge = properties.ObjectProperty(allownone=True)
-    badge_text = properties.StringProperty('')
-    profile = properties.StringProperty()
-    data = properties.DictProperty()
-    jid = properties.StringProperty('')
-
-    def on_kv_post(self, __):
-        if ((self.profile and self.jid and self.data is not None
-             and ('avatar' not in self.data or 'nicknames' not in self.data))):
-            G.host.bridge.identity_get(
-                self.jid, ['avatar', 'nicknames'], True, self.profile,
-                callback=self._identity_get_cb,
-                errback=partial(
-                    G.host.errback,
-                    message=_("Can't retrieve identity for {jid}: {{msg}}").format(
-                        jid=self.jid)
-                )
-            )
-
-    def _identity_get_cb(self, identity_raw):
-        identity_data = data_format.deserialise(identity_raw)
-        self.data.update(identity_data)
-
-    def on_badge_text(self, wid, text):
-        if text:
-            if self.badge is not None:
-                self.badge.text = text
-            else:
-                self.badge = NotifLabel(
-                    pos_hint={"right": 0.8, "y": 0},
-                    text=text,
-                )
-                self.avatar_layout.add_widget(self.badge)
-        else:
-            if self.badge is not None:
-                self.avatar_layout.remove_widget(self.badge)
-                self.badge = None
-
-    def __lt__(self, other):
-        return self.jid < other.jid
-
-
-class ContactButton(ButtonBehavior, ContactItem):
-    pass
-
-
-class JidItem(BoxLayout):
-    bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1])
-    color = properties.ListProperty([1, 1, 1, 1])
-    jid = properties.StringProperty()
-    profile = properties.StringProperty()
-    nick = properties.StringProperty()
-    avatar = properties.ObjectProperty()
-
-    def on_avatar(self, wid, jid_):
-        if self.jid and self.profile:
-            self.get_image()
-
-    def on_jid(self, wid, jid_):
-        if self.profile and self.avatar:
-            self.get_image()
-
-    def on_profile(self, wid, profile):
-        if self.jid and self.avatar:
-            self.get_image()
-
-    def get_image(self):
-        host = G.host
-        if host.contact_lists[self.profile].is_room(self.jid.bare):
-            self.avatar.opacity = 0
-            self.avatar.source = ""
-        else:
-            self.avatar.source = (
-                host.get_avatar(self.jid, profile=self.profile)
-                or host.get_default_avatar(self.jid)
-            )
-
-
-class JidButton(ButtonBehavior, JidItem):
-    pass
-
-
-class JidToggle(ToggleButtonBehavior, JidItem):
-    selected_color = properties.ListProperty(C.COLOR_SEC_DARK)
-
-
-class Symbol(Label):
-    symbol_map = None
-    symbol = properties.StringProperty()
-
-    def __init__(self, **kwargs):
-        if self.symbol_map is None:
-            with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f:
-                fontello_conf = json.load(f)
-            Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']}
-
-        super(Symbol, self).__init__(**kwargs)
-
-    def on_symbol(self, instance, symbol):
-        try:
-            code = self.symbol_map[symbol]
-        except KeyError:
-            log.warning(_("Invalid symbol {symbol}").format(symbol=symbol))
-        else:
-            self.text = chr(code)
-
-
-class SymbolButton(ButtonBehavior, Symbol):
-    pass
-
-
-class SymbolLabel(BoxLayout):
-    symbol = properties.StringProperty("")
-    text = properties.StringProperty("")
-    color = properties.ListProperty(C.COLOR_SEC)
-    bold = properties.BooleanProperty(True)
-    symbol_wid = properties.ObjectProperty()
-    label = properties.ObjectProperty()
-
-
-class SymbolButtonLabel(ButtonBehavior, SymbolLabel):
-    pass
-
-
-class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel):
-    pass
-
-
-class ActionSymbol(Symbol):
-    pass
-
-
-class ActionIcon(BoxLayout):
-    plugin_info = properties.DictProperty()
-
-    def on_plugin_info(self, instance, plugin_info):
-        self.clear_widgets()
-        try:
-            symbol = plugin_info['icon_symbol']
-        except KeyError:
-            icon_src = plugin_info['icon_medium']
-            icon_wid = Image(source=icon_src, allow_stretch=True)
-            self.add_widget(icon_wid)
-        else:
-            icon_wid = ActionSymbol(symbol=symbol)
-            self.add_widget(icon_wid)
-
-
-class SizedImage(AsyncImage):
-    """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT"""
-    # following properties are desired height/width
-    # i.e. the ones specified in height/width attributes of <img>
-    # (or wanted for whatever reason)
-    # set to None to ignore them
-    target_height = properties.NumericProperty(allownone=True)
-    target_width = properties.NumericProperty(allownone=True)
-
-    def __init__(self, **kwargs):
-        # best calculated size
-        self._best_width = self._best_height = 100
-        super().__init__(**kwargs)
-
-    def on_texture(self, instance, texture):
-        """Adapt the size according to max size and target_*"""
-        if texture is None:
-            return
-        max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT)
-        width, height = texture.size
-        if self.target_width:
-            width = min(width, self.target_width)
-        if width > max_width:
-            width = C.IMG_MAX_WIDTH
-
-        height = width / self.image_ratio
-
-        if self.target_height:
-            height = min(height, self.target_height)
-
-        if height > max_height:
-            height = max_height
-            width = height * self.image_ratio
-
-        self.width, self.height = self._best_width, self._best_height = width, height
-
-    def on_parent(self, instance, parent):
-        if parent is not None:
-            parent.bind(width=self.on_parent_width)
-
-    def on_parent_width(self, instance, width):
-        if self._best_width > width:
-            self.width = width
-            self.height = width / self.image_ratio
-        else:
-            self.width, self.height = self._best_width, self._best_height
-
-
-class JidSelectorCategoryLayout(StackLayout):
-    pass
-
-
-class JidSelector(ScrollView, EventDispatcher):
-    layout = properties.ObjectProperty(None)
-    # if item_class is changed, the properties must be the same as for ContactButton
-    # and ordering must be supported
-    item_class = properties.ObjectProperty(ContactButton)
-    add_separators = properties.ObjectProperty(True)
-    # list of item to show, can be:
-    #    - a well-known string which can be:
-    #       * "roster": all roster jids
-    #       * "opened_chats": all opened chat widgets
-    #       * "bookmarks": MUC bookmarks
-    #       A layout will be created each time and stored in the attribute of the same
-    #       name.
-    #       If add_separators is True, a CategorySeparator will be added on top of each
-    #       layout.
-    #    - a kivy Widget, which will be added to the layout (notable useful with
-    #      common_widgets.CategorySeparator)
-    #    - a callable, which must return an iterable of kwargs for ContactButton
-    to_show = properties.ListProperty(['roster'])
-
-    # TODO: roster and bookmarks must be updated in real time, like for opened_chats
-
-
-    def __init__(self, **kwargs):
-        self.register_event_type('on_select')
-        # list of layouts containing items
-        self.items_layouts = []
-        # jid to list of ContactButton instances map
-        self.items_map = {}
-        super().__init__(**kwargs)
-
-    def on_kv_post(self, wid):
-        self.update()
-
-    def on_select(self, wid):
-        pass
-
-    def on_parent(self, wid, parent):
-        if parent is None:
-            log.debug("removing listeners")
-            G.host.removeListener("contactsFilled", self.on_contacts_filled)
-            G.host.removeListener("notification", self.on_notification)
-            G.host.removeListener("notificationsClear", self.on_notifications_clear)
-            G.host.removeListener(
-                "widgetNew", self.on_widget_new, ignore_missing=True)
-            G.host.removeListener(
-                "widgetDeleted", self.on_widget_deleted, ignore_missing=True)
-        else:
-            log.debug("adding listeners")
-            G.host.addListener("contactsFilled", self.on_contacts_filled)
-            G.host.addListener("notification", self.on_notification)
-            G.host.addListener("notificationsClear", self.on_notifications_clear)
-
-    def on_contacts_filled(self, profile):
-        log.debug("on_contacts_filled event received")
-        self.update()
-
-    def on_notification(self, entity, notification_data, profile):
-        for item in self.items_map.get(entity.bare, []):
-            notifs = list(G.host.get_notifs(entity.bare, profile=profile))
-            item.badge_text = str(len(notifs))
-
-    def on_notifications_clear(self, entity, type_, profile):
-        for item in self.items_map.get(entity.bare, []):
-            item.badge_text = ''
-
-    def on_widget_new(self, wid):
-        if not isinstance(wid, quick_chat.QuickChat):
-            return
-        item = self.get_item_from_wid(wid)
-        if item is None:
-            return
-        idx = 0
-        for child in self.opened_chats.children:
-            if isinstance(child, self.item_class) and child < item:
-                break
-            idx+=1
-        self.opened_chats.add_widget(item, index=idx)
-
-    def on_widget_deleted(self, wid):
-        if not isinstance(wid, quick_chat.QuickChat):
-            return
-
-        for child in self.opened_chats.children:
-            if not isinstance(child, self.item_class):
-                continue
-            if child.jid.bare == wid.target.bare:
-                self.opened_chats.remove_widget(child)
-                break
-
-    def _create_item(self, **kwargs):
-        item = self.item_class(**kwargs)
-        jid = kwargs['jid']
-        self.items_map.setdefault(jid, []).append(item)
-        return item
-
-    def update(self):
-        log.debug("starting update")
-        self.layout.clear_widgets()
-        for item in self.to_show:
-            if isinstance(item, str):
-                if item == 'roster':
-                    self.add_roster_items()
-                elif item == 'bookmarks':
-                    self.add_bookmarks_items()
-                elif item == 'opened_chats':
-                    self.add_opened_chats_items()
-                else:
-                    log.error(f'unknown "to_show" magic string {item!r}')
-            elif isinstance(item, Widget):
-                self.layout.add_widget(item)
-            elif callable(item):
-                items_kwargs = item()
-                for item_kwargs in items_kwargs:
-                    item = self._create_item(**items_kwargs)
-                    item.bind(on_press=partial(self.dispatch, 'on_select'))
-                    self.layout.add_widget(item)
-            else:
-                log.error(f"unmanaged to_show item type: {item!r}")
-
-    def add_category_layout(self, label=None):
-        category_layout = JidSelectorCategoryLayout()
-
-        if label and self.add_separators:
-            category_layout.add_widget(CategorySeparator(text=label))
-
-        self.layout.add_widget(category_layout)
-        self.items_layouts.append(category_layout)
-        return category_layout
-
-    def get_item_from_wid(self, wid):
-        """create JidSelector item from QuickChat widget"""
-        contact_list = G.host.contact_lists[wid.profile]
-        try:
-            data=contact_list.get_item(wid.target)
-        except KeyError:
-            log.warning(f"Can't find item data for {wid.target}")
-            data={}
-        try:
-            item = self._create_item(
-                jid=wid.target,
-                data=data,
-                profile=wid.profile,
-            )
-        except Exception as e:
-            log.warning(f"Can't add contact {wid.target}: {e}")
-            return
-        notifs = list(G.host.get_notifs(wid.target, profile=wid.profile))
-        if notifs:
-            item.badge_text = str(len(notifs))
-        item.bind(on_press=partial(self.dispatch, 'on_select'))
-        return item
-
-    def add_opened_chats_items(self):
-        G.host.addListener("widgetNew", self.on_widget_new)
-        G.host.addListener("widgetDeleted", self.on_widget_deleted)
-        self.opened_chats = category_layout = self.add_category_layout(_("Opened chats"))
-        widgets = sorted(G.host.widgets.get_widgets(
-            quick_chat.QuickChat,
-            profiles = G.host.profiles,
-            with_duplicates=False))
-
-        for wid in widgets:
-            item = self.get_item_from_wid(wid)
-            if item is None:
-                continue
-            category_layout.add_widget(item)
-
-    def add_roster_items(self):
-        self.roster = category_layout = self.add_category_layout(_("Your contacts"))
-        for profile in G.host.profiles:
-            contact_list = G.host.contact_lists[profile]
-            for entity_jid in sorted(contact_list.roster):
-                item = self._create_item(
-                    jid=entity_jid,
-                    data=contact_list.get_item(entity_jid),
-                    profile=profile,
-                )
-                item.bind(on_press=partial(self.dispatch, 'on_select'))
-                category_layout.add_widget(item)
-
-    def add_bookmarks_items(self):
-        self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms"))
-        for profile in G.host.profiles:
-            profile_manager = G.host.profiles[profile]
-            try:
-                bookmarks = profile_manager._bookmarks
-            except AttributeError:
-                log.warning(f"no bookmark in cache for profile {profile}")
-                continue
-
-            contact_list = G.host.contact_lists[profile]
-            for entity_jid in bookmarks:
-                try:
-                    cache = contact_list.get_item(entity_jid)
-                except KeyError:
-                    cache = {}
-                item = self._create_item(
-                    jid=entity_jid,
-                    data=cache,
-                    profile=profile,
-                )
-                item.bind(on_press=partial(self.dispatch, 'on_select'))
-                category_layout.add_widget(item)
--- a/cagou/core/common_widgets.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,182 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-"""common advanced widgets, which can be reused everywhere."""
-
-from kivy.clock import Clock
-from kivy import properties
-from kivy.metrics import dp
-from kivy.uix.scatterlayout import ScatterLayout
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.label import Label
-from sat.core.i18n import _
-from sat.core import log as logging
-from cagou import G
-from .behaviors import TouchMenuItemBehavior
-
-log = logging.getLogger(__name__)
-
-
-class DelayedBoxLayout(BoxLayout):
-    """A BoxLayout with delayed layout, to avoid slowing down during resize"""
-    # XXX: thanks to Alexander Taylor for his blog post at
-    #      https://blog.kivy.org/2019/07/a-delayed-resize-layout-in-kivy/
-
-    do_layout_event = properties.ObjectProperty(None, allownone=True)
-    layout_delay_s = properties.NumericProperty(0.2)
-    #: set this to X to force next X layouts to be done without delay
-    dont_delay_next_layouts = properties.NumericProperty(0)
-
-    def do_layout(self, *args, **kwargs):
-        if self.do_layout_event is not None:
-            self.do_layout_event.cancel()
-        if self.dont_delay_next_layouts>0:
-            self.dont_delay_next_layouts-=1
-            super().do_layout()
-        else:
-            real_do_layout = super().do_layout
-            self.do_layout_event = Clock.schedule_once(
-                lambda dt: real_do_layout(*args, **kwargs),
-                self.layout_delay_s)
-
-
-class Identities(object):
-
-    def __init__(self, entity_ids):
-        identities = {}
-        for cat, type_, name in entity_ids:
-            identities.setdefault(cat, {}).setdefault(type_, []).append(name)
-        client = identities.get('client', {})
-        if 'pc' in client:
-            self.type = 'desktop'
-        elif 'phone' in client:
-            self.type = 'phone'
-        elif 'web' in client:
-            self.type = 'web'
-        elif 'console' in client:
-            self.type = 'console'
-        else:
-            self.type = 'desktop'
-
-        self.identities = identities
-
-    @property
-    def name(self):
-        first_identity = next(iter(self.identities.values()))
-        names = next(iter(first_identity.values()))
-        return names[0]
-
-
-class ItemWidget(TouchMenuItemBehavior, BoxLayout):
-    name = properties.StringProperty()
-    base_width = properties.NumericProperty(dp(100))
-
-
-class DeviceWidget(ItemWidget):
-
-    def __init__(self, main_wid, entity_jid, identities, **kw):
-        self.entity_jid = entity_jid
-        self.identities = identities
-        own_jid = next(iter(G.host.profiles.values())).whoami
-        self.own_device = entity_jid.bare == own_jid.bare
-        if self.own_device:
-            name = self.identities.name
-        elif self.entity_jid.node:
-            name = self.entity_jid.node
-        elif self.entity_jid == own_jid.domain:
-            name = _("your server")
-        else:
-            name = entity_jid
-
-        super(DeviceWidget, self).__init__(name=name, main_wid=main_wid, **kw)
-
-    @property
-    def profile(self):
-        return self.main_wid.profile
-
-    def get_symbol(self):
-        if self.identities.type == 'desktop':
-            return 'desktop'
-        elif self.identities.type == 'phone':
-            return 'mobile'
-        elif self.identities.type == 'web':
-            return 'globe'
-        elif self.identities.type == 'console':
-            return 'terminal'
-        else:
-            return 'desktop'
-
-    def do_item_action(self, touch):
-        pass
-
-
-class CategorySeparator(Label):
-    pass
-
-
-class ImageViewer(ScatterLayout):
-    source = properties.StringProperty()
-
-    def on_touch_down(self, touch):
-        if touch.is_double_tap:
-            self.reset()
-            return True
-        return super().on_touch_down(touch)
-
-    def reset(self):
-        self.rotation = 0
-        self.scale = 1
-        self.x = 0
-        self.y = 0
-
-
-class ImagesGallery(BoxLayout):
-    """Show list of images in a Carousel, with some controls to downloads"""
-    sources = properties.ListProperty()
-    carousel = properties.ObjectProperty()
-    previous_slide = None
-
-    def on_kv_post(self, __):
-        self.on_sources(None, self.sources)
-        self.previous_slide = self.carousel.current_slide
-        self.carousel.bind(current_slide=self.on_slide_change)
-
-    def on_parent(self, __, parent):
-        # we hide the head widget to have full screen
-        G.host.app.show_head_widget(not bool(parent), animation=False)
-
-    def on_sources(self, __, sources):
-        if not sources or not self.carousel:
-            return
-        self.carousel.clear_widgets()
-        for source in sources:
-            img = ImageViewer(
-                source=source,
-            )
-            self.carousel.add_widget(img)
-
-    def on_slide_change(self, __, slide):
-        if isinstance(self.previous_slide, ImageViewer):
-            self.previous_slide.reset()
-
-        self.previous_slide = slide
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            G.host.close_ui()
-            return True
--- a/cagou/core/config.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-#!/usr//bin/env python2
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-"""This module keep an open instance of sat configuration"""
-
-from sat.tools import config
-sat_conf = config.parse_main_conf()
-
-
-def config_get(section, name, default):
-    return config.config_get(sat_conf, section, name, default)
--- a/cagou/core/constants.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: a SàT frontend
-# Copyright (C) 2016-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 sat_frontends.quick_frontend import constants
-import cagou
-
-# Kivy must not be imported here due to log hijacking see core/kivy_hack.py
-
-
-class Const(constants.Const):
-    APP_NAME = "Libervia Desktop"
-    APP_COMPONENT = "desktop/mobile"
-    APP_NAME_ALT = "Cagou"
-    APP_NAME_FILE = "libervia_desktop"
-    APP_VERSION = cagou.__version__
-    LOG_OPT_SECTION = APP_NAME.lower()
-    CONFIG_SECTION = "desktop"
-    WID_SELECTOR = 'selector'
-    ICON_SIZES = ('small', 'medium')  # small = 32, medium = 44
-    DEFAULT_WIDGET_ICON = '{media}/misc/black.png'
-
-    BTN_HEIGHT = '35dp'
-
-    PLUG_TYPE_WID = 'wid'
-    PLUG_TYPE_TRANSFER = 'transfer'
-
-    TRANSFER_UPLOAD = "upload"
-    TRANSFER_SEND = "send"
-
-    COLOR_PRIM = (0.98, 0.98, 0.98, 1)
-    COLOR_PRIM_LIGHT = (1, 1, 1, 1)
-    COLOR_PRIM_DARK = (0.78, 0.78, 0.78, 1)
-    COLOR_SEC = (0.27, 0.54, 1.0, 1)
-    COLOR_SEC_LIGHT = (0.51, 0.73, 1.0, 1)
-    COLOR_SEC_DARK = (0.0, 0.37, 0.8, 1)
-
-    COLOR_INFO = COLOR_PRIM_LIGHT
-    COLOR_WARNING = (1.0, 1.0, 0.0, 1)
-    COLOR_ERROR = (1.0, 0.0, 0.0, 1)
-
-    COLOR_BTN_LIGHT = (0.4, 0.4, 0.4, 1)
-
-    # values are in dp
-    IMG_MAX_WIDTH = 400
-    IMG_MAX_HEIGHT = 400
-
-    # files
-    FILE_DEST_DOWNLOAD = "DOWNLOAD"
-    FILE_DEST_CACHE = "CACHE"
--- a/cagou/core/dialog.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,44 +0,0 @@
-#!/usr//bin/env python2
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-"""generic dialogs"""
-
-from sat.core.i18n import _
-from cagou.core.constants import Const as C
-from kivy.uix.boxlayout import BoxLayout
-from kivy import properties
-from sat.core import log as logging
-
-log = logging.getLogger(__name__)
-
-
-class MessageDialog(BoxLayout):
-    title = properties.StringProperty()
-    message = properties.StringProperty()
-    level = properties.OptionProperty(C.XMLUI_DATA_LVL_INFO, options=C.XMLUI_DATA_LVLS)
-    close_cb = properties.ObjectProperty()
-
-
-class ConfirmDialog(BoxLayout):
-    title = properties.StringProperty()
-    message = properties.StringProperty(_("Are you sure?"))
-    # callback for no/cancel
-    no_cb = properties.ObjectProperty()
-    # callback for yes/ok
-    yes_cb = properties.ObjectProperty()
--- a/cagou/core/image.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-import mimetypes
-from functools import partial
-from kivy.uix import image as kivy_img
-from sat.core import log as logging
-from sat.tools.common import data_format
-from cagou import G
-
-log = logging.getLogger(__name__)
-
-
-class Image(kivy_img.Image):
-    """Image widget which accept source without extension"""
-    SVG_CONVERT_EXTRA = {'width': 128, 'height': 128}
-
-    def __init__(self, **kwargs):
-        self.register_event_type('on_error')
-        super().__init__(**kwargs)
-
-    def _image_convert_cb(self, path):
-        self.source = path
-
-    def texture_update(self, *largs):
-        if self.source:
-            if mimetypes.guess_type(self.source, strict=False)[0] == 'image/svg+xml':
-                log.debug(f"Converting SVG image at {self.source} to PNG")
-                G.host.bridge.image_convert(
-                    self.source,
-                    "",
-                    data_format.serialise(self.SVG_CONVERT_EXTRA),
-                    "",
-                    callback=self._image_convert_cb,
-                    errback=partial(
-                        G.host.errback,
-                        message=f"Can't load image at {self.source}: {{msg}}"
-                    )
-                )
-                return
-
-        super().texture_update(*largs)
-        if self.source and self.texture is None:
-            log.warning(
-                f"Image {self.source} has not been imported correctly, replacing by "
-                f"empty one")
-            # FIXME: temporary image, to be replaced by something showing that something
-            #   went wrong
-            self.source = G.host.app.expand(
-                "{media}/misc/borders/border_hollow_black.png")
-            self.dispatch('on_error', Exception(f"Can't load source {self.source}"))
-
-    def on_error(self, err):
-        pass
-
-
-class AsyncImage(kivy_img.AsyncImage):
-    """AsyncImage which accept file:// schema"""
-
-    def _load_source(self, *args):
-        if self.source.startswith('file://'):
-            self.source = self.source[7:]
-        else:
-            super(AsyncImage, self)._load_source(*args)
--- a/cagou/core/kivy_hack.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,70 +0,0 @@
-#!/usr//bin/env python2
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-CONF_KIVY_LEVEL = 'log_kivy_level'
-
-
-def do_hack():
-    """work around Kivy hijacking of logs and arguments"""
-    # we remove args so kivy doesn't use them
-    # this is need to avoid kivy breaking QuickApp args handling
-    import sys
-    ori_argv = sys.argv[:]
-    sys.argv = sys.argv[:1]
-    from .constants import Const as C
-    from sat.core import log_config
-    log_config.sat_configure(C.LOG_BACKEND_STANDARD, C)
-
-    from . import config
-    kivy_level = config.config_get(C.CONFIG_SECTION, CONF_KIVY_LEVEL, 'follow').upper()
-
-    # kivy handles its own loggers, we don't want that!
-    import logging
-    root_logger = logging.root
-    kivy_logger = logging.getLogger('kivy')
-    ori_addHandler = kivy_logger.addHandler
-    kivy_logger.addHandler = lambda __: None
-    ori_setLevel = kivy_logger.setLevel
-    if kivy_level == 'FOLLOW':
-        # level is following SàT level
-        kivy_logger.setLevel = lambda level: None
-    elif kivy_level == 'KIVY':
-        # level will be set by Kivy according to its own conf
-        pass
-    elif kivy_level in C.LOG_LEVELS:
-        kivy_logger.setLevel(kivy_level)
-        kivy_logger.setLevel = lambda level: None
-    else:
-        raise ValueError("Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level))
-
-    # during import kivy set its logging stuff
-    import kivy
-    kivy # to avoid pyflakes warning
-
-    # we want to separate kivy logs from other logs
-    logging.root = root_logger
-    from kivy import logger
-    sys.stderr = logger.previous_stderr
-
-    # we restore original methods
-    kivy_logger.addHandler = ori_addHandler
-    kivy_logger.setLevel = ori_setLevel
-
-    # we restore original arguments
-    sys.argv = ori_argv
--- a/cagou/core/menu.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,348 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core.i18n import _
-from sat.core import log as logging
-from cagou.core.constants import Const as C
-from cagou.core.common import JidToggle
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.label import Label
-from kivy.uix.button import Button
-from kivy.uix.popup import Popup
-from .behaviors import FilterBehavior
-from kivy import properties
-from kivy.core.window import Window
-from kivy.animation import Animation
-from kivy.metrics import dp
-from cagou import G
-from functools import partial
-import webbrowser
-
-log = logging.getLogger(__name__)
-
-ABOUT_TITLE = _("About {}").format(C.APP_NAME)
-ABOUT_CONTENT = _("""[b]{app_name} ({app_name_alt})[/b]
-
-[u]{app_name} version[/u]:
-{version}
-
-[u]backend version[/u]:
-{backend_version}
-
-{app_name} is a libre communication tool based on libre standard XMPP.
-
-{app_name} is part of the "Libervia" project ({app_component} frontend)
-more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color]
-""")
-
-
-class AboutContent(Label):
-
-    def on_ref_press(self, value):
-        if value == "website":
-            webbrowser.open("https://salut-a-toi.org")
-
-
-class AboutPopup(Popup):
-
-    def on_touch_down(self, touch):
-        if self.collide_point(*touch.pos):
-            self.dismiss()
-        return super(AboutPopup, self).on_touch_down(touch)
-
-
-class TransferItem(BoxLayout):
-    plug_info = properties.DictProperty()
-
-    def on_touch_up(self, touch):
-        if not self.collide_point(*touch.pos):
-            return super(TransferItem, self).on_touch_up(touch)
-        else:
-            transfer_menu = self.parent
-            while not isinstance(transfer_menu, TransferMenu):
-                transfer_menu = transfer_menu.parent
-            transfer_menu.do_callback(self.plug_info)
-            return True
-
-
-class SideMenu(BoxLayout):
-    size_hint_close = (0, 1)
-    size_hint_open = (0.4, 1)
-    size_close = (100, 100)
-    size_open = (0, 0)
-    bg_color = properties.ListProperty([0, 0, 0, 1])
-    # callback will be called with arguments relevant to menu
-    callback = properties.ObjectProperty()
-    # call do_callback even when menu is cancelled
-    callback_on_close = properties.BooleanProperty(False)
-    # cancel callback need to remove the widget for UI
-    # will be called with the widget to remove as argument
-    cancel_cb = properties.ObjectProperty()
-
-    def __init__(self, **kwargs):
-        super(SideMenu, self).__init__(**kwargs)
-        if self.cancel_cb is None:
-            self.cancel_cb = self.on_menu_cancelled
-
-    def _set_anim_kw(self, kw, size_hint, size):
-        """Set animation keywords
-
-        for each value of size_hint it is used if not None,
-        else size is used.
-        If one value of size is bigger than the respective one of Window
-        the one of Window is used
-        """
-        size_hint_x, size_hint_y = size_hint
-        width, height = size
-        if size_hint_x is not None:
-            kw['size_hint_x'] = size_hint_x
-        elif width is not None:
-            kw['width'] = min(width, Window.width)
-
-        if size_hint_y is not None:
-            kw['size_hint_y'] = size_hint_y
-        elif height is not None:
-            kw['height'] = min(height, Window.height)
-
-    def show(self, caller_wid=None):
-        Window.bind(on_keyboard=self.key_input)
-        G.host.app.root.add_widget(self)
-        kw = {'d': 0.3, 't': 'out_back'}
-        self._set_anim_kw(kw, self.size_hint_open, self.size_open)
-        Animation(**kw).start(self)
-
-    def _remove_from_parent(self, anim, menu):
-        # self.parent can already be None if the widget has been removed by a callback
-        # before the animation started.
-        if self.parent is not None:
-            self.parent.remove_widget(self)
-
-    def hide(self):
-        Window.unbind(on_keyboard=self.key_input)
-        kw = {'d': 0.2}
-        self._set_anim_kw(kw, self.size_hint_close, self.size_close)
-        anim = Animation(**kw)
-        anim.bind(on_complete=self._remove_from_parent)
-        anim.start(self)
-        if self.callback_on_close:
-            self.do_callback()
-
-    def on_touch_down(self, touch):
-        # we remove the menu if we click outside
-        # else we want to handle the event, but not
-        # transmit it to parents
-        if not self.collide_point(*touch.pos):
-            self.hide()
-        else:
-            return super(SideMenu, self).on_touch_down(touch)
-        return True
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            self.hide()
-            return True
-
-    def on_menu_cancelled(self, wid, cleaning_cb=None):
-        self._close_ui(wid)
-        if cleaning_cb is not None:
-            cleaning_cb()
-
-    def _close_ui(self, wid):
-        G.host.close_ui()
-
-    def do_callback(self, *args, **kwargs):
-        log.warning("callback not implemented")
-
-
-class ExtraMenuItem(Button):
-    pass
-
-
-class ExtraSideMenu(SideMenu):
-    """Menu with general app actions like showing the about widget"""
-
-    def __init__(self, **kwargs):
-        super().__init__(**kwargs)
-        G.local_platform.on_extra_menu_init(self)
-
-    def add_item(self, label, callback):
-        self.add_widget(
-            ExtraMenuItem(
-                text=label,
-                on_press=partial(self.on_item_press, callback=callback),
-            ),
-            # we want the new item above "About" and last empty Widget
-            index=2)
-
-    def on_item_press(self, *args, callback):
-        self.hide()
-        callback()
-
-    def on_about(self):
-        self.hide()
-        about = AboutPopup()
-        about.title = ABOUT_TITLE
-        about.content = AboutContent(
-            text=ABOUT_CONTENT.format(
-                app_name = C.APP_NAME,
-                app_name_alt = C.APP_NAME_ALT,
-                app_component = C.APP_COMPONENT,
-                backend_version = G.host.backend_version,
-                version=G.host.version
-            ),
-            markup=True)
-        about.open()
-
-
-class TransferMenu(SideMenu):
-    """transfer menu which handle display and callbacks"""
-    # callback will be called with path to file to transfer
-    # profiles if set will be sent to transfer widget, may be used to get specific files
-    profiles = properties.ObjectProperty()
-    transfer_txt = properties.StringProperty()
-    transfer_info = properties.ObjectProperty()
-    upload_btn = properties.ObjectProperty()
-    encrypted = properties.BooleanProperty(False)
-    items_layout = properties.ObjectProperty()
-    size_hint_close = (1, 0)
-    size_hint_open = (1, 0.5)
-
-    def __init__(self, **kwargs):
-        super(TransferMenu, self).__init__(**kwargs)
-        if self.profiles is None:
-            self.profiles = iter(G.host.profiles)
-        for plug_info in G.host.get_plugged_widgets(type_=C.PLUG_TYPE_TRANSFER):
-            item = TransferItem(
-                plug_info = plug_info
-                )
-            self.items_layout.add_widget(item)
-
-    def on_kv_post(self, __):
-        self.update_transfer_info()
-
-    def get_transfer_info(self):
-        if self.upload_btn.state == "down":
-            # upload
-            if self.encrypted:
-                return _(
-                    "The file will be [color=00aa00][b]encrypted[/b][/color] and sent to "
-                    "your server\nServer admin(s) can delete the file, but they won't be "
-                    "able to see its content"
-                )
-            else:
-                return _(
-                    "Beware! The file will be sent to your server and stay "
-                    "[color=ff0000][b]unencrypted[/b][/color] there\nServer admin(s) "
-                    "can see the file, and they choose how, when and if it will be "
-                    "deleted"
-                )
-        else:
-            # P2P
-            if self.encrypted:
-                return _(
-                    "The file will be sent [color=ff0000][b]unencrypted[/b][/color] "
-                    "directly to your contact (it may be transiting by the "
-                    "server if direct connection is not possible).\n[color=ff0000]"
-                    "Please note that end-to-end encryption is not yet implemented for "
-                    "P2P transfer."
-                )
-            else:
-                return _(
-                    "The file will be sent [color=ff0000][b]unencrypted[/b][/color] "
-                    "directly to your contact (it [i]may be[/i] transiting by the "
-                    "server if direct connection is not possible)."
-                )
-
-    def update_transfer_info(self):
-        self.transfer_info.text = self.get_transfer_info()
-
-    def _on_transfer_cb(self, file_path, cleaning_cb=None, external=False, wid_cont=None):
-        if not external:
-            wid = wid_cont[0]
-            self._close_ui(wid)
-        self.callback(
-            file_path,
-            transfer_type = (C.TRANSFER_UPLOAD
-                if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND),
-            cleaning_cb=cleaning_cb,
-        )
-
-    def _check_plugin_permissions_cb(self, plug_info):
-        external = plug_info.get('external', False)
-        wid_cont = []
-        wid_cont.append(plug_info['factory'](
-            plug_info,
-            partial(self._on_transfer_cb, external=external, wid_cont=wid_cont),
-            self.cancel_cb,
-            self.profiles))
-        if not external:
-            G.host.show_extra_ui(wid_cont[0])
-
-    def do_callback(self, plug_info):
-        self.parent.remove_widget(self)
-        if self.callback is None:
-            log.warning("TransferMenu callback is not set")
-        else:
-            G.local_platform.check_plugin_permissions(
-                plug_info,
-                callback=partial(self._check_plugin_permissions_cb, plug_info),
-                errback=lambda: G.host.add_note(
-                    _("permission refused"),
-                    _("this transfer menu can't be used if you refuse the requested "
-                      "permission"),
-                    C.XMLUI_DATA_LVL_WARNING)
-            )
-
-
-class EntitiesSelectorMenu(SideMenu, FilterBehavior):
-    """allow to select entities from roster"""
-    profiles = properties.ObjectProperty()
-    layout = properties.ObjectProperty()
-    instructions = properties.StringProperty(_("Please select entities"))
-    filter_input = properties.ObjectProperty()
-    size_hint_close = (None, 1)
-    size_hint_open = (None, 1)
-    size_open = (dp(250), 100)
-    size_close = (0, 100)
-
-    def __init__(self, **kwargs):
-        super(EntitiesSelectorMenu, self).__init__(**kwargs)
-        self.filter_input.bind(text=self.do_filter_input)
-        if self.profiles is None:
-            self.profiles = iter(G.host.profiles)
-        for profile in self.profiles:
-            for jid_, jid_data in G.host.contact_lists[profile].all_iter:
-                jid_wid = JidToggle(
-                    jid=jid_,
-                    profile=profile)
-                self.layout.add_widget(jid_wid)
-
-    def do_callback(self):
-        if self.callback is not None:
-            jids = [c.jid for c in self.layout.children if c.state == 'down']
-            self.callback(jids)
-
-    def do_filter_input(self, filter_input, text):
-        self.layout.spacing = 0 if text else dp(5)
-        self.do_filter(self.layout,
-                       text,
-                       lambda c: c.jid,
-                       width_cb=lambda c: c.width,
-                       height_cb=lambda c: dp(70))
--- a/cagou/core/patches.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-import urllib.request, urllib.error, urllib.parse
-import ssl
-
-
-def disable_tls_validation():
-    # allow to disable certificate validation
-    ctx_no_verify = ssl.create_default_context()
-    ctx_no_verify.check_hostname = False
-    ctx_no_verify.verify_mode = ssl.CERT_NONE
-
-    class HTTPSHandler(urllib.request.HTTPSHandler):
-        no_certificate_check = False
-
-        def __init__(self, *args, **kwargs):
-            urllib.request._HTTPSHandler_ori.__init__(self, *args, **kwargs)
-            if self.no_certificate_check:
-                self._context = ctx_no_verify
-
-    urllib.request._HTTPSHandler_ori = urllib.request.HTTPSHandler
-    urllib.request.HTTPSHandler = HTTPSHandler
-    urllib.request.HTTPSHandler.no_certificate_check = True
--- a/cagou/core/platform_/__init__.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,29 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 kivy import utils as kivy_utils
-
-
-def create():
-    """Factory method to create the platform instance adapted to running one"""
-    if kivy_utils.platform == "android":
-        from .android import Platform
-        return Platform()
-    else:
-        from .base import Platform
-        return Platform()
--- a/cagou/core/platform_/android.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,487 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-import sys
-import os
-import socket
-import json
-from functools import partial
-from urllib.parse import urlparse
-from pathlib import Path
-import shutil
-import mimetypes
-from jnius import autoclass, cast, JavaException
-from android import activity
-from android.permissions import request_permissions, Permission
-from kivy.clock import Clock
-from kivy.uix.label import Label
-from sat.core.i18n import _
-from sat.core import log as logging
-from sat.tools.common import data_format
-from sat_frontends.tools import jid
-from cagou.core.constants import Const as C
-from cagou.core import dialog
-from cagou import G
-from .base import Platform as BasePlatform
-
-
-log = logging.getLogger(__name__)
-
-# permission that are necessary to have Cagou running properly
-PERMISSION_MANDATORY = [
-    Permission.READ_EXTERNAL_STORAGE,
-    Permission.WRITE_EXTERNAL_STORAGE,
-]
-
-service = autoclass('org.libervia.cagou.ServiceBackend')
-PythonActivity = autoclass('org.kivy.android.PythonActivity')
-mActivity = PythonActivity.mActivity
-Intent = autoclass('android.content.Intent')
-AndroidString = autoclass('java.lang.String')
-Uri = autoclass('android.net.Uri')
-ImagesMedia = autoclass('android.provider.MediaStore$Images$Media')
-AudioMedia = autoclass('android.provider.MediaStore$Audio$Media')
-VideoMedia = autoclass('android.provider.MediaStore$Video$Media')
-URLConnection = autoclass('java.net.URLConnection')
-
-DISPLAY_NAME = '_display_name'
-DATA = '_data'
-
-
-STATE_RUNNING = b"running"
-STATE_PAUSED = b"paused"
-STATE_STOPPED = b"stopped"
-SOCKET_DIR = "/data/data/org.libervia.cagou/"
-SOCKET_FILE = ".socket"
-INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction")
-
-
-class Platform(BasePlatform):
-    send_button_visible = True
-
-    def __init__(self):
-        super().__init__()
-        # cache for callbacks to run when profile is plugged
-        self.cache = []
-
-    def init_platform(self):
-        # sys.platform is "linux" on android by default
-        # so we change it to allow backend to detect android
-        sys.platform = "android"
-        C.PLUGIN_EXT = 'pyc'
-
-    def on_host_init(self, host):
-        argument = ''
-        service.start(mActivity, argument)
-
-        activity.bind(on_new_intent=self.on_new_intent)
-        self.cache.append((self.on_new_intent, mActivity.getIntent()))
-        self.last_selected_wid = None
-        self.restore_selected_wid = True
-        host.addListener('profile_plugged', self.on_profile_plugged)
-        host.addListener('selected', self.on_selected_widget)
-        local_dir = Path(host.config_get('', 'local_dir')).resolve()
-        self.tmp_dir = local_dir / 'tmp'
-        # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line
-        # above
-        assert self.tmp_dir.resolve() != local_dir
-        # we reset tmp dir on each run, to be sure that there is no residual file
-        if self.tmp_dir.exists():
-            shutil.rmtree(self.tmp_dir)
-        self.tmp_dir.mkdir(0o700, parents=True)
-
-    def on_init_frontend_state(self):
-        # XXX: we use a separated socket instead of bridge because if we
-        #      try to call a bridge method in on_pause method, the call data
-        #      is not written before the actual pause
-        s = self._frontend_status_socket = socket.socket(
-            socket.AF_UNIX, socket.SOCK_STREAM)
-        s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE))
-        s.sendall(STATE_RUNNING)
-
-    def profile_autoconnect_get_cb(self, profile=None):
-        if profile is not None:
-            G.host.options.profile = profile
-        G.host.post_init()
-
-    def profile_autoconnect_get_eb(self, failure_):
-        log.error(f"Error while getting profile to autoconnect: {failure_}")
-        G.host.post_init()
-
-    def _show_perm_warning(self, permissions):
-        root_wid = G.host.app.root
-        perm_warning = Label(
-            size_hint=(1, 1),
-            text_size=(root_wid.width, root_wid.height),
-            font_size='22sp',
-            bold=True,
-            color=(0.67, 0, 0, 1),
-            halign='center',
-            valign='center',
-            text=_(
-            "Requested permissions are mandatory to run Cagou, if you don't "
-            "accept them, Cagou can't run properly. Please accept following "
-            "permissions, or set them in Android settings for Cagou:\n"
-            "{permissions}\n\nCagou will be closed in 20 s").format(
-                permissions='\n'.join(p.split('.')[-1] for p in permissions)))
-        root_wid.clear_widgets()
-        root_wid.add_widget(perm_warning)
-        Clock.schedule_once(lambda *args: G.host.app.stop(), 20)
-
-    def permission_cb(self, permissions, grant_results):
-        if not all(grant_results):
-            # we keep asking until they are accepted, as we can't run properly
-            # without them
-            # TODO: a message explaining why permission is needed should be printed
-            # TODO: the storage permission is mainly used to set download_dir, we should
-            #   be able to run Cagou without it.
-            if not hasattr(self, 'perms_counter'):
-                self.perms_counter = 0
-            self.perms_counter += 1
-            if self.perms_counter > 5:
-                Clock.schedule_once(
-                    lambda *args: self._show_perm_warning(permissions),
-                    0)
-                return
-
-            perm_dict = dict(zip(permissions, grant_results))
-            log.warning(
-                f"not all mandatory permissions are granted, requesting again: "
-                f"{perm_dict}")
-            request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb)
-            return
-
-        Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get(
-            callback=self.profile_autoconnect_get_cb,
-            errback=self.profile_autoconnect_get_eb),
-            0)
-
-    def do_post_init(self):
-        request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb)
-        return False
-
-    def private_data_get_cb(self, data_s, profile):
-        data = data_format.deserialise(data_s, type_check=None)
-        if data is not None and self.restore_selected_wid:
-            log.debug(f"restoring previous widget {data}")
-            try:
-                name = data['name']
-                target = data['target']
-            except KeyError as e:
-                log.error(f"Bad data format for selected widget: {e}\ndata={data}")
-                return
-            if target:
-                target = jid.JID(data['target'])
-            plugin_info = G.host.get_plugin_info(name=name)
-            if plugin_info is None:
-                log.warning("Can't restore unknown plugin: {name}")
-                return
-            factory = plugin_info['factory']
-            G.host.switch_widget(
-                None,
-                factory(plugin_info, target=target, profiles=[profile])
-            )
-
-    def on_profile_plugged(self, profile):
-        log.debug("ANDROID profile_plugged")
-        G.host.bridge.param_set(
-            "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile,
-            callback=lambda: log.info(f"profile {profile} autoconnection set"),
-            errback=lambda: log.error(f"can't set {profile} autoconnection"))
-        for method, *args in self.cache:
-            method(*args)
-        del self.cache
-        G.host.removeListener("profile_plugged", self.on_profile_plugged)
-        # we restore the stored widget if any
-        # user will then go back to where they was when the frontend was closed
-        G.host.bridge.private_data_get(
-            "cagou", "selected_widget", profile,
-            callback=partial(self.private_data_get_cb, profile=profile),
-            errback=partial(
-                G.host.errback,
-                title=_("can't get selected widget"),
-                message=_("error while retrieving selected widget: {msg}"))
-        )
-
-    def on_selected_widget(self, wid):
-        """Store selected widget in backend, to restore it on next startup"""
-        if self.last_selected_wid == None:
-            self.last_selected_wid = wid
-            # we skip the first selected widget, as we'll restore stored one if possible
-            return
-
-        self.last_selected_wid = wid
-
-        try:
-            plugin_info = wid.plugin_info
-        except AttributeError:
-            log.warning(f"No plugin info found for {wid}, can't store selected widget")
-            return
-
-        try:
-            profile = next(iter(wid.profiles))
-        except (AttributeError, StopIteration):
-            profile = None
-
-        if profile is None:
-            try:
-                profile = next(iter(G.host.profiles))
-            except StopIteration:
-                log.debug("No profile plugged yet, can't store selected widget")
-                return
-        try:
-            target = wid.target
-        except AttributeError:
-            target = None
-
-        data = {
-            "name": plugin_info["name"],
-            "target": target,
-        }
-
-        G.host.bridge.private_data_set(
-            "cagou", "selected_widget", data_format.serialise(data), profile,
-            errback=partial(
-                G.host.errback,
-                title=_("can set selected widget"),
-                message=_("error while setting selected widget: {msg}"))
-        )
-
-    def on_pause(self):
-        G.host.sync = False
-        self._frontend_status_socket.sendall(STATE_PAUSED)
-        return True
-
-    def on_resume(self):
-        self._frontend_status_socket.sendall(STATE_RUNNING)
-        G.host.sync = True
-
-    def on_stop(self):
-        self._frontend_status_socket.sendall(STATE_STOPPED)
-        self._frontend_status_socket.close()
-
-    def on_key_back_root(self):
-        PythonActivity.moveTaskToBack(True)
-        return True
-
-    def on_key_back_share(self, share_widget):
-        share_widget.close()
-        PythonActivity.moveTaskToBack(True)
-        return True
-
-    def _disconnect(self, profile):
-        G.host.bridge.param_set(
-            "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile,
-            callback=lambda: log.info(f"profile {profile} autoconnection unset"),
-            errback=lambda: log.error(f"can't unset {profile} autoconnection"))
-        G.host.profiles.unplug(profile)
-        G.host.bridge.disconnect(profile)
-        G.host.app.show_profile_manager()
-        G.host.close_ui()
-
-    def _on_disconnect(self):
-        current_profile = next(iter(G.host.profiles))
-        wid = dialog.ConfirmDialog(
-            title=_("Are you sure to disconnect?"),
-            message=_(
-                "If you disconnect the current user ({profile}), you won't receive "
-                "any notification until you connect it again, is this really what you "
-                "want?").format(profile=current_profile),
-            yes_cb=partial(self._disconnect, profile=current_profile),
-            no_cb=G.host.close_ui,
-        )
-        G.host.show_extra_ui(wid)
-
-    def on_extra_menu_init(self, extra_menu):
-        extra_menu.add_item(_('disconnect'), self._on_disconnect)
-
-    def update_params_extra(self, extra):
-        # on Android, we handle autoconnection automatically,
-        # user must not modify those parameters
-        extra.update(
-            {
-                "ignore": [
-                    ["Connection", "autoconnect_backend"],
-                    ["Connection", "autoconnect"],
-                    ["Connection", "autodisconnect"],
-                ],
-            }
-        )
-
-    def get_col_data_from_uri(self, uri, col_name):
-        cursor = mActivity.getContentResolver().query(uri, None, None, None, None)
-        if cursor is None:
-            return None
-        try:
-            cursor.moveToFirst()
-            col_idx = cursor.getColumnIndex(col_name);
-            if col_idx == -1:
-                return None
-            return cursor.getString(col_idx)
-        finally:
-            cursor.close()
-
-    def get_filename_from_uri(self, uri, media_type):
-        filename = self.get_col_data_from_uri(uri, DISPLAY_NAME)
-        if filename is None:
-            uri_p = Path(uri.toString())
-            filename = uri_p.name or "unnamed"
-            if not uri_p.suffix and media_type:
-                suffix = mimetypes.guess_extension(media_type, strict=False)
-                if suffix:
-                    filename = filename + suffix
-        return filename
-
-    def get_path_from_uri(self, uri):
-        # FIXME: using DATA is not recommended (and DATA is deprecated)
-        # we should read directly the file with
-        # ContentResolver#openFileDescriptor(Uri, String)
-        path = self.get_col_data_from_uri(uri, DATA)
-        return uri.getPath() if path is None else path
-
-    def on_new_intent(self, intent):
-        log.debug("on_new_intent")
-        action = intent.getAction();
-        intent_type = intent.getType();
-        if action == Intent.ACTION_MAIN:
-            action_str = intent.getStringExtra(INTENT_EXTRA_ACTION)
-            if action_str is not None:
-                action = json.loads(action_str)
-                log.debug(f"Extra action found: {action}")
-                action_type = action.get('type')
-                if action_type == "open":
-                    try:
-                        widget = action['widget']
-                        target = action['target']
-                    except KeyError as e:
-                        log.warning(f"incomplete action {action}: {e}")
-                    else:
-                        # we don't want stored selected widget to be displayed after this
-                        # one
-                        log.debug("cancelling restoration of previous widget")
-                        self.restore_selected_wid = False
-                        # and now we open the widget linked to the intent
-                        current_profile = next(iter(G.host.profiles))
-                        Clock.schedule_once(
-                            lambda *args: G.host.do_action(
-                                widget, jid.JID(target), [current_profile]),
-                            0)
-                else:
-                    log.warning(f"unexpected action: {action}")
-
-            text = None
-            uri = None
-            path = None
-        elif action == Intent.ACTION_SEND:
-            # we have receiving data to share, we parse the intent data
-            # and show the share widget
-            data = {}
-            text = intent.getStringExtra(Intent.EXTRA_TEXT)
-            if text is not None:
-                data['text'] = text
-
-            item = intent.getParcelableExtra(Intent.EXTRA_STREAM)
-            if item is not None:
-                uri = cast('android.net.Uri', item)
-                if uri.getScheme() == 'content':
-                    # Android content, we'll dump it to a temporary file
-                    filename = self.get_filename_from_uri(uri, intent_type)
-                    filepath = self.tmp_dir / filename
-                    input_stream = mActivity.getContentResolver().openInputStream(uri)
-                    buff = bytearray(4096)
-                    with open(filepath, 'wb') as f:
-                        while True:
-                            ret = input_stream.read(buff, 0, 4096)
-                            if ret != -1:
-                                f.write(buff[:ret])
-                            else:
-                                break
-                    input_stream.close()
-                    data['path'] = path = str(filepath)
-                else:
-                    data['uri'] = uri.toString()
-                    path = self.get_path_from_uri(uri)
-                    if path is not None and path not in data:
-                        data['path'] = path
-            else:
-                uri = None
-                path = None
-
-
-            Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0)
-        else:
-            text = None
-            uri = None
-            path = None
-
-        msg = (f"NEW INTENT RECEIVED\n"
-               f"type: {intent_type}\n"
-               f"action: {action}\n"
-               f"text: {text}\n"
-               f"uri: {uri}\n"
-               f"path: {path}")
-
-        log.debug(msg)
-
-    def check_plugin_permissions(self, plug_info, callback, errback):
-        perms = plug_info.get("android_permissons")
-        if not perms:
-            callback()
-            return
-        perms = [f"android.permission.{p}" if '.' not in p else p for p in perms]
-
-        def request_permissions_cb(permissions, granted):
-            if all(granted):
-                Clock.schedule_once(lambda *args: callback())
-            else:
-                Clock.schedule_once(lambda *args: errback())
-
-        request_permissions(perms, callback=request_permissions_cb)
-
-    def open_url(self, url, wid=None):
-        parsed_url = urlparse(url)
-        if parsed_url.scheme == "aesgcm":
-            return super().open_url(url, wid)
-        else:
-            media_type = mimetypes.guess_type(url, strict=False)[0]
-            if media_type is None:
-                log.debug(
-                    f"media_type for {url!r} not found with python mimetypes, trying "
-                    f"guessContentTypeFromName")
-                media_type = URLConnection.guessContentTypeFromName(url)
-            intent = Intent(Intent.ACTION_VIEW)
-            if media_type is not None:
-                log.debug(f"file {url!r} is of type {media_type}")
-                intent.setDataAndType(Uri.parse(url), media_type)
-            else:
-                log.debug(f"can't guess media type for {url!r}")
-                intent.setData(Uri.parse(url))
-            if mActivity.getPackageManager() is not None:
-                activity = cast('android.app.Activity', mActivity)
-                try:
-                    activity.startActivity(intent)
-                except JavaException as e:
-                    if e.classname != "android.content.ActivityNotFoundException":
-                        raise e
-                    log.debug(
-                        f"activity not found for url {url!r}, we'll try generic opener")
-                else:
-                    return
-
-        # if nothing else worked, we default to base open_url
-        super().open_url(url, wid)
--- a/cagou/core/platform_/base.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,129 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-import sys
-import webbrowser
-import subprocess
-import shutil
-from urllib import parse
-from kivy.config import Config as KivyConfig
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat_frontends.quick_frontend.quick_widgets import QuickWidget
-from cagou import G
-
-
-log = getLogger(__name__)
-
-
-class Platform:
-    """Base class to handle platform specific behaviours"""
-    # set to True to always show the send button in chat
-    send_button_visible = False
-
-    def init_platform(self):
-        # we don't want multi-touch emulation with mouse
-
-        # this option doesn't make sense on Android and cause troubles, so we only
-        # activate it for other platforms (cf. https://github.com/kivy/kivy/issues/6229)
-        KivyConfig.set('input', 'mouse', 'mouse,disable_multitouch')
-
-    def on_app_build(self, Wid):
-        pass
-
-    def on_host_init(self, host):
-        pass
-
-    def on_init_frontend_state(self):
-        pass
-
-    def do_post_init(self):
-        return True
-
-    def on_pause(self):
-        pass
-
-    def on_resume(self):
-        pass
-
-    def on_stop(self):
-        pass
-
-    def on_key_back_root(self):
-        """Back key is called while being on root widget"""
-        return True
-
-    def on_key_back_share(self, share_widget):
-        """Back key is called while being on share widget"""
-        share_widget.close()
-        return True
-
-    def _on_new_window(self):
-        """Launch a new instance of Cagou to have an extra window"""
-        subprocess.Popen(sys.argv)
-
-    def on_extra_menu_init(self, extra_menu):
-        extra_menu.add_item(_('new window'), self._on_new_window)
-
-    def update_params_extra(self, extra):
-        pass
-
-    def check_plugin_permissions(self, plug_info, callback, errback):
-        """Check that plugin permissions for this platform are granted"""
-        callback()
-
-    def _open(self, path):
-        """Open url or path with appropriate application if possible"""
-        try:
-            opener = self._opener
-        except AttributeError:
-            xdg_open_path = shutil.which("xdg-open")
-            if xdg_open_path is not None:
-                log.debug("xdg-open found, it will be used to open files")
-                opener = lambda path: subprocess.Popen([xdg_open_path, path])
-            else:
-                log.debug("files will be opened with webbrower.open")
-                opener = webbrowser.open
-            self._opener = opener
-
-        opener(path)
-
-
-    def open_url(self, url, wid=None):
-        """Open an URL in the way appropriate for the platform
-
-        @param url(str): URL to open
-        @param wid(CagouWidget, None): widget requesting the opening
-            it may influence the way the URL is opened
-        """
-        parsed_url = parse.urlparse(url)
-        if parsed_url.scheme == "aesgcm" and wid is not None:
-            # aesgcm files need to be decrypted first
-            # so we download them before opening
-            quick_widget = G.host.get_ancestor_widget(wid, QuickWidget)
-            if quick_widget is None:
-                msg = f"Can't find ancestor QuickWidget of {wid}"
-                log.error(msg)
-                G.host.errback(exceptions.InternalError(msg))
-                return
-            G.host.download_url(
-                parsed_url, self.open_url, G.host.errback, profile=quick_widget.profile
-            )
-        else:
-            self._open(url)
--- a/cagou/core/profile_manager.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,178 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from .constants import Const as C
-from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.togglebutton import ToggleButton
-from kivy.uix.screenmanager import ScreenManager, Screen
-from kivy.metrics import sp
-from kivy import properties
-from cagou import G
-
-
-class ProfileItem(ToggleButton):
-    ps = properties.ObjectProperty()
-    index = properties.NumericProperty(0)
-
-
-class NewProfileScreen(Screen):
-    profile_name = properties.ObjectProperty(None)
-    jid = properties.ObjectProperty(None)
-    password = properties.ObjectProperty(None)
-    error_msg = properties.StringProperty('')
-
-    def __init__(self, pm):
-        super(NewProfileScreen, self).__init__(name='new_profile')
-        self.pm = pm
-
-    def on_creation_failure(self, failure):
-        msg = [l for l in str(failure).split('\n') if l][-1]
-        self.error_msg = str(msg)
-
-    def on_creation_success(self, profile):
-        self.pm.profiles_screen.reload()
-        G.host.bridge.profile_start_session(
-            self.password.text, profile,
-            callback=lambda __: self._session_started(profile),
-            errback=self.on_creation_failure)
-
-    def _session_started(self, profile):
-        jid = self.jid.text.strip()
-        G.host.bridge.param_set("JabberID", jid, "Connection", -1, profile)
-        G.host.bridge.param_set("Password", self.password.text, "Connection", -1, profile)
-        self.pm.screen_manager.transition.direction = 'right'
-        self.pm.screen_manager.current = 'profiles'
-
-    def doCreate(self):
-        name = self.profile_name.text.strip()
-        # XXX: we use XMPP password for profile password to simplify
-        #      if user want to change profile password, he can do it in preferences
-        G.host.bridge.profile_create(
-            name, self.password.text, '',
-            callback=lambda: self.on_creation_success(name),
-            errback=self.on_creation_failure)
-
-
-class DeleteProfilesScreen(Screen):
-
-    def __init__(self, pm):
-        self.pm = pm
-        super(DeleteProfilesScreen, self).__init__(name='delete_profiles')
-
-    def do_delete(self):
-        """This method will delete *ALL* selected profiles"""
-        to_delete = self.pm.get_profiles()
-        deleted = [0]
-
-        def delete_inc():
-            deleted[0] += 1
-            if deleted[0] == len(to_delete):
-                self.pm.profiles_screen.reload()
-                self.pm.screen_manager.transition.direction = 'right'
-                self.pm.screen_manager.current = 'profiles'
-
-        for profile in to_delete:
-            log.info("Deleteing profile [{}]".format(profile))
-            G.host.bridge.profile_delete_async(
-                profile, callback=delete_inc, errback=delete_inc)
-
-
-class ProfilesScreen(Screen):
-    layout = properties.ObjectProperty(None)
-    profiles = properties.ListProperty()
-
-    def __init__(self, pm):
-        self.pm = pm
-        super(ProfilesScreen, self).__init__(name='profiles')
-        self.reload()
-
-    def _profiles_list_get_cb(self, profiles):
-        profiles.sort()
-        self.profiles = profiles
-        for idx, profile in enumerate(profiles):
-            item = ProfileItem(ps=self, index=idx, text=profile, group='profiles')
-            self.layout.add_widget(item)
-
-    def converter(self, row_idx, obj):
-        return {'text': obj,
-                'size_hint_y': None,
-                'height': sp(40)}
-
-    def reload(self):
-        """Reload profiles list"""
-        self.layout.clear_widgets()
-        G.host.bridge.profiles_list_get(callback=self._profiles_list_get_cb)
-
-
-class ProfileManager(QuickProfileManager, BoxLayout):
-    selected = properties.ObjectProperty(None)
-
-    def __init__(self, autoconnect=None):
-        QuickProfileManager.__init__(self, G.host, autoconnect)
-        BoxLayout.__init__(self, orientation="vertical")
-        self.screen_manager = ScreenManager()
-        self.profiles_screen = ProfilesScreen(self)
-        self.new_profile_screen = NewProfileScreen(self)
-        self.delete_profiles_screen = DeleteProfilesScreen(self)
-        self.xmlui_screen = Screen(name='xmlui')
-        self.screen_manager.add_widget(self.profiles_screen)
-        self.screen_manager.add_widget(self.xmlui_screen)
-        self.screen_manager.add_widget(self.new_profile_screen)
-        self.screen_manager.add_widget(self.delete_profiles_screen)
-        self.add_widget(self.screen_manager)
-
-    def close_ui(self, xmlui, reason=None):
-        self.screen_manager.transition.direction = 'right'
-        self.screen_manager.current = 'profiles'
-
-    def show_ui(self, xmlui):
-        xmlui.set_close_cb(self.close_ui)
-        if xmlui.type == 'popup':
-            xmlui.bind(on_touch_up=lambda obj, value: self.close_ui(xmlui))
-        self.xmlui_screen.clear_widgets()
-        self.xmlui_screen.add_widget(xmlui)
-        self.screen_manager.transition.direction = 'left'
-        self.screen_manager.current = 'xmlui'
-
-    def select_profile(self, profile_item):
-        if not profile_item.selected:
-            return
-        def authenticate_cb(data, cb_id, profile):
-            if not C.bool(data.pop('validated', C.BOOL_FALSE)):
-                # profile didn't validate, we unselect it
-                profile_item.state = 'normal'
-                self.selected = ''
-            else:
-                # state may have been modified so we need to be sure it's down
-                profile_item.state = 'down'
-                self.selected = profile_item
-            G.host.action_manager(data, callback=authenticate_cb, ui_show_cb=self.show_ui,
-                                 profile=profile)
-
-        G.host.action_launch(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb,
-                            profile=profile_item.text)
-
-    def get_profiles(self):
-        # for now we restrict to a single profile in Cagou
-        # TODO: handle multi-profiles
-        return [self.selected.text] if self.selected else []
--- a/cagou/core/share_widget.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,154 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 pathlib import Path
-from functools import partial
-from sat.core import log as logging
-from sat.core.i18n import _
-from sat.tools.common import data_format
-from sat_frontends.tools import jid
-from kivy.uix.boxlayout import BoxLayout
-from kivy.properties import StringProperty, DictProperty, ObjectProperty
-from kivy.metrics import dp
-from .constants import Const as C
-from cagou import G
-
-
-log = logging.getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    "name": _("share"),
-    "main": "Share",
-    "description": _("share a file"),
-    "icon_symbol": "share",
-}
-
-
-class TextPreview(BoxLayout):
-    """Widget previewing shared text"""
-    text = StringProperty()
-
-
-class ImagePreview(BoxLayout):
-    """Widget previewing shared image"""
-    path = StringProperty()
-    reduce_layout = ObjectProperty()
-    reduce_checkbox = ObjectProperty()
-
-    def _check_image_cb(self, report_raw):
-        self.report = data_format.deserialise(report_raw)
-        if self.report['too_large']:
-            self.reduce_layout.opacity = 1
-            self.reduce_layout.height = self.reduce_layout.minimum_height + dp(10)
-            self.reduce_layout.padding = [0, dp(5)]
-
-    def _check_image_eb(self, failure_):
-        log.error(f"Can't check image: {failure_}")
-
-    def on_path(self, wid, path):
-        G.host.bridge.image_check(
-            path, callback=self._check_image_cb, errback=self._check_image_eb)
-
-    def resize_image(self, data, callback, errback):
-
-        def image_resize_cb(new_path):
-            new_path = Path(new_path)
-            log.debug(f"image {data['path']} resized at {new_path}")
-            data['path'] = new_path
-            data['cleaning_cb'] = lambda: new_path.unlink()
-            callback(data)
-
-        path = data['path']
-        width, height = self.report['recommended_size']
-        G.host.bridge.image_resize(
-            path, width, height,
-            callback=image_resize_cb,
-            errback=errback
-        )
-
-    def get_filter(self):
-        if self.report['too_large'] and self.reduce_checkbox.active:
-            return self.resize_image
-        else:
-            return lambda data, callback, errback: callback(data)
-
-
-class GenericPreview(BoxLayout):
-    """Widget previewing shared image"""
-    path = StringProperty()
-
-
-class ShareWidget(BoxLayout):
-    media_type = StringProperty()
-    data = DictProperty()
-    preview_box = ObjectProperty()
-
-    def on_kv_post(self, wid):
-        self.type, self.subtype = self.media_type.split('/')
-        if self.type == 'text' and 'text' in self.data:
-            self.preview_box.add_widget(TextPreview(text=self.data['text']))
-        elif self.type == 'image':
-            self.preview_box.add_widget(ImagePreview(path=self.data['path']))
-        else:
-            self.preview_box.add_widget(GenericPreview(path=self.data['path']))
-
-    def close(self):
-        G.host.close_ui()
-
-    def get_filtered_data(self, callback, errback):
-        """Apply filter if suitable, and call callback with with modified data"""
-        try:
-            get_filter = self.preview_box.children[0].get_filter
-        except AttributeError:
-            callback(self.data)
-        else:
-            filter_ = get_filter()
-            filter_(self.data, callback=callback, errback=errback)
-
-    def filter_data_cb(self, data, contact_jid, profile):
-        chat_wid = G.host.do_action('chat', contact_jid, [profile])
-
-        if self.type == 'text' and 'text' in self.data:
-            text = self.data['text']
-            chat_wid.message_input.text += text
-        else:
-            path = self.data['path']
-            chat_wid.transfer_file(path, cleaning_cb=data.get('cleaning_cb'))
-        self.close()
-
-    def filter_data_eb(self, failure_):
-        G.host.add_note(
-            _("file filter error"),
-            _("Can't apply filter to file: {msg}").format(msg=failure_),
-            level=C.XMLUI_DATA_LVL_ERROR)
-
-    def on_select(self, contact_button):
-        contact_jid = jid.JID(contact_button.jid)
-        self.get_filtered_data(
-            partial(
-                self.filter_data_cb,
-                contact_jid=contact_jid,
-                profile=contact_button.profile),
-            self.filter_data_eb
-        )
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            return G.local_platform.on_key_back_share(self)
--- a/cagou/core/simple_xhtml.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,417 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 xml.etree import ElementTree as ET
-from kivy.uix.stacklayout import StackLayout
-from kivy.uix.label import Label
-from kivy.utils import escape_markup
-from kivy.metrics import sp
-from kivy import properties
-from sat.core import log as logging
-from sat_frontends.tools import css_color, strings as sat_strings
-from cagou import G
-from cagou.core.common  import SizedImage
-
-
-log = logging.getLogger(__name__)
-
-
-class Escape(str):
-    """Class used to mark that a message need to be escaped"""
-
-
-class SimpleXHTMLWidgetEscapedText(Label):
-
-    def on_parent(self, instance, parent):
-        if parent is not None:
-            self.font_size = parent.font_size
-
-    def _add_url_markup(self, text):
-        text_elts = []
-        idx = 0
-        links = 0
-        while True:
-            m = sat_strings.RE_URL.search(text[idx:])
-            if m is not None:
-                text_elts.append(escape_markup(m.string[0:m.start()]))
-                link_key = 'link_' + str(links)
-                url = m.group()
-                escaped_url = escape_markup(url)
-                text_elts.append(
-                    f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]')
-                if not links:
-                    self.ref_urls = {link_key: url}
-                else:
-                    self.ref_urls[link_key] = url
-                links += 1
-                idx += m.end()
-            else:
-                if links:
-                    text_elts.append(escape_markup(text[idx:]))
-                    self.markup = True
-                    self.text = ''.join(text_elts)
-                break
-
-    def on_text(self, instance, text):
-        # do NOT call the method if self.markup is set
-        # this would result in infinite loop (because self.text
-        # is changed if an URL is found, and in this case markup too)
-        if text and not self.markup:
-            self._add_url_markup(text)
-
-    def on_ref_press(self, ref):
-        url = self.ref_urls[ref]
-        G.local_platform.open_url(url, self)
-
-
-class SimpleXHTMLWidgetText(Label):
-
-    def on_parent(self, instance, parent):
-        if parent is not None:
-            self.font_size = parent.font_size
-
-
-class SimpleXHTMLWidget(StackLayout):
-    """widget handling simple XHTML parsing"""
-    xhtml = properties.StringProperty()
-    color = properties.ListProperty([1, 1, 1, 1])
-    # XXX: bold is only used for escaped text
-    bold = properties.BooleanProperty(False)
-    font_size = properties.NumericProperty(sp(14))
-
-    # text/XHTML input
-
-    def on_xhtml(self, instance, xhtml):
-        """parse xhtml and set content accordingly
-
-        if xhtml is an instance of Escape, a Label with no markup will be used
-        """
-        self.clear_widgets()
-        if isinstance(xhtml, Escape):
-            label = SimpleXHTMLWidgetEscapedText(
-                text=xhtml, color=self.color, bold=self.bold)
-            self.bind(font_size=label.setter('font_size'))
-            self.bind(color=label.setter('color'))
-            self.bind(bold=label.setter('bold'))
-            self.add_widget(label)
-        else:
-            xhtml = ET.fromstring(xhtml.encode())
-            self.current_wid = None
-            self.styles = []
-            self._call_parse_method(xhtml)
-        if len(self.children) > 1:
-            self._do_split_labels()
-
-    def escape(self, text):
-        """mark that a text need to be escaped (i.e. no markup)"""
-        return Escape(text)
-
-    def _do_split_labels(self):
-        """Split labels so their content can flow with images"""
-        # XXX: to make things easier, we split labels in words
-        log.debug("labels splitting start")
-        children = self.children[::-1]
-        self.clear_widgets()
-        for child in children:
-            if isinstance(child, Label):
-                log.debug("label before split: {}".format(child.text))
-                styles = []
-                tag = False
-                new_text = []
-                current_tag = []
-                current_value = []
-                current_wid = self._create_text()
-                value = False
-                close = False
-                # we will parse the text and create a new widget
-                # on each new word (actually each space)
-                # FIXME: handle '\n' and other white chars
-                for c in child.text:
-                    if tag:
-                        # we are parsing a markup tag
-                        if c == ']':
-                            current_tag_s = ''.join(current_tag)
-                            current_style = (current_tag_s, ''.join(current_value))
-                            if close:
-                                for idx, s in enumerate(reversed(styles)):
-                                    if s[0] == current_tag_s:
-                                        del styles[len(styles) - idx - 1]
-                                        break
-                            else:
-                                styles.append(current_style)
-                            current_tag = []
-                            current_value = []
-                            tag = False
-                            value = False
-                            close = False
-                        elif c == '/':
-                            close = True
-                        elif c == '=':
-                            value = True
-                        elif value:
-                            current_value.append(c)
-                        else:
-                            current_tag.append(c)
-                        new_text.append(c)
-                    else:
-                        # we are parsing regular text
-                        if c == '[':
-                            new_text.append(c)
-                            tag = True
-                        elif c == ' ':
-                            # new word, we do a new widget
-                            new_text.append(' ')
-                            for t, v in reversed(styles):
-                                new_text.append('[/{}]'.format(t))
-                            current_wid.text = ''.join(new_text)
-                            new_text = []
-                            self.add_widget(current_wid)
-                            log.debug("new widget: {}".format(current_wid.text))
-                            current_wid = self._create_text()
-                            for t, v in styles:
-                                new_text.append('[{tag}{value}]'.format(
-                                    tag = t,
-                                    value = '={}'.format(v) if v else ''))
-                        else:
-                            new_text.append(c)
-                if current_wid.text:
-                    # we may have a remaining widget after the parsing
-                    close_styles = []
-                    for t, v in reversed(styles):
-                        close_styles.append('[/{}]'.format(t))
-                    current_wid.text = ''.join(close_styles)
-                    self.add_widget(current_wid)
-                    log.debug("new widget: {}".format(current_wid.text))
-            else:
-                # non Label widgets, we just add them
-                self.add_widget(child)
-        self.splitted = True
-        log.debug("split OK")
-
-    # XHTML parsing methods
-
-    def _call_parse_method(self, e):
-        """Call the suitable method to parse the element
-
-        self.xhtml_[tag] will be called if it exists, else
-        self.xhtml_generic will be used
-        @param e(ET.Element): element to parse
-        """
-        try:
-            method = getattr(self, f"xhtml_{e.tag}")
-        except AttributeError:
-            log.warning(f"Unhandled XHTML tag: {e.tag}")
-            method = self.xhtml_generic
-        method(e)
-
-    def _add_style(self, tag, value=None, append_to_list=True):
-        """add a markup style to label
-
-        @param tag(unicode): markup tag
-        @param value(unicode): markup value if suitable
-        @param append_to_list(bool): if True style we be added to self.styles
-            self.styles is needed to keep track of styles to remove
-            should most probably be set to True
-        """
-        label = self._get_label()
-        label.text += '[{tag}{value}]'.format(
-            tag = tag,
-            value = '={}'.format(value) if value else ''
-            )
-        if append_to_list:
-            self.styles.append((tag, value))
-
-    def _remove_style(self, tag, remove_from_list=True):
-        """remove a markup style from the label
-
-        @param tag(unicode): markup tag to remove
-        @param remove_from_list(bool): if True, remove from self.styles too
-            should most probably be set to True
-        """
-        label = self._get_label()
-        label.text += '[/{tag}]'.format(
-            tag = tag
-            )
-        if remove_from_list:
-            for rev_idx, style in enumerate(reversed(self.styles)):
-                if style[0] == tag:
-                    tag_idx = len(self.styles) - 1 - rev_idx
-                    del self.styles[tag_idx]
-                    break
-
-    def _get_label(self):
-        """get current Label if it exists, or create a new one"""
-        if not isinstance(self.current_wid, Label):
-            self._add_label()
-        return self.current_wid
-
-    def _add_label(self):
-        """add a new Label
-
-        current styles will be closed and reopened if needed
-        """
-        self._close_label()
-        self.current_wid = self._create_text()
-        for tag, value in self.styles:
-            self._add_style(tag, value, append_to_list=False)
-        self.add_widget(self.current_wid)
-
-    def _create_text(self):
-        label = SimpleXHTMLWidgetText(color=self.color, markup=True)
-        self.bind(color=label.setter('color'))
-        label.bind(texture_size=label.setter('size'))
-        return label
-
-    def _close_label(self):
-        """close current style tags in current label
-
-        needed when you change label to keep style between
-        different widgets
-        """
-        if isinstance(self.current_wid, Label):
-            for tag, value in reversed(self.styles):
-                self._remove_style(tag, remove_from_list=False)
-
-    def _parse_css(self, e):
-        """parse CSS found in "style" attribute of element
-
-        self._css_styles will be created and contained markup styles added by this method
-        @param e(ET.Element): element which may have a "style" attribute
-        """
-        styles_limit = len(self.styles)
-        styles = e.attrib['style'].split(';')
-        for style in styles:
-            try:
-                prop, value = style.split(':')
-            except ValueError:
-                log.warning(f"can't parse style: {style}")
-                continue
-            prop = prop.strip().replace('-', '_')
-            value = value.strip()
-            try:
-                method = getattr(self, f"css_{prop}")
-            except AttributeError:
-                log.warning(f"Unhandled CSS: {prop}")
-            else:
-                method(e, value)
-        self._css_styles = self.styles[styles_limit:]
-
-    def _close_css(self):
-        """removed CSS styles
-
-        styles in self._css_styles will be removed
-        and the attribute will be deleted
-        """
-        for tag, __ in reversed(self._css_styles):
-            self._remove_style(tag)
-        del self._css_styles
-
-    def xhtml_generic(self, elem, style=True, markup=None):
-        """Generic method for adding HTML elements
-
-        this method handle content, style and children parsing
-        @param elem(ET.Element): element to add
-        @param style(bool): if True handle style attribute (CSS)
-        @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
-        """
-        # we first add markup and CSS style
-        if markup is not None:
-            if isinstance(markup, str):
-                tag, value = markup, None
-            else:
-                tag, value = markup
-            self._add_style(tag, value)
-        style_ = 'style' in elem.attrib and style
-        if style_:
-            self._parse_css(elem)
-
-        # then content
-        if elem.text:
-            self._get_label().text += escape_markup(elem.text)
-
-        # we parse the children
-        for child in elem:
-            self._call_parse_method(child)
-
-        # closing CSS style and markup
-        if style_:
-            self._close_css()
-        if markup is not None:
-            self._remove_style(tag)
-
-        # and the tail, which is regular text
-        if elem.tail:
-            self._get_label().text += escape_markup(elem.tail)
-
-    # method handling XHTML elements
-
-    def xhtml_br(self, elem):
-        label = self._get_label()
-        label.text+='\n'
-        self.xhtml_generic(elem, style=False)
-
-    def xhtml_em(self, elem):
-        self.xhtml_generic(elem, markup='i')
-
-    def xhtml_img(self, elem):
-        try:
-            src = elem.attrib['src']
-        except KeyError:
-            log.warning("<img> element without src: {}".format(ET.tostring(elem)))
-            return
-        try:
-            target_height = int(elem.get('height', 0))
-        except ValueError:
-            log.warning(f"Can't parse image height: {elem.get('height')}")
-            target_height = None
-        try:
-            target_width = int(elem.get('width', 0))
-        except ValueError:
-            log.warning(f"Can't parse image width: {elem.get('width')}")
-            target_width = None
-
-        img = SizedImage(
-            source=src, target_height=target_height, target_width=target_width)
-        self.current_wid = img
-        self.add_widget(img)
-
-    def xhtml_p(self, elem):
-        if isinstance(self.current_wid, Label):
-            self.current_wid.text+="\n\n"
-        self.xhtml_generic(elem)
-
-    def xhtml_span(self, elem):
-        self.xhtml_generic(elem)
-
-    def xhtml_strong(self, elem):
-        self.xhtml_generic(elem, markup='b')
-
-    # methods handling CSS properties
-
-    def css_color(self, elem, value):
-        self._add_style("color", css_color.parse(value))
-
-    def css_text_decoration(self, elem, value):
-        if value == 'underline':
-            self._add_style('u')
-        elif value == 'line-through':
-            self._add_style('s')
-        else:
-            log.warning("unhandled text decoration: {}".format(value))
--- a/cagou/core/widgets_handler.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,621 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-from sat.core import exceptions
-from sat_frontends.quick_frontend import quick_widgets
-from kivy.graphics import Color, Ellipse
-from kivy.uix.layout import Layout
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.stencilview import StencilView
-from kivy.uix.carousel import Carousel
-from kivy.uix.screenmanager import ScreenManager, Screen
-from kivy.metrics import dp
-from kivy import properties
-from cagou import G
-from .constants import Const as C
-from . import cagou_widget
-
-log = logging.getLogger(__name__)
-
-
-REMOVE_WID_LIMIT = dp(50)
-MIN_WIDTH = MIN_HEIGHT = dp(70)
-
-
-class BoxStencil(BoxLayout, StencilView):
-    pass
-
-
-class WHWrapper(BoxLayout):
-    main_container = properties.ObjectProperty(None)
-    screen_manager = properties.ObjectProperty(None, allownone=True)
-    carousel = properties.ObjectProperty(None, allownone=True)
-    split_size = properties.NumericProperty(dp(1))
-    split_margin = properties.NumericProperty(dp(2))
-    split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
-    split_color_move = C.COLOR_SEC_DARK
-    split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1])
-    # sp stands for "split point"
-    sp_size = properties.NumericProperty(dp(1))
-    sp_space = properties.NumericProperty(dp(4))
-    sp_zone = properties.NumericProperty(dp(30))
-    _split = properties.OptionProperty('None', options=['None', 'left', 'top'])
-    _split_del = properties.BooleanProperty(False)
-
-    def __init__(self, **kwargs):
-        idx = kwargs.pop('_wid_idx')
-        self._wid_idx = idx
-        super(WHWrapper, self).__init__(**kwargs)
-        self._left_wids = set()
-        self._top_wids = set()
-        self._right_wids = set()
-        self._bottom_wids = set()
-        self._clear_attributes()
-
-    def _clear_attributes(self):
-        self._former_slide = None
-
-    def __repr__(self):
-        return "WHWrapper_{idx}".format(idx=self._wid_idx)
-
-    def _main_wid(self, wid_list):
-        """return main widget of a side list
-
-        main widget is either the widget currently splitted
-        or any widget if none is split
-        @return (WHWrapper, None): main widget or None
-            if there is not widget
-        """
-        if not wid_list:
-            return None
-        for wid in wid_list:
-            if wid._split != 'None':
-                return wid
-        return next(iter(wid_list))
-
-    def on_parent(self, __, new_parent):
-        if new_parent is None:
-            # we detach all children so CagouWidget.whwrapper won't link to this one
-            # anymore
-            self.clear_widgets()
-
-    @property
-    def _left_wid(self):
-        return self._main_wid(self._left_wids)
-
-    @property
-    def _top_wid(self):
-        return self._main_wid(self._top_wids)
-
-    @property
-    def _right_wid(self):
-        return self._main_wid(self._right_wids)
-
-    @property
-    def _bottom_wid(self):
-        return self._main_wid(self._bottom_wids)
-
-    @property
-    def current_slide(self):
-        if (self.carousel is not None
-            and (self.screen_manager is None or self.screen_manager.current == '')):
-            return self.carousel.current_slide
-        elif self.screen_manager is not None:
-            # we should have exactly one children in current_screen, else there is a bug
-            return self.screen_manager.current_screen.children[0]
-        else:
-            try:
-                return self.main_container.children[0]
-            except IndexError:
-                log.error("No child found, this should not happen")
-                return None
-
-    @property
-    def carousel_active(self):
-        """Return True if Carousel is used and active"""
-        if self.carousel is None:
-            return False
-        if self.screen_manager is not None and self.screen_manager.current != '':
-            return False
-        return True
-
-    @property
-    def former_screen_wid(self):
-        """Return widget currently active for former screen"""
-        if self.screen_manager is None:
-            raise exceptions.InternalError(
-                "former_screen_wid can only be used if ScreenManager is used")
-        if self._former_screen_name is None:
-            return None
-        return self.get_screen_widget(self._former_screen_name)
-
-    def get_screen_widget(self, screen_name):
-        """Return screen main widget, handling carousel if necessary"""
-        if self.carousel is not None and screen_name == '':
-            return self.carousel.current_slide
-        try:
-            return self.screen_manager.get_screen(screen_name).children[0]
-        except IndexError:
-            return None
-
-    def _draw_ellipse(self):
-        """draw split ellipse"""
-        color = self.split_color_del if self._split_del else self.split_color_move
-        try:
-            self.canvas.after.remove(self.ellipse)
-        except AttributeError:
-            pass
-        if self._split == "top":
-            with self.canvas.after:
-                Color(*color)
-                self.ellipse = Ellipse(angle_start=90, angle_end=270,
-                               pos=(self.x + self.width/2 - self.sp_zone/2,
-                                    self.y + self.height - self.sp_zone/2),
-                               size=(self.sp_zone, self.sp_zone))
-        elif self._split == "left":
-            with self.canvas.after:
-                Color(*color)
-                self.ellipse = Ellipse(angle_end=180,
-                               pos=(self.x + -self.sp_zone/2,
-                                    self.y + self.height/2 - self.sp_zone/2),
-                               size = (self.sp_zone, self.sp_zone))
-        else:
-            raise exceptions.InternalError('unexpected split value')
-
-    def on_touch_down(self, touch):
-        """activate split if touch is on a split zone"""
-        if not self.collide_point(*touch.pos):
-            return
-        log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format(
-            self._wid_idx,
-            'None' if not self._left_wids else [w._wid_idx for w in self._left_wids],
-            'None' if not self._top_wids else [w._wid_idx for w in self._top_wids],
-            'None' if not self._right_wids else [w._wid_idx for w in self._right_wids],
-            'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids],
-            self.pos,
-            self.size,
-            ))
-        touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True)
-        if (touch_ry <= self.height and
-            touch_ry >= self.height - self.split_size - self.split_margin or
-            touch_ry <= self.height and
-            touch_ry >= self.height - self.sp_zone and
-            touch_rx >= self.width//2 - self.sp_zone//2 and
-            touch_rx <= self.width//2 + self.sp_zone//2):
-            # split area is touched, we activate top split mode
-            self._split = "top"
-            self._draw_ellipse()
-        elif (touch_rx >= 0 and
-              touch_rx <= self.split_size + self.split_margin or
-              touch_rx >= 0 and
-              touch_rx <= self.sp_zone and
-              touch_ry >= self.height//2 - self.sp_zone//2 and
-              touch_ry <= self.height//2 + self.sp_zone//2):
-            # split area is touched, we activate left split mode
-            self._split = "left"
-            touch.ud['ori_width'] = self.width
-            self._draw_ellipse()
-        else:
-            if self.carousel_active and len(self.carousel.slides) <= 1:
-                # we don't want swipe of carousel if there is only one slide
-                return StencilView.on_touch_down(self.carousel, touch)
-            else:
-                return super(WHWrapper, self).on_touch_down(touch)
-
-    def on_touch_move(self, touch):
-        """handle size change and widget creation on split"""
-        if self._split == 'None':
-            return super(WHWrapper, self).on_touch_move(touch)
-
-        elif self._split == 'top':
-            new_height = touch.y - self.y
-
-            if new_height < MIN_HEIGHT:
-                return
-
-            # we must not pass the top widget/border
-            if self._top_wids:
-                top = next(iter(self._top_wids))
-                y_limit = top.y + top.height
-
-                if top.height <= REMOVE_WID_LIMIT:
-                    # we are in remove zone, we add visual hint for that
-                    if not self._split_del and self._top_wids:
-                        self._split_del = True
-                        self._draw_ellipse()
-                else:
-                    if self._split_del:
-                        self._split_del = False
-                        self._draw_ellipse()
-            else:
-                y_limit = self.y + self.height
-
-            if touch.y >= y_limit:
-                return
-
-            # all right, we can change size
-            self.height = new_height
-            self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2)
-
-            if not self._top_wids:
-                # we are the last widget on the top
-                # so we create a new widget
-                new_wid = self.parent.add_widget()
-                self._top_wids.add(new_wid)
-                new_wid._bottom_wids.add(self)
-                for w in self._right_wids:
-                    new_wid._right_wids.add(w)
-                    w._left_wids.add(new_wid)
-                for w in self._left_wids:
-                    new_wid._left_wids.add(w)
-                    w._right_wids.add(new_wid)
-
-        elif self._split == 'left':
-            ori_width = touch.ud['ori_width']
-            new_x = touch.x
-            new_width = ori_width - (touch.x - touch.ox)
-
-            if new_width < MIN_WIDTH:
-                return
-
-            # we must not pass the left widget/border
-            if self._left_wids:
-                left = next(iter(self._left_wids))
-                x_limit = left.x
-
-                if left.width <= REMOVE_WID_LIMIT:
-                    # we are in remove zone, we add visual hint for that
-                    if not self._split_del and self._left_wids:
-                        self._split_del = True
-                        self._draw_ellipse()
-                else:
-                    if self._split_del:
-                        self._split_del = False
-                        self._draw_ellipse()
-            else:
-                x_limit = self.x
-
-            if new_x <= x_limit:
-                return
-
-            # all right, we can change position/size
-            self.x = new_x
-            self.width = new_width
-            self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1])
-
-            if not self._left_wids:
-                # we are the last widget on the left
-                # so we create a new widget
-                new_wid = self.parent.add_widget()
-                self._left_wids.add(new_wid)
-                new_wid._right_wids.add(self)
-                for w in self._top_wids:
-                    new_wid._top_wids.add(w)
-                    w._bottom_wids.add(new_wid)
-                for w in self._bottom_wids:
-                    new_wid._bottom_wids.add(w)
-                    w._top_wids.add(new_wid)
-
-        else:
-            raise Exception.InternalError('invalid _split value')
-
-    def on_touch_up(self, touch):
-        if self._split == 'None':
-            return super(WHWrapper, self).on_touch_up(touch)
-        if self._split == 'top':
-            # we remove all top widgets in delete zone,
-            # and update there side widgets list
-            for top in self._top_wids.copy():
-                if top.height <= REMOVE_WID_LIMIT:
-                    G.host._remove_visible_widget(top.current_slide)
-                    for w in top._top_wids:
-                        w._bottom_wids.remove(top)
-                        w._bottom_wids.update(top._bottom_wids)
-                    for w in top._bottom_wids:
-                        w._top_wids.remove(top)
-                        w._top_wids.update(top._top_wids)
-                    for w in top._left_wids:
-                        w._right_wids.remove(top)
-                    for w in top._right_wids:
-                        w._left_wids.remove(top)
-                    self.parent.remove_widget(top)
-        elif self._split == 'left':
-            # we remove all left widgets in delete zone,
-            # and update there side widgets list
-            for left in self._left_wids.copy():
-                if left.width <= REMOVE_WID_LIMIT:
-                    G.host._remove_visible_widget(left.current_slide)
-                    for w in left._left_wids:
-                        w._right_wids.remove(left)
-                        w._right_wids.update(left._right_wids)
-                    for w in left._right_wids:
-                        w._left_wids.remove(left)
-                        w._left_wids.update(left._left_wids)
-                    for w in left._top_wids:
-                        w._bottom_wids.remove(left)
-                    for w in left._bottom_wids:
-                        w._top_wids.remove(left)
-                    self.parent.remove_widget(left)
-        self._split = 'None'
-        self.canvas.after.remove(self.ellipse)
-        del self.ellipse
-
-    def clear_widgets(self):
-        current_slide = self.current_slide
-        if current_slide is not None:
-            G.host._remove_visible_widget(current_slide, ignore_missing=True)
-
-        super().clear_widgets()
-
-        self.screen_manager = None
-        self.carousel = None
-        self._clear_attributes()
-
-    def set_widget(self, wid, index=0):
-        assert len(self.children) == 0
-
-        if wid.collection_carousel or wid.global_screen_manager:
-            self.main_container = self
-        else:
-            self.main_container = BoxStencil()
-            self.add_widget(self.main_container)
-
-        if self.carousel is not None:
-            return self.carousel.add_widget(wid, index)
-
-        if wid.global_screen_manager:
-            if self.screen_manager is None:
-                self.screen_manager = ScreenManager()
-                self.main_container.add_widget(self.screen_manager)
-                parent = Screen()
-                self.screen_manager.add_widget(parent)
-                self._former_screen_name = ''
-                self.screen_manager.bind(current=self.on_screen_change)
-                wid.screen_manager_init(self.screen_manager)
-        else:
-            parent = self.main_container
-
-        if wid.collection_carousel:
-            # a Carousel is requested, and this is the first widget that we add
-            # so we need to create the carousel
-            self.carousel = Carousel(
-                direction = "right",
-                ignore_perpendicular_swipes = True,
-                loop = True,
-            )
-            self._slides_update_lock = 0
-            self.carousel.bind(current_slide=self.on_slide_change)
-            parent.add_widget(self.carousel)
-            self.carousel.add_widget(wid, index)
-        else:
-            # no Carousel requested, we add the widget as a direct child
-            parent.add_widget(wid)
-            G.host._add_visible_widget(wid)
-
-    def change_widget(self, new_widget):
-        """Change currently displayed widget
-
-        slides widgets will be updated
-        """
-        if (self.carousel is not None
-            and self.carousel.current_slide.__class__ == new_widget.__class__):
-            # we have the same class, we reuse carousel and screen manager setting
-
-            if self.carousel.current_slide != new_widget:
-                # slides update need to be blocked to avoid the update in on_slide_change
-                # which would mess the removal of current widgets
-                self._slides_update_lock += 1
-                new_wid = None
-                for w in self.carousel.slides[:]:
-                    if w.widget_hash == new_widget.widget_hash:
-                        new_wid = w
-                        continue
-                    self.carousel.remove_widget(w)
-                    if isinstance(w, quick_widgets.QuickWidget):
-                        G.host.widgets.delete_widget(w)
-                if new_wid is None:
-                    new_wid = G.host.get_or_clone(new_widget)
-                    self.carousel.add_widget(new_wid)
-                self._update_hidden_slides()
-                self._slides_update_lock -= 1
-
-            if self.screen_manager is not None:
-                self.screen_manager.clear_widgets([
-                    s for s in self.screen_manager.screens if s.name != ''])
-                new_wid.screen_manager_init(self.screen_manager)
-        else:
-            # else, we restart fresh
-            self.clear_widgets()
-            self.set_widget(G.host.get_or_clone(new_widget))
-
-    def on_screen_change(self, screen_manager, new_screen):
-        try:
-            new_screen_wid = self.current_slide
-        except IndexError:
-            new_screen_wid = None
-            log.warning("Switching to a screen without children")
-        if new_screen == '' and self.carousel is not None:
-            # carousel may have been changed in the background, so we update slides
-            self._update_hidden_slides()
-        former_screen_wid = self.former_screen_wid
-        if isinstance(former_screen_wid, cagou_widget.CagouWidget):
-            G.host._remove_visible_widget(former_screen_wid)
-        if isinstance(new_screen_wid, cagou_widget.CagouWidget):
-            G.host._add_visible_widget(new_screen_wid)
-        self._former_screen_name = new_screen
-        G.host.selected_widget = new_screen_wid
-
-    def on_slide_change(self, handler, new_slide):
-        if self._former_slide is new_slide:
-            # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide
-            #        binding now calls on_slide_change twice with the same widget (here
-            #        "new_slide"). To be checked with Kivy team.
-            return
-        log.debug(f"Slide change: new_slide = {new_slide}")
-        if self._former_slide is not None:
-            G.host._remove_visible_widget(self._former_slide, ignore_missing=True)
-        self._former_slide = new_slide
-        if self.carousel_active:
-            G.host.selected_widget = new_slide
-            if new_slide is not None:
-                G.host._add_visible_widget(new_slide)
-                self._update_hidden_slides()
-
-    def hidden_list(self, visible_list, ignore=None):
-        """return widgets of same class as carousel current one, if they are hidden
-
-        @param visible_list(list[QuickWidget]): widgets visible
-        @param ignore(QuickWidget, None): do no return this widget
-        @return (iter[QuickWidget]): widgets hidden
-        """
-        # we want to avoid recreated widgets
-        added = [w.widget_hash for w in visible_list]
-        current_slide = self.carousel.current_slide
-        for w in G.host.widgets.get_widgets(current_slide.__class__,
-                                           profiles=current_slide.profiles):
-            wid_hash = w.widget_hash
-            if w in visible_list or wid_hash in added:
-                continue
-            if wid_hash == ignore.widget_hash:
-                continue
-            yield w
-
-
-    def _update_hidden_slides(self):
-        """adjust carousel slides according to visible widgets"""
-        if self._slides_update_lock or not self.carousel_active:
-            return
-        current_slide = self.carousel.current_slide
-        if not isinstance(current_slide, quick_widgets.QuickWidget):
-            return
-        # lock must be used here to avoid recursions
-        self._slides_update_lock += 1
-        visible_list = G.host.get_visible_list(current_slide.__class__)
-        # we ignore current_slide as it may not be visible yet (e.g. if an other
-        # screen is shown
-        hidden = list(self.hidden_list(visible_list, ignore=current_slide))
-        slides_sorted =  sorted(set(hidden + [current_slide]))
-        to_remove = set(self.carousel.slides).difference({current_slide})
-        for w in to_remove:
-            self.carousel.remove_widget(w)
-        if hidden:
-            # no need to add more than two widgets (next and previous),
-            # as the list will be updated on each new visible widget
-            current_idx = slides_sorted.index(current_slide)
-            try:
-                next_slide = slides_sorted[current_idx+1]
-            except IndexError:
-                next_slide = slides_sorted[0]
-            self.carousel.add_widget(G.host.get_or_clone(next_slide))
-            if len(hidden)>1:
-                previous_slide = slides_sorted[current_idx-1]
-                self.carousel.add_widget(G.host.get_or_clone(previous_slide))
-
-        self._slides_update_lock -= 1
-
-
-class WidgetsHandlerLayout(Layout):
-    count = 0
-
-    def __init__(self, **kwargs):
-        super(WidgetsHandlerLayout, self).__init__(**kwargs)
-        self._layout_size = None  # size used for the last layout
-        fbind = self.fbind
-        update = self._trigger_layout
-        fbind('children', update)
-        fbind('parent', update)
-        fbind('size', self.adjust_prop)
-        fbind('pos', update)
-
-    @property
-    def default_widget(self):
-        return G.host.default_wid['factory'](G.host.default_wid, None, None)
-
-    def adjust_prop(self, handler, new_size):
-        """Adjust children proportion
-
-        useful when this widget is resized (e.g. when going to fullscreen)
-        """
-        if len(self.children) > 1:
-            old_width, old_height = self._layout_size
-            if not old_width or not old_height:
-                # we don't want division by zero
-                return self._trigger_layout(handler, new_size)
-            width_factor = float(self.width) / old_width
-            height_factor = float(self.height) / old_height
-            for child in self.children:
-                child.width *= width_factor
-                child.height *= height_factor
-                child.x *= width_factor
-                child.y *= height_factor
-        self._trigger_layout(handler, new_size)
-
-    def do_layout(self, *args):
-        self._layout_size = self.size[:]
-        for child in self.children:
-            # XXX: left must be calculated before right and bottom before top
-            #      because they are the pos, and are used to caculate size (right and top)
-            # left
-            left = child._left_wid
-            left_end_x = self.x-1 if left is None else left.right
-            if child.x != left_end_x + 1 and child._split == "None":
-                child.x = left_end_x + 1
-            # right
-            right = child._right_wid
-            right_x = self.right + 1 if right is None else right.x
-            if child.right != right_x - 1:
-                child.width = right_x - child.x - 1
-            # bottom
-            bottom = child._bottom_wid
-            if bottom is None:
-                if child.y != self.y:
-                    child.y = self.y
-            else:
-                if child.y != bottom.top + 1:
-                    child.y = bottom.top + 1
-            # top
-            top = child._top_wid
-            top_y = self.top+1 if top is None else top.y
-            if child.top != top_y - 1:
-                if child._split == "None":
-                    child.height = top_y - child.y - 1
-
-    def remove_widget(self, wid):
-        super(WidgetsHandlerLayout, self).remove_widget(wid)
-        log.debug("widget deleted ({})".format(wid._wid_idx))
-
-    def add_widget(self, wid=None, index=0):
-        WidgetsHandlerLayout.count += 1
-        if wid is None:
-            wid = self.default_widget
-        if G.host.selected_widget is None:
-            G.host.selected_widget = wid
-        wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count)
-        log.debug("WHWrapper created ({})".format(wrapper._wid_idx))
-        wrapper.set_widget(wid)
-        super(WidgetsHandlerLayout, self).add_widget(wrapper, index)
-        return wrapper
-
-
-class WidgetsHandler(WidgetsHandlerLayout):
-
-    def __init__(self, **kw):
-        super(WidgetsHandler, self).__init__(**kw)
-        self.add_widget()
--- a/cagou/core/xmlui.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,624 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: a SàT frontend
-# Copyright (C) 2016-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 sat.core.i18n import _
-from .constants import Const as C
-from sat.core.log import getLogger
-from sat_frontends.tools import xmlui
-from kivy.uix.scrollview import ScrollView
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.gridlayout import GridLayout
-from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
-from kivy.uix.textinput import TextInput
-from kivy.uix.label import Label
-from kivy.uix.button import Button
-from kivy.uix.togglebutton import ToggleButton
-from kivy.uix.widget import Widget
-from kivy.uix.switch import Switch
-from kivy import properties
-from cagou import G
-from cagou.core import dialog
-from functools import partial
-
-log = getLogger(__name__)
-
-## Widgets ##
-
-
-class TextInputOnChange(object):
-
-    def __init__(self):
-        self._xmlui_onchange_cb = None
-        self._got_focus = False
-
-    def _xmlui_on_change(self, callback):
-        self._xmlui_onchange_cb = callback
-
-    def on_focus(self, instance, focus):
-        # we need to wait for first focus, else initial value
-        # will trigger a on_text
-        if not self._got_focus and focus:
-            self._got_focus = True
-
-    def on_text(self, instance, new_text):
-        if self._xmlui_onchange_cb is not None and self._got_focus:
-            self._xmlui_onchange_cb(self)
-
-
-class EmptyWidget(xmlui.EmptyWidget, Widget):
-
-    def __init__(self, _xmlui_parent):
-        Widget.__init__(self)
-
-
-class TextWidget(xmlui.TextWidget, Label):
-
-    def __init__(self, xmlui_parent, value):
-        Label.__init__(self, text=value)
-
-
-class LabelWidget(xmlui.LabelWidget, TextWidget):
-    pass
-
-
-class JidWidget(xmlui.JidWidget, TextWidget):
-    pass
-
-
-class StringWidget(xmlui.StringWidget, TextInput, TextInputOnChange):
-
-    def __init__(self, xmlui_parent, value, read_only=False):
-        TextInput.__init__(self, text=value)
-        TextInputOnChange.__init__(self)
-        self.readonly = read_only
-
-    def _xmlui_set_value(self, value):
-        self.text = value
-
-    def _xmlui_get_value(self):
-        return self.text
-
-
-class TextBoxWidget(xmlui.TextBoxWidget, StringWidget):
-    pass
-
-
-class JidInputWidget(xmlui.JidInputWidget, StringWidget):
-    pass
-
-
-class ButtonWidget(xmlui.ButtonWidget, Button):
-
-    def __init__(self, _xmlui_parent, value, click_callback):
-        Button.__init__(self)
-        self.text = value
-        self.callback = click_callback
-
-    def _xmlui_on_click(self, callback):
-        self.callback = callback
-
-    def on_release(self):
-        self.callback(self)
-
-
-class DividerWidget(xmlui.DividerWidget, Widget):
-    # FIXME: not working properly + only 'line' is handled
-    style = properties.OptionProperty('line',
-        options=['line', 'dot', 'dash', 'plain', 'blank'])
-
-    def __init__(self, _xmlui_parent, style="line"):
-        Widget.__init__(self, style=style)
-
-
-class ListWidgetItem(ToggleButton):
-    value = properties.StringProperty()
-
-    def on_release(self):
-        parent = self.parent
-        while parent is not None and not isinstance(parent, ListWidget):
-            parent = parent.parent
-
-        if parent is not None:
-            parent.select(self)
-        return super(ListWidgetItem, self).on_release()
-
-    @property
-    def selected(self):
-        return self.state == 'down'
-
-    @selected.setter
-    def selected(self, value):
-        self.state = 'down' if value else 'normal'
-
-
-class ListWidget(xmlui.ListWidget, ScrollView):
-    layout = properties.ObjectProperty()
-
-    def __init__(self, _xmlui_parent, options, selected, flags):
-        ScrollView.__init__(self)
-        self.multi = 'single' not in flags
-        self._values = []
-        for option in options:
-            self.add_value(option)
-        self._xmlui_select_values(selected)
-        self._on_change = None
-
-    @property
-    def items(self):
-        return self.layout.children
-
-    def select(self, item):
-        if not self.multi:
-            self._xmlui_select_values([item.value])
-        if self._on_change is not None:
-            self._on_change(self)
-
-    def add_value(self, option, selected=False):
-        """add a value in the list
-
-        @param option(tuple): value, label in a tuple
-        """
-        self._values.append(option)
-        item = ListWidgetItem()
-        item.value, item.text = option
-        item.selected = selected
-        self.layout.add_widget(item)
-
-    def _xmlui_select_value(self, value):
-        self._xmlui_select_values([value])
-
-    def _xmlui_select_values(self, values):
-        for item in self.items:
-            item.selected = item.value in values
-            if item.selected and not self.multi:
-                self.text = item.text
-
-    def _xmlui_get_selected_values(self):
-        return [item.value for item in self.items if item.selected]
-
-    def _xmlui_add_values(self, values, select=True):
-        values = set(values).difference([c.value for c in self.items])
-        for v in values:
-            self.add_value(v, select)
-
-    def _xmlui_on_change(self, callback):
-        self._on_change = callback
-
-
-class JidsListWidget(ListWidget):
-    # TODO: real list dedicated to jids
-
-    def __init__(self, _xmlui_parent, jids, flags):
-        ListWidget.__init__(self, _xmlui_parent, [(j,j) for j in jids], [], flags)
-
-
-class PasswordWidget(xmlui.PasswordWidget, TextInput, TextInputOnChange):
-
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        TextInput.__init__(self, password=True, multiline=False,
-            text=value, readonly=read_only, size=(100,25), size_hint=(1,None))
-        TextInputOnChange.__init__(self)
-
-    def _xmlui_set_value(self, value):
-        self.text = value
-
-    def _xmlui_get_value(self):
-        return self.text
-
-
-class BoolWidget(xmlui.BoolWidget, Switch):
-
-    def __init__(self, _xmlui_parent, state, read_only=False):
-        Switch.__init__(self, active=state)
-        if read_only:
-            self.disabled = True
-
-    def _xmlui_set_value(self, value):
-        self.active = value
-
-    def _xmlui_get_value(self):
-        return C.BOOL_TRUE if self.active else C.BOOL_FALSE
-
-    def _xmlui_on_change(self, callback):
-        self.bind(active=lambda instance, value: callback(instance))
-
-
-class IntWidget(xmlui.IntWidget, TextInput, TextInputOnChange):
-
-    def __init__(self, _xmlui_parent, value, read_only=False):
-        TextInput.__init__(self, text=value, input_filter='int', multiline=False)
-        TextInputOnChange.__init__(self)
-        if read_only:
-            self.disabled = True
-
-    def _xmlui_set_value(self, value):
-        self.text = value
-
-    def _xmlui_get_value(self):
-        return self.text
-
-
-## Containers ##
-
-
-class VerticalContainer(xmlui.VerticalContainer, BoxLayout):
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        BoxLayout.__init__(self)
-
-    def _xmlui_append(self, widget):
-        self.add_widget(widget)
-
-
-class PairsContainer(xmlui.PairsContainer, GridLayout):
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        GridLayout.__init__(self)
-
-    def _xmlui_append(self, widget):
-        self.add_widget(widget)
-
-
-class LabelContainer(PairsContainer, xmlui.LabelContainer):
-    pass
-
-
-class TabsPanelContainer(TabbedPanelItem):
-    layout = properties.ObjectProperty(None)
-
-    def _xmlui_append(self, widget):
-        self.layout.add_widget(widget)
-
-
-class TabsContainer(xmlui.TabsContainer, TabbedPanel):
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        TabbedPanel.__init__(self, do_default_tab=False)
-
-    def _xmlui_add_tab(self, label, selected):
-        tab = TabsPanelContainer(text=label)
-        self.add_widget(tab)
-        return tab
-
-
-class AdvancedListRow(BoxLayout):
-    global_index = 0
-    index = properties.ObjectProperty()
-    selected = properties.BooleanProperty(False)
-
-    def __init__(self, **kwargs):
-        self.global_index = AdvancedListRow.global_index
-        AdvancedListRow.global_index += 1
-        super(AdvancedListRow, self).__init__(**kwargs)
-
-    def on_touch_down(self, touch):
-        if self.collide_point(*touch.pos):
-            parent = self.parent
-            while parent is not None and not isinstance(parent, AdvancedListContainer):
-                parent = parent.parent
-            if parent is None:
-                log.error("Can't find parent AdvancedListContainer")
-            else:
-                if parent.selectable:
-                    self.selected = parent._xmlui_toggle_selected(self)
-
-        return super(AdvancedListRow, self).on_touch_down(touch)
-
-
-class AdvancedListContainer(xmlui.AdvancedListContainer, BoxLayout):
-
-    def __init__(self, xmlui_parent, columns, selectable='no'):
-        self.xmlui_parent = xmlui_parent
-        BoxLayout.__init__(self)
-        self._columns = columns
-        self.selectable = selectable != 'no'
-        self._current_row = None
-        self._selected = []
-        self._xmlui_select_cb = None
-
-    def _xmlui_toggle_selected(self, row):
-        """inverse selection status of an AdvancedListRow
-
-        @param row(AdvancedListRow): row to (un)select
-        @return (bool): True if row is selected
-        """
-        try:
-            self._selected.remove(row)
-        except ValueError:
-            self._selected.append(row)
-            if self._xmlui_select_cb is not None:
-                self._xmlui_select_cb(self)
-            return True
-        else:
-            return False
-
-    def _xmlui_append(self, widget):
-        if self._current_row is None:
-            log.error("No row set, ignoring append")
-            return
-        self._current_row.add_widget(widget)
-
-    def _xmlui_add_row(self, idx):
-        self._current_row = AdvancedListRow()
-        self._current_row.cols = self._columns
-        self._current_row.index = idx
-        self.add_widget(self._current_row)
-
-    def _xmlui_get_selected_widgets(self):
-        return self._selected
-
-    def _xmlui_get_selected_index(self):
-        if not self._selected:
-            return None
-        return self._selected[0].index
-
-    def _xmlui_on_select(self, callback):
-        """ Call callback with widget as only argument """
-        self._xmlui_select_cb = callback
-
-
-## Dialogs ##
-
-
-class NoteDialog(xmlui.NoteDialog):
-
-    def __init__(self, _xmlui_parent, title, message, level):
-        xmlui.NoteDialog.__init__(self, _xmlui_parent)
-        self.title, self.message, self.level = title, message, level
-
-    def _xmlui_show(self):
-        G.host.add_note(self.title, self.message, self.level)
-
-
-class MessageDialog(xmlui.MessageDialog, dialog.MessageDialog):
-
-    def __init__(self, _xmlui_parent, title, message, level):
-        dialog.MessageDialog.__init__(self,
-                                      title=title,
-                                      message=message,
-                                      level=level,
-                                      close_cb = self.close_cb)
-        xmlui.MessageDialog.__init__(self, _xmlui_parent)
-
-    def close_cb(self):
-        self._xmlui_close()
-
-    def _xmlui_show(self):
-        G.host.add_notif_ui(self)
-
-    def _xmlui_close(self, reason=None):
-        G.host.close_ui()
-
-    def show(self, *args, **kwargs):
-        G.host.show_ui(self)
-
-
-class ConfirmDialog(xmlui.ConfirmDialog, dialog.ConfirmDialog):
-
-    def __init__(self, _xmlui_parent, title, message, level, buttons_set):
-        dialog.ConfirmDialog.__init__(self)
-        xmlui.ConfirmDialog.__init__(self, _xmlui_parent)
-        self.title=title
-        self.message=message
-        self.no_cb = self.no_cb
-        self.yes_cb = self.yes_cb
-
-    def no_cb(self):
-        G.host.close_ui()
-        self._xmlui_cancelled()
-
-    def yes_cb(self):
-        G.host.close_ui()
-        self._xmlui_validated()
-
-    def _xmlui_show(self):
-        G.host.add_notif_ui(self)
-
-    def _xmlui_close(self, reason=None):
-        G.host.close_ui()
-
-    def show(self, *args, **kwargs):
-        assert kwargs["force"]
-        G.host.show_ui(self)
-
-
-class FileDialog(xmlui.FileDialog, BoxLayout):
-    message = properties.ObjectProperty()
-
-    def __init__(self, _xmlui_parent, title, message, level, filetype):
-        xmlui.FileDialog.__init__(self, _xmlui_parent)
-        BoxLayout.__init__(self)
-        self.message.text = message
-        if filetype == C.XMLUI_DATA_FILETYPE_DIR:
-            self.file_chooser.dirselect = True
-
-    def _xmlui_show(self):
-        G.host.add_notif_ui(self)
-
-    def _xmlui_close(self, reason=None):
-        # FIXME: notif UI is not removed if dialog is not shown yet
-        G.host.close_ui()
-
-    def on_select(self, path):
-        try:
-            path = path[0]
-        except IndexError:
-            path = None
-        if not path:
-            self._xmlui_cancelled()
-        else:
-            self._xmlui_validated({'path': path})
-
-    def show(self, *args, **kwargs):
-        assert kwargs["force"]
-        G.host.show_ui(self)
-
-
-## Factory ##
-
-
-class WidgetFactory(object):
-
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = globals()[attr[6:]]
-            return cls
-
-
-## Core ##
-
-
-class Title(Label):
-
-    def __init__(self, *args, **kwargs):
-        kwargs['size'] = (100, 25)
-        kwargs['size_hint'] = (1,None)
-        super(Title, self).__init__(*args, **kwargs)
-
-
-class FormButton(Button):
-    pass
-
-class SubmitButton(FormButton):
-    pass
-
-class CancelButton(FormButton):
-    pass
-
-class SaveButton(FormButton):
-    pass
-
-
-class XMLUIPanel(xmlui.XMLUIPanel, ScrollView):
-    widget_factory = WidgetFactory()
-    layout = properties.ObjectProperty()
-
-    def __init__(self, host, parsed_xml, title=None, flags=None, callback=None,
-                 ignore=None, whitelist=None, profile=C.PROF_KEY_NONE):
-        ScrollView.__init__(self)
-        self.close_cb = None
-        self._post_treats = []  # list of callback to call after UI is constructed
-
-        # used to workaround touch issues when a ScrollView is used inside this
-        # one. This happens notably when a TabsContainer is used as main container
-        # (this is the case with settings).
-        self._skip_scroll_events = False
-        xmlui.XMLUIPanel.__init__(self,
-                                  host,
-                                  parsed_xml,
-                                  title=title,
-                                  flags=flags,
-                                  callback=callback,
-                                  ignore=ignore,
-                                  whitelist=whitelist,
-                                  profile=profile)
-        self.bind(height=self.on_height)
-
-    def on_touch_down(self, touch, after=False):
-        if self._skip_scroll_events:
-            return super(ScrollView, self).on_touch_down(touch)
-        else:
-            return super(XMLUIPanel, self).on_touch_down(touch)
-
-    def on_touch_up(self, touch, after=False):
-        if self._skip_scroll_events:
-            return super(ScrollView, self).on_touch_up(touch)
-        else:
-            return super(XMLUIPanel, self).on_touch_up(touch)
-
-    def on_touch_move(self, touch, after=False):
-        if self._skip_scroll_events:
-            return super(ScrollView, self).on_touch_move(touch)
-        else:
-            return super(XMLUIPanel, self).on_touch_move(touch)
-
-    def set_close_cb(self, close_cb):
-        self.close_cb = close_cb
-
-    def _xmlui_close(self, __=None, reason=None):
-        if self.close_cb is not None:
-            self.close_cb(self, reason)
-        else:
-            G.host.close_ui()
-
-    def on_param_change(self, ctrl):
-        super(XMLUIPanel, self).on_param_change(ctrl)
-        self.save_btn.disabled = False
-
-    def add_post_treat(self, callback):
-        self._post_treats.append(callback)
-
-    def _post_treat_cb(self):
-        for cb in self._post_treats:
-            cb()
-        del self._post_treats
-
-    def _save_button_cb(self, button):
-        button.disabled = True
-        self.on_save_params(button)
-
-    def construct_ui(self, parsed_dom):
-        xmlui.XMLUIPanel.construct_ui(self, parsed_dom, self._post_treat_cb)
-        if self.xmlui_title:
-            self.layout.add_widget(Title(text=self.xmlui_title))
-        if isinstance(self.main_cont, TabsContainer):
-            # cf. comments above
-            self._skip_scroll_events = True
-        self.layout.add_widget(self.main_cont)
-        if self.type == 'form':
-            submit_btn = SubmitButton()
-            submit_btn.bind(on_press=self.on_form_submitted)
-            self.layout.add_widget(submit_btn)
-            if not 'NO_CANCEL' in self.flags:
-                cancel_btn = CancelButton(text=_("Cancel"))
-                cancel_btn.bind(on_press=self.on_form_cancelled)
-                self.layout.add_widget(cancel_btn)
-        elif self.type == 'param':
-            self.save_btn = SaveButton(text=_("Save"), disabled=True)
-            self.save_btn.bind(on_press=self._save_button_cb)
-            self.layout.add_widget(self.save_btn)
-        elif self.type == 'window':
-            cancel_btn = CancelButton(text=_("Cancel"))
-            cancel_btn.bind(
-                on_press=partial(self._xmlui_close, reason=C.XMLUI_DATA_CANCELLED))
-            self.layout.add_widget(cancel_btn)
-
-    def on_height(self, __, height):
-        if isinstance(self.main_cont, TabsContainer):
-            other_children_height = sum([c.height for c in self.layout.children
-                                         if c is not self.main_cont])
-            self.main_cont.height = height - other_children_height
-
-    def show(self, *args, **kwargs):
-        if not self.user_action and not kwargs.get("force", False):
-            G.host.add_notif_ui(self)
-        else:
-            G.host.show_ui(self)
-
-
-class XMLUIDialog(xmlui.XMLUIDialog):
-    dialog_factory = WidgetFactory()
-
-
-create = partial(xmlui.create, class_map={
-    xmlui.CLASS_PANEL: XMLUIPanel,
-    xmlui.CLASS_DIALOG: XMLUIDialog})
--- a/cagou/kv/base.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-<Label>:
-    color: 0, 0, 0, 1
-
-<Button>:
-    color: 1, 1, 1, 1
-
-<TextInput>:
-    background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
--- a/cagou/kv/behaviors.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,28 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-<TouchMenu>:
-    creation_direction: -1
-    radius: dp(25)
-    creation_timeout: .4
-    cancel_color: app.c_sec_light[:3] + [0.3]
-    color: app.c_sec
-    line_width: dp(2)
-
-<ModernMenuLabel>:
-    bg_color: app.c_sec[:3] + [0.9]
-    padding: dp(5), dp(5)
-    radius: dp(100)
--- a/cagou/kv/cagou_widget.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,84 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import C cagou.core.constants.Const
-
-
-<HeaderChoice>:
-    canvas.before:
-        Color:
-            rgba: 1, 1, 1, 1
-        BorderImage:
-            pos: self.pos
-            size: self.size
-            source: 'atlas://data/images/defaulttheme/button'
-    size_hint_y: None
-    height: dp(44)
-    spacing: dp(20)
-    padding: dp(5), dp(3), dp(10), dp(3)
-
-<HeaderChoiceWidget>:
-    ActionIcon:
-        plugin_info: root.plugin_info
-        size_hint: None, 1
-        width: self.height
-    Label:
-        size_hint: None, 1
-        text: root.plugin_info['name']
-        color: 1, 1, 1, 1
-        bold: True
-        size: self.texture_size
-        halign: "center"
-        valign: "middle"
-
-<HeaderChoiceExtraMenu>:
-    ActionSymbol:
-        symbol: "dot-3-vert"
-        size_hint: None, 1
-        width: self.height
-    Label:
-        size_hint: None, 1
-        text: _("extra")
-        color: 1, 1, 1, 1
-        bold: True
-        size: self.texture_size
-        halign: "center"
-        valign: "middle"
-
-<HeaderWidgetSelector>:
-    size_hint: None, None
-    auto_width: False
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-<CagouWidget>:
-    header_box: header_box
-    orientation: "vertical"
-    BoxLayout:
-        id: header_box
-        size_hint: 1, None
-        height: dp(32)
-        spacing: dp(3)
-        padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
-        HeaderWidgetCurrent:
-            plugin_info: root.plugin_info
-            size_hint: None, 1
-            width: self.height
-            on_release: root.selector.open(self)
--- a/cagou/kv/common.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,164 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-<NotifLabel>:
-    background_color: app.c_sec_light
-    size_hint: None, None
-    text_size: None, root.height
-    padding_x: sp(5)
-    size: self.texture_size
-    bold: True
-    canvas.before:
-        Color:
-            # self.background_color doesn't seem initialized correctly on startup
-            # (maybe a Kivy bug? to be checked), thus we use the "or" below
-            rgb: self.background_color or app.c_sec_light
-        Ellipse:
-            size: self.size
-            pos: self.pos
-
-
-<ContactItem>:
-    size_hint: None, None
-    width: self.base_width
-    height: self.minimum_height
-    orientation: 'vertical'
-    avatar: avatar
-    avatar_layout: avatar_layout
-    FloatLayout:
-        id: avatar_layout
-        size_hint: 1, None
-        height: dp(60)
-        Avatar:
-            id: avatar
-            pos_hint: {'x': 0, 'y': 0}
-            data: root.data.get('avatar')
-            allow_stretch: True
-    BoxLayout:
-        id: label_box
-        size_hint: 1, None
-        height: self.minimum_height
-        Label:
-            size_hint: 1, None
-            height: self.font_size + sp(5)
-            text_size: self.size
-            shorten: True
-            shorten_from: "right"
-            text: root.data.get('nick', root.jid.node or root.jid)
-            bold: True
-            valign: 'middle'
-            halign: 'center'
-
-
-<JidItem>:
-    size_hint: 1, None
-    height: dp(68)
-    avatar: avatar
-    padding: 0, dp(2), 0, dp(2)
-    canvas.before:
-        Color:
-            rgba: self.bg_color
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Image:
-        id: avatar
-        size_hint: None, None
-        size: dp(64), dp(64)
-    Label:
-        size_hint: 1, 1
-        text_size: self.size
-        color: root.color
-        bold: True
-        text: root.jid
-        halign: 'left'
-        valign: 'middle'
-        padding_x: dp(5)
-
-<JidToggle>:
-    canvas.before:
-        Color:
-            rgba: self.selected_color if self.state == 'down' else self.bg_color
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-<Symbol>:
-    width: dp(35)
-    height: dp(35)
-    font_name: app.expand('{media}/fonts/fontello/font/fontello.ttf')
-    text_size: self.size
-    font_size: dp(30)
-    halign: 'center'
-    valign: 'middle'
-    bg_color: 0, 0, 0, 0
-    canvas.before:
-        Color:
-            rgba: self.bg_color
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-<SymbolLabel>:
-    size_hint: None, 1
-    width: self.minimum_width
-    symbol_wid: symbol_wid
-    label: label
-    Symbol:
-        id: symbol_wid
-        size_hint: None, 1
-        symbol: root.symbol
-        color: root.color
-    Label:
-        id: label
-        size_hint: None, 1
-        text_size: None, root.height
-        size: self.texture_size
-        padding_x: dp(5)
-        valign: 'middle'
-        text: root.text
-        bold: root.bold
-
-<SymbolToggleLabel>:
-    color: 0, 0, 0, 1
-    canvas.before:
-        Color:
-            rgba: app.c_sec_light if self.state == 'down' else (0, 0, 0, 0)
-        RoundedRectangle:
-            pos: self.pos
-            size: self.size
-
-<ActionSymbol>:
-    bg_color: 0, 0, 0, 0
-    color: app.c_sec_light
-
-<SizedImage>:
-    size_hint: None, None
-
-
-<JidSelectorCategoryLayout>:
-    size_hint: 1, None
-    height: self.minimum_height
-    spacing: 0
-
-<JidSelector>:
-    layout: layout
-    StackLayout:
-        id: layout
-        size_hint: 1, None
-        height: self.minimum_height
-        spacing: 0
--- a/cagou/kv/common_widgets.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-<ItemWidget>:
-    size_hint: None, None
-    width: self.base_width
-    height: self.minimum_height
-    orientation: 'vertical'
-
-
-<DeviceWidget>:
-    Symbol:
-        size_hint: 1, None
-        height: dp(80)
-        font_size: dp(40)
-        symbol: root.get_symbol()
-        color: 0, 0, 0, 1
-    Label:
-        size_hint: None, None
-        width: dp(100)
-        font_size: sp(14)
-        text_size: dp(95), None
-        size: self.texture_size
-        text: root.name
-        halign: 'center'
-
-
-<CategorySeparator>:
-    size_hint: 1, None
-    height: sp(35)
-    bold: True
-    font_size: sp(20)
-    color: app.c_sec
-
-<ImageViewer>:
-    do_rotation: False
-    AsyncImage:
-        source: root.source
-        allow_stretch: True,
-
-
-<ImagesGallery>:
-    carousel: carousel
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Carousel:
-        id: carousel
--- a/cagou/kv/dialog.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,95 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import _ sat.core.i18n._
-
-
-<MessageDialog>:
-    orientation: "vertical"
-    spacing: dp(5)
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Label:
-        size_hint: 1, None
-        text_size: root.width, None
-        size: self.texture_size
-        font_size: sp(20)
-        padding: dp(5), dp(10)
-        color: 1, 1, 1, 1
-        text: root.title
-        halign: "center"
-        italic: True
-        bold: True
-    Label:
-        text: root.message
-        text_size: root.width, None
-        size: self.texture_size
-        padding: dp(25), 0
-        font_size: sp(20)
-        color: 1, 1, 1, 1
-    Button:
-        size_hint: 1, None
-        height: dp(50)
-        background_color: 0.33, 1.0, 0.0, 1
-        text: _("Close")
-        bold: True
-        on_release: root.close_cb()
-
-
-<ConfirmDialog>:
-    orientation: "vertical"
-    spacing: dp(5)
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Label:
-        size_hint: 1, None
-        text_size: root.width, None
-        size: self.texture_size
-        font_size: sp(20)
-        padding: dp(5), dp(10)
-        color: 1, 1, 1, 1
-        text: root.title
-        halign: "center"
-        italic: True
-        bold: True
-    Label:
-        text: root.message
-        text_size: root.width, None
-        size: self.texture_size
-        padding: dp(25), 0
-        font_size: sp(20)
-        color: 1, 1, 1, 1
-    Button:
-        size_hint: 1, None
-        height: dp(50)
-        background_color: 0.33, 1.0, 0.0, 1
-        text: _("Yes")
-        bold: True
-        on_release: root.yes_cb() if root.yes_cb is not None else None
-    Button:
-        size_hint: 1, None
-        height: dp(50)
-        text: _("No")
-        bold: True
-        on_release: root.no_cb() if root.no_cb is not None else None
--- a/cagou/kv/menu.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,152 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import _ sat.core.i18n._
-#:import C cagou.core.constants.Const
-
-<AboutContent>:
-    text_size: self.size
-    color: 1, 1, 1, 1
-    halign: "center"
-    valign: "middle"
-
-<AboutPopup>:
-    title_align: "center"
-    size_hint: 0.8, 0.8
-
-<ExtraMenuItem>:
-    size_hint: 1, None
-    height: dp(30)
-
-<ExtraSideMenu>:
-    bg_color: 0.23, 0.23, 0.23, 1
-    ExtraMenuItem:
-        text: _("About")
-        on_press: root.on_about()
-    Widget:
-        # to push content to the top
-
-<TransferMenu>:
-    items_layout: items_layout
-    orientation: "vertical"
-    bg_color: app.c_prim
-    size_hint: 1, 0.5
-    padding: [app.MARGIN_LEFT, 3, app.MARGIN_RIGHT, 0]
-    spacing: dp(5)
-    transfer_info: transfer_info
-    upload_btn: upload_btn
-    on_encrypted: self.update_transfer_info()
-    canvas.after:
-        Color:
-            rgba: app.c_prim_dark
-        Line:
-            points: 0, self.y + self.height, self.width + self.x, self.y + self.height
-            width: 1
-    BoxLayout:
-        size_hint: 1, None
-        height: dp(50)
-        spacing: dp(10)
-        Widget:
-        SymbolToggleLabel
-            id: upload_btn
-            symbol: "upload"
-            text: _(u"upload")
-            group: "transfer"
-            state: "down"
-            on_state: root.update_transfer_info()
-        SymbolToggleLabel
-            id: send_btn
-            symbol: "loop-alt"
-            text: _(u"send")
-            group: "transfer"
-        Widget:
-    Label:
-        id: transfer_info
-        size_hint: 1, None
-        padding: 0, dp(5)
-        markup: True
-        text_size: self.width, None
-        size: self.texture_size
-        halign: 'center'
-        canvas.before:
-            Color:
-                rgba: app.c_prim_dark
-            RoundedRectangle:
-                pos: self.pos
-                size: self.size
-    ScrollView:
-        do_scroll_x: False
-        StackLayout:
-            size_hint: 1, None
-            padding: 20, 0
-            spacing: 15, 5
-            id: items_layout
-
-<TransferItem>:
-    orientation: "vertical"
-    size_hint: None, None
-    size: dp(50), dp(90)
-    IconButton:
-        source: root.plug_info['icon_medium']
-        allow_stretch: True
-        size_hint: 1, None
-        height: dp(50)
-    Label:
-        color: 0, 0, 0, 1
-        text: root.plug_info['name']
-        text_size: self.size
-        halign: "center"
-        valign: "top"
-
-
-<SideMenu>:
-    orientation: "vertical"
-    size_hint: self.size_hint_close
-    canvas.before:
-        Color:
-            rgba: self.bg_color
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-
-<EntitiesSelectorMenu>:
-    bg_color: 0, 0, 0, 0.9
-    filter_input: filter_input
-    layout: layout
-    callback_on_close: True
-    Label:
-        size_hint: 1, None
-        text_size: root.width, None
-        size: self.texture_size
-        padding: dp(5), dp(5)
-        color: 1, 1, 1, 1
-        text: root.instructions
-        halign: "center"
-    TextInput:
-        id: filter_input
-        size_hint: 1, None
-        height: dp(32)
-        multiline: False
-        hint_text: _(u"enter filter here")
-    ScrollView:
-        size_hint: 1, 1
-        BoxLayout:
-            id: layout
-            orientation: "vertical"
-            size_hint: 1, None
-            height: self.minimum_height
-            spacing: dp(5)
--- a/cagou/kv/profile_manager.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,197 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-<ProfileManager>:
-    Label:
-        size_hint: 1, None
-        text_size: root.width, None
-        width: self.texture_size[0]
-        height: self.texture_size[1] + dp(20)
-        text: "Profile Manager"
-        halign: "center"
-        bold: True
-
-<PMLabel@Label>:
-    size_hint: 1, None
-    height: sp(30)
-
-<PMInput@TextInput>:
-    multiline: False
-    size_hint: 1, None
-    height: sp(30)
-    write_tab: False
-
-<PMButton@Button>:
-    size_hint: 1, None
-    height: dp(40)
-
-
-<NewProfileScreen>:
-    profile_name: profile_name
-    jid: jid
-    password: password
-
-    BoxLayout:
-        orientation: "vertical"
-
-        Label:
-            size_hint: 1, None
-            text_size: root.width, None
-            size: self.texture_size
-            text: "Creation of a new profile"
-            halign: "center"
-        Label:
-            text: root.error_msg
-            bold: True
-            size_hint: 1, None
-            height: dp(40)
-            color: 1,0,0,1
-        GridLayout:
-            cols: 2
-
-            PMLabel:
-                text: "Profile name"
-            PMInput:
-                id: profile_name
-
-            PMLabel:
-                text: "JID"
-            PMInput:
-                id: jid
-
-            PMLabel:
-                text: "Password"
-            PMInput:
-                id: password
-                password: True
-
-            Widget:
-                size_hint: 1, None
-                height: dp(50)
-
-            Widget:
-                size_hint: 1, None
-                height: dp(50)
-
-            PMButton:
-                text: "OK"
-                on_press: root.doCreate()
-
-            PMButton:
-                text: "Cancel"
-                on_press:
-                    root.pm.screen_manager.transition.direction = 'right'
-                    root.pm.screen_manager.current = 'profiles'
-
-            Widget:
-
-
-<DeleteProfilesScreen>:
-    BoxLayout:
-        orientation: "vertical"
-
-        Label:
-            size_hint: 1, None
-            text_size: root.width, None
-            size: self.texture_size
-            text: "Are you sure you want to delete the following profiles?"
-            halign: "center"
-
-        Label:
-            size_hint: 1, None
-            text_size: root.width, None
-            height: self.texture_size[1] + dp(60)
-            width: self.texture_size[0]
-            halign: "center"
-            # for now we only handle single selection
-            text: u'\n'.join([i.text for i in [root.pm.selected]]) if root.pm.selected else u''
-            bold: True
-
-        Label:
-            size_hint: 1, None
-            text_size:  root.width, dp(30)
-            height: self.texture_size[1]
-            text: u'/!\\ WARNING: this operation is irreversible'
-            color: 1,0,0,1
-            bold: True
-            halign: "center"
-            valign: "top"
-        GridLayout:
-            cols: 2
-            PMButton:
-                text: "Delete"
-                on_press: root.do_delete()
-
-            PMButton:
-                text: "Cancel"
-                on_press:
-                    root.pm.screen_manager.transition.direction = 'right'
-                    root.pm.screen_manager.current = 'profiles'
-
-
-<ProfilesScreen>:
-    layout: layout
-    BoxLayout:
-        orientation: 'vertical'
-
-        Label:
-            size_hint: 1, None
-            text_size: root.width, None
-            size: self.texture_size
-            text: "Select a profile or create a new one"
-            halign: "center"
-
-        GridLayout:
-            cols: 2
-            size_hint: 1, None
-            height: dp(40)
-            Button:
-                text: "New"
-                on_press:
-                    root.pm.screen_manager.transition.direction = 'left'
-                    root.pm.screen_manager.current = 'new_profile'
-            Button:
-                disabled: not root.pm.selected
-                text: "Delete"
-                on_press:
-                    root.pm.screen_manager.transition.direction = 'left'
-                    root.pm.screen_manager.current = 'delete_profiles'
-        ScrollView
-            BoxLayout:
-                size_hint: 1, None
-                height: self.minimum_height
-                orientation: "vertical"
-                id: layout
-        Button
-            text: "Connect"
-            size_hint: 1, None
-            height: dp(40)
-            disabled: not root.pm.selected
-            on_press: root.pm._on_connect_profiles()
-
-
-<ProfileItem>:
-    size_hint: 1, None
-    background_normal: ""
-    background_down: ""
-    deselected_color: (1,1,1,1) if self.index%2 else (0.87,0.87,0.87,1)
-    selected_color: 0.67,1.0,1.0,1
-    selected: self.state == 'down'
-    color: 0,0,0,1
-    background_color: self.selected_color if self.selected else self.deselected_color
-    on_press: self.ps.pm.select_profile(self)
-    height: dp(30)
--- a/cagou/kv/root_widget.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,128 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import IconButton cagou.core.common.IconButton
-#:import C cagou.core.constants.Const
-
-# <NotifIcon>:
-#     source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png")
-#     size_hint: None, None
-#     size: self.texture_size
-
-<Note>:
-    text: self.message
-    text_size: self.parent.size if self.parent else (100, 100)
-    halign: 'center'
-    padding_x: dp(5)
-    shorten: True
-    shorten_from: 'right'
-
-<NoteDrop>:
-    orientation: 'horizontal'
-    size_hint: 1, None
-    height: max(label.height, dp(45))
-    symbol: symbol
-    canvas.before:
-        BorderImage:
-            pos: self.pos
-            size: self.size
-            source: 'atlas://data/images/defaulttheme/button'
-    Widget:
-        size_hint: None, 1
-        width: dp(20)
-    Symbol:
-        id: symbol
-        size_hint: None, 1
-        width: dp(30)
-        padding_y: dp(10)
-        valign: 'top'
-        haligh: 'right'
-        symbol: root.symbol or root.level
-        color:
-            C.COLOR_PRIM_LIGHT if root.symbol is None else \
-            {C.XMLUI_DATA_LVL_INFO: app.c_prim_light,\
-            C.XMLUI_DATA_LVL_WARNING: C.COLOR_WARNING,\
-            C.XMLUI_DATA_LVL_ERROR: C.COLOR_ERROR}[root.level]
-    Label:
-        id: label
-        size_hint: 1, None
-        color: 1, 1, 1, 1
-        text: root.message
-        text_size: self.width, None
-        halign: 'center'
-        size: self.texture_size
-        padding: dp(2), dp(10)
-
-<NotesDrop>:
-    clear_btn: clear_btn.__self__
-    auto_width: False
-    size_hint: 0.9, None
-    size_hint_max_x: dp(400)
-    canvas.before:
-        Color:
-            rgba: 0.8, 0.8, 0.8, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Button:
-        id: clear_btn
-        text: "clear"
-        bold: True
-        size_hint: 1, None
-        height: dp(50)
-        on_release: del root.notes[:]; root.dismiss()
-
-<RootHeadWidget>:
-    manager: manager
-    notifs_icon: notifs_icon
-    size_hint: 1, None
-    height: self.HEIGHT
-    padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
-    IconButton:
-        source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_48.png")
-        allow_stretch: True
-        size_hint: None, None
-        pos_hint: {'center_y': .5}
-        height: dp(25)
-        width: dp(35) if root.notes else 0
-        opacity: 1 if root.notes else 0
-        on_release: root.notes_drop.open(self) if root.notes else None
-    ScreenManager:
-        id: manager
-    NotifsIcon:
-        id: notifs_icon
-        allow_stretch: True
-        source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_miroir_48.png")
-        size_hint: None, None
-        pos_hint: {'center_y': .5}
-        height: dp(25)
-        width: dp(35) if self.notifs else 0
-        opacity: 1 if self.notifs else 0
-    Symbol:
-        id: disconnected_icon
-        size_hint: None, 1
-        pos_hint: {'center_y': .5}
-        font_size: dp(23)
-        width: 0 if app.connected else dp(30)
-        opacity: 0 if app.connected else 1
-        symbol: "plug"
-        color: 0.80, 0.0, 0.0, 1
-
-<CagouRootWidget>:
-    root_body: root_body
-    RootBody:
-        id: root_body
-        orientation: "vertical"
--- a/cagou/kv/share_widget.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,114 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import Path pathlib.Path
-#:import _ sat.core.i18n._
-#:import C cagou.core.constants.Const
-
-
-<ShareWidget>:
-    preview_box: preview_box
-    orientation: 'vertical'
-    Label:
-        size_hint: 1, None
-        text_size: self.size
-        halign: 'center'
-        text: _("share")
-        height: self.font_size + dp(5)
-        bold: True
-        font_size: '35sp'
-    BoxLayout:
-        id: preview_box
-        size_hint: 1, None
-        height: self.minimum_height
-        orientation: 'vertical'
-        text: str(root.data)
-    Label:
-        size_hint: 1, None
-        text_size: self.size
-        halign: 'center'
-        text: _("with")
-        height: self.font_size + dp(5)
-        bold: True
-        font_size: '25sp'
-    JidSelector:
-        on_select: root.on_select(args[1])
-    Button:
-        size_hint: 1, None
-        height: C.BTN_HEIGHT
-        text: _("cancel")
-        on_press: app.host.close_ui()
-
-
-<TextPreview>:
-    size_hint: 1, None
-    height: min(data.height, dp(100))
-    ScrollView
-        Label:
-            id: data
-            size_hint: 1, None
-            text: root.text
-            text_size: self.width, None
-            size: self.texture_size
-            font_size: sp(20)
-            padding_x: dp(10)
-            padding_y: dp(5)
-            halign: 'center'
-            canvas.before:
-                Color:
-                    rgba: 0.95, 0.95, 0.95, 1
-                Rectangle:
-                    pos: self.pos
-                    size: self.size
-
-<ImagePreview>:
-    reduce_layout: reduce_layout
-    reduce_checkbox: reduce_checkbox
-    size_hint: 1, None
-    height: dp(120)
-    orientation: "vertical"
-    Image:
-        source: root.path
-    BoxLayout
-        id: reduce_layout
-        size_hint: 1, None
-        padding_y: None
-        opacity: 0
-        height: 0
-        Widget:
-        CheckBox:
-            id: reduce_checkbox
-            size_hint: None, 1
-            width: dp(20)
-            active: True
-        Label:
-            size_hint: None, None
-            text: _("reduce image size")
-            text_size: None, None
-            size: self.texture_size
-            padding_x: dp(10)
-            font_size: sp(15)
-        Widget:
-
-<GenericPreview>:
-    size_hint: 1, None
-    height: dp(100)
-    Widget:
-    SymbolButtonLabel:
-        symbol: "doc"
-        text: Path(root.path).name
-    Widget:
-
--- a/cagou/kv/simple_xhtml.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,31 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import C cagou.core.constants.Const
-
-
-<SimpleXHTMLWidget>:
-    size_hint: 1, None
-    height: self.minimum_height
-
-<SimpleXHTMLWidgetEscapedText>:
-    size_hint: 1, None
-    text_size: self.width, None
-    height: self.texture_size[1] if self.text else 0
-
-<SimpleXHTMLWidgetText>:
-    size_hint: None, None
-    size: self.texture_size
--- a/cagou/kv/widgets_handler.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,43 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-<WHWrapper>:
-    _sp_top_y: self.y + self.height - self.sp_size
-    padding: self.split_size + self.split_margin, self.split_size + self.split_margin, 0, 0
-
-    canvas.before:
-        # 2 lines to indicate the split zones
-        Color:
-            rgba: self.split_color if self._split != 'left' else self.split_color_del if self._split_del else self.split_color_move
-        Rectangle:
-            pos: self.pos
-            size: self.split_size, self.height
-        Color:
-            rgba: self.split_color if self._split != 'top' else self.split_color_del if self._split_del else self.split_color_move
-        Rectangle:
-            pos: self.x, self.y + self.height - self.split_size
-            size: self.width, self.split_size
-        # 3 dots to indicate the main split points
-        Color:
-            rgba: 0, 0, 0, 1
-        Point:
-            # left
-            points: self.x + self.sp_size, self.y + self.height / 2 - self.sp_size - self.sp_space, self.x + self.sp_size,  self.y + self.height / 2, self.x + self.sp_size, self.y + self.height / 2 + self.sp_size + self.sp_space
-            pointsize: self.sp_size
-        Point:
-            # top
-            points: self.x + self.width / 2 - self.sp_size - self.sp_space, root._sp_top_y,  self.x + self.width / 2, root._sp_top_y, self.x + self.width / 2 + self.sp_size + self.sp_space, root._sp_top_y
-            pointsize: self.sp_size
--- a/cagou/kv/xmlui.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,206 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:set common_height 30
-#:set button_height 50
-
-
-<EmptyWidget,StringWidget,PasswordWidget,JidInputWidget>:
-    size_hint: 1, None
-    height: dp(common_height)
-
-
-<TextWidget,LabelWidget,JidWidget>:
-    size_hint: 1, 1
-    size_hint_min_y: max(dp(common_height), self.texture_size[1])
-    text_size: self.width, None
-
-
-<StringWidget,PasswordWidget,IntWidget>:
-    multiline: False
-    background_normal: app.expand('atlas://data/images/defaulttheme/textinput')
-
-
-<TextBoxWidget>:
-    multiline: True
-    height: dp(common_height) * 5
-
-
-<ButtonWidget>:
-    size_hint: 1, None
-    height: dp(button_height)
-
-
-<BoolWidget>:
-    size_hint: 1, 1
-
-
-<DividerWidget>:
-    size_hint: 1, None
-    height: dp(12)
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Line
-            points: self.x, self.y + dp(5), self.x + self.width, self.y + dp(5)
-            width: dp(2)
-
-
-<ListWidgetItem>:
-    size_hint_y: None
-    height: dp(button_height)
-
-
-<ListWidget>:
-    size_hint: 1, None
-    layout: layout
-    height: min(layout.minimum_height, dp(250))
-    do_scroll_x: False
-    scroll_type: ['bars', 'content']
-    bar_width: dp(6)
-    BoxLayout:
-        id: layout
-        size_hint: 1, None
-        height: self.minimum_height
-        orientation: "vertical"
-        padding: dp(10)
-
-
-<AdvancedListRow>:
-    orientation: "horizontal"
-    size_hint: 1, None
-    height: self.minimum_height
-    canvas.before:
-        Color:
-            rgba: app.c_prim_light if self.global_index%2 else app.c_prim_dark
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    canvas.after:
-        Color:
-            rgba: 0, 0, 1, 0.5 if self.selected else 0
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-
-<AdvancedListContainer>:
-    size_hint: 1, None
-    height: self.minimum_height
-    orientation: "vertical"
-
-
-<VerticalContainer>:
-    orientation: "vertical"
-    size_hint: 1, None
-    height: self.minimum_height
-
-<PairsContainer>:
-    cols: 2
-    size_hint: 1, None
-    height: self.minimum_height
-    padding: dp(10)
-
-
-<TabsContainer>:
-    size_hint: 1, None
-    height: dp(200)
-
-<TabsPanelContainer>:
-    layout: layout
-    ScrollView:
-        do_scroll_x: False
-        scroll_type: ['bars', 'content']
-        bar_width: dp(6)
-        canvas.before:
-            Color:
-                rgba: 1, 1, 1, 1
-            Rectangle:
-                pos: self.pos
-                size: self.size
-        BoxLayout:
-            id: layout
-            orientation: "vertical"
-            size_hint: 1, None
-            height: self.minimum_height
-            canvas.before:
-                Color:
-                    rgba: 1, 1, 1, 1
-                Rectangle:
-                    pos: self.pos
-                    size: self.size
-
-
-<FormButton>:
-    size_hint: 1, None
-    height: dp(button_height)
-    color: 0, 0, 0, 1
-    bold: True
-
-
-<SubmitButton>:
-    text: _(u"Submit")
-    background_normal: ''
-    background_color: 0.33, 0.67, 0.0, 1
-
-
-<CancelButton>:
-    text: _(u"Cancel")
-    color: 1, 1, 1, 1
-    bold: False
-
-
-<SaveButton>:
-    text: _(u"Save")
-    background_normal: ''
-    background_color: 0.33, 0.67, 0.0, 1
-
-
-<FileDialog>:
-    orientation: "vertical"
-    message: message
-    file_chooser: file_chooser
-    Label:
-        id: message
-        size_hint: 1, None
-        text_size: root.width, None
-        size: self.texture_size
-    FileChooserListView:
-        id: file_chooser
-    Button:
-        size_hint: 1, None
-        height: dp(50)
-        text: "choose"
-        on_release: root.on_select(file_chooser.selection)
-    Button:
-        size_hint: 1, None
-        height: dp(50)
-        text: "cancel"
-        on_release: root.onCancel()
-
-
-<XMLUIPanel>:
-    size_hint: 1, 1
-    layout: layout
-    do_scroll_x: False
-    scroll_type: ['bars', 'content']
-    bar_width: dp(6)
-    BoxLayout:
-        id: layout
-        orientation: "vertical"
-        size_hint: 1, None
-        padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
-        height: self.minimum_height
--- a/cagou/plugins/plugin_transfer_android_gallery.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,96 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from sat.core.i18n import _
-import sys
-import tempfile
-import os
-import os.path
-if sys.platform=="android":
-    from jnius import autoclass
-    from android import activity, mActivity
-
-    Intent = autoclass('android.content.Intent')
-    OpenableColumns = autoclass('android.provider.OpenableColumns')
-    PHOTO_GALLERY = 1
-    RESULT_OK = -1
-
-
-
-PLUGIN_INFO = {
-    "name": _("gallery"),
-    "main": "AndroidGallery",
-    "platforms": ('android',),
-    "external": True,
-    "description": _("upload a photo from photo gallery"),
-    "icon_medium": "{media}/icons/muchoslava/png/gallery_50.png",
-}
-
-
-class AndroidGallery:
-
-    def __init__(self, callback, cancel_cb):
-        self.callback = callback
-        self.cancel_cb = cancel_cb
-        activity.bind(on_activity_result=self.on_activity_result)
-        intent = Intent()
-        intent.setType('image/*')
-        intent.setAction(Intent.ACTION_GET_CONTENT)
-        mActivity.startActivityForResult(intent, PHOTO_GALLERY);
-
-    def on_activity_result(self, requestCode, resultCode, data):
-        activity.unbind(on_activity_result=self.on_activity_result)
-        # TODO: move file dump to a thread or use async callbacks during file writting
-        if requestCode == PHOTO_GALLERY and resultCode == RESULT_OK:
-            if data is None:
-                log.warning("No data found in activity result")
-                self.cancel_cb(self, None)
-                return
-            uri = data.getData()
-
-            # we get filename in the way explained at https://developer.android.com/training/secure-file-sharing/retrieve-info.html
-            cursor = mActivity.getContentResolver().query(uri, None, None, None, None )
-            name_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
-            cursor.moveToFirst()
-            filename = cursor.getString(name_idx)
-
-            # we save data in a temporary file that we send to callback
-            # the file will be removed once upload is done (or if an error happens)
-            input_stream = mActivity.getContentResolver().openInputStream(uri)
-            tmp_dir = tempfile.mkdtemp()
-            tmp_file = os.path.join(tmp_dir, filename)
-            def cleaning():
-                os.unlink(tmp_file)
-                os.rmdir(tmp_dir)
-                log.debug('temporary file cleaned')
-            buff = bytearray(4096)
-            with open(tmp_file, 'wb') as f:
-                while True:
-                    ret = input_stream.read(buff, 0, 4096)
-                    if ret != -1:
-                        f.write(buff)
-                    else:
-                        break
-            input_stream.close()
-            self.callback(tmp_file, cleaning)
-        else:
-            self.cancel_cb(self, None)
--- a/cagou/plugins/plugin_transfer_android_photo.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from sat.core.i18n import _
-import sys
-import os
-import os.path
-import time
-if sys.platform == "android":
-    from plyer import camera
-    from jnius import autoclass
-    Environment = autoclass('android.os.Environment')
-else:
-    import tempfile
-
-
-PLUGIN_INFO = {
-    "name": _("take photo"),
-    "main": "AndroidPhoto",
-    "platforms": ('android',),
-    "external": True,
-    "description": _("upload a photo from photo application"),
-    "icon_medium": "{media}/icons/muchoslava/png/camera_off_50.png",
-}
-
-
-class AndroidPhoto(object):
-
-    def __init__(self, callback, cancel_cb):
-        self.callback = callback
-        self.cancel_cb = cancel_cb
-        filename = time.strftime("%Y-%m-%d_%H:%M:%S.jpg", time.gmtime())
-        tmp_dir = self.get_tmp_dir()
-        tmp_file = os.path.join(tmp_dir, filename)
-        log.debug("Picture will be saved to {}".format(tmp_file))
-        camera.take_picture(tmp_file, self.callback)
-        # we don't delete the file, as it is nice to keep it locally
-
-    def get_tmp_dir(self):
-        if sys.platform == "android":
-            dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()
-            return dcim_path
-        else:
-            return tempfile.mkdtemp()
--- a/cagou/plugins/plugin_transfer_android_video.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from sat.core.i18n import _
-import sys
-import os
-import os.path
-import time
-if sys.platform == "android":
-    from plyer import camera
-    from jnius import autoclass
-    Environment = autoclass('android.os.Environment')
-else:
-    import tempfile
-
-
-PLUGIN_INFO = {
-    "name": _("take video"),
-    "main": "AndroidVideo",
-    "platforms": ('android',),
-    "external": True,
-    "description": _("upload a video from video application"),
-    "icon_medium": "{media}/icons/muchoslava/png/film_camera_off_50.png",
-}
-
-
-class AndroidVideo(object):
-
-    def __init__(self, callback, cancel_cb):
-        self.callback = callback
-        self.cancel_cb = cancel_cb
-        filename = time.strftime("%Y-%m-%d_%H:%M:%S.mpg", time.gmtime())
-        tmp_dir = self.get_tmp_dir()
-        tmp_file = os.path.join(tmp_dir, filename)
-        log.debug("Video will be saved to {}".format(tmp_file))
-        camera.take_video(tmp_file, self.callback)
-        # we don't delete the file, as it is nice to keep it locally
-
-    def get_tmp_dir(self):
-        if sys.platform == "android":
-            dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()
-            return dcim_path
-        else:
-            return tempfile.mkdtemp()
--- a/cagou/plugins/plugin_transfer_file.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,35 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import expanduser os.path.expanduser
-#:import platform kivy.utils.platform
-
-
-<FileChooserBox>:
-    orientation: "vertical"
-    FileChooserListView:
-        id: filechooser
-        path: root.default_path
-    Button:
-        text: "choose"
-        size_hint: 1, None
-        height: dp(50)
-        on_release: root.callback(filechooser.selection)
-    Button:
-        text: "cancel"
-        size_hint: 1, None
-        height: dp(50)
-        on_release: root.cancel_cb()
--- a/cagou/plugins/plugin_transfer_file.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-import threading
-import sys
-from functools import partial
-from sat.core import log as logging
-from sat.core.i18n import _
-from kivy.uix.boxlayout import BoxLayout
-from kivy import properties
-from kivy.clock import Clock
-from plyer import filechooser, storagepath
-
-log = logging.getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    "name": _("file"),
-    "main": "FileTransmitter",
-    "description": _("transmit a local file"),
-    "icon_medium": "{media}/icons/muchoslava/png/fichier_50.png",
-}
-
-
-class FileChooserBox(BoxLayout):
-    callback = properties.ObjectProperty()
-    cancel_cb = properties.ObjectProperty()
-    default_path = properties.StringProperty()
-
-
-class FileTransmitter(BoxLayout):
-    callback = properties.ObjectProperty()
-    cancel_cb = properties.ObjectProperty()
-    native_filechooser = True
-    default_path = storagepath.get_home_dir()
-
-    def __init__(self, *args, **kwargs):
-        if sys.platform == 'android':
-            self.native_filechooser = False
-            self.default_path = storagepath.get_downloads_dir()
-
-        super(FileTransmitter, self).__init__(*args, **kwargs)
-
-        if self.native_filechooser:
-            thread = threading.Thread(target=self._native_file_chooser)
-            thread.start()
-        else:
-            self.add_widget(FileChooserBox(default_path = self.default_path,
-                                           callback=self.on_files,
-                                           cancel_cb=partial(self.cancel_cb, self)))
-
-    def _native_file_chooser(self, *args, **kwargs):
-        title=_("Please select a file to upload")
-        files = filechooser.open_file(title=title,
-                                      path=self.default_path,
-                                      multiple=False,
-                                      preview=True)
-        # we want to leave the thread when calling on_files, so we use Clock
-        Clock.schedule_once(lambda *args: self.on_files(files=files), 0)
-
-    def on_files(self, files):
-        if files:
-            self.callback(files[0])
-        else:
-            self.cancel_cb(self)
--- a/cagou/plugins/plugin_transfer_voice.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,72 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import _ sat.core.i18n._
-#:import IconButton cagou.core.common.IconButton
-
-
-<VoiceRecorder>:
-    orientation: "vertical"
-    counter: counter
-    Label:
-        size_hint: 1, 0.4
-        text_size: self.size
-        halign: 'center'
-        valign: 'top'
-        text: _(u"Push the microphone button to start the record, then push it again to stop it.\nWhen you are satisfied, click on the transmit button")
-    Label:
-        id: counter
-        size_hint: 1, None
-        height: dp(60)
-        bold: True
-        font_size: sp(40)
-        text_size: self.size
-        text: u"{}:{:02}".format(root.time//60, root.time%60)
-        halign: 'center'
-        valign: 'middle'
-    BoxLayout:
-        size_hint: 1, None
-        height: dp(60)
-        spacing: dp(5)
-        Widget
-        IconButton:
-            source: app.expand("{media}/icons/muchoslava/png/") + ("micro_on_50.png" if root.recording else "micro_off_50.png")
-            allow_stretch: True
-            size_hint: None, None
-            size: dp(60), dp(60)
-            on_release: root.switch_recording()
-        IconButton:
-            opacity: 0 if root.recording or not root.time and not root.playing else 1
-            source: app.expand("{media}/icons/muchoslava/png/") + ("stop_50.png" if root.playing else "play_50.png")
-            allow_stretch: True
-            size_hint: None, None
-            size: dp(60), dp(60)
-            on_release: root.play_record()
-        Widget
-    Widget:
-        size_hint: 1, None
-        height: dp(50)
-    Button:
-        text: _("transmit")
-        size_hint: 1, None
-        height: dp(50)
-        on_release: root.callback(root.audio.file_path)
-    Button:
-        text: _("cancel")
-        size_hint: 1, None
-        height: dp(50)
-        on_release: root.cancel_cb(root)
-    Widget
--- a/cagou/plugins/plugin_transfer_voice.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,112 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from sat.core.i18n import _
-from kivy.uix.boxlayout import BoxLayout
-import sys
-import time
-from kivy.clock import Clock
-from kivy import properties
-if sys.platform == "android":
-    from plyer import audio
-
-
-PLUGIN_INFO = {
-    "name": _("voice"),
-    "main": "VoiceRecorder",
-    "platforms": ["android"],
-    "description": _("transmit a voice record"),
-    "icon_medium": "{media}/icons/muchoslava/png/micro_off_50.png",
-    "android_permissons": ["RECORD_AUDIO"],
-}
-
-
-class VoiceRecorder(BoxLayout):
-    callback = properties.ObjectProperty()
-    cancel_cb = properties.ObjectProperty()
-    recording = properties.BooleanProperty(False)
-    playing = properties.BooleanProperty(False)
-    time = properties.NumericProperty(0)
-
-    def __init__(self, **kwargs):
-        super(VoiceRecorder, self).__init__(**kwargs)
-        self._started_at = None
-        self._counter_timer = None
-        self._play_timer = None
-        self.record_time = None
-        self.audio = audio
-        self.audio.file_path = "/sdcard/cagou_record.3gp"
-
-    def _update_timer(self, dt):
-        self.time = int(time.time() - self._started_at)
-
-    def switch_recording(self):
-        if self.playing:
-            self._stop_playing()
-        if self.recording:
-            try:
-                audio.stop()
-            except Exception as e:
-                # an exception can happen if record is pressed
-                # repeatedly in a short time (not a normal use)
-                log.warning("Exception on stop: {}".format(e))
-            self._counter_timer.cancel()
-            self.time = self.time + 1
-        else:
-            audio.start()
-            self._started_at = time.time()
-            self.time = 0
-            self._counter_timer = Clock.schedule_interval(self._update_timer, 1)
-
-        self.recording = not self.recording
-
-    def _stop_playing(self, __=None):
-        if self.record_time is None:
-            log.error("_stop_playing should no be called when record_time is None")
-            return
-        audio.stop()
-        self.playing = False
-        self.time = self.record_time
-        if self._counter_timer is not None:
-            self._counter_timer.cancel()
-
-    def play_record(self):
-        if self.recording:
-            return
-        if self.playing:
-            self._stop_playing()
-        else:
-            try:
-                audio.play()
-            except Exception as e:
-                # an exception can happen in the same situation
-                # as for audio.stop() above (i.e. bad record)
-                log.warning("Exception on play: {}".format(e))
-                self.time = 0
-                return
-
-            self.playing = True
-            self.record_time = self.time
-            Clock.schedule_once(self._stop_playing, self.time + 1)
-            self._started_at = time.time()
-            self.time = 0
-            self._counter_timer =  Clock.schedule_interval(self._update_timer, 0.5)
--- a/cagou/plugins/plugin_wid_blog.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,168 +0,0 @@
-# desktop/mobile frontend for Libervia XMPP client
-# Copyright (C) 2016-2022 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/>.
-
-#:import date_fmt sat.tools.common.date_utils.date_fmt
-
-<SearchButton>:
-    size_hint: None, 1
-    symbol: "search"
-    width: dp(30)
-    font_size: dp(25)
-    color: 0.4, 0.4, 0.4, 1
-
-
-<NewPostButton>:
-    size_hint: None, 1
-    symbol: "pencil"
-    width: dp(30)
-    font_size: dp(25)
-    color: 0.4, 0.4, 0.4, 1
-
-<NewPosttMenu>:
-    padding: dp(20)
-    spacing: dp(10)
-    e2ee: e2ee_checkbox
-    Label:
-        size_hint: 1, None
-        color: 1, 1, 1, 1
-        text: _("Publish a new post on {node} node of {service}").format(node=root.blog.node or "personal blog", service=root.blog.service or root.blog.profile)
-        text_size: root.width, None
-        size: self.texture_size
-        halign: "center"
-        bold: True
-    TextInput:
-        id: title
-        size_hint: 1, None
-        height: sp(30)
-        hint_text: _("title of your post (optional)")
-    TextInput:
-        id: content
-        size_hint: 1, None
-        height: sp(300)
-        hint_text: _("body of your post (markdown syntax allowed)")
-    BoxLayout
-        id: e2ee
-        size_hint: 1, None
-        padding_y: None
-        height: dp(25)
-        Widget:
-        CheckBox:
-            id: e2ee_checkbox
-            size_hint: None, 1
-            width: dp(20)
-            active: False
-            color: 1, 1, 1, 1
-        Label:
-            size_hint: None, None
-            text: _("encrypt post")
-            text_size: None, None
-            size: self.texture_size
-            padding_x: dp(10)
-            font_size: sp(15)
-            color: 1, 1, 1, 1
-        Widget:
-    Button:
-        size_hint: 1, None
-        height: sp(50)
-        text: _("publish")
-        on_release: root.publish(title.text, content.text, e2ee=e2ee_checkbox.active)
-    Widget:
-
-
-<BlogPostAvatar>:
-    size_hint: None, None
-    size: dp(30), dp(30)
-    canvas.before:
-        Color:
-            rgba: (0.87,0.87,0.87,1)
-        RoundedRectangle:
-            radius: [dp(5)]
-            pos: self.pos
-            size: self.size
-
-<BlogPostWidget>:
-    size_hint: 1, None
-    avatar: avatar
-    header_box: header_box
-    height: self.minimum_height
-    orientation: "vertical"
-    Label:
-        color: 0, 0, 0, 1
-        bold: True
-        font_size: root.title_font_size
-        text_size: None, None
-        size_hint: None, None
-        size: self.texture_size[0], self.texture_size[1] if root.blog_data.get("title") else 0
-        opacity: 1 if root.blog_data.get("title") else 0
-        padding: dp(5), 0
-        text: root.blog_data.get("title", "")
-    BoxLayout:
-        id: header_box
-        size_hint: 1, None
-        height: dp(40)
-        BoxLayout:
-            orientation: 'vertical'
-            width: avatar.width
-            size_hint: None, 1
-            BlogPostAvatar:
-                id: avatar
-                source: app.default_avatar
-        Label:
-            id: created_ts
-            color: (0, 0, 0, 1)
-            font_size: root.font_size
-            text_size: None, None
-            size_hint: None, None
-            size: self.texture_size
-            padding: dp(5), 0
-            markup: True
-            valign: 'middle'
-            text: f"published on [b]{date_fmt(root.blog_data.get('published', 0), 'auto_day')}[/b]"
-        Symbol:
-            size_hint: None, None
-            height: created_ts.height
-            width: self.height
-            id: encrypted
-            symbol: 'lock-filled' if root.blog_data.get("encrypted") else 'lock-open'
-            font_size: created_ts.height
-            opacity: 1 if root.blog_data.get("encrypted") else 0
-            color: 0.29,0.87,0.0,1
-    SimpleXHTMLWidget:
-        size_hint: 1, None
-        height: self.minimum_height
-        xhtml: root.blog_data.get("content_xhtml") or self.escape(root.blog_data.get("content", ""))
-        color: (0, 0, 0, 1)
-        padding: dp(5), dp(5)
-
-
-<Blog>:
-    float_layout: float_layout
-    orientation: 'vertical'
-    posts_widget: posts_widget
-    FloatLayout:
-        id: float_layout
-        ScrollView:
-            size_hint: 1, 1
-            pos_hint: {'x': 0, 'y': 0}
-            do_scroll_x: False
-            scroll_type: ['bars', 'content']
-            bar_width: dp(6)
-            BoxLayout:
-                id: posts_widget
-                orientation: "vertical"
-                size_hint: 1, None
-                height: self.minimum_height
-                spacing: dp(10)
--- a/cagou/plugins/plugin_wid_blog.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,192 +0,0 @@
-#!/usr/bin/env python3
-
-#desktop/mobile frontend for Libervia XMPP client
-# Copyright (C) 2016-2022 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 functools import partial
-import json
-from typing import Any, Dict, Optional
-
-from kivy import properties
-from kivy.metrics import sp
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.uix.boxlayout import BoxLayout
-from sat.core import log as logging
-from sat.core.i18n import _
-from sat.tools.common import data_format
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.tools import jid
-
-from cagou import G
-from cagou.core.menu import SideMenu
-
-from ..core import cagou_widget
-from ..core.common import SymbolButton
-from ..core.constants import Const as C
-from ..core.image import Image
-
-log = logging.getLogger(__name__)
-
-PLUGIN_INFO = {
-    "name": _("blog"),
-    "main": "Blog",
-    "description": _("(micro)blog"),
-    "icon_symbol": "pencil",
-}
-
-
-class SearchButton(SymbolButton):
-    blog = properties.ObjectProperty()
-
-    def on_release(self, *args):
-        self.blog.header_input.dispatch('on_text_validate')
-
-
-class NewPostButton(SymbolButton):
-    blog = properties.ObjectProperty()
-
-    def on_release(self, *args):
-        self.blog.show_new_post_menu()
-
-
-class NewPosttMenu(SideMenu):
-    blog = properties.ObjectProperty()
-    size_hint_close = (1, 0)
-    size_hint_open = (1, 0.9)
-
-    def _publish_cb(self, item_id: str) -> None:
-        G.host.add_note(
-            _("blog post published"),
-            _("your blog post has been published with ID {item_id}").format(
-                item_id=item_id
-            )
-        )
-        self.blog.load_blog()
-
-    def _publish_eb(self, exc: BridgeException) -> None:
-        G.host.add_note(
-            _("Problem while publish blog post"),
-            _("Can't publish blog post at {node!r} from {service}: {problem}").format(
-                node=self.blog.node or G.host.ns_map.get("microblog"),
-                service=(
-                    self.blog.service if self.blog.service
-                    else G.host.profiles[self.blog.profile].whoami,
-                ),
-                problem=exc
-            ),
-            C.XMLUI_DATA_LVL_ERROR
-        )
-
-    def publish(
-            self,
-            title: str,
-            content: str,
-            e2ee: bool = False
-    ) -> None:
-        self.hide()
-        mb_data: Dict[str, Any] = {"content_rich": content}
-        if e2ee:
-            mb_data["encrypted"] = True
-        title = title.strip()
-        if title:
-            mb_data["title_rich"] = title
-        G.host.bridge.mb_send(
-            self.blog.service,
-            self.blog.node,
-            data_format.serialise(mb_data),
-            self.blog.profile,
-            callback=self._publish_cb,
-            errback=self._publish_eb,
-        )
-
-
-class BlogPostAvatar(ButtonBehavior, Image):
-    pass
-
-
-class BlogPostWidget(BoxLayout):
-    blog_data = properties.DictProperty()
-    font_size = properties.NumericProperty(sp(12))
-    title_font_size = properties.NumericProperty(sp(14))
-
-
-class Blog(quick_widgets.QuickWidget, cagou_widget.CagouWidget):
-    posts_widget = properties.ObjectProperty()
-    service = properties.StringProperty()
-    node = properties.StringProperty()
-    use_header_input = True
-
-    def __init__(self, host, target, profiles):
-        quick_widgets.QuickWidget.__init__(self, G.host, target, profiles)
-        cagou_widget.CagouWidget.__init__(self)
-        search_btn = SearchButton(blog=self)
-        self.header_input_add_extra(search_btn)
-        new_post_btn = NewPostButton(blog=self)
-        self.header_input_add_extra(new_post_btn)
-        self.load_blog()
-
-    def on_kv_post(self, __):
-        self.bind(
-            service=lambda __, value: self.load_blog(),
-            node=lambda __, value: self.load_blog(),
-        )
-
-    def on_header_wid_input(self):
-        text = self.header_input.text.strip()
-        # for now we only use text as node
-        self.node = text
-
-    def show_new_post_menu(self):
-        """Show the "add a contact" menu"""
-        NewPosttMenu(blog=self).show()
-
-    def _mb_get_cb(self, blog_data_s: str) -> None:
-        blog_data = json.loads(blog_data_s)
-        for item in blog_data["items"]:
-            self.posts_widget.add_widget(BlogPostWidget(blog_data=item))
-
-    def _mb_get_eb(
-        self,
-        exc: BridgeException,
-    ) -> None:
-        G.host.add_note(
-            _("Problem while getting blog data"),
-            _("Can't get blog for {node!r} at {service}: {problem}").format(
-                node=self.node or G.host.ns_map.get("microblog"),
-                service=self.service if self.service else G.host.profiles[self.profile].whoami,
-                problem=exc
-            ),
-            C.XMLUI_DATA_LVL_ERROR
-        )
-
-    def load_blog(
-        self,
-    ) -> None:
-        """Retrieve a blog and display it"""
-        extra = {}
-        self.posts_widget.clear_widgets()
-        G.host.bridge.mb_get(
-            self.service,
-            self.node,
-            20,
-            [],
-            data_format.serialise(extra),
-            self.profile,
-            callback=self._mb_get_cb,
-            errback=self._mb_get_eb,
-        )
--- a/cagou/plugins/plugin_wid_chat.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,349 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import _ sat.core.i18n._
-#:import C cagou.core.constants.Const
-#:import G cagou.G
-#:import escape kivy.utils.escape_markup
-#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget
-#:import DelayedBoxLayout cagou.core.common_widgets.DelayedBoxLayout
-#:import ScrollEffect kivy.effects.scroll.ScrollEffect
-#:import CategorySeparator cagou.core.common_widgets.CategorySeparator
-
-
-# Chat
-
-
-<BaseAttachmentItem>:
-    size_hint: None, None
-    size: self.minimum_width, dp(50)
-
-
-<AttachmentItem>:
-    canvas.before:
-        Color:
-            rgb: app.c_prim_dark
-        Line:
-            rounded_rectangle: self.x + dp(1), self.y + dp(1), self.width - dp(2), self.height - dp(2), 10
-        Color:
-            rgb: app.c_sec_light
-        RoundedRectangle:
-            pos: self.x + dp(1), self.y + dp(1)
-            size: (self.width - dp(2)) * root.progress / 100, self.height - dp(2)
-    SymbolButtonLabel:
-        symbol: root.get_symbol(root.data)
-        color: 0, 0, 0, 1
-        text: root.data.get('name', _('unnamed'))
-        bold: False
-        on_press: root.on_press()
-
-
-<AttachmentImageItem>:
-    size: self.minimum_width, self.minimum_height
-    image: image
-    orientation: "vertical"
-    SizedImage:
-        id: image
-        anim_delay: -1
-        source: "data/images/image-loading.gif"
-
-
-<AttachmentImagesCollectionItem>:
-    cols: 2
-    size_hint: None, None
-    size: dp(150), dp(150)
-    padding: dp(5)
-    spacing: dp(2)
-    canvas.before:
-        Color:
-            rgb: app.c_prim
-        RoundedRectangle:
-            radius: [dp(5)]
-            pos: self.pos
-            size: self.size
-        Color:
-            rgb: 0, 0, 0, 1
-        Line:
-            rounded_rectangle: self.x, self.y, self.width, self.height, dp(5)
-
-
-<AttachmentsLayout>:
-    attachments: self
-    size_hint: 1, None
-    height: self.minimum_height
-    spacing: dp(5)
-
-
-<MessAvatar>:
-    size_hint: None, None
-    size: dp(30), dp(30)
-    canvas.before:
-        Color:
-            rgba: (0.87,0.87,0.87,1)
-        RoundedRectangle:
-            radius: [dp(5)]
-            pos: self.pos
-            size: self.size
-
-
-<MessageWidget>:
-    size_hint: 1, None
-    avatar: avatar
-    delivery: delivery
-    mess_xhtml: mess_xhtml
-    right_part: right_part
-    header_box: header_box
-    height: self.minimum_height
-    BoxLayout:
-        orientation: 'vertical'
-        width: avatar.width
-        size_hint: None, 1
-        MessAvatar:
-            id: avatar
-            source: root.mess_data.avatar['path'] if root.mess_data and root.mess_data.avatar else app.default_avatar
-            on_press: root.chat.add_nick(root.nick)
-        Widget:
-            # use to push the avatar on the top
-            size_hint: 1, 1
-    BoxLayout:
-        size_hint: 1, None
-        orientation: 'vertical'
-        id: right_part
-        height: self.minimum_height
-        BoxLayout:
-            id: header_box
-            size_hint: 1, None
-            height: time_label.height if root.mess_type != C.MESS_TYPE_INFO else 0
-            opacity: 1 if root.mess_type != C.MESS_TYPE_INFO else 0
-            Label:
-                id: time_label
-                color: (0, 0, 0, 1) if root.own_mess else (0.55,0.55,0.55,1)
-                font_size: root.font_size
-                text_size: None, None
-                size_hint: None, None
-                size: self.texture_size
-                padding: dp(5), 0
-                markup: True
-                valign: 'middle'
-                text: u"[b]{}[/b], {}".format(escape(root.nick), root.time_text)
-            Symbol:
-                size_hint_x: None
-                width: self.height
-                id: encrypted
-                symbol: 'lock-filled' if root.mess_data.encrypted else 'lock-open'
-                font_size: self.height - dp(3)
-                color: (1, 0, 0, 1) if not root.mess_data.encrypted and root.chat.encrypted else (0.55,0.55,0.55,1)
-            Label:
-                id: delivery
-                color: C.COLOR_BTN_LIGHT
-                font_size: root.font_size
-                text_size: None, None
-                size_hint: None, None
-                size: self.texture_size
-                padding: dp(5), 0
-                # XXX: DejaVuSans font is needed as check mark is not in Roboto
-                # this can be removed when Kivy will be able to handle fallback mechanism
-                # which will allow us to use fonts with more unicode characters
-                font_name: "DejaVuSans"
-                text: u''
-        SimpleXHTMLWidget:
-            id: mess_xhtml
-            size_hint: 1, None
-            height: self.minimum_height
-            xhtml: root.message_xhtml or self.escape(root.message or '')
-            color: (0.74,0.74,0.24,1) if root.mess_type == "info" else (0, 0, 0, 1)
-            padding: root.mess_padding
-            bold: True if root.mess_type == "info" else False
-
-
-<AttachmentToSendItem>:
-    SymbolButton:
-        opacity: 0 if root.sending else 1
-        size_hint: None, 1
-        symbol: "cancel-circled"
-        on_press: root.parent.remove_widget(root)
-
-
-<AttachmentsToSend>:
-    attachments: attachments_layout.attachments
-    reduce_checkbox: reduce_checkbox
-    orientation: "vertical"
-    size_hint: 1, None
-    height: self.minimum_height if self.attachments.children else 0
-    opacity: 1 if self.attachments.children else 0
-    padding: [app.MARGIN_LEFT, dp(5), app.MARGIN_RIGHT, dp(5)]
-    canvas.before:
-        Color:
-            rgba: app.c_prim
-        Rectangle:
-            pos: self.pos
-            size: self.size
-    Label:
-        size_hint: 1, None
-        size: self.texture_size
-        text: _("attachments:")
-        bold: True
-    AttachmentsLayout:
-        id: attachments_layout
-    BoxLayout:
-        id: resize_box
-        size_hint: 1, None
-        opacity: 1 if root.show_resize else 0
-        height: dp(25) if root.show_resize else 0
-        Widget:
-        CheckBox:
-            id: reduce_checkbox
-            size_hint: None, 1
-            width: dp(20)
-            active: True
-        Label:
-            size_hint: None, 1
-            text: _("reduce images size")
-            text_size: None, None
-            size: self.texture_size
-            valign: "middle"
-            padding_x: dp(10)
-            font_size: sp(15)
-        Widget:
-
-<Chat>:
-    attachments_to_send: attachments_to_send
-    message_input: message_input
-    messages_widget: messages_widget
-    history_scroll: history_scroll
-    send_button_visible: G.local_platform.send_button_visible or bool(attachments_to_send.attachments.children)
-    ScrollView:
-        id: history_scroll
-        scroll_y: 0
-        on_scroll_y: root.on_scroll(*args)
-        do_scroll_x: False
-        scroll_type: ['bars', 'content']
-        bar_width: dp(10)
-        effect_cls: ScrollEffect
-        DelayedBoxLayout:
-            id: messages_widget
-            size_hint_y: None
-            padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)]
-            spacing: dp(10)
-            height: self.minimum_height
-            orientation: 'vertical'
-    AttachmentsToSend:
-        id: attachments_to_send
-    MessageInputBox:
-        size_hint: 1, None
-        height: self.minimum_height
-        spacing: dp(10)
-        padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)]
-        message_input: message_input
-        MessageInputWidget:
-            id: message_input
-            size_hint: 1, None
-            height: min(self.minimum_height, dp(250))
-            multiline: True
-            hint_text: _(u"Enter your message here")
-            on_text_validate: root.on_send(args[0])
-        SymbolButton:
-            # "send" button, permanent visibility depends on platform
-            symbol: "forward"
-            size_hint: None, 1
-            width: dp(30) if root.send_button_visible else 0
-            opacity: 1 if root.send_button_visible else 0
-            font_size: dp(25)
-            on_release: self.parent.send_text()
-
-
-# Buttons added in header
-
-<TransferButton>:
-    size_hint: None, 1
-    symbol: "plus-circled"
-    width: dp(30)
-    font_size: dp(25)
-    color: 0.4, 0.4, 0.4, 1
-
-<MenuButton@Button>
-    size_hint_y: None
-    height: dp(30)
-    on_texture_size: self.parent.parent.width = max(self.parent.parent.width, self.texture_size[0] + dp(10))
-
-<ExtraMenu>:
-    auto_width: False
-    MenuButton:
-        text: _("bookmarks")
-        on_release: root.select("bookmark")
-    MenuButton:
-        text: _("close")
-        on_release: root.select("close")
-
-<ExtraButton>:
-    size_hint: None, 1
-    symbol: "dot-3-vert"
-    width: dp(30)
-    font_size: dp(25)
-    color: 0.4, 0.4, 0.4, 1
-    on_release: self.chat.extra_menu.open(self)
-
-<EncryptionMainButton>:
-    size_hint: None, 1
-    width: dp(30)
-    color: self.get_color()
-    symbol: self.get_symbol()
-
-<TrustManagementButton>:
-    symbol: "shield"
-    padding: dp(5), dp(10)
-    bg_color: app.c_prim_dark
-    size_hint: None, 1
-    width: dp(30)
-    on_release: self.parent.dispatch("on_trust_release")
-
-<EncryptionButton>:
-    size_hint: None, None
-    width: self.parent.parent.best_width if self.parent is not None else 30
-    height: dp(30)
-    on_best_width: self.parent.parent.best_width = max(self.parent.parent.best_width, args[1])
-    Button:
-        text: root.text
-        size_hint: 1, 1
-        padding: dp(5), dp(10)
-        color: 0, 0, 0, 1
-        bold: root.bold
-        background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
-        background_color: app.c_sec if root.selected else app.c_prim_dark
-        on_release: root.dispatch("on_release")
-        on_texture_size: root.best_width = self.texture_size[0] + (dp(30) if root.trust_button else 0)
-
-<EncryptionMenu>:
-    size_hint_x: None
-    width: self.container.minimum_width
-    auto_width: False
-    canvas.before:
-        Color:
-            rgba: 0, 0, 0, 1
-        Rectangle:
-            pos: self.pos
-            size: self.size
-
-
-# Chat Selector
-
-<ChatSelector>:
-    jid_selector: jid_selector
-    JidSelector:
-        id: jid_selector
-        on_select: root.on_select(args[1])
-        to_show: ["opened_chats", "roster", "bookmarks"]
-
--- a/cagou/plugins/plugin_wid_chat.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1280 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 functools import partial
-from pathlib import Path
-import sys
-import uuid
-import mimetypes
-from urllib.parse import urlparse
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.gridlayout import GridLayout
-from kivy.uix.screenmanager import Screen, NoTransition
-from kivy.uix.textinput import TextInput
-from kivy.uix.label import Label
-from kivy.uix import screenmanager
-from kivy.uix.behaviors import ButtonBehavior
-from kivy.metrics import sp, dp
-from kivy.clock import Clock
-from kivy import properties
-from kivy.uix.stacklayout import StackLayout
-from kivy.uix.dropdown import DropDown
-from kivy.core.window import Window
-from sat.core import log as logging
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.tools.common import data_format
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend import quick_chat
-from sat_frontends.tools import jid
-from cagou import G
-from ..core.constants import Const as C
-from ..core import cagou_widget
-from ..core import xmlui
-from ..core.image import Image, AsyncImage
-from ..core.common import Symbol, SymbolButton, JidButton, ContactButton
-from ..core.behaviors import FilterBehavior
-from ..core import menu
-from ..core.common_widgets import ImagesGallery
-
-log = logging.getLogger(__name__)
-
-PLUGIN_INFO = {
-    "name": _("chat"),
-    "main": "Chat",
-    "description": _("instant messaging with one person or a group"),
-    "icon_symbol": "chat",
-}
-
-# FIXME: OTR specific code is legacy, and only used nowadays for lock color
-# we can probably get rid of them.
-OTR_STATE_UNTRUSTED = 'untrusted'
-OTR_STATE_TRUSTED = 'trusted'
-OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED)
-OTR_STATE_UNENCRYPTED = 'unencrypted'
-OTR_STATE_ENCRYPTED = 'encrypted'
-OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED)
-
-SYMBOL_UNENCRYPTED = 'lock-open'
-SYMBOL_ENCRYPTED = 'lock'
-SYMBOL_ENCRYPTED_TRUSTED = 'lock-filled'
-COLOR_UNENCRYPTED = (0.4, 0.4, 0.4, 1)
-COLOR_ENCRYPTED = (0.4, 0.4, 0.4, 1)
-COLOR_ENCRYPTED_TRUSTED = (0.29,0.87,0.0,1)
-
-# below this limit, new messages will be prepended
-INFINITE_SCROLL_LIMIT = dp(600)
-
-# File sending progress
-PROGRESS_UPDATE = 0.2 # number of seconds before next progress update
-
-
-# FIXME: a ScrollLayout was supposed to be used here, but due
-#   to https://github.com/kivy/kivy/issues/6745, a StackLayout is used for now
-class AttachmentsLayout(StackLayout):
-    """Layout for attachments in a received message"""
-    padding = properties.VariableListProperty([dp(5), dp(5), 0, dp(5)])
-    attachments = properties.ObjectProperty()
-
-
-class AttachmentsToSend(BoxLayout):
-    """Layout for attachments to be sent with current message"""
-    attachments = properties.ObjectProperty()
-    reduce_checkbox = properties.ObjectProperty()
-    show_resize = properties.BooleanProperty(False)
-
-    def on_kv_post(self, __):
-        self.attachments.bind(children=self.on_attachment)
-
-    def on_attachment(self, __, attachments):
-        if len(attachments) == 0:
-            self.show_resize = False
-
-
-class BaseAttachmentItem(BoxLayout):
-    data = properties.DictProperty()
-    progress = properties.NumericProperty(0)
-
-
-class AttachmentItem(BaseAttachmentItem):
-
-    def get_symbol(self, data):
-        media_type = data.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '')
-        main_type = media_type.split('/', 1)[0]
-        if main_type == 'image':
-            return "file-image"
-        elif main_type == 'video':
-            return "file-video"
-        elif main_type == 'audio':
-            return "file-audio"
-        else:
-            return "doc"
-
-    def on_press(self):
-        url = self.data.get('url')
-        if url:
-            G.local_platform.open_url(url, self)
-        else:
-            log.warning(f"can't find URL in {self.data}")
-
-
-class AttachmentImageItem(ButtonBehavior, BaseAttachmentItem):
-    image = properties.ObjectProperty()
-
-    def on_press(self):
-        full_size_source = self.data.get('path', self.data.get('url'))
-        gallery = ImagesGallery(sources=[full_size_source])
-        G.host.show_extra_ui(gallery)
-
-    def on_kv_post(self, __):
-        self.on_data(None, self.data)
-
-    def on_data(self, __, data):
-        if self.image is None:
-            return
-        source = data.get('preview') or data.get('path') or data.get('url')
-        if source:
-            self.image.source = source
-
-
-class AttachmentImagesCollectionItem(ButtonBehavior, GridLayout):
-    attachments = properties.ListProperty([])
-    chat = properties.ObjectProperty()
-    mess_data = properties.ObjectProperty()
-
-    def _set_preview(self, attachment, wid, preview_path):
-        attachment['preview'] = preview_path
-        wid.source = preview_path
-
-    def _set_path(self, attachment, wid, path):
-        attachment['path'] = path
-        if wid is not None:
-            # we also need a preview for the widget
-            if 'preview' in attachment:
-                wid.source = attachment['preview']
-            else:
-                G.host.bridge.image_generate_preview(
-                    path,
-                    self.chat.profile,
-                    callback=partial(self._set_preview, attachment, wid),
-                )
-
-    def on_kv_post(self, __):
-        attachments = self.attachments
-        self.clear_widgets()
-        for idx, attachment in enumerate(attachments):
-            try:
-                url = attachment['url']
-            except KeyError:
-                url = None
-                to_download = False
-            else:
-                if url.startswith("aesgcm:"):
-                    del attachment['url']
-                    # if the file is encrypted, we need to download it for decryption
-                    to_download = True
-                else:
-                    to_download = False
-
-            if idx < 3 or len(attachments) <= 4:
-                if ((self.mess_data.own_mess
-                     or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))):
-                    wid = AsyncImage(size_hint=(1, 1), source="data/images/image-loading.gif")
-                    if 'preview' in attachment:
-                        wid.source = attachment["preview"]
-                    elif 'path' in attachment:
-                        G.host.bridge.image_generate_preview(
-                            attachment['path'],
-                            self.chat.profile,
-                            callback=partial(self._set_preview, attachment, wid),
-                        )
-                    elif url is None:
-                        log.warning(f"Can't find any source for {attachment}")
-                    else:
-                        # we'll download the file, the preview will then be generated
-                        to_download = True
-                else:
-                    # we don't download automatically the image if the contact is not
-                    # in roster, to avoid leaking the ip
-                    wid = Symbol(symbol="file-image")
-                self.add_widget(wid)
-            else:
-                wid = None
-
-            if to_download:
-                # the file needs to be downloaded, the widget source,
-                # attachment path, and preview will then be completed
-                G.host.download_url(
-                    url,
-                    callback=partial(self._set_path, attachment, wid),
-                    dest=C.FILE_DEST_CACHE,
-                    profile=self.chat.profile,
-                )
-
-        if len(attachments) > 4:
-            counter = Label(
-                bold=True,
-                text=f"+{len(attachments) - 3}",
-            )
-            self.add_widget(counter)
-
-    def on_press(self):
-        sources = []
-        for attachment in self.attachments:
-            source = attachment.get('path') or attachment.get('url')
-            if not source:
-                log.warning(f"no source for {attachment}")
-            else:
-                sources.append(source)
-        gallery = ImagesGallery(sources=sources)
-        G.host.show_extra_ui(gallery)
-
-
-class AttachmentToSendItem(AttachmentItem):
-    # True when the item is being sent
-    sending = properties.BooleanProperty(False)
-
-
-class MessAvatar(ButtonBehavior, Image):
-    pass
-
-
-class MessageWidget(quick_chat.MessageWidget, BoxLayout):
-    mess_data = properties.ObjectProperty()
-    mess_xhtml = properties.ObjectProperty()
-    mess_padding = (dp(5), dp(5))
-    avatar = properties.ObjectProperty()
-    delivery = properties.ObjectProperty()
-    font_size = properties.NumericProperty(sp(12))
-    right_part = properties.ObjectProperty()
-    header_box = properties.ObjectProperty()
-
-    def on_kv_post(self, __):
-        if not self.mess_data:
-            raise exceptions.InternalError(
-                "mess_data must always be set in MessageWidget")
-
-        self.mess_data.widgets.add(self)
-        self.add_attachments()
-
-    @property
-    def chat(self):
-        """return parent Chat instance"""
-        return self.mess_data.parent
-
-    def _get_from_mess_data(self, name, default):
-        if self.mess_data is None:
-            return default
-        return getattr(self.mess_data, name)
-
-    def _get_message(self):
-        """Return currently displayed message"""
-        if self.mess_data is None:
-            return ""
-        return self.mess_data.main_message
-
-    def _set_message(self, message):
-        if self.mess_data is None:
-            return False
-        if message == self.mess_data.message.get(""):
-            return False
-        self.mess_data.message = {"": message}
-        return True
-
-    message = properties.AliasProperty(
-        partial(_get_from_mess_data, name="main_message", default=""),
-        _set_message,
-        bind=['mess_data'],
-    )
-    message_xhtml = properties.AliasProperty(
-        partial(_get_from_mess_data, name="main_message_xhtml", default=""),
-        bind=['mess_data'])
-    mess_type = properties.AliasProperty(
-        partial(_get_from_mess_data, name="type", default=""), bind=['mess_data'])
-    own_mess = properties.AliasProperty(
-        partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data'])
-    nick = properties.AliasProperty(
-        partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data'])
-    time_text = properties.AliasProperty(
-        partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data'])
-
-    @property
-    def info_type(self):
-        return self.mess_data.info_type
-
-    def update(self, update_dict):
-        if 'avatar' in update_dict:
-            avatar_data = update_dict['avatar']
-            if avatar_data is None:
-                source = G.host.get_default_avatar()
-            else:
-                source = avatar_data['path']
-            self.avatar.source = source
-        if 'status' in update_dict:
-            status = update_dict['status']
-            self.delivery.text =  '\u2714' if status == 'delivered' else ''
-
-    def _set_path(self, data, path):
-        """Set path of decrypted file to an item"""
-        data['path'] = path
-
-    def add_attachments(self):
-        """Add attachments layout + attachments item"""
-        attachments = self.mess_data.attachments
-        if not attachments:
-            return
-        root_layout = AttachmentsLayout()
-        self.right_part.add_widget(root_layout)
-        layout = root_layout.attachments
-
-        image_attachments = []
-        other_attachments = []
-        # we first separate images and other attachments, so we know if we need
-        # to use an image collection
-        for attachment in attachments:
-            media_type = attachment.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '')
-            main_type = media_type.split('/', 1)[0]
-            # GIF images are really badly handled by Kivy, the memory
-            # consumption explode, and the images frequencies are not handled
-            # correctly, thus we can't display them and we consider them as
-            # other attachment, so user can open the item with appropriate
-            # software.
-            if main_type == 'image' and media_type != "image/gif":
-                image_attachments.append(attachment)
-            else:
-                other_attachments.append(attachment)
-
-        if len(image_attachments) > 1:
-            collection = AttachmentImagesCollectionItem(
-                attachments=image_attachments,
-                chat=self.chat,
-                mess_data=self.mess_data,
-            )
-            layout.add_widget(collection)
-        elif image_attachments:
-            attachment = image_attachments[0]
-            # to avoid leaking IP address, we only display image if the contact is in
-            # roster
-            if ((self.mess_data.own_mess
-                 or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))):
-                try:
-                    url = urlparse(attachment['url'])
-                except KeyError:
-                    item = AttachmentImageItem(data=attachment)
-                else:
-                    if url.scheme == "aesgcm":
-                        # we remove the URL now, we'll replace it by
-                        # the local decrypted version
-                        del attachment['url']
-                        item = AttachmentImageItem(data=attachment)
-                        G.host.download_url(
-                            url.geturl(),
-                            callback=partial(self._set_path, item.data),
-                            dest=C.FILE_DEST_CACHE,
-                            profile=self.chat.profile,
-                        )
-                    else:
-                        item = AttachmentImageItem(data=attachment)
-            else:
-                item = AttachmentItem(data=attachment)
-
-            layout.add_widget(item)
-
-        for attachment in other_attachments:
-            item = AttachmentItem(data=attachment)
-            layout.add_widget(item)
-
-
-class MessageInputBox(BoxLayout):
-    message_input = properties.ObjectProperty()
-
-    def send_text(self):
-        self.message_input.send_text()
-
-
-class MessageInputWidget(TextInput):
-
-    def keyboard_on_key_down(self, window, keycode, text, modifiers):
-        # We don't send text when shift is pressed to be able to add line feeds
-        # (i.e. multi-lines messages). We don't send on Android either as the
-        # send button appears on this platform.
-        if (keycode[-1] == "enter"
-            and "shift" not in modifiers
-            and sys.platform != 'android'):
-            self.send_text()
-        else:
-            return super(MessageInputWidget, self).keyboard_on_key_down(
-                window, keycode, text, modifiers)
-
-    def send_text(self):
-        self.dispatch('on_text_validate')
-
-
-class TransferButton(SymbolButton):
-    chat = properties.ObjectProperty()
-
-    def on_release(self, *args):
-        menu.TransferMenu(
-            encrypted=self.chat.encrypted,
-            callback=self.chat.transfer_file,
-        ).show(self)
-
-
-class ExtraMenu(DropDown):
-    chat = properties.ObjectProperty()
-
-    def on_select(self, menu):
-        if menu == 'bookmark':
-            G.host.bridge.menu_launch(C.MENU_GLOBAL, ("groups", "bookmarks"),
-                                     {}, C.NO_SECURITY_LIMIT, self.chat.profile,
-                                     callback=partial(
-                                        G.host.action_manager, profile=self.chat.profile),
-                                     errback=G.host.errback)
-        elif menu == 'close':
-            if self.chat.type == C.CHAT_GROUP:
-                # for MUC, we have to indicate the backend that we've left
-                G.host.bridge.muc_leave(self.chat.target, self.chat.profile)
-            else:
-                # for one2one, backend doesn't keep any state, so we just delete the
-                # widget here in the frontend
-                G.host.widgets.delete_widget(
-                    self.chat, all_instances=True, explicit_close=True)
-        else:
-            raise exceptions.InternalError("Unknown menu: {}".format(menu))
-
-
-class ExtraButton(SymbolButton):
-    chat = properties.ObjectProperty()
-
-
-class EncryptionMainButton(SymbolButton):
-
-    def __init__(self, chat, **kwargs):
-        """
-        @param chat(Chat): Chat instance
-        """
-        self.chat = chat
-        self.encryption_menu = EncryptionMenu(chat)
-        super(EncryptionMainButton, self).__init__(**kwargs)
-        self.bind(on_release=self.encryption_menu.open)
-
-    def select_algo(self, name):
-        """Mark an encryption algorithm as selected.
-
-        This will also deselect all other button
-        @param name(unicode, None): encryption plugin name
-            None for plain text
-        """
-        buttons = self.encryption_menu.container.children
-        buttons[-1].selected = name is None
-        for button in buttons[:-1]:
-            button.selected = button.text == name
-
-    def get_color(self):
-        if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED:
-            return  (0.4, 0.4, 0.4, 1)
-        elif self.chat.otr_state_trust == OTR_STATE_TRUSTED:
-            return (0.29,0.87,0.0,1)
-        else:
-            return  (0.4, 0.4, 0.4, 1)
-
-    def get_symbol(self):
-        if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED:
-            return 'lock-open'
-        elif self.chat.otr_state_trust == OTR_STATE_TRUSTED:
-            return 'lock-filled'
-        else:
-            return 'lock'
-
-
-class TrustManagementButton(SymbolButton):
-    pass
-
-
-class EncryptionButton(BoxLayout):
-    selected = properties.BooleanProperty(False)
-    text = properties.StringProperty()
-    trust_button = properties.BooleanProperty(False)
-    best_width = properties.NumericProperty(0)
-    bold = properties.BooleanProperty(True)
-
-    def __init__(self, **kwargs):
-        super(EncryptionButton, self).__init__(**kwargs)
-        self.register_event_type('on_release')
-        self.register_event_type('on_trust_release')
-        if self.trust_button:
-            self.add_widget(TrustManagementButton())
-
-    def on_release(self):
-        pass
-
-    def on_trust_release(self):
-        pass
-
-
-class EncryptionMenu(DropDown):
-    # best with to display all algorithms buttons + trust buttons
-    best_width = properties.NumericProperty(0)
-
-    def __init__(self, chat, **kwargs):
-        """
-        @param chat(Chat): Chat instance
-        """
-        self.chat = chat
-        super(EncryptionMenu, self).__init__(**kwargs)
-        btn = EncryptionButton(
-            text=_("unencrypted (plain text)"),
-            on_release=self.unencrypted,
-            selected=True,
-            bold=False,
-            )
-        btn.bind(
-            on_release=self.unencrypted,
-        )
-        self.add_widget(btn)
-        for plugin in G.host.encryption_plugins:
-            if chat.type == C.CHAT_GROUP and plugin["directed"]:
-                # directed plugins can't work with group chat
-                continue
-            btn = EncryptionButton(
-                text=plugin['name'],
-                trust_button=True,
-                )
-            btn.bind(
-                on_release=partial(self.start_encryption, plugin=plugin),
-                on_trust_release=partial(self.get_trust_ui, plugin=plugin),
-            )
-            self.add_widget(btn)
-            log.info("added encryption: {}".format(plugin['name']))
-
-    def message_encryption_stop_cb(self):
-        log.info(_("Session with {destinee} is now in plain text").format(
-            destinee = self.chat.target))
-
-    def message_encryption_stop_eb(self, failure_):
-        msg = _("Error while stopping encryption with {destinee}: {reason}").format(
-            destinee = self.chat.target,
-            reason = failure_)
-        log.warning(msg)
-        G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR)
-
-    def unencrypted(self, button):
-        self.dismiss()
-        G.host.bridge.message_encryption_stop(
-            str(self.chat.target),
-            self.chat.profile,
-            callback=self.message_encryption_stop_cb,
-            errback=self.message_encryption_stop_eb)
-
-    def message_encryption_start_cb(self, plugin):
-        log.info(_("Session with {destinee} is now encrypted with {encr_name}").format(
-            destinee = self.chat.target,
-            encr_name = plugin['name']))
-
-    def message_encryption_start_eb(self, failure_):
-        msg = _("Session can't be encrypted with {destinee}: {reason}").format(
-            destinee = self.chat.target,
-            reason = failure_)
-        log.warning(msg)
-        G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR)
-
-    def start_encryption(self, button, plugin):
-        """Request encryption with given plugin for this session
-
-        @param button(EncryptionButton): button which has been pressed
-        @param plugin(dict): plugin data
-        """
-        self.dismiss()
-        G.host.bridge.message_encryption_start(
-            str(self.chat.target),
-            plugin['namespace'],
-            True,
-            self.chat.profile,
-            callback=partial(self.message_encryption_start_cb, plugin=plugin),
-            errback=self.message_encryption_start_eb)
-
-    def encryption_trust_ui_get_cb(self, xmlui_raw):
-        xml_ui = xmlui.create(
-            G.host, xmlui_raw, profile=self.chat.profile)
-        xml_ui.show()
-
-    def encryption_trust_ui_get_eb(self, failure_):
-        msg = _("Trust manager interface can't be retrieved: {reason}").format(
-            reason = failure_)
-        log.warning(msg)
-        G.host.add_note(_("encryption trust management problem"), msg,
-                       C.XMLUI_DATA_LVL_ERROR)
-
-    def get_trust_ui(self, button, plugin):
-        """Request and display trust management UI
-
-        @param button(EncryptionButton): button which has been pressed
-        @param plugin(dict): plugin data
-        """
-        self.dismiss()
-        G.host.bridge.encryption_trust_ui_get(
-            str(self.chat.target),
-            plugin['namespace'],
-            self.chat.profile,
-            callback=self.encryption_trust_ui_get_cb,
-            errback=self.encryption_trust_ui_get_eb)
-
-
-class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget):
-    message_input = properties.ObjectProperty()
-    messages_widget = properties.ObjectProperty()
-    history_scroll = properties.ObjectProperty()
-    attachments_to_send = properties.ObjectProperty()
-    send_button_visible = properties.BooleanProperty()
-    use_header_input = True
-    global_screen_manager = True
-    collection_carousel = True
-
-    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
-                 subject=None, statuses=None, profiles=None):
-        self.show_chat_selector = False
-        if statuses is None:
-            statuses = {}
-        quick_chat.QuickChat.__init__(
-            self, host, target, type_, nick, occupants, subject, statuses,
-            profiles=profiles)
-        self.otr_state_encryption = OTR_STATE_UNENCRYPTED
-        self.otr_state_trust = OTR_STATE_UNTRUSTED
-        # completion attributes
-        self._hi_comp_data = None
-        self._hi_comp_last = None
-        self._hi_comp_dropdown = DropDown()
-        self._hi_comp_allowed = True
-        cagou_widget.CagouWidget.__init__(self)
-        transfer_btn = TransferButton(chat=self)
-        self.header_input_add_extra(transfer_btn)
-        if (type_ == C.CHAT_ONE2ONE or "REALJID_PUBLIC" in statuses):
-            self.encryption_btn = EncryptionMainButton(self)
-            self.header_input_add_extra(self.encryption_btn)
-        self.extra_menu = ExtraMenu(chat=self)
-        extra_btn = ExtraButton(chat=self)
-        self.header_input_add_extra(extra_btn)
-        self.header_input.hint_text = target
-        self._history_prepend_lock = False
-        self.history_count = 0
-
-    def on_kv_post(self, __):
-        self.post_init()
-
-    def screen_manager_init(self, screen_manager):
-        screen_manager.transition = screenmanager.SlideTransition(direction='down')
-        sel_screen = Screen(name='chat_selector')
-        chat_selector = ChatSelector(profile=self.profile)
-        sel_screen.add_widget(chat_selector)
-        screen_manager.add_widget(sel_screen)
-        if self.show_chat_selector:
-            transition = screen_manager.transition
-            screen_manager.transition = NoTransition()
-            screen_manager.current = 'chat_selector'
-            screen_manager.transition = transition
-
-    def __str__(self):
-        return "Chat({})".format(self.target)
-
-    def __repr__(self):
-        return self.__str__()
-
-    @classmethod
-    def factory(cls, plugin_info, target, profiles):
-        profiles = list(profiles)
-        if len(profiles) > 1:
-            raise NotImplementedError("Multi-profiles is not available yet for chat")
-        if target is None:
-            show_chat_selector = True
-            target = G.host.profiles[profiles[0]].whoami
-        else:
-            show_chat_selector = False
-        wid = G.host.widgets.get_or_create_widget(cls, target, on_new_widget=None,
-                                               on_existing_widget=G.host.get_or_clone,
-                                               profiles=profiles)
-        wid.show_chat_selector = show_chat_selector
-        return wid
-
-    @property
-    def message_widgets_rev(self):
-        return self.messages_widget.children
-
-    ## keyboard ##
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            screen_manager = self.screen_manager
-            screen_manager.transition.direction = 'down'
-            screen_manager.current = 'chat_selector'
-            return True
-
-    ## drop ##
-
-    def on_drop_file(self, path):
-        self.add_attachment(path)
-
-    ## header ##
-
-    def change_widget(self, jid_):
-        """change current widget for a new one with given jid
-
-        @param jid_(jid.JID): jid of the widget to create
-        """
-        plugin_info = G.host.get_plugin_info(main=Chat)
-        factory = plugin_info['factory']
-        G.host.switch_widget(self, factory(plugin_info, jid_, profiles=[self.profile]))
-        self.header_input.text = ''
-
-    def on_header_wid_input(self):
-        text = self.header_input.text.strip()
-        try:
-            if text.count('@') != 1 or text.count(' '):
-                raise ValueError
-            jid_ = jid.JID(text)
-        except ValueError:
-            log.info("entered text is not a jid")
-            return
-
-        def disco_cb(disco):
-            # TODO: check if plugin XEP-0045 is activated
-            if "conference" in [i[0] for i in disco[1]]:
-                G.host.bridge.muc_join(str(jid_), "", "", self.profile,
-                                      callback=self._muc_join_cb, errback=self._muc_join_eb)
-            else:
-                self.change_widget(jid_)
-
-        def disco_eb(failure):
-            log.warning("Disco failure, ignore this text: {}".format(failure))
-
-        G.host.bridge.disco_infos(
-            jid_.domain,
-            profile_key=self.profile,
-            callback=disco_cb,
-            errback=disco_eb)
-
-    def on_header_wid_input_completed(self, input_wid, completed_text):
-        self._hi_comp_allowed = False
-        input_wid.text = completed_text
-        self._hi_comp_allowed = True
-        self._hi_comp_dropdown.dismiss()
-        self.on_header_wid_input()
-
-    def on_header_wid_input_complete(self, wid, text):
-        if not self._hi_comp_allowed:
-            return
-        text = text.lstrip()
-        if not text:
-            self._hi_comp_data = None
-            self._hi_comp_last = None
-            self._hi_comp_dropdown.dismiss()
-            return
-
-        profile = list(self.profiles)[0]
-
-        if self._hi_comp_data is None:
-            # first completion, we build the initial list
-            comp_data = self._hi_comp_data = []
-            self._hi_comp_last = ''
-            for jid_, jid_data in G.host.contact_lists[profile].all_iter:
-                comp_data.append((jid_, jid_data))
-            comp_data.sort(key=lambda datum: datum[0])
-        else:
-            comp_data = self._hi_comp_data
-
-        # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed,
-        #      it works OK, but some optimisation may be done here
-        dropdown = self._hi_comp_dropdown
-
-        if not text.startswith(self._hi_comp_last) or not self._hi_comp_last:
-            # text has changed or backspace has been pressed, we restart
-            dropdown.clear_widgets()
-
-            for jid_, jid_data in comp_data:
-                nick = jid_data.get('nick', '')
-                if text in jid_.bare or text in nick.lower():
-                    btn = JidButton(
-                        jid = jid_.bare,
-                        profile = profile,
-                        size_hint = (0.5, None),
-                        nick = nick,
-                        on_release=lambda __, txt=jid_.bare: self.on_header_wid_input_completed(wid, txt)
-                        )
-                    dropdown.add_widget(btn)
-        else:
-            # more chars, we continue completion by removing unwanted widgets
-            to_remove = []
-            for c in dropdown.children[0].children:
-                if text not in c.jid and text not in (c.nick or ''):
-                    to_remove.append(c)
-            for c in to_remove:
-                dropdown.remove_widget(c)
-        if dropdown.attach_to is None:
-            dropdown.open(wid)
-        self._hi_comp_last = text
-
-    def message_data_converter(self, idx, mess_id):
-        return {"mess_data": self.messages[mess_id]}
-
-    def _on_history_printed(self):
-        """Refresh or scroll down the focus after the history is printed"""
-        # self.adapter.data = self.messages
-        for mess_data in self.messages.values():
-            self.appendMessage(mess_data)
-        super(Chat, self)._on_history_printed()
-
-    def create_message(self, message):
-        self.appendMessage(message)
-        # we need to render immediatly next 2 layouts to avoid an unpleasant flickering
-        # when sending or receiving a message
-        self.messages_widget.dont_delay_next_layouts = 2
-
-    def appendMessage(self, mess_data):
-        """Append a message Widget to the history
-
-        @param mess_data(quick_chat.Message): message data
-        """
-        if self.handle_user_moved(mess_data):
-            return
-        self.messages_widget.add_widget(MessageWidget(mess_data=mess_data))
-        self.notify(mess_data)
-
-    def prepend_message(self, mess_data):
-        """Prepend a message Widget to the history
-
-        @param mess_data(quick_chat.Message): message data
-        """
-        mess_wid = self.messages_widget
-        last_idx = len(mess_wid.children)
-        mess_wid.add_widget(MessageWidget(mess_data=mess_data), index=last_idx)
-
-    def _get_notif_msg(self, mess_data):
-        return _("{nick}: {message}").format(
-            nick=mess_data.nick,
-            message=mess_data.main_message)
-
-    def notify(self, mess_data):
-        """Notify user when suitable
-
-        For one2one chat, notification will happen when window has not focus
-        or when one2one chat is not visible. A note is also there when widget
-        is not visible.
-        For group chat, note will be added on mention, with a desktop notification if
-        window has not focus or is not visible.
-        """
-        visible_clones = [w for w in G.host.get_visible_list(self.__class__)
-                          if w.target == self.target]
-        if len(visible_clones) > 1 and visible_clones.index(self) > 0:
-            # to avoid multiple notifications in case of multiple cloned widgets
-            # we only handle first clone
-            return
-        is_visible = bool(visible_clones)
-        if self.type == C.CHAT_ONE2ONE:
-            if (not Window.focus or not is_visible) and not mess_data.history:
-                notif_msg = self._get_notif_msg(mess_data)
-                G.host.notify(
-                    type_=C.NOTIFY_MESSAGE,
-                    entity=mess_data.from_jid,
-                    message=notif_msg,
-                    subject=_("private message"),
-                    widget=self,
-                    profile=self.profile
-                )
-                if not is_visible:
-                    G.host.add_note(
-                        _("private message"),
-                        notif_msg,
-                        symbol = "chat",
-                        action = {
-                            "action": 'chat',
-                            "target": self.target,
-                            "profiles": self.profiles}
-                        )
-        else:
-            if mess_data.mention:
-                notif_msg = self._get_notif_msg(mess_data)
-                G.host.add_note(
-                    _("mention"),
-                    notif_msg,
-                    symbol = "chat",
-                    action = {
-                        "action": 'chat',
-                        "target": self.target,
-                        "profiles": self.profiles}
-                    )
-                if not is_visible or not Window.focus:
-                    subject=_("mention ({room_jid})").format(room_jid=self.target)
-                    G.host.notify(
-                        type_=C.NOTIFY_MENTION,
-                        entity=self.target,
-                        message=notif_msg,
-                        subject=subject,
-                        widget=self,
-                        profile=self.profile
-                    )
-
-    # message input
-
-    def _attachment_progress_cb(self, item, metadata, profile):
-        item.parent.remove_widget(item)
-        log.info(f"item {item.data.get('path')} uploaded successfully")
-
-    def _attachment_progress_eb(self, item, err_msg, profile):
-        item.parent.remove_widget(item)
-        path = item.data.get('path')
-        msg = _("item {path} could not be uploaded: {err_msg}").format(
-            path=path, err_msg=err_msg)
-        G.host.add_note(_("can't upload file"), msg, C.XMLUI_DATA_LVL_WARNING)
-        log.warning(msg)
-
-    def _progress_get_cb(self, item, metadata):
-        try:
-            position = int(metadata["position"])
-            size = int(metadata["size"])
-        except KeyError:
-            # we got empty metadata, the progression is either not yet started or
-            # finished
-            if item.progress:
-                # if progress is already started, receiving empty metadata means
-                # that progression is finished
-                item.progress = 100
-                return
-        else:
-            item.progress = position/size*100
-
-        if item.parent is not None:
-            # the item is not yet fully received, we reschedule an update
-            Clock.schedule_once(
-                partial(self._attachment_progress_update, item),
-                PROGRESS_UPDATE)
-
-    def _attachment_progress_update(self, item, __):
-        G.host.bridge.progress_get(
-            item.data["progress_id"],
-            self.profile,
-            callback=partial(self._progress_get_cb, item),
-            errback=G.host.errback,
-        )
-
-    def add_nick(self, nick):
-        """Add a nickname to message_input if suitable"""
-        if (self.type == C.CHAT_GROUP and not self.message_input.text.startswith(nick)):
-            self.message_input.text = f'{nick}: {self.message_input.text}'
-
-    def on_send(self, input_widget):
-        extra = {}
-        for item in self.attachments_to_send.attachments.children:
-            if item.sending:
-                # the item is already being sent
-                continue
-            item.sending = True
-            progress_id = item.data["progress_id"] = str(uuid.uuid4())
-            attachments = extra.setdefault(C.KEY_ATTACHMENTS, [])
-            attachment = {
-                "path": str(item.data["path"]),
-                "progress_id": progress_id,
-            }
-            if 'media_type' in item.data:
-                attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = item.data['media_type']
-
-            if ((self.attachments_to_send.reduce_checkbox.active
-                 and attachment.get('media_type', '').split('/')[0] == 'image')):
-                attachment[C.KEY_ATTACHMENTS_RESIZE] = True
-
-            attachments.append(attachment)
-
-            Clock.schedule_once(
-                partial(self._attachment_progress_update, item),
-                PROGRESS_UPDATE)
-
-            G.host.register_progress_cbs(
-                progress_id,
-                callback=partial(self._attachment_progress_cb, item),
-                errback=partial(self._attachment_progress_eb, item)
-            )
-
-
-        G.host.message_send(
-            self.target,
-            # TODO: handle language
-            {'': input_widget.text},
-            # TODO: put this in QuickChat
-            mess_type=
-                C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT,
-            extra=extra,
-            profile_key=self.profile
-            )
-        input_widget.text = ''
-
-    def _image_check_cb(self, report_raw):
-        report = data_format.deserialise(report_raw)
-        if report['too_large']:
-            self.attachments_to_send.show_resize=True
-            self.attachments_to_send.reduce_checkbox.active=True
-
-    def add_attachment(self, file_path, media_type=None):
-        file_path = Path(file_path)
-        if media_type is None:
-            media_type = mimetypes.guess_type(str(file_path), strict=False)[0]
-        if not self.attachments_to_send.show_resize and media_type is not None:
-            # we check if the attachment is an image and if it's too large.
-            # If too large, the reduce size check box will be displayed, and checked by
-            # default.
-            main_type = media_type.split('/')[0]
-            if main_type == "image":
-                G.host.bridge.image_check(
-                    str(file_path),
-                    callback=self._image_check_cb,
-                    errback=partial(
-                        G.host.errback,
-                        title=_("Can't check image size"),
-                        message=_("Can't check image at {path}: {{msg}}").format(
-                            path=file_path),
-                    )
-                )
-
-        data = {
-            "path": file_path,
-            "name": file_path.name,
-        }
-
-        if media_type is not None:
-            data['media_type'] = media_type
-
-        self.attachments_to_send.attachments.add_widget(
-            AttachmentToSendItem(data=data)
-        )
-
-    def transfer_file(self, file_path, transfer_type=C.TRANSFER_UPLOAD, cleaning_cb=None):
-        # FIXME: cleaning_cb is not managed
-        if transfer_type == C.TRANSFER_UPLOAD:
-            self.add_attachment(file_path)
-        elif transfer_type == C.TRANSFER_SEND:
-            if self.type == C.CHAT_GROUP:
-                log.warning("P2P transfer is not possible for group chat")
-                # TODO: show an error dialog to user, or better hide the send button for
-                #       MUC
-            else:
-                jid_ = self.target
-                if not jid_.resource:
-                    jid_ = G.host.contact_lists[self.profile].get_full_jid(jid_)
-                G.host.bridge.file_send(str(jid_), str(file_path), "", "", "",
-                                       profile=self.profile)
-                # TODO: notification of sending/failing
-        else:
-            raise log.error("transfer of type {} are not handled".format(transfer_type))
-
-    def message_encryption_started(self, plugin_data):
-        quick_chat.QuickChat.message_encryption_started(self, plugin_data)
-        self.encryption_btn.symbol = SYMBOL_ENCRYPTED
-        self.encryption_btn.color = COLOR_ENCRYPTED
-        self.encryption_btn.select_algo(plugin_data['name'])
-
-    def message_encryption_stopped(self, plugin_data):
-        quick_chat.QuickChat.message_encryption_stopped(self, plugin_data)
-        self.encryption_btn.symbol = SYMBOL_UNENCRYPTED
-        self.encryption_btn.color = COLOR_UNENCRYPTED
-        self.encryption_btn.select_algo(None)
-
-    def _muc_join_cb(self, joined_data):
-        joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data
-        self.host.muc_room_joined_handler(*joined_data[1:])
-        jid_ = jid.JID(room_jid_s)
-        self.change_widget(jid_)
-
-    def _muc_join_eb(self, failure):
-        log.warning("Can't join room: {}".format(failure))
-
-    def on_otr_state(self, state, dest_jid, profile):
-        assert profile in self.profiles
-        if state in OTR_STATE_ENCRYPTION:
-            self.otr_state_encryption = state
-        elif state in OTR_STATE_TRUST:
-            self.otr_state_trust = state
-        else:
-            log.error(_("Unknown OTR state received: {}".format(state)))
-            return
-        self.encryption_btn.symbol = self.encryption_btn.get_symbol()
-        self.encryption_btn.color = self.encryption_btn.get_color()
-
-    def on_visible(self):
-        if not self.sync:
-            self.resync()
-
-    def on_selected(self):
-        G.host.clear_notifs(self.target, profile=self.profile)
-
-    def on_delete(self, **kwargs):
-        if kwargs.get('explicit_close', False):
-            wrapper = self.whwrapper
-            if wrapper is not None:
-                if len(wrapper.carousel.slides) == 1:
-                    # if we delete the last opened chat, we need to show the selector
-                    screen_manager = self.screen_manager
-                    screen_manager.transition.direction = 'down'
-                    screen_manager.current = 'chat_selector'
-                wrapper.carousel.remove_widget(self)
-            return True
-        # we always keep one widget, so it's available when swiping
-        # TODO: delete all widgets when chat is closed
-        nb_instances = sum(1 for _ in self.host.widgets.get_widget_instances(self))
-        # we want to keep at least one instance of Chat by WHWrapper
-        nb_to_keep = len(G.host.widgets_handler.children)
-        if nb_instances <= nb_to_keep:
-            return False
-
-    def _history_unlock(self, __):
-        self._history_prepend_lock = False
-        log.debug("history prepend unlocked")
-        # we call manually on_scroll, to check if we are still in the scrolling zone
-        self.on_scroll(self.history_scroll, self.history_scroll.scroll_y)
-
-    def _history_scroll_adjust(self, __, scroll_start_height):
-        # history scroll position must correspond to where it was before new messages
-        # have been appended
-        self.history_scroll.scroll_y = (
-            scroll_start_height / self.messages_widget.height
-        )
-
-        # we want a small delay before unlocking, to avoid re-fetching history
-        # again
-        Clock.schedule_once(self._history_unlock, 1.5)
-
-    def _back_history_get_cb_post(self, __, history, scroll_start_height):
-        if len(history) == 0:
-            # we don't unlock self._history_prepend_lock if there is no history, as there
-            # is no sense to try to retrieve more in this case.
-            log.debug(f"we've reached top of history for {self.target.bare} chat")
-        else:
-            # we have to schedule again for _history_scroll_adjust, else messages_widget
-            # is not resized (self.messages_widget.height is not yet updated)
-            # as a result, the scroll_to can't work correctly
-            Clock.schedule_once(partial(
-                self._history_scroll_adjust,
-                scroll_start_height=scroll_start_height))
-            log.debug(
-                f"{len(history)} messages prepended to history (last: {history[0][0]})")
-
-    def _back_history_get_cb(self, history):
-        # TODO: factorise with QuickChat._history_get_cb
-        scroll_start_height = self.messages_widget.height * self.history_scroll.scroll_y
-        for data in reversed(history):
-            uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data
-            from_jid = jid.JID(from_jid)
-            to_jid = jid.JID(to_jid)
-            extra = data_format.deserialise(extra_s)
-            extra["history"] = True
-            self.messages[uid] = message = quick_chat.Message(
-                self,
-                uid,
-                timestamp,
-                from_jid,
-                to_jid,
-                message,
-                subject,
-                type_,
-                extra,
-                self.profile,
-            )
-            self.messages.move_to_end(uid, last=False)
-            self.prepend_message(message)
-        Clock.schedule_once(partial(
-            self._back_history_get_cb_post,
-            history=history,
-            scroll_start_height=scroll_start_height))
-
-    def _back_history_get_eb(self, failure_):
-        G.host.add_note(
-            _("Problem while getting back history"),
-            _("Can't back history for {target}: {problem}").format(
-                target=self.target, problem=failure_),
-            C.XMLUI_DATA_LVL_ERROR)
-        # we don't unlock self._history_prepend_lock on purpose, no need
-        # to try to get more history if something is wrong
-
-    def on_scroll(self, scroll_view, scroll_y):
-        if self._history_prepend_lock:
-            return
-        if (1-scroll_y) * self.messages_widget.height < INFINITE_SCROLL_LIMIT:
-            self._history_prepend_lock = True
-            log.debug(f"Retrieving back history for {self} [{self.history_count}]")
-            self.history_count += 1
-            first_uid = next(iter(self.messages.keys()))
-            filters = self.history_filters.copy()
-            filters['before_uid'] = first_uid
-            self.host.bridge.history_get(
-                str(self.host.profiles[self.profile].whoami.bare),
-                str(self.target),
-                30,
-                True,
-                {k: str(v) for k,v in filters.items()},
-                self.profile,
-                callback=self._back_history_get_cb,
-                errback=self._back_history_get_eb,
-            )
-
-
-class ChatSelector(cagou_widget.CagouWidget, FilterBehavior):
-    jid_selector = properties.ObjectProperty()
-    profile = properties.StringProperty()
-    plugin_info_class = Chat
-    use_header_input = True
-
-    def on_select(self, contact_button):
-        contact_jid = jid.JID(contact_button.jid)
-        plugin_info = G.host.get_plugin_info(main=Chat)
-        factory = plugin_info['factory']
-        self.screen_manager.transition.direction = 'up'
-        carousel = self.whwrapper.carousel
-        current_slides = {w.target: w for w in carousel.slides}
-        if contact_jid in current_slides:
-            slide = current_slides[contact_jid]
-            idx = carousel.slides.index(slide)
-            carousel.index = idx
-            self.screen_manager.current = ''
-        else:
-            G.host.switch_widget(
-                self, factory(plugin_info, contact_jid, profiles=[self.profile]))
-
-
-    def on_header_wid_input(self):
-        text = self.header_input.text.strip()
-        try:
-            if text.count('@') != 1 or text.count(' '):
-                raise ValueError
-            jid_ = jid.JID(text)
-        except ValueError:
-            log.info("entered text is not a jid")
-            return
-        G.host.do_action("chat", jid_, [self.profile])
-
-    def on_header_wid_input_complete(self, wid, text, **kwargs):
-        """we filter items when text is entered in input box"""
-        for layout in self.jid_selector.items_layouts:
-            self.do_filter(
-                layout,
-                text,
-                # we append nick to jid to filter on both
-                lambda c: c.jid + c.data.get('nick', ''),
-                width_cb=lambda c: c.base_width,
-                height_cb=lambda c: c.minimum_height,
-                continue_tests=[lambda c: not isinstance(c, ContactButton)])
-
-
-PLUGIN_INFO["factory"] = Chat.factory
-quick_widgets.register(quick_chat.QuickChat, Chat)
--- a/cagou/plugins/plugin_wid_contact_list.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import _ sat.core.i18n._
-#:import e kivy.utils.escape_markup
-
-<AddContactMenu>:
-    padding: dp(20)
-    spacing: dp(10)
-    Label:
-        size_hint: 1, None
-        color: 1, 1, 1, 1
-        text: _("Please enter new contact JID")
-        text_size: root.width, None
-        size: self.texture_size
-        halign: "center"
-        bold: True
-    TextInput:
-        id: contact_jid
-        size_hint: 1, None
-        height: sp(30)
-        hint_text: _("enter here your new contact JID")
-    Button:
-        size_hint: 1, None
-        height: sp(50)
-        text: _("add this contact")
-        on_release: root.contact_add(contact_jid.text)
-    Widget:
-
-
-<DelContactMenu>:
-    padding: dp(20)
-    spacing: dp(10)
-    Avatar:
-        id: avatar
-        size_hint: 1, None
-        height: dp(60)
-        data: root.contact_item.data.get('avatar')
-        allow_stretch: True
-    Label:
-        size_hint: 1, None
-        color: 1, 1, 1, 1
-        text: _("Are you sure you wand to remove [b]{name}[/b] from your contact list?").format(name=e(root.contact_item.jid))
-        markup: True
-        text_size: root.width, None
-        size: self.texture_size
-        halign: "center"
-    BoxLayout:
-        Button:
-            background_color: 1, 0, 0, 1
-            size_hint: 0.5, None
-            height: sp(50)
-            text: _("yes, remove it")
-            bold: True
-            on_release: root.do_delete_contact()
-        Button:
-            size_hint: 0.5, None
-            height: sp(50)
-            text: _("no, keep it")
-            on_release: root.hide()
-    Widget:
-
-
-<ContactList>:
-    float_layout: float_layout
-    layout: layout
-    orientation: 'vertical'
-    BoxLayout:
-        size_hint: 1, None
-        height: dp(35)
-        width: dp(35)
-        font_size: dp(30)
-        Widget:
-        SymbolButtonLabel:
-            symbol: 'plus-circled'
-            text: _("add a contact")
-            on_release: root.add_contact_menu()
-        Widget:
-    FloatLayout:
-        id: float_layout
-        ScrollView:
-            size_hint: 1, 1
-            pos_hint: {'x': 0, 'y': 0}
-            do_scroll_x: False
-            scroll_type: ['bars', 'content']
-            bar_width: dp(6)
-            StackLayout:
-                id: layout
-                size_hint: 1, None
-                height: self.minimum_height
-                spacing: 0
--- a/cagou/plugins/plugin_wid_contact_list.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,196 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 functools import partial
-import bisect
-import re
-from sat.core import log as logging
-from sat.core.i18n import _
-from sat_frontends.quick_frontend.quick_contact_list import QuickContactList
-from sat_frontends.tools import jid
-from kivy import properties
-from cagou import G
-from ..core import cagou_widget
-from ..core.constants import Const as C
-from ..core.common import ContactItem
-from ..core.behaviors import FilterBehavior, TouchMenuBehavior, TouchMenuItemBehavior
-from ..core.menu import SideMenu
-
-
-log = logging.getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    "name": _("contacts"),
-    "main": "ContactList",
-    "description": _("list of contacts"),
-    "icon_medium": "{media}/icons/muchoslava/png/contact_list_no_border_blue_44.png"
-}
-
-
-class AddContactMenu(SideMenu):
-    profile = properties.StringProperty()
-    size_hint_close = (1, 0)
-    size_hint_open = (1, 0.5)
-
-    def __init__(self, **kwargs):
-        super(AddContactMenu, self).__init__(**kwargs)
-        if self.profile is None:
-            log.warning(_("profile not set in AddContactMenu"))
-            self.profile = next(iter(G.host.profiles))
-
-    def contact_add(self, contact_jid):
-        """Actually add the contact
-
-        @param contact_jid(unicode): jid of the contact to add
-        """
-        self.hide()
-        contact_jid = contact_jid.strip()
-        # FIXME: trivial jid verification
-        if not contact_jid or not re.match(r"[^@ ]+@[^@ ]+", contact_jid):
-            return
-        contact_jid = jid.JID(contact_jid).bare
-        G.host.bridge.contact_add(str(contact_jid),
-            self.profile,
-            callback=lambda: G.host.add_note(
-                _("contact request"),
-                _("a contact request has been sent to {contact_jid}").format(
-                    contact_jid=contact_jid)),
-            errback=partial(G.host.errback,
-                title=_("can't add contact"),
-                message=_("error while trying to add contact: {msg}")))
-
-
-class DelContactMenu(SideMenu):
-    size_hint_close = (1, 0)
-    size_hint_open = (1, 0.5)
-
-    def __init__(self, contact_item, **kwargs):
-        self.contact_item = contact_item
-        super(DelContactMenu, self).__init__(**kwargs)
-
-    def do_delete_contact(self):
-        self.hide()
-        G.host.bridge.contact_del(str(self.contact_item.jid.bare),
-        self.contact_item.profile,
-        callback=lambda: G.host.add_note(
-            _("contact removed"),
-            _("{contact_jid} has been removed from your contacts list").format(
-                contact_jid=self.contact_item.jid.bare)),
-        errback=partial(G.host.errback,
-            title=_("can't remove contact"),
-            message=_("error while trying to remove contact: {msg}")))
-
-
-class CLContactItem(TouchMenuItemBehavior, ContactItem):
-
-    def do_item_action(self, touch):
-        assert self.profile
-        # XXX: for now clicking on an item launch the corresponding Chat widget
-        #      behaviour should change in the future
-        G.host.do_action('chat', jid.JID(self.jid), [self.profile])
-
-    def get_menu_choices(self):
-        choices = []
-        choices.append(dict(text=_('delete'),
-                            index=len(choices)+1,
-                            callback=self.main_wid.remove_contact))
-        return choices
-
-
-class ContactList(QuickContactList, cagou_widget.CagouWidget, FilterBehavior,
-                  TouchMenuBehavior):
-    float_layout = properties.ObjectProperty()
-    layout = properties.ObjectProperty()
-    use_header_input = True
-
-    def __init__(self, host, target, profiles):
-        QuickContactList.__init__(self, G.host, profiles)
-        cagou_widget.CagouWidget.__init__(self)
-        FilterBehavior.__init__(self)
-        self._wid_map = {}  # (profile, bare_jid) to widget map
-        self.post_init()
-        if len(self.profiles) != 1:
-            raise NotImplementedError('multi profiles is not implemented yet')
-        self.update(profile=next(iter(self.profiles)))
-
-    def add_contact_menu(self):
-        """Show the "add a contact" menu"""
-        # FIXME: for now we add contact to the first profile we find
-        profile = next(iter(self.profiles))
-        AddContactMenu(profile=profile).show()
-
-    def remove_contact(self, menu_label):
-        item = self.menu_item
-        self.clear_menu()
-        DelContactMenu(contact_item=item).show()
-
-    def on_header_wid_input_complete(self, wid, text):
-        self.do_filter(self.layout,
-                       text,
-                       lambda c: c.jid,
-                       width_cb=lambda c: c.base_width,
-                       height_cb=lambda c: c.minimum_height,
-                       )
-
-    def _add_contact_item(self, bare_jid, profile):
-        """Create a new CLContactItem instance, and add it
-
-        item will be added in a sorted position
-        @param bare_jid(jid.JID): entity bare JID
-        @param profile(unicode): profile where the contact is
-        """
-        data = G.host.contact_lists[profile].get_item(bare_jid)
-        wid = CLContactItem(profile=profile, data=data, jid=bare_jid, main_wid=self)
-        child_jids = [c.jid for c in reversed(self.layout.children)]
-        idx = bisect.bisect_right(child_jids, bare_jid)
-        self.layout.add_widget(wid, -idx)
-        self._wid_map[(profile, bare_jid)] = wid
-
-    def update(self, entities=None, type_=None, profile=None):
-        log.debug("update: %s %s %s" % (entities, type_, profile))
-        if type_ == None or type_ == C.UPDATE_STRUCTURE:
-            log.debug("full contact list update")
-            self.layout.clear_widgets()
-            for bare_jid, data in self.items_sorted.items():
-                wid = CLContactItem(
-                    profile=data['profile'],
-                    data=data,
-                    jid=bare_jid,
-                    main_wid=self,
-                )
-                self.layout.add_widget(wid)
-                self._wid_map[(profile, bare_jid)] = wid
-        elif type_ == C.UPDATE_MODIFY:
-            for entity in entities:
-                entity_bare = entity.bare
-                wid = self._wid_map[(profile, entity_bare)]
-                wid.data = G.host.contact_lists[profile].get_item(entity_bare)
-        elif type_ == C.UPDATE_ADD:
-            for entity in entities:
-                self._add_contact_item(entity.bare, profile)
-        elif type_ == C.UPDATE_DELETE:
-            for entity in entities:
-                try:
-                    self.layout.remove_widget(self._wid_map.pop((profile, entity.bare)))
-                except KeyError:
-                    log.debug("entity not found: {entity}".format(entity=entity.bare))
-        else:
-            log.debug("update type not handled: {update_type}".format(update_type=type_))
--- a/cagou/plugins/plugin_wid_file_sharing.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-#:import ModernMenu kivy_garden.modernmenu.ModernMenu
-
-
-<ModeBtn>:
-    width: self.texture_size[0] + sp(20)
-    size_hint: None, 1
-
-
-<FileSharing>:
-    float_layout: float_layout
-    layout: layout
-    FloatLayout:
-        id: float_layout
-        ScrollView:
-            size_hint: 1, 1
-            pos_hint: {'x': 0, 'y': 0}
-            do_scroll_x: False
-            scroll_type: ['bars', 'content']
-            bar_width: dp(6)
-            StackLayout:
-                id: layout
-                size_hint: 1, None
-                height: self.minimum_height
-                spacing: 0
-
-
-<PathWidget>:
-    shared: False
-    Symbol:
-        size_hint: 1, None
-        height: dp(80)
-        font_size: dp(40)
-        symbol: 'folder-open-empty' if root.is_dir else 'doc'
-        color: (1, 0, 0, 1) if root.shared else (0, 0, 0, 1) if root.is_dir else app.c_prim_dark
-    Label:
-        size_hint: None, None
-        width: dp(100)
-        font_size: sp(14)
-        text_size: dp(95), None
-        size: self.texture_size
-        text: root.name
-        halign: 'center'
-
-
-<LocalPathWidget>:
-    shared: root.filepath in root.main_wid.shared_paths
--- a/cagou/plugins/plugin_wid_file_sharing.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,419 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 functools import partial
-import os.path
-import json
-from sat.core import log as logging
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.tools.common import files_utils
-from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.tools import jid
-from ..core.constants import Const as C
-from ..core import cagou_widget
-from ..core.menu import EntitiesSelectorMenu
-from ..core.behaviors import TouchMenuBehavior, FilterBehavior
-from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
-                                       CategorySeparator)
-from cagou import G
-from kivy import properties
-from kivy.uix.label import Label
-from kivy.uix.button import Button
-from kivy import utils as kivy_utils
-
-log = logging.getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    "name": _("file sharing"),
-    "main": "FileSharing",
-    "description": _("share/transfer files between devices"),
-    "icon_symbol": "exchange",
-}
-MODE_VIEW = "view"
-MODE_LOCAL = "local"
-SELECT_INSTRUCTIONS = _("Please select entities to share with")
-
-if kivy_utils.platform == "android":
-    from jnius import autoclass
-    Environment = autoclass("android.os.Environment")
-    base_dir = Environment.getExternalStorageDirectory().getAbsolutePath()
-    def expanduser(path):
-        if path == '~' or path.startswith('~/'):
-            return path.replace('~', base_dir, 1)
-        return path
-else:
-    expanduser = os.path.expanduser
-
-
-class ModeBtn(Button):
-
-    def __init__(self, parent, **kwargs):
-        super(ModeBtn, self).__init__(**kwargs)
-        parent.bind(mode=self.on_mode)
-        self.on_mode(parent, parent.mode)
-
-    def on_mode(self, parent, new_mode):
-        if new_mode == MODE_VIEW:
-            self.text = _("view shared files")
-        elif new_mode == MODE_LOCAL:
-            self.text = _("share local files")
-        else:
-            exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode))
-
-
-class PathWidget(ItemWidget):
-
-    def __init__(self, filepath, main_wid, **kw):
-        name = os.path.basename(filepath)
-        self.filepath = os.path.normpath(filepath)
-        if self.filepath == '.':
-            self.filepath = ''
-        super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw)
-
-    @property
-    def is_dir(self):
-        raise NotImplementedError
-
-    def do_item_action(self, touch):
-        if self.is_dir:
-            self.main_wid.current_dir = self.filepath
-
-    def open_menu(self, touch, dt):
-        log.debug(_("opening menu for {path}").format(path=self.filepath))
-        super(PathWidget, self).open_menu(touch, dt)
-
-
-class LocalPathWidget(PathWidget):
-
-    @property
-    def is_dir(self):
-        return os.path.isdir(self.filepath)
-
-    def get_menu_choices(self):
-        choices = []
-        if self.shared:
-            choices.append(dict(text=_('unshare'),
-                                index=len(choices)+1,
-                                callback=self.main_wid.unshare))
-        else:
-            choices.append(dict(text=_('share'),
-                                index=len(choices)+1,
-                                callback=self.main_wid.share))
-        return choices
-
-
-class RemotePathWidget(PathWidget):
-
-    def __init__(self, main_wid, filepath, type_, **kw):
-        self.type_ = type_
-        super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw)
-
-    @property
-    def is_dir(self):
-        return self.type_ == C.FILE_TYPE_DIRECTORY
-
-    def do_item_action(self, touch):
-        if self.is_dir:
-            if self.filepath == '..':
-                self.main_wid.remote_entity = ''
-            else:
-                super(RemotePathWidget, self).do_item_action(touch)
-        else:
-            self.main_wid.request_item(self)
-            return True
-
-class SharingDeviceWidget(DeviceWidget):
-
-    def do_item_action(self, touch):
-        self.main_wid.remote_entity = self.entity_jid
-        self.main_wid.remote_dir = ''
-
-
-class FileSharing(quick_widgets.QuickWidget, cagou_widget.CagouWidget, FilterBehavior,
-                  TouchMenuBehavior):
-    SINGLE=False
-    layout = properties.ObjectProperty()
-    mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL])
-    local_dir = properties.StringProperty(expanduser('~'))
-    remote_dir = properties.StringProperty('')
-    remote_entity = properties.StringProperty('')
-    shared_paths = properties.ListProperty()
-    use_header_input = True
-    signals_registered = False
-
-    def __init__(self, host, target, profiles):
-        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
-        cagou_widget.CagouWidget.__init__(self)
-        FilterBehavior.__init__(self)
-        TouchMenuBehavior.__init__(self)
-        self.mode_btn = ModeBtn(self)
-        self.mode_btn.bind(on_release=self.change_mode)
-        self.header_input_add_extra(self.mode_btn)
-        self.bind(local_dir=self.update_view,
-                  remote_dir=self.update_view,
-                  remote_entity=self.update_view)
-        self.update_view()
-        if not FileSharing.signals_registered:
-            # FIXME: we use this hack (registering the signal for the whole class) now
-            #        as there is currently no unregisterSignal available in bridges
-            G.host.register_signal("fis_shared_path_new",
-                                  handler=FileSharing.shared_path_new,
-                                  iface="plugin")
-            G.host.register_signal("fis_shared_path_removed",
-                                  handler=FileSharing.shared_path_removed,
-                                  iface="plugin")
-            FileSharing.signals_registered = True
-        G.host.bridge.fis_local_shares_get(self.profile,
-                                        callback=self.fill_paths,
-                                        errback=G.host.errback)
-
-    @property
-    def current_dir(self):
-        return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir
-
-    @current_dir.setter
-    def current_dir(self, new_dir):
-        if self.mode == MODE_LOCAL:
-            self.local_dir = new_dir
-        else:
-            self.remote_dir = new_dir
-
-    def fill_paths(self, shared_paths):
-        self.shared_paths.extend(shared_paths)
-
-    def change_mode(self, mode_btn):
-        self.clear_menu()
-        opt = self.__class__.mode.options
-        new_idx = (opt.index(self.mode)+1) % len(opt)
-        self.mode = opt[new_idx]
-
-    def on_mode(self, instance, new_mode):
-        self.update_view(None, self.local_dir)
-
-    def on_header_wid_input(self):
-        if '/' in self.header_input.text or self.header_input.text == '~':
-            self.current_dir = expanduser(self.header_input.text)
-
-    def on_header_wid_input_complete(self, wid, text, **kwargs):
-        """we filter items when text is entered in input box"""
-        if '/' in text:
-            return
-        self.do_filter(self.layout,
-                       text,
-                       lambda c: c.name,
-                       width_cb=lambda c: c.base_width,
-                       height_cb=lambda c: c.minimum_height,
-                       continue_tests=[lambda c: not isinstance(c, ItemWidget),
-                                       lambda c: c.name == '..'])
-
-
-    ## remote sharing callback ##
-
-    def _disco_find_by_features_cb(self, data):
-        entities_services, entities_own, entities_roster = data
-        for entities_map, title in ((entities_services,
-                                     _('services')),
-                                    (entities_own,
-                                     _('your devices')),
-                                    (entities_roster,
-                                     _('your contacts devices'))):
-            if entities_map:
-                self.layout.add_widget(CategorySeparator(text=title))
-                for entity_str, entity_ids in entities_map.items():
-                    entity_jid = jid.JID(entity_str)
-                    item = SharingDeviceWidget(
-                        self, entity_jid, Identities(entity_ids))
-                    self.layout.add_widget(item)
-        if not entities_services and not entities_own and not entities_roster:
-            self.layout.add_widget(Label(
-                size_hint=(1, 1),
-                halign='center',
-                text_size=self.size,
-                text=_("No sharing device found")))
-
-    def discover_devices(self):
-        """Looks for devices handling file "File Information Sharing" and display them"""
-        try:
-            namespace = self.host.ns_map['fis']
-        except KeyError:
-            msg = _("can't find file information sharing namespace, "
-                    "is the plugin running?")
-            log.warning(msg)
-            G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
-            return
-        self.host.bridge.disco_find_by_features(
-            [namespace], [], False, True, True, True, False, self.profile,
-            callback=self._disco_find_by_features_cb,
-            errback=partial(G.host.errback,
-                title=_("shared folder error"),
-                message=_("can't check sharing devices: {msg}")))
-
-    def fis_list_cb(self, files_data):
-        for file_data in files_data:
-            filepath = os.path.join(self.current_dir, file_data['name'])
-            item = RemotePathWidget(
-                filepath=filepath,
-                main_wid=self,
-                type_=file_data['type'])
-            self.layout.add_widget(item)
-
-    def fis_list_eb(self, failure_):
-        self.remote_dir = ''
-        G.host.add_note(
-            _("shared folder error"),
-            _("can't list files for {remote_entity}: {msg}").format(
-                remote_entity=self.remote_entity,
-                msg=failure_),
-            level=C.XMLUI_DATA_LVL_WARNING)
-
-    ## view generation ##
-
-    def update_view(self, *args):
-        """update items according to current mode, entity and dir"""
-        log.debug('updating {}, {}'.format(self.current_dir, args))
-        self.layout.clear_widgets()
-        self.header_input.text = ''
-        self.header_input.hint_text = self.current_dir
-
-        if self.mode == MODE_LOCAL:
-            filepath = os.path.join(self.local_dir, '..')
-            self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self))
-            try:
-                files = sorted(os.listdir(self.local_dir))
-            except OSError as e:
-                msg = _("can't list files in \"{local_dir}\": {msg}").format(
-                    local_dir=self.local_dir,
-                    msg=e)
-                G.host.add_note(
-                    _("shared folder error"),
-                    msg,
-                    level=C.XMLUI_DATA_LVL_WARNING)
-                self.local_dir = expanduser('~')
-                return
-            for f in files:
-                filepath = os.path.join(self.local_dir, f)
-                self.layout.add_widget(LocalPathWidget(filepath=filepath,
-                                                       main_wid=self))
-        elif self.mode == MODE_VIEW:
-            if not self.remote_entity:
-                self.discover_devices()
-            else:
-                # we always a way to go back
-                # so user can return to previous list even in case of error
-                parent_path = os.path.join(self.remote_dir, '..')
-                item = RemotePathWidget(
-                    filepath = parent_path,
-                    main_wid=self,
-                    type_ = C.FILE_TYPE_DIRECTORY)
-                self.layout.add_widget(item)
-                self.host.bridge.fis_list(
-                    str(self.remote_entity),
-                    self.remote_dir,
-                    {},
-                    self.profile,
-                    callback=self.fis_list_cb,
-                    errback=self.fis_list_eb)
-
-    ## Share methods ##
-
-    def do_share(self, entities_jids, item):
-        if entities_jids:
-            access = {'read': {'type': 'whitelist',
-                                'jids': entities_jids}}
-        else:
-            access = {}
-
-        G.host.bridge.fis_share_path(
-            item.name,
-            item.filepath,
-            json.dumps(access, ensure_ascii=False),
-            self.profile,
-            callback=lambda name: G.host.add_note(
-                _("sharing folder"),
-                _("{name} is now shared").format(name=name)),
-            errback=partial(G.host.errback,
-                title=_("sharing folder"),
-                message=_("can't share folder: {msg}")))
-
-    def share(self, menu):
-        item = self.menu_item
-        self.clear_menu()
-        EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS,
-                             callback=partial(self.do_share, item=item)).show()
-
-    def unshare(self, menu):
-        item = self.menu_item
-        self.clear_menu()
-        G.host.bridge.fis_unshare_path(
-            item.filepath,
-            self.profile,
-            callback=lambda: G.host.add_note(
-                _("sharing folder"),
-                _("{name} is not shared anymore").format(name=item.name)),
-            errback=partial(G.host.errback,
-                title=_("sharing folder"),
-                message=_("can't unshare folder: {msg}")))
-
-    def file_jingle_request_cb(self, progress_id, item, dest_path):
-        G.host.add_note(
-            _("file request"),
-            _("{name} download started at {dest_path}").format(
-                name = item.name,
-                dest_path = dest_path))
-
-    def request_item(self, item):
-        """Retrieve an item from remote entity
-
-        @param item(RemotePathWidget): item to retrieve
-        """
-        path, name = os.path.split(item.filepath)
-        assert name
-        assert self.remote_entity
-        extra = {'path': path}
-        dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name))
-        G.host.bridge.file_jingle_request(str(self.remote_entity),
-                                        str(dest_path),
-                                        name,
-                                        '',
-                                        '',
-                                        extra,
-                                        self.profile,
-                                        callback=partial(self.file_jingle_request_cb,
-                                            item=item,
-                                            dest_path=dest_path),
-                                        errback=partial(G.host.errback,
-                                            title = _("file request error"),
-                                            message = _("can't request file: {msg}")))
-
-    @classmethod
-    def shared_path_new(cls, shared_path, name, profile):
-        for wid in G.host.get_visible_list(cls):
-            if shared_path not in wid.shared_paths:
-                wid.shared_paths.append(shared_path)
-
-    @classmethod
-    def shared_path_removed(cls, shared_path, profile):
-        for wid in G.host.get_visible_list(cls):
-            if shared_path in wid.shared_paths:
-                wid.shared_paths.remove(shared_path)
-            else:
-                log.warning(_("shared path {path} not found in {widget}".format(
-                    path = shared_path, widget = wid)))
--- a/cagou/plugins/plugin_wid_remote.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,101 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-
-<RemoteControl>:
-    layout: layout
-    BoxLayout:
-        id: layout
-
-
-<DevicesLayout>:
-    layout: layout
-    size_hint: 1, 1
-    ScrollView:
-        size_hint: 1, 1
-        pos_hint: {'x': 0, 'y': 0}
-        do_scroll_x: False
-        scroll_type: ['bars', 'content']
-        bar_width: dp(6)
-        StackLayout:
-            id: layout
-            size_hint: 1, None
-            height: self.minimum_height
-            spacing: 0
-
-
-<RemoteItemWidget>:
-    shared: False
-    Symbol:
-        size_hint: 1, None
-        height: dp(80)
-        symbol: 'video'
-        color: 0, 0, 0, 1
-    Label:
-        size_hint: None, None
-        width: dp(100)
-        font_size: sp(14)
-        text_size: dp(95), None
-        size: self.texture_size
-        text: root.name
-        halign: 'center'
-
-
-<PlayerLabel@Label>:
-    size_hint: 1, None
-    text_size: self.width, None
-    size: self.texture_size
-    halign: 'center'
-
-
-<PlayerButton@SymbolButton>:
-    size_hint: None, 1
-
-
-<MediaPlayerControlWidget>:
-    orientation: 'vertical'
-    PlayerLabel:
-        text: root.title
-        bold: True
-        font_size: '20sp'
-    PlayerLabel:
-        text: root.identity
-        font_size: '15sp'
-    Widget:
-        size_hint: 1, None
-        height: dp(50)
-    BoxLayout:
-        size_hint: 1, None
-        spacing: dp(20)
-        height: dp(30)
-        Widget:
-        PlayerButton:
-            symbol: "previous"
-            on_release: root.do_cmd("Previous")
-        PlayerButton:
-            symbol: "fast-bw"
-            on_release: root.do_cmd("GoBack")
-        PlayerButton:
-            symbol: root.status
-            on_release: root.do_cmd("PlayPause")
-        PlayerButton
-            symbol: "fast-fw"
-            on_release: root.do_cmd("GoFW")
-        PlayerButton
-            symbol: "next"
-            on_release: root.do_cmd("Next")
-        Widget:
-    Widget:
--- a/cagou/plugins/plugin_wid_remote.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,290 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-from sat.core.i18n import _
-from sat_frontends.quick_frontend import quick_widgets
-from ..core import cagou_widget
-from ..core.constants import Const as C
-from ..core.behaviors import TouchMenuBehavior, FilterBehavior
-from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
-                                       CategorySeparator)
-from sat.tools.common import template_xmlui
-from sat.tools.common import data_format
-from cagou.core import xmlui
-from sat_frontends.tools import jid
-from kivy import properties
-from kivy.uix.label import Label
-from kivy.uix.boxlayout import BoxLayout
-from kivy.uix.floatlayout import FloatLayout
-from cagou import G
-from functools import partial
-
-
-log = logging.getLogger(__name__)
-
-PLUGIN_INFO = {
-    "name": _("remote control"),
-    "main": "RemoteControl",
-    "description": _("universal remote control"),
-    "icon_symbol": "signal",
-}
-
-NOTE_TITLE = _("Media Player Remote Control")
-
-
-class RemoteItemWidget(ItemWidget):
-
-    def __init__(self, device_jid, node, name, main_wid, **kw):
-        self.device_jid = device_jid
-        self.node = node
-        super(RemoteItemWidget, self).__init__(name=name, main_wid=main_wid, **kw)
-
-    def do_item_action(self, touch):
-        self.main_wid.layout.clear_widgets()
-        player_wid = MediaPlayerControlWidget(main_wid=self.main_wid, remote_item=self)
-        self.main_wid.layout.add_widget(player_wid)
-
-
-class MediaPlayerControlWidget(BoxLayout):
-    main_wid = properties.ObjectProperty()
-    remote_item = properties.ObjectProperty()
-    status = properties.OptionProperty("play", options=("play", "pause", "stop"))
-    title = properties.StringProperty()
-    identity = properties.StringProperty()
-    command = properties.DictProperty()
-    ui_tpl = properties.ObjectProperty()
-
-    @property
-    def profile(self):
-        return self.main_wid.profile
-
-    def update_ui(self, action_data_s):
-        action_data = data_format.deserialise(action_data_s)
-        xmlui_raw = action_data['xmlui']
-        ui_tpl = template_xmlui.create(G.host, xmlui_raw)
-        self.ui_tpl = ui_tpl
-        for prop in ('Title', 'Identity'):
-            try:
-                setattr(self, prop.lower(), ui_tpl.widgets[prop].value)
-            except KeyError:
-                log.warning(_("Missing field: {name}").format(name=prop))
-        playback_status = self.ui_tpl.widgets['PlaybackStatus'].value
-        if playback_status == "Playing":
-            self.status = "pause"
-        elif playback_status == "Paused":
-            self.status = "play"
-        elif playback_status == "Stopped":
-            self.status = "play"
-        else:
-            G.host.add_note(
-                title=NOTE_TITLE,
-                message=_("Unknown playback status: playback_status")
-                          .format(playback_status=playback_status),
-                level=C.XMLUI_DATA_LVL_WARNING)
-        self.commands = {v:k for k,v in ui_tpl.widgets['command'].options}
-
-    def ad_hoc_run_cb(self, xmlui_raw):
-        ui_tpl = template_xmlui.create(G.host, xmlui_raw)
-        data = {xmlui.XMLUIPanel.escape("media_player"): self.remote_item.node,
-                "session_id": ui_tpl.session_id}
-        G.host.bridge.action_launch(
-            ui_tpl.submit_id, data_format.serialise(data),
-            self.profile, callback=self.update_ui,
-            errback=self.main_wid.errback
-        )
-
-    def on_remote_item(self, __, remote):
-        NS_MEDIA_PLAYER = G.host.ns_map["mediaplayer"]
-        G.host.bridge.ad_hoc_run(str(remote.device_jid), NS_MEDIA_PLAYER, self.profile,
-                               callback=self.ad_hoc_run_cb,
-                               errback=self.main_wid.errback)
-
-    def do_cmd(self, command):
-        try:
-            cmd_value = self.commands[command]
-        except KeyError:
-            G.host.add_note(
-                title=NOTE_TITLE,
-                message=_("{command} command is not managed").format(command=command),
-                level=C.XMLUI_DATA_LVL_WARNING)
-        else:
-            data = {xmlui.XMLUIPanel.escape("command"): cmd_value,
-                    "session_id": self.ui_tpl.session_id}
-            # hidden values are normally transparently managed by XMLUIPanel
-            # but here we have to add them by hand
-            hidden = {xmlui.XMLUIPanel.escape(k):v
-                      for k,v in self.ui_tpl.hidden.items()}
-            data.update(hidden)
-            G.host.bridge.action_launch(
-                self.ui_tpl.submit_id, data_format.serialise(data), self.profile,
-                callback=self.update_ui, errback=self.main_wid.errback
-            )
-
-
-class RemoteDeviceWidget(DeviceWidget):
-
-    def xmlui_cb(self, data, cb_id, profile):
-        if 'xmlui' in data:
-            xml_ui = xmlui.create(
-                G.host, data['xmlui'], callback=self.xmlui_cb, profile=profile)
-            if isinstance(xml_ui, xmlui.XMLUIDialog):
-                self.main_wid.show_root_widget()
-                xml_ui.show()
-            else:
-                xml_ui.set_close_cb(self.on_close)
-                self.main_wid.layout.add_widget(xml_ui)
-        else:
-            if data:
-                log.warning(_("Unhandled data: {data}").format(data=data))
-            self.main_wid.show_root_widget()
-
-    def on_close(self, __, reason):
-        if reason == C.XMLUI_DATA_CANCELLED:
-            self.main_wid.show_root_widget()
-        else:
-            self.main_wid.layout.clear_widgets()
-
-    def ad_hoc_run_cb(self, data):
-        xml_ui = xmlui.create(G.host, data, callback=self.xmlui_cb, profile=self.profile)
-        xml_ui.set_close_cb(self.on_close)
-        self.main_wid.layout.add_widget(xml_ui)
-
-    def do_item_action(self, touch):
-        self.main_wid.layout.clear_widgets()
-        G.host.bridge.ad_hoc_run(str(self.entity_jid), '', self.profile,
-            callback=self.ad_hoc_run_cb, errback=self.main_wid.errback)
-
-
-class DevicesLayout(FloatLayout):
-    """Layout used to show devices"""
-    layout = properties.ObjectProperty()
-
-
-class RemoteControl(quick_widgets.QuickWidget, cagou_widget.CagouWidget, FilterBehavior,
-                  TouchMenuBehavior):
-    SINGLE=False
-    layout = properties.ObjectProperty()
-
-    def __init__(self, host, target, profiles):
-        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
-        cagou_widget.CagouWidget.__init__(self)
-        FilterBehavior.__init__(self)
-        TouchMenuBehavior.__init__(self)
-        self.stack_layout = None
-        self.show_root_widget()
-
-    def errback(self, failure_):
-        """Generic errback which add a warning note and go back to root widget"""
-        G.host.add_note(
-            title=NOTE_TITLE,
-            message=_("Can't use remote control: {reason}").format(reason=failure_),
-            level=C.XMLUI_DATA_LVL_WARNING)
-        self.show_root_widget()
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        if key == 27:
-            self.show_root_widget()
-            return True
-
-    def show_root_widget(self):
-        self.layout.clear_widgets()
-        devices_layout = DevicesLayout()
-        self.stack_layout = devices_layout.layout
-        self.layout.add_widget(devices_layout)
-        found = []
-        self.get_remotes(found)
-        self.discover_devices(found)
-
-    def ad_hoc_remotes_get_cb(self, remotes_data, found):
-        found.insert(0, remotes_data)
-        if len(found) == 2:
-            self.show_devices(found)
-
-    def ad_hoc_remotes_get_eb(self, failure_, found):
-        G.host.errback(failure_, title=_("discovery error"),
-                       message=_("can't check remote controllers: {msg}"))
-        found.insert(0, [])
-        if len(found) == 2:
-            self.show_devices(found)
-
-    def get_remotes(self, found):
-        self.host.bridge.ad_hoc_remotes_get(
-            self.profile,
-            callback=partial(self.ad_hoc_remotes_get_cb, found=found),
-            errback=partial(self.ad_hoc_remotes_get_eb,found=found))
-
-    def _disco_find_by_features_cb(self, data, found):
-        found.append(data)
-        if len(found) == 2:
-            self.show_devices(found)
-
-    def _disco_find_by_features_eb(self, failure_, found):
-        G.host.errback(failure_, title=_("discovery error"),
-                       message=_("can't check devices: {msg}"))
-        found.append(({}, {}, {}))
-        if len(found) == 2:
-            self.show_devices(found)
-
-    def discover_devices(self, found):
-        """Looks for devices handling file "File Information Sharing" and display them"""
-        try:
-            namespace = self.host.ns_map['commands']
-        except KeyError:
-            msg = _("can't find ad-hoc commands namespace, is the plugin running?")
-            log.warning(msg)
-            G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
-            return
-        self.host.bridge.disco_find_by_features(
-            [namespace], [], False, True, True, True, False, self.profile,
-            callback=partial(self._disco_find_by_features_cb, found=found),
-            errback=partial(self._disco_find_by_features_eb, found=found))
-
-    def show_devices(self, found):
-        remotes_data, (entities_services, entities_own, entities_roster) = found
-        if remotes_data:
-            title = _("media players remote controls")
-            self.stack_layout.add_widget(CategorySeparator(text=title))
-
-        for remote_data in remotes_data:
-            device_jid, node, name = remote_data
-            wid = RemoteItemWidget(device_jid, node, name, self)
-            self.stack_layout.add_widget(wid)
-
-        for entities_map, title in ((entities_services,
-                                     _('services')),
-                                    (entities_own,
-                                     _('your devices')),
-                                    (entities_roster,
-                                     _('your contacts devices'))):
-            if entities_map:
-                self.stack_layout.add_widget(CategorySeparator(text=title))
-                for entity_str, entity_ids in entities_map.items():
-                    entity_jid = jid.JID(entity_str)
-                    item = RemoteDeviceWidget(
-                        self, entity_jid, Identities(entity_ids))
-                    self.stack_layout.add_widget(item)
-        if (not remotes_data and not entities_services and not entities_own
-            and not entities_roster):
-            self.stack_layout.add_widget(Label(
-                size_hint=(1, 1),
-                halign='center',
-                text_size=self.size,
-                text=_("No sharing device found")))
--- a/cagou/plugins/plugin_wid_settings.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
--- a/cagou/plugins/plugin_wid_settings.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,74 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.tools.common import data_format
-from sat_frontends.quick_frontend import quick_widgets
-from kivy.uix.label import Label
-from kivy.uix.widget import Widget
-from cagou.core import cagou_widget
-from cagou import G
-
-
-log = logging.getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    "name": _("settings"),
-    "main": "CagouSettings",
-    "description": _("Cagou/SàT settings"),
-    "icon_symbol": "wrench",
-}
-
-
-class CagouSettings(quick_widgets.QuickWidget, cagou_widget.CagouWidget):
-    # XXX: this class can't be called "Settings", because Kivy has already a class
-    #      of this name, and the kv there would apply
-
-    def __init__(self, host, target, profiles):
-        quick_widgets.QuickWidget.__init__(self, G.host, target, profiles)
-        cagou_widget.CagouWidget.__init__(self)
-        # the Widget() avoid CagouWidget header to be down at the beginning
-        # then up when the UI is loaded
-        self.loading_widget = Widget()
-        self.add_widget(self.loading_widget)
-        extra = {}
-        G.local_platform.update_params_extra(extra)
-        G.host.bridge.param_ui_get(
-            -1, C.APP_NAME, data_format.serialise(extra), self.profile,
-            callback=self.get_params_ui_cb,
-            errback=self.get_params_ui_eb)
-
-    def change_widget(self, widget):
-        self.clear_widgets([self.loading_widget])
-        del self.loading_widget
-        self.add_widget(widget)
-
-    def get_params_ui_cb(self, xmlui):
-        G.host.action_manager({"xmlui": xmlui}, ui_show_cb=self.change_widget, profile=self.profile)
-
-    def get_params_ui_eb(self, failure):
-        self.change_widget(Label(
-            text=_("Can't load parameters!"),
-            bold=True,
-            color=(1,0,0,1)))
-        G.host.show_dialog("Can't load params UI", str(failure), "error")
--- a/cagou/plugins/plugin_wid_widget_selector.kv	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,48 +0,0 @@
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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/>.
-
-<WidgetSelItem>:
-    size_hint: (1, None)
-    height: dp(40)
-    item: item
-    Widget:
-    BoxLayout:
-        id: item
-        size_hint: None, 1
-        spacing: dp(10)
-        ActionIcon:
-            plugin_info: root.plugin_info
-            size_hint: None, 1
-            width: self.height
-        Label:
-            text: root.plugin_info["name"]
-            bold: True
-            valign: 'middle'
-            font_size: sp(20)
-            size_hint: None, 1
-            width: self.texture_size[0]
-    Widget:
-
-
-<WidgetSelector>:
-    spacing: dp(10)
-    container: container
-    ScrollView:
-        BoxLayout:
-            orientation: "vertical"
-            size_hint: 1, None
-            height: self.minimum_height
-            id: container
--- a/cagou/plugins/plugin_wid_widget_selector.py	Fri Jun 02 17:53:09 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,80 +0,0 @@
-#!/usr/bin/env python3
-
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
-# Copyright (C) 2016-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 sat.core import log as logging
-log = logging.getLogger(__name__)
-from sat.core.i18n import _
-from cagou.core.constants import Const as C
-from kivy.uix.widget import Widget
-from kivy.uix.boxlayout import BoxLayout
-from kivy import properties
-from kivy.uix.behaviors import ButtonBehavior
-from cagou.core import cagou_widget
-from cagou import G
-
-
-PLUGIN_INFO = {
-    "name": _("widget selector"),
-    "import_name": C.WID_SELECTOR,
-    "main": "WidgetSelector",
-    "description": _("show available widgets and allow to select one"),
-    "icon_medium": "{media}/icons/muchoslava/png/selector_no_border_blue_44.png"
-}
-
-
-class WidgetSelItem(ButtonBehavior, BoxLayout):
-    plugin_info = properties.DictProperty()
-    item = properties.ObjectProperty()
-
-    def on_release(self, *args):
-        log.debug("widget selection: {}".format(self.plugin_info["name"]))
-        factory = self.plugin_info["factory"]
-        G.host.switch_widget(
-            self, factory(self.plugin_info, None, profiles=iter(G.host.profiles)))
-
-
-class WidgetSelector(cagou_widget.CagouWidget):
-    container = properties.ObjectProperty()
-
-    def __init__(self):
-        super(WidgetSelector, self).__init__()
-        self.items = []
-        for plugin_info in G.host.get_plugged_widgets(except_cls=self.__class__):
-            item = WidgetSelItem(plugin_info=plugin_info)
-            self.items.append(item.item)
-            item.item.bind(minimum_width=self.adjust_width)
-            self.container.add_widget(item)
-        self.container.add_widget(Widget())
-
-    def adjust_width(self, label, texture_size):
-        width = max([i.minimum_width for i in self.items])
-        for i in self.items:
-            i.width = width
-
-    def key_input(self, window, key, scancode, codepoint, modifier):
-        # we pass to avoid default CagouWidget which is going back to default widget
-        # (which is this one)
-        pass
-
-    @classmethod
-    def factory(cls, plugin_info, target, profiles):
-        return cls()
-
-
-PLUGIN_INFO["factory"] = WidgetSelector.factory
--- a/doc/conf.py	Fri Jun 02 17:53:09 2023 +0200
+++ b/doc/conf.py	Fri Jun 02 18:26:16 2023 +0200
@@ -19,8 +19,8 @@
 
 # -- Project information -----------------------------------------------------
 
-project = u'Cagou (Salut à Toi)'
-copyright = u'2019-2021 Jérôme Poisson'
+project = u'Libervia Desktop-Kivy'
+copyright = u'2019-2023 Jérôme Poisson'
 author = u'Jérôme Poisson'
 
 # The short X.Y version
@@ -101,7 +101,7 @@
 # -- Options for HTMLHelp output ---------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'CagouSalutToidoc'
+htmlhelp_basename = 'LiberviaDesktopKivySalutToidoc'
 
 
 # -- Options for LaTeX output ------------------------------------------------
@@ -128,7 +128,7 @@
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'CagouSalutToi.tex', u'Cagou (Salut à Toi) Documentation',
+    (master_doc, 'LiberviaDesktopKivySalutToi.tex', u'LiberviaDesktopKivy (Salut à Toi) Documentation',
      u'Jérôme Poisson', 'manual'),
 ]
 
@@ -138,7 +138,7 @@
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [
-    (master_doc, 'cagousaluttoi', u'Cagou (Salut à Toi) Documentation',
+    (master_doc, 'cagousaluttoi', u'LiberviaDesktopKivy (Salut à Toi) Documentation',
      [author], 1)
 ]
 
@@ -149,8 +149,8 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'CagouSalutToi', u'Cagou (Salut à Toi) Documentation',
-     author, 'CagouSalutToi', 'One line description of project.',
+    (master_doc, 'LiberviaDesktopKivySalutToi', u'LiberviaDesktopKivy (Salut à Toi) Documentation',
+     author, 'LiberviaDesktopKivySalutToi', 'One line description of project.',
      'Miscellaneous'),
 ]
 
--- a/doc/index.rst	Fri Jun 02 17:53:09 2023 +0200
+++ b/doc/index.rst	Fri Jun 02 18:26:16 2023 +0200
@@ -1,16 +1,16 @@
-.. Cagou (Salut à Toi) documentation master file, created by
+.. Libervia Desktop-Kivy documentation master file, created by
    sphinx-quickstart on Tue Jul 23 20:07:36 2019.
    You can adapt this file completely to your liking, but it should at least
    contain the root `toctree` directive.
 
-Libervia Desktop/Mobile documentation
+Libervia Desktop/Mobile (Kivy) documentation
 =====================================
 
-Welcome to Cagou's documentation. You'll find here both end-user end developer documentations.
+Welcome to Libervia Desktop-Kivy's documentation. You'll find here both end-user end developer documentations.
 
-Cagou is the desktop/mobile frontend of Salut à Toi (or SàT), a Libre communication ecosystem based on XMPP standard.
+Libervia Desktop-Kivy is the desktop/mobile frontend for Libervia, a Libre communication ecosystem based on XMPP standard.
 
-You can follow this documentation to learn more on it, or join our official XMPP room at `sat@chat.jabberfr.org <xmpp:sat@chat.jabberfr.org?join>`_ (also available via a `web link <https://chat.jabberfr.org/converse.js/sat@chat.jabberfr.org>`_)
+You can follow this documentation to learn more on it, or join our official XMPP room at `libervia@chat.jabberfr.org <xmpp:libervia@chat.jabberfr.org?join>`_ (also available via a `web link <https://chat.jabberfr.org/converse.js/libervia@chat.jabberfr.org>`_)
 
 
 .. toctree::
--- a/doc/installation.rst	Fri Jun 02 17:53:09 2023 +0200
+++ b/doc/installation.rst	Fri Jun 02 18:26:16 2023 +0200
@@ -2,73 +2,50 @@
 Installation
 ============
 
-This are the instructions to install Cagou (SàT) using Python.
-Note that if you are using GNU/Linux, Cagou may already be present on your distribution.
-
-Cagou is a Salut à Toi frontend, the SàT backend must be installed first (if you
-haven't installed it yet, it will be downloaded automatically as it is a dependency of
-Cagou). Cagou and SàT backend must always have the same version.
+This are the instructions to install Libervia Desktop-Kivy using Python.
+Note that if you are using GNU/Linux, Libervia Desktop-Kivy may already be present on your distribution.
 
-We recommend to use development version for now, until the release of 0.7 version which
-will be "general public" version.
+Libervia Desktop-Kivy is a frontend, the Libervia backend must be installed first (if you
+haven't installed it yet, it will be downloaded automatically as it is a dependency of
+Libervia Desktop-Kivy). Libervia Desktop-Kivy and Libervia Backend must always have the
+same version.
 
-Also note that Cagou as all SàT ecosystem is still using Python 2 (this will change for
-0.8 version which will be Python 3 only), so all instructions below have to be made using
-python 2.
+We recommend to use development version for now.
 
 Development Version
 -------------------
 
-*Note for Arch users: a pkgbuild is available for your distribution on
-AUR, check sat-cagou-hg (as well as other sat-\* packages).*
-
-You can install the latest development version using pip. Please check backend documentation
-to see the system dependencies needed.
-
-You can use the same virtual environment as the one used for installing the backend. If
-you haven't installed it yet, just select a location when you want to install it, for
-instance your home directory::
-
-  $ cd
+The simplest way to install Libervia Desktop-Kivy at the moment is with `pipx`_::
 
-And enter the following commands (note that *virtualenv2* may be named
-*virtualenv* on some distributions, just be sure it's Python **2** version)::
-
-  $ virtualenv2 env
-  $ source env/bin/activate
-  $ pip install hg+https://repos.goffi.org/cagou
-
-If you haven't done it for the backend, you need to install the media::
-
-  $ cd
-  $ hg clone https://repos.goffi.org/sat_media
+  $ pipx install --system-site-packages hg+https://repos.goffi.org/libervia-desktop#egg=libervia-desktop
 
 Usage
 =====
 
-To launch Cagou enter::
+To launch Libervia Desktop-Kivy enter::
 
-  $ cagou
+  $ libervia-desktop_kivy
 
 If you want to connect directly a profile::
 
-  $ cagou -p profile_name
+  $ libervia-desktop_kivy -p profile_name
 
 Once started, you can use ``F11`` to switch fullscreen mode.
 
 You can show/hide the menu with ``ALT + M`` and show/hide the notification bar with ``ALT + N``.
 
-In Cagou, notifications appear on the top of the screen, in the *notification bar*. They
-appear for a few seconds, but you can click on the left Cagou icon to see them entirely
-and take your time to read them.
+In Libervia Desktop-Kivy, notifications appear on the top of the screen, in the
+*notification bar*. They appear for a few seconds, but you can click on the left Libervia
+Desktop-Kivy icon to see them entirely and take your time to read them.
 
-There is no focus stealing pop-up in Cagou, when some event requires a user action, a Cagou
-icon will appear on the right of notification bar, so user can click and interact with it
-when it is suitable.
+There is no focus stealing pop-up in Libervia Desktop-Kivy, when some event requires a
+user action, a Libervia Desktop-Kivy icon will appear on the right of notification bar, so
+user can click and interact with it when it is suitable.
 
-Cagou has a concept of **activities**. An activity is some kind of communication tool
-(chat, file sharing, remote control, etc.). On top left of each activity you have an icon
-representing the activity selected. Click on it to select something else.
+Libervia Desktop-Kivy has a concept of **activities**. An activity is some kind of
+communication tool (chat, file sharing, remote control, etc.). On top left of each
+activity you have an icon representing the activity selected. Click on it to select
+something else.
 
 You may have noticed the 3 small dots on top and left border of each activity. You can
 click (or touch) them, and drag to the bottom or right to create a new activity. This way
@@ -76,3 +53,5 @@
 file sharing and the chat at the same time). To close this extra activity, click again on
 the 3 dots and drag in the opposite direction until the top or left line become red, then
 release your mouse.
+
+.. _pipx: https://pypa.github.io/pipx/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/VERSION	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,1 @@
+0.9.0D
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/__init__.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+
+# Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+import os.path
+
+
+version_file = os.path.join(os.path.dirname(__file__), 'VERSION')
+with open(version_file) as f:
+    __version__ = f.read().strip()
+
+class Global(object):
+    @property
+    def host(self):
+        return self._host
+G = Global()
+
+# this import must be done after G is created
+from .core import cagou_main
+
+def run():
+    host = G._host = cagou_main.LiberviaDesktopKivy()
+    G.local_platform = cagou_main.local_platform
+    host.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/behaviors.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 kivy import properties
+from kivy.animation import Animation
+from kivy.clock import Clock
+from kivy_garden import modernmenu
+from functools import partial
+
+
+class TouchMenu(modernmenu.ModernMenu):
+    pass
+
+
+class TouchMenuItemBehavior:
+    """Class to use on every item where a menu may appear
+
+    main_wid attribute must be set to the class inheriting from TouchMenuBehavior
+    do_item_action is the method called on simple click
+    get_menu_choices must return a list of menus for long press
+        menus there are dict as expected by ModernMenu
+        (translated text, index and callback)
+    """
+    main_wid = properties.ObjectProperty()
+    click_timeout = properties.NumericProperty(0.4)
+
+    def on_touch_down(self, touch):
+        if not self.collide_point(*touch.pos):
+            return
+        t = partial(self.open_menu, touch)
+        touch.ud['menu_timeout'] = t
+        Clock.schedule_once(t, self.click_timeout)
+        return super(TouchMenuItemBehavior, self).on_touch_down(touch)
+
+    def do_item_action(self, touch):
+        pass
+
+    def on_touch_up(self, touch):
+        if touch.ud.get('menu_timeout'):
+            Clock.unschedule(touch.ud['menu_timeout'])
+            if self.collide_point(*touch.pos) and self.main_wid.menu is None:
+                self.do_item_action(touch)
+        return super(TouchMenuItemBehavior, self).on_touch_up(touch)
+
+    def open_menu(self, touch, dt):
+        self.main_wid.open_menu(self, touch)
+        del touch.ud['menu_timeout']
+
+    def get_menu_choices(self):
+        """return choice adapted to selected item
+
+        @return (list[dict]): choices ad expected by ModernMenu
+        """
+        return []
+
+
+class TouchMenuBehavior:
+    """Class to handle a menu appearing on long press on items
+
+    classes using this behaviour need to have a float_layout property
+    pointing the main FloatLayout.
+    """
+    float_layout = properties.ObjectProperty()
+
+    def __init__(self, *args, **kwargs):
+        super(TouchMenuBehavior, self).__init__(*args, **kwargs)
+        self.menu = None
+        self.menu_item = None
+
+    ## menu methods ##
+
+    def clean_fl_children(self, layout, children):
+        """insure that self.menu and self.menu_item are None when menu is dimissed"""
+        if self.menu is not None and self.menu not in children:
+            self.menu = self.menu_item = None
+
+    def clear_menu(self):
+        """remove menu if there is one"""
+        if self.menu is not None:
+            self.menu.dismiss()
+            self.menu = None
+            self.menu_item = None
+
+    def open_menu(self, item, touch):
+        """open menu for item
+
+        @param item(PathWidget): item when the menu has been requested
+        @param touch(kivy.input.MotionEvent): touch data
+        """
+        if self.menu_item == item:
+            return
+        self.clear_menu()
+        pos = self.to_widget(*touch.pos)
+        choices = item.get_menu_choices()
+        if not choices:
+            return
+        self.menu = TouchMenu(choices=choices,
+                              center=pos,
+                              size_hint=(None, None))
+        self.float_layout.add_widget(self.menu)
+        self.menu.start_display(touch)
+        self.menu_item = item
+
+    def on_float_layout(self, wid, float_layout):
+        float_layout.bind(children=self.clean_fl_children)
+
+
+class FilterBehavior(object):
+    """class to handle items filtering with animation"""
+
+    def __init__(self, *args, **kwargs):
+        super(FilterBehavior, self).__init__(*args, **kwargs)
+        self._filter_last = {}
+        self._filter_anim = Animation(width = 0,
+                                      height = 0,
+                                      opacity = 0,
+                                      d = 0.5)
+
+    def do_filter(self, parent, text, get_child_text, width_cb, height_cb,
+                  continue_tests=None):
+        """filter the children
+
+        filtered children will have a animation to set width, height and opacity to 0
+        @param parent(kivy.uix.widget.Widget): parent layout of the widgets to filter
+        @param text(unicode): filter text (if this text is not present in a child,
+            the child is filtered out)
+        @param get_child_text(callable): must retrieve child text
+            child is used as sole argument
+        @param width_cb(callable, int, None): method to retrieve width when opened
+            child is used as sole argument, int can be used instead of callable
+        @param height_cb(callable, int, None): method to retrieve height when opened
+            child is used as sole argument, int can be used instead of callable
+        @param continue_tests(list[callable]): list of test to skip the item
+            all callables take child as sole argument.
+            if any of the callable return True, the child is skipped (i.e. not filtered)
+        """
+        text = text.strip().lower()
+        filtering = len(text)>len(self._filter_last.get(parent, ''))
+        self._filter_last[parent] = text
+        for child in parent.children:
+            if continue_tests is not None and any((t(child) for t in continue_tests)):
+                continue
+            if text in get_child_text(child).lower():
+                self._filter_anim.cancel(child)
+                for key, method in (('width', width_cb),
+                                    ('height', height_cb),
+                                    ('opacity', lambda c: 1)):
+                    try:
+                        setattr(child, key, method(child))
+                    except TypeError:
+                        # method is not a callable, must be an int
+                        setattr(child, key, method)
+            elif (filtering
+                  and child.opacity > 0
+                  and not self._filter_anim.have_properties_to_animate(child)):
+                self._filter_anim.start(child)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/cagou_main.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,1176 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+import os.path
+import glob
+import sys
+from pathlib import Path
+from urllib import parse as urlparse
+from functools import partial
+from libervia.backend.core.i18n import _
+from . import kivy_hack
+kivy_hack.do_hack()
+from .constants import Const as C
+from libervia.backend.core import log as logging
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend.quick_app import QuickApp
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend import quick_chat
+from libervia.frontends.quick_frontend import quick_utils
+from libervia.frontends.tools import jid
+from libervia.backend.tools import utils as libervia_utils
+from libervia.backend.tools import config
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import files_utils
+import kivy
+kivy.require('1.11.0')
+import kivy.support
+main_config = config.parse_main_conf(log_filenames=True)
+bridge_name = config.config_get(main_config, '', 'bridge', 'dbus')
+# FIXME: event loop is choosen according to bridge_name, a better way should be used
+if 'dbus' in bridge_name:
+    kivy.support.install_gobject_iteration()
+elif bridge_name in ('pb', 'embedded'):
+    kivy.support.install_twisted_reactor()
+from kivy.app import App
+from kivy.lang import Builder
+from kivy import properties
+from . import xmlui
+from .profile_manager import ProfileManager
+from kivy.clock import Clock
+from kivy.uix.label import Label
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.floatlayout import FloatLayout
+from kivy.uix.screenmanager import (ScreenManager, Screen,
+                                    FallOutTransition, RiseInTransition)
+from kivy.uix.dropdown import DropDown
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.core.window import Window
+from kivy.animation import Animation
+from kivy.metrics import dp
+from .cagou_widget import LiberviaDesktopKivyWidget
+from .share_widget import ShareWidget
+from . import widgets_handler
+from .common import IconButton
+from . import dialog
+from importlib import import_module
+import libervia.backend
+import libervia.desktop_kivy
+import libervia.desktop_kivy.plugins
+import libervia.desktop_kivy.kv
+
+
+log = logging.getLogger(__name__)
+
+
+try:
+    from plyer import notification
+except ImportError:
+    notification = None
+    log.warning(_("Can't import plyer, some features disabled"))
+
+
+## platform specific settings ##
+
+from . import platform_
+local_platform = platform_.create()
+local_platform.init_platform()
+
+
+## General Configuration ##
+
+# we want white background by default
+Window.clearcolor = (1, 1, 1, 1)
+
+
+class NotifsIcon(IconButton):
+    notifs = properties.ListProperty()
+
+    def on_release(self):
+        callback, args, kwargs = self.notifs.pop(0)
+        callback(*args, **kwargs)
+
+    def add_notif(self, callback, *args, **kwargs):
+        self.notifs.append((callback, args, kwargs))
+
+
+class Note(Label):
+    title = properties.StringProperty()
+    message = properties.StringProperty()
+    level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT,
+                                      options=list(C.XMLUI_DATA_LVLS))
+    symbol = properties.StringProperty()
+    action = properties.ObjectProperty()
+
+
+class NoteDrop(ButtonBehavior, BoxLayout):
+    title = properties.StringProperty()
+    message = properties.StringProperty()
+    level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT,
+                                      options=list(C.XMLUI_DATA_LVLS))
+    symbol = properties.StringProperty()
+    action = properties.ObjectProperty()
+
+    def on_press(self):
+        if self.action is not None:
+            self.parent.parent.select(self.action)
+
+
+class NotesDrop(DropDown):
+    clear_btn = properties.ObjectProperty()
+
+    def __init__(self, notes):
+        super(NotesDrop, self).__init__()
+        self.notes = notes
+
+    def open(self, widget):
+        self.clear_widgets()
+        for n in self.notes:
+            kwargs = {
+                'title': n.title,
+                'message': n.message,
+                'level': n.level
+            }
+            if n.symbol is not None:
+                kwargs['symbol'] = n.symbol
+            if n.action is not None:
+                kwargs['action'] = n.action
+            self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level,
+                                     symbol=n.symbol, action=n.action))
+        self.add_widget(self.clear_btn)
+        super(NotesDrop, self).open(widget)
+
+    def on_select(self, action_kwargs):
+        app = App.get_running_app()
+        app.host.do_action(**action_kwargs)
+
+
+class RootHeadWidget(BoxLayout):
+    """Notifications widget"""
+    manager = properties.ObjectProperty()
+    notifs_icon = properties.ObjectProperty()
+    notes = properties.ListProperty()
+    HEIGHT = dp(35)
+
+    def __init__(self):
+        super(RootHeadWidget, self).__init__()
+        self.notes_last = None
+        self.notes_event = None
+        self.notes_drop = NotesDrop(self.notes)
+
+    def add_notif(self, callback, *args, **kwargs):
+        """add a notification with a callback attached
+
+        when notification is pressed, callback is called
+        @param *args, **kwargs: arguments of callback
+        """
+        self.notifs_icon.add_notif(callback, *args, **kwargs)
+
+    def add_note(self, title, message, level, symbol, action):
+        kwargs = {
+            'title': title,
+            'message': message,
+            'level': level
+        }
+        if symbol is not None:
+            kwargs['symbol'] = symbol
+        if action is not None:
+            kwargs['action'] = action
+        note = Note(**kwargs)
+        self.notes.append(note)
+        if self.notes_event is None:
+            self.notes_event = Clock.schedule_interval(self._display_next_note, 5)
+            self._display_next_note()
+
+    def add_notif_ui(self, ui):
+        self.notifs_icon.add_notif(ui.show, force=True)
+
+    def add_notif_widget(self, widget):
+        app = App.get_running_app()
+        self.notifs_icon.add_notif(app.host.show_extra_ui, widget=widget)
+
+    def _display_next_note(self, __=None):
+        screen = Screen()
+        try:
+            idx = self.notes.index(self.notes_last) + 1
+        except ValueError:
+            idx = 0
+        try:
+            note = self.notes_last = self.notes[idx]
+        except IndexError:
+            self.notes_event.cancel()
+            self.notes_event = None
+        else:
+            screen.add_widget(note)
+        self.manager.switch_to(screen)
+
+
+class RootBody(BoxLayout):
+    pass
+
+
+class LiberviaDesktopKivyRootWidget(FloatLayout):
+    root_body = properties.ObjectProperty
+
+    def __init__(self, main_widget):
+        super(LiberviaDesktopKivyRootWidget, self).__init__()
+        # header
+        self.head_widget = RootHeadWidget()
+        self.root_body.add_widget(self.head_widget)
+        # body
+        self._manager = ScreenManager()
+        # main widgets
+        main_screen = Screen(name='main')
+        main_screen.add_widget(main_widget)
+        self._manager.add_widget(main_screen)
+        # backend XMLUI (popups, forms, etc)
+        xmlui_screen = Screen(name='xmlui')
+        self._manager.add_widget(xmlui_screen)
+        # extra (file chooser, audio record, etc)
+        extra_screen = Screen(name='extra')
+        self._manager.add_widget(extra_screen)
+        self.root_body.add_widget(self._manager)
+
+    def change_widget(self, widget, screen_name="main"):
+        """change main widget"""
+        if self._manager.transition.is_active:
+            # FIXME: workaround for what seems a Kivy bug
+            # TODO: report this upstream
+            self._manager.transition.stop()
+        screen = self._manager.get_screen(screen_name)
+        screen.clear_widgets()
+        screen.add_widget(widget)
+
+    def show(self, screen="main"):
+        if self._manager.transition.is_active:
+            # FIXME: workaround for what seems a Kivy bug
+            # TODO: report this upstream
+            self._manager.transition.stop()
+        if self._manager.current == screen:
+            return
+        if screen == "main":
+            self._manager.transition = FallOutTransition()
+        else:
+            self._manager.transition = RiseInTransition()
+        self._manager.current = screen
+
+    def new_action(self, handler, action_data, id_, security_limit, profile):
+        """Add a notification for an action"""
+        self.head_widget.add_notif(handler, action_data, id_, security_limit, profile)
+
+    def add_note(self, title, message, level, symbol, action):
+        self.head_widget.add_note(title, message, level, symbol, action)
+
+    def add_notif_ui(self, ui):
+        self.head_widget.add_notif_ui(ui)
+
+    def add_notif_widget(self, widget):
+        self.head_widget.add_notif_widget(widget)
+
+
+class LiberviaDesktopKivyApp(App):
+    """Kivy App for LiberviaDesktopKivy"""
+    c_prim = properties.ListProperty(C.COLOR_PRIM)
+    c_prim_light = properties.ListProperty(C.COLOR_PRIM_LIGHT)
+    c_prim_dark = properties.ListProperty(C.COLOR_PRIM_DARK)
+    c_sec = properties.ListProperty(C.COLOR_SEC)
+    c_sec_light = properties.ListProperty(C.COLOR_SEC_LIGHT)
+    c_sec_dark = properties.ListProperty(C.COLOR_SEC_DARK)
+    connected = properties.BooleanProperty(False)
+    # we have to put those constants here and not in core/constants.py
+    # because of the use of dp(), which would import Kivy too early
+    # and prevent the log hack
+    MARGIN_LEFT = MARGIN_RIGHT = dp(10)
+
+    def _install_settings_keys(self, window):
+        # we don't want default Kivy's behaviour of displaying
+        # a settings screen when pressing F1 or platform specific key
+        return
+
+    def build(self):
+        Window.bind(on_keyboard=self.key_input)
+        Window.bind(on_dropfile=self.on_dropfile)
+        wid = LiberviaDesktopKivyRootWidget(Label(text=_("Loading please wait")))
+        local_platform.on_app_build(wid)
+        return wid
+
+    def show_profile_manager(self):
+        self._profile_manager = ProfileManager()
+        self.root.change_widget(self._profile_manager)
+
+    def expand(self, path, *args, **kwargs):
+        """expand path and replace known values
+
+        useful in kv. Values which can be used:
+            - {media}: media dir
+        @param path(unicode): path to expand
+        @param *args: additional arguments used in format
+        @param **kwargs: additional keyword arguments used in format
+        """
+        return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs)
+
+    def init_frontend_state(self):
+        """Init state to handle paused/stopped/running on mobile OSes"""
+        local_platform.on_init_frontend_state()
+
+    def on_pause(self):
+        return local_platform.on_pause()
+
+    def on_resume(self):
+        return local_platform.on_resume()
+
+    def on_stop(self):
+        return local_platform.on_stop()
+
+    def show_head_widget(self, show=None, animation=True):
+        """Show/Hide the head widget
+
+        @param show(bool, None): True to show, False to hide, None to switch
+        @param animation(bool): animate the show/hide if True
+        """
+        head = self.root.head_widget
+        if bool(self.root.head_widget.height) == show:
+            return
+        if head.height:
+            if animation:
+                Animation(height=0, opacity=0, duration=0.3).start(head)
+            else:
+                head.height = head.opacity = 0
+        else:
+            if animation:
+                Animation(height=head.HEIGHT, opacity=1, duration=0.3).start(head)
+            else:
+                head.height = head.HEIGHT
+                head.opacity = 1
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+
+        # we first check if selected widget handles the key
+        if ((self.host.selected_widget is not None
+             and hasattr(self.host.selected_widget, 'key_input')
+             and self.host.selected_widget.key_input(window, key, scancode, codepoint,
+                 modifier))):
+            return True
+
+        if key == 27:
+            if ((self.host.selected_widget is None
+                 or self.host.selected_widget.__class__ == self.host.default_class)):
+                # we are on root widget, or nothing is selected
+                return local_platform.on_key_back_root()
+
+            # we disable [esc] handling, because default action is to quit app
+            return True
+        elif key == 292:
+            # F11: full screen
+            if not Window.fullscreen:
+                Window.fullscreen = 'auto'
+            else:
+                Window.fullscreen = False
+            return True
+        elif key == 110 and 'alt' in modifier:
+            # M-n we hide/show notifications
+            self.show_head_widget()
+            return True
+        else:
+            return False
+
+    def on_dropfile(self, __, file_path):
+        if self.host.selected_widget is not None:
+            try:
+                on_drop_file = self.host.selected_widget.on_drop_file
+            except AttributeError:
+                log.info(
+                    f"Select widget {self.host.selected_widget} doesn't handle file "
+                    f"dropping")
+            else:
+                on_drop_file(Path(file_path.decode()))
+
+
+class LiberviaDesktopKivy(QuickApp):
+    MB_HANDLE = False
+    AUTO_RESYNC = False
+
+    def __init__(self):
+        if bridge_name == 'embedded':
+            from libervia.backend.core import main
+            self.libervia_backend = main.LiberviaBackend()
+
+        bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge')
+        if bridge_module is None:
+            log.error(f"Can't import {bridge_name} bridge")
+            sys.exit(3)
+        else:
+            log.debug(f"Loading {bridge_name} bridge")
+        super(LiberviaDesktopKivy, self).__init__(bridge_factory=bridge_module.bridge,
+                                    xmlui=xmlui,
+                                    check_options=quick_utils.check_options,
+                                    connect_bridge=False)
+        self._import_kv()
+        self.app = LiberviaDesktopKivyApp()
+        self.app.host = self
+        self.media_dir = self.app.media_dir = config.config_get(main_config, '',
+                                                               'media_dir')
+        self.downloads_dir = self.app.downloads_dir = config.config_get(main_config, '',
+                                                                       'downloads_dir')
+        if not os.path.exists(self.downloads_dir):
+            try:
+                os.makedirs(self.downloads_dir)
+            except OSError as e:
+                log.warnings(_("Can't create downloads dir: {reason}").format(reason=e))
+        self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png")
+        self.app.icon = os.path.join(
+            self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png"
+        )
+        self.app.title = C.APP_NAME
+        # main widgets plugins
+        self._plg_wids = []
+        # transfer widgets plugins
+        self._plg_wids_transfer = []
+        self._import_plugins()
+        # visible widgets by classes
+        self._visible_widgets = {}
+        # used to keep track of last selected widget in "main" screen when changing
+        # root screen
+        self._selected_widget_main = None
+        self.backend_version = libervia.backend.__version__  # will be replaced by version_get()
+        if C.APP_VERSION.endswith('D'):
+            self.version = "{} {}".format(
+                C.APP_VERSION,
+                libervia_utils.get_repository_data(libervia.desktop_kivy)
+            )
+        else:
+            self.version = C.APP_VERSION
+
+        self.tls_validation =  not C.bool(config.config_get(main_config,
+                                                           C.CONFIG_SECTION,
+                                                           'no_certificate_validation',
+                                                           C.BOOL_FALSE))
+        if not self.tls_validation:
+            from libervia.desktop_kivy.core import patches
+            patches.disable_tls_validation()
+            log.warning("SSL certificate validation is disabled, this is unsecure!")
+
+        local_platform.on_host_init(self)
+
+    @property
+    def visible_widgets(self):
+        for w_list in self._visible_widgets.values():
+            for w in w_list:
+                yield w
+
+    @property
+    def default_class(self):
+        if self.default_wid is None:
+            return None
+        return self.default_wid['main']
+
+    @QuickApp.sync.setter
+    def sync(self, state):
+        QuickApp.sync.fset(self, state)
+        # widget are resynchronised in on_visible event,
+        # so we must call resync for widgets which are already visible
+        if state:
+            for w in self.visible_widgets:
+                try:
+                    resync = w.resync
+                except AttributeError:
+                    pass
+                else:
+                    resync()
+            self.contact_lists.fill()
+
+    def config_get(self, section, name, default=None):
+        return config.config_get(main_config, section, name, default)
+
+    def on_bridge_connected(self):
+        super(LiberviaDesktopKivy, self).on_bridge_connected()
+        self.register_signal("otr_state", iface="plugin")
+
+    def _bridge_eb(self, failure):
+        if bridge_name == "pb" and sys.platform == "android":
+            try:
+                self.retried += 1
+            except AttributeError:
+                self.retried = 1
+            if ((isinstance(failure, exceptions.BridgeExceptionNoService)
+                 and self.retried < 100)):
+                if self.retried % 20 == 0:
+                    log.debug("backend not ready, retrying ({})".format(self.retried))
+                Clock.schedule_once(lambda __: self.connect_bridge(), 0.05)
+                return
+        super(LiberviaDesktopKivy, self)._bridge_eb(failure)
+
+    def run(self):
+        self.connect_bridge()
+        self.app.bind(on_stop=self.onStop)
+        self.app.run()
+
+    def onStop(self, obj):
+        try:
+            libervia_instance = self.libervia_backend
+        except AttributeError:
+            pass
+        else:
+            libervia_instance.stopService()
+
+    def _get_version_cb(self, version):
+        self.backend_version = version
+
+    def on_backend_ready(self):
+        super().on_backend_ready()
+        self.app.show_profile_manager()
+        self.bridge.version_get(callback=self._get_version_cb)
+        self.app.init_frontend_state()
+        if local_platform.do_post_init():
+            self.post_init()
+
+    def post_init(self, __=None):
+        # FIXME: resize doesn't work with SDL2 on android, so we use below_target for now
+        self.app.root_window.softinput_mode = "below_target"
+        profile_manager = self.app._profile_manager
+        del self.app._profile_manager
+        super(LiberviaDesktopKivy, self).post_init(profile_manager)
+
+    def profile_plugged(self, profile):
+        super().profile_plugged(profile)
+        # FIXME: this won't work with multiple profiles
+        self.app.connected = self.profiles[profile].connected
+
+    def _bookmarks_list_cb(self, bookmarks_dict, profile):
+        bookmarks = set()
+        for data in bookmarks_dict.values():
+            bookmarks.update({jid.JID(k) for k in data.keys()})
+        self.profiles[profile]._bookmarks = sorted(bookmarks)
+
+    def profile_connected(self, profile):
+        self.bridge.bookmarks_list(
+            "muc", "all", profile,
+            callback=partial(self._bookmarks_list_cb, profile=profile),
+            errback=partial(self.errback, title=_("Bookmark error")))
+
+    def _default_factory_main(self, plugin_info, target, profiles):
+        """default factory used to create main widgets instances
+
+        used when PLUGIN_INFO["factory"] is not set
+        @param plugin_info(dict): plugin datas
+        @param target: QuickWidget target
+        @param profiles(iterable): list of profiles
+        """
+        main_cls = plugin_info['main']
+        return self.widgets.get_or_create_widget(main_cls,
+                                              target,
+                                              on_new_widget=None,
+                                              profiles=iter(self.profiles))
+
+    def _default_factory_transfer(self, plugin_info, callback, cancel_cb, profiles):
+        """default factory used to create transfer widgets instances
+
+        @param plugin_info(dict): plugin datas
+        @param callback(callable): method to call with path to file to transfer
+        @param cancel_cb(callable): call when transfer is cancelled
+            transfer widget must be used as first argument
+        @param profiles(iterable): list of profiles
+            None if not specified
+        """
+        main_cls = plugin_info['main']
+        return main_cls(callback=callback, cancel_cb=cancel_cb)
+
+    ## plugins & kv import ##
+
+    def _import_kv(self):
+        """import all kv files in libervia.desktop_kivy.kv"""
+        path = os.path.dirname(libervia.desktop_kivy.kv.__file__)
+        kv_files = glob.glob(os.path.join(path, "*.kv"))
+        # we want to be sure that base.kv is loaded first
+        # as it override some Kivy widgets properties
+        for kv_file in kv_files:
+            if kv_file.endswith('base.kv'):
+                kv_files.remove(kv_file)
+                kv_files.insert(0, kv_file)
+                break
+        else:
+            raise exceptions.InternalError("base.kv is missing")
+
+        for kv_file in kv_files:
+            Builder.load_file(kv_file)
+            log.debug(f"kv file {kv_file} loaded")
+
+    def _import_plugins(self):
+        """import all plugins"""
+        self.default_wid = None
+        plugins_path = os.path.dirname(libervia.desktop_kivy.plugins.__file__)
+        plugin_glob = "plugin*." + C.PLUGIN_EXT
+        plug_lst = [os.path.splitext(p)[0] for p in
+                    map(os.path.basename, glob.glob(os.path.join(plugins_path,
+                                                                 plugin_glob)))]
+
+        imported_names_main = set()  # used to avoid loading 2 times
+                                     # plugin with same import name
+        imported_names_transfer = set()
+        for plug in plug_lst:
+            plugin_path = 'libervia.desktop_kivy.plugins.' + plug
+
+            # we get type from plugin name
+            suff = plug[7:]
+            if '_' not in suff:
+                log.error("invalid plugin name: {}, skipping".format(plug))
+                continue
+            plugin_type = suff[:suff.find('_')]
+
+            # and select the variable to use according to type
+            if plugin_type == C.PLUG_TYPE_WID:
+                imported_names = imported_names_main
+                default_factory = self._default_factory_main
+            elif plugin_type == C.PLUG_TYPE_TRANSFER:
+                imported_names = imported_names_transfer
+                default_factory = self._default_factory_transfer
+            else:
+                log.error("unknown plugin type {type_} for plugin {file_}, skipping"
+                    .format(
+                    type_ = plugin_type,
+                    file_ = plug
+                    ))
+                continue
+            plugins_set = self._get_plugins_set(plugin_type)
+
+            mod = import_module(plugin_path)
+            try:
+                plugin_info = mod.PLUGIN_INFO
+            except AttributeError:
+                plugin_info = {}
+
+            plugin_info['plugin_file'] = plug
+            plugin_info['plugin_type'] = plugin_type
+
+            if 'platforms' in plugin_info:
+                if sys.platform not in plugin_info['platforms']:
+                    log.info("{plugin_file} is not used on this platform, skipping"
+                             .format(**plugin_info))
+                    continue
+
+            # import name is used to differentiate plugins
+            if 'import_name' not in plugin_info:
+                plugin_info['import_name'] = plug
+            if plugin_info['import_name'] in imported_names:
+                log.warning(_("there is already a plugin named {}, "
+                              "ignoring new one").format(plugin_info['import_name']))
+                continue
+            if plugin_info['import_name'] == C.WID_SELECTOR:
+                if plugin_type != C.PLUG_TYPE_WID:
+                    log.error("{import_name} import name can only be used with {type_} "
+                              "type, skipping {name}".format(type_=C.PLUG_TYPE_WID,
+                                                              **plugin_info))
+                    continue
+                # if WidgetSelector exists, it will be our default widget
+                self.default_wid = plugin_info
+
+            # we want everything optional, so we use plugin file name
+            # if actual name is not found
+            if 'name' not in plugin_info:
+                name_start = 8 + len(plugin_type)
+                plugin_info['name'] = plug[name_start:]
+
+            # we need to load the kv file
+            if 'kv_file' not in plugin_info:
+                plugin_info['kv_file'] = '{}.kv'.format(plug)
+            kv_path = os.path.join(plugins_path, plugin_info['kv_file'])
+            if not os.path.exists(kv_path):
+                log.debug("no kv found for {plugin_file}".format(**plugin_info))
+            else:
+                Builder.load_file(kv_path)
+
+            # what is the main class ?
+            main_cls = getattr(mod, plugin_info['main'])
+            plugin_info['main'] = main_cls
+
+            # factory is used to create the instance
+            # if not found, we use a defaut one with get_or_create_widget
+            if 'factory' not in plugin_info:
+                plugin_info['factory'] = default_factory
+
+            # icons
+            for size in ('small', 'medium'):
+                key = 'icon_{}'.format(size)
+                try:
+                    path = plugin_info[key]
+                except KeyError:
+                    path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
+                else:
+                    path = path.format(media=self.media_dir)
+                    if not os.path.isfile(path):
+                        path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
+                plugin_info[key] = path
+
+            plugins_set.append(plugin_info)
+        if not self._plg_wids:
+            log.error(_("no widget plugin found"))
+            return
+
+        # we want widgets sorted by names
+        self._plg_wids.sort(key=lambda p: p['name'].lower())
+        self._plg_wids_transfer.sort(key=lambda p: p['name'].lower())
+
+        if self.default_wid is None:
+            # we have no selector widget, we use the first widget as default
+            self.default_wid = self._plg_wids[0]
+
+    def _get_plugins_set(self, type_):
+        if type_ == C.PLUG_TYPE_WID:
+            return self._plg_wids
+        elif type_ == C.PLUG_TYPE_TRANSFER:
+            return self._plg_wids_transfer
+        else:
+            raise KeyError("{} plugin type is unknown".format(type_))
+
+    def get_plugged_widgets(self, type_=C.PLUG_TYPE_WID, except_cls=None):
+        """get available widgets plugin infos
+
+        @param type_(unicode): type of widgets to get
+            one of C.PLUG_TYPE_* constant
+        @param except_cls(None, class): if not None,
+            widgets from this class will be excluded
+        @return (iter[dict]): available widgets plugin infos
+        """
+        plugins_set = self._get_plugins_set(type_)
+        for plugin_data in plugins_set:
+            if plugin_data['main'] == except_cls:
+                continue
+            yield plugin_data
+
+    def get_plugin_info(self, type_=C.PLUG_TYPE_WID, **kwargs):
+        """get first plugin info corresponding to filters
+
+        @param type_(unicode): type of widgets to get
+            one of C.PLUG_TYPE_* constant
+        @param **kwargs: filter(s) to use, each key present here must also
+            exist and be of the same value in requested plugin info
+        @return (dict, None): found plugin info or None
+        """
+        plugins_set = self._get_plugins_set(type_)
+        for plugin_info in plugins_set:
+            for k, w in kwargs.items():
+                try:
+                    if plugin_info[k] != w:
+                        continue
+                except KeyError:
+                    continue
+                return plugin_info
+
+    ## widgets handling
+
+    def new_widget(self, widget):
+        log.debug("new widget created: {}".format(widget))
+        if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP:
+            self.add_note("", _("room {} has been joined").format(widget.target))
+
+    def switch_widget(self, old, new=None):
+        """Replace old widget by new one
+
+        @param old(LiberviaDesktopKivyWidget, None): LiberviaDesktopKivyWidget instance or a child
+            None to select automatically widget to switch
+        @param new(LiberviaDesktopKivyWidget): new widget instance
+            None to use default widget
+        @return (LiberviaDesktopKivyWidget): new widget
+        """
+        if old is None:
+            old = self.get_widget_to_switch()
+        if new is None:
+            factory = self.default_wid['factory']
+            try:
+                profiles = old.profiles
+            except AttributeError:
+                profiles = None
+            new = factory(self.default_wid, None, profiles=profiles)
+        to_change = None
+        if isinstance(old, LiberviaDesktopKivyWidget):
+            to_change = old
+        else:
+            for w in old.walk_reverse():
+                if isinstance(w, LiberviaDesktopKivyWidget):
+                    to_change = w
+                    break
+
+        if to_change is None:
+            raise exceptions.InternalError("no LiberviaDesktopKivyWidget found when "
+                                           "trying to switch widget")
+
+        # selected_widget can be modified in change_widget, so we need to set it before
+        self.selected_widget = new
+        if to_change == new:
+            log.debug("switch_widget called with old==new, nothing to do")
+            return new
+        to_change.whwrapper.change_widget(new)
+        return new
+
+    def _add_visible_widget(self, widget):
+        """declare a widget visible
+
+        for internal use only!
+        """
+        assert isinstance(widget, LiberviaDesktopKivyWidget)
+        log.debug(f"Visible widget: {widget}")
+        self._visible_widgets.setdefault(widget.__class__, set()).add(widget)
+        log.debug(f"visible widgets list: {self.get_visible_list(None)}")
+        widget.on_visible()
+
+    def _remove_visible_widget(self, widget, ignore_missing=False):
+        """declare a widget not visible anymore
+
+        for internal use only!
+        """
+        log.debug(f"Widget not visible anymore: {widget}")
+        try:
+            self._visible_widgets[widget.__class__].remove(widget)
+        except KeyError as e:
+            if not ignore_missing:
+                log.error(f"trying to remove a not visible widget ({widget}): {e}")
+            return
+        log.debug(f"visible widgets list: {self.get_visible_list(None)}")
+        if isinstance(widget, LiberviaDesktopKivyWidget):
+            widget.on_not_visible()
+        if isinstance(widget, quick_widgets.QuickWidget):
+            self.widgets.delete_widget(widget)
+
+    def get_visible_list(self, cls):
+        """get list of visible widgets for a given class
+
+        @param cls(type): type of widgets to get
+            None to get all visible widgets
+        @return (set[type]): visible widgets of this class
+        """
+        if cls is None:
+            ret = set()
+            for widgets in self._visible_widgets.values():
+                for w in widgets:
+                    ret.add(w)
+            return ret
+        try:
+            return self._visible_widgets[cls]
+        except KeyError:
+            return set()
+
+    def delete_unused_widget_instances(self, widget):
+        """Delete instance of this widget which are not attached to a WHWrapper
+
+        @param widget(quick_widgets.QuickWidget): reference widget
+            other instance of this widget will be deleted if they have no parent
+        """
+        to_delete = []
+        if isinstance(widget, quick_widgets.QuickWidget):
+            for w in self.widgets.get_widget_instances(widget):
+                if w.whwrapper is None and w != widget:
+                    to_delete.append(w)
+            for w in to_delete:
+                log.debug("cleaning widget: {wid}".format(wid=w))
+                self.widgets.delete_widget(w)
+
+    def get_or_clone(self, widget, **kwargs):
+        """Get a QuickWidget if it is not in a WHWrapper, else clone it
+
+        if an other instance of this widget exist without being in a WHWrapper
+        (i.e. if it is not already in use) it will be used.
+        """
+        if widget.whwrapper is None:
+            if widget.parent is not None:
+                widget.parent.remove_widget(widget)
+            self.delete_unused_widget_instances(widget)
+            return widget
+        for w in self.widgets.get_widget_instances(widget):
+            if w.whwrapper is None:
+                if w.parent is not None:
+                    w.parent.remove_widget(w)
+                self.delete_unused_widget_instances(w)
+                return w
+        targets = list(widget.targets)
+        w = self.widgets.get_or_create_widget(widget.__class__,
+                                           targets[0],
+                                           on_new_widget=None,
+                                           on_existing_widget=C.WIDGET_RECREATE,
+                                           profiles=widget.profiles,
+                                           **kwargs)
+        for t in targets[1:]:
+            w.add_target(t)
+        return w
+
+    def get_widget_to_switch(self):
+        """Choose best candidate when we need to switch widget and old is not specified
+
+        @return (LiberviaDesktopKivyWidget): widget to switch
+        """
+        if (self._selected_widget_main is not None
+            and self._selected_widget_main.whwrapper is not None):
+            # we are not on the main screen, we want to switch a widget from main screen
+            return self._selected_widget_main
+        elif (self.selected_widget is not None
+              and isinstance(self.selected_widget, LiberviaDesktopKivyWidget)
+              and self.selected_widget.whwrapper is not None):
+            return self.selected_widget
+        # no widget is selected we check if we have any default widget
+        default_cls = self.default_class
+        for w in self.visible_widgets:
+            if isinstance(w, default_cls):
+                return w
+
+        # no default widget found, we return the first widget
+        return next(iter(self.visible_widgets))
+
+    def do_action(self, action, target, profiles):
+        """Launch an action handler by a plugin
+
+        @param action(unicode): action to do, can be:
+            - chat: open a chat widget
+        @param target(unicode): target of the action
+        @param profiles(list[unicode]): profiles to use
+        @return (LiberviaDesktopKivyWidget, None): new widget
+        """
+        try:
+            # FIXME: Q&D way to get chat plugin, should be replaced by a clean method
+            #        in host
+            plg_infos = [p for p in self.get_plugged_widgets()
+                         if action in p['import_name']][0]
+        except IndexError:
+            log.warning("No plugin widget found to do {action}".format(action=action))
+        else:
+            try:
+                # does the widget already exist?
+                wid = next(self.widgets.get_widgets(
+                    plg_infos['main'],
+                    target=target,
+                    profiles=profiles))
+            except StopIteration:
+                # no, let's create a new one
+                factory = plg_infos['factory']
+                wid = factory(plg_infos, target=target, profiles=profiles)
+
+            return self.switch_widget(None, wid)
+
+    ## bridge handlers ##
+
+    def otr_state_handler(self, state, dest_jid, profile):
+        """OTR state has changed for on destinee"""
+        # XXX: this method could be in QuickApp but it's here as
+        #      it's only used by LiberviaDesktopKivy so far
+        dest_jid = jid.JID(dest_jid)
+        bare_jid = dest_jid.bare
+        for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
+            if widget.type == C.CHAT_ONE2ONE and widget.target == bare_jid:
+                widget.on_otr_state(state, dest_jid, profile)
+
+    def _debug_handler(self, action, parameters, profile):
+        if action == "visible_widgets_dump":
+            from pprint import pformat
+            log.info("Visible widgets dump:\n{data}".format(
+                data=pformat(self._visible_widgets)))
+        else:
+            return super(LiberviaDesktopKivy, self)._debug_handler(action, parameters, profile)
+
+    def connected_handler(self, jid_s, profile):
+        # FIXME: this won't work with multiple profiles
+        super().connected_handler(jid_s, profile)
+        self.app.connected = True
+
+    def disconnected_handler(self, profile):
+        # FIXME: this won't work with multiple profiles
+        super().disconnected_handler(profile)
+        self.app.connected = False
+
+    ## misc ##
+
+    def plugging_profiles(self):
+        self.widgets_handler = widgets_handler.WidgetsHandler()
+        self.app.root.change_widget(self.widgets_handler)
+
+    def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE):
+        log.info("Profile presence status set to {show}/{status}".format(show=show,
+                                                                          status=status))
+
+    def errback(self, failure_, title=_('error'),
+                message=_('error while processing: {msg}')):
+        self.add_note(title, message.format(msg=failure_), level=C.XMLUI_DATA_LVL_WARNING)
+
+    def add_note(self, title, message, level=C.XMLUI_DATA_LVL_INFO, symbol=None,
+        action=None):
+        """add a note (message which disappear) to root widget's header"""
+        self.app.root.add_note(title, message, level, symbol, action)
+
+    def add_notif_ui(self, ui):
+        """add a notification with a XMLUI attached
+
+        @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected
+        """
+        self.app.root.add_notif_ui(ui)
+
+    def add_notif_widget(self, widget):
+        """add a notification with a Kivy widget attached
+
+        @param widget(kivy.uix.Widget): widget to attach to notification
+        """
+        self.app.root.add_notif_widget(widget)
+
+    def show_ui(self, ui):
+        """show a XMLUI"""
+        self.app.root.change_widget(ui, "xmlui")
+        self.app.root.show("xmlui")
+        self._selected_widget_main = self.selected_widget
+        self.selected_widget = ui
+
+    def show_extra_ui(self, widget):
+        """show any extra widget"""
+        self.app.root.change_widget(widget, "extra")
+        self.app.root.show("extra")
+        self._selected_widget_main = self.selected_widget
+        self.selected_widget = widget
+
+    def close_ui(self):
+        self.app.root.show()
+        self.selected_widget = self._selected_widget_main
+        self._selected_widget_main = None
+        screen = self.app.root._manager.get_screen("extra")
+        screen.clear_widgets()
+
+    def get_default_avatar(self, entity=None):
+        return self.app.default_avatar
+
+    def _dialog_cb(self, cb, *args, **kwargs):
+        """generic dialog callback
+
+        close dialog then call the callback with given arguments
+        """
+        def callback():
+            self.close_ui()
+            cb(*args, **kwargs)
+        return callback
+
+    def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
+        if type in ('info', 'warning', 'error'):
+            self.add_note(title, message, type)
+        elif type == "yes/no":
+            wid = dialog.ConfirmDialog(title=title, message=message,
+                                       yes_cb=self._dialog_cb(answer_cb,
+                                                              True,
+                                                              answer_data),
+                                       no_cb=self._dialog_cb(answer_cb,
+                                                             False,
+                                                             answer_data)
+                                       )
+            self.add_notif_widget(wid)
+        else:
+            log.warning(_("unknown dialog type: {dialog_type}").format(dialog_type=type))
+
+    def share(self, media_type, data):
+        share_wid = ShareWidget(media_type=media_type, data=data)
+        try:
+            self.show_extra_ui(share_wid)
+        except Exception as e:
+            log.error(e)
+            self.close_ui()
+
+    def download_url(
+        self, url, callback, errback=None, options=None, dest=C.FILE_DEST_DOWNLOAD,
+        profile=C.PROF_KEY_NONE):
+        """Download an URL (decrypt it if necessary)
+
+        @param url(str, parse.SplitResult): url to download
+        @param callback(callable): method to call when download is complete
+        @param errback(callable, None): method to call in case of error
+            if None, default errback will be called
+        @param dest(str): where the file should be downloaded:
+            - C.FILE_DEST_DOWNLOAD: in platform download directory
+            - C.FILE_DEST_CACHE: in SàT cache
+        @param options(dict, None): options to pass to bridge.file_download_complete
+        """
+        if not isinstance(url, urlparse.ParseResult):
+            url = urlparse.urlparse(url)
+        if errback is None:
+            errback = partial(
+                self.errback,
+                title=_("Download error"),
+                message=_("Error while downloading {url}: {{msg}}").format(url=url.geturl()))
+        name = Path(url.path).name.strip() or C.FILE_DEFAULT_NAME
+        log.info(f"downloading/decrypting file {name!r}")
+        if dest == C.FILE_DEST_DOWNLOAD:
+            dest_path = files_utils.get_unique_name(Path(self.downloads_dir)/name)
+        elif dest == C.FILE_DEST_CACHE:
+            dest_path = ''
+        else:
+            raise exceptions.InternalError(f"Invalid dest_path: {dest_path!r}")
+        self.bridge.file_download_complete(
+            data_format.serialise({"uri": url.geturl()}),
+            str(dest_path),
+            '' if not options else data_format.serialise(options),
+            profile,
+            callback=callback,
+            errback=errback
+        )
+
+    def notify(self, type_, entity=None, message=None, subject=None, callback=None,
+               cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
+        super().notify(
+            type_=type_, entity=entity, message=message, subject=subject,
+            callback=callback, cb_args=cb_args, widget=widget, profile=profile)
+        self.desktop_notif(message, title=subject)
+
+    def desktop_notif(self, message, title='', duration=5):
+        global notification
+        if notification is not None:
+            try:
+                log.debug(
+                    f"sending desktop notification (duration: {duration}):\n"
+                    f"{title}\n"
+                    f"{message}"
+                )
+                notification.notify(title=title,
+                                    message=message,
+                                    app_name=C.APP_NAME,
+                                    app_icon=self.app.icon,
+                                    timeout=duration)
+            except Exception as e:
+                log.warning(_("Can't use notifications, disabling: {msg}").format(
+                    msg = e))
+                notification = None
+
+    def get_parent_wh_wrapper(self, wid):
+        """Retrieve parent WHWrapper instance managing a widget
+
+        @param wid(Widget): widget to check
+        @return (WHWrapper, None): found instance if any, else None
+        """
+        wh = self.get_ancestor_widget(wid, widgets_handler.WHWrapper)
+        if wh is None:
+            # we may have a screen
+            try:
+                sm = wid.screen_manager
+            except (exceptions.InternalError, exceptions.NotFound):
+                return None
+            else:
+                wh = self.get_ancestor_widget(sm, widgets_handler.WHWrapper)
+        return wh
+
+    def get_ancestor_widget(self, wid, cls):
+        """Retrieve an ancestor of given class
+
+        @param wid(Widget): current widget
+        @param cls(type): class of the ancestor to retrieve
+        @return (Widget, None): found instance or None
+        """
+        parent = wid.parent
+        while parent and not isinstance(parent, cls):
+            parent = parent.parent
+        return parent
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/cagou_widget.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 functools import total_ordering
+from libervia.backend.core import log as logging
+from libervia.backend.core import exceptions
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.dropdown import DropDown
+from kivy.uix.screenmanager import Screen
+from kivy.uix.textinput import TextInput
+from kivy import properties
+from libervia.desktop_kivy import G
+from .common import ActionIcon
+from . import menu
+
+
+log = logging.getLogger(__name__)
+
+
+class HeaderChoice(ButtonBehavior, BoxLayout):
+    pass
+
+
+class HeaderChoiceWidget(HeaderChoice):
+    cagou_widget = properties.ObjectProperty()
+    plugin_info = properties.ObjectProperty()
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.bind(on_release=lambda btn: self.cagou_widget.switch_widget(
+            self.plugin_info))
+
+
+class HeaderChoiceExtraMenu(HeaderChoice):
+    pass
+
+
+class HeaderWidgetCurrent(ButtonBehavior, ActionIcon):
+    pass
+
+
+class HeaderWidgetSelector(DropDown):
+
+    def __init__(self, cagou_widget):
+        super(HeaderWidgetSelector, self).__init__()
+        plg_info_cls = cagou_widget.plugin_info_class or cagou_widget.__class__
+        for plugin_info in G.host.get_plugged_widgets(except_cls=plg_info_cls):
+            choice = HeaderChoiceWidget(
+                cagou_widget=cagou_widget,
+                plugin_info=plugin_info,
+            )
+            self.add_widget(choice)
+        main_menu = HeaderChoiceExtraMenu(on_press=self.on_extra_menu)
+        self.add_widget(main_menu)
+
+    def add_widget(self, *args):
+        widget = args[0]
+        widget.bind(minimum_width=self.set_width)
+        return super(HeaderWidgetSelector, self).add_widget(*args)
+
+    def set_width(self, choice, minimum_width):
+        self.width = max([c.minimum_width for c in self.container.children])
+
+    def on_extra_menu(self, *args):
+        self.dismiss()
+        menu.ExtraSideMenu().show()
+
+
+@total_ordering
+class LiberviaDesktopKivyWidget(BoxLayout):
+    main_container = properties.ObjectProperty(None)
+    header_input = properties.ObjectProperty(None)
+    header_box = properties.ObjectProperty(None)
+    use_header_input = False
+    # set to True if you want to be able to switch between visible widgets of this
+    # class using a carousel
+    collection_carousel = False
+    # set to True if you a global ScreenManager global to all widgets of this class.
+    # The screen manager is created in WHWrapper
+    global_screen_manager = False
+    # override this if a specific class (i.e. not self.__class__) must be used for
+    # plugin info. Useful when a LiberviaDesktopKivyWidget is used with global_screen_manager.
+    plugin_info_class = None
+
+    def __init__(self, **kwargs):
+        plg_info_cls = self.plugin_info_class or self.__class__
+        for p in G.host.get_plugged_widgets():
+            if p['main'] == plg_info_cls:
+                self.plugin_info = p
+                break
+        super().__init__(**kwargs)
+        self.selector = HeaderWidgetSelector(self)
+        if self.use_header_input:
+            self.header_input = TextInput(
+                background_normal=G.host.app.expand(
+                    '{media}/misc/borders/border_hollow_light.png'),
+                multiline=False,
+            )
+            self.header_input.bind(
+                on_text_validate=lambda *args: self.on_header_wid_input(),
+                text=self.on_header_wid_input_complete,
+            )
+            self.header_box.add_widget(self.header_input)
+
+    def __lt__(self, other):
+        # XXX: sorting is notably used when collection_carousel is set
+        try:
+            target = str(self.target)
+        except AttributeError:
+            target = str(list(self.targets)[0])
+            other_target = str(list(other.targets)[0])
+        else:
+            other_target = str(other.target)
+        return target < other_target
+
+    @property
+    def screen_manager(self):
+        if ((not self.global_screen_manager
+             and not (self.plugin_info_class is not None
+                      and self.plugin_info_class.global_screen_manager))):
+            raise exceptions.InternalError(
+                "screen_manager property can't be used if global_screen_manager is not "
+                "set")
+        screen = self.get_ancestor(Screen)
+        if screen is None:
+            raise exceptions.NotFound("Can't find parent Screen")
+        if screen.manager is None:
+            raise exceptions.NotFound("Can't find parent ScreenManager")
+        return screen.manager
+
+    @property
+    def whwrapper(self):
+        """Retrieve parent widget handler"""
+        return G.host.get_parent_wh_wrapper(self)
+
+    def screen_manager_init(self, screen_manager):
+        """Override this method to do init when ScreenManager is instantiated
+
+        This is only called once even if collection_carousel is used.
+        """
+        if not self.global_screen_manager:
+            raise exceptions.InternalError("screen_manager_init should not be called")
+
+    def get_ancestor(self, cls):
+        """Helper method to use host.get_ancestor_widget with self"""
+        return G.host.get_ancestor_widget(self, cls)
+
+    def switch_widget(self, plugin_info):
+        self.selector.dismiss()
+        factory = plugin_info["factory"]
+        new_widget = factory(plugin_info, None, iter(G.host.profiles))
+        G.host.switch_widget(self, new_widget)
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            # we go back to root screen
+            G.host.switch_widget(self)
+            return True
+
+    def on_header_wid_input(self):
+        log.info("header input text entered")
+
+    def on_header_wid_input_complete(self, wid, text):
+        return
+
+    def on_touch_down(self, touch):
+        if self.collide_point(*touch.pos):
+            G.host.selected_widget = self
+        return super(LiberviaDesktopKivyWidget, self).on_touch_down(touch)
+
+    def header_input_add_extra(self, widget):
+        """add a widget on the right of header input"""
+        self.header_box.add_widget(widget)
+
+    def on_visible(self):
+        pass
+        # log.debug(u"{self} is visible".format(self=self))
+
+    def on_not_visible(self):
+        pass
+        # log.debug(u"{self} is not visible anymore".format(self=self))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/common.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,481 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+"""common simple widgets"""
+
+import json
+from functools import partial, total_ordering
+from kivy.uix.widget import Widget
+from kivy.uix.label import Label
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.behaviors import ToggleButtonBehavior
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.scrollview import ScrollView
+from kivy.event import EventDispatcher
+from kivy.metrics import dp
+from kivy import properties
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+from libervia.backend.tools.common import data_format
+from libervia.frontends.quick_frontend import quick_chat
+from .constants import Const as C
+from .common_widgets import CategorySeparator
+from .image import Image, AsyncImage
+from libervia.desktop_kivy import G
+
+log = logging.getLogger(__name__)
+
+UNKNOWN_SYMBOL = 'Unknown symbol name'
+
+
+class IconButton(ButtonBehavior, Image):
+    pass
+
+
+class Avatar(Image):
+    data = properties.DictProperty(allownone=True)
+
+    def on_kv_post(self, __):
+        if not self.source:
+            self.source = G.host.get_default_avatar()
+
+    def on_data(self, __, data):
+        if data is None:
+            self.source = G.host.get_default_avatar()
+        else:
+            self.source = data['path']
+
+
+class NotifLabel(Label):
+    pass
+
+@total_ordering
+class ContactItem(BoxLayout):
+    """An item from ContactList
+
+    The item will drawn as an icon (JID avatar) with its jid below.
+    If "badge_text" is set, a label with the text will be drawn above the avatar.
+    """
+    base_width = dp(150)
+    avatar_layout = properties.ObjectProperty()
+    avatar = properties.ObjectProperty()
+    badge = properties.ObjectProperty(allownone=True)
+    badge_text = properties.StringProperty('')
+    profile = properties.StringProperty()
+    data = properties.DictProperty()
+    jid = properties.StringProperty('')
+
+    def on_kv_post(self, __):
+        if ((self.profile and self.jid and self.data is not None
+             and ('avatar' not in self.data or 'nicknames' not in self.data))):
+            G.host.bridge.identity_get(
+                self.jid, ['avatar', 'nicknames'], True, self.profile,
+                callback=self._identity_get_cb,
+                errback=partial(
+                    G.host.errback,
+                    message=_("Can't retrieve identity for {jid}: {{msg}}").format(
+                        jid=self.jid)
+                )
+            )
+
+    def _identity_get_cb(self, identity_raw):
+        identity_data = data_format.deserialise(identity_raw)
+        self.data.update(identity_data)
+
+    def on_badge_text(self, wid, text):
+        if text:
+            if self.badge is not None:
+                self.badge.text = text
+            else:
+                self.badge = NotifLabel(
+                    pos_hint={"right": 0.8, "y": 0},
+                    text=text,
+                )
+                self.avatar_layout.add_widget(self.badge)
+        else:
+            if self.badge is not None:
+                self.avatar_layout.remove_widget(self.badge)
+                self.badge = None
+
+    def __lt__(self, other):
+        return self.jid < other.jid
+
+
+class ContactButton(ButtonBehavior, ContactItem):
+    pass
+
+
+class JidItem(BoxLayout):
+    bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1])
+    color = properties.ListProperty([1, 1, 1, 1])
+    jid = properties.StringProperty()
+    profile = properties.StringProperty()
+    nick = properties.StringProperty()
+    avatar = properties.ObjectProperty()
+
+    def on_avatar(self, wid, jid_):
+        if self.jid and self.profile:
+            self.get_image()
+
+    def on_jid(self, wid, jid_):
+        if self.profile and self.avatar:
+            self.get_image()
+
+    def on_profile(self, wid, profile):
+        if self.jid and self.avatar:
+            self.get_image()
+
+    def get_image(self):
+        host = G.host
+        if host.contact_lists[self.profile].is_room(self.jid.bare):
+            self.avatar.opacity = 0
+            self.avatar.source = ""
+        else:
+            self.avatar.source = (
+                host.get_avatar(self.jid, profile=self.profile)
+                or host.get_default_avatar(self.jid)
+            )
+
+
+class JidButton(ButtonBehavior, JidItem):
+    pass
+
+
+class JidToggle(ToggleButtonBehavior, JidItem):
+    selected_color = properties.ListProperty(C.COLOR_SEC_DARK)
+
+
+class Symbol(Label):
+    symbol_map = None
+    symbol = properties.StringProperty()
+
+    def __init__(self, **kwargs):
+        if self.symbol_map is None:
+            with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f:
+                fontello_conf = json.load(f)
+            Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']}
+
+        super(Symbol, self).__init__(**kwargs)
+
+    def on_symbol(self, instance, symbol):
+        try:
+            code = self.symbol_map[symbol]
+        except KeyError:
+            log.warning(_("Invalid symbol {symbol}").format(symbol=symbol))
+        else:
+            self.text = chr(code)
+
+
+class SymbolButton(ButtonBehavior, Symbol):
+    pass
+
+
+class SymbolLabel(BoxLayout):
+    symbol = properties.StringProperty("")
+    text = properties.StringProperty("")
+    color = properties.ListProperty(C.COLOR_SEC)
+    bold = properties.BooleanProperty(True)
+    symbol_wid = properties.ObjectProperty()
+    label = properties.ObjectProperty()
+
+
+class SymbolButtonLabel(ButtonBehavior, SymbolLabel):
+    pass
+
+
+class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel):
+    pass
+
+
+class ActionSymbol(Symbol):
+    pass
+
+
+class ActionIcon(BoxLayout):
+    plugin_info = properties.DictProperty()
+
+    def on_plugin_info(self, instance, plugin_info):
+        self.clear_widgets()
+        try:
+            symbol = plugin_info['icon_symbol']
+        except KeyError:
+            icon_src = plugin_info['icon_medium']
+            icon_wid = Image(source=icon_src, allow_stretch=True)
+            self.add_widget(icon_wid)
+        else:
+            icon_wid = ActionSymbol(symbol=symbol)
+            self.add_widget(icon_wid)
+
+
+class SizedImage(AsyncImage):
+    """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT"""
+    # following properties are desired height/width
+    # i.e. the ones specified in height/width attributes of <img>
+    # (or wanted for whatever reason)
+    # set to None to ignore them
+    target_height = properties.NumericProperty(allownone=True)
+    target_width = properties.NumericProperty(allownone=True)
+
+    def __init__(self, **kwargs):
+        # best calculated size
+        self._best_width = self._best_height = 100
+        super().__init__(**kwargs)
+
+    def on_texture(self, instance, texture):
+        """Adapt the size according to max size and target_*"""
+        if texture is None:
+            return
+        max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT)
+        width, height = texture.size
+        if self.target_width:
+            width = min(width, self.target_width)
+        if width > max_width:
+            width = C.IMG_MAX_WIDTH
+
+        height = width / self.image_ratio
+
+        if self.target_height:
+            height = min(height, self.target_height)
+
+        if height > max_height:
+            height = max_height
+            width = height * self.image_ratio
+
+        self.width, self.height = self._best_width, self._best_height = width, height
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            parent.bind(width=self.on_parent_width)
+
+    def on_parent_width(self, instance, width):
+        if self._best_width > width:
+            self.width = width
+            self.height = width / self.image_ratio
+        else:
+            self.width, self.height = self._best_width, self._best_height
+
+
+class JidSelectorCategoryLayout(StackLayout):
+    pass
+
+
+class JidSelector(ScrollView, EventDispatcher):
+    layout = properties.ObjectProperty(None)
+    # if item_class is changed, the properties must be the same as for ContactButton
+    # and ordering must be supported
+    item_class = properties.ObjectProperty(ContactButton)
+    add_separators = properties.ObjectProperty(True)
+    # list of item to show, can be:
+    #    - a well-known string which can be:
+    #       * "roster": all roster jids
+    #       * "opened_chats": all opened chat widgets
+    #       * "bookmarks": MUC bookmarks
+    #       A layout will be created each time and stored in the attribute of the same
+    #       name.
+    #       If add_separators is True, a CategorySeparator will be added on top of each
+    #       layout.
+    #    - a kivy Widget, which will be added to the layout (notable useful with
+    #      common_widgets.CategorySeparator)
+    #    - a callable, which must return an iterable of kwargs for ContactButton
+    to_show = properties.ListProperty(['roster'])
+
+    # TODO: roster and bookmarks must be updated in real time, like for opened_chats
+
+
+    def __init__(self, **kwargs):
+        self.register_event_type('on_select')
+        # list of layouts containing items
+        self.items_layouts = []
+        # jid to list of ContactButton instances map
+        self.items_map = {}
+        super().__init__(**kwargs)
+
+    def on_kv_post(self, wid):
+        self.update()
+
+    def on_select(self, wid):
+        pass
+
+    def on_parent(self, wid, parent):
+        if parent is None:
+            log.debug("removing listeners")
+            G.host.removeListener("contactsFilled", self.on_contacts_filled)
+            G.host.removeListener("notification", self.on_notification)
+            G.host.removeListener("notificationsClear", self.on_notifications_clear)
+            G.host.removeListener(
+                "widgetNew", self.on_widget_new, ignore_missing=True)
+            G.host.removeListener(
+                "widgetDeleted", self.on_widget_deleted, ignore_missing=True)
+        else:
+            log.debug("adding listeners")
+            G.host.addListener("contactsFilled", self.on_contacts_filled)
+            G.host.addListener("notification", self.on_notification)
+            G.host.addListener("notificationsClear", self.on_notifications_clear)
+
+    def on_contacts_filled(self, profile):
+        log.debug("on_contacts_filled event received")
+        self.update()
+
+    def on_notification(self, entity, notification_data, profile):
+        for item in self.items_map.get(entity.bare, []):
+            notifs = list(G.host.get_notifs(entity.bare, profile=profile))
+            item.badge_text = str(len(notifs))
+
+    def on_notifications_clear(self, entity, type_, profile):
+        for item in self.items_map.get(entity.bare, []):
+            item.badge_text = ''
+
+    def on_widget_new(self, wid):
+        if not isinstance(wid, quick_chat.QuickChat):
+            return
+        item = self.get_item_from_wid(wid)
+        if item is None:
+            return
+        idx = 0
+        for child in self.opened_chats.children:
+            if isinstance(child, self.item_class) and child < item:
+                break
+            idx+=1
+        self.opened_chats.add_widget(item, index=idx)
+
+    def on_widget_deleted(self, wid):
+        if not isinstance(wid, quick_chat.QuickChat):
+            return
+
+        for child in self.opened_chats.children:
+            if not isinstance(child, self.item_class):
+                continue
+            if child.jid.bare == wid.target.bare:
+                self.opened_chats.remove_widget(child)
+                break
+
+    def _create_item(self, **kwargs):
+        item = self.item_class(**kwargs)
+        jid = kwargs['jid']
+        self.items_map.setdefault(jid, []).append(item)
+        return item
+
+    def update(self):
+        log.debug("starting update")
+        self.layout.clear_widgets()
+        for item in self.to_show:
+            if isinstance(item, str):
+                if item == 'roster':
+                    self.add_roster_items()
+                elif item == 'bookmarks':
+                    self.add_bookmarks_items()
+                elif item == 'opened_chats':
+                    self.add_opened_chats_items()
+                else:
+                    log.error(f'unknown "to_show" magic string {item!r}')
+            elif isinstance(item, Widget):
+                self.layout.add_widget(item)
+            elif callable(item):
+                items_kwargs = item()
+                for item_kwargs in items_kwargs:
+                    item = self._create_item(**items_kwargs)
+                    item.bind(on_press=partial(self.dispatch, 'on_select'))
+                    self.layout.add_widget(item)
+            else:
+                log.error(f"unmanaged to_show item type: {item!r}")
+
+    def add_category_layout(self, label=None):
+        category_layout = JidSelectorCategoryLayout()
+
+        if label and self.add_separators:
+            category_layout.add_widget(CategorySeparator(text=label))
+
+        self.layout.add_widget(category_layout)
+        self.items_layouts.append(category_layout)
+        return category_layout
+
+    def get_item_from_wid(self, wid):
+        """create JidSelector item from QuickChat widget"""
+        contact_list = G.host.contact_lists[wid.profile]
+        try:
+            data=contact_list.get_item(wid.target)
+        except KeyError:
+            log.warning(f"Can't find item data for {wid.target}")
+            data={}
+        try:
+            item = self._create_item(
+                jid=wid.target,
+                data=data,
+                profile=wid.profile,
+            )
+        except Exception as e:
+            log.warning(f"Can't add contact {wid.target}: {e}")
+            return
+        notifs = list(G.host.get_notifs(wid.target, profile=wid.profile))
+        if notifs:
+            item.badge_text = str(len(notifs))
+        item.bind(on_press=partial(self.dispatch, 'on_select'))
+        return item
+
+    def add_opened_chats_items(self):
+        G.host.addListener("widgetNew", self.on_widget_new)
+        G.host.addListener("widgetDeleted", self.on_widget_deleted)
+        self.opened_chats = category_layout = self.add_category_layout(_("Opened chats"))
+        widgets = sorted(G.host.widgets.get_widgets(
+            quick_chat.QuickChat,
+            profiles = G.host.profiles,
+            with_duplicates=False))
+
+        for wid in widgets:
+            item = self.get_item_from_wid(wid)
+            if item is None:
+                continue
+            category_layout.add_widget(item)
+
+    def add_roster_items(self):
+        self.roster = category_layout = self.add_category_layout(_("Your contacts"))
+        for profile in G.host.profiles:
+            contact_list = G.host.contact_lists[profile]
+            for entity_jid in sorted(contact_list.roster):
+                item = self._create_item(
+                    jid=entity_jid,
+                    data=contact_list.get_item(entity_jid),
+                    profile=profile,
+                )
+                item.bind(on_press=partial(self.dispatch, 'on_select'))
+                category_layout.add_widget(item)
+
+    def add_bookmarks_items(self):
+        self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms"))
+        for profile in G.host.profiles:
+            profile_manager = G.host.profiles[profile]
+            try:
+                bookmarks = profile_manager._bookmarks
+            except AttributeError:
+                log.warning(f"no bookmark in cache for profile {profile}")
+                continue
+
+            contact_list = G.host.contact_lists[profile]
+            for entity_jid in bookmarks:
+                try:
+                    cache = contact_list.get_item(entity_jid)
+                except KeyError:
+                    cache = {}
+                item = self._create_item(
+                    jid=entity_jid,
+                    data=cache,
+                    profile=profile,
+                )
+                item.bind(on_press=partial(self.dispatch, 'on_select'))
+                category_layout.add_widget(item)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/common_widgets.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+"""common advanced widgets, which can be reused everywhere."""
+
+from kivy.clock import Clock
+from kivy import properties
+from kivy.metrics import dp
+from kivy.uix.scatterlayout import ScatterLayout
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.label import Label
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+from libervia.desktop_kivy import G
+from .behaviors import TouchMenuItemBehavior
+
+log = logging.getLogger(__name__)
+
+
+class DelayedBoxLayout(BoxLayout):
+    """A BoxLayout with delayed layout, to avoid slowing down during resize"""
+    # XXX: thanks to Alexander Taylor for his blog post at
+    #      https://blog.kivy.org/2019/07/a-delayed-resize-layout-in-kivy/
+
+    do_layout_event = properties.ObjectProperty(None, allownone=True)
+    layout_delay_s = properties.NumericProperty(0.2)
+    #: set this to X to force next X layouts to be done without delay
+    dont_delay_next_layouts = properties.NumericProperty(0)
+
+    def do_layout(self, *args, **kwargs):
+        if self.do_layout_event is not None:
+            self.do_layout_event.cancel()
+        if self.dont_delay_next_layouts>0:
+            self.dont_delay_next_layouts-=1
+            super().do_layout()
+        else:
+            real_do_layout = super().do_layout
+            self.do_layout_event = Clock.schedule_once(
+                lambda dt: real_do_layout(*args, **kwargs),
+                self.layout_delay_s)
+
+
+class Identities(object):
+
+    def __init__(self, entity_ids):
+        identities = {}
+        for cat, type_, name in entity_ids:
+            identities.setdefault(cat, {}).setdefault(type_, []).append(name)
+        client = identities.get('client', {})
+        if 'pc' in client:
+            self.type = 'desktop'
+        elif 'phone' in client:
+            self.type = 'phone'
+        elif 'web' in client:
+            self.type = 'web'
+        elif 'console' in client:
+            self.type = 'console'
+        else:
+            self.type = 'desktop'
+
+        self.identities = identities
+
+    @property
+    def name(self):
+        first_identity = next(iter(self.identities.values()))
+        names = next(iter(first_identity.values()))
+        return names[0]
+
+
+class ItemWidget(TouchMenuItemBehavior, BoxLayout):
+    name = properties.StringProperty()
+    base_width = properties.NumericProperty(dp(100))
+
+
+class DeviceWidget(ItemWidget):
+
+    def __init__(self, main_wid, entity_jid, identities, **kw):
+        self.entity_jid = entity_jid
+        self.identities = identities
+        own_jid = next(iter(G.host.profiles.values())).whoami
+        self.own_device = entity_jid.bare == own_jid.bare
+        if self.own_device:
+            name = self.identities.name
+        elif self.entity_jid.node:
+            name = self.entity_jid.node
+        elif self.entity_jid == own_jid.domain:
+            name = _("your server")
+        else:
+            name = entity_jid
+
+        super(DeviceWidget, self).__init__(name=name, main_wid=main_wid, **kw)
+
+    @property
+    def profile(self):
+        return self.main_wid.profile
+
+    def get_symbol(self):
+        if self.identities.type == 'desktop':
+            return 'desktop'
+        elif self.identities.type == 'phone':
+            return 'mobile'
+        elif self.identities.type == 'web':
+            return 'globe'
+        elif self.identities.type == 'console':
+            return 'terminal'
+        else:
+            return 'desktop'
+
+    def do_item_action(self, touch):
+        pass
+
+
+class CategorySeparator(Label):
+    pass
+
+
+class ImageViewer(ScatterLayout):
+    source = properties.StringProperty()
+
+    def on_touch_down(self, touch):
+        if touch.is_double_tap:
+            self.reset()
+            return True
+        return super().on_touch_down(touch)
+
+    def reset(self):
+        self.rotation = 0
+        self.scale = 1
+        self.x = 0
+        self.y = 0
+
+
+class ImagesGallery(BoxLayout):
+    """Show list of images in a Carousel, with some controls to downloads"""
+    sources = properties.ListProperty()
+    carousel = properties.ObjectProperty()
+    previous_slide = None
+
+    def on_kv_post(self, __):
+        self.on_sources(None, self.sources)
+        self.previous_slide = self.carousel.current_slide
+        self.carousel.bind(current_slide=self.on_slide_change)
+
+    def on_parent(self, __, parent):
+        # we hide the head widget to have full screen
+        G.host.app.show_head_widget(not bool(parent), animation=False)
+
+    def on_sources(self, __, sources):
+        if not sources or not self.carousel:
+            return
+        self.carousel.clear_widgets()
+        for source in sources:
+            img = ImageViewer(
+                source=source,
+            )
+            self.carousel.add_widget(img)
+
+    def on_slide_change(self, __, slide):
+        if isinstance(self.previous_slide, ImageViewer):
+            self.previous_slide.reset()
+
+        self.previous_slide = slide
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            G.host.close_ui()
+            return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/config.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,27 @@
+#!/usr//bin/env python2
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+"""This module keep an open instance of sat configuration"""
+
+from libervia.backend.tools import config
+sat_conf = config.parse_main_conf()
+
+
+def config_get(section, name, default):
+    return config.config_get(sat_conf, section, name, default)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/constants.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+
+# Libervia Desktop-Kivy
+# Copyright (C) 2016-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.frontends.quick_frontend import constants
+from libervia import desktop_kivy
+
+# Kivy must not be imported here due to log hijacking see core/kivy_hack.py
+
+
+class Const(constants.Const):
+    APP_NAME = "Libervia Desktop"
+    APP_COMPONENT = "desktop/mobile"
+    APP_NAME_ALT = "LiberviaDesktopKivy"
+    APP_NAME_FILE = "libervia_desktop"
+    APP_VERSION = desktop_kivy.__version__
+    LOG_OPT_SECTION = APP_NAME.lower()
+    CONFIG_SECTION = "desktop"
+    WID_SELECTOR = 'selector'
+    ICON_SIZES = ('small', 'medium')  # small = 32, medium = 44
+    DEFAULT_WIDGET_ICON = '{media}/misc/black.png'
+
+    BTN_HEIGHT = '35dp'
+
+    PLUG_TYPE_WID = 'wid'
+    PLUG_TYPE_TRANSFER = 'transfer'
+
+    TRANSFER_UPLOAD = "upload"
+    TRANSFER_SEND = "send"
+
+    COLOR_PRIM = (0.98, 0.98, 0.98, 1)
+    COLOR_PRIM_LIGHT = (1, 1, 1, 1)
+    COLOR_PRIM_DARK = (0.78, 0.78, 0.78, 1)
+    COLOR_SEC = (0.27, 0.54, 1.0, 1)
+    COLOR_SEC_LIGHT = (0.51, 0.73, 1.0, 1)
+    COLOR_SEC_DARK = (0.0, 0.37, 0.8, 1)
+
+    COLOR_INFO = COLOR_PRIM_LIGHT
+    COLOR_WARNING = (1.0, 1.0, 0.0, 1)
+    COLOR_ERROR = (1.0, 0.0, 0.0, 1)
+
+    COLOR_BTN_LIGHT = (0.4, 0.4, 0.4, 1)
+
+    # values are in dp
+    IMG_MAX_WIDTH = 400
+    IMG_MAX_HEIGHT = 400
+
+    # files
+    FILE_DEST_DOWNLOAD = "DOWNLOAD"
+    FILE_DEST_CACHE = "CACHE"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/dialog.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,44 @@
+#!/usr//bin/env python2
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+"""generic dialogs"""
+
+from libervia.backend.core.i18n import _
+from libervia.desktop_kivy.core.constants import Const as C
+from kivy.uix.boxlayout import BoxLayout
+from kivy import properties
+from libervia.backend.core import log as logging
+
+log = logging.getLogger(__name__)
+
+
+class MessageDialog(BoxLayout):
+    title = properties.StringProperty()
+    message = properties.StringProperty()
+    level = properties.OptionProperty(C.XMLUI_DATA_LVL_INFO, options=C.XMLUI_DATA_LVLS)
+    close_cb = properties.ObjectProperty()
+
+
+class ConfirmDialog(BoxLayout):
+    title = properties.StringProperty()
+    message = properties.StringProperty(_("Are you sure?"))
+    # callback for no/cancel
+    no_cb = properties.ObjectProperty()
+    # callback for yes/ok
+    yes_cb = properties.ObjectProperty()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/image.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+import mimetypes
+from functools import partial
+from kivy.uix import image as kivy_img
+from libervia.backend.core import log as logging
+from libervia.backend.tools.common import data_format
+from libervia.desktop_kivy import G
+
+log = logging.getLogger(__name__)
+
+
+class Image(kivy_img.Image):
+    """Image widget which accept source without extension"""
+    SVG_CONVERT_EXTRA = {'width': 128, 'height': 128}
+
+    def __init__(self, **kwargs):
+        self.register_event_type('on_error')
+        super().__init__(**kwargs)
+
+    def _image_convert_cb(self, path):
+        self.source = path
+
+    def texture_update(self, *largs):
+        if self.source:
+            if mimetypes.guess_type(self.source, strict=False)[0] == 'image/svg+xml':
+                log.debug(f"Converting SVG image at {self.source} to PNG")
+                G.host.bridge.image_convert(
+                    self.source,
+                    "",
+                    data_format.serialise(self.SVG_CONVERT_EXTRA),
+                    "",
+                    callback=self._image_convert_cb,
+                    errback=partial(
+                        G.host.errback,
+                        message=f"Can't load image at {self.source}: {{msg}}"
+                    )
+                )
+                return
+
+        super().texture_update(*largs)
+        if self.source and self.texture is None:
+            log.warning(
+                f"Image {self.source} has not been imported correctly, replacing by "
+                f"empty one")
+            # FIXME: temporary image, to be replaced by something showing that something
+            #   went wrong
+            self.source = G.host.app.expand(
+                "{media}/misc/borders/border_hollow_black.png")
+            self.dispatch('on_error', Exception(f"Can't load source {self.source}"))
+
+    def on_error(self, err):
+        pass
+
+
+class AsyncImage(kivy_img.AsyncImage):
+    """AsyncImage which accept file:// schema"""
+
+    def _load_source(self, *args):
+        if self.source.startswith('file://'):
+            self.source = self.source[7:]
+        else:
+            super(AsyncImage, self)._load_source(*args)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/kivy_hack.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,70 @@
+#!/usr//bin/env python2
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+CONF_KIVY_LEVEL = 'log_kivy_level'
+
+
+def do_hack():
+    """work around Kivy hijacking of logs and arguments"""
+    # we remove args so kivy doesn't use them
+    # this is need to avoid kivy breaking QuickApp args handling
+    import sys
+    ori_argv = sys.argv[:]
+    sys.argv = sys.argv[:1]
+    from .constants import Const as C
+    from libervia.backend.core import log_config
+    log_config.libervia_configure(C.LOG_BACKEND_STANDARD, C)
+
+    from . import config
+    kivy_level = config.config_get(C.CONFIG_SECTION, CONF_KIVY_LEVEL, 'follow').upper()
+
+    # kivy handles its own loggers, we don't want that!
+    import logging
+    root_logger = logging.root
+    kivy_logger = logging.getLogger('kivy')
+    ori_addHandler = kivy_logger.addHandler
+    kivy_logger.addHandler = lambda __: None
+    ori_setLevel = kivy_logger.setLevel
+    if kivy_level == 'FOLLOW':
+        # level is following SàT level
+        kivy_logger.setLevel = lambda level: None
+    elif kivy_level == 'KIVY':
+        # level will be set by Kivy according to its own conf
+        pass
+    elif kivy_level in C.LOG_LEVELS:
+        kivy_logger.setLevel(kivy_level)
+        kivy_logger.setLevel = lambda level: None
+    else:
+        raise ValueError("Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level))
+
+    # during import kivy set its logging stuff
+    import kivy
+    kivy # to avoid pyflakes warning
+
+    # we want to separate kivy logs from other logs
+    logging.root = root_logger
+    from kivy import logger
+    sys.stderr = logger.previous_stderr
+
+    # we restore original methods
+    kivy_logger.addHandler = ori_addHandler
+    kivy_logger.setLevel = ori_setLevel
+
+    # we restore original arguments
+    sys.argv = ori_argv
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/menu.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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.i18n import _
+from libervia.backend.core import log as logging
+from libervia.desktop_kivy.core.constants import Const as C
+from libervia.desktop_kivy.core.common import JidToggle
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.label import Label
+from kivy.uix.button import Button
+from kivy.uix.popup import Popup
+from .behaviors import FilterBehavior
+from kivy import properties
+from kivy.core.window import Window
+from kivy.animation import Animation
+from kivy.metrics import dp
+from libervia.desktop_kivy import G
+from functools import partial
+import webbrowser
+
+log = logging.getLogger(__name__)
+
+ABOUT_TITLE = _("About {}").format(C.APP_NAME)
+ABOUT_CONTENT = _("""[b]{app_name} ({app_name_alt})[/b]
+
+[u]{app_name} version[/u]:
+{version}
+
+[u]backend version[/u]:
+{backend_version}
+
+{app_name} is a libre communication tool based on libre standard XMPP.
+
+{app_name} is part of the "Libervia" project ({app_component} frontend)
+more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color]
+""")
+
+
+class AboutContent(Label):
+
+    def on_ref_press(self, value):
+        if value == "website":
+            webbrowser.open("https://salut-a-toi.org")
+
+
+class AboutPopup(Popup):
+
+    def on_touch_down(self, touch):
+        if self.collide_point(*touch.pos):
+            self.dismiss()
+        return super(AboutPopup, self).on_touch_down(touch)
+
+
+class TransferItem(BoxLayout):
+    plug_info = properties.DictProperty()
+
+    def on_touch_up(self, touch):
+        if not self.collide_point(*touch.pos):
+            return super(TransferItem, self).on_touch_up(touch)
+        else:
+            transfer_menu = self.parent
+            while not isinstance(transfer_menu, TransferMenu):
+                transfer_menu = transfer_menu.parent
+            transfer_menu.do_callback(self.plug_info)
+            return True
+
+
+class SideMenu(BoxLayout):
+    size_hint_close = (0, 1)
+    size_hint_open = (0.4, 1)
+    size_close = (100, 100)
+    size_open = (0, 0)
+    bg_color = properties.ListProperty([0, 0, 0, 1])
+    # callback will be called with arguments relevant to menu
+    callback = properties.ObjectProperty()
+    # call do_callback even when menu is cancelled
+    callback_on_close = properties.BooleanProperty(False)
+    # cancel callback need to remove the widget for UI
+    # will be called with the widget to remove as argument
+    cancel_cb = properties.ObjectProperty()
+
+    def __init__(self, **kwargs):
+        super(SideMenu, self).__init__(**kwargs)
+        if self.cancel_cb is None:
+            self.cancel_cb = self.on_menu_cancelled
+
+    def _set_anim_kw(self, kw, size_hint, size):
+        """Set animation keywords
+
+        for each value of size_hint it is used if not None,
+        else size is used.
+        If one value of size is bigger than the respective one of Window
+        the one of Window is used
+        """
+        size_hint_x, size_hint_y = size_hint
+        width, height = size
+        if size_hint_x is not None:
+            kw['size_hint_x'] = size_hint_x
+        elif width is not None:
+            kw['width'] = min(width, Window.width)
+
+        if size_hint_y is not None:
+            kw['size_hint_y'] = size_hint_y
+        elif height is not None:
+            kw['height'] = min(height, Window.height)
+
+    def show(self, caller_wid=None):
+        Window.bind(on_keyboard=self.key_input)
+        G.host.app.root.add_widget(self)
+        kw = {'d': 0.3, 't': 'out_back'}
+        self._set_anim_kw(kw, self.size_hint_open, self.size_open)
+        Animation(**kw).start(self)
+
+    def _remove_from_parent(self, anim, menu):
+        # self.parent can already be None if the widget has been removed by a callback
+        # before the animation started.
+        if self.parent is not None:
+            self.parent.remove_widget(self)
+
+    def hide(self):
+        Window.unbind(on_keyboard=self.key_input)
+        kw = {'d': 0.2}
+        self._set_anim_kw(kw, self.size_hint_close, self.size_close)
+        anim = Animation(**kw)
+        anim.bind(on_complete=self._remove_from_parent)
+        anim.start(self)
+        if self.callback_on_close:
+            self.do_callback()
+
+    def on_touch_down(self, touch):
+        # we remove the menu if we click outside
+        # else we want to handle the event, but not
+        # transmit it to parents
+        if not self.collide_point(*touch.pos):
+            self.hide()
+        else:
+            return super(SideMenu, self).on_touch_down(touch)
+        return True
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            self.hide()
+            return True
+
+    def on_menu_cancelled(self, wid, cleaning_cb=None):
+        self._close_ui(wid)
+        if cleaning_cb is not None:
+            cleaning_cb()
+
+    def _close_ui(self, wid):
+        G.host.close_ui()
+
+    def do_callback(self, *args, **kwargs):
+        log.warning("callback not implemented")
+
+
+class ExtraMenuItem(Button):
+    pass
+
+
+class ExtraSideMenu(SideMenu):
+    """Menu with general app actions like showing the about widget"""
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        G.local_platform.on_extra_menu_init(self)
+
+    def add_item(self, label, callback):
+        self.add_widget(
+            ExtraMenuItem(
+                text=label,
+                on_press=partial(self.on_item_press, callback=callback),
+            ),
+            # we want the new item above "About" and last empty Widget
+            index=2)
+
+    def on_item_press(self, *args, callback):
+        self.hide()
+        callback()
+
+    def on_about(self):
+        self.hide()
+        about = AboutPopup()
+        about.title = ABOUT_TITLE
+        about.content = AboutContent(
+            text=ABOUT_CONTENT.format(
+                app_name = C.APP_NAME,
+                app_name_alt = C.APP_NAME_ALT,
+                app_component = C.APP_COMPONENT,
+                backend_version = G.host.backend_version,
+                version=G.host.version
+            ),
+            markup=True)
+        about.open()
+
+
+class TransferMenu(SideMenu):
+    """transfer menu which handle display and callbacks"""
+    # callback will be called with path to file to transfer
+    # profiles if set will be sent to transfer widget, may be used to get specific files
+    profiles = properties.ObjectProperty()
+    transfer_txt = properties.StringProperty()
+    transfer_info = properties.ObjectProperty()
+    upload_btn = properties.ObjectProperty()
+    encrypted = properties.BooleanProperty(False)
+    items_layout = properties.ObjectProperty()
+    size_hint_close = (1, 0)
+    size_hint_open = (1, 0.5)
+
+    def __init__(self, **kwargs):
+        super(TransferMenu, self).__init__(**kwargs)
+        if self.profiles is None:
+            self.profiles = iter(G.host.profiles)
+        for plug_info in G.host.get_plugged_widgets(type_=C.PLUG_TYPE_TRANSFER):
+            item = TransferItem(
+                plug_info = plug_info
+                )
+            self.items_layout.add_widget(item)
+
+    def on_kv_post(self, __):
+        self.update_transfer_info()
+
+    def get_transfer_info(self):
+        if self.upload_btn.state == "down":
+            # upload
+            if self.encrypted:
+                return _(
+                    "The file will be [color=00aa00][b]encrypted[/b][/color] and sent to "
+                    "your server\nServer admin(s) can delete the file, but they won't be "
+                    "able to see its content"
+                )
+            else:
+                return _(
+                    "Beware! The file will be sent to your server and stay "
+                    "[color=ff0000][b]unencrypted[/b][/color] there\nServer admin(s) "
+                    "can see the file, and they choose how, when and if it will be "
+                    "deleted"
+                )
+        else:
+            # P2P
+            if self.encrypted:
+                return _(
+                    "The file will be sent [color=ff0000][b]unencrypted[/b][/color] "
+                    "directly to your contact (it may be transiting by the "
+                    "server if direct connection is not possible).\n[color=ff0000]"
+                    "Please note that end-to-end encryption is not yet implemented for "
+                    "P2P transfer."
+                )
+            else:
+                return _(
+                    "The file will be sent [color=ff0000][b]unencrypted[/b][/color] "
+                    "directly to your contact (it [i]may be[/i] transiting by the "
+                    "server if direct connection is not possible)."
+                )
+
+    def update_transfer_info(self):
+        self.transfer_info.text = self.get_transfer_info()
+
+    def _on_transfer_cb(self, file_path, cleaning_cb=None, external=False, wid_cont=None):
+        if not external:
+            wid = wid_cont[0]
+            self._close_ui(wid)
+        self.callback(
+            file_path,
+            transfer_type = (C.TRANSFER_UPLOAD
+                if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND),
+            cleaning_cb=cleaning_cb,
+        )
+
+    def _check_plugin_permissions_cb(self, plug_info):
+        external = plug_info.get('external', False)
+        wid_cont = []
+        wid_cont.append(plug_info['factory'](
+            plug_info,
+            partial(self._on_transfer_cb, external=external, wid_cont=wid_cont),
+            self.cancel_cb,
+            self.profiles))
+        if not external:
+            G.host.show_extra_ui(wid_cont[0])
+
+    def do_callback(self, plug_info):
+        self.parent.remove_widget(self)
+        if self.callback is None:
+            log.warning("TransferMenu callback is not set")
+        else:
+            G.local_platform.check_plugin_permissions(
+                plug_info,
+                callback=partial(self._check_plugin_permissions_cb, plug_info),
+                errback=lambda: G.host.add_note(
+                    _("permission refused"),
+                    _("this transfer menu can't be used if you refuse the requested "
+                      "permission"),
+                    C.XMLUI_DATA_LVL_WARNING)
+            )
+
+
+class EntitiesSelectorMenu(SideMenu, FilterBehavior):
+    """allow to select entities from roster"""
+    profiles = properties.ObjectProperty()
+    layout = properties.ObjectProperty()
+    instructions = properties.StringProperty(_("Please select entities"))
+    filter_input = properties.ObjectProperty()
+    size_hint_close = (None, 1)
+    size_hint_open = (None, 1)
+    size_open = (dp(250), 100)
+    size_close = (0, 100)
+
+    def __init__(self, **kwargs):
+        super(EntitiesSelectorMenu, self).__init__(**kwargs)
+        self.filter_input.bind(text=self.do_filter_input)
+        if self.profiles is None:
+            self.profiles = iter(G.host.profiles)
+        for profile in self.profiles:
+            for jid_, jid_data in G.host.contact_lists[profile].all_iter:
+                jid_wid = JidToggle(
+                    jid=jid_,
+                    profile=profile)
+                self.layout.add_widget(jid_wid)
+
+    def do_callback(self):
+        if self.callback is not None:
+            jids = [c.jid for c in self.layout.children if c.state == 'down']
+            self.callback(jids)
+
+    def do_filter_input(self, filter_input, text):
+        self.layout.spacing = 0 if text else dp(5)
+        self.do_filter(self.layout,
+                       text,
+                       lambda c: c.jid,
+                       width_cb=lambda c: c.width,
+                       height_cb=lambda c: dp(70))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/patches.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+import urllib.request, urllib.error, urllib.parse
+import ssl
+
+
+def disable_tls_validation():
+    # allow to disable certificate validation
+    ctx_no_verify = ssl.create_default_context()
+    ctx_no_verify.check_hostname = False
+    ctx_no_verify.verify_mode = ssl.CERT_NONE
+
+    class HTTPSHandler(urllib.request.HTTPSHandler):
+        no_certificate_check = False
+
+        def __init__(self, *args, **kwargs):
+            urllib.request._HTTPSHandler_ori.__init__(self, *args, **kwargs)
+            if self.no_certificate_check:
+                self._context = ctx_no_verify
+
+    urllib.request._HTTPSHandler_ori = urllib.request.HTTPSHandler
+    urllib.request.HTTPSHandler = HTTPSHandler
+    urllib.request.HTTPSHandler.no_certificate_check = True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/platform_/__init__.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 kivy import utils as kivy_utils
+
+
+def create():
+    """Factory method to create the platform instance adapted to running one"""
+    if kivy_utils.platform == "android":
+        from .android import Platform
+        return Platform()
+    else:
+        from .base import Platform
+        return Platform()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/platform_/android.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,487 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+import sys
+import os
+import socket
+import json
+from functools import partial
+from urllib.parse import urlparse
+from pathlib import Path
+import shutil
+import mimetypes
+from jnius import autoclass, cast, JavaException
+from android import activity
+from android.permissions import request_permissions, Permission
+from kivy.clock import Clock
+from kivy.uix.label import Label
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+from libervia.backend.tools.common import data_format
+from libervia.frontends.tools import jid
+from libervia.desktop_kivy.core.constants import Const as C
+from libervia.desktop_kivy.core import dialog
+from libervia.desktop_kivy import G
+from .base import Platform as BasePlatform
+
+
+log = logging.getLogger(__name__)
+
+# permission that are necessary to have LiberviaDesktopKivy running properly
+PERMISSION_MANDATORY = [
+    Permission.READ_EXTERNAL_STORAGE,
+    Permission.WRITE_EXTERNAL_STORAGE,
+]
+
+service = autoclass('org.libervia.libervia.desktop_kivy.ServiceBackend')
+PythonActivity = autoclass('org.kivy.android.PythonActivity')
+mActivity = PythonActivity.mActivity
+Intent = autoclass('android.content.Intent')
+AndroidString = autoclass('java.lang.String')
+Uri = autoclass('android.net.Uri')
+ImagesMedia = autoclass('android.provider.MediaStore$Images$Media')
+AudioMedia = autoclass('android.provider.MediaStore$Audio$Media')
+VideoMedia = autoclass('android.provider.MediaStore$Video$Media')
+URLConnection = autoclass('java.net.URLConnection')
+
+DISPLAY_NAME = '_display_name'
+DATA = '_data'
+
+
+STATE_RUNNING = b"running"
+STATE_PAUSED = b"paused"
+STATE_STOPPED = b"stopped"
+SOCKET_DIR = "/data/data/org.libervia.cagou/"
+SOCKET_FILE = ".socket"
+INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction")
+
+
+class Platform(BasePlatform):
+    send_button_visible = True
+
+    def __init__(self):
+        super().__init__()
+        # cache for callbacks to run when profile is plugged
+        self.cache = []
+
+    def init_platform(self):
+        # sys.platform is "linux" on android by default
+        # so we change it to allow backend to detect android
+        sys.platform = "android"
+        C.PLUGIN_EXT = 'pyc'
+
+    def on_host_init(self, host):
+        argument = ''
+        service.start(mActivity, argument)
+
+        activity.bind(on_new_intent=self.on_new_intent)
+        self.cache.append((self.on_new_intent, mActivity.getIntent()))
+        self.last_selected_wid = None
+        self.restore_selected_wid = True
+        host.addListener('profile_plugged', self.on_profile_plugged)
+        host.addListener('selected', self.on_selected_widget)
+        local_dir = Path(host.config_get('', 'local_dir')).resolve()
+        self.tmp_dir = local_dir / 'tmp'
+        # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line
+        # above
+        assert self.tmp_dir.resolve() != local_dir
+        # we reset tmp dir on each run, to be sure that there is no residual file
+        if self.tmp_dir.exists():
+            shutil.rmtree(self.tmp_dir)
+        self.tmp_dir.mkdir(0o700, parents=True)
+
+    def on_init_frontend_state(self):
+        # XXX: we use a separated socket instead of bridge because if we
+        #      try to call a bridge method in on_pause method, the call data
+        #      is not written before the actual pause
+        s = self._frontend_status_socket = socket.socket(
+            socket.AF_UNIX, socket.SOCK_STREAM)
+        s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE))
+        s.sendall(STATE_RUNNING)
+
+    def profile_autoconnect_get_cb(self, profile=None):
+        if profile is not None:
+            G.host.options.profile = profile
+        G.host.post_init()
+
+    def profile_autoconnect_get_eb(self, failure_):
+        log.error(f"Error while getting profile to autoconnect: {failure_}")
+        G.host.post_init()
+
+    def _show_perm_warning(self, permissions):
+        root_wid = G.host.app.root
+        perm_warning = Label(
+            size_hint=(1, 1),
+            text_size=(root_wid.width, root_wid.height),
+            font_size='22sp',
+            bold=True,
+            color=(0.67, 0, 0, 1),
+            halign='center',
+            valign='center',
+            text=_(
+            "Requested permissions are mandatory to run LiberviaDesktopKivy, if you don't "
+            "accept them, LiberviaDesktopKivy can't run properly. Please accept following "
+            "permissions, or set them in Android settings for LiberviaDesktopKivy:\n"
+            "{permissions}\n\nLiberviaDesktopKivy will be closed in 20 s").format(
+                permissions='\n'.join(p.split('.')[-1] for p in permissions)))
+        root_wid.clear_widgets()
+        root_wid.add_widget(perm_warning)
+        Clock.schedule_once(lambda *args: G.host.app.stop(), 20)
+
+    def permission_cb(self, permissions, grant_results):
+        if not all(grant_results):
+            # we keep asking until they are accepted, as we can't run properly
+            # without them
+            # TODO: a message explaining why permission is needed should be printed
+            # TODO: the storage permission is mainly used to set download_dir, we should
+            #   be able to run LiberviaDesktopKivy without it.
+            if not hasattr(self, 'perms_counter'):
+                self.perms_counter = 0
+            self.perms_counter += 1
+            if self.perms_counter > 5:
+                Clock.schedule_once(
+                    lambda *args: self._show_perm_warning(permissions),
+                    0)
+                return
+
+            perm_dict = dict(zip(permissions, grant_results))
+            log.warning(
+                f"not all mandatory permissions are granted, requesting again: "
+                f"{perm_dict}")
+            request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb)
+            return
+
+        Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get(
+            callback=self.profile_autoconnect_get_cb,
+            errback=self.profile_autoconnect_get_eb),
+            0)
+
+    def do_post_init(self):
+        request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb)
+        return False
+
+    def private_data_get_cb(self, data_s, profile):
+        data = data_format.deserialise(data_s, type_check=None)
+        if data is not None and self.restore_selected_wid:
+            log.debug(f"restoring previous widget {data}")
+            try:
+                name = data['name']
+                target = data['target']
+            except KeyError as e:
+                log.error(f"Bad data format for selected widget: {e}\ndata={data}")
+                return
+            if target:
+                target = jid.JID(data['target'])
+            plugin_info = G.host.get_plugin_info(name=name)
+            if plugin_info is None:
+                log.warning("Can't restore unknown plugin: {name}")
+                return
+            factory = plugin_info['factory']
+            G.host.switch_widget(
+                None,
+                factory(plugin_info, target=target, profiles=[profile])
+            )
+
+    def on_profile_plugged(self, profile):
+        log.debug("ANDROID profile_plugged")
+        G.host.bridge.param_set(
+            "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile,
+            callback=lambda: log.info(f"profile {profile} autoconnection set"),
+            errback=lambda: log.error(f"can't set {profile} autoconnection"))
+        for method, *args in self.cache:
+            method(*args)
+        del self.cache
+        G.host.removeListener("profile_plugged", self.on_profile_plugged)
+        # we restore the stored widget if any
+        # user will then go back to where they was when the frontend was closed
+        G.host.bridge.private_data_get(
+            "cagou", "selected_widget", profile,
+            callback=partial(self.private_data_get_cb, profile=profile),
+            errback=partial(
+                G.host.errback,
+                title=_("can't get selected widget"),
+                message=_("error while retrieving selected widget: {msg}"))
+        )
+
+    def on_selected_widget(self, wid):
+        """Store selected widget in backend, to restore it on next startup"""
+        if self.last_selected_wid == None:
+            self.last_selected_wid = wid
+            # we skip the first selected widget, as we'll restore stored one if possible
+            return
+
+        self.last_selected_wid = wid
+
+        try:
+            plugin_info = wid.plugin_info
+        except AttributeError:
+            log.warning(f"No plugin info found for {wid}, can't store selected widget")
+            return
+
+        try:
+            profile = next(iter(wid.profiles))
+        except (AttributeError, StopIteration):
+            profile = None
+
+        if profile is None:
+            try:
+                profile = next(iter(G.host.profiles))
+            except StopIteration:
+                log.debug("No profile plugged yet, can't store selected widget")
+                return
+        try:
+            target = wid.target
+        except AttributeError:
+            target = None
+
+        data = {
+            "name": plugin_info["name"],
+            "target": target,
+        }
+
+        G.host.bridge.private_data_set(
+            "cagou", "selected_widget", data_format.serialise(data), profile,
+            errback=partial(
+                G.host.errback,
+                title=_("can set selected widget"),
+                message=_("error while setting selected widget: {msg}"))
+        )
+
+    def on_pause(self):
+        G.host.sync = False
+        self._frontend_status_socket.sendall(STATE_PAUSED)
+        return True
+
+    def on_resume(self):
+        self._frontend_status_socket.sendall(STATE_RUNNING)
+        G.host.sync = True
+
+    def on_stop(self):
+        self._frontend_status_socket.sendall(STATE_STOPPED)
+        self._frontend_status_socket.close()
+
+    def on_key_back_root(self):
+        PythonActivity.moveTaskToBack(True)
+        return True
+
+    def on_key_back_share(self, share_widget):
+        share_widget.close()
+        PythonActivity.moveTaskToBack(True)
+        return True
+
+    def _disconnect(self, profile):
+        G.host.bridge.param_set(
+            "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile,
+            callback=lambda: log.info(f"profile {profile} autoconnection unset"),
+            errback=lambda: log.error(f"can't unset {profile} autoconnection"))
+        G.host.profiles.unplug(profile)
+        G.host.bridge.disconnect(profile)
+        G.host.app.show_profile_manager()
+        G.host.close_ui()
+
+    def _on_disconnect(self):
+        current_profile = next(iter(G.host.profiles))
+        wid = dialog.ConfirmDialog(
+            title=_("Are you sure to disconnect?"),
+            message=_(
+                "If you disconnect the current user ({profile}), you won't receive "
+                "any notification until you connect it again, is this really what you "
+                "want?").format(profile=current_profile),
+            yes_cb=partial(self._disconnect, profile=current_profile),
+            no_cb=G.host.close_ui,
+        )
+        G.host.show_extra_ui(wid)
+
+    def on_extra_menu_init(self, extra_menu):
+        extra_menu.add_item(_('disconnect'), self._on_disconnect)
+
+    def update_params_extra(self, extra):
+        # on Android, we handle autoconnection automatically,
+        # user must not modify those parameters
+        extra.update(
+            {
+                "ignore": [
+                    ["Connection", "autoconnect_backend"],
+                    ["Connection", "autoconnect"],
+                    ["Connection", "autodisconnect"],
+                ],
+            }
+        )
+
+    def get_col_data_from_uri(self, uri, col_name):
+        cursor = mActivity.getContentResolver().query(uri, None, None, None, None)
+        if cursor is None:
+            return None
+        try:
+            cursor.moveToFirst()
+            col_idx = cursor.getColumnIndex(col_name);
+            if col_idx == -1:
+                return None
+            return cursor.getString(col_idx)
+        finally:
+            cursor.close()
+
+    def get_filename_from_uri(self, uri, media_type):
+        filename = self.get_col_data_from_uri(uri, DISPLAY_NAME)
+        if filename is None:
+            uri_p = Path(uri.toString())
+            filename = uri_p.name or "unnamed"
+            if not uri_p.suffix and media_type:
+                suffix = mimetypes.guess_extension(media_type, strict=False)
+                if suffix:
+                    filename = filename + suffix
+        return filename
+
+    def get_path_from_uri(self, uri):
+        # FIXME: using DATA is not recommended (and DATA is deprecated)
+        # we should read directly the file with
+        # ContentResolver#openFileDescriptor(Uri, String)
+        path = self.get_col_data_from_uri(uri, DATA)
+        return uri.getPath() if path is None else path
+
+    def on_new_intent(self, intent):
+        log.debug("on_new_intent")
+        action = intent.getAction();
+        intent_type = intent.getType();
+        if action == Intent.ACTION_MAIN:
+            action_str = intent.getStringExtra(INTENT_EXTRA_ACTION)
+            if action_str is not None:
+                action = json.loads(action_str)
+                log.debug(f"Extra action found: {action}")
+                action_type = action.get('type')
+                if action_type == "open":
+                    try:
+                        widget = action['widget']
+                        target = action['target']
+                    except KeyError as e:
+                        log.warning(f"incomplete action {action}: {e}")
+                    else:
+                        # we don't want stored selected widget to be displayed after this
+                        # one
+                        log.debug("cancelling restoration of previous widget")
+                        self.restore_selected_wid = False
+                        # and now we open the widget linked to the intent
+                        current_profile = next(iter(G.host.profiles))
+                        Clock.schedule_once(
+                            lambda *args: G.host.do_action(
+                                widget, jid.JID(target), [current_profile]),
+                            0)
+                else:
+                    log.warning(f"unexpected action: {action}")
+
+            text = None
+            uri = None
+            path = None
+        elif action == Intent.ACTION_SEND:
+            # we have receiving data to share, we parse the intent data
+            # and show the share widget
+            data = {}
+            text = intent.getStringExtra(Intent.EXTRA_TEXT)
+            if text is not None:
+                data['text'] = text
+
+            item = intent.getParcelableExtra(Intent.EXTRA_STREAM)
+            if item is not None:
+                uri = cast('android.net.Uri', item)
+                if uri.getScheme() == 'content':
+                    # Android content, we'll dump it to a temporary file
+                    filename = self.get_filename_from_uri(uri, intent_type)
+                    filepath = self.tmp_dir / filename
+                    input_stream = mActivity.getContentResolver().openInputStream(uri)
+                    buff = bytearray(4096)
+                    with open(filepath, 'wb') as f:
+                        while True:
+                            ret = input_stream.read(buff, 0, 4096)
+                            if ret != -1:
+                                f.write(buff[:ret])
+                            else:
+                                break
+                    input_stream.close()
+                    data['path'] = path = str(filepath)
+                else:
+                    data['uri'] = uri.toString()
+                    path = self.get_path_from_uri(uri)
+                    if path is not None and path not in data:
+                        data['path'] = path
+            else:
+                uri = None
+                path = None
+
+
+            Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0)
+        else:
+            text = None
+            uri = None
+            path = None
+
+        msg = (f"NEW INTENT RECEIVED\n"
+               f"type: {intent_type}\n"
+               f"action: {action}\n"
+               f"text: {text}\n"
+               f"uri: {uri}\n"
+               f"path: {path}")
+
+        log.debug(msg)
+
+    def check_plugin_permissions(self, plug_info, callback, errback):
+        perms = plug_info.get("android_permissons")
+        if not perms:
+            callback()
+            return
+        perms = [f"android.permission.{p}" if '.' not in p else p for p in perms]
+
+        def request_permissions_cb(permissions, granted):
+            if all(granted):
+                Clock.schedule_once(lambda *args: callback())
+            else:
+                Clock.schedule_once(lambda *args: errback())
+
+        request_permissions(perms, callback=request_permissions_cb)
+
+    def open_url(self, url, wid=None):
+        parsed_url = urlparse(url)
+        if parsed_url.scheme == "aesgcm":
+            return super().open_url(url, wid)
+        else:
+            media_type = mimetypes.guess_type(url, strict=False)[0]
+            if media_type is None:
+                log.debug(
+                    f"media_type for {url!r} not found with python mimetypes, trying "
+                    f"guessContentTypeFromName")
+                media_type = URLConnection.guessContentTypeFromName(url)
+            intent = Intent(Intent.ACTION_VIEW)
+            if media_type is not None:
+                log.debug(f"file {url!r} is of type {media_type}")
+                intent.setDataAndType(Uri.parse(url), media_type)
+            else:
+                log.debug(f"can't guess media type for {url!r}")
+                intent.setData(Uri.parse(url))
+            if mActivity.getPackageManager() is not None:
+                activity = cast('android.app.Activity', mActivity)
+                try:
+                    activity.startActivity(intent)
+                except JavaException as e:
+                    if e.classname != "android.content.ActivityNotFoundException":
+                        raise e
+                    log.debug(
+                        f"activity not found for url {url!r}, we'll try generic opener")
+                else:
+                    return
+
+        # if nothing else worked, we default to base open_url
+        super().open_url(url, wid)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/platform_/base.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+import sys
+import webbrowser
+import subprocess
+import shutil
+from urllib import parse
+from kivy.config import Config as KivyConfig
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend.quick_widgets import QuickWidget
+from libervia.desktop_kivy import G
+
+
+log = getLogger(__name__)
+
+
+class Platform:
+    """Base class to handle platform specific behaviours"""
+    # set to True to always show the send button in chat
+    send_button_visible = False
+
+    def init_platform(self):
+        # we don't want multi-touch emulation with mouse
+
+        # this option doesn't make sense on Android and cause troubles, so we only
+        # activate it for other platforms (cf. https://github.com/kivy/kivy/issues/6229)
+        KivyConfig.set('input', 'mouse', 'mouse,disable_multitouch')
+
+    def on_app_build(self, Wid):
+        pass
+
+    def on_host_init(self, host):
+        pass
+
+    def on_init_frontend_state(self):
+        pass
+
+    def do_post_init(self):
+        return True
+
+    def on_pause(self):
+        pass
+
+    def on_resume(self):
+        pass
+
+    def on_stop(self):
+        pass
+
+    def on_key_back_root(self):
+        """Back key is called while being on root widget"""
+        return True
+
+    def on_key_back_share(self, share_widget):
+        """Back key is called while being on share widget"""
+        share_widget.close()
+        return True
+
+    def _on_new_window(self):
+        """Launch a new instance of LiberviaDesktopKivy to have an extra window"""
+        subprocess.Popen(sys.argv)
+
+    def on_extra_menu_init(self, extra_menu):
+        extra_menu.add_item(_('new window'), self._on_new_window)
+
+    def update_params_extra(self, extra):
+        pass
+
+    def check_plugin_permissions(self, plug_info, callback, errback):
+        """Check that plugin permissions for this platform are granted"""
+        callback()
+
+    def _open(self, path):
+        """Open url or path with appropriate application if possible"""
+        try:
+            opener = self._opener
+        except AttributeError:
+            xdg_open_path = shutil.which("xdg-open")
+            if xdg_open_path is not None:
+                log.debug("xdg-open found, it will be used to open files")
+                opener = lambda path: subprocess.Popen([xdg_open_path, path])
+            else:
+                log.debug("files will be opened with webbrower.open")
+                opener = webbrowser.open
+            self._opener = opener
+
+        opener(path)
+
+
+    def open_url(self, url, wid=None):
+        """Open an URL in the way appropriate for the platform
+
+        @param url(str): URL to open
+        @param wid(LiberviaDesktopKivyWidget, None): widget requesting the opening
+            it may influence the way the URL is opened
+        """
+        parsed_url = parse.urlparse(url)
+        if parsed_url.scheme == "aesgcm" and wid is not None:
+            # aesgcm files need to be decrypted first
+            # so we download them before opening
+            quick_widget = G.host.get_ancestor_widget(wid, QuickWidget)
+            if quick_widget is None:
+                msg = f"Can't find ancestor QuickWidget of {wid}"
+                log.error(msg)
+                G.host.errback(exceptions.InternalError(msg))
+                return
+            G.host.download_url(
+                parsed_url, self.open_url, G.host.errback, profile=quick_widget.profile
+            )
+        else:
+            self._open(url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/profile_manager.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from .constants import Const as C
+from libervia.frontends.quick_frontend.quick_profile_manager import QuickProfileManager
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.togglebutton import ToggleButton
+from kivy.uix.screenmanager import ScreenManager, Screen
+from kivy.metrics import sp
+from kivy import properties
+from libervia.desktop_kivy import G
+
+
+class ProfileItem(ToggleButton):
+    ps = properties.ObjectProperty()
+    index = properties.NumericProperty(0)
+
+
+class NewProfileScreen(Screen):
+    profile_name = properties.ObjectProperty(None)
+    jid = properties.ObjectProperty(None)
+    password = properties.ObjectProperty(None)
+    error_msg = properties.StringProperty('')
+
+    def __init__(self, pm):
+        super(NewProfileScreen, self).__init__(name='new_profile')
+        self.pm = pm
+
+    def on_creation_failure(self, failure):
+        msg = [l for l in str(failure).split('\n') if l][-1]
+        self.error_msg = str(msg)
+
+    def on_creation_success(self, profile):
+        self.pm.profiles_screen.reload()
+        G.host.bridge.profile_start_session(
+            self.password.text, profile,
+            callback=lambda __: self._session_started(profile),
+            errback=self.on_creation_failure)
+
+    def _session_started(self, profile):
+        jid = self.jid.text.strip()
+        G.host.bridge.param_set("JabberID", jid, "Connection", -1, profile)
+        G.host.bridge.param_set("Password", self.password.text, "Connection", -1, profile)
+        self.pm.screen_manager.transition.direction = 'right'
+        self.pm.screen_manager.current = 'profiles'
+
+    def doCreate(self):
+        name = self.profile_name.text.strip()
+        # XXX: we use XMPP password for profile password to simplify
+        #      if user want to change profile password, he can do it in preferences
+        G.host.bridge.profile_create(
+            name, self.password.text, '',
+            callback=lambda: self.on_creation_success(name),
+            errback=self.on_creation_failure)
+
+
+class DeleteProfilesScreen(Screen):
+
+    def __init__(self, pm):
+        self.pm = pm
+        super(DeleteProfilesScreen, self).__init__(name='delete_profiles')
+
+    def do_delete(self):
+        """This method will delete *ALL* selected profiles"""
+        to_delete = self.pm.get_profiles()
+        deleted = [0]
+
+        def delete_inc():
+            deleted[0] += 1
+            if deleted[0] == len(to_delete):
+                self.pm.profiles_screen.reload()
+                self.pm.screen_manager.transition.direction = 'right'
+                self.pm.screen_manager.current = 'profiles'
+
+        for profile in to_delete:
+            log.info("Deleteing profile [{}]".format(profile))
+            G.host.bridge.profile_delete_async(
+                profile, callback=delete_inc, errback=delete_inc)
+
+
+class ProfilesScreen(Screen):
+    layout = properties.ObjectProperty(None)
+    profiles = properties.ListProperty()
+
+    def __init__(self, pm):
+        self.pm = pm
+        super(ProfilesScreen, self).__init__(name='profiles')
+        self.reload()
+
+    def _profiles_list_get_cb(self, profiles):
+        profiles.sort()
+        self.profiles = profiles
+        for idx, profile in enumerate(profiles):
+            item = ProfileItem(ps=self, index=idx, text=profile, group='profiles')
+            self.layout.add_widget(item)
+
+    def converter(self, row_idx, obj):
+        return {'text': obj,
+                'size_hint_y': None,
+                'height': sp(40)}
+
+    def reload(self):
+        """Reload profiles list"""
+        self.layout.clear_widgets()
+        G.host.bridge.profiles_list_get(callback=self._profiles_list_get_cb)
+
+
+class ProfileManager(QuickProfileManager, BoxLayout):
+    selected = properties.ObjectProperty(None)
+
+    def __init__(self, autoconnect=None):
+        QuickProfileManager.__init__(self, G.host, autoconnect)
+        BoxLayout.__init__(self, orientation="vertical")
+        self.screen_manager = ScreenManager()
+        self.profiles_screen = ProfilesScreen(self)
+        self.new_profile_screen = NewProfileScreen(self)
+        self.delete_profiles_screen = DeleteProfilesScreen(self)
+        self.xmlui_screen = Screen(name='xmlui')
+        self.screen_manager.add_widget(self.profiles_screen)
+        self.screen_manager.add_widget(self.xmlui_screen)
+        self.screen_manager.add_widget(self.new_profile_screen)
+        self.screen_manager.add_widget(self.delete_profiles_screen)
+        self.add_widget(self.screen_manager)
+
+    def close_ui(self, xmlui, reason=None):
+        self.screen_manager.transition.direction = 'right'
+        self.screen_manager.current = 'profiles'
+
+    def show_ui(self, xmlui):
+        xmlui.set_close_cb(self.close_ui)
+        if xmlui.type == 'popup':
+            xmlui.bind(on_touch_up=lambda obj, value: self.close_ui(xmlui))
+        self.xmlui_screen.clear_widgets()
+        self.xmlui_screen.add_widget(xmlui)
+        self.screen_manager.transition.direction = 'left'
+        self.screen_manager.current = 'xmlui'
+
+    def select_profile(self, profile_item):
+        if not profile_item.selected:
+            return
+        def authenticate_cb(data, cb_id, profile):
+            if not C.bool(data.pop('validated', C.BOOL_FALSE)):
+                # profile didn't validate, we unselect it
+                profile_item.state = 'normal'
+                self.selected = ''
+            else:
+                # state may have been modified so we need to be sure it's down
+                profile_item.state = 'down'
+                self.selected = profile_item
+            G.host.action_manager(data, callback=authenticate_cb, ui_show_cb=self.show_ui,
+                                 profile=profile)
+
+        G.host.action_launch(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb,
+                            profile=profile_item.text)
+
+    def get_profiles(self):
+        # for now we restrict to a single profile in LiberviaDesktopKivy
+        # TODO: handle multi-profiles
+        return [self.selected.text] if self.selected else []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/share_widget.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 pathlib import Path
+from functools import partial
+from libervia.backend.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.tools import jid
+from kivy.uix.boxlayout import BoxLayout
+from kivy.properties import StringProperty, DictProperty, ObjectProperty
+from kivy.metrics import dp
+from .constants import Const as C
+from libervia.desktop_kivy import G
+
+
+log = logging.getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    "name": _("share"),
+    "main": "Share",
+    "description": _("share a file"),
+    "icon_symbol": "share",
+}
+
+
+class TextPreview(BoxLayout):
+    """Widget previewing shared text"""
+    text = StringProperty()
+
+
+class ImagePreview(BoxLayout):
+    """Widget previewing shared image"""
+    path = StringProperty()
+    reduce_layout = ObjectProperty()
+    reduce_checkbox = ObjectProperty()
+
+    def _check_image_cb(self, report_raw):
+        self.report = data_format.deserialise(report_raw)
+        if self.report['too_large']:
+            self.reduce_layout.opacity = 1
+            self.reduce_layout.height = self.reduce_layout.minimum_height + dp(10)
+            self.reduce_layout.padding = [0, dp(5)]
+
+    def _check_image_eb(self, failure_):
+        log.error(f"Can't check image: {failure_}")
+
+    def on_path(self, wid, path):
+        G.host.bridge.image_check(
+            path, callback=self._check_image_cb, errback=self._check_image_eb)
+
+    def resize_image(self, data, callback, errback):
+
+        def image_resize_cb(new_path):
+            new_path = Path(new_path)
+            log.debug(f"image {data['path']} resized at {new_path}")
+            data['path'] = new_path
+            data['cleaning_cb'] = lambda: new_path.unlink()
+            callback(data)
+
+        path = data['path']
+        width, height = self.report['recommended_size']
+        G.host.bridge.image_resize(
+            path, width, height,
+            callback=image_resize_cb,
+            errback=errback
+        )
+
+    def get_filter(self):
+        if self.report['too_large'] and self.reduce_checkbox.active:
+            return self.resize_image
+        else:
+            return lambda data, callback, errback: callback(data)
+
+
+class GenericPreview(BoxLayout):
+    """Widget previewing shared image"""
+    path = StringProperty()
+
+
+class ShareWidget(BoxLayout):
+    media_type = StringProperty()
+    data = DictProperty()
+    preview_box = ObjectProperty()
+
+    def on_kv_post(self, wid):
+        self.type, self.subtype = self.media_type.split('/')
+        if self.type == 'text' and 'text' in self.data:
+            self.preview_box.add_widget(TextPreview(text=self.data['text']))
+        elif self.type == 'image':
+            self.preview_box.add_widget(ImagePreview(path=self.data['path']))
+        else:
+            self.preview_box.add_widget(GenericPreview(path=self.data['path']))
+
+    def close(self):
+        G.host.close_ui()
+
+    def get_filtered_data(self, callback, errback):
+        """Apply filter if suitable, and call callback with with modified data"""
+        try:
+            get_filter = self.preview_box.children[0].get_filter
+        except AttributeError:
+            callback(self.data)
+        else:
+            filter_ = get_filter()
+            filter_(self.data, callback=callback, errback=errback)
+
+    def filter_data_cb(self, data, contact_jid, profile):
+        chat_wid = G.host.do_action('chat', contact_jid, [profile])
+
+        if self.type == 'text' and 'text' in self.data:
+            text = self.data['text']
+            chat_wid.message_input.text += text
+        else:
+            path = self.data['path']
+            chat_wid.transfer_file(path, cleaning_cb=data.get('cleaning_cb'))
+        self.close()
+
+    def filter_data_eb(self, failure_):
+        G.host.add_note(
+            _("file filter error"),
+            _("Can't apply filter to file: {msg}").format(msg=failure_),
+            level=C.XMLUI_DATA_LVL_ERROR)
+
+    def on_select(self, contact_button):
+        contact_jid = jid.JID(contact_button.jid)
+        self.get_filtered_data(
+            partial(
+                self.filter_data_cb,
+                contact_jid=contact_jid,
+                profile=contact_button.profile),
+            self.filter_data_eb
+        )
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            return G.local_platform.on_key_back_share(self)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/simple_xhtml.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 xml.etree import ElementTree as ET
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.label import Label
+from kivy.utils import escape_markup
+from kivy.metrics import sp
+from kivy import properties
+from libervia.backend.core import log as logging
+from libervia.frontends.tools import css_color, strings as sat_strings
+from libervia.desktop_kivy import G
+from libervia.desktop_kivy.core.common  import SizedImage
+
+
+log = logging.getLogger(__name__)
+
+
+class Escape(str):
+    """Class used to mark that a message need to be escaped"""
+
+
+class SimpleXHTMLWidgetEscapedText(Label):
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            self.font_size = parent.font_size
+
+    def _add_url_markup(self, text):
+        text_elts = []
+        idx = 0
+        links = 0
+        while True:
+            m = sat_strings.RE_URL.search(text[idx:])
+            if m is not None:
+                text_elts.append(escape_markup(m.string[0:m.start()]))
+                link_key = 'link_' + str(links)
+                url = m.group()
+                escaped_url = escape_markup(url)
+                text_elts.append(
+                    f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]')
+                if not links:
+                    self.ref_urls = {link_key: url}
+                else:
+                    self.ref_urls[link_key] = url
+                links += 1
+                idx += m.end()
+            else:
+                if links:
+                    text_elts.append(escape_markup(text[idx:]))
+                    self.markup = True
+                    self.text = ''.join(text_elts)
+                break
+
+    def on_text(self, instance, text):
+        # do NOT call the method if self.markup is set
+        # this would result in infinite loop (because self.text
+        # is changed if an URL is found, and in this case markup too)
+        if text and not self.markup:
+            self._add_url_markup(text)
+
+    def on_ref_press(self, ref):
+        url = self.ref_urls[ref]
+        G.local_platform.open_url(url, self)
+
+
+class SimpleXHTMLWidgetText(Label):
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            self.font_size = parent.font_size
+
+
+class SimpleXHTMLWidget(StackLayout):
+    """widget handling simple XHTML parsing"""
+    xhtml = properties.StringProperty()
+    color = properties.ListProperty([1, 1, 1, 1])
+    # XXX: bold is only used for escaped text
+    bold = properties.BooleanProperty(False)
+    font_size = properties.NumericProperty(sp(14))
+
+    # text/XHTML input
+
+    def on_xhtml(self, instance, xhtml):
+        """parse xhtml and set content accordingly
+
+        if xhtml is an instance of Escape, a Label with no markup will be used
+        """
+        self.clear_widgets()
+        if isinstance(xhtml, Escape):
+            label = SimpleXHTMLWidgetEscapedText(
+                text=xhtml, color=self.color, bold=self.bold)
+            self.bind(font_size=label.setter('font_size'))
+            self.bind(color=label.setter('color'))
+            self.bind(bold=label.setter('bold'))
+            self.add_widget(label)
+        else:
+            xhtml = ET.fromstring(xhtml.encode())
+            self.current_wid = None
+            self.styles = []
+            self._call_parse_method(xhtml)
+        if len(self.children) > 1:
+            self._do_split_labels()
+
+    def escape(self, text):
+        """mark that a text need to be escaped (i.e. no markup)"""
+        return Escape(text)
+
+    def _do_split_labels(self):
+        """Split labels so their content can flow with images"""
+        # XXX: to make things easier, we split labels in words
+        log.debug("labels splitting start")
+        children = self.children[::-1]
+        self.clear_widgets()
+        for child in children:
+            if isinstance(child, Label):
+                log.debug("label before split: {}".format(child.text))
+                styles = []
+                tag = False
+                new_text = []
+                current_tag = []
+                current_value = []
+                current_wid = self._create_text()
+                value = False
+                close = False
+                # we will parse the text and create a new widget
+                # on each new word (actually each space)
+                # FIXME: handle '\n' and other white chars
+                for c in child.text:
+                    if tag:
+                        # we are parsing a markup tag
+                        if c == ']':
+                            current_tag_s = ''.join(current_tag)
+                            current_style = (current_tag_s, ''.join(current_value))
+                            if close:
+                                for idx, s in enumerate(reversed(styles)):
+                                    if s[0] == current_tag_s:
+                                        del styles[len(styles) - idx - 1]
+                                        break
+                            else:
+                                styles.append(current_style)
+                            current_tag = []
+                            current_value = []
+                            tag = False
+                            value = False
+                            close = False
+                        elif c == '/':
+                            close = True
+                        elif c == '=':
+                            value = True
+                        elif value:
+                            current_value.append(c)
+                        else:
+                            current_tag.append(c)
+                        new_text.append(c)
+                    else:
+                        # we are parsing regular text
+                        if c == '[':
+                            new_text.append(c)
+                            tag = True
+                        elif c == ' ':
+                            # new word, we do a new widget
+                            new_text.append(' ')
+                            for t, v in reversed(styles):
+                                new_text.append('[/{}]'.format(t))
+                            current_wid.text = ''.join(new_text)
+                            new_text = []
+                            self.add_widget(current_wid)
+                            log.debug("new widget: {}".format(current_wid.text))
+                            current_wid = self._create_text()
+                            for t, v in styles:
+                                new_text.append('[{tag}{value}]'.format(
+                                    tag = t,
+                                    value = '={}'.format(v) if v else ''))
+                        else:
+                            new_text.append(c)
+                if current_wid.text:
+                    # we may have a remaining widget after the parsing
+                    close_styles = []
+                    for t, v in reversed(styles):
+                        close_styles.append('[/{}]'.format(t))
+                    current_wid.text = ''.join(close_styles)
+                    self.add_widget(current_wid)
+                    log.debug("new widget: {}".format(current_wid.text))
+            else:
+                # non Label widgets, we just add them
+                self.add_widget(child)
+        self.splitted = True
+        log.debug("split OK")
+
+    # XHTML parsing methods
+
+    def _call_parse_method(self, e):
+        """Call the suitable method to parse the element
+
+        self.xhtml_[tag] will be called if it exists, else
+        self.xhtml_generic will be used
+        @param e(ET.Element): element to parse
+        """
+        try:
+            method = getattr(self, f"xhtml_{e.tag}")
+        except AttributeError:
+            log.warning(f"Unhandled XHTML tag: {e.tag}")
+            method = self.xhtml_generic
+        method(e)
+
+    def _add_style(self, tag, value=None, append_to_list=True):
+        """add a markup style to label
+
+        @param tag(unicode): markup tag
+        @param value(unicode): markup value if suitable
+        @param append_to_list(bool): if True style we be added to self.styles
+            self.styles is needed to keep track of styles to remove
+            should most probably be set to True
+        """
+        label = self._get_label()
+        label.text += '[{tag}{value}]'.format(
+            tag = tag,
+            value = '={}'.format(value) if value else ''
+            )
+        if append_to_list:
+            self.styles.append((tag, value))
+
+    def _remove_style(self, tag, remove_from_list=True):
+        """remove a markup style from the label
+
+        @param tag(unicode): markup tag to remove
+        @param remove_from_list(bool): if True, remove from self.styles too
+            should most probably be set to True
+        """
+        label = self._get_label()
+        label.text += '[/{tag}]'.format(
+            tag = tag
+            )
+        if remove_from_list:
+            for rev_idx, style in enumerate(reversed(self.styles)):
+                if style[0] == tag:
+                    tag_idx = len(self.styles) - 1 - rev_idx
+                    del self.styles[tag_idx]
+                    break
+
+    def _get_label(self):
+        """get current Label if it exists, or create a new one"""
+        if not isinstance(self.current_wid, Label):
+            self._add_label()
+        return self.current_wid
+
+    def _add_label(self):
+        """add a new Label
+
+        current styles will be closed and reopened if needed
+        """
+        self._close_label()
+        self.current_wid = self._create_text()
+        for tag, value in self.styles:
+            self._add_style(tag, value, append_to_list=False)
+        self.add_widget(self.current_wid)
+
+    def _create_text(self):
+        label = SimpleXHTMLWidgetText(color=self.color, markup=True)
+        self.bind(color=label.setter('color'))
+        label.bind(texture_size=label.setter('size'))
+        return label
+
+    def _close_label(self):
+        """close current style tags in current label
+
+        needed when you change label to keep style between
+        different widgets
+        """
+        if isinstance(self.current_wid, Label):
+            for tag, value in reversed(self.styles):
+                self._remove_style(tag, remove_from_list=False)
+
+    def _parse_css(self, e):
+        """parse CSS found in "style" attribute of element
+
+        self._css_styles will be created and contained markup styles added by this method
+        @param e(ET.Element): element which may have a "style" attribute
+        """
+        styles_limit = len(self.styles)
+        styles = e.attrib['style'].split(';')
+        for style in styles:
+            try:
+                prop, value = style.split(':')
+            except ValueError:
+                log.warning(f"can't parse style: {style}")
+                continue
+            prop = prop.strip().replace('-', '_')
+            value = value.strip()
+            try:
+                method = getattr(self, f"css_{prop}")
+            except AttributeError:
+                log.warning(f"Unhandled CSS: {prop}")
+            else:
+                method(e, value)
+        self._css_styles = self.styles[styles_limit:]
+
+    def _close_css(self):
+        """removed CSS styles
+
+        styles in self._css_styles will be removed
+        and the attribute will be deleted
+        """
+        for tag, __ in reversed(self._css_styles):
+            self._remove_style(tag)
+        del self._css_styles
+
+    def xhtml_generic(self, elem, style=True, markup=None):
+        """Generic method for adding HTML elements
+
+        this method handle content, style and children parsing
+        @param elem(ET.Element): element to add
+        @param style(bool): if True handle style attribute (CSS)
+        @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
+        """
+        # we first add markup and CSS style
+        if markup is not None:
+            if isinstance(markup, str):
+                tag, value = markup, None
+            else:
+                tag, value = markup
+            self._add_style(tag, value)
+        style_ = 'style' in elem.attrib and style
+        if style_:
+            self._parse_css(elem)
+
+        # then content
+        if elem.text:
+            self._get_label().text += escape_markup(elem.text)
+
+        # we parse the children
+        for child in elem:
+            self._call_parse_method(child)
+
+        # closing CSS style and markup
+        if style_:
+            self._close_css()
+        if markup is not None:
+            self._remove_style(tag)
+
+        # and the tail, which is regular text
+        if elem.tail:
+            self._get_label().text += escape_markup(elem.tail)
+
+    # method handling XHTML elements
+
+    def xhtml_br(self, elem):
+        label = self._get_label()
+        label.text+='\n'
+        self.xhtml_generic(elem, style=False)
+
+    def xhtml_em(self, elem):
+        self.xhtml_generic(elem, markup='i')
+
+    def xhtml_img(self, elem):
+        try:
+            src = elem.attrib['src']
+        except KeyError:
+            log.warning("<img> element without src: {}".format(ET.tostring(elem)))
+            return
+        try:
+            target_height = int(elem.get('height', 0))
+        except ValueError:
+            log.warning(f"Can't parse image height: {elem.get('height')}")
+            target_height = None
+        try:
+            target_width = int(elem.get('width', 0))
+        except ValueError:
+            log.warning(f"Can't parse image width: {elem.get('width')}")
+            target_width = None
+
+        img = SizedImage(
+            source=src, target_height=target_height, target_width=target_width)
+        self.current_wid = img
+        self.add_widget(img)
+
+    def xhtml_p(self, elem):
+        if isinstance(self.current_wid, Label):
+            self.current_wid.text+="\n\n"
+        self.xhtml_generic(elem)
+
+    def xhtml_span(self, elem):
+        self.xhtml_generic(elem)
+
+    def xhtml_strong(self, elem):
+        self.xhtml_generic(elem, markup='b')
+
+    # methods handling CSS properties
+
+    def css_color(self, elem, value):
+        self._add_style("color", css_color.parse(value))
+
+    def css_text_decoration(self, elem, value):
+        if value == 'underline':
+            self._add_style('u')
+        elif value == 'line-through':
+            self._add_style('s')
+        else:
+            log.warning("unhandled text decoration: {}".format(value))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/widgets_handler.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend import quick_widgets
+from kivy.graphics import Color, Ellipse
+from kivy.uix.layout import Layout
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.stencilview import StencilView
+from kivy.uix.carousel import Carousel
+from kivy.uix.screenmanager import ScreenManager, Screen
+from kivy.metrics import dp
+from kivy import properties
+from libervia.desktop_kivy import G
+from .constants import Const as C
+from . import cagou_widget
+
+log = logging.getLogger(__name__)
+
+
+REMOVE_WID_LIMIT = dp(50)
+MIN_WIDTH = MIN_HEIGHT = dp(70)
+
+
+class BoxStencil(BoxLayout, StencilView):
+    pass
+
+
+class WHWrapper(BoxLayout):
+    main_container = properties.ObjectProperty(None)
+    screen_manager = properties.ObjectProperty(None, allownone=True)
+    carousel = properties.ObjectProperty(None, allownone=True)
+    split_size = properties.NumericProperty(dp(1))
+    split_margin = properties.NumericProperty(dp(2))
+    split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
+    split_color_move = C.COLOR_SEC_DARK
+    split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1])
+    # sp stands for "split point"
+    sp_size = properties.NumericProperty(dp(1))
+    sp_space = properties.NumericProperty(dp(4))
+    sp_zone = properties.NumericProperty(dp(30))
+    _split = properties.OptionProperty('None', options=['None', 'left', 'top'])
+    _split_del = properties.BooleanProperty(False)
+
+    def __init__(self, **kwargs):
+        idx = kwargs.pop('_wid_idx')
+        self._wid_idx = idx
+        super(WHWrapper, self).__init__(**kwargs)
+        self._left_wids = set()
+        self._top_wids = set()
+        self._right_wids = set()
+        self._bottom_wids = set()
+        self._clear_attributes()
+
+    def _clear_attributes(self):
+        self._former_slide = None
+
+    def __repr__(self):
+        return "WHWrapper_{idx}".format(idx=self._wid_idx)
+
+    def _main_wid(self, wid_list):
+        """return main widget of a side list
+
+        main widget is either the widget currently splitted
+        or any widget if none is split
+        @return (WHWrapper, None): main widget or None
+            if there is not widget
+        """
+        if not wid_list:
+            return None
+        for wid in wid_list:
+            if wid._split != 'None':
+                return wid
+        return next(iter(wid_list))
+
+    def on_parent(self, __, new_parent):
+        if new_parent is None:
+            # we detach all children so LiberviaDesktopKivyWidget.whwrapper won't link to this one
+            # anymore
+            self.clear_widgets()
+
+    @property
+    def _left_wid(self):
+        return self._main_wid(self._left_wids)
+
+    @property
+    def _top_wid(self):
+        return self._main_wid(self._top_wids)
+
+    @property
+    def _right_wid(self):
+        return self._main_wid(self._right_wids)
+
+    @property
+    def _bottom_wid(self):
+        return self._main_wid(self._bottom_wids)
+
+    @property
+    def current_slide(self):
+        if (self.carousel is not None
+            and (self.screen_manager is None or self.screen_manager.current == '')):
+            return self.carousel.current_slide
+        elif self.screen_manager is not None:
+            # we should have exactly one children in current_screen, else there is a bug
+            return self.screen_manager.current_screen.children[0]
+        else:
+            try:
+                return self.main_container.children[0]
+            except IndexError:
+                log.error("No child found, this should not happen")
+                return None
+
+    @property
+    def carousel_active(self):
+        """Return True if Carousel is used and active"""
+        if self.carousel is None:
+            return False
+        if self.screen_manager is not None and self.screen_manager.current != '':
+            return False
+        return True
+
+    @property
+    def former_screen_wid(self):
+        """Return widget currently active for former screen"""
+        if self.screen_manager is None:
+            raise exceptions.InternalError(
+                "former_screen_wid can only be used if ScreenManager is used")
+        if self._former_screen_name is None:
+            return None
+        return self.get_screen_widget(self._former_screen_name)
+
+    def get_screen_widget(self, screen_name):
+        """Return screen main widget, handling carousel if necessary"""
+        if self.carousel is not None and screen_name == '':
+            return self.carousel.current_slide
+        try:
+            return self.screen_manager.get_screen(screen_name).children[0]
+        except IndexError:
+            return None
+
+    def _draw_ellipse(self):
+        """draw split ellipse"""
+        color = self.split_color_del if self._split_del else self.split_color_move
+        try:
+            self.canvas.after.remove(self.ellipse)
+        except AttributeError:
+            pass
+        if self._split == "top":
+            with self.canvas.after:
+                Color(*color)
+                self.ellipse = Ellipse(angle_start=90, angle_end=270,
+                               pos=(self.x + self.width/2 - self.sp_zone/2,
+                                    self.y + self.height - self.sp_zone/2),
+                               size=(self.sp_zone, self.sp_zone))
+        elif self._split == "left":
+            with self.canvas.after:
+                Color(*color)
+                self.ellipse = Ellipse(angle_end=180,
+                               pos=(self.x + -self.sp_zone/2,
+                                    self.y + self.height/2 - self.sp_zone/2),
+                               size = (self.sp_zone, self.sp_zone))
+        else:
+            raise exceptions.InternalError('unexpected split value')
+
+    def on_touch_down(self, touch):
+        """activate split if touch is on a split zone"""
+        if not self.collide_point(*touch.pos):
+            return
+        log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format(
+            self._wid_idx,
+            'None' if not self._left_wids else [w._wid_idx for w in self._left_wids],
+            'None' if not self._top_wids else [w._wid_idx for w in self._top_wids],
+            'None' if not self._right_wids else [w._wid_idx for w in self._right_wids],
+            'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids],
+            self.pos,
+            self.size,
+            ))
+        touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True)
+        if (touch_ry <= self.height and
+            touch_ry >= self.height - self.split_size - self.split_margin or
+            touch_ry <= self.height and
+            touch_ry >= self.height - self.sp_zone and
+            touch_rx >= self.width//2 - self.sp_zone//2 and
+            touch_rx <= self.width//2 + self.sp_zone//2):
+            # split area is touched, we activate top split mode
+            self._split = "top"
+            self._draw_ellipse()
+        elif (touch_rx >= 0 and
+              touch_rx <= self.split_size + self.split_margin or
+              touch_rx >= 0 and
+              touch_rx <= self.sp_zone and
+              touch_ry >= self.height//2 - self.sp_zone//2 and
+              touch_ry <= self.height//2 + self.sp_zone//2):
+            # split area is touched, we activate left split mode
+            self._split = "left"
+            touch.ud['ori_width'] = self.width
+            self._draw_ellipse()
+        else:
+            if self.carousel_active and len(self.carousel.slides) <= 1:
+                # we don't want swipe of carousel if there is only one slide
+                return StencilView.on_touch_down(self.carousel, touch)
+            else:
+                return super(WHWrapper, self).on_touch_down(touch)
+
+    def on_touch_move(self, touch):
+        """handle size change and widget creation on split"""
+        if self._split == 'None':
+            return super(WHWrapper, self).on_touch_move(touch)
+
+        elif self._split == 'top':
+            new_height = touch.y - self.y
+
+            if new_height < MIN_HEIGHT:
+                return
+
+            # we must not pass the top widget/border
+            if self._top_wids:
+                top = next(iter(self._top_wids))
+                y_limit = top.y + top.height
+
+                if top.height <= REMOVE_WID_LIMIT:
+                    # we are in remove zone, we add visual hint for that
+                    if not self._split_del and self._top_wids:
+                        self._split_del = True
+                        self._draw_ellipse()
+                else:
+                    if self._split_del:
+                        self._split_del = False
+                        self._draw_ellipse()
+            else:
+                y_limit = self.y + self.height
+
+            if touch.y >= y_limit:
+                return
+
+            # all right, we can change size
+            self.height = new_height
+            self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2)
+
+            if not self._top_wids:
+                # we are the last widget on the top
+                # so we create a new widget
+                new_wid = self.parent.add_widget()
+                self._top_wids.add(new_wid)
+                new_wid._bottom_wids.add(self)
+                for w in self._right_wids:
+                    new_wid._right_wids.add(w)
+                    w._left_wids.add(new_wid)
+                for w in self._left_wids:
+                    new_wid._left_wids.add(w)
+                    w._right_wids.add(new_wid)
+
+        elif self._split == 'left':
+            ori_width = touch.ud['ori_width']
+            new_x = touch.x
+            new_width = ori_width - (touch.x - touch.ox)
+
+            if new_width < MIN_WIDTH:
+                return
+
+            # we must not pass the left widget/border
+            if self._left_wids:
+                left = next(iter(self._left_wids))
+                x_limit = left.x
+
+                if left.width <= REMOVE_WID_LIMIT:
+                    # we are in remove zone, we add visual hint for that
+                    if not self._split_del and self._left_wids:
+                        self._split_del = True
+                        self._draw_ellipse()
+                else:
+                    if self._split_del:
+                        self._split_del = False
+                        self._draw_ellipse()
+            else:
+                x_limit = self.x
+
+            if new_x <= x_limit:
+                return
+
+            # all right, we can change position/size
+            self.x = new_x
+            self.width = new_width
+            self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1])
+
+            if not self._left_wids:
+                # we are the last widget on the left
+                # so we create a new widget
+                new_wid = self.parent.add_widget()
+                self._left_wids.add(new_wid)
+                new_wid._right_wids.add(self)
+                for w in self._top_wids:
+                    new_wid._top_wids.add(w)
+                    w._bottom_wids.add(new_wid)
+                for w in self._bottom_wids:
+                    new_wid._bottom_wids.add(w)
+                    w._top_wids.add(new_wid)
+
+        else:
+            raise Exception.InternalError('invalid _split value')
+
+    def on_touch_up(self, touch):
+        if self._split == 'None':
+            return super(WHWrapper, self).on_touch_up(touch)
+        if self._split == 'top':
+            # we remove all top widgets in delete zone,
+            # and update there side widgets list
+            for top in self._top_wids.copy():
+                if top.height <= REMOVE_WID_LIMIT:
+                    G.host._remove_visible_widget(top.current_slide)
+                    for w in top._top_wids:
+                        w._bottom_wids.remove(top)
+                        w._bottom_wids.update(top._bottom_wids)
+                    for w in top._bottom_wids:
+                        w._top_wids.remove(top)
+                        w._top_wids.update(top._top_wids)
+                    for w in top._left_wids:
+                        w._right_wids.remove(top)
+                    for w in top._right_wids:
+                        w._left_wids.remove(top)
+                    self.parent.remove_widget(top)
+        elif self._split == 'left':
+            # we remove all left widgets in delete zone,
+            # and update there side widgets list
+            for left in self._left_wids.copy():
+                if left.width <= REMOVE_WID_LIMIT:
+                    G.host._remove_visible_widget(left.current_slide)
+                    for w in left._left_wids:
+                        w._right_wids.remove(left)
+                        w._right_wids.update(left._right_wids)
+                    for w in left._right_wids:
+                        w._left_wids.remove(left)
+                        w._left_wids.update(left._left_wids)
+                    for w in left._top_wids:
+                        w._bottom_wids.remove(left)
+                    for w in left._bottom_wids:
+                        w._top_wids.remove(left)
+                    self.parent.remove_widget(left)
+        self._split = 'None'
+        self.canvas.after.remove(self.ellipse)
+        del self.ellipse
+
+    def clear_widgets(self):
+        current_slide = self.current_slide
+        if current_slide is not None:
+            G.host._remove_visible_widget(current_slide, ignore_missing=True)
+
+        super().clear_widgets()
+
+        self.screen_manager = None
+        self.carousel = None
+        self._clear_attributes()
+
+    def set_widget(self, wid, index=0):
+        assert len(self.children) == 0
+
+        if wid.collection_carousel or wid.global_screen_manager:
+            self.main_container = self
+        else:
+            self.main_container = BoxStencil()
+            self.add_widget(self.main_container)
+
+        if self.carousel is not None:
+            return self.carousel.add_widget(wid, index)
+
+        if wid.global_screen_manager:
+            if self.screen_manager is None:
+                self.screen_manager = ScreenManager()
+                self.main_container.add_widget(self.screen_manager)
+                parent = Screen()
+                self.screen_manager.add_widget(parent)
+                self._former_screen_name = ''
+                self.screen_manager.bind(current=self.on_screen_change)
+                wid.screen_manager_init(self.screen_manager)
+        else:
+            parent = self.main_container
+
+        if wid.collection_carousel:
+            # a Carousel is requested, and this is the first widget that we add
+            # so we need to create the carousel
+            self.carousel = Carousel(
+                direction = "right",
+                ignore_perpendicular_swipes = True,
+                loop = True,
+            )
+            self._slides_update_lock = 0
+            self.carousel.bind(current_slide=self.on_slide_change)
+            parent.add_widget(self.carousel)
+            self.carousel.add_widget(wid, index)
+        else:
+            # no Carousel requested, we add the widget as a direct child
+            parent.add_widget(wid)
+            G.host._add_visible_widget(wid)
+
+    def change_widget(self, new_widget):
+        """Change currently displayed widget
+
+        slides widgets will be updated
+        """
+        if (self.carousel is not None
+            and self.carousel.current_slide.__class__ == new_widget.__class__):
+            # we have the same class, we reuse carousel and screen manager setting
+
+            if self.carousel.current_slide != new_widget:
+                # slides update need to be blocked to avoid the update in on_slide_change
+                # which would mess the removal of current widgets
+                self._slides_update_lock += 1
+                new_wid = None
+                for w in self.carousel.slides[:]:
+                    if w.widget_hash == new_widget.widget_hash:
+                        new_wid = w
+                        continue
+                    self.carousel.remove_widget(w)
+                    if isinstance(w, quick_widgets.QuickWidget):
+                        G.host.widgets.delete_widget(w)
+                if new_wid is None:
+                    new_wid = G.host.get_or_clone(new_widget)
+                    self.carousel.add_widget(new_wid)
+                self._update_hidden_slides()
+                self._slides_update_lock -= 1
+
+            if self.screen_manager is not None:
+                self.screen_manager.clear_widgets([
+                    s for s in self.screen_manager.screens if s.name != ''])
+                new_wid.screen_manager_init(self.screen_manager)
+        else:
+            # else, we restart fresh
+            self.clear_widgets()
+            self.set_widget(G.host.get_or_clone(new_widget))
+
+    def on_screen_change(self, screen_manager, new_screen):
+        try:
+            new_screen_wid = self.current_slide
+        except IndexError:
+            new_screen_wid = None
+            log.warning("Switching to a screen without children")
+        if new_screen == '' and self.carousel is not None:
+            # carousel may have been changed in the background, so we update slides
+            self._update_hidden_slides()
+        former_screen_wid = self.former_screen_wid
+        if isinstance(former_screen_wid, cagou_widget.LiberviaDesktopKivyWidget):
+            G.host._remove_visible_widget(former_screen_wid)
+        if isinstance(new_screen_wid, cagou_widget.LiberviaDesktopKivyWidget):
+            G.host._add_visible_widget(new_screen_wid)
+        self._former_screen_name = new_screen
+        G.host.selected_widget = new_screen_wid
+
+    def on_slide_change(self, handler, new_slide):
+        if self._former_slide is new_slide:
+            # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide
+            #        binding now calls on_slide_change twice with the same widget (here
+            #        "new_slide"). To be checked with Kivy team.
+            return
+        log.debug(f"Slide change: new_slide = {new_slide}")
+        if self._former_slide is not None:
+            G.host._remove_visible_widget(self._former_slide, ignore_missing=True)
+        self._former_slide = new_slide
+        if self.carousel_active:
+            G.host.selected_widget = new_slide
+            if new_slide is not None:
+                G.host._add_visible_widget(new_slide)
+                self._update_hidden_slides()
+
+    def hidden_list(self, visible_list, ignore=None):
+        """return widgets of same class as carousel current one, if they are hidden
+
+        @param visible_list(list[QuickWidget]): widgets visible
+        @param ignore(QuickWidget, None): do no return this widget
+        @return (iter[QuickWidget]): widgets hidden
+        """
+        # we want to avoid recreated widgets
+        added = [w.widget_hash for w in visible_list]
+        current_slide = self.carousel.current_slide
+        for w in G.host.widgets.get_widgets(current_slide.__class__,
+                                           profiles=current_slide.profiles):
+            wid_hash = w.widget_hash
+            if w in visible_list or wid_hash in added:
+                continue
+            if wid_hash == ignore.widget_hash:
+                continue
+            yield w
+
+
+    def _update_hidden_slides(self):
+        """adjust carousel slides according to visible widgets"""
+        if self._slides_update_lock or not self.carousel_active:
+            return
+        current_slide = self.carousel.current_slide
+        if not isinstance(current_slide, quick_widgets.QuickWidget):
+            return
+        # lock must be used here to avoid recursions
+        self._slides_update_lock += 1
+        visible_list = G.host.get_visible_list(current_slide.__class__)
+        # we ignore current_slide as it may not be visible yet (e.g. if an other
+        # screen is shown
+        hidden = list(self.hidden_list(visible_list, ignore=current_slide))
+        slides_sorted =  sorted(set(hidden + [current_slide]))
+        to_remove = set(self.carousel.slides).difference({current_slide})
+        for w in to_remove:
+            self.carousel.remove_widget(w)
+        if hidden:
+            # no need to add more than two widgets (next and previous),
+            # as the list will be updated on each new visible widget
+            current_idx = slides_sorted.index(current_slide)
+            try:
+                next_slide = slides_sorted[current_idx+1]
+            except IndexError:
+                next_slide = slides_sorted[0]
+            self.carousel.add_widget(G.host.get_or_clone(next_slide))
+            if len(hidden)>1:
+                previous_slide = slides_sorted[current_idx-1]
+                self.carousel.add_widget(G.host.get_or_clone(previous_slide))
+
+        self._slides_update_lock -= 1
+
+
+class WidgetsHandlerLayout(Layout):
+    count = 0
+
+    def __init__(self, **kwargs):
+        super(WidgetsHandlerLayout, self).__init__(**kwargs)
+        self._layout_size = None  # size used for the last layout
+        fbind = self.fbind
+        update = self._trigger_layout
+        fbind('children', update)
+        fbind('parent', update)
+        fbind('size', self.adjust_prop)
+        fbind('pos', update)
+
+    @property
+    def default_widget(self):
+        return G.host.default_wid['factory'](G.host.default_wid, None, None)
+
+    def adjust_prop(self, handler, new_size):
+        """Adjust children proportion
+
+        useful when this widget is resized (e.g. when going to fullscreen)
+        """
+        if len(self.children) > 1:
+            old_width, old_height = self._layout_size
+            if not old_width or not old_height:
+                # we don't want division by zero
+                return self._trigger_layout(handler, new_size)
+            width_factor = float(self.width) / old_width
+            height_factor = float(self.height) / old_height
+            for child in self.children:
+                child.width *= width_factor
+                child.height *= height_factor
+                child.x *= width_factor
+                child.y *= height_factor
+        self._trigger_layout(handler, new_size)
+
+    def do_layout(self, *args):
+        self._layout_size = self.size[:]
+        for child in self.children:
+            # XXX: left must be calculated before right and bottom before top
+            #      because they are the pos, and are used to caculate size (right and top)
+            # left
+            left = child._left_wid
+            left_end_x = self.x-1 if left is None else left.right
+            if child.x != left_end_x + 1 and child._split == "None":
+                child.x = left_end_x + 1
+            # right
+            right = child._right_wid
+            right_x = self.right + 1 if right is None else right.x
+            if child.right != right_x - 1:
+                child.width = right_x - child.x - 1
+            # bottom
+            bottom = child._bottom_wid
+            if bottom is None:
+                if child.y != self.y:
+                    child.y = self.y
+            else:
+                if child.y != bottom.top + 1:
+                    child.y = bottom.top + 1
+            # top
+            top = child._top_wid
+            top_y = self.top+1 if top is None else top.y
+            if child.top != top_y - 1:
+                if child._split == "None":
+                    child.height = top_y - child.y - 1
+
+    def remove_widget(self, wid):
+        super(WidgetsHandlerLayout, self).remove_widget(wid)
+        log.debug("widget deleted ({})".format(wid._wid_idx))
+
+    def add_widget(self, wid=None, index=0):
+        WidgetsHandlerLayout.count += 1
+        if wid is None:
+            wid = self.default_widget
+        if G.host.selected_widget is None:
+            G.host.selected_widget = wid
+        wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count)
+        log.debug("WHWrapper created ({})".format(wrapper._wid_idx))
+        wrapper.set_widget(wid)
+        super(WidgetsHandlerLayout, self).add_widget(wrapper, index)
+        return wrapper
+
+
+class WidgetsHandler(WidgetsHandlerLayout):
+
+    def __init__(self, **kw):
+        super(WidgetsHandler, self).__init__(**kw)
+        self.add_widget()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/xmlui.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,624 @@
+#!/usr/bin/env python3
+
+
+# Libervia Desktop-Kivy
+# Copyright (C) 2016-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.i18n import _
+from .constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.frontends.tools import xmlui
+from kivy.uix.scrollview import ScrollView
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.gridlayout import GridLayout
+from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
+from kivy.uix.textinput import TextInput
+from kivy.uix.label import Label
+from kivy.uix.button import Button
+from kivy.uix.togglebutton import ToggleButton
+from kivy.uix.widget import Widget
+from kivy.uix.switch import Switch
+from kivy import properties
+from libervia.desktop_kivy import G
+from libervia.desktop_kivy.core import dialog
+from functools import partial
+
+log = getLogger(__name__)
+
+## Widgets ##
+
+
+class TextInputOnChange(object):
+
+    def __init__(self):
+        self._xmlui_onchange_cb = None
+        self._got_focus = False
+
+    def _xmlui_on_change(self, callback):
+        self._xmlui_onchange_cb = callback
+
+    def on_focus(self, instance, focus):
+        # we need to wait for first focus, else initial value
+        # will trigger a on_text
+        if not self._got_focus and focus:
+            self._got_focus = True
+
+    def on_text(self, instance, new_text):
+        if self._xmlui_onchange_cb is not None and self._got_focus:
+            self._xmlui_onchange_cb(self)
+
+
+class EmptyWidget(xmlui.EmptyWidget, Widget):
+
+    def __init__(self, _xmlui_parent):
+        Widget.__init__(self)
+
+
+class TextWidget(xmlui.TextWidget, Label):
+
+    def __init__(self, xmlui_parent, value):
+        Label.__init__(self, text=value)
+
+
+class LabelWidget(xmlui.LabelWidget, TextWidget):
+    pass
+
+
+class JidWidget(xmlui.JidWidget, TextWidget):
+    pass
+
+
+class StringWidget(xmlui.StringWidget, TextInput, TextInputOnChange):
+
+    def __init__(self, xmlui_parent, value, read_only=False):
+        TextInput.__init__(self, text=value)
+        TextInputOnChange.__init__(self)
+        self.readonly = read_only
+
+    def _xmlui_set_value(self, value):
+        self.text = value
+
+    def _xmlui_get_value(self):
+        return self.text
+
+
+class TextBoxWidget(xmlui.TextBoxWidget, StringWidget):
+    pass
+
+
+class JidInputWidget(xmlui.JidInputWidget, StringWidget):
+    pass
+
+
+class ButtonWidget(xmlui.ButtonWidget, Button):
+
+    def __init__(self, _xmlui_parent, value, click_callback):
+        Button.__init__(self)
+        self.text = value
+        self.callback = click_callback
+
+    def _xmlui_on_click(self, callback):
+        self.callback = callback
+
+    def on_release(self):
+        self.callback(self)
+
+
+class DividerWidget(xmlui.DividerWidget, Widget):
+    # FIXME: not working properly + only 'line' is handled
+    style = properties.OptionProperty('line',
+        options=['line', 'dot', 'dash', 'plain', 'blank'])
+
+    def __init__(self, _xmlui_parent, style="line"):
+        Widget.__init__(self, style=style)
+
+
+class ListWidgetItem(ToggleButton):
+    value = properties.StringProperty()
+
+    def on_release(self):
+        parent = self.parent
+        while parent is not None and not isinstance(parent, ListWidget):
+            parent = parent.parent
+
+        if parent is not None:
+            parent.select(self)
+        return super(ListWidgetItem, self).on_release()
+
+    @property
+    def selected(self):
+        return self.state == 'down'
+
+    @selected.setter
+    def selected(self, value):
+        self.state = 'down' if value else 'normal'
+
+
+class ListWidget(xmlui.ListWidget, ScrollView):
+    layout = properties.ObjectProperty()
+
+    def __init__(self, _xmlui_parent, options, selected, flags):
+        ScrollView.__init__(self)
+        self.multi = 'single' not in flags
+        self._values = []
+        for option in options:
+            self.add_value(option)
+        self._xmlui_select_values(selected)
+        self._on_change = None
+
+    @property
+    def items(self):
+        return self.layout.children
+
+    def select(self, item):
+        if not self.multi:
+            self._xmlui_select_values([item.value])
+        if self._on_change is not None:
+            self._on_change(self)
+
+    def add_value(self, option, selected=False):
+        """add a value in the list
+
+        @param option(tuple): value, label in a tuple
+        """
+        self._values.append(option)
+        item = ListWidgetItem()
+        item.value, item.text = option
+        item.selected = selected
+        self.layout.add_widget(item)
+
+    def _xmlui_select_value(self, value):
+        self._xmlui_select_values([value])
+
+    def _xmlui_select_values(self, values):
+        for item in self.items:
+            item.selected = item.value in values
+            if item.selected and not self.multi:
+                self.text = item.text
+
+    def _xmlui_get_selected_values(self):
+        return [item.value for item in self.items if item.selected]
+
+    def _xmlui_add_values(self, values, select=True):
+        values = set(values).difference([c.value for c in self.items])
+        for v in values:
+            self.add_value(v, select)
+
+    def _xmlui_on_change(self, callback):
+        self._on_change = callback
+
+
+class JidsListWidget(ListWidget):
+    # TODO: real list dedicated to jids
+
+    def __init__(self, _xmlui_parent, jids, flags):
+        ListWidget.__init__(self, _xmlui_parent, [(j,j) for j in jids], [], flags)
+
+
+class PasswordWidget(xmlui.PasswordWidget, TextInput, TextInputOnChange):
+
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        TextInput.__init__(self, password=True, multiline=False,
+            text=value, readonly=read_only, size=(100,25), size_hint=(1,None))
+        TextInputOnChange.__init__(self)
+
+    def _xmlui_set_value(self, value):
+        self.text = value
+
+    def _xmlui_get_value(self):
+        return self.text
+
+
+class BoolWidget(xmlui.BoolWidget, Switch):
+
+    def __init__(self, _xmlui_parent, state, read_only=False):
+        Switch.__init__(self, active=state)
+        if read_only:
+            self.disabled = True
+
+    def _xmlui_set_value(self, value):
+        self.active = value
+
+    def _xmlui_get_value(self):
+        return C.BOOL_TRUE if self.active else C.BOOL_FALSE
+
+    def _xmlui_on_change(self, callback):
+        self.bind(active=lambda instance, value: callback(instance))
+
+
+class IntWidget(xmlui.IntWidget, TextInput, TextInputOnChange):
+
+    def __init__(self, _xmlui_parent, value, read_only=False):
+        TextInput.__init__(self, text=value, input_filter='int', multiline=False)
+        TextInputOnChange.__init__(self)
+        if read_only:
+            self.disabled = True
+
+    def _xmlui_set_value(self, value):
+        self.text = value
+
+    def _xmlui_get_value(self):
+        return self.text
+
+
+## Containers ##
+
+
+class VerticalContainer(xmlui.VerticalContainer, BoxLayout):
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        BoxLayout.__init__(self)
+
+    def _xmlui_append(self, widget):
+        self.add_widget(widget)
+
+
+class PairsContainer(xmlui.PairsContainer, GridLayout):
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        GridLayout.__init__(self)
+
+    def _xmlui_append(self, widget):
+        self.add_widget(widget)
+
+
+class LabelContainer(PairsContainer, xmlui.LabelContainer):
+    pass
+
+
+class TabsPanelContainer(TabbedPanelItem):
+    layout = properties.ObjectProperty(None)
+
+    def _xmlui_append(self, widget):
+        self.layout.add_widget(widget)
+
+
+class TabsContainer(xmlui.TabsContainer, TabbedPanel):
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        TabbedPanel.__init__(self, do_default_tab=False)
+
+    def _xmlui_add_tab(self, label, selected):
+        tab = TabsPanelContainer(text=label)
+        self.add_widget(tab)
+        return tab
+
+
+class AdvancedListRow(BoxLayout):
+    global_index = 0
+    index = properties.ObjectProperty()
+    selected = properties.BooleanProperty(False)
+
+    def __init__(self, **kwargs):
+        self.global_index = AdvancedListRow.global_index
+        AdvancedListRow.global_index += 1
+        super(AdvancedListRow, self).__init__(**kwargs)
+
+    def on_touch_down(self, touch):
+        if self.collide_point(*touch.pos):
+            parent = self.parent
+            while parent is not None and not isinstance(parent, AdvancedListContainer):
+                parent = parent.parent
+            if parent is None:
+                log.error("Can't find parent AdvancedListContainer")
+            else:
+                if parent.selectable:
+                    self.selected = parent._xmlui_toggle_selected(self)
+
+        return super(AdvancedListRow, self).on_touch_down(touch)
+
+
+class AdvancedListContainer(xmlui.AdvancedListContainer, BoxLayout):
+
+    def __init__(self, xmlui_parent, columns, selectable='no'):
+        self.xmlui_parent = xmlui_parent
+        BoxLayout.__init__(self)
+        self._columns = columns
+        self.selectable = selectable != 'no'
+        self._current_row = None
+        self._selected = []
+        self._xmlui_select_cb = None
+
+    def _xmlui_toggle_selected(self, row):
+        """inverse selection status of an AdvancedListRow
+
+        @param row(AdvancedListRow): row to (un)select
+        @return (bool): True if row is selected
+        """
+        try:
+            self._selected.remove(row)
+        except ValueError:
+            self._selected.append(row)
+            if self._xmlui_select_cb is not None:
+                self._xmlui_select_cb(self)
+            return True
+        else:
+            return False
+
+    def _xmlui_append(self, widget):
+        if self._current_row is None:
+            log.error("No row set, ignoring append")
+            return
+        self._current_row.add_widget(widget)
+
+    def _xmlui_add_row(self, idx):
+        self._current_row = AdvancedListRow()
+        self._current_row.cols = self._columns
+        self._current_row.index = idx
+        self.add_widget(self._current_row)
+
+    def _xmlui_get_selected_widgets(self):
+        return self._selected
+
+    def _xmlui_get_selected_index(self):
+        if not self._selected:
+            return None
+        return self._selected[0].index
+
+    def _xmlui_on_select(self, callback):
+        """ Call callback with widget as only argument """
+        self._xmlui_select_cb = callback
+
+
+## Dialogs ##
+
+
+class NoteDialog(xmlui.NoteDialog):
+
+    def __init__(self, _xmlui_parent, title, message, level):
+        xmlui.NoteDialog.__init__(self, _xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    def _xmlui_show(self):
+        G.host.add_note(self.title, self.message, self.level)
+
+
+class MessageDialog(xmlui.MessageDialog, dialog.MessageDialog):
+
+    def __init__(self, _xmlui_parent, title, message, level):
+        dialog.MessageDialog.__init__(self,
+                                      title=title,
+                                      message=message,
+                                      level=level,
+                                      close_cb = self.close_cb)
+        xmlui.MessageDialog.__init__(self, _xmlui_parent)
+
+    def close_cb(self):
+        self._xmlui_close()
+
+    def _xmlui_show(self):
+        G.host.add_notif_ui(self)
+
+    def _xmlui_close(self, reason=None):
+        G.host.close_ui()
+
+    def show(self, *args, **kwargs):
+        G.host.show_ui(self)
+
+
+class ConfirmDialog(xmlui.ConfirmDialog, dialog.ConfirmDialog):
+
+    def __init__(self, _xmlui_parent, title, message, level, buttons_set):
+        dialog.ConfirmDialog.__init__(self)
+        xmlui.ConfirmDialog.__init__(self, _xmlui_parent)
+        self.title=title
+        self.message=message
+        self.no_cb = self.no_cb
+        self.yes_cb = self.yes_cb
+
+    def no_cb(self):
+        G.host.close_ui()
+        self._xmlui_cancelled()
+
+    def yes_cb(self):
+        G.host.close_ui()
+        self._xmlui_validated()
+
+    def _xmlui_show(self):
+        G.host.add_notif_ui(self)
+
+    def _xmlui_close(self, reason=None):
+        G.host.close_ui()
+
+    def show(self, *args, **kwargs):
+        assert kwargs["force"]
+        G.host.show_ui(self)
+
+
+class FileDialog(xmlui.FileDialog, BoxLayout):
+    message = properties.ObjectProperty()
+
+    def __init__(self, _xmlui_parent, title, message, level, filetype):
+        xmlui.FileDialog.__init__(self, _xmlui_parent)
+        BoxLayout.__init__(self)
+        self.message.text = message
+        if filetype == C.XMLUI_DATA_FILETYPE_DIR:
+            self.file_chooser.dirselect = True
+
+    def _xmlui_show(self):
+        G.host.add_notif_ui(self)
+
+    def _xmlui_close(self, reason=None):
+        # FIXME: notif UI is not removed if dialog is not shown yet
+        G.host.close_ui()
+
+    def on_select(self, path):
+        try:
+            path = path[0]
+        except IndexError:
+            path = None
+        if not path:
+            self._xmlui_cancelled()
+        else:
+            self._xmlui_validated({'path': path})
+
+    def show(self, *args, **kwargs):
+        assert kwargs["force"]
+        G.host.show_ui(self)
+
+
+## Factory ##
+
+
+class WidgetFactory(object):
+
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[attr[6:]]
+            return cls
+
+
+## Core ##
+
+
+class Title(Label):
+
+    def __init__(self, *args, **kwargs):
+        kwargs['size'] = (100, 25)
+        kwargs['size_hint'] = (1,None)
+        super(Title, self).__init__(*args, **kwargs)
+
+
+class FormButton(Button):
+    pass
+
+class SubmitButton(FormButton):
+    pass
+
+class CancelButton(FormButton):
+    pass
+
+class SaveButton(FormButton):
+    pass
+
+
+class XMLUIPanel(xmlui.XMLUIPanel, ScrollView):
+    widget_factory = WidgetFactory()
+    layout = properties.ObjectProperty()
+
+    def __init__(self, host, parsed_xml, title=None, flags=None, callback=None,
+                 ignore=None, whitelist=None, profile=C.PROF_KEY_NONE):
+        ScrollView.__init__(self)
+        self.close_cb = None
+        self._post_treats = []  # list of callback to call after UI is constructed
+
+        # used to workaround touch issues when a ScrollView is used inside this
+        # one. This happens notably when a TabsContainer is used as main container
+        # (this is the case with settings).
+        self._skip_scroll_events = False
+        xmlui.XMLUIPanel.__init__(self,
+                                  host,
+                                  parsed_xml,
+                                  title=title,
+                                  flags=flags,
+                                  callback=callback,
+                                  ignore=ignore,
+                                  whitelist=whitelist,
+                                  profile=profile)
+        self.bind(height=self.on_height)
+
+    def on_touch_down(self, touch, after=False):
+        if self._skip_scroll_events:
+            return super(ScrollView, self).on_touch_down(touch)
+        else:
+            return super(XMLUIPanel, self).on_touch_down(touch)
+
+    def on_touch_up(self, touch, after=False):
+        if self._skip_scroll_events:
+            return super(ScrollView, self).on_touch_up(touch)
+        else:
+            return super(XMLUIPanel, self).on_touch_up(touch)
+
+    def on_touch_move(self, touch, after=False):
+        if self._skip_scroll_events:
+            return super(ScrollView, self).on_touch_move(touch)
+        else:
+            return super(XMLUIPanel, self).on_touch_move(touch)
+
+    def set_close_cb(self, close_cb):
+        self.close_cb = close_cb
+
+    def _xmlui_close(self, __=None, reason=None):
+        if self.close_cb is not None:
+            self.close_cb(self, reason)
+        else:
+            G.host.close_ui()
+
+    def on_param_change(self, ctrl):
+        super(XMLUIPanel, self).on_param_change(ctrl)
+        self.save_btn.disabled = False
+
+    def add_post_treat(self, callback):
+        self._post_treats.append(callback)
+
+    def _post_treat_cb(self):
+        for cb in self._post_treats:
+            cb()
+        del self._post_treats
+
+    def _save_button_cb(self, button):
+        button.disabled = True
+        self.on_save_params(button)
+
+    def construct_ui(self, parsed_dom):
+        xmlui.XMLUIPanel.construct_ui(self, parsed_dom, self._post_treat_cb)
+        if self.xmlui_title:
+            self.layout.add_widget(Title(text=self.xmlui_title))
+        if isinstance(self.main_cont, TabsContainer):
+            # cf. comments above
+            self._skip_scroll_events = True
+        self.layout.add_widget(self.main_cont)
+        if self.type == 'form':
+            submit_btn = SubmitButton()
+            submit_btn.bind(on_press=self.on_form_submitted)
+            self.layout.add_widget(submit_btn)
+            if not 'NO_CANCEL' in self.flags:
+                cancel_btn = CancelButton(text=_("Cancel"))
+                cancel_btn.bind(on_press=self.on_form_cancelled)
+                self.layout.add_widget(cancel_btn)
+        elif self.type == 'param':
+            self.save_btn = SaveButton(text=_("Save"), disabled=True)
+            self.save_btn.bind(on_press=self._save_button_cb)
+            self.layout.add_widget(self.save_btn)
+        elif self.type == 'window':
+            cancel_btn = CancelButton(text=_("Cancel"))
+            cancel_btn.bind(
+                on_press=partial(self._xmlui_close, reason=C.XMLUI_DATA_CANCELLED))
+            self.layout.add_widget(cancel_btn)
+
+    def on_height(self, __, height):
+        if isinstance(self.main_cont, TabsContainer):
+            other_children_height = sum([c.height for c in self.layout.children
+                                         if c is not self.main_cont])
+            self.main_cont.height = height - other_children_height
+
+    def show(self, *args, **kwargs):
+        if not self.user_action and not kwargs.get("force", False):
+            G.host.add_notif_ui(self)
+        else:
+            G.host.show_ui(self)
+
+
+class XMLUIDialog(xmlui.XMLUIDialog):
+    dialog_factory = WidgetFactory()
+
+
+create = partial(xmlui.create, class_map={
+    xmlui.CLASS_PANEL: XMLUIPanel,
+    xmlui.CLASS_DIALOG: XMLUIDialog})
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/base.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,25 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+<Label>:
+    color: 0, 0, 0, 1
+
+<Button>:
+    color: 1, 1, 1, 1
+
+<TextInput>:
+    background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/behaviors.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,28 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+<TouchMenu>:
+    creation_direction: -1
+    radius: dp(25)
+    creation_timeout: .4
+    cancel_color: app.c_sec_light[:3] + [0.3]
+    color: app.c_sec
+    line_width: dp(2)
+
+<ModernMenuLabel>:
+    bg_color: app.c_sec[:3] + [0.9]
+    padding: dp(5), dp(5)
+    radius: dp(100)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/cagou_widget.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,84 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import C libervia.desktop_kivy.core.constants.Const
+
+
+<HeaderChoice>:
+    canvas.before:
+        Color:
+            rgba: 1, 1, 1, 1
+        BorderImage:
+            pos: self.pos
+            size: self.size
+            source: 'atlas://data/images/defaulttheme/button'
+    size_hint_y: None
+    height: dp(44)
+    spacing: dp(20)
+    padding: dp(5), dp(3), dp(10), dp(3)
+
+<HeaderChoiceWidget>:
+    ActionIcon:
+        plugin_info: root.plugin_info
+        size_hint: None, 1
+        width: self.height
+    Label:
+        size_hint: None, 1
+        text: root.plugin_info['name']
+        color: 1, 1, 1, 1
+        bold: True
+        size: self.texture_size
+        halign: "center"
+        valign: "middle"
+
+<HeaderChoiceExtraMenu>:
+    ActionSymbol:
+        symbol: "dot-3-vert"
+        size_hint: None, 1
+        width: self.height
+    Label:
+        size_hint: None, 1
+        text: _("extra")
+        color: 1, 1, 1, 1
+        bold: True
+        size: self.texture_size
+        halign: "center"
+        valign: "middle"
+
+<HeaderWidgetSelector>:
+    size_hint: None, None
+    auto_width: False
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+<LiberviaDesktopKivyWidget>:
+    header_box: header_box
+    orientation: "vertical"
+    BoxLayout:
+        id: header_box
+        size_hint: 1, None
+        height: dp(32)
+        spacing: dp(3)
+        padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
+        HeaderWidgetCurrent:
+            plugin_info: root.plugin_info
+            size_hint: None, 1
+            width: self.height
+            on_release: root.selector.open(self)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/common.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,164 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+<NotifLabel>:
+    background_color: app.c_sec_light
+    size_hint: None, None
+    text_size: None, root.height
+    padding_x: sp(5)
+    size: self.texture_size
+    bold: True
+    canvas.before:
+        Color:
+            # self.background_color doesn't seem initialized correctly on startup
+            # (maybe a Kivy bug? to be checked), thus we use the "or" below
+            rgb: self.background_color or app.c_sec_light
+        Ellipse:
+            size: self.size
+            pos: self.pos
+
+
+<ContactItem>:
+    size_hint: None, None
+    width: self.base_width
+    height: self.minimum_height
+    orientation: 'vertical'
+    avatar: avatar
+    avatar_layout: avatar_layout
+    FloatLayout:
+        id: avatar_layout
+        size_hint: 1, None
+        height: dp(60)
+        Avatar:
+            id: avatar
+            pos_hint: {'x': 0, 'y': 0}
+            data: root.data.get('avatar')
+            allow_stretch: True
+    BoxLayout:
+        id: label_box
+        size_hint: 1, None
+        height: self.minimum_height
+        Label:
+            size_hint: 1, None
+            height: self.font_size + sp(5)
+            text_size: self.size
+            shorten: True
+            shorten_from: "right"
+            text: root.data.get('nick', root.jid.node or root.jid)
+            bold: True
+            valign: 'middle'
+            halign: 'center'
+
+
+<JidItem>:
+    size_hint: 1, None
+    height: dp(68)
+    avatar: avatar
+    padding: 0, dp(2), 0, dp(2)
+    canvas.before:
+        Color:
+            rgba: self.bg_color
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Image:
+        id: avatar
+        size_hint: None, None
+        size: dp(64), dp(64)
+    Label:
+        size_hint: 1, 1
+        text_size: self.size
+        color: root.color
+        bold: True
+        text: root.jid
+        halign: 'left'
+        valign: 'middle'
+        padding_x: dp(5)
+
+<JidToggle>:
+    canvas.before:
+        Color:
+            rgba: self.selected_color if self.state == 'down' else self.bg_color
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+<Symbol>:
+    width: dp(35)
+    height: dp(35)
+    font_name: app.expand('{media}/fonts/fontello/font/fontello.ttf')
+    text_size: self.size
+    font_size: dp(30)
+    halign: 'center'
+    valign: 'middle'
+    bg_color: 0, 0, 0, 0
+    canvas.before:
+        Color:
+            rgba: self.bg_color
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+<SymbolLabel>:
+    size_hint: None, 1
+    width: self.minimum_width
+    symbol_wid: symbol_wid
+    label: label
+    Symbol:
+        id: symbol_wid
+        size_hint: None, 1
+        symbol: root.symbol
+        color: root.color
+    Label:
+        id: label
+        size_hint: None, 1
+        text_size: None, root.height
+        size: self.texture_size
+        padding_x: dp(5)
+        valign: 'middle'
+        text: root.text
+        bold: root.bold
+
+<SymbolToggleLabel>:
+    color: 0, 0, 0, 1
+    canvas.before:
+        Color:
+            rgba: app.c_sec_light if self.state == 'down' else (0, 0, 0, 0)
+        RoundedRectangle:
+            pos: self.pos
+            size: self.size
+
+<ActionSymbol>:
+    bg_color: 0, 0, 0, 0
+    color: app.c_sec_light
+
+<SizedImage>:
+    size_hint: None, None
+
+
+<JidSelectorCategoryLayout>:
+    size_hint: 1, None
+    height: self.minimum_height
+    spacing: 0
+
+<JidSelector>:
+    layout: layout
+    StackLayout:
+        id: layout
+        size_hint: 1, None
+        height: self.minimum_height
+        spacing: 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/common_widgets.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,65 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+<ItemWidget>:
+    size_hint: None, None
+    width: self.base_width
+    height: self.minimum_height
+    orientation: 'vertical'
+
+
+<DeviceWidget>:
+    Symbol:
+        size_hint: 1, None
+        height: dp(80)
+        font_size: dp(40)
+        symbol: root.get_symbol()
+        color: 0, 0, 0, 1
+    Label:
+        size_hint: None, None
+        width: dp(100)
+        font_size: sp(14)
+        text_size: dp(95), None
+        size: self.texture_size
+        text: root.name
+        halign: 'center'
+
+
+<CategorySeparator>:
+    size_hint: 1, None
+    height: sp(35)
+    bold: True
+    font_size: sp(20)
+    color: app.c_sec
+
+<ImageViewer>:
+    do_rotation: False
+    AsyncImage:
+        source: root.source
+        allow_stretch: True,
+
+
+<ImagesGallery>:
+    carousel: carousel
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Carousel:
+        id: carousel
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/dialog.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,95 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import _ libervia.backend.core.i18n._
+
+
+<MessageDialog>:
+    orientation: "vertical"
+    spacing: dp(5)
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Label:
+        size_hint: 1, None
+        text_size: root.width, None
+        size: self.texture_size
+        font_size: sp(20)
+        padding: dp(5), dp(10)
+        color: 1, 1, 1, 1
+        text: root.title
+        halign: "center"
+        italic: True
+        bold: True
+    Label:
+        text: root.message
+        text_size: root.width, None
+        size: self.texture_size
+        padding: dp(25), 0
+        font_size: sp(20)
+        color: 1, 1, 1, 1
+    Button:
+        size_hint: 1, None
+        height: dp(50)
+        background_color: 0.33, 1.0, 0.0, 1
+        text: _("Close")
+        bold: True
+        on_release: root.close_cb()
+
+
+<ConfirmDialog>:
+    orientation: "vertical"
+    spacing: dp(5)
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Label:
+        size_hint: 1, None
+        text_size: root.width, None
+        size: self.texture_size
+        font_size: sp(20)
+        padding: dp(5), dp(10)
+        color: 1, 1, 1, 1
+        text: root.title
+        halign: "center"
+        italic: True
+        bold: True
+    Label:
+        text: root.message
+        text_size: root.width, None
+        size: self.texture_size
+        padding: dp(25), 0
+        font_size: sp(20)
+        color: 1, 1, 1, 1
+    Button:
+        size_hint: 1, None
+        height: dp(50)
+        background_color: 0.33, 1.0, 0.0, 1
+        text: _("Yes")
+        bold: True
+        on_release: root.yes_cb() if root.yes_cb is not None else None
+    Button:
+        size_hint: 1, None
+        height: dp(50)
+        text: _("No")
+        bold: True
+        on_release: root.no_cb() if root.no_cb is not None else None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/menu.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,152 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import _ libervia.backend.core.i18n._
+#:import C libervia.desktop_kivy.core.constants.Const
+
+<AboutContent>:
+    text_size: self.size
+    color: 1, 1, 1, 1
+    halign: "center"
+    valign: "middle"
+
+<AboutPopup>:
+    title_align: "center"
+    size_hint: 0.8, 0.8
+
+<ExtraMenuItem>:
+    size_hint: 1, None
+    height: dp(30)
+
+<ExtraSideMenu>:
+    bg_color: 0.23, 0.23, 0.23, 1
+    ExtraMenuItem:
+        text: _("About")
+        on_press: root.on_about()
+    Widget:
+        # to push content to the top
+
+<TransferMenu>:
+    items_layout: items_layout
+    orientation: "vertical"
+    bg_color: app.c_prim
+    size_hint: 1, 0.5
+    padding: [app.MARGIN_LEFT, 3, app.MARGIN_RIGHT, 0]
+    spacing: dp(5)
+    transfer_info: transfer_info
+    upload_btn: upload_btn
+    on_encrypted: self.update_transfer_info()
+    canvas.after:
+        Color:
+            rgba: app.c_prim_dark
+        Line:
+            points: 0, self.y + self.height, self.width + self.x, self.y + self.height
+            width: 1
+    BoxLayout:
+        size_hint: 1, None
+        height: dp(50)
+        spacing: dp(10)
+        Widget:
+        SymbolToggleLabel
+            id: upload_btn
+            symbol: "upload"
+            text: _(u"upload")
+            group: "transfer"
+            state: "down"
+            on_state: root.update_transfer_info()
+        SymbolToggleLabel
+            id: send_btn
+            symbol: "loop-alt"
+            text: _(u"send")
+            group: "transfer"
+        Widget:
+    Label:
+        id: transfer_info
+        size_hint: 1, None
+        padding: 0, dp(5)
+        markup: True
+        text_size: self.width, None
+        size: self.texture_size
+        halign: 'center'
+        canvas.before:
+            Color:
+                rgba: app.c_prim_dark
+            RoundedRectangle:
+                pos: self.pos
+                size: self.size
+    ScrollView:
+        do_scroll_x: False
+        StackLayout:
+            size_hint: 1, None
+            padding: 20, 0
+            spacing: 15, 5
+            id: items_layout
+
+<TransferItem>:
+    orientation: "vertical"
+    size_hint: None, None
+    size: dp(50), dp(90)
+    IconButton:
+        source: root.plug_info['icon_medium']
+        allow_stretch: True
+        size_hint: 1, None
+        height: dp(50)
+    Label:
+        color: 0, 0, 0, 1
+        text: root.plug_info['name']
+        text_size: self.size
+        halign: "center"
+        valign: "top"
+
+
+<SideMenu>:
+    orientation: "vertical"
+    size_hint: self.size_hint_close
+    canvas.before:
+        Color:
+            rgba: self.bg_color
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+
+<EntitiesSelectorMenu>:
+    bg_color: 0, 0, 0, 0.9
+    filter_input: filter_input
+    layout: layout
+    callback_on_close: True
+    Label:
+        size_hint: 1, None
+        text_size: root.width, None
+        size: self.texture_size
+        padding: dp(5), dp(5)
+        color: 1, 1, 1, 1
+        text: root.instructions
+        halign: "center"
+    TextInput:
+        id: filter_input
+        size_hint: 1, None
+        height: dp(32)
+        multiline: False
+        hint_text: _(u"enter filter here")
+    ScrollView:
+        size_hint: 1, 1
+        BoxLayout:
+            id: layout
+            orientation: "vertical"
+            size_hint: 1, None
+            height: self.minimum_height
+            spacing: dp(5)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/profile_manager.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,197 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+<ProfileManager>:
+    Label:
+        size_hint: 1, None
+        text_size: root.width, None
+        width: self.texture_size[0]
+        height: self.texture_size[1] + dp(20)
+        text: "Profile Manager"
+        halign: "center"
+        bold: True
+
+<PMLabel@Label>:
+    size_hint: 1, None
+    height: sp(30)
+
+<PMInput@TextInput>:
+    multiline: False
+    size_hint: 1, None
+    height: sp(30)
+    write_tab: False
+
+<PMButton@Button>:
+    size_hint: 1, None
+    height: dp(40)
+
+
+<NewProfileScreen>:
+    profile_name: profile_name
+    jid: jid
+    password: password
+
+    BoxLayout:
+        orientation: "vertical"
+
+        Label:
+            size_hint: 1, None
+            text_size: root.width, None
+            size: self.texture_size
+            text: "Creation of a new profile"
+            halign: "center"
+        Label:
+            text: root.error_msg
+            bold: True
+            size_hint: 1, None
+            height: dp(40)
+            color: 1,0,0,1
+        GridLayout:
+            cols: 2
+
+            PMLabel:
+                text: "Profile name"
+            PMInput:
+                id: profile_name
+
+            PMLabel:
+                text: "JID"
+            PMInput:
+                id: jid
+
+            PMLabel:
+                text: "Password"
+            PMInput:
+                id: password
+                password: True
+
+            Widget:
+                size_hint: 1, None
+                height: dp(50)
+
+            Widget:
+                size_hint: 1, None
+                height: dp(50)
+
+            PMButton:
+                text: "OK"
+                on_press: root.doCreate()
+
+            PMButton:
+                text: "Cancel"
+                on_press:
+                    root.pm.screen_manager.transition.direction = 'right'
+                    root.pm.screen_manager.current = 'profiles'
+
+            Widget:
+
+
+<DeleteProfilesScreen>:
+    BoxLayout:
+        orientation: "vertical"
+
+        Label:
+            size_hint: 1, None
+            text_size: root.width, None
+            size: self.texture_size
+            text: "Are you sure you want to delete the following profiles?"
+            halign: "center"
+
+        Label:
+            size_hint: 1, None
+            text_size: root.width, None
+            height: self.texture_size[1] + dp(60)
+            width: self.texture_size[0]
+            halign: "center"
+            # for now we only handle single selection
+            text: u'\n'.join([i.text for i in [root.pm.selected]]) if root.pm.selected else u''
+            bold: True
+
+        Label:
+            size_hint: 1, None
+            text_size:  root.width, dp(30)
+            height: self.texture_size[1]
+            text: u'/!\\ WARNING: this operation is irreversible'
+            color: 1,0,0,1
+            bold: True
+            halign: "center"
+            valign: "top"
+        GridLayout:
+            cols: 2
+            PMButton:
+                text: "Delete"
+                on_press: root.do_delete()
+
+            PMButton:
+                text: "Cancel"
+                on_press:
+                    root.pm.screen_manager.transition.direction = 'right'
+                    root.pm.screen_manager.current = 'profiles'
+
+
+<ProfilesScreen>:
+    layout: layout
+    BoxLayout:
+        orientation: 'vertical'
+
+        Label:
+            size_hint: 1, None
+            text_size: root.width, None
+            size: self.texture_size
+            text: "Select a profile or create a new one"
+            halign: "center"
+
+        GridLayout:
+            cols: 2
+            size_hint: 1, None
+            height: dp(40)
+            Button:
+                text: "New"
+                on_press:
+                    root.pm.screen_manager.transition.direction = 'left'
+                    root.pm.screen_manager.current = 'new_profile'
+            Button:
+                disabled: not root.pm.selected
+                text: "Delete"
+                on_press:
+                    root.pm.screen_manager.transition.direction = 'left'
+                    root.pm.screen_manager.current = 'delete_profiles'
+        ScrollView
+            BoxLayout:
+                size_hint: 1, None
+                height: self.minimum_height
+                orientation: "vertical"
+                id: layout
+        Button
+            text: "Connect"
+            size_hint: 1, None
+            height: dp(40)
+            disabled: not root.pm.selected
+            on_press: root.pm._on_connect_profiles()
+
+
+<ProfileItem>:
+    size_hint: 1, None
+    background_normal: ""
+    background_down: ""
+    deselected_color: (1,1,1,1) if self.index%2 else (0.87,0.87,0.87,1)
+    selected_color: 0.67,1.0,1.0,1
+    selected: self.state == 'down'
+    color: 0,0,0,1
+    background_color: self.selected_color if self.selected else self.deselected_color
+    on_press: self.ps.pm.select_profile(self)
+    height: dp(30)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/root_widget.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,128 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import IconButton libervia.desktop_kivy.core.common.IconButton
+#:import C libervia.desktop_kivy.core.constants.Const
+
+# <NotifIcon>:
+#     source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png")
+#     size_hint: None, None
+#     size: self.texture_size
+
+<Note>:
+    text: self.message
+    text_size: self.parent.size if self.parent else (100, 100)
+    halign: 'center'
+    padding_x: dp(5)
+    shorten: True
+    shorten_from: 'right'
+
+<NoteDrop>:
+    orientation: 'horizontal'
+    size_hint: 1, None
+    height: max(label.height, dp(45))
+    symbol: symbol
+    canvas.before:
+        BorderImage:
+            pos: self.pos
+            size: self.size
+            source: 'atlas://data/images/defaulttheme/button'
+    Widget:
+        size_hint: None, 1
+        width: dp(20)
+    Symbol:
+        id: symbol
+        size_hint: None, 1
+        width: dp(30)
+        padding_y: dp(10)
+        valign: 'top'
+        haligh: 'right'
+        symbol: root.symbol or root.level
+        color:
+            C.COLOR_PRIM_LIGHT if root.symbol is None else \
+            {C.XMLUI_DATA_LVL_INFO: app.c_prim_light,\
+            C.XMLUI_DATA_LVL_WARNING: C.COLOR_WARNING,\
+            C.XMLUI_DATA_LVL_ERROR: C.COLOR_ERROR}[root.level]
+    Label:
+        id: label
+        size_hint: 1, None
+        color: 1, 1, 1, 1
+        text: root.message
+        text_size: self.width, None
+        halign: 'center'
+        size: self.texture_size
+        padding: dp(2), dp(10)
+
+<NotesDrop>:
+    clear_btn: clear_btn.__self__
+    auto_width: False
+    size_hint: 0.9, None
+    size_hint_max_x: dp(400)
+    canvas.before:
+        Color:
+            rgba: 0.8, 0.8, 0.8, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Button:
+        id: clear_btn
+        text: "clear"
+        bold: True
+        size_hint: 1, None
+        height: dp(50)
+        on_release: del root.notes[:]; root.dismiss()
+
+<RootHeadWidget>:
+    manager: manager
+    notifs_icon: notifs_icon
+    size_hint: 1, None
+    height: self.HEIGHT
+    padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
+    IconButton:
+        source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_48.png")
+        allow_stretch: True
+        size_hint: None, None
+        pos_hint: {'center_y': .5}
+        height: dp(25)
+        width: dp(35) if root.notes else 0
+        opacity: 1 if root.notes else 0
+        on_release: root.notes_drop.open(self) if root.notes else None
+    ScreenManager:
+        id: manager
+    NotifsIcon:
+        id: notifs_icon
+        allow_stretch: True
+        source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_miroir_48.png")
+        size_hint: None, None
+        pos_hint: {'center_y': .5}
+        height: dp(25)
+        width: dp(35) if self.notifs else 0
+        opacity: 1 if self.notifs else 0
+    Symbol:
+        id: disconnected_icon
+        size_hint: None, 1
+        pos_hint: {'center_y': .5}
+        font_size: dp(23)
+        width: 0 if app.connected else dp(30)
+        opacity: 0 if app.connected else 1
+        symbol: "plug"
+        color: 0.80, 0.0, 0.0, 1
+
+<LiberviaDesktopKivyRootWidget>:
+    root_body: root_body
+    RootBody:
+        id: root_body
+        orientation: "vertical"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/share_widget.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,114 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import Path pathlib.Path
+#:import _ libervia.backend.core.i18n._
+#:import C libervia.desktop_kivy.core.constants.Const
+
+
+<ShareWidget>:
+    preview_box: preview_box
+    orientation: 'vertical'
+    Label:
+        size_hint: 1, None
+        text_size: self.size
+        halign: 'center'
+        text: _("share")
+        height: self.font_size + dp(5)
+        bold: True
+        font_size: '35sp'
+    BoxLayout:
+        id: preview_box
+        size_hint: 1, None
+        height: self.minimum_height
+        orientation: 'vertical'
+        text: str(root.data)
+    Label:
+        size_hint: 1, None
+        text_size: self.size
+        halign: 'center'
+        text: _("with")
+        height: self.font_size + dp(5)
+        bold: True
+        font_size: '25sp'
+    JidSelector:
+        on_select: root.on_select(args[1])
+    Button:
+        size_hint: 1, None
+        height: C.BTN_HEIGHT
+        text: _("cancel")
+        on_press: app.host.close_ui()
+
+
+<TextPreview>:
+    size_hint: 1, None
+    height: min(data.height, dp(100))
+    ScrollView
+        Label:
+            id: data
+            size_hint: 1, None
+            text: root.text
+            text_size: self.width, None
+            size: self.texture_size
+            font_size: sp(20)
+            padding_x: dp(10)
+            padding_y: dp(5)
+            halign: 'center'
+            canvas.before:
+                Color:
+                    rgba: 0.95, 0.95, 0.95, 1
+                Rectangle:
+                    pos: self.pos
+                    size: self.size
+
+<ImagePreview>:
+    reduce_layout: reduce_layout
+    reduce_checkbox: reduce_checkbox
+    size_hint: 1, None
+    height: dp(120)
+    orientation: "vertical"
+    Image:
+        source: root.path
+    BoxLayout
+        id: reduce_layout
+        size_hint: 1, None
+        padding_y: None
+        opacity: 0
+        height: 0
+        Widget:
+        CheckBox:
+            id: reduce_checkbox
+            size_hint: None, 1
+            width: dp(20)
+            active: True
+        Label:
+            size_hint: None, None
+            text: _("reduce image size")
+            text_size: None, None
+            size: self.texture_size
+            padding_x: dp(10)
+            font_size: sp(15)
+        Widget:
+
+<GenericPreview>:
+    size_hint: 1, None
+    height: dp(100)
+    Widget:
+    SymbolButtonLabel:
+        symbol: "doc"
+        text: Path(root.path).name
+    Widget:
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/simple_xhtml.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,31 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import C libervia.desktop_kivy.core.constants.Const
+
+
+<SimpleXHTMLWidget>:
+    size_hint: 1, None
+    height: self.minimum_height
+
+<SimpleXHTMLWidgetEscapedText>:
+    size_hint: 1, None
+    text_size: self.width, None
+    height: self.texture_size[1] if self.text else 0
+
+<SimpleXHTMLWidgetText>:
+    size_hint: None, None
+    size: self.texture_size
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/widgets_handler.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,43 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+<WHWrapper>:
+    _sp_top_y: self.y + self.height - self.sp_size
+    padding: self.split_size + self.split_margin, self.split_size + self.split_margin, 0, 0
+
+    canvas.before:
+        # 2 lines to indicate the split zones
+        Color:
+            rgba: self.split_color if self._split != 'left' else self.split_color_del if self._split_del else self.split_color_move
+        Rectangle:
+            pos: self.pos
+            size: self.split_size, self.height
+        Color:
+            rgba: self.split_color if self._split != 'top' else self.split_color_del if self._split_del else self.split_color_move
+        Rectangle:
+            pos: self.x, self.y + self.height - self.split_size
+            size: self.width, self.split_size
+        # 3 dots to indicate the main split points
+        Color:
+            rgba: 0, 0, 0, 1
+        Point:
+            # left
+            points: self.x + self.sp_size, self.y + self.height / 2 - self.sp_size - self.sp_space, self.x + self.sp_size,  self.y + self.height / 2, self.x + self.sp_size, self.y + self.height / 2 + self.sp_size + self.sp_space
+            pointsize: self.sp_size
+        Point:
+            # top
+            points: self.x + self.width / 2 - self.sp_size - self.sp_space, root._sp_top_y,  self.x + self.width / 2, root._sp_top_y, self.x + self.width / 2 + self.sp_size + self.sp_space, root._sp_top_y
+            pointsize: self.sp_size
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/kv/xmlui.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,206 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:set common_height 30
+#:set button_height 50
+
+
+<EmptyWidget,StringWidget,PasswordWidget,JidInputWidget>:
+    size_hint: 1, None
+    height: dp(common_height)
+
+
+<TextWidget,LabelWidget,JidWidget>:
+    size_hint: 1, 1
+    size_hint_min_y: max(dp(common_height), self.texture_size[1])
+    text_size: self.width, None
+
+
+<StringWidget,PasswordWidget,IntWidget>:
+    multiline: False
+    background_normal: app.expand('atlas://data/images/defaulttheme/textinput')
+
+
+<TextBoxWidget>:
+    multiline: True
+    height: dp(common_height) * 5
+
+
+<ButtonWidget>:
+    size_hint: 1, None
+    height: dp(button_height)
+
+
+<BoolWidget>:
+    size_hint: 1, 1
+
+
+<DividerWidget>:
+    size_hint: 1, None
+    height: dp(12)
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Line
+            points: self.x, self.y + dp(5), self.x + self.width, self.y + dp(5)
+            width: dp(2)
+
+
+<ListWidgetItem>:
+    size_hint_y: None
+    height: dp(button_height)
+
+
+<ListWidget>:
+    size_hint: 1, None
+    layout: layout
+    height: min(layout.minimum_height, dp(250))
+    do_scroll_x: False
+    scroll_type: ['bars', 'content']
+    bar_width: dp(6)
+    BoxLayout:
+        id: layout
+        size_hint: 1, None
+        height: self.minimum_height
+        orientation: "vertical"
+        padding: dp(10)
+
+
+<AdvancedListRow>:
+    orientation: "horizontal"
+    size_hint: 1, None
+    height: self.minimum_height
+    canvas.before:
+        Color:
+            rgba: app.c_prim_light if self.global_index%2 else app.c_prim_dark
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    canvas.after:
+        Color:
+            rgba: 0, 0, 1, 0.5 if self.selected else 0
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+
+<AdvancedListContainer>:
+    size_hint: 1, None
+    height: self.minimum_height
+    orientation: "vertical"
+
+
+<VerticalContainer>:
+    orientation: "vertical"
+    size_hint: 1, None
+    height: self.minimum_height
+
+<PairsContainer>:
+    cols: 2
+    size_hint: 1, None
+    height: self.minimum_height
+    padding: dp(10)
+
+
+<TabsContainer>:
+    size_hint: 1, None
+    height: dp(200)
+
+<TabsPanelContainer>:
+    layout: layout
+    ScrollView:
+        do_scroll_x: False
+        scroll_type: ['bars', 'content']
+        bar_width: dp(6)
+        canvas.before:
+            Color:
+                rgba: 1, 1, 1, 1
+            Rectangle:
+                pos: self.pos
+                size: self.size
+        BoxLayout:
+            id: layout
+            orientation: "vertical"
+            size_hint: 1, None
+            height: self.minimum_height
+            canvas.before:
+                Color:
+                    rgba: 1, 1, 1, 1
+                Rectangle:
+                    pos: self.pos
+                    size: self.size
+
+
+<FormButton>:
+    size_hint: 1, None
+    height: dp(button_height)
+    color: 0, 0, 0, 1
+    bold: True
+
+
+<SubmitButton>:
+    text: _(u"Submit")
+    background_normal: ''
+    background_color: 0.33, 0.67, 0.0, 1
+
+
+<CancelButton>:
+    text: _(u"Cancel")
+    color: 1, 1, 1, 1
+    bold: False
+
+
+<SaveButton>:
+    text: _(u"Save")
+    background_normal: ''
+    background_color: 0.33, 0.67, 0.0, 1
+
+
+<FileDialog>:
+    orientation: "vertical"
+    message: message
+    file_chooser: file_chooser
+    Label:
+        id: message
+        size_hint: 1, None
+        text_size: root.width, None
+        size: self.texture_size
+    FileChooserListView:
+        id: file_chooser
+    Button:
+        size_hint: 1, None
+        height: dp(50)
+        text: "choose"
+        on_release: root.on_select(file_chooser.selection)
+    Button:
+        size_hint: 1, None
+        height: dp(50)
+        text: "cancel"
+        on_release: root.onCancel()
+
+
+<XMLUIPanel>:
+    size_hint: 1, 1
+    layout: layout
+    do_scroll_x: False
+    scroll_type: ['bars', 'content']
+    bar_width: dp(6)
+    BoxLayout:
+        id: layout
+        orientation: "vertical"
+        size_hint: 1, None
+        padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0
+        height: self.minimum_height
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_gallery.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.core.i18n import _
+import sys
+import tempfile
+import os
+import os.path
+if sys.platform=="android":
+    from jnius import autoclass
+    from android import activity, mActivity
+
+    Intent = autoclass('android.content.Intent')
+    OpenableColumns = autoclass('android.provider.OpenableColumns')
+    PHOTO_GALLERY = 1
+    RESULT_OK = -1
+
+
+
+PLUGIN_INFO = {
+    "name": _("gallery"),
+    "main": "AndroidGallery",
+    "platforms": ('android',),
+    "external": True,
+    "description": _("upload a photo from photo gallery"),
+    "icon_medium": "{media}/icons/muchoslava/png/gallery_50.png",
+}
+
+
+class AndroidGallery:
+
+    def __init__(self, callback, cancel_cb):
+        self.callback = callback
+        self.cancel_cb = cancel_cb
+        activity.bind(on_activity_result=self.on_activity_result)
+        intent = Intent()
+        intent.setType('image/*')
+        intent.setAction(Intent.ACTION_GET_CONTENT)
+        mActivity.startActivityForResult(intent, PHOTO_GALLERY);
+
+    def on_activity_result(self, requestCode, resultCode, data):
+        activity.unbind(on_activity_result=self.on_activity_result)
+        # TODO: move file dump to a thread or use async callbacks during file writting
+        if requestCode == PHOTO_GALLERY and resultCode == RESULT_OK:
+            if data is None:
+                log.warning("No data found in activity result")
+                self.cancel_cb(self, None)
+                return
+            uri = data.getData()
+
+            # we get filename in the way explained at https://developer.android.com/training/secure-file-sharing/retrieve-info.html
+            cursor = mActivity.getContentResolver().query(uri, None, None, None, None )
+            name_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+            cursor.moveToFirst()
+            filename = cursor.getString(name_idx)
+
+            # we save data in a temporary file that we send to callback
+            # the file will be removed once upload is done (or if an error happens)
+            input_stream = mActivity.getContentResolver().openInputStream(uri)
+            tmp_dir = tempfile.mkdtemp()
+            tmp_file = os.path.join(tmp_dir, filename)
+            def cleaning():
+                os.unlink(tmp_file)
+                os.rmdir(tmp_dir)
+                log.debug('temporary file cleaned')
+            buff = bytearray(4096)
+            with open(tmp_file, 'wb') as f:
+                while True:
+                    ret = input_stream.read(buff, 0, 4096)
+                    if ret != -1:
+                        f.write(buff)
+                    else:
+                        break
+            input_stream.close()
+            self.callback(tmp_file, cleaning)
+        else:
+            self.cancel_cb(self, None)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_photo.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.core.i18n import _
+import sys
+import os
+import os.path
+import time
+if sys.platform == "android":
+    from plyer import camera
+    from jnius import autoclass
+    Environment = autoclass('android.os.Environment')
+else:
+    import tempfile
+
+
+PLUGIN_INFO = {
+    "name": _("take photo"),
+    "main": "AndroidPhoto",
+    "platforms": ('android',),
+    "external": True,
+    "description": _("upload a photo from photo application"),
+    "icon_medium": "{media}/icons/muchoslava/png/camera_off_50.png",
+}
+
+
+class AndroidPhoto(object):
+
+    def __init__(self, callback, cancel_cb):
+        self.callback = callback
+        self.cancel_cb = cancel_cb
+        filename = time.strftime("%Y-%m-%d_%H:%M:%S.jpg", time.gmtime())
+        tmp_dir = self.get_tmp_dir()
+        tmp_file = os.path.join(tmp_dir, filename)
+        log.debug("Picture will be saved to {}".format(tmp_file))
+        camera.take_picture(tmp_file, self.callback)
+        # we don't delete the file, as it is nice to keep it locally
+
+    def get_tmp_dir(self):
+        if sys.platform == "android":
+            dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()
+            return dcim_path
+        else:
+            return tempfile.mkdtemp()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_video.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.core.i18n import _
+import sys
+import os
+import os.path
+import time
+if sys.platform == "android":
+    from plyer import camera
+    from jnius import autoclass
+    Environment = autoclass('android.os.Environment')
+else:
+    import tempfile
+
+
+PLUGIN_INFO = {
+    "name": _("take video"),
+    "main": "AndroidVideo",
+    "platforms": ('android',),
+    "external": True,
+    "description": _("upload a video from video application"),
+    "icon_medium": "{media}/icons/muchoslava/png/film_camera_off_50.png",
+}
+
+
+class AndroidVideo(object):
+
+    def __init__(self, callback, cancel_cb):
+        self.callback = callback
+        self.cancel_cb = cancel_cb
+        filename = time.strftime("%Y-%m-%d_%H:%M:%S.mpg", time.gmtime())
+        tmp_dir = self.get_tmp_dir()
+        tmp_file = os.path.join(tmp_dir, filename)
+        log.debug("Video will be saved to {}".format(tmp_file))
+        camera.take_video(tmp_file, self.callback)
+        # we don't delete the file, as it is nice to keep it locally
+
+    def get_tmp_dir(self):
+        if sys.platform == "android":
+            dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()
+            return dcim_path
+        else:
+            return tempfile.mkdtemp()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_file.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,35 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import expanduser os.path.expanduser
+#:import platform kivy.utils.platform
+
+
+<FileChooserBox>:
+    orientation: "vertical"
+    FileChooserListView:
+        id: filechooser
+        path: root.default_path
+    Button:
+        text: "choose"
+        size_hint: 1, None
+        height: dp(50)
+        on_release: root.callback(filechooser.selection)
+    Button:
+        text: "cancel"
+        size_hint: 1, None
+        height: dp(50)
+        on_release: root.cancel_cb()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_file.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+import threading
+import sys
+from functools import partial
+from libervia.backend.core import log as logging
+from libervia.backend.core.i18n import _
+from kivy.uix.boxlayout import BoxLayout
+from kivy import properties
+from kivy.clock import Clock
+from plyer import filechooser, storagepath
+
+log = logging.getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    "name": _("file"),
+    "main": "FileTransmitter",
+    "description": _("transmit a local file"),
+    "icon_medium": "{media}/icons/muchoslava/png/fichier_50.png",
+}
+
+
+class FileChooserBox(BoxLayout):
+    callback = properties.ObjectProperty()
+    cancel_cb = properties.ObjectProperty()
+    default_path = properties.StringProperty()
+
+
+class FileTransmitter(BoxLayout):
+    callback = properties.ObjectProperty()
+    cancel_cb = properties.ObjectProperty()
+    native_filechooser = True
+    default_path = storagepath.get_home_dir()
+
+    def __init__(self, *args, **kwargs):
+        if sys.platform == 'android':
+            self.native_filechooser = False
+            self.default_path = storagepath.get_downloads_dir()
+
+        super(FileTransmitter, self).__init__(*args, **kwargs)
+
+        if self.native_filechooser:
+            thread = threading.Thread(target=self._native_file_chooser)
+            thread.start()
+        else:
+            self.add_widget(FileChooserBox(default_path = self.default_path,
+                                           callback=self.on_files,
+                                           cancel_cb=partial(self.cancel_cb, self)))
+
+    def _native_file_chooser(self, *args, **kwargs):
+        title=_("Please select a file to upload")
+        files = filechooser.open_file(title=title,
+                                      path=self.default_path,
+                                      multiple=False,
+                                      preview=True)
+        # we want to leave the thread when calling on_files, so we use Clock
+        Clock.schedule_once(lambda *args: self.on_files(files=files), 0)
+
+    def on_files(self, files):
+        if files:
+            self.callback(files[0])
+        else:
+            self.cancel_cb(self)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_voice.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,72 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import _ libervia.backend.core.i18n._
+#:import IconButton libervia.desktop_kivy.core.common.IconButton
+
+
+<VoiceRecorder>:
+    orientation: "vertical"
+    counter: counter
+    Label:
+        size_hint: 1, 0.4
+        text_size: self.size
+        halign: 'center'
+        valign: 'top'
+        text: _(u"Push the microphone button to start the record, then push it again to stop it.\nWhen you are satisfied, click on the transmit button")
+    Label:
+        id: counter
+        size_hint: 1, None
+        height: dp(60)
+        bold: True
+        font_size: sp(40)
+        text_size: self.size
+        text: u"{}:{:02}".format(root.time//60, root.time%60)
+        halign: 'center'
+        valign: 'middle'
+    BoxLayout:
+        size_hint: 1, None
+        height: dp(60)
+        spacing: dp(5)
+        Widget
+        IconButton:
+            source: app.expand("{media}/icons/muchoslava/png/") + ("micro_on_50.png" if root.recording else "micro_off_50.png")
+            allow_stretch: True
+            size_hint: None, None
+            size: dp(60), dp(60)
+            on_release: root.switch_recording()
+        IconButton:
+            opacity: 0 if root.recording or not root.time and not root.playing else 1
+            source: app.expand("{media}/icons/muchoslava/png/") + ("stop_50.png" if root.playing else "play_50.png")
+            allow_stretch: True
+            size_hint: None, None
+            size: dp(60), dp(60)
+            on_release: root.play_record()
+        Widget
+    Widget:
+        size_hint: 1, None
+        height: dp(50)
+    Button:
+        text: _("transmit")
+        size_hint: 1, None
+        height: dp(50)
+        on_release: root.callback(root.audio.file_path)
+    Button:
+        text: _("cancel")
+        size_hint: 1, None
+        height: dp(50)
+        on_release: root.cancel_cb(root)
+    Widget
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_transfer_voice.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.core.i18n import _
+from kivy.uix.boxlayout import BoxLayout
+import sys
+import time
+from kivy.clock import Clock
+from kivy import properties
+if sys.platform == "android":
+    from plyer import audio
+
+
+PLUGIN_INFO = {
+    "name": _("voice"),
+    "main": "VoiceRecorder",
+    "platforms": ["android"],
+    "description": _("transmit a voice record"),
+    "icon_medium": "{media}/icons/muchoslava/png/micro_off_50.png",
+    "android_permissons": ["RECORD_AUDIO"],
+}
+
+
+class VoiceRecorder(BoxLayout):
+    callback = properties.ObjectProperty()
+    cancel_cb = properties.ObjectProperty()
+    recording = properties.BooleanProperty(False)
+    playing = properties.BooleanProperty(False)
+    time = properties.NumericProperty(0)
+
+    def __init__(self, **kwargs):
+        super(VoiceRecorder, self).__init__(**kwargs)
+        self._started_at = None
+        self._counter_timer = None
+        self._play_timer = None
+        self.record_time = None
+        self.audio = audio
+        self.audio.file_path = "/sdcard/cagou_record.3gp"
+
+    def _update_timer(self, dt):
+        self.time = int(time.time() - self._started_at)
+
+    def switch_recording(self):
+        if self.playing:
+            self._stop_playing()
+        if self.recording:
+            try:
+                audio.stop()
+            except Exception as e:
+                # an exception can happen if record is pressed
+                # repeatedly in a short time (not a normal use)
+                log.warning("Exception on stop: {}".format(e))
+            self._counter_timer.cancel()
+            self.time = self.time + 1
+        else:
+            audio.start()
+            self._started_at = time.time()
+            self.time = 0
+            self._counter_timer = Clock.schedule_interval(self._update_timer, 1)
+
+        self.recording = not self.recording
+
+    def _stop_playing(self, __=None):
+        if self.record_time is None:
+            log.error("_stop_playing should no be called when record_time is None")
+            return
+        audio.stop()
+        self.playing = False
+        self.time = self.record_time
+        if self._counter_timer is not None:
+            self._counter_timer.cancel()
+
+    def play_record(self):
+        if self.recording:
+            return
+        if self.playing:
+            self._stop_playing()
+        else:
+            try:
+                audio.play()
+            except Exception as e:
+                # an exception can happen in the same situation
+                # as for audio.stop() above (i.e. bad record)
+                log.warning("Exception on play: {}".format(e))
+                self.time = 0
+                return
+
+            self.playing = True
+            self.record_time = self.time
+            Clock.schedule_once(self._stop_playing, self.time + 1)
+            self._started_at = time.time()
+            self.time = 0
+            self._counter_timer =  Clock.schedule_interval(self._update_timer, 0.5)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_blog.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,168 @@
+# desktop/mobile frontend for Libervia XMPP client
+# Copyright (C) 2016-2022 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/>.
+
+#:import date_fmt libervia.backend.tools.common.date_utils.date_fmt
+
+<SearchButton>:
+    size_hint: None, 1
+    symbol: "search"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+
+
+<NewPostButton>:
+    size_hint: None, 1
+    symbol: "pencil"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+
+<NewPosttMenu>:
+    padding: dp(20)
+    spacing: dp(10)
+    e2ee: e2ee_checkbox
+    Label:
+        size_hint: 1, None
+        color: 1, 1, 1, 1
+        text: _("Publish a new post on {node} node of {service}").format(node=root.blog.node or "personal blog", service=root.blog.service or root.blog.profile)
+        text_size: root.width, None
+        size: self.texture_size
+        halign: "center"
+        bold: True
+    TextInput:
+        id: title
+        size_hint: 1, None
+        height: sp(30)
+        hint_text: _("title of your post (optional)")
+    TextInput:
+        id: content
+        size_hint: 1, None
+        height: sp(300)
+        hint_text: _("body of your post (markdown syntax allowed)")
+    BoxLayout
+        id: e2ee
+        size_hint: 1, None
+        padding_y: None
+        height: dp(25)
+        Widget:
+        CheckBox:
+            id: e2ee_checkbox
+            size_hint: None, 1
+            width: dp(20)
+            active: False
+            color: 1, 1, 1, 1
+        Label:
+            size_hint: None, None
+            text: _("encrypt post")
+            text_size: None, None
+            size: self.texture_size
+            padding_x: dp(10)
+            font_size: sp(15)
+            color: 1, 1, 1, 1
+        Widget:
+    Button:
+        size_hint: 1, None
+        height: sp(50)
+        text: _("publish")
+        on_release: root.publish(title.text, content.text, e2ee=e2ee_checkbox.active)
+    Widget:
+
+
+<BlogPostAvatar>:
+    size_hint: None, None
+    size: dp(30), dp(30)
+    canvas.before:
+        Color:
+            rgba: (0.87,0.87,0.87,1)
+        RoundedRectangle:
+            radius: [dp(5)]
+            pos: self.pos
+            size: self.size
+
+<BlogPostWidget>:
+    size_hint: 1, None
+    avatar: avatar
+    header_box: header_box
+    height: self.minimum_height
+    orientation: "vertical"
+    Label:
+        color: 0, 0, 0, 1
+        bold: True
+        font_size: root.title_font_size
+        text_size: None, None
+        size_hint: None, None
+        size: self.texture_size[0], self.texture_size[1] if root.blog_data.get("title") else 0
+        opacity: 1 if root.blog_data.get("title") else 0
+        padding: dp(5), 0
+        text: root.blog_data.get("title", "")
+    BoxLayout:
+        id: header_box
+        size_hint: 1, None
+        height: dp(40)
+        BoxLayout:
+            orientation: 'vertical'
+            width: avatar.width
+            size_hint: None, 1
+            BlogPostAvatar:
+                id: avatar
+                source: app.default_avatar
+        Label:
+            id: created_ts
+            color: (0, 0, 0, 1)
+            font_size: root.font_size
+            text_size: None, None
+            size_hint: None, None
+            size: self.texture_size
+            padding: dp(5), 0
+            markup: True
+            valign: 'middle'
+            text: f"published on [b]{date_fmt(root.blog_data.get('published', 0), 'auto_day')}[/b]"
+        Symbol:
+            size_hint: None, None
+            height: created_ts.height
+            width: self.height
+            id: encrypted
+            symbol: 'lock-filled' if root.blog_data.get("encrypted") else 'lock-open'
+            font_size: created_ts.height
+            opacity: 1 if root.blog_data.get("encrypted") else 0
+            color: 0.29,0.87,0.0,1
+    SimpleXHTMLWidget:
+        size_hint: 1, None
+        height: self.minimum_height
+        xhtml: root.blog_data.get("content_xhtml") or self.escape(root.blog_data.get("content", ""))
+        color: (0, 0, 0, 1)
+        padding: dp(5), dp(5)
+
+
+<Blog>:
+    float_layout: float_layout
+    orientation: 'vertical'
+    posts_widget: posts_widget
+    FloatLayout:
+        id: float_layout
+        ScrollView:
+            size_hint: 1, 1
+            pos_hint: {'x': 0, 'y': 0}
+            do_scroll_x: False
+            scroll_type: ['bars', 'content']
+            bar_width: dp(6)
+            BoxLayout:
+                id: posts_widget
+                orientation: "vertical"
+                size_hint: 1, None
+                height: self.minimum_height
+                spacing: dp(10)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_blog.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+
+#desktop/mobile frontend for Libervia XMPP client
+# Copyright (C) 2016-2022 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 functools import partial
+import json
+from typing import Any, Dict, Optional
+
+from kivy import properties
+from kivy.metrics import sp
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.boxlayout import BoxLayout
+from libervia.backend.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.tools import jid
+
+from libervia.desktop_kivy import G
+from libervia.desktop_kivy.core.menu import SideMenu
+
+from ..core import cagou_widget
+from ..core.common import SymbolButton
+from ..core.constants import Const as C
+from ..core.image import Image
+
+log = logging.getLogger(__name__)
+
+PLUGIN_INFO = {
+    "name": _("blog"),
+    "main": "Blog",
+    "description": _("(micro)blog"),
+    "icon_symbol": "pencil",
+}
+
+
+class SearchButton(SymbolButton):
+    blog = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        self.blog.header_input.dispatch('on_text_validate')
+
+
+class NewPostButton(SymbolButton):
+    blog = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        self.blog.show_new_post_menu()
+
+
+class NewPosttMenu(SideMenu):
+    blog = properties.ObjectProperty()
+    size_hint_close = (1, 0)
+    size_hint_open = (1, 0.9)
+
+    def _publish_cb(self, item_id: str) -> None:
+        G.host.add_note(
+            _("blog post published"),
+            _("your blog post has been published with ID {item_id}").format(
+                item_id=item_id
+            )
+        )
+        self.blog.load_blog()
+
+    def _publish_eb(self, exc: BridgeException) -> None:
+        G.host.add_note(
+            _("Problem while publish blog post"),
+            _("Can't publish blog post at {node!r} from {service}: {problem}").format(
+                node=self.blog.node or G.host.ns_map.get("microblog"),
+                service=(
+                    self.blog.service if self.blog.service
+                    else G.host.profiles[self.blog.profile].whoami,
+                ),
+                problem=exc
+            ),
+            C.XMLUI_DATA_LVL_ERROR
+        )
+
+    def publish(
+            self,
+            title: str,
+            content: str,
+            e2ee: bool = False
+    ) -> None:
+        self.hide()
+        mb_data: Dict[str, Any] = {"content_rich": content}
+        if e2ee:
+            mb_data["encrypted"] = True
+        title = title.strip()
+        if title:
+            mb_data["title_rich"] = title
+        G.host.bridge.mb_send(
+            self.blog.service,
+            self.blog.node,
+            data_format.serialise(mb_data),
+            self.blog.profile,
+            callback=self._publish_cb,
+            errback=self._publish_eb,
+        )
+
+
+class BlogPostAvatar(ButtonBehavior, Image):
+    pass
+
+
+class BlogPostWidget(BoxLayout):
+    blog_data = properties.DictProperty()
+    font_size = properties.NumericProperty(sp(12))
+    title_font_size = properties.NumericProperty(sp(14))
+
+
+class Blog(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget):
+    posts_widget = properties.ObjectProperty()
+    service = properties.StringProperty()
+    node = properties.StringProperty()
+    use_header_input = True
+
+    def __init__(self, host, target, profiles):
+        quick_widgets.QuickWidget.__init__(self, G.host, target, profiles)
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        search_btn = SearchButton(blog=self)
+        self.header_input_add_extra(search_btn)
+        new_post_btn = NewPostButton(blog=self)
+        self.header_input_add_extra(new_post_btn)
+        self.load_blog()
+
+    def on_kv_post(self, __):
+        self.bind(
+            service=lambda __, value: self.load_blog(),
+            node=lambda __, value: self.load_blog(),
+        )
+
+    def on_header_wid_input(self):
+        text = self.header_input.text.strip()
+        # for now we only use text as node
+        self.node = text
+
+    def show_new_post_menu(self):
+        """Show the "add a contact" menu"""
+        NewPosttMenu(blog=self).show()
+
+    def _mb_get_cb(self, blog_data_s: str) -> None:
+        blog_data = json.loads(blog_data_s)
+        for item in blog_data["items"]:
+            self.posts_widget.add_widget(BlogPostWidget(blog_data=item))
+
+    def _mb_get_eb(
+        self,
+        exc: BridgeException,
+    ) -> None:
+        G.host.add_note(
+            _("Problem while getting blog data"),
+            _("Can't get blog for {node!r} at {service}: {problem}").format(
+                node=self.node or G.host.ns_map.get("microblog"),
+                service=self.service if self.service else G.host.profiles[self.profile].whoami,
+                problem=exc
+            ),
+            C.XMLUI_DATA_LVL_ERROR
+        )
+
+    def load_blog(
+        self,
+    ) -> None:
+        """Retrieve a blog and display it"""
+        extra = {}
+        self.posts_widget.clear_widgets()
+        G.host.bridge.mb_get(
+            self.service,
+            self.node,
+            20,
+            [],
+            data_format.serialise(extra),
+            self.profile,
+            callback=self._mb_get_cb,
+            errback=self._mb_get_eb,
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_chat.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,349 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import _ libervia.backend.core.i18n._
+#:import C libervia.desktop_kivy.core.constants.Const
+#:import G libervia.desktop_kivy.G
+#:import escape kivy.utils.escape_markup
+#:import SimpleXHTMLWidget libervia.desktop_kivy.core.simple_xhtml.SimpleXHTMLWidget
+#:import DelayedBoxLayout libervia.desktop_kivy.core.common_widgets.DelayedBoxLayout
+#:import ScrollEffect kivy.effects.scroll.ScrollEffect
+#:import CategorySeparator libervia.desktop_kivy.core.common_widgets.CategorySeparator
+
+
+# Chat
+
+
+<BaseAttachmentItem>:
+    size_hint: None, None
+    size: self.minimum_width, dp(50)
+
+
+<AttachmentItem>:
+    canvas.before:
+        Color:
+            rgb: app.c_prim_dark
+        Line:
+            rounded_rectangle: self.x + dp(1), self.y + dp(1), self.width - dp(2), self.height - dp(2), 10
+        Color:
+            rgb: app.c_sec_light
+        RoundedRectangle:
+            pos: self.x + dp(1), self.y + dp(1)
+            size: (self.width - dp(2)) * root.progress / 100, self.height - dp(2)
+    SymbolButtonLabel:
+        symbol: root.get_symbol(root.data)
+        color: 0, 0, 0, 1
+        text: root.data.get('name', _('unnamed'))
+        bold: False
+        on_press: root.on_press()
+
+
+<AttachmentImageItem>:
+    size: self.minimum_width, self.minimum_height
+    image: image
+    orientation: "vertical"
+    SizedImage:
+        id: image
+        anim_delay: -1
+        source: "data/images/image-loading.gif"
+
+
+<AttachmentImagesCollectionItem>:
+    cols: 2
+    size_hint: None, None
+    size: dp(150), dp(150)
+    padding: dp(5)
+    spacing: dp(2)
+    canvas.before:
+        Color:
+            rgb: app.c_prim
+        RoundedRectangle:
+            radius: [dp(5)]
+            pos: self.pos
+            size: self.size
+        Color:
+            rgb: 0, 0, 0, 1
+        Line:
+            rounded_rectangle: self.x, self.y, self.width, self.height, dp(5)
+
+
+<AttachmentsLayout>:
+    attachments: self
+    size_hint: 1, None
+    height: self.minimum_height
+    spacing: dp(5)
+
+
+<MessAvatar>:
+    size_hint: None, None
+    size: dp(30), dp(30)
+    canvas.before:
+        Color:
+            rgba: (0.87,0.87,0.87,1)
+        RoundedRectangle:
+            radius: [dp(5)]
+            pos: self.pos
+            size: self.size
+
+
+<MessageWidget>:
+    size_hint: 1, None
+    avatar: avatar
+    delivery: delivery
+    mess_xhtml: mess_xhtml
+    right_part: right_part
+    header_box: header_box
+    height: self.minimum_height
+    BoxLayout:
+        orientation: 'vertical'
+        width: avatar.width
+        size_hint: None, 1
+        MessAvatar:
+            id: avatar
+            source: root.mess_data.avatar['path'] if root.mess_data and root.mess_data.avatar else app.default_avatar
+            on_press: root.chat.add_nick(root.nick)
+        Widget:
+            # use to push the avatar on the top
+            size_hint: 1, 1
+    BoxLayout:
+        size_hint: 1, None
+        orientation: 'vertical'
+        id: right_part
+        height: self.minimum_height
+        BoxLayout:
+            id: header_box
+            size_hint: 1, None
+            height: time_label.height if root.mess_type != C.MESS_TYPE_INFO else 0
+            opacity: 1 if root.mess_type != C.MESS_TYPE_INFO else 0
+            Label:
+                id: time_label
+                color: (0, 0, 0, 1) if root.own_mess else (0.55,0.55,0.55,1)
+                font_size: root.font_size
+                text_size: None, None
+                size_hint: None, None
+                size: self.texture_size
+                padding: dp(5), 0
+                markup: True
+                valign: 'middle'
+                text: u"[b]{}[/b], {}".format(escape(root.nick), root.time_text)
+            Symbol:
+                size_hint_x: None
+                width: self.height
+                id: encrypted
+                symbol: 'lock-filled' if root.mess_data.encrypted else 'lock-open'
+                font_size: self.height - dp(3)
+                color: (1, 0, 0, 1) if not root.mess_data.encrypted and root.chat.encrypted else (0.55,0.55,0.55,1)
+            Label:
+                id: delivery
+                color: C.COLOR_BTN_LIGHT
+                font_size: root.font_size
+                text_size: None, None
+                size_hint: None, None
+                size: self.texture_size
+                padding: dp(5), 0
+                # XXX: DejaVuSans font is needed as check mark is not in Roboto
+                # this can be removed when Kivy will be able to handle fallback mechanism
+                # which will allow us to use fonts with more unicode characters
+                font_name: "DejaVuSans"
+                text: u''
+        SimpleXHTMLWidget:
+            id: mess_xhtml
+            size_hint: 1, None
+            height: self.minimum_height
+            xhtml: root.message_xhtml or self.escape(root.message or '')
+            color: (0.74,0.74,0.24,1) if root.mess_type == "info" else (0, 0, 0, 1)
+            padding: root.mess_padding
+            bold: True if root.mess_type == "info" else False
+
+
+<AttachmentToSendItem>:
+    SymbolButton:
+        opacity: 0 if root.sending else 1
+        size_hint: None, 1
+        symbol: "cancel-circled"
+        on_press: root.parent.remove_widget(root)
+
+
+<AttachmentsToSend>:
+    attachments: attachments_layout.attachments
+    reduce_checkbox: reduce_checkbox
+    orientation: "vertical"
+    size_hint: 1, None
+    height: self.minimum_height if self.attachments.children else 0
+    opacity: 1 if self.attachments.children else 0
+    padding: [app.MARGIN_LEFT, dp(5), app.MARGIN_RIGHT, dp(5)]
+    canvas.before:
+        Color:
+            rgba: app.c_prim
+        Rectangle:
+            pos: self.pos
+            size: self.size
+    Label:
+        size_hint: 1, None
+        size: self.texture_size
+        text: _("attachments:")
+        bold: True
+    AttachmentsLayout:
+        id: attachments_layout
+    BoxLayout:
+        id: resize_box
+        size_hint: 1, None
+        opacity: 1 if root.show_resize else 0
+        height: dp(25) if root.show_resize else 0
+        Widget:
+        CheckBox:
+            id: reduce_checkbox
+            size_hint: None, 1
+            width: dp(20)
+            active: True
+        Label:
+            size_hint: None, 1
+            text: _("reduce images size")
+            text_size: None, None
+            size: self.texture_size
+            valign: "middle"
+            padding_x: dp(10)
+            font_size: sp(15)
+        Widget:
+
+<Chat>:
+    attachments_to_send: attachments_to_send
+    message_input: message_input
+    messages_widget: messages_widget
+    history_scroll: history_scroll
+    send_button_visible: G.local_platform.send_button_visible or bool(attachments_to_send.attachments.children)
+    ScrollView:
+        id: history_scroll
+        scroll_y: 0
+        on_scroll_y: root.on_scroll(*args)
+        do_scroll_x: False
+        scroll_type: ['bars', 'content']
+        bar_width: dp(10)
+        effect_cls: ScrollEffect
+        DelayedBoxLayout:
+            id: messages_widget
+            size_hint_y: None
+            padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)]
+            spacing: dp(10)
+            height: self.minimum_height
+            orientation: 'vertical'
+    AttachmentsToSend:
+        id: attachments_to_send
+    MessageInputBox:
+        size_hint: 1, None
+        height: self.minimum_height
+        spacing: dp(10)
+        padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)]
+        message_input: message_input
+        MessageInputWidget:
+            id: message_input
+            size_hint: 1, None
+            height: min(self.minimum_height, dp(250))
+            multiline: True
+            hint_text: _(u"Enter your message here")
+            on_text_validate: root.on_send(args[0])
+        SymbolButton:
+            # "send" button, permanent visibility depends on platform
+            symbol: "forward"
+            size_hint: None, 1
+            width: dp(30) if root.send_button_visible else 0
+            opacity: 1 if root.send_button_visible else 0
+            font_size: dp(25)
+            on_release: self.parent.send_text()
+
+
+# Buttons added in header
+
+<TransferButton>:
+    size_hint: None, 1
+    symbol: "plus-circled"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+
+<MenuButton@Button>
+    size_hint_y: None
+    height: dp(30)
+    on_texture_size: self.parent.parent.width = max(self.parent.parent.width, self.texture_size[0] + dp(10))
+
+<ExtraMenu>:
+    auto_width: False
+    MenuButton:
+        text: _("bookmarks")
+        on_release: root.select("bookmark")
+    MenuButton:
+        text: _("close")
+        on_release: root.select("close")
+
+<ExtraButton>:
+    size_hint: None, 1
+    symbol: "dot-3-vert"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+    on_release: self.chat.extra_menu.open(self)
+
+<EncryptionMainButton>:
+    size_hint: None, 1
+    width: dp(30)
+    color: self.get_color()
+    symbol: self.get_symbol()
+
+<TrustManagementButton>:
+    symbol: "shield"
+    padding: dp(5), dp(10)
+    bg_color: app.c_prim_dark
+    size_hint: None, 1
+    width: dp(30)
+    on_release: self.parent.dispatch("on_trust_release")
+
+<EncryptionButton>:
+    size_hint: None, None
+    width: self.parent.parent.best_width if self.parent is not None else 30
+    height: dp(30)
+    on_best_width: self.parent.parent.best_width = max(self.parent.parent.best_width, args[1])
+    Button:
+        text: root.text
+        size_hint: 1, 1
+        padding: dp(5), dp(10)
+        color: 0, 0, 0, 1
+        bold: root.bold
+        background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
+        background_color: app.c_sec if root.selected else app.c_prim_dark
+        on_release: root.dispatch("on_release")
+        on_texture_size: root.best_width = self.texture_size[0] + (dp(30) if root.trust_button else 0)
+
+<EncryptionMenu>:
+    size_hint_x: None
+    width: self.container.minimum_width
+    auto_width: False
+    canvas.before:
+        Color:
+            rgba: 0, 0, 0, 1
+        Rectangle:
+            pos: self.pos
+            size: self.size
+
+
+# Chat Selector
+
+<ChatSelector>:
+    jid_selector: jid_selector
+    JidSelector:
+        id: jid_selector
+        on_select: root.on_select(args[1])
+        to_show: ["opened_chats", "roster", "bookmarks"]
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_chat.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,1280 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 functools import partial
+from pathlib import Path
+import sys
+import uuid
+import mimetypes
+from urllib.parse import urlparse
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.gridlayout import GridLayout
+from kivy.uix.screenmanager import Screen, NoTransition
+from kivy.uix.textinput import TextInput
+from kivy.uix.label import Label
+from kivy.uix import screenmanager
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.metrics import sp, dp
+from kivy.clock import Clock
+from kivy import properties
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.dropdown import DropDown
+from kivy.core.window import Window
+from libervia.backend.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import data_format
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.quick_frontend import quick_chat
+from libervia.frontends.tools import jid
+from libervia.desktop_kivy import G
+from ..core.constants import Const as C
+from ..core import cagou_widget
+from ..core import xmlui
+from ..core.image import Image, AsyncImage
+from ..core.common import Symbol, SymbolButton, JidButton, ContactButton
+from ..core.behaviors import FilterBehavior
+from ..core import menu
+from ..core.common_widgets import ImagesGallery
+
+log = logging.getLogger(__name__)
+
+PLUGIN_INFO = {
+    "name": _("chat"),
+    "main": "Chat",
+    "description": _("instant messaging with one person or a group"),
+    "icon_symbol": "chat",
+}
+
+# FIXME: OTR specific code is legacy, and only used nowadays for lock color
+# we can probably get rid of them.
+OTR_STATE_UNTRUSTED = 'untrusted'
+OTR_STATE_TRUSTED = 'trusted'
+OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED)
+OTR_STATE_UNENCRYPTED = 'unencrypted'
+OTR_STATE_ENCRYPTED = 'encrypted'
+OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED)
+
+SYMBOL_UNENCRYPTED = 'lock-open'
+SYMBOL_ENCRYPTED = 'lock'
+SYMBOL_ENCRYPTED_TRUSTED = 'lock-filled'
+COLOR_UNENCRYPTED = (0.4, 0.4, 0.4, 1)
+COLOR_ENCRYPTED = (0.4, 0.4, 0.4, 1)
+COLOR_ENCRYPTED_TRUSTED = (0.29,0.87,0.0,1)
+
+# below this limit, new messages will be prepended
+INFINITE_SCROLL_LIMIT = dp(600)
+
+# File sending progress
+PROGRESS_UPDATE = 0.2 # number of seconds before next progress update
+
+
+# FIXME: a ScrollLayout was supposed to be used here, but due
+#   to https://github.com/kivy/kivy/issues/6745, a StackLayout is used for now
+class AttachmentsLayout(StackLayout):
+    """Layout for attachments in a received message"""
+    padding = properties.VariableListProperty([dp(5), dp(5), 0, dp(5)])
+    attachments = properties.ObjectProperty()
+
+
+class AttachmentsToSend(BoxLayout):
+    """Layout for attachments to be sent with current message"""
+    attachments = properties.ObjectProperty()
+    reduce_checkbox = properties.ObjectProperty()
+    show_resize = properties.BooleanProperty(False)
+
+    def on_kv_post(self, __):
+        self.attachments.bind(children=self.on_attachment)
+
+    def on_attachment(self, __, attachments):
+        if len(attachments) == 0:
+            self.show_resize = False
+
+
+class BaseAttachmentItem(BoxLayout):
+    data = properties.DictProperty()
+    progress = properties.NumericProperty(0)
+
+
+class AttachmentItem(BaseAttachmentItem):
+
+    def get_symbol(self, data):
+        media_type = data.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '')
+        main_type = media_type.split('/', 1)[0]
+        if main_type == 'image':
+            return "file-image"
+        elif main_type == 'video':
+            return "file-video"
+        elif main_type == 'audio':
+            return "file-audio"
+        else:
+            return "doc"
+
+    def on_press(self):
+        url = self.data.get('url')
+        if url:
+            G.local_platform.open_url(url, self)
+        else:
+            log.warning(f"can't find URL in {self.data}")
+
+
+class AttachmentImageItem(ButtonBehavior, BaseAttachmentItem):
+    image = properties.ObjectProperty()
+
+    def on_press(self):
+        full_size_source = self.data.get('path', self.data.get('url'))
+        gallery = ImagesGallery(sources=[full_size_source])
+        G.host.show_extra_ui(gallery)
+
+    def on_kv_post(self, __):
+        self.on_data(None, self.data)
+
+    def on_data(self, __, data):
+        if self.image is None:
+            return
+        source = data.get('preview') or data.get('path') or data.get('url')
+        if source:
+            self.image.source = source
+
+
+class AttachmentImagesCollectionItem(ButtonBehavior, GridLayout):
+    attachments = properties.ListProperty([])
+    chat = properties.ObjectProperty()
+    mess_data = properties.ObjectProperty()
+
+    def _set_preview(self, attachment, wid, preview_path):
+        attachment['preview'] = preview_path
+        wid.source = preview_path
+
+    def _set_path(self, attachment, wid, path):
+        attachment['path'] = path
+        if wid is not None:
+            # we also need a preview for the widget
+            if 'preview' in attachment:
+                wid.source = attachment['preview']
+            else:
+                G.host.bridge.image_generate_preview(
+                    path,
+                    self.chat.profile,
+                    callback=partial(self._set_preview, attachment, wid),
+                )
+
+    def on_kv_post(self, __):
+        attachments = self.attachments
+        self.clear_widgets()
+        for idx, attachment in enumerate(attachments):
+            try:
+                url = attachment['url']
+            except KeyError:
+                url = None
+                to_download = False
+            else:
+                if url.startswith("aesgcm:"):
+                    del attachment['url']
+                    # if the file is encrypted, we need to download it for decryption
+                    to_download = True
+                else:
+                    to_download = False
+
+            if idx < 3 or len(attachments) <= 4:
+                if ((self.mess_data.own_mess
+                     or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))):
+                    wid = AsyncImage(size_hint=(1, 1), source="data/images/image-loading.gif")
+                    if 'preview' in attachment:
+                        wid.source = attachment["preview"]
+                    elif 'path' in attachment:
+                        G.host.bridge.image_generate_preview(
+                            attachment['path'],
+                            self.chat.profile,
+                            callback=partial(self._set_preview, attachment, wid),
+                        )
+                    elif url is None:
+                        log.warning(f"Can't find any source for {attachment}")
+                    else:
+                        # we'll download the file, the preview will then be generated
+                        to_download = True
+                else:
+                    # we don't download automatically the image if the contact is not
+                    # in roster, to avoid leaking the ip
+                    wid = Symbol(symbol="file-image")
+                self.add_widget(wid)
+            else:
+                wid = None
+
+            if to_download:
+                # the file needs to be downloaded, the widget source,
+                # attachment path, and preview will then be completed
+                G.host.download_url(
+                    url,
+                    callback=partial(self._set_path, attachment, wid),
+                    dest=C.FILE_DEST_CACHE,
+                    profile=self.chat.profile,
+                )
+
+        if len(attachments) > 4:
+            counter = Label(
+                bold=True,
+                text=f"+{len(attachments) - 3}",
+            )
+            self.add_widget(counter)
+
+    def on_press(self):
+        sources = []
+        for attachment in self.attachments:
+            source = attachment.get('path') or attachment.get('url')
+            if not source:
+                log.warning(f"no source for {attachment}")
+            else:
+                sources.append(source)
+        gallery = ImagesGallery(sources=sources)
+        G.host.show_extra_ui(gallery)
+
+
+class AttachmentToSendItem(AttachmentItem):
+    # True when the item is being sent
+    sending = properties.BooleanProperty(False)
+
+
+class MessAvatar(ButtonBehavior, Image):
+    pass
+
+
+class MessageWidget(quick_chat.MessageWidget, BoxLayout):
+    mess_data = properties.ObjectProperty()
+    mess_xhtml = properties.ObjectProperty()
+    mess_padding = (dp(5), dp(5))
+    avatar = properties.ObjectProperty()
+    delivery = properties.ObjectProperty()
+    font_size = properties.NumericProperty(sp(12))
+    right_part = properties.ObjectProperty()
+    header_box = properties.ObjectProperty()
+
+    def on_kv_post(self, __):
+        if not self.mess_data:
+            raise exceptions.InternalError(
+                "mess_data must always be set in MessageWidget")
+
+        self.mess_data.widgets.add(self)
+        self.add_attachments()
+
+    @property
+    def chat(self):
+        """return parent Chat instance"""
+        return self.mess_data.parent
+
+    def _get_from_mess_data(self, name, default):
+        if self.mess_data is None:
+            return default
+        return getattr(self.mess_data, name)
+
+    def _get_message(self):
+        """Return currently displayed message"""
+        if self.mess_data is None:
+            return ""
+        return self.mess_data.main_message
+
+    def _set_message(self, message):
+        if self.mess_data is None:
+            return False
+        if message == self.mess_data.message.get(""):
+            return False
+        self.mess_data.message = {"": message}
+        return True
+
+    message = properties.AliasProperty(
+        partial(_get_from_mess_data, name="main_message", default=""),
+        _set_message,
+        bind=['mess_data'],
+    )
+    message_xhtml = properties.AliasProperty(
+        partial(_get_from_mess_data, name="main_message_xhtml", default=""),
+        bind=['mess_data'])
+    mess_type = properties.AliasProperty(
+        partial(_get_from_mess_data, name="type", default=""), bind=['mess_data'])
+    own_mess = properties.AliasProperty(
+        partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data'])
+    nick = properties.AliasProperty(
+        partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data'])
+    time_text = properties.AliasProperty(
+        partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data'])
+
+    @property
+    def info_type(self):
+        return self.mess_data.info_type
+
+    def update(self, update_dict):
+        if 'avatar' in update_dict:
+            avatar_data = update_dict['avatar']
+            if avatar_data is None:
+                source = G.host.get_default_avatar()
+            else:
+                source = avatar_data['path']
+            self.avatar.source = source
+        if 'status' in update_dict:
+            status = update_dict['status']
+            self.delivery.text =  '\u2714' if status == 'delivered' else ''
+
+    def _set_path(self, data, path):
+        """Set path of decrypted file to an item"""
+        data['path'] = path
+
+    def add_attachments(self):
+        """Add attachments layout + attachments item"""
+        attachments = self.mess_data.attachments
+        if not attachments:
+            return
+        root_layout = AttachmentsLayout()
+        self.right_part.add_widget(root_layout)
+        layout = root_layout.attachments
+
+        image_attachments = []
+        other_attachments = []
+        # we first separate images and other attachments, so we know if we need
+        # to use an image collection
+        for attachment in attachments:
+            media_type = attachment.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '')
+            main_type = media_type.split('/', 1)[0]
+            # GIF images are really badly handled by Kivy, the memory
+            # consumption explode, and the images frequencies are not handled
+            # correctly, thus we can't display them and we consider them as
+            # other attachment, so user can open the item with appropriate
+            # software.
+            if main_type == 'image' and media_type != "image/gif":
+                image_attachments.append(attachment)
+            else:
+                other_attachments.append(attachment)
+
+        if len(image_attachments) > 1:
+            collection = AttachmentImagesCollectionItem(
+                attachments=image_attachments,
+                chat=self.chat,
+                mess_data=self.mess_data,
+            )
+            layout.add_widget(collection)
+        elif image_attachments:
+            attachment = image_attachments[0]
+            # to avoid leaking IP address, we only display image if the contact is in
+            # roster
+            if ((self.mess_data.own_mess
+                 or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))):
+                try:
+                    url = urlparse(attachment['url'])
+                except KeyError:
+                    item = AttachmentImageItem(data=attachment)
+                else:
+                    if url.scheme == "aesgcm":
+                        # we remove the URL now, we'll replace it by
+                        # the local decrypted version
+                        del attachment['url']
+                        item = AttachmentImageItem(data=attachment)
+                        G.host.download_url(
+                            url.geturl(),
+                            callback=partial(self._set_path, item.data),
+                            dest=C.FILE_DEST_CACHE,
+                            profile=self.chat.profile,
+                        )
+                    else:
+                        item = AttachmentImageItem(data=attachment)
+            else:
+                item = AttachmentItem(data=attachment)
+
+            layout.add_widget(item)
+
+        for attachment in other_attachments:
+            item = AttachmentItem(data=attachment)
+            layout.add_widget(item)
+
+
+class MessageInputBox(BoxLayout):
+    message_input = properties.ObjectProperty()
+
+    def send_text(self):
+        self.message_input.send_text()
+
+
+class MessageInputWidget(TextInput):
+
+    def keyboard_on_key_down(self, window, keycode, text, modifiers):
+        # We don't send text when shift is pressed to be able to add line feeds
+        # (i.e. multi-lines messages). We don't send on Android either as the
+        # send button appears on this platform.
+        if (keycode[-1] == "enter"
+            and "shift" not in modifiers
+            and sys.platform != 'android'):
+            self.send_text()
+        else:
+            return super(MessageInputWidget, self).keyboard_on_key_down(
+                window, keycode, text, modifiers)
+
+    def send_text(self):
+        self.dispatch('on_text_validate')
+
+
+class TransferButton(SymbolButton):
+    chat = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        menu.TransferMenu(
+            encrypted=self.chat.encrypted,
+            callback=self.chat.transfer_file,
+        ).show(self)
+
+
+class ExtraMenu(DropDown):
+    chat = properties.ObjectProperty()
+
+    def on_select(self, menu):
+        if menu == 'bookmark':
+            G.host.bridge.menu_launch(C.MENU_GLOBAL, ("groups", "bookmarks"),
+                                     {}, C.NO_SECURITY_LIMIT, self.chat.profile,
+                                     callback=partial(
+                                        G.host.action_manager, profile=self.chat.profile),
+                                     errback=G.host.errback)
+        elif menu == 'close':
+            if self.chat.type == C.CHAT_GROUP:
+                # for MUC, we have to indicate the backend that we've left
+                G.host.bridge.muc_leave(self.chat.target, self.chat.profile)
+            else:
+                # for one2one, backend doesn't keep any state, so we just delete the
+                # widget here in the frontend
+                G.host.widgets.delete_widget(
+                    self.chat, all_instances=True, explicit_close=True)
+        else:
+            raise exceptions.InternalError("Unknown menu: {}".format(menu))
+
+
+class ExtraButton(SymbolButton):
+    chat = properties.ObjectProperty()
+
+
+class EncryptionMainButton(SymbolButton):
+
+    def __init__(self, chat, **kwargs):
+        """
+        @param chat(Chat): Chat instance
+        """
+        self.chat = chat
+        self.encryption_menu = EncryptionMenu(chat)
+        super(EncryptionMainButton, self).__init__(**kwargs)
+        self.bind(on_release=self.encryption_menu.open)
+
+    def select_algo(self, name):
+        """Mark an encryption algorithm as selected.
+
+        This will also deselect all other button
+        @param name(unicode, None): encryption plugin name
+            None for plain text
+        """
+        buttons = self.encryption_menu.container.children
+        buttons[-1].selected = name is None
+        for button in buttons[:-1]:
+            button.selected = button.text == name
+
+    def get_color(self):
+        if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED:
+            return  (0.4, 0.4, 0.4, 1)
+        elif self.chat.otr_state_trust == OTR_STATE_TRUSTED:
+            return (0.29,0.87,0.0,1)
+        else:
+            return  (0.4, 0.4, 0.4, 1)
+
+    def get_symbol(self):
+        if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED:
+            return 'lock-open'
+        elif self.chat.otr_state_trust == OTR_STATE_TRUSTED:
+            return 'lock-filled'
+        else:
+            return 'lock'
+
+
+class TrustManagementButton(SymbolButton):
+    pass
+
+
+class EncryptionButton(BoxLayout):
+    selected = properties.BooleanProperty(False)
+    text = properties.StringProperty()
+    trust_button = properties.BooleanProperty(False)
+    best_width = properties.NumericProperty(0)
+    bold = properties.BooleanProperty(True)
+
+    def __init__(self, **kwargs):
+        super(EncryptionButton, self).__init__(**kwargs)
+        self.register_event_type('on_release')
+        self.register_event_type('on_trust_release')
+        if self.trust_button:
+            self.add_widget(TrustManagementButton())
+
+    def on_release(self):
+        pass
+
+    def on_trust_release(self):
+        pass
+
+
+class EncryptionMenu(DropDown):
+    # best with to display all algorithms buttons + trust buttons
+    best_width = properties.NumericProperty(0)
+
+    def __init__(self, chat, **kwargs):
+        """
+        @param chat(Chat): Chat instance
+        """
+        self.chat = chat
+        super(EncryptionMenu, self).__init__(**kwargs)
+        btn = EncryptionButton(
+            text=_("unencrypted (plain text)"),
+            on_release=self.unencrypted,
+            selected=True,
+            bold=False,
+            )
+        btn.bind(
+            on_release=self.unencrypted,
+        )
+        self.add_widget(btn)
+        for plugin in G.host.encryption_plugins:
+            if chat.type == C.CHAT_GROUP and plugin["directed"]:
+                # directed plugins can't work with group chat
+                continue
+            btn = EncryptionButton(
+                text=plugin['name'],
+                trust_button=True,
+                )
+            btn.bind(
+                on_release=partial(self.start_encryption, plugin=plugin),
+                on_trust_release=partial(self.get_trust_ui, plugin=plugin),
+            )
+            self.add_widget(btn)
+            log.info("added encryption: {}".format(plugin['name']))
+
+    def message_encryption_stop_cb(self):
+        log.info(_("Session with {destinee} is now in plain text").format(
+            destinee = self.chat.target))
+
+    def message_encryption_stop_eb(self, failure_):
+        msg = _("Error while stopping encryption with {destinee}: {reason}").format(
+            destinee = self.chat.target,
+            reason = failure_)
+        log.warning(msg)
+        G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR)
+
+    def unencrypted(self, button):
+        self.dismiss()
+        G.host.bridge.message_encryption_stop(
+            str(self.chat.target),
+            self.chat.profile,
+            callback=self.message_encryption_stop_cb,
+            errback=self.message_encryption_stop_eb)
+
+    def message_encryption_start_cb(self, plugin):
+        log.info(_("Session with {destinee} is now encrypted with {encr_name}").format(
+            destinee = self.chat.target,
+            encr_name = plugin['name']))
+
+    def message_encryption_start_eb(self, failure_):
+        msg = _("Session can't be encrypted with {destinee}: {reason}").format(
+            destinee = self.chat.target,
+            reason = failure_)
+        log.warning(msg)
+        G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR)
+
+    def start_encryption(self, button, plugin):
+        """Request encryption with given plugin for this session
+
+        @param button(EncryptionButton): button which has been pressed
+        @param plugin(dict): plugin data
+        """
+        self.dismiss()
+        G.host.bridge.message_encryption_start(
+            str(self.chat.target),
+            plugin['namespace'],
+            True,
+            self.chat.profile,
+            callback=partial(self.message_encryption_start_cb, plugin=plugin),
+            errback=self.message_encryption_start_eb)
+
+    def encryption_trust_ui_get_cb(self, xmlui_raw):
+        xml_ui = xmlui.create(
+            G.host, xmlui_raw, profile=self.chat.profile)
+        xml_ui.show()
+
+    def encryption_trust_ui_get_eb(self, failure_):
+        msg = _("Trust manager interface can't be retrieved: {reason}").format(
+            reason = failure_)
+        log.warning(msg)
+        G.host.add_note(_("encryption trust management problem"), msg,
+                       C.XMLUI_DATA_LVL_ERROR)
+
+    def get_trust_ui(self, button, plugin):
+        """Request and display trust management UI
+
+        @param button(EncryptionButton): button which has been pressed
+        @param plugin(dict): plugin data
+        """
+        self.dismiss()
+        G.host.bridge.encryption_trust_ui_get(
+            str(self.chat.target),
+            plugin['namespace'],
+            self.chat.profile,
+            callback=self.encryption_trust_ui_get_cb,
+            errback=self.encryption_trust_ui_get_eb)
+
+
+class Chat(quick_chat.QuickChat, cagou_widget.LiberviaDesktopKivyWidget):
+    message_input = properties.ObjectProperty()
+    messages_widget = properties.ObjectProperty()
+    history_scroll = properties.ObjectProperty()
+    attachments_to_send = properties.ObjectProperty()
+    send_button_visible = properties.BooleanProperty()
+    use_header_input = True
+    global_screen_manager = True
+    collection_carousel = True
+
+    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
+                 subject=None, statuses=None, profiles=None):
+        self.show_chat_selector = False
+        if statuses is None:
+            statuses = {}
+        quick_chat.QuickChat.__init__(
+            self, host, target, type_, nick, occupants, subject, statuses,
+            profiles=profiles)
+        self.otr_state_encryption = OTR_STATE_UNENCRYPTED
+        self.otr_state_trust = OTR_STATE_UNTRUSTED
+        # completion attributes
+        self._hi_comp_data = None
+        self._hi_comp_last = None
+        self._hi_comp_dropdown = DropDown()
+        self._hi_comp_allowed = True
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        transfer_btn = TransferButton(chat=self)
+        self.header_input_add_extra(transfer_btn)
+        if (type_ == C.CHAT_ONE2ONE or "REALJID_PUBLIC" in statuses):
+            self.encryption_btn = EncryptionMainButton(self)
+            self.header_input_add_extra(self.encryption_btn)
+        self.extra_menu = ExtraMenu(chat=self)
+        extra_btn = ExtraButton(chat=self)
+        self.header_input_add_extra(extra_btn)
+        self.header_input.hint_text = target
+        self._history_prepend_lock = False
+        self.history_count = 0
+
+    def on_kv_post(self, __):
+        self.post_init()
+
+    def screen_manager_init(self, screen_manager):
+        screen_manager.transition = screenmanager.SlideTransition(direction='down')
+        sel_screen = Screen(name='chat_selector')
+        chat_selector = ChatSelector(profile=self.profile)
+        sel_screen.add_widget(chat_selector)
+        screen_manager.add_widget(sel_screen)
+        if self.show_chat_selector:
+            transition = screen_manager.transition
+            screen_manager.transition = NoTransition()
+            screen_manager.current = 'chat_selector'
+            screen_manager.transition = transition
+
+    def __str__(self):
+        return "Chat({})".format(self.target)
+
+    def __repr__(self):
+        return self.__str__()
+
+    @classmethod
+    def factory(cls, plugin_info, target, profiles):
+        profiles = list(profiles)
+        if len(profiles) > 1:
+            raise NotImplementedError("Multi-profiles is not available yet for chat")
+        if target is None:
+            show_chat_selector = True
+            target = G.host.profiles[profiles[0]].whoami
+        else:
+            show_chat_selector = False
+        wid = G.host.widgets.get_or_create_widget(cls, target, on_new_widget=None,
+                                               on_existing_widget=G.host.get_or_clone,
+                                               profiles=profiles)
+        wid.show_chat_selector = show_chat_selector
+        return wid
+
+    @property
+    def message_widgets_rev(self):
+        return self.messages_widget.children
+
+    ## keyboard ##
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            screen_manager = self.screen_manager
+            screen_manager.transition.direction = 'down'
+            screen_manager.current = 'chat_selector'
+            return True
+
+    ## drop ##
+
+    def on_drop_file(self, path):
+        self.add_attachment(path)
+
+    ## header ##
+
+    def change_widget(self, jid_):
+        """change current widget for a new one with given jid
+
+        @param jid_(jid.JID): jid of the widget to create
+        """
+        plugin_info = G.host.get_plugin_info(main=Chat)
+        factory = plugin_info['factory']
+        G.host.switch_widget(self, factory(plugin_info, jid_, profiles=[self.profile]))
+        self.header_input.text = ''
+
+    def on_header_wid_input(self):
+        text = self.header_input.text.strip()
+        try:
+            if text.count('@') != 1 or text.count(' '):
+                raise ValueError
+            jid_ = jid.JID(text)
+        except ValueError:
+            log.info("entered text is not a jid")
+            return
+
+        def disco_cb(disco):
+            # TODO: check if plugin XEP-0045 is activated
+            if "conference" in [i[0] for i in disco[1]]:
+                G.host.bridge.muc_join(str(jid_), "", "", self.profile,
+                                      callback=self._muc_join_cb, errback=self._muc_join_eb)
+            else:
+                self.change_widget(jid_)
+
+        def disco_eb(failure):
+            log.warning("Disco failure, ignore this text: {}".format(failure))
+
+        G.host.bridge.disco_infos(
+            jid_.domain,
+            profile_key=self.profile,
+            callback=disco_cb,
+            errback=disco_eb)
+
+    def on_header_wid_input_completed(self, input_wid, completed_text):
+        self._hi_comp_allowed = False
+        input_wid.text = completed_text
+        self._hi_comp_allowed = True
+        self._hi_comp_dropdown.dismiss()
+        self.on_header_wid_input()
+
+    def on_header_wid_input_complete(self, wid, text):
+        if not self._hi_comp_allowed:
+            return
+        text = text.lstrip()
+        if not text:
+            self._hi_comp_data = None
+            self._hi_comp_last = None
+            self._hi_comp_dropdown.dismiss()
+            return
+
+        profile = list(self.profiles)[0]
+
+        if self._hi_comp_data is None:
+            # first completion, we build the initial list
+            comp_data = self._hi_comp_data = []
+            self._hi_comp_last = ''
+            for jid_, jid_data in G.host.contact_lists[profile].all_iter:
+                comp_data.append((jid_, jid_data))
+            comp_data.sort(key=lambda datum: datum[0])
+        else:
+            comp_data = self._hi_comp_data
+
+        # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed,
+        #      it works OK, but some optimisation may be done here
+        dropdown = self._hi_comp_dropdown
+
+        if not text.startswith(self._hi_comp_last) or not self._hi_comp_last:
+            # text has changed or backspace has been pressed, we restart
+            dropdown.clear_widgets()
+
+            for jid_, jid_data in comp_data:
+                nick = jid_data.get('nick', '')
+                if text in jid_.bare or text in nick.lower():
+                    btn = JidButton(
+                        jid = jid_.bare,
+                        profile = profile,
+                        size_hint = (0.5, None),
+                        nick = nick,
+                        on_release=lambda __, txt=jid_.bare: self.on_header_wid_input_completed(wid, txt)
+                        )
+                    dropdown.add_widget(btn)
+        else:
+            # more chars, we continue completion by removing unwanted widgets
+            to_remove = []
+            for c in dropdown.children[0].children:
+                if text not in c.jid and text not in (c.nick or ''):
+                    to_remove.append(c)
+            for c in to_remove:
+                dropdown.remove_widget(c)
+        if dropdown.attach_to is None:
+            dropdown.open(wid)
+        self._hi_comp_last = text
+
+    def message_data_converter(self, idx, mess_id):
+        return {"mess_data": self.messages[mess_id]}
+
+    def _on_history_printed(self):
+        """Refresh or scroll down the focus after the history is printed"""
+        # self.adapter.data = self.messages
+        for mess_data in self.messages.values():
+            self.appendMessage(mess_data)
+        super(Chat, self)._on_history_printed()
+
+    def create_message(self, message):
+        self.appendMessage(message)
+        # we need to render immediatly next 2 layouts to avoid an unpleasant flickering
+        # when sending or receiving a message
+        self.messages_widget.dont_delay_next_layouts = 2
+
+    def appendMessage(self, mess_data):
+        """Append a message Widget to the history
+
+        @param mess_data(quick_chat.Message): message data
+        """
+        if self.handle_user_moved(mess_data):
+            return
+        self.messages_widget.add_widget(MessageWidget(mess_data=mess_data))
+        self.notify(mess_data)
+
+    def prepend_message(self, mess_data):
+        """Prepend a message Widget to the history
+
+        @param mess_data(quick_chat.Message): message data
+        """
+        mess_wid = self.messages_widget
+        last_idx = len(mess_wid.children)
+        mess_wid.add_widget(MessageWidget(mess_data=mess_data), index=last_idx)
+
+    def _get_notif_msg(self, mess_data):
+        return _("{nick}: {message}").format(
+            nick=mess_data.nick,
+            message=mess_data.main_message)
+
+    def notify(self, mess_data):
+        """Notify user when suitable
+
+        For one2one chat, notification will happen when window has not focus
+        or when one2one chat is not visible. A note is also there when widget
+        is not visible.
+        For group chat, note will be added on mention, with a desktop notification if
+        window has not focus or is not visible.
+        """
+        visible_clones = [w for w in G.host.get_visible_list(self.__class__)
+                          if w.target == self.target]
+        if len(visible_clones) > 1 and visible_clones.index(self) > 0:
+            # to avoid multiple notifications in case of multiple cloned widgets
+            # we only handle first clone
+            return
+        is_visible = bool(visible_clones)
+        if self.type == C.CHAT_ONE2ONE:
+            if (not Window.focus or not is_visible) and not mess_data.history:
+                notif_msg = self._get_notif_msg(mess_data)
+                G.host.notify(
+                    type_=C.NOTIFY_MESSAGE,
+                    entity=mess_data.from_jid,
+                    message=notif_msg,
+                    subject=_("private message"),
+                    widget=self,
+                    profile=self.profile
+                )
+                if not is_visible:
+                    G.host.add_note(
+                        _("private message"),
+                        notif_msg,
+                        symbol = "chat",
+                        action = {
+                            "action": 'chat',
+                            "target": self.target,
+                            "profiles": self.profiles}
+                        )
+        else:
+            if mess_data.mention:
+                notif_msg = self._get_notif_msg(mess_data)
+                G.host.add_note(
+                    _("mention"),
+                    notif_msg,
+                    symbol = "chat",
+                    action = {
+                        "action": 'chat',
+                        "target": self.target,
+                        "profiles": self.profiles}
+                    )
+                if not is_visible or not Window.focus:
+                    subject=_("mention ({room_jid})").format(room_jid=self.target)
+                    G.host.notify(
+                        type_=C.NOTIFY_MENTION,
+                        entity=self.target,
+                        message=notif_msg,
+                        subject=subject,
+                        widget=self,
+                        profile=self.profile
+                    )
+
+    # message input
+
+    def _attachment_progress_cb(self, item, metadata, profile):
+        item.parent.remove_widget(item)
+        log.info(f"item {item.data.get('path')} uploaded successfully")
+
+    def _attachment_progress_eb(self, item, err_msg, profile):
+        item.parent.remove_widget(item)
+        path = item.data.get('path')
+        msg = _("item {path} could not be uploaded: {err_msg}").format(
+            path=path, err_msg=err_msg)
+        G.host.add_note(_("can't upload file"), msg, C.XMLUI_DATA_LVL_WARNING)
+        log.warning(msg)
+
+    def _progress_get_cb(self, item, metadata):
+        try:
+            position = int(metadata["position"])
+            size = int(metadata["size"])
+        except KeyError:
+            # we got empty metadata, the progression is either not yet started or
+            # finished
+            if item.progress:
+                # if progress is already started, receiving empty metadata means
+                # that progression is finished
+                item.progress = 100
+                return
+        else:
+            item.progress = position/size*100
+
+        if item.parent is not None:
+            # the item is not yet fully received, we reschedule an update
+            Clock.schedule_once(
+                partial(self._attachment_progress_update, item),
+                PROGRESS_UPDATE)
+
+    def _attachment_progress_update(self, item, __):
+        G.host.bridge.progress_get(
+            item.data["progress_id"],
+            self.profile,
+            callback=partial(self._progress_get_cb, item),
+            errback=G.host.errback,
+        )
+
+    def add_nick(self, nick):
+        """Add a nickname to message_input if suitable"""
+        if (self.type == C.CHAT_GROUP and not self.message_input.text.startswith(nick)):
+            self.message_input.text = f'{nick}: {self.message_input.text}'
+
+    def on_send(self, input_widget):
+        extra = {}
+        for item in self.attachments_to_send.attachments.children:
+            if item.sending:
+                # the item is already being sent
+                continue
+            item.sending = True
+            progress_id = item.data["progress_id"] = str(uuid.uuid4())
+            attachments = extra.setdefault(C.KEY_ATTACHMENTS, [])
+            attachment = {
+                "path": str(item.data["path"]),
+                "progress_id": progress_id,
+            }
+            if 'media_type' in item.data:
+                attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = item.data['media_type']
+
+            if ((self.attachments_to_send.reduce_checkbox.active
+                 and attachment.get('media_type', '').split('/')[0] == 'image')):
+                attachment[C.KEY_ATTACHMENTS_RESIZE] = True
+
+            attachments.append(attachment)
+
+            Clock.schedule_once(
+                partial(self._attachment_progress_update, item),
+                PROGRESS_UPDATE)
+
+            G.host.register_progress_cbs(
+                progress_id,
+                callback=partial(self._attachment_progress_cb, item),
+                errback=partial(self._attachment_progress_eb, item)
+            )
+
+
+        G.host.message_send(
+            self.target,
+            # TODO: handle language
+            {'': input_widget.text},
+            # TODO: put this in QuickChat
+            mess_type=
+                C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT,
+            extra=extra,
+            profile_key=self.profile
+            )
+        input_widget.text = ''
+
+    def _image_check_cb(self, report_raw):
+        report = data_format.deserialise(report_raw)
+        if report['too_large']:
+            self.attachments_to_send.show_resize=True
+            self.attachments_to_send.reduce_checkbox.active=True
+
+    def add_attachment(self, file_path, media_type=None):
+        file_path = Path(file_path)
+        if media_type is None:
+            media_type = mimetypes.guess_type(str(file_path), strict=False)[0]
+        if not self.attachments_to_send.show_resize and media_type is not None:
+            # we check if the attachment is an image and if it's too large.
+            # If too large, the reduce size check box will be displayed, and checked by
+            # default.
+            main_type = media_type.split('/')[0]
+            if main_type == "image":
+                G.host.bridge.image_check(
+                    str(file_path),
+                    callback=self._image_check_cb,
+                    errback=partial(
+                        G.host.errback,
+                        title=_("Can't check image size"),
+                        message=_("Can't check image at {path}: {{msg}}").format(
+                            path=file_path),
+                    )
+                )
+
+        data = {
+            "path": file_path,
+            "name": file_path.name,
+        }
+
+        if media_type is not None:
+            data['media_type'] = media_type
+
+        self.attachments_to_send.attachments.add_widget(
+            AttachmentToSendItem(data=data)
+        )
+
+    def transfer_file(self, file_path, transfer_type=C.TRANSFER_UPLOAD, cleaning_cb=None):
+        # FIXME: cleaning_cb is not managed
+        if transfer_type == C.TRANSFER_UPLOAD:
+            self.add_attachment(file_path)
+        elif transfer_type == C.TRANSFER_SEND:
+            if self.type == C.CHAT_GROUP:
+                log.warning("P2P transfer is not possible for group chat")
+                # TODO: show an error dialog to user, or better hide the send button for
+                #       MUC
+            else:
+                jid_ = self.target
+                if not jid_.resource:
+                    jid_ = G.host.contact_lists[self.profile].get_full_jid(jid_)
+                G.host.bridge.file_send(str(jid_), str(file_path), "", "", "",
+                                       profile=self.profile)
+                # TODO: notification of sending/failing
+        else:
+            raise log.error("transfer of type {} are not handled".format(transfer_type))
+
+    def message_encryption_started(self, plugin_data):
+        quick_chat.QuickChat.message_encryption_started(self, plugin_data)
+        self.encryption_btn.symbol = SYMBOL_ENCRYPTED
+        self.encryption_btn.color = COLOR_ENCRYPTED
+        self.encryption_btn.select_algo(plugin_data['name'])
+
+    def message_encryption_stopped(self, plugin_data):
+        quick_chat.QuickChat.message_encryption_stopped(self, plugin_data)
+        self.encryption_btn.symbol = SYMBOL_UNENCRYPTED
+        self.encryption_btn.color = COLOR_UNENCRYPTED
+        self.encryption_btn.select_algo(None)
+
+    def _muc_join_cb(self, joined_data):
+        joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data
+        self.host.muc_room_joined_handler(*joined_data[1:])
+        jid_ = jid.JID(room_jid_s)
+        self.change_widget(jid_)
+
+    def _muc_join_eb(self, failure):
+        log.warning("Can't join room: {}".format(failure))
+
+    def on_otr_state(self, state, dest_jid, profile):
+        assert profile in self.profiles
+        if state in OTR_STATE_ENCRYPTION:
+            self.otr_state_encryption = state
+        elif state in OTR_STATE_TRUST:
+            self.otr_state_trust = state
+        else:
+            log.error(_("Unknown OTR state received: {}".format(state)))
+            return
+        self.encryption_btn.symbol = self.encryption_btn.get_symbol()
+        self.encryption_btn.color = self.encryption_btn.get_color()
+
+    def on_visible(self):
+        if not self.sync:
+            self.resync()
+
+    def on_selected(self):
+        G.host.clear_notifs(self.target, profile=self.profile)
+
+    def on_delete(self, **kwargs):
+        if kwargs.get('explicit_close', False):
+            wrapper = self.whwrapper
+            if wrapper is not None:
+                if len(wrapper.carousel.slides) == 1:
+                    # if we delete the last opened chat, we need to show the selector
+                    screen_manager = self.screen_manager
+                    screen_manager.transition.direction = 'down'
+                    screen_manager.current = 'chat_selector'
+                wrapper.carousel.remove_widget(self)
+            return True
+        # we always keep one widget, so it's available when swiping
+        # TODO: delete all widgets when chat is closed
+        nb_instances = sum(1 for _ in self.host.widgets.get_widget_instances(self))
+        # we want to keep at least one instance of Chat by WHWrapper
+        nb_to_keep = len(G.host.widgets_handler.children)
+        if nb_instances <= nb_to_keep:
+            return False
+
+    def _history_unlock(self, __):
+        self._history_prepend_lock = False
+        log.debug("history prepend unlocked")
+        # we call manually on_scroll, to check if we are still in the scrolling zone
+        self.on_scroll(self.history_scroll, self.history_scroll.scroll_y)
+
+    def _history_scroll_adjust(self, __, scroll_start_height):
+        # history scroll position must correspond to where it was before new messages
+        # have been appended
+        self.history_scroll.scroll_y = (
+            scroll_start_height / self.messages_widget.height
+        )
+
+        # we want a small delay before unlocking, to avoid re-fetching history
+        # again
+        Clock.schedule_once(self._history_unlock, 1.5)
+
+    def _back_history_get_cb_post(self, __, history, scroll_start_height):
+        if len(history) == 0:
+            # we don't unlock self._history_prepend_lock if there is no history, as there
+            # is no sense to try to retrieve more in this case.
+            log.debug(f"we've reached top of history for {self.target.bare} chat")
+        else:
+            # we have to schedule again for _history_scroll_adjust, else messages_widget
+            # is not resized (self.messages_widget.height is not yet updated)
+            # as a result, the scroll_to can't work correctly
+            Clock.schedule_once(partial(
+                self._history_scroll_adjust,
+                scroll_start_height=scroll_start_height))
+            log.debug(
+                f"{len(history)} messages prepended to history (last: {history[0][0]})")
+
+    def _back_history_get_cb(self, history):
+        # TODO: factorise with QuickChat._history_get_cb
+        scroll_start_height = self.messages_widget.height * self.history_scroll.scroll_y
+        for data in reversed(history):
+            uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data
+            from_jid = jid.JID(from_jid)
+            to_jid = jid.JID(to_jid)
+            extra = data_format.deserialise(extra_s)
+            extra["history"] = True
+            self.messages[uid] = message = quick_chat.Message(
+                self,
+                uid,
+                timestamp,
+                from_jid,
+                to_jid,
+                message,
+                subject,
+                type_,
+                extra,
+                self.profile,
+            )
+            self.messages.move_to_end(uid, last=False)
+            self.prepend_message(message)
+        Clock.schedule_once(partial(
+            self._back_history_get_cb_post,
+            history=history,
+            scroll_start_height=scroll_start_height))
+
+    def _back_history_get_eb(self, failure_):
+        G.host.add_note(
+            _("Problem while getting back history"),
+            _("Can't back history for {target}: {problem}").format(
+                target=self.target, problem=failure_),
+            C.XMLUI_DATA_LVL_ERROR)
+        # we don't unlock self._history_prepend_lock on purpose, no need
+        # to try to get more history if something is wrong
+
+    def on_scroll(self, scroll_view, scroll_y):
+        if self._history_prepend_lock:
+            return
+        if (1-scroll_y) * self.messages_widget.height < INFINITE_SCROLL_LIMIT:
+            self._history_prepend_lock = True
+            log.debug(f"Retrieving back history for {self} [{self.history_count}]")
+            self.history_count += 1
+            first_uid = next(iter(self.messages.keys()))
+            filters = self.history_filters.copy()
+            filters['before_uid'] = first_uid
+            self.host.bridge.history_get(
+                str(self.host.profiles[self.profile].whoami.bare),
+                str(self.target),
+                30,
+                True,
+                {k: str(v) for k,v in filters.items()},
+                self.profile,
+                callback=self._back_history_get_cb,
+                errback=self._back_history_get_eb,
+            )
+
+
+class ChatSelector(cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior):
+    jid_selector = properties.ObjectProperty()
+    profile = properties.StringProperty()
+    plugin_info_class = Chat
+    use_header_input = True
+
+    def on_select(self, contact_button):
+        contact_jid = jid.JID(contact_button.jid)
+        plugin_info = G.host.get_plugin_info(main=Chat)
+        factory = plugin_info['factory']
+        self.screen_manager.transition.direction = 'up'
+        carousel = self.whwrapper.carousel
+        current_slides = {w.target: w for w in carousel.slides}
+        if contact_jid in current_slides:
+            slide = current_slides[contact_jid]
+            idx = carousel.slides.index(slide)
+            carousel.index = idx
+            self.screen_manager.current = ''
+        else:
+            G.host.switch_widget(
+                self, factory(plugin_info, contact_jid, profiles=[self.profile]))
+
+
+    def on_header_wid_input(self):
+        text = self.header_input.text.strip()
+        try:
+            if text.count('@') != 1 or text.count(' '):
+                raise ValueError
+            jid_ = jid.JID(text)
+        except ValueError:
+            log.info("entered text is not a jid")
+            return
+        G.host.do_action("chat", jid_, [self.profile])
+
+    def on_header_wid_input_complete(self, wid, text, **kwargs):
+        """we filter items when text is entered in input box"""
+        for layout in self.jid_selector.items_layouts:
+            self.do_filter(
+                layout,
+                text,
+                # we append nick to jid to filter on both
+                lambda c: c.jid + c.data.get('nick', ''),
+                width_cb=lambda c: c.base_width,
+                height_cb=lambda c: c.minimum_height,
+                continue_tests=[lambda c: not isinstance(c, ContactButton)])
+
+
+PLUGIN_INFO["factory"] = Chat.factory
+quick_widgets.register(quick_chat.QuickChat, Chat)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_contact_list.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,104 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import _ libervia.backend.core.i18n._
+#:import e kivy.utils.escape_markup
+
+<AddContactMenu>:
+    padding: dp(20)
+    spacing: dp(10)
+    Label:
+        size_hint: 1, None
+        color: 1, 1, 1, 1
+        text: _("Please enter new contact JID")
+        text_size: root.width, None
+        size: self.texture_size
+        halign: "center"
+        bold: True
+    TextInput:
+        id: contact_jid
+        size_hint: 1, None
+        height: sp(30)
+        hint_text: _("enter here your new contact JID")
+    Button:
+        size_hint: 1, None
+        height: sp(50)
+        text: _("add this contact")
+        on_release: root.contact_add(contact_jid.text)
+    Widget:
+
+
+<DelContactMenu>:
+    padding: dp(20)
+    spacing: dp(10)
+    Avatar:
+        id: avatar
+        size_hint: 1, None
+        height: dp(60)
+        data: root.contact_item.data.get('avatar')
+        allow_stretch: True
+    Label:
+        size_hint: 1, None
+        color: 1, 1, 1, 1
+        text: _("Are you sure you wand to remove [b]{name}[/b] from your contact list?").format(name=e(root.contact_item.jid))
+        markup: True
+        text_size: root.width, None
+        size: self.texture_size
+        halign: "center"
+    BoxLayout:
+        Button:
+            background_color: 1, 0, 0, 1
+            size_hint: 0.5, None
+            height: sp(50)
+            text: _("yes, remove it")
+            bold: True
+            on_release: root.do_delete_contact()
+        Button:
+            size_hint: 0.5, None
+            height: sp(50)
+            text: _("no, keep it")
+            on_release: root.hide()
+    Widget:
+
+
+<ContactList>:
+    float_layout: float_layout
+    layout: layout
+    orientation: 'vertical'
+    BoxLayout:
+        size_hint: 1, None
+        height: dp(35)
+        width: dp(35)
+        font_size: dp(30)
+        Widget:
+        SymbolButtonLabel:
+            symbol: 'plus-circled'
+            text: _("add a contact")
+            on_release: root.add_contact_menu()
+        Widget:
+    FloatLayout:
+        id: float_layout
+        ScrollView:
+            size_hint: 1, 1
+            pos_hint: {'x': 0, 'y': 0}
+            do_scroll_x: False
+            scroll_type: ['bars', 'content']
+            bar_width: dp(6)
+            StackLayout:
+                id: layout
+                size_hint: 1, None
+                height: self.minimum_height
+                spacing: 0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_contact_list.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 functools import partial
+import bisect
+import re
+from libervia.backend.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.frontends.quick_frontend.quick_contact_list import QuickContactList
+from libervia.frontends.tools import jid
+from kivy import properties
+from libervia.desktop_kivy import G
+from ..core import cagou_widget
+from ..core.constants import Const as C
+from ..core.common import ContactItem
+from ..core.behaviors import FilterBehavior, TouchMenuBehavior, TouchMenuItemBehavior
+from ..core.menu import SideMenu
+
+
+log = logging.getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    "name": _("contacts"),
+    "main": "ContactList",
+    "description": _("list of contacts"),
+    "icon_medium": "{media}/icons/muchoslava/png/contact_list_no_border_blue_44.png"
+}
+
+
+class AddContactMenu(SideMenu):
+    profile = properties.StringProperty()
+    size_hint_close = (1, 0)
+    size_hint_open = (1, 0.5)
+
+    def __init__(self, **kwargs):
+        super(AddContactMenu, self).__init__(**kwargs)
+        if self.profile is None:
+            log.warning(_("profile not set in AddContactMenu"))
+            self.profile = next(iter(G.host.profiles))
+
+    def contact_add(self, contact_jid):
+        """Actually add the contact
+
+        @param contact_jid(unicode): jid of the contact to add
+        """
+        self.hide()
+        contact_jid = contact_jid.strip()
+        # FIXME: trivial jid verification
+        if not contact_jid or not re.match(r"[^@ ]+@[^@ ]+", contact_jid):
+            return
+        contact_jid = jid.JID(contact_jid).bare
+        G.host.bridge.contact_add(str(contact_jid),
+            self.profile,
+            callback=lambda: G.host.add_note(
+                _("contact request"),
+                _("a contact request has been sent to {contact_jid}").format(
+                    contact_jid=contact_jid)),
+            errback=partial(G.host.errback,
+                title=_("can't add contact"),
+                message=_("error while trying to add contact: {msg}")))
+
+
+class DelContactMenu(SideMenu):
+    size_hint_close = (1, 0)
+    size_hint_open = (1, 0.5)
+
+    def __init__(self, contact_item, **kwargs):
+        self.contact_item = contact_item
+        super(DelContactMenu, self).__init__(**kwargs)
+
+    def do_delete_contact(self):
+        self.hide()
+        G.host.bridge.contact_del(str(self.contact_item.jid.bare),
+        self.contact_item.profile,
+        callback=lambda: G.host.add_note(
+            _("contact removed"),
+            _("{contact_jid} has been removed from your contacts list").format(
+                contact_jid=self.contact_item.jid.bare)),
+        errback=partial(G.host.errback,
+            title=_("can't remove contact"),
+            message=_("error while trying to remove contact: {msg}")))
+
+
+class CLContactItem(TouchMenuItemBehavior, ContactItem):
+
+    def do_item_action(self, touch):
+        assert self.profile
+        # XXX: for now clicking on an item launch the corresponding Chat widget
+        #      behaviour should change in the future
+        G.host.do_action('chat', jid.JID(self.jid), [self.profile])
+
+    def get_menu_choices(self):
+        choices = []
+        choices.append(dict(text=_('delete'),
+                            index=len(choices)+1,
+                            callback=self.main_wid.remove_contact))
+        return choices
+
+
+class ContactList(QuickContactList, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior,
+                  TouchMenuBehavior):
+    float_layout = properties.ObjectProperty()
+    layout = properties.ObjectProperty()
+    use_header_input = True
+
+    def __init__(self, host, target, profiles):
+        QuickContactList.__init__(self, G.host, profiles)
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        FilterBehavior.__init__(self)
+        self._wid_map = {}  # (profile, bare_jid) to widget map
+        self.post_init()
+        if len(self.profiles) != 1:
+            raise NotImplementedError('multi profiles is not implemented yet')
+        self.update(profile=next(iter(self.profiles)))
+
+    def add_contact_menu(self):
+        """Show the "add a contact" menu"""
+        # FIXME: for now we add contact to the first profile we find
+        profile = next(iter(self.profiles))
+        AddContactMenu(profile=profile).show()
+
+    def remove_contact(self, menu_label):
+        item = self.menu_item
+        self.clear_menu()
+        DelContactMenu(contact_item=item).show()
+
+    def on_header_wid_input_complete(self, wid, text):
+        self.do_filter(self.layout,
+                       text,
+                       lambda c: c.jid,
+                       width_cb=lambda c: c.base_width,
+                       height_cb=lambda c: c.minimum_height,
+                       )
+
+    def _add_contact_item(self, bare_jid, profile):
+        """Create a new CLContactItem instance, and add it
+
+        item will be added in a sorted position
+        @param bare_jid(jid.JID): entity bare JID
+        @param profile(unicode): profile where the contact is
+        """
+        data = G.host.contact_lists[profile].get_item(bare_jid)
+        wid = CLContactItem(profile=profile, data=data, jid=bare_jid, main_wid=self)
+        child_jids = [c.jid for c in reversed(self.layout.children)]
+        idx = bisect.bisect_right(child_jids, bare_jid)
+        self.layout.add_widget(wid, -idx)
+        self._wid_map[(profile, bare_jid)] = wid
+
+    def update(self, entities=None, type_=None, profile=None):
+        log.debug("update: %s %s %s" % (entities, type_, profile))
+        if type_ == None or type_ == C.UPDATE_STRUCTURE:
+            log.debug("full contact list update")
+            self.layout.clear_widgets()
+            for bare_jid, data in self.items_sorted.items():
+                wid = CLContactItem(
+                    profile=data['profile'],
+                    data=data,
+                    jid=bare_jid,
+                    main_wid=self,
+                )
+                self.layout.add_widget(wid)
+                self._wid_map[(profile, bare_jid)] = wid
+        elif type_ == C.UPDATE_MODIFY:
+            for entity in entities:
+                entity_bare = entity.bare
+                wid = self._wid_map[(profile, entity_bare)]
+                wid.data = G.host.contact_lists[profile].get_item(entity_bare)
+        elif type_ == C.UPDATE_ADD:
+            for entity in entities:
+                self._add_contact_item(entity.bare, profile)
+        elif type_ == C.UPDATE_DELETE:
+            for entity in entities:
+                try:
+                    self.layout.remove_widget(self._wid_map.pop((profile, entity.bare)))
+                except KeyError:
+                    log.debug("entity not found: {entity}".format(entity=entity.bare))
+        else:
+            log.debug("update type not handled: {update_type}".format(update_type=type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_file_sharing.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,62 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+#:import ModernMenu kivy_garden.modernmenu.ModernMenu
+
+
+<ModeBtn>:
+    width: self.texture_size[0] + sp(20)
+    size_hint: None, 1
+
+
+<FileSharing>:
+    float_layout: float_layout
+    layout: layout
+    FloatLayout:
+        id: float_layout
+        ScrollView:
+            size_hint: 1, 1
+            pos_hint: {'x': 0, 'y': 0}
+            do_scroll_x: False
+            scroll_type: ['bars', 'content']
+            bar_width: dp(6)
+            StackLayout:
+                id: layout
+                size_hint: 1, None
+                height: self.minimum_height
+                spacing: 0
+
+
+<PathWidget>:
+    shared: False
+    Symbol:
+        size_hint: 1, None
+        height: dp(80)
+        font_size: dp(40)
+        symbol: 'folder-open-empty' if root.is_dir else 'doc'
+        color: (1, 0, 0, 1) if root.shared else (0, 0, 0, 1) if root.is_dir else app.c_prim_dark
+    Label:
+        size_hint: None, None
+        width: dp(100)
+        font_size: sp(14)
+        text_size: dp(95), None
+        size: self.texture_size
+        text: root.name
+        halign: 'center'
+
+
+<LocalPathWidget>:
+    shared: root.filepath in root.main_wid.shared_paths
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_file_sharing.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 functools import partial
+import os.path
+import json
+from libervia.backend.core import log as logging
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import files_utils
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.frontends.tools import jid
+from ..core.constants import Const as C
+from ..core import cagou_widget
+from ..core.menu import EntitiesSelectorMenu
+from ..core.behaviors import TouchMenuBehavior, FilterBehavior
+from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
+                                       CategorySeparator)
+from libervia.desktop_kivy import G
+from kivy import properties
+from kivy.uix.label import Label
+from kivy.uix.button import Button
+from kivy import utils as kivy_utils
+
+log = logging.getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    "name": _("file sharing"),
+    "main": "FileSharing",
+    "description": _("share/transfer files between devices"),
+    "icon_symbol": "exchange",
+}
+MODE_VIEW = "view"
+MODE_LOCAL = "local"
+SELECT_INSTRUCTIONS = _("Please select entities to share with")
+
+if kivy_utils.platform == "android":
+    from jnius import autoclass
+    Environment = autoclass("android.os.Environment")
+    base_dir = Environment.getExternalStorageDirectory().getAbsolutePath()
+    def expanduser(path):
+        if path == '~' or path.startswith('~/'):
+            return path.replace('~', base_dir, 1)
+        return path
+else:
+    expanduser = os.path.expanduser
+
+
+class ModeBtn(Button):
+
+    def __init__(self, parent, **kwargs):
+        super(ModeBtn, self).__init__(**kwargs)
+        parent.bind(mode=self.on_mode)
+        self.on_mode(parent, parent.mode)
+
+    def on_mode(self, parent, new_mode):
+        if new_mode == MODE_VIEW:
+            self.text = _("view shared files")
+        elif new_mode == MODE_LOCAL:
+            self.text = _("share local files")
+        else:
+            exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode))
+
+
+class PathWidget(ItemWidget):
+
+    def __init__(self, filepath, main_wid, **kw):
+        name = os.path.basename(filepath)
+        self.filepath = os.path.normpath(filepath)
+        if self.filepath == '.':
+            self.filepath = ''
+        super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw)
+
+    @property
+    def is_dir(self):
+        raise NotImplementedError
+
+    def do_item_action(self, touch):
+        if self.is_dir:
+            self.main_wid.current_dir = self.filepath
+
+    def open_menu(self, touch, dt):
+        log.debug(_("opening menu for {path}").format(path=self.filepath))
+        super(PathWidget, self).open_menu(touch, dt)
+
+
+class LocalPathWidget(PathWidget):
+
+    @property
+    def is_dir(self):
+        return os.path.isdir(self.filepath)
+
+    def get_menu_choices(self):
+        choices = []
+        if self.shared:
+            choices.append(dict(text=_('unshare'),
+                                index=len(choices)+1,
+                                callback=self.main_wid.unshare))
+        else:
+            choices.append(dict(text=_('share'),
+                                index=len(choices)+1,
+                                callback=self.main_wid.share))
+        return choices
+
+
+class RemotePathWidget(PathWidget):
+
+    def __init__(self, main_wid, filepath, type_, **kw):
+        self.type_ = type_
+        super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw)
+
+    @property
+    def is_dir(self):
+        return self.type_ == C.FILE_TYPE_DIRECTORY
+
+    def do_item_action(self, touch):
+        if self.is_dir:
+            if self.filepath == '..':
+                self.main_wid.remote_entity = ''
+            else:
+                super(RemotePathWidget, self).do_item_action(touch)
+        else:
+            self.main_wid.request_item(self)
+            return True
+
+class SharingDeviceWidget(DeviceWidget):
+
+    def do_item_action(self, touch):
+        self.main_wid.remote_entity = self.entity_jid
+        self.main_wid.remote_dir = ''
+
+
+class FileSharing(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior,
+                  TouchMenuBehavior):
+    SINGLE=False
+    layout = properties.ObjectProperty()
+    mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL])
+    local_dir = properties.StringProperty(expanduser('~'))
+    remote_dir = properties.StringProperty('')
+    remote_entity = properties.StringProperty('')
+    shared_paths = properties.ListProperty()
+    use_header_input = True
+    signals_registered = False
+
+    def __init__(self, host, target, profiles):
+        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        FilterBehavior.__init__(self)
+        TouchMenuBehavior.__init__(self)
+        self.mode_btn = ModeBtn(self)
+        self.mode_btn.bind(on_release=self.change_mode)
+        self.header_input_add_extra(self.mode_btn)
+        self.bind(local_dir=self.update_view,
+                  remote_dir=self.update_view,
+                  remote_entity=self.update_view)
+        self.update_view()
+        if not FileSharing.signals_registered:
+            # FIXME: we use this hack (registering the signal for the whole class) now
+            #        as there is currently no unregisterSignal available in bridges
+            G.host.register_signal("fis_shared_path_new",
+                                  handler=FileSharing.shared_path_new,
+                                  iface="plugin")
+            G.host.register_signal("fis_shared_path_removed",
+                                  handler=FileSharing.shared_path_removed,
+                                  iface="plugin")
+            FileSharing.signals_registered = True
+        G.host.bridge.fis_local_shares_get(self.profile,
+                                        callback=self.fill_paths,
+                                        errback=G.host.errback)
+
+    @property
+    def current_dir(self):
+        return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir
+
+    @current_dir.setter
+    def current_dir(self, new_dir):
+        if self.mode == MODE_LOCAL:
+            self.local_dir = new_dir
+        else:
+            self.remote_dir = new_dir
+
+    def fill_paths(self, shared_paths):
+        self.shared_paths.extend(shared_paths)
+
+    def change_mode(self, mode_btn):
+        self.clear_menu()
+        opt = self.__class__.mode.options
+        new_idx = (opt.index(self.mode)+1) % len(opt)
+        self.mode = opt[new_idx]
+
+    def on_mode(self, instance, new_mode):
+        self.update_view(None, self.local_dir)
+
+    def on_header_wid_input(self):
+        if '/' in self.header_input.text or self.header_input.text == '~':
+            self.current_dir = expanduser(self.header_input.text)
+
+    def on_header_wid_input_complete(self, wid, text, **kwargs):
+        """we filter items when text is entered in input box"""
+        if '/' in text:
+            return
+        self.do_filter(self.layout,
+                       text,
+                       lambda c: c.name,
+                       width_cb=lambda c: c.base_width,
+                       height_cb=lambda c: c.minimum_height,
+                       continue_tests=[lambda c: not isinstance(c, ItemWidget),
+                                       lambda c: c.name == '..'])
+
+
+    ## remote sharing callback ##
+
+    def _disco_find_by_features_cb(self, data):
+        entities_services, entities_own, entities_roster = data
+        for entities_map, title in ((entities_services,
+                                     _('services')),
+                                    (entities_own,
+                                     _('your devices')),
+                                    (entities_roster,
+                                     _('your contacts devices'))):
+            if entities_map:
+                self.layout.add_widget(CategorySeparator(text=title))
+                for entity_str, entity_ids in entities_map.items():
+                    entity_jid = jid.JID(entity_str)
+                    item = SharingDeviceWidget(
+                        self, entity_jid, Identities(entity_ids))
+                    self.layout.add_widget(item)
+        if not entities_services and not entities_own and not entities_roster:
+            self.layout.add_widget(Label(
+                size_hint=(1, 1),
+                halign='center',
+                text_size=self.size,
+                text=_("No sharing device found")))
+
+    def discover_devices(self):
+        """Looks for devices handling file "File Information Sharing" and display them"""
+        try:
+            namespace = self.host.ns_map['fis']
+        except KeyError:
+            msg = _("can't find file information sharing namespace, "
+                    "is the plugin running?")
+            log.warning(msg)
+            G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
+            return
+        self.host.bridge.disco_find_by_features(
+            [namespace], [], False, True, True, True, False, self.profile,
+            callback=self._disco_find_by_features_cb,
+            errback=partial(G.host.errback,
+                title=_("shared folder error"),
+                message=_("can't check sharing devices: {msg}")))
+
+    def fis_list_cb(self, files_data):
+        for file_data in files_data:
+            filepath = os.path.join(self.current_dir, file_data['name'])
+            item = RemotePathWidget(
+                filepath=filepath,
+                main_wid=self,
+                type_=file_data['type'])
+            self.layout.add_widget(item)
+
+    def fis_list_eb(self, failure_):
+        self.remote_dir = ''
+        G.host.add_note(
+            _("shared folder error"),
+            _("can't list files for {remote_entity}: {msg}").format(
+                remote_entity=self.remote_entity,
+                msg=failure_),
+            level=C.XMLUI_DATA_LVL_WARNING)
+
+    ## view generation ##
+
+    def update_view(self, *args):
+        """update items according to current mode, entity and dir"""
+        log.debug('updating {}, {}'.format(self.current_dir, args))
+        self.layout.clear_widgets()
+        self.header_input.text = ''
+        self.header_input.hint_text = self.current_dir
+
+        if self.mode == MODE_LOCAL:
+            filepath = os.path.join(self.local_dir, '..')
+            self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self))
+            try:
+                files = sorted(os.listdir(self.local_dir))
+            except OSError as e:
+                msg = _("can't list files in \"{local_dir}\": {msg}").format(
+                    local_dir=self.local_dir,
+                    msg=e)
+                G.host.add_note(
+                    _("shared folder error"),
+                    msg,
+                    level=C.XMLUI_DATA_LVL_WARNING)
+                self.local_dir = expanduser('~')
+                return
+            for f in files:
+                filepath = os.path.join(self.local_dir, f)
+                self.layout.add_widget(LocalPathWidget(filepath=filepath,
+                                                       main_wid=self))
+        elif self.mode == MODE_VIEW:
+            if not self.remote_entity:
+                self.discover_devices()
+            else:
+                # we always a way to go back
+                # so user can return to previous list even in case of error
+                parent_path = os.path.join(self.remote_dir, '..')
+                item = RemotePathWidget(
+                    filepath = parent_path,
+                    main_wid=self,
+                    type_ = C.FILE_TYPE_DIRECTORY)
+                self.layout.add_widget(item)
+                self.host.bridge.fis_list(
+                    str(self.remote_entity),
+                    self.remote_dir,
+                    {},
+                    self.profile,
+                    callback=self.fis_list_cb,
+                    errback=self.fis_list_eb)
+
+    ## Share methods ##
+
+    def do_share(self, entities_jids, item):
+        if entities_jids:
+            access = {'read': {'type': 'whitelist',
+                                'jids': entities_jids}}
+        else:
+            access = {}
+
+        G.host.bridge.fis_share_path(
+            item.name,
+            item.filepath,
+            json.dumps(access, ensure_ascii=False),
+            self.profile,
+            callback=lambda name: G.host.add_note(
+                _("sharing folder"),
+                _("{name} is now shared").format(name=name)),
+            errback=partial(G.host.errback,
+                title=_("sharing folder"),
+                message=_("can't share folder: {msg}")))
+
+    def share(self, menu):
+        item = self.menu_item
+        self.clear_menu()
+        EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS,
+                             callback=partial(self.do_share, item=item)).show()
+
+    def unshare(self, menu):
+        item = self.menu_item
+        self.clear_menu()
+        G.host.bridge.fis_unshare_path(
+            item.filepath,
+            self.profile,
+            callback=lambda: G.host.add_note(
+                _("sharing folder"),
+                _("{name} is not shared anymore").format(name=item.name)),
+            errback=partial(G.host.errback,
+                title=_("sharing folder"),
+                message=_("can't unshare folder: {msg}")))
+
+    def file_jingle_request_cb(self, progress_id, item, dest_path):
+        G.host.add_note(
+            _("file request"),
+            _("{name} download started at {dest_path}").format(
+                name = item.name,
+                dest_path = dest_path))
+
+    def request_item(self, item):
+        """Retrieve an item from remote entity
+
+        @param item(RemotePathWidget): item to retrieve
+        """
+        path, name = os.path.split(item.filepath)
+        assert name
+        assert self.remote_entity
+        extra = {'path': path}
+        dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name))
+        G.host.bridge.file_jingle_request(str(self.remote_entity),
+                                        str(dest_path),
+                                        name,
+                                        '',
+                                        '',
+                                        extra,
+                                        self.profile,
+                                        callback=partial(self.file_jingle_request_cb,
+                                            item=item,
+                                            dest_path=dest_path),
+                                        errback=partial(G.host.errback,
+                                            title = _("file request error"),
+                                            message = _("can't request file: {msg}")))
+
+    @classmethod
+    def shared_path_new(cls, shared_path, name, profile):
+        for wid in G.host.get_visible_list(cls):
+            if shared_path not in wid.shared_paths:
+                wid.shared_paths.append(shared_path)
+
+    @classmethod
+    def shared_path_removed(cls, shared_path, profile):
+        for wid in G.host.get_visible_list(cls):
+            if shared_path in wid.shared_paths:
+                wid.shared_paths.remove(shared_path)
+            else:
+                log.warning(_("shared path {path} not found in {widget}".format(
+                    path = shared_path, widget = wid)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_remote.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,101 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+
+<RemoteControl>:
+    layout: layout
+    BoxLayout:
+        id: layout
+
+
+<DevicesLayout>:
+    layout: layout
+    size_hint: 1, 1
+    ScrollView:
+        size_hint: 1, 1
+        pos_hint: {'x': 0, 'y': 0}
+        do_scroll_x: False
+        scroll_type: ['bars', 'content']
+        bar_width: dp(6)
+        StackLayout:
+            id: layout
+            size_hint: 1, None
+            height: self.minimum_height
+            spacing: 0
+
+
+<RemoteItemWidget>:
+    shared: False
+    Symbol:
+        size_hint: 1, None
+        height: dp(80)
+        symbol: 'video'
+        color: 0, 0, 0, 1
+    Label:
+        size_hint: None, None
+        width: dp(100)
+        font_size: sp(14)
+        text_size: dp(95), None
+        size: self.texture_size
+        text: root.name
+        halign: 'center'
+
+
+<PlayerLabel@Label>:
+    size_hint: 1, None
+    text_size: self.width, None
+    size: self.texture_size
+    halign: 'center'
+
+
+<PlayerButton@SymbolButton>:
+    size_hint: None, 1
+
+
+<MediaPlayerControlWidget>:
+    orientation: 'vertical'
+    PlayerLabel:
+        text: root.title
+        bold: True
+        font_size: '20sp'
+    PlayerLabel:
+        text: root.identity
+        font_size: '15sp'
+    Widget:
+        size_hint: 1, None
+        height: dp(50)
+    BoxLayout:
+        size_hint: 1, None
+        spacing: dp(20)
+        height: dp(30)
+        Widget:
+        PlayerButton:
+            symbol: "previous"
+            on_release: root.do_cmd("Previous")
+        PlayerButton:
+            symbol: "fast-bw"
+            on_release: root.do_cmd("GoBack")
+        PlayerButton:
+            symbol: root.status
+            on_release: root.do_cmd("PlayPause")
+        PlayerButton
+            symbol: "fast-fw"
+            on_release: root.do_cmd("GoFW")
+        PlayerButton
+            symbol: "next"
+            on_release: root.do_cmd("Next")
+        Widget:
+    Widget:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_remote.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+from libervia.backend.core.i18n import _
+from libervia.frontends.quick_frontend import quick_widgets
+from ..core import cagou_widget
+from ..core.constants import Const as C
+from ..core.behaviors import TouchMenuBehavior, FilterBehavior
+from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
+                                       CategorySeparator)
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import data_format
+from libervia.desktop_kivy.core import xmlui
+from libervia.frontends.tools import jid
+from kivy import properties
+from kivy.uix.label import Label
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.floatlayout import FloatLayout
+from libervia.desktop_kivy import G
+from functools import partial
+
+
+log = logging.getLogger(__name__)
+
+PLUGIN_INFO = {
+    "name": _("remote control"),
+    "main": "RemoteControl",
+    "description": _("universal remote control"),
+    "icon_symbol": "signal",
+}
+
+NOTE_TITLE = _("Media Player Remote Control")
+
+
+class RemoteItemWidget(ItemWidget):
+
+    def __init__(self, device_jid, node, name, main_wid, **kw):
+        self.device_jid = device_jid
+        self.node = node
+        super(RemoteItemWidget, self).__init__(name=name, main_wid=main_wid, **kw)
+
+    def do_item_action(self, touch):
+        self.main_wid.layout.clear_widgets()
+        player_wid = MediaPlayerControlWidget(main_wid=self.main_wid, remote_item=self)
+        self.main_wid.layout.add_widget(player_wid)
+
+
+class MediaPlayerControlWidget(BoxLayout):
+    main_wid = properties.ObjectProperty()
+    remote_item = properties.ObjectProperty()
+    status = properties.OptionProperty("play", options=("play", "pause", "stop"))
+    title = properties.StringProperty()
+    identity = properties.StringProperty()
+    command = properties.DictProperty()
+    ui_tpl = properties.ObjectProperty()
+
+    @property
+    def profile(self):
+        return self.main_wid.profile
+
+    def update_ui(self, action_data_s):
+        action_data = data_format.deserialise(action_data_s)
+        xmlui_raw = action_data['xmlui']
+        ui_tpl = template_xmlui.create(G.host, xmlui_raw)
+        self.ui_tpl = ui_tpl
+        for prop in ('Title', 'Identity'):
+            try:
+                setattr(self, prop.lower(), ui_tpl.widgets[prop].value)
+            except KeyError:
+                log.warning(_("Missing field: {name}").format(name=prop))
+        playback_status = self.ui_tpl.widgets['PlaybackStatus'].value
+        if playback_status == "Playing":
+            self.status = "pause"
+        elif playback_status == "Paused":
+            self.status = "play"
+        elif playback_status == "Stopped":
+            self.status = "play"
+        else:
+            G.host.add_note(
+                title=NOTE_TITLE,
+                message=_("Unknown playback status: playback_status")
+                          .format(playback_status=playback_status),
+                level=C.XMLUI_DATA_LVL_WARNING)
+        self.commands = {v:k for k,v in ui_tpl.widgets['command'].options}
+
+    def ad_hoc_run_cb(self, xmlui_raw):
+        ui_tpl = template_xmlui.create(G.host, xmlui_raw)
+        data = {xmlui.XMLUIPanel.escape("media_player"): self.remote_item.node,
+                "session_id": ui_tpl.session_id}
+        G.host.bridge.action_launch(
+            ui_tpl.submit_id, data_format.serialise(data),
+            self.profile, callback=self.update_ui,
+            errback=self.main_wid.errback
+        )
+
+    def on_remote_item(self, __, remote):
+        NS_MEDIA_PLAYER = G.host.ns_map["mediaplayer"]
+        G.host.bridge.ad_hoc_run(str(remote.device_jid), NS_MEDIA_PLAYER, self.profile,
+                               callback=self.ad_hoc_run_cb,
+                               errback=self.main_wid.errback)
+
+    def do_cmd(self, command):
+        try:
+            cmd_value = self.commands[command]
+        except KeyError:
+            G.host.add_note(
+                title=NOTE_TITLE,
+                message=_("{command} command is not managed").format(command=command),
+                level=C.XMLUI_DATA_LVL_WARNING)
+        else:
+            data = {xmlui.XMLUIPanel.escape("command"): cmd_value,
+                    "session_id": self.ui_tpl.session_id}
+            # hidden values are normally transparently managed by XMLUIPanel
+            # but here we have to add them by hand
+            hidden = {xmlui.XMLUIPanel.escape(k):v
+                      for k,v in self.ui_tpl.hidden.items()}
+            data.update(hidden)
+            G.host.bridge.action_launch(
+                self.ui_tpl.submit_id, data_format.serialise(data), self.profile,
+                callback=self.update_ui, errback=self.main_wid.errback
+            )
+
+
+class RemoteDeviceWidget(DeviceWidget):
+
+    def xmlui_cb(self, data, cb_id, profile):
+        if 'xmlui' in data:
+            xml_ui = xmlui.create(
+                G.host, data['xmlui'], callback=self.xmlui_cb, profile=profile)
+            if isinstance(xml_ui, xmlui.XMLUIDialog):
+                self.main_wid.show_root_widget()
+                xml_ui.show()
+            else:
+                xml_ui.set_close_cb(self.on_close)
+                self.main_wid.layout.add_widget(xml_ui)
+        else:
+            if data:
+                log.warning(_("Unhandled data: {data}").format(data=data))
+            self.main_wid.show_root_widget()
+
+    def on_close(self, __, reason):
+        if reason == C.XMLUI_DATA_CANCELLED:
+            self.main_wid.show_root_widget()
+        else:
+            self.main_wid.layout.clear_widgets()
+
+    def ad_hoc_run_cb(self, data):
+        xml_ui = xmlui.create(G.host, data, callback=self.xmlui_cb, profile=self.profile)
+        xml_ui.set_close_cb(self.on_close)
+        self.main_wid.layout.add_widget(xml_ui)
+
+    def do_item_action(self, touch):
+        self.main_wid.layout.clear_widgets()
+        G.host.bridge.ad_hoc_run(str(self.entity_jid), '', self.profile,
+            callback=self.ad_hoc_run_cb, errback=self.main_wid.errback)
+
+
+class DevicesLayout(FloatLayout):
+    """Layout used to show devices"""
+    layout = properties.ObjectProperty()
+
+
+class RemoteControl(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior,
+                  TouchMenuBehavior):
+    SINGLE=False
+    layout = properties.ObjectProperty()
+
+    def __init__(self, host, target, profiles):
+        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        FilterBehavior.__init__(self)
+        TouchMenuBehavior.__init__(self)
+        self.stack_layout = None
+        self.show_root_widget()
+
+    def errback(self, failure_):
+        """Generic errback which add a warning note and go back to root widget"""
+        G.host.add_note(
+            title=NOTE_TITLE,
+            message=_("Can't use remote control: {reason}").format(reason=failure_),
+            level=C.XMLUI_DATA_LVL_WARNING)
+        self.show_root_widget()
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        if key == 27:
+            self.show_root_widget()
+            return True
+
+    def show_root_widget(self):
+        self.layout.clear_widgets()
+        devices_layout = DevicesLayout()
+        self.stack_layout = devices_layout.layout
+        self.layout.add_widget(devices_layout)
+        found = []
+        self.get_remotes(found)
+        self.discover_devices(found)
+
+    def ad_hoc_remotes_get_cb(self, remotes_data, found):
+        found.insert(0, remotes_data)
+        if len(found) == 2:
+            self.show_devices(found)
+
+    def ad_hoc_remotes_get_eb(self, failure_, found):
+        G.host.errback(failure_, title=_("discovery error"),
+                       message=_("can't check remote controllers: {msg}"))
+        found.insert(0, [])
+        if len(found) == 2:
+            self.show_devices(found)
+
+    def get_remotes(self, found):
+        self.host.bridge.ad_hoc_remotes_get(
+            self.profile,
+            callback=partial(self.ad_hoc_remotes_get_cb, found=found),
+            errback=partial(self.ad_hoc_remotes_get_eb,found=found))
+
+    def _disco_find_by_features_cb(self, data, found):
+        found.append(data)
+        if len(found) == 2:
+            self.show_devices(found)
+
+    def _disco_find_by_features_eb(self, failure_, found):
+        G.host.errback(failure_, title=_("discovery error"),
+                       message=_("can't check devices: {msg}"))
+        found.append(({}, {}, {}))
+        if len(found) == 2:
+            self.show_devices(found)
+
+    def discover_devices(self, found):
+        """Looks for devices handling file "File Information Sharing" and display them"""
+        try:
+            namespace = self.host.ns_map['commands']
+        except KeyError:
+            msg = _("can't find ad-hoc commands namespace, is the plugin running?")
+            log.warning(msg)
+            G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
+            return
+        self.host.bridge.disco_find_by_features(
+            [namespace], [], False, True, True, True, False, self.profile,
+            callback=partial(self._disco_find_by_features_cb, found=found),
+            errback=partial(self._disco_find_by_features_eb, found=found))
+
+    def show_devices(self, found):
+        remotes_data, (entities_services, entities_own, entities_roster) = found
+        if remotes_data:
+            title = _("media players remote controls")
+            self.stack_layout.add_widget(CategorySeparator(text=title))
+
+        for remote_data in remotes_data:
+            device_jid, node, name = remote_data
+            wid = RemoteItemWidget(device_jid, node, name, self)
+            self.stack_layout.add_widget(wid)
+
+        for entities_map, title in ((entities_services,
+                                     _('services')),
+                                    (entities_own,
+                                     _('your devices')),
+                                    (entities_roster,
+                                     _('your contacts devices'))):
+            if entities_map:
+                self.stack_layout.add_widget(CategorySeparator(text=title))
+                for entity_str, entity_ids in entities_map.items():
+                    entity_jid = jid.JID(entity_str)
+                    item = RemoteDeviceWidget(
+                        self, entity_jid, Identities(entity_ids))
+                    self.stack_layout.add_widget(item)
+        if (not remotes_data and not entities_services and not entities_own
+            and not entities_roster):
+            self.stack_layout.add_widget(Label(
+                size_hint=(1, 1),
+                halign='center',
+                text_size=self.size,
+                text=_("No sharing device found")))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_settings.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,15 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_settings.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools.common import data_format
+from libervia.frontends.quick_frontend import quick_widgets
+from kivy.uix.label import Label
+from kivy.uix.widget import Widget
+from libervia.desktop_kivy.core import cagou_widget
+from libervia.desktop_kivy import G
+
+
+log = logging.getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    "name": _("settings"),
+    "main": "LiberviaDesktopKivySettings",
+    "description": _("LiberviaDesktopKivy/SàT settings"),
+    "icon_symbol": "wrench",
+}
+
+
+class LiberviaDesktopKivySettings(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget):
+    # XXX: this class can't be called "Settings", because Kivy has already a class
+    #      of this name, and the kv there would apply
+
+    def __init__(self, host, target, profiles):
+        quick_widgets.QuickWidget.__init__(self, G.host, target, profiles)
+        cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
+        # the Widget() avoid LiberviaDesktopKivyWidget header to be down at the beginning
+        # then up when the UI is loaded
+        self.loading_widget = Widget()
+        self.add_widget(self.loading_widget)
+        extra = {}
+        G.local_platform.update_params_extra(extra)
+        G.host.bridge.param_ui_get(
+            -1, C.APP_NAME, data_format.serialise(extra), self.profile,
+            callback=self.get_params_ui_cb,
+            errback=self.get_params_ui_eb)
+
+    def change_widget(self, widget):
+        self.clear_widgets([self.loading_widget])
+        del self.loading_widget
+        self.add_widget(widget)
+
+    def get_params_ui_cb(self, xmlui):
+        G.host.action_manager({"xmlui": xmlui}, ui_show_cb=self.change_widget, profile=self.profile)
+
+    def get_params_ui_eb(self, failure):
+        self.change_widget(Label(
+            text=_("Can't load parameters!"),
+            bold=True,
+            color=(1,0,0,1)))
+        G.host.show_dialog("Can't load params UI", str(failure), "error")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_widget_selector.kv	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,48 @@
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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/>.
+
+<WidgetSelItem>:
+    size_hint: (1, None)
+    height: dp(40)
+    item: item
+    Widget:
+    BoxLayout:
+        id: item
+        size_hint: None, 1
+        spacing: dp(10)
+        ActionIcon:
+            plugin_info: root.plugin_info
+            size_hint: None, 1
+            width: self.height
+        Label:
+            text: root.plugin_info["name"]
+            bold: True
+            valign: 'middle'
+            font_size: sp(20)
+            size_hint: None, 1
+            width: self.texture_size[0]
+    Widget:
+
+
+<WidgetSelector>:
+    spacing: dp(10)
+    container: container
+    ScrollView:
+        BoxLayout:
+            orientation: "vertical"
+            size_hint: 1, None
+            height: self.minimum_height
+            id: container
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_widget_selector.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-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 import log as logging
+log = logging.getLogger(__name__)
+from libervia.backend.core.i18n import _
+from libervia.desktop_kivy.core.constants import Const as C
+from kivy.uix.widget import Widget
+from kivy.uix.boxlayout import BoxLayout
+from kivy import properties
+from kivy.uix.behaviors import ButtonBehavior
+from libervia.desktop_kivy.core import cagou_widget
+from libervia.desktop_kivy import G
+
+
+PLUGIN_INFO = {
+    "name": _("widget selector"),
+    "import_name": C.WID_SELECTOR,
+    "main": "WidgetSelector",
+    "description": _("show available widgets and allow to select one"),
+    "icon_medium": "{media}/icons/muchoslava/png/selector_no_border_blue_44.png"
+}
+
+
+class WidgetSelItem(ButtonBehavior, BoxLayout):
+    plugin_info = properties.DictProperty()
+    item = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        log.debug("widget selection: {}".format(self.plugin_info["name"]))
+        factory = self.plugin_info["factory"]
+        G.host.switch_widget(
+            self, factory(self.plugin_info, None, profiles=iter(G.host.profiles)))
+
+
+class WidgetSelector(cagou_widget.LiberviaDesktopKivyWidget):
+    container = properties.ObjectProperty()
+
+    def __init__(self):
+        super(WidgetSelector, self).__init__()
+        self.items = []
+        for plugin_info in G.host.get_plugged_widgets(except_cls=self.__class__):
+            item = WidgetSelItem(plugin_info=plugin_info)
+            self.items.append(item.item)
+            item.item.bind(minimum_width=self.adjust_width)
+            self.container.add_widget(item)
+        self.container.add_widget(Widget())
+
+    def adjust_width(self, label, texture_size):
+        width = max([i.minimum_width for i in self.items])
+        for i in self.items:
+            i.width = width
+
+    def key_input(self, window, key, scancode, codepoint, modifier):
+        # we pass to avoid default LiberviaDesktopKivyWidget which is going back to default widget
+        # (which is this one)
+        pass
+
+    @classmethod
+    def factory(cls, plugin_info, target, profiles):
+        return cls()
+
+
+PLUGIN_INFO["factory"] = WidgetSelector.factory
--- a/main.py	Fri Jun 02 17:53:09 2023 +0200
+++ b/main.py	Fri Jun 02 18:26:16 2023 +0200
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
+#Libervia Desktop-Kivy
 # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
@@ -16,7 +16,7 @@
 # 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/>.
 
-import cagou
+import libervia.desktop_kivy
 
 if __name__ == "__main__":
-    cagou.run()
+    libervia.desktop_kivy.run()
--- a/service/main.py	Fri Jun 02 17:53:09 2023 +0200
+++ b/service/main.py	Fri Jun 02 18:26:16 2023 +0200
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
+#Libervia Desktop-Kivy
 # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org)
 
 # This program is free software: you can redistribute it and/or modify
@@ -18,13 +18,13 @@
 
 import sys
 import os
-# we want the service to access the modules from parent dir (sat, etc.)
+# we want the service to access the modules from parent dir (libervia.backend, etc.)
 os.chdir('..')
 sys.path.insert(0, '')
-from sat.core.constants import Const as C
-from sat.core import log_config
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import log_config
 # SàT log conf must be done before calling Kivy
-log_config.sat_configure(C.LOG_BACKEND_STANDARD, C)
+log_config.libervia_configure(C.LOG_BACKEND_STANDARD, C)
 # if this module is called, we should be on android,
 # but just in case...
 from kivy import utils as kivy_utils
@@ -33,7 +33,7 @@
     # so we change it to allow backend to detect android
     sys.platform = "android"
     C.PLUGIN_EXT = "pyc"
-from sat.core import sat_main
+from libervia.backend.core import main
 from twisted.internet import reactor
 from twisted.application import app, service
 from jnius import autoclass
@@ -43,8 +43,8 @@
 PythonService.mService.setAutoRestartService(True)
 
 
-sat = sat_main.SAT()
-application = service.Application("SàT backend")
-sat.setServiceParent(application)
+libervia_backend = main.LiberviaBackend()
+application = service.Application("Libervia backend")
+libervia_backend.setServiceParent(application)
 app.startApplication(application, None)
 reactor.run()
--- a/setup.py	Fri Jun 02 17:53:09 2023 +0200
+++ b/setup.py	Fri Jun 02 18:26:16 2023 +0200
@@ -21,9 +21,9 @@
 import os
 import textwrap
 
-NAME = "libervia-desktop"
+NAME = "libervia-desktop_kivy"
 # NOTE: directory is still "cagou" for compatibility reason, should be changed for 0.9
-DIR_NAME = "cagou"
+DIR_NAME = "libervia/desktop_kivy"
 
 install_requires = [
     'kivy >=2.0.0, <2.2.0',
@@ -38,7 +38,7 @@
 is_dev_version = VERSION.endswith('D')
 
 
-def cagou_dev_version():
+def desktop_kivy_dev_version():
     """Use mercurial data to compute version"""
     def version_scheme(version):
         return VERSION.replace('D', '.dev0')
@@ -55,16 +55,17 @@
 setup(
     name=NAME,
     version=VERSION,
-    description="Desktop/Android frontend for Libervia XMPP client",
+    description="Desktop/Android frontend (Kivy version) for Libervia XMPP client",
     long_description=textwrap.dedent("""\
-        Libervia Desktop (Cagou) is a desktop/Android frontend for Libervia.
+        Libervia Desktop Kivy is a Desktop/Android frontend for Libervia.
+        This is an alternative version using the Kivy framework.
         It provides native graphical interface with a modern user interface,
         using touch screen abilitiy when available, and with split ability inspired from
         Blender
         """),
-    author="Association « Salut à Toi »",
+    author="Libervia Team",
     author_email="contact@goffi.org",
-    url="https://salut-a-toi.org",
+    url="https://libervia.org",
     classifiers=[
         "Programming Language :: Python :: 3 :: Only",
         "Programming Language :: Python :: 3.7",
@@ -83,15 +84,15 @@
     packages=find_packages(),
     entry_points={
         "console_scripts": [
-            "libervia-desktop = cagou:run",
-            "libervia-mobile = cagou:run",
-            "cagou = cagou:run",
+            "libervia-desktop_kivy = libervia.desktop_kivy:run",
+            "libervia-mobile = libervia.desktop_kivy:run",
+            "cagou = libervia.desktop_kivy:run",
             ],
         },
     zip_safe=False,
     setup_requires=["setuptools_scm"] if is_dev_version else [],
-    use_scm_version=cagou_dev_version if is_dev_version else False,
+    use_scm_version=desktop_kivy_dev_version if is_dev_version else False,
     install_requires=install_requires,
-    package_data={"": ["*.kv"], "cagou": ["VERSION"]},
+    package_data={"": ["*.kv"], "libervia.desktop_kivy": ["VERSION"]},
     python_requires=">=3.7",
 )