# HG changeset patch # User Goffi # Date 1685723176 -7200 # Node ID b3cedbee561da7605a9d31d2a3c3c5adbaad0349 # Parent 5114bbb5daa395b205dc009d6f2495b1f5e119f7 refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes diff -r 5114bbb5daa3 -r b3cedbee561d cagou/VERSION --- 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 diff -r 5114bbb5daa3 -r b3cedbee561d cagou/__init__.py --- 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 . - -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() diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/__init__.py diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/behaviors.py --- 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 . - - -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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/cagou_main.py --- 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 . - - -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 diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/cagou_widget.py --- 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 . - - -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)) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/common.py --- 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 . - -"""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 - # (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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/common_widgets.py --- 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 . - -"""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 diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/config.py --- 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 . - -"""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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/constants.py --- 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 . - -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" diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/dialog.py --- 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 . - -"""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() diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/image.py --- 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 . - - -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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/kivy_hack.py --- 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 . - -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 diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/menu.py --- 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 . - - -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)) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/patches.py --- 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 . - -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 diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/platform_/__init__.py --- 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 . - -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() diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/platform_/android.py --- 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 . - -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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/platform_/base.py --- 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 . - -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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/profile_manager.py --- 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 . - - -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 [] diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/share_widget.py --- 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 . - - -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) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/simple_xhtml.py --- 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 . - - -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(" 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)) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/widgets_handler.py --- 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 . - - -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() diff -r 5114bbb5daa3 -r b3cedbee561d cagou/core/xmlui.py --- 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 . - -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}) diff -r 5114bbb5daa3 -r b3cedbee561d cagou/kv/__init__.py diff -r 5114bbb5daa3 -r b3cedbee561d cagou/kv/base.kv --- 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 . - - -