Mercurial > libervia-desktop-kivy
changeset 493:b3cedbee561d
refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
line wrap: on
line diff
--- a/cagou/VERSION Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -0.9.0D
--- a/cagou/__init__.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os.path - - -version_file = os.path.join(os.path.dirname(__file__), 'VERSION') -with open(version_file) as f: - __version__ = f.read().strip() - -class Global(object): - @property - def host(self): - return self._host -G = Global() - -# this import must be done after G is created -from .core import cagou_main - -def run(): - host = G._host = cagou_main.Cagou() - G.local_platform = cagou_main.local_platform - host.run()
--- a/cagou/core/behaviors.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from kivy import properties -from kivy.animation import Animation -from kivy.clock import Clock -from kivy_garden import modernmenu -from functools import partial - - -class TouchMenu(modernmenu.ModernMenu): - pass - - -class TouchMenuItemBehavior: - """Class to use on every item where a menu may appear - - main_wid attribute must be set to the class inheriting from TouchMenuBehavior - do_item_action is the method called on simple click - get_menu_choices must return a list of menus for long press - menus there are dict as expected by ModernMenu - (translated text, index and callback) - """ - main_wid = properties.ObjectProperty() - click_timeout = properties.NumericProperty(0.4) - - def on_touch_down(self, touch): - if not self.collide_point(*touch.pos): - return - t = partial(self.open_menu, touch) - touch.ud['menu_timeout'] = t - Clock.schedule_once(t, self.click_timeout) - return super(TouchMenuItemBehavior, self).on_touch_down(touch) - - def do_item_action(self, touch): - pass - - def on_touch_up(self, touch): - if touch.ud.get('menu_timeout'): - Clock.unschedule(touch.ud['menu_timeout']) - if self.collide_point(*touch.pos) and self.main_wid.menu is None: - self.do_item_action(touch) - return super(TouchMenuItemBehavior, self).on_touch_up(touch) - - def open_menu(self, touch, dt): - self.main_wid.open_menu(self, touch) - del touch.ud['menu_timeout'] - - def get_menu_choices(self): - """return choice adapted to selected item - - @return (list[dict]): choices ad expected by ModernMenu - """ - return [] - - -class TouchMenuBehavior: - """Class to handle a menu appearing on long press on items - - classes using this behaviour need to have a float_layout property - pointing the main FloatLayout. - """ - float_layout = properties.ObjectProperty() - - def __init__(self, *args, **kwargs): - super(TouchMenuBehavior, self).__init__(*args, **kwargs) - self.menu = None - self.menu_item = None - - ## menu methods ## - - def clean_fl_children(self, layout, children): - """insure that self.menu and self.menu_item are None when menu is dimissed""" - if self.menu is not None and self.menu not in children: - self.menu = self.menu_item = None - - def clear_menu(self): - """remove menu if there is one""" - if self.menu is not None: - self.menu.dismiss() - self.menu = None - self.menu_item = None - - def open_menu(self, item, touch): - """open menu for item - - @param item(PathWidget): item when the menu has been requested - @param touch(kivy.input.MotionEvent): touch data - """ - if self.menu_item == item: - return - self.clear_menu() - pos = self.to_widget(*touch.pos) - choices = item.get_menu_choices() - if not choices: - return - self.menu = TouchMenu(choices=choices, - center=pos, - size_hint=(None, None)) - self.float_layout.add_widget(self.menu) - self.menu.start_display(touch) - self.menu_item = item - - def on_float_layout(self, wid, float_layout): - float_layout.bind(children=self.clean_fl_children) - - -class FilterBehavior(object): - """class to handle items filtering with animation""" - - def __init__(self, *args, **kwargs): - super(FilterBehavior, self).__init__(*args, **kwargs) - self._filter_last = {} - self._filter_anim = Animation(width = 0, - height = 0, - opacity = 0, - d = 0.5) - - def do_filter(self, parent, text, get_child_text, width_cb, height_cb, - continue_tests=None): - """filter the children - - filtered children will have a animation to set width, height and opacity to 0 - @param parent(kivy.uix.widget.Widget): parent layout of the widgets to filter - @param text(unicode): filter text (if this text is not present in a child, - the child is filtered out) - @param get_child_text(callable): must retrieve child text - child is used as sole argument - @param width_cb(callable, int, None): method to retrieve width when opened - child is used as sole argument, int can be used instead of callable - @param height_cb(callable, int, None): method to retrieve height when opened - child is used as sole argument, int can be used instead of callable - @param continue_tests(list[callable]): list of test to skip the item - all callables take child as sole argument. - if any of the callable return True, the child is skipped (i.e. not filtered) - """ - text = text.strip().lower() - filtering = len(text)>len(self._filter_last.get(parent, '')) - self._filter_last[parent] = text - for child in parent.children: - if continue_tests is not None and any((t(child) for t in continue_tests)): - continue - if text in get_child_text(child).lower(): - self._filter_anim.cancel(child) - for key, method in (('width', width_cb), - ('height', height_cb), - ('opacity', lambda c: 1)): - try: - setattr(child, key, method(child)) - except TypeError: - # method is not a callable, must be an int - setattr(child, key, method) - elif (filtering - and child.opacity > 0 - and not self._filter_anim.have_properties_to_animate(child)): - self._filter_anim.start(child)
--- a/cagou/core/cagou_main.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1176 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -import os.path -import glob -import sys -from pathlib import Path -from urllib import parse as urlparse -from functools import partial -from sat.core.i18n import _ -from . import kivy_hack -kivy_hack.do_hack() -from .constants import Const as C -from sat.core import log as logging -from sat.core import exceptions -from sat_frontends.quick_frontend.quick_app import QuickApp -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend import quick_chat -from sat_frontends.quick_frontend import quick_utils -from sat_frontends.tools import jid -from sat.tools import utils as sat_utils -from sat.tools import config -from sat.tools.common import data_format -from sat.tools.common import dynamic_import -from sat.tools.common import files_utils -import kivy -kivy.require('1.11.0') -import kivy.support -main_config = config.parse_main_conf(log_filenames=True) -bridge_name = config.config_get(main_config, '', 'bridge', 'dbus') -# FIXME: event loop is choosen according to bridge_name, a better way should be used -if 'dbus' in bridge_name: - kivy.support.install_gobject_iteration() -elif bridge_name in ('pb', 'embedded'): - kivy.support.install_twisted_reactor() -from kivy.app import App -from kivy.lang import Builder -from kivy import properties -from . import xmlui -from .profile_manager import ProfileManager -from kivy.clock import Clock -from kivy.uix.label import Label -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.floatlayout import FloatLayout -from kivy.uix.screenmanager import (ScreenManager, Screen, - FallOutTransition, RiseInTransition) -from kivy.uix.dropdown import DropDown -from kivy.uix.behaviors import ButtonBehavior -from kivy.core.window import Window -from kivy.animation import Animation -from kivy.metrics import dp -from .cagou_widget import CagouWidget -from .share_widget import ShareWidget -from . import widgets_handler -from .common import IconButton -from . import dialog -from importlib import import_module -import sat -import cagou -import cagou.plugins -import cagou.kv - - -log = logging.getLogger(__name__) - - -try: - from plyer import notification -except ImportError: - notification = None - log.warning(_("Can't import plyer, some features disabled")) - - -## platform specific settings ## - -from . import platform_ -local_platform = platform_.create() -local_platform.init_platform() - - -## General Configuration ## - -# we want white background by default -Window.clearcolor = (1, 1, 1, 1) - - -class NotifsIcon(IconButton): - notifs = properties.ListProperty() - - def on_release(self): - callback, args, kwargs = self.notifs.pop(0) - callback(*args, **kwargs) - - def add_notif(self, callback, *args, **kwargs): - self.notifs.append((callback, args, kwargs)) - - -class Note(Label): - title = properties.StringProperty() - message = properties.StringProperty() - level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, - options=list(C.XMLUI_DATA_LVLS)) - symbol = properties.StringProperty() - action = properties.ObjectProperty() - - -class NoteDrop(ButtonBehavior, BoxLayout): - title = properties.StringProperty() - message = properties.StringProperty() - level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, - options=list(C.XMLUI_DATA_LVLS)) - symbol = properties.StringProperty() - action = properties.ObjectProperty() - - def on_press(self): - if self.action is not None: - self.parent.parent.select(self.action) - - -class NotesDrop(DropDown): - clear_btn = properties.ObjectProperty() - - def __init__(self, notes): - super(NotesDrop, self).__init__() - self.notes = notes - - def open(self, widget): - self.clear_widgets() - for n in self.notes: - kwargs = { - 'title': n.title, - 'message': n.message, - 'level': n.level - } - if n.symbol is not None: - kwargs['symbol'] = n.symbol - if n.action is not None: - kwargs['action'] = n.action - self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level, - symbol=n.symbol, action=n.action)) - self.add_widget(self.clear_btn) - super(NotesDrop, self).open(widget) - - def on_select(self, action_kwargs): - app = App.get_running_app() - app.host.do_action(**action_kwargs) - - -class RootHeadWidget(BoxLayout): - """Notifications widget""" - manager = properties.ObjectProperty() - notifs_icon = properties.ObjectProperty() - notes = properties.ListProperty() - HEIGHT = dp(35) - - def __init__(self): - super(RootHeadWidget, self).__init__() - self.notes_last = None - self.notes_event = None - self.notes_drop = NotesDrop(self.notes) - - def add_notif(self, callback, *args, **kwargs): - """add a notification with a callback attached - - when notification is pressed, callback is called - @param *args, **kwargs: arguments of callback - """ - self.notifs_icon.add_notif(callback, *args, **kwargs) - - def add_note(self, title, message, level, symbol, action): - kwargs = { - 'title': title, - 'message': message, - 'level': level - } - if symbol is not None: - kwargs['symbol'] = symbol - if action is not None: - kwargs['action'] = action - note = Note(**kwargs) - self.notes.append(note) - if self.notes_event is None: - self.notes_event = Clock.schedule_interval(self._display_next_note, 5) - self._display_next_note() - - def add_notif_ui(self, ui): - self.notifs_icon.add_notif(ui.show, force=True) - - def add_notif_widget(self, widget): - app = App.get_running_app() - self.notifs_icon.add_notif(app.host.show_extra_ui, widget=widget) - - def _display_next_note(self, __=None): - screen = Screen() - try: - idx = self.notes.index(self.notes_last) + 1 - except ValueError: - idx = 0 - try: - note = self.notes_last = self.notes[idx] - except IndexError: - self.notes_event.cancel() - self.notes_event = None - else: - screen.add_widget(note) - self.manager.switch_to(screen) - - -class RootBody(BoxLayout): - pass - - -class CagouRootWidget(FloatLayout): - root_body = properties.ObjectProperty - - def __init__(self, main_widget): - super(CagouRootWidget, self).__init__() - # header - self.head_widget = RootHeadWidget() - self.root_body.add_widget(self.head_widget) - # body - self._manager = ScreenManager() - # main widgets - main_screen = Screen(name='main') - main_screen.add_widget(main_widget) - self._manager.add_widget(main_screen) - # backend XMLUI (popups, forms, etc) - xmlui_screen = Screen(name='xmlui') - self._manager.add_widget(xmlui_screen) - # extra (file chooser, audio record, etc) - extra_screen = Screen(name='extra') - self._manager.add_widget(extra_screen) - self.root_body.add_widget(self._manager) - - def change_widget(self, widget, screen_name="main"): - """change main widget""" - if self._manager.transition.is_active: - # FIXME: workaround for what seems a Kivy bug - # TODO: report this upstream - self._manager.transition.stop() - screen = self._manager.get_screen(screen_name) - screen.clear_widgets() - screen.add_widget(widget) - - def show(self, screen="main"): - if self._manager.transition.is_active: - # FIXME: workaround for what seems a Kivy bug - # TODO: report this upstream - self._manager.transition.stop() - if self._manager.current == screen: - return - if screen == "main": - self._manager.transition = FallOutTransition() - else: - self._manager.transition = RiseInTransition() - self._manager.current = screen - - def new_action(self, handler, action_data, id_, security_limit, profile): - """Add a notification for an action""" - self.head_widget.add_notif(handler, action_data, id_, security_limit, profile) - - def add_note(self, title, message, level, symbol, action): - self.head_widget.add_note(title, message, level, symbol, action) - - def add_notif_ui(self, ui): - self.head_widget.add_notif_ui(ui) - - def add_notif_widget(self, widget): - self.head_widget.add_notif_widget(widget) - - -class CagouApp(App): - """Kivy App for Cagou""" - c_prim = properties.ListProperty(C.COLOR_PRIM) - c_prim_light = properties.ListProperty(C.COLOR_PRIM_LIGHT) - c_prim_dark = properties.ListProperty(C.COLOR_PRIM_DARK) - c_sec = properties.ListProperty(C.COLOR_SEC) - c_sec_light = properties.ListProperty(C.COLOR_SEC_LIGHT) - c_sec_dark = properties.ListProperty(C.COLOR_SEC_DARK) - connected = properties.BooleanProperty(False) - # we have to put those constants here and not in core/constants.py - # because of the use of dp(), which would import Kivy too early - # and prevent the log hack - MARGIN_LEFT = MARGIN_RIGHT = dp(10) - - def _install_settings_keys(self, window): - # we don't want default Kivy's behaviour of displaying - # a settings screen when pressing F1 or platform specific key - return - - def build(self): - Window.bind(on_keyboard=self.key_input) - Window.bind(on_dropfile=self.on_dropfile) - wid = CagouRootWidget(Label(text=_("Loading please wait"))) - local_platform.on_app_build(wid) - return wid - - def show_profile_manager(self): - self._profile_manager = ProfileManager() - self.root.change_widget(self._profile_manager) - - def expand(self, path, *args, **kwargs): - """expand path and replace known values - - useful in kv. Values which can be used: - - {media}: media dir - @param path(unicode): path to expand - @param *args: additional arguments used in format - @param **kwargs: additional keyword arguments used in format - """ - return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs) - - def init_frontend_state(self): - """Init state to handle paused/stopped/running on mobile OSes""" - local_platform.on_init_frontend_state() - - def on_pause(self): - return local_platform.on_pause() - - def on_resume(self): - return local_platform.on_resume() - - def on_stop(self): - return local_platform.on_stop() - - def show_head_widget(self, show=None, animation=True): - """Show/Hide the head widget - - @param show(bool, None): True to show, False to hide, None to switch - @param animation(bool): animate the show/hide if True - """ - head = self.root.head_widget - if bool(self.root.head_widget.height) == show: - return - if head.height: - if animation: - Animation(height=0, opacity=0, duration=0.3).start(head) - else: - head.height = head.opacity = 0 - else: - if animation: - Animation(height=head.HEIGHT, opacity=1, duration=0.3).start(head) - else: - head.height = head.HEIGHT - head.opacity = 1 - - def key_input(self, window, key, scancode, codepoint, modifier): - - # we first check if selected widget handles the key - if ((self.host.selected_widget is not None - and hasattr(self.host.selected_widget, 'key_input') - and self.host.selected_widget.key_input(window, key, scancode, codepoint, - modifier))): - return True - - if key == 27: - if ((self.host.selected_widget is None - or self.host.selected_widget.__class__ == self.host.default_class)): - # we are on root widget, or nothing is selected - return local_platform.on_key_back_root() - - # we disable [esc] handling, because default action is to quit app - return True - elif key == 292: - # F11: full screen - if not Window.fullscreen: - Window.fullscreen = 'auto' - else: - Window.fullscreen = False - return True - elif key == 110 and 'alt' in modifier: - # M-n we hide/show notifications - self.show_head_widget() - return True - else: - return False - - def on_dropfile(self, __, file_path): - if self.host.selected_widget is not None: - try: - on_drop_file = self.host.selected_widget.on_drop_file - except AttributeError: - log.info( - f"Select widget {self.host.selected_widget} doesn't handle file " - f"dropping") - else: - on_drop_file(Path(file_path.decode())) - - -class Cagou(QuickApp): - MB_HANDLE = False - AUTO_RESYNC = False - - def __init__(self): - if bridge_name == 'embedded': - from sat.core import sat_main - self.sat = sat_main.SAT() - - bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') - if bridge_module is None: - log.error(f"Can't import {bridge_name} bridge") - sys.exit(3) - else: - log.debug(f"Loading {bridge_name} bridge") - super(Cagou, self).__init__(bridge_factory=bridge_module.bridge, - xmlui=xmlui, - check_options=quick_utils.check_options, - connect_bridge=False) - self._import_kv() - self.app = CagouApp() - self.app.host = self - self.media_dir = self.app.media_dir = config.config_get(main_config, '', - 'media_dir') - self.downloads_dir = self.app.downloads_dir = config.config_get(main_config, '', - 'downloads_dir') - if not os.path.exists(self.downloads_dir): - try: - os.makedirs(self.downloads_dir) - except OSError as e: - log.warnings(_("Can't create downloads dir: {reason}").format(reason=e)) - self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png") - self.app.icon = os.path.join( - self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png" - ) - self.app.title = C.APP_NAME - # main widgets plugins - self._plg_wids = [] - # transfer widgets plugins - self._plg_wids_transfer = [] - self._import_plugins() - # visible widgets by classes - self._visible_widgets = {} - # used to keep track of last selected widget in "main" screen when changing - # root screen - self._selected_widget_main = None - self.backend_version = sat.__version__ # will be replaced by version_get() - if C.APP_VERSION.endswith('D'): - self.version = "{} {}".format( - C.APP_VERSION, - sat_utils.get_repository_data(cagou) - ) - else: - self.version = C.APP_VERSION - - self.tls_validation = not C.bool(config.config_get(main_config, - C.CONFIG_SECTION, - 'no_certificate_validation', - C.BOOL_FALSE)) - if not self.tls_validation: - from cagou.core import patches - patches.disable_tls_validation() - log.warning("SSL certificate validation is disabled, this is unsecure!") - - local_platform.on_host_init(self) - - @property - def visible_widgets(self): - for w_list in self._visible_widgets.values(): - for w in w_list: - yield w - - @property - def default_class(self): - if self.default_wid is None: - return None - return self.default_wid['main'] - - @QuickApp.sync.setter - def sync(self, state): - QuickApp.sync.fset(self, state) - # widget are resynchronised in on_visible event, - # so we must call resync for widgets which are already visible - if state: - for w in self.visible_widgets: - try: - resync = w.resync - except AttributeError: - pass - else: - resync() - self.contact_lists.fill() - - def config_get(self, section, name, default=None): - return config.config_get(main_config, section, name, default) - - def on_bridge_connected(self): - super(Cagou, self).on_bridge_connected() - self.register_signal("otr_state", iface="plugin") - - def _bridge_eb(self, failure): - if bridge_name == "pb" and sys.platform == "android": - try: - self.retried += 1 - except AttributeError: - self.retried = 1 - if ((isinstance(failure, exceptions.BridgeExceptionNoService) - and self.retried < 100)): - if self.retried % 20 == 0: - log.debug("backend not ready, retrying ({})".format(self.retried)) - Clock.schedule_once(lambda __: self.connect_bridge(), 0.05) - return - super(Cagou, self)._bridge_eb(failure) - - def run(self): - self.connect_bridge() - self.app.bind(on_stop=self.onStop) - self.app.run() - - def onStop(self, obj): - try: - sat_instance = self.sat - except AttributeError: - pass - else: - sat_instance.stopService() - - def _get_version_cb(self, version): - self.backend_version = version - - def on_backend_ready(self): - super().on_backend_ready() - self.app.show_profile_manager() - self.bridge.version_get(callback=self._get_version_cb) - self.app.init_frontend_state() - if local_platform.do_post_init(): - self.post_init() - - def post_init(self, __=None): - # FIXME: resize doesn't work with SDL2 on android, so we use below_target for now - self.app.root_window.softinput_mode = "below_target" - profile_manager = self.app._profile_manager - del self.app._profile_manager - super(Cagou, self).post_init(profile_manager) - - def profile_plugged(self, profile): - super().profile_plugged(profile) - # FIXME: this won't work with multiple profiles - self.app.connected = self.profiles[profile].connected - - def _bookmarks_list_cb(self, bookmarks_dict, profile): - bookmarks = set() - for data in bookmarks_dict.values(): - bookmarks.update({jid.JID(k) for k in data.keys()}) - self.profiles[profile]._bookmarks = sorted(bookmarks) - - def profile_connected(self, profile): - self.bridge.bookmarks_list( - "muc", "all", profile, - callback=partial(self._bookmarks_list_cb, profile=profile), - errback=partial(self.errback, title=_("Bookmark error"))) - - def _default_factory_main(self, plugin_info, target, profiles): - """default factory used to create main widgets instances - - used when PLUGIN_INFO["factory"] is not set - @param plugin_info(dict): plugin datas - @param target: QuickWidget target - @param profiles(iterable): list of profiles - """ - main_cls = plugin_info['main'] - return self.widgets.get_or_create_widget(main_cls, - target, - on_new_widget=None, - profiles=iter(self.profiles)) - - def _default_factory_transfer(self, plugin_info, callback, cancel_cb, profiles): - """default factory used to create transfer widgets instances - - @param plugin_info(dict): plugin datas - @param callback(callable): method to call with path to file to transfer - @param cancel_cb(callable): call when transfer is cancelled - transfer widget must be used as first argument - @param profiles(iterable): list of profiles - None if not specified - """ - main_cls = plugin_info['main'] - return main_cls(callback=callback, cancel_cb=cancel_cb) - - ## plugins & kv import ## - - def _import_kv(self): - """import all kv files in cagou.kv""" - path = os.path.dirname(cagou.kv.__file__) - kv_files = glob.glob(os.path.join(path, "*.kv")) - # we want to be sure that base.kv is loaded first - # as it override some Kivy widgets properties - for kv_file in kv_files: - if kv_file.endswith('base.kv'): - kv_files.remove(kv_file) - kv_files.insert(0, kv_file) - break - else: - raise exceptions.InternalError("base.kv is missing") - - for kv_file in kv_files: - Builder.load_file(kv_file) - log.debug(f"kv file {kv_file} loaded") - - def _import_plugins(self): - """import all plugins""" - self.default_wid = None - plugins_path = os.path.dirname(cagou.plugins.__file__) - plugin_glob = "plugin*." + C.PLUGIN_EXT - plug_lst = [os.path.splitext(p)[0] for p in - map(os.path.basename, glob.glob(os.path.join(plugins_path, - plugin_glob)))] - - imported_names_main = set() # used to avoid loading 2 times - # plugin with same import name - imported_names_transfer = set() - for plug in plug_lst: - plugin_path = 'cagou.plugins.' + plug - - # we get type from plugin name - suff = plug[7:] - if '_' not in suff: - log.error("invalid plugin name: {}, skipping".format(plug)) - continue - plugin_type = suff[:suff.find('_')] - - # and select the variable to use according to type - if plugin_type == C.PLUG_TYPE_WID: - imported_names = imported_names_main - default_factory = self._default_factory_main - elif plugin_type == C.PLUG_TYPE_TRANSFER: - imported_names = imported_names_transfer - default_factory = self._default_factory_transfer - else: - log.error("unknown plugin type {type_} for plugin {file_}, skipping" - .format( - type_ = plugin_type, - file_ = plug - )) - continue - plugins_set = self._get_plugins_set(plugin_type) - - mod = import_module(plugin_path) - try: - plugin_info = mod.PLUGIN_INFO - except AttributeError: - plugin_info = {} - - plugin_info['plugin_file'] = plug - plugin_info['plugin_type'] = plugin_type - - if 'platforms' in plugin_info: - if sys.platform not in plugin_info['platforms']: - log.info("{plugin_file} is not used on this platform, skipping" - .format(**plugin_info)) - continue - - # import name is used to differentiate plugins - if 'import_name' not in plugin_info: - plugin_info['import_name'] = plug - if plugin_info['import_name'] in imported_names: - log.warning(_("there is already a plugin named {}, " - "ignoring new one").format(plugin_info['import_name'])) - continue - if plugin_info['import_name'] == C.WID_SELECTOR: - if plugin_type != C.PLUG_TYPE_WID: - log.error("{import_name} import name can only be used with {type_} " - "type, skipping {name}".format(type_=C.PLUG_TYPE_WID, - **plugin_info)) - continue - # if WidgetSelector exists, it will be our default widget - self.default_wid = plugin_info - - # we want everything optional, so we use plugin file name - # if actual name is not found - if 'name' not in plugin_info: - name_start = 8 + len(plugin_type) - plugin_info['name'] = plug[name_start:] - - # we need to load the kv file - if 'kv_file' not in plugin_info: - plugin_info['kv_file'] = '{}.kv'.format(plug) - kv_path = os.path.join(plugins_path, plugin_info['kv_file']) - if not os.path.exists(kv_path): - log.debug("no kv found for {plugin_file}".format(**plugin_info)) - else: - Builder.load_file(kv_path) - - # what is the main class ? - main_cls = getattr(mod, plugin_info['main']) - plugin_info['main'] = main_cls - - # factory is used to create the instance - # if not found, we use a defaut one with get_or_create_widget - if 'factory' not in plugin_info: - plugin_info['factory'] = default_factory - - # icons - for size in ('small', 'medium'): - key = 'icon_{}'.format(size) - try: - path = plugin_info[key] - except KeyError: - path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) - else: - path = path.format(media=self.media_dir) - if not os.path.isfile(path): - path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) - plugin_info[key] = path - - plugins_set.append(plugin_info) - if not self._plg_wids: - log.error(_("no widget plugin found")) - return - - # we want widgets sorted by names - self._plg_wids.sort(key=lambda p: p['name'].lower()) - self._plg_wids_transfer.sort(key=lambda p: p['name'].lower()) - - if self.default_wid is None: - # we have no selector widget, we use the first widget as default - self.default_wid = self._plg_wids[0] - - def _get_plugins_set(self, type_): - if type_ == C.PLUG_TYPE_WID: - return self._plg_wids - elif type_ == C.PLUG_TYPE_TRANSFER: - return self._plg_wids_transfer - else: - raise KeyError("{} plugin type is unknown".format(type_)) - - def get_plugged_widgets(self, type_=C.PLUG_TYPE_WID, except_cls=None): - """get available widgets plugin infos - - @param type_(unicode): type of widgets to get - one of C.PLUG_TYPE_* constant - @param except_cls(None, class): if not None, - widgets from this class will be excluded - @return (iter[dict]): available widgets plugin infos - """ - plugins_set = self._get_plugins_set(type_) - for plugin_data in plugins_set: - if plugin_data['main'] == except_cls: - continue - yield plugin_data - - def get_plugin_info(self, type_=C.PLUG_TYPE_WID, **kwargs): - """get first plugin info corresponding to filters - - @param type_(unicode): type of widgets to get - one of C.PLUG_TYPE_* constant - @param **kwargs: filter(s) to use, each key present here must also - exist and be of the same value in requested plugin info - @return (dict, None): found plugin info or None - """ - plugins_set = self._get_plugins_set(type_) - for plugin_info in plugins_set: - for k, w in kwargs.items(): - try: - if plugin_info[k] != w: - continue - except KeyError: - continue - return plugin_info - - ## widgets handling - - def new_widget(self, widget): - log.debug("new widget created: {}".format(widget)) - if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: - self.add_note("", _("room {} has been joined").format(widget.target)) - - def switch_widget(self, old, new=None): - """Replace old widget by new one - - @param old(CagouWidget, None): CagouWidget instance or a child - None to select automatically widget to switch - @param new(CagouWidget): new widget instance - None to use default widget - @return (CagouWidget): new widget - """ - if old is None: - old = self.get_widget_to_switch() - if new is None: - factory = self.default_wid['factory'] - try: - profiles = old.profiles - except AttributeError: - profiles = None - new = factory(self.default_wid, None, profiles=profiles) - to_change = None - if isinstance(old, CagouWidget): - to_change = old - else: - for w in old.walk_reverse(): - if isinstance(w, CagouWidget): - to_change = w - break - - if to_change is None: - raise exceptions.InternalError("no CagouWidget found when " - "trying to switch widget") - - # selected_widget can be modified in change_widget, so we need to set it before - self.selected_widget = new - if to_change == new: - log.debug("switch_widget called with old==new, nothing to do") - return new - to_change.whwrapper.change_widget(new) - return new - - def _add_visible_widget(self, widget): - """declare a widget visible - - for internal use only! - """ - assert isinstance(widget, CagouWidget) - log.debug(f"Visible widget: {widget}") - self._visible_widgets.setdefault(widget.__class__, set()).add(widget) - log.debug(f"visible widgets list: {self.get_visible_list(None)}") - widget.on_visible() - - def _remove_visible_widget(self, widget, ignore_missing=False): - """declare a widget not visible anymore - - for internal use only! - """ - log.debug(f"Widget not visible anymore: {widget}") - try: - self._visible_widgets[widget.__class__].remove(widget) - except KeyError as e: - if not ignore_missing: - log.error(f"trying to remove a not visible widget ({widget}): {e}") - return - log.debug(f"visible widgets list: {self.get_visible_list(None)}") - if isinstance(widget, CagouWidget): - widget.on_not_visible() - if isinstance(widget, quick_widgets.QuickWidget): - self.widgets.delete_widget(widget) - - def get_visible_list(self, cls): - """get list of visible widgets for a given class - - @param cls(type): type of widgets to get - None to get all visible widgets - @return (set[type]): visible widgets of this class - """ - if cls is None: - ret = set() - for widgets in self._visible_widgets.values(): - for w in widgets: - ret.add(w) - return ret - try: - return self._visible_widgets[cls] - except KeyError: - return set() - - def delete_unused_widget_instances(self, widget): - """Delete instance of this widget which are not attached to a WHWrapper - - @param widget(quick_widgets.QuickWidget): reference widget - other instance of this widget will be deleted if they have no parent - """ - to_delete = [] - if isinstance(widget, quick_widgets.QuickWidget): - for w in self.widgets.get_widget_instances(widget): - if w.whwrapper is None and w != widget: - to_delete.append(w) - for w in to_delete: - log.debug("cleaning widget: {wid}".format(wid=w)) - self.widgets.delete_widget(w) - - def get_or_clone(self, widget, **kwargs): - """Get a QuickWidget if it is not in a WHWrapper, else clone it - - if an other instance of this widget exist without being in a WHWrapper - (i.e. if it is not already in use) it will be used. - """ - if widget.whwrapper is None: - if widget.parent is not None: - widget.parent.remove_widget(widget) - self.delete_unused_widget_instances(widget) - return widget - for w in self.widgets.get_widget_instances(widget): - if w.whwrapper is None: - if w.parent is not None: - w.parent.remove_widget(w) - self.delete_unused_widget_instances(w) - return w - targets = list(widget.targets) - w = self.widgets.get_or_create_widget(widget.__class__, - targets[0], - on_new_widget=None, - on_existing_widget=C.WIDGET_RECREATE, - profiles=widget.profiles, - **kwargs) - for t in targets[1:]: - w.add_target(t) - return w - - def get_widget_to_switch(self): - """Choose best candidate when we need to switch widget and old is not specified - - @return (CagouWidget): widget to switch - """ - if (self._selected_widget_main is not None - and self._selected_widget_main.whwrapper is not None): - # we are not on the main screen, we want to switch a widget from main screen - return self._selected_widget_main - elif (self.selected_widget is not None - and isinstance(self.selected_widget, CagouWidget) - and self.selected_widget.whwrapper is not None): - return self.selected_widget - # no widget is selected we check if we have any default widget - default_cls = self.default_class - for w in self.visible_widgets: - if isinstance(w, default_cls): - return w - - # no default widget found, we return the first widget - return next(iter(self.visible_widgets)) - - def do_action(self, action, target, profiles): - """Launch an action handler by a plugin - - @param action(unicode): action to do, can be: - - chat: open a chat widget - @param target(unicode): target of the action - @param profiles(list[unicode]): profiles to use - @return (CagouWidget, None): new widget - """ - try: - # FIXME: Q&D way to get chat plugin, should be replaced by a clean method - # in host - plg_infos = [p for p in self.get_plugged_widgets() - if action in p['import_name']][0] - except IndexError: - log.warning("No plugin widget found to do {action}".format(action=action)) - else: - try: - # does the widget already exist? - wid = next(self.widgets.get_widgets( - plg_infos['main'], - target=target, - profiles=profiles)) - except StopIteration: - # no, let's create a new one - factory = plg_infos['factory'] - wid = factory(plg_infos, target=target, profiles=profiles) - - return self.switch_widget(None, wid) - - ## bridge handlers ## - - def otr_state_handler(self, state, dest_jid, profile): - """OTR state has changed for on destinee""" - # XXX: this method could be in QuickApp but it's here as - # it's only used by Cagou so far - dest_jid = jid.JID(dest_jid) - bare_jid = dest_jid.bare - for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)): - if widget.type == C.CHAT_ONE2ONE and widget.target == bare_jid: - widget.on_otr_state(state, dest_jid, profile) - - def _debug_handler(self, action, parameters, profile): - if action == "visible_widgets_dump": - from pprint import pformat - log.info("Visible widgets dump:\n{data}".format( - data=pformat(self._visible_widgets))) - else: - return super(Cagou, self)._debug_handler(action, parameters, profile) - - def connected_handler(self, jid_s, profile): - # FIXME: this won't work with multiple profiles - super().connected_handler(jid_s, profile) - self.app.connected = True - - def disconnected_handler(self, profile): - # FIXME: this won't work with multiple profiles - super().disconnected_handler(profile) - self.app.connected = False - - ## misc ## - - def plugging_profiles(self): - self.widgets_handler = widgets_handler.WidgetsHandler() - self.app.root.change_widget(self.widgets_handler) - - def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE): - log.info("Profile presence status set to {show}/{status}".format(show=show, - status=status)) - - def errback(self, failure_, title=_('error'), - message=_('error while processing: {msg}')): - self.add_note(title, message.format(msg=failure_), level=C.XMLUI_DATA_LVL_WARNING) - - def add_note(self, title, message, level=C.XMLUI_DATA_LVL_INFO, symbol=None, - action=None): - """add a note (message which disappear) to root widget's header""" - self.app.root.add_note(title, message, level, symbol, action) - - def add_notif_ui(self, ui): - """add a notification with a XMLUI attached - - @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected - """ - self.app.root.add_notif_ui(ui) - - def add_notif_widget(self, widget): - """add a notification with a Kivy widget attached - - @param widget(kivy.uix.Widget): widget to attach to notification - """ - self.app.root.add_notif_widget(widget) - - def show_ui(self, ui): - """show a XMLUI""" - self.app.root.change_widget(ui, "xmlui") - self.app.root.show("xmlui") - self._selected_widget_main = self.selected_widget - self.selected_widget = ui - - def show_extra_ui(self, widget): - """show any extra widget""" - self.app.root.change_widget(widget, "extra") - self.app.root.show("extra") - self._selected_widget_main = self.selected_widget - self.selected_widget = widget - - def close_ui(self): - self.app.root.show() - self.selected_widget = self._selected_widget_main - self._selected_widget_main = None - screen = self.app.root._manager.get_screen("extra") - screen.clear_widgets() - - def get_default_avatar(self, entity=None): - return self.app.default_avatar - - def _dialog_cb(self, cb, *args, **kwargs): - """generic dialog callback - - close dialog then call the callback with given arguments - """ - def callback(): - self.close_ui() - cb(*args, **kwargs) - return callback - - def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None): - if type in ('info', 'warning', 'error'): - self.add_note(title, message, type) - elif type == "yes/no": - wid = dialog.ConfirmDialog(title=title, message=message, - yes_cb=self._dialog_cb(answer_cb, - True, - answer_data), - no_cb=self._dialog_cb(answer_cb, - False, - answer_data) - ) - self.add_notif_widget(wid) - else: - log.warning(_("unknown dialog type: {dialog_type}").format(dialog_type=type)) - - def share(self, media_type, data): - share_wid = ShareWidget(media_type=media_type, data=data) - try: - self.show_extra_ui(share_wid) - except Exception as e: - log.error(e) - self.close_ui() - - def download_url( - self, url, callback, errback=None, options=None, dest=C.FILE_DEST_DOWNLOAD, - profile=C.PROF_KEY_NONE): - """Download an URL (decrypt it if necessary) - - @param url(str, parse.SplitResult): url to download - @param callback(callable): method to call when download is complete - @param errback(callable, None): method to call in case of error - if None, default errback will be called - @param dest(str): where the file should be downloaded: - - C.FILE_DEST_DOWNLOAD: in platform download directory - - C.FILE_DEST_CACHE: in SàT cache - @param options(dict, None): options to pass to bridge.file_download_complete - """ - if not isinstance(url, urlparse.ParseResult): - url = urlparse.urlparse(url) - if errback is None: - errback = partial( - self.errback, - title=_("Download error"), - message=_("Error while downloading {url}: {{msg}}").format(url=url.geturl())) - name = Path(url.path).name.strip() or C.FILE_DEFAULT_NAME - log.info(f"downloading/decrypting file {name!r}") - if dest == C.FILE_DEST_DOWNLOAD: - dest_path = files_utils.get_unique_name(Path(self.downloads_dir)/name) - elif dest == C.FILE_DEST_CACHE: - dest_path = '' - else: - raise exceptions.InternalError(f"Invalid dest_path: {dest_path!r}") - self.bridge.file_download_complete( - data_format.serialise({"uri": url.geturl()}), - str(dest_path), - '' if not options else data_format.serialise(options), - profile, - callback=callback, - errback=errback - ) - - def notify(self, type_, entity=None, message=None, subject=None, callback=None, - cb_args=None, widget=None, profile=C.PROF_KEY_NONE): - super().notify( - type_=type_, entity=entity, message=message, subject=subject, - callback=callback, cb_args=cb_args, widget=widget, profile=profile) - self.desktop_notif(message, title=subject) - - def desktop_notif(self, message, title='', duration=5): - global notification - if notification is not None: - try: - log.debug( - f"sending desktop notification (duration: {duration}):\n" - f"{title}\n" - f"{message}" - ) - notification.notify(title=title, - message=message, - app_name=C.APP_NAME, - app_icon=self.app.icon, - timeout=duration) - except Exception as e: - log.warning(_("Can't use notifications, disabling: {msg}").format( - msg = e)) - notification = None - - def get_parent_wh_wrapper(self, wid): - """Retrieve parent WHWrapper instance managing a widget - - @param wid(Widget): widget to check - @return (WHWrapper, None): found instance if any, else None - """ - wh = self.get_ancestor_widget(wid, widgets_handler.WHWrapper) - if wh is None: - # we may have a screen - try: - sm = wid.screen_manager - except (exceptions.InternalError, exceptions.NotFound): - return None - else: - wh = self.get_ancestor_widget(sm, widgets_handler.WHWrapper) - return wh - - def get_ancestor_widget(self, wid, cls): - """Retrieve an ancestor of given class - - @param wid(Widget): current widget - @param cls(type): class of the ancestor to retrieve - @return (Widget, None): found instance or None - """ - parent = wid.parent - while parent and not isinstance(parent, cls): - parent = parent.parent - return parent
--- a/cagou/core/cagou_widget.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from functools import total_ordering -from sat.core import log as logging -from sat.core import exceptions -from kivy.uix.behaviors import ButtonBehavior -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.dropdown import DropDown -from kivy.uix.screenmanager import Screen -from kivy.uix.textinput import TextInput -from kivy import properties -from cagou import G -from .common import ActionIcon -from . import menu - - -log = logging.getLogger(__name__) - - -class HeaderChoice(ButtonBehavior, BoxLayout): - pass - - -class HeaderChoiceWidget(HeaderChoice): - cagou_widget = properties.ObjectProperty() - plugin_info = properties.ObjectProperty() - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.bind(on_release=lambda btn: self.cagou_widget.switch_widget( - self.plugin_info)) - - -class HeaderChoiceExtraMenu(HeaderChoice): - pass - - -class HeaderWidgetCurrent(ButtonBehavior, ActionIcon): - pass - - -class HeaderWidgetSelector(DropDown): - - def __init__(self, cagou_widget): - super(HeaderWidgetSelector, self).__init__() - plg_info_cls = cagou_widget.plugin_info_class or cagou_widget.__class__ - for plugin_info in G.host.get_plugged_widgets(except_cls=plg_info_cls): - choice = HeaderChoiceWidget( - cagou_widget=cagou_widget, - plugin_info=plugin_info, - ) - self.add_widget(choice) - main_menu = HeaderChoiceExtraMenu(on_press=self.on_extra_menu) - self.add_widget(main_menu) - - def add_widget(self, *args): - widget = args[0] - widget.bind(minimum_width=self.set_width) - return super(HeaderWidgetSelector, self).add_widget(*args) - - def set_width(self, choice, minimum_width): - self.width = max([c.minimum_width for c in self.container.children]) - - def on_extra_menu(self, *args): - self.dismiss() - menu.ExtraSideMenu().show() - - -@total_ordering -class CagouWidget(BoxLayout): - main_container = properties.ObjectProperty(None) - header_input = properties.ObjectProperty(None) - header_box = properties.ObjectProperty(None) - use_header_input = False - # set to True if you want to be able to switch between visible widgets of this - # class using a carousel - collection_carousel = False - # set to True if you a global ScreenManager global to all widgets of this class. - # The screen manager is created in WHWrapper - global_screen_manager = False - # override this if a specific class (i.e. not self.__class__) must be used for - # plugin info. Useful when a CagouWidget is used with global_screen_manager. - plugin_info_class = None - - def __init__(self, **kwargs): - plg_info_cls = self.plugin_info_class or self.__class__ - for p in G.host.get_plugged_widgets(): - if p['main'] == plg_info_cls: - self.plugin_info = p - break - super().__init__(**kwargs) - self.selector = HeaderWidgetSelector(self) - if self.use_header_input: - self.header_input = TextInput( - background_normal=G.host.app.expand( - '{media}/misc/borders/border_hollow_light.png'), - multiline=False, - ) - self.header_input.bind( - on_text_validate=lambda *args: self.on_header_wid_input(), - text=self.on_header_wid_input_complete, - ) - self.header_box.add_widget(self.header_input) - - def __lt__(self, other): - # XXX: sorting is notably used when collection_carousel is set - try: - target = str(self.target) - except AttributeError: - target = str(list(self.targets)[0]) - other_target = str(list(other.targets)[0]) - else: - other_target = str(other.target) - return target < other_target - - @property - def screen_manager(self): - if ((not self.global_screen_manager - and not (self.plugin_info_class is not None - and self.plugin_info_class.global_screen_manager))): - raise exceptions.InternalError( - "screen_manager property can't be used if global_screen_manager is not " - "set") - screen = self.get_ancestor(Screen) - if screen is None: - raise exceptions.NotFound("Can't find parent Screen") - if screen.manager is None: - raise exceptions.NotFound("Can't find parent ScreenManager") - return screen.manager - - @property - def whwrapper(self): - """Retrieve parent widget handler""" - return G.host.get_parent_wh_wrapper(self) - - def screen_manager_init(self, screen_manager): - """Override this method to do init when ScreenManager is instantiated - - This is only called once even if collection_carousel is used. - """ - if not self.global_screen_manager: - raise exceptions.InternalError("screen_manager_init should not be called") - - def get_ancestor(self, cls): - """Helper method to use host.get_ancestor_widget with self""" - return G.host.get_ancestor_widget(self, cls) - - def switch_widget(self, plugin_info): - self.selector.dismiss() - factory = plugin_info["factory"] - new_widget = factory(plugin_info, None, iter(G.host.profiles)) - G.host.switch_widget(self, new_widget) - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - # we go back to root screen - G.host.switch_widget(self) - return True - - def on_header_wid_input(self): - log.info("header input text entered") - - def on_header_wid_input_complete(self, wid, text): - return - - def on_touch_down(self, touch): - if self.collide_point(*touch.pos): - G.host.selected_widget = self - return super(CagouWidget, self).on_touch_down(touch) - - def header_input_add_extra(self, widget): - """add a widget on the right of header input""" - self.header_box.add_widget(widget) - - def on_visible(self): - pass - # log.debug(u"{self} is visible".format(self=self)) - - def on_not_visible(self): - pass - # log.debug(u"{self} is not visible anymore".format(self=self))
--- a/cagou/core/common.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,481 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""common simple widgets""" - -import json -from functools import partial, total_ordering -from kivy.uix.widget import Widget -from kivy.uix.label import Label -from kivy.uix.behaviors import ButtonBehavior -from kivy.uix.behaviors import ToggleButtonBehavior -from kivy.uix.stacklayout import StackLayout -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.scrollview import ScrollView -from kivy.event import EventDispatcher -from kivy.metrics import dp -from kivy import properties -from sat.core.i18n import _ -from sat.core import log as logging -from sat.tools.common import data_format -from sat_frontends.quick_frontend import quick_chat -from .constants import Const as C -from .common_widgets import CategorySeparator -from .image import Image, AsyncImage -from cagou import G - -log = logging.getLogger(__name__) - -UNKNOWN_SYMBOL = 'Unknown symbol name' - - -class IconButton(ButtonBehavior, Image): - pass - - -class Avatar(Image): - data = properties.DictProperty(allownone=True) - - def on_kv_post(self, __): - if not self.source: - self.source = G.host.get_default_avatar() - - def on_data(self, __, data): - if data is None: - self.source = G.host.get_default_avatar() - else: - self.source = data['path'] - - -class NotifLabel(Label): - pass - -@total_ordering -class ContactItem(BoxLayout): - """An item from ContactList - - The item will drawn as an icon (JID avatar) with its jid below. - If "badge_text" is set, a label with the text will be drawn above the avatar. - """ - base_width = dp(150) - avatar_layout = properties.ObjectProperty() - avatar = properties.ObjectProperty() - badge = properties.ObjectProperty(allownone=True) - badge_text = properties.StringProperty('') - profile = properties.StringProperty() - data = properties.DictProperty() - jid = properties.StringProperty('') - - def on_kv_post(self, __): - if ((self.profile and self.jid and self.data is not None - and ('avatar' not in self.data or 'nicknames' not in self.data))): - G.host.bridge.identity_get( - self.jid, ['avatar', 'nicknames'], True, self.profile, - callback=self._identity_get_cb, - errback=partial( - G.host.errback, - message=_("Can't retrieve identity for {jid}: {{msg}}").format( - jid=self.jid) - ) - ) - - def _identity_get_cb(self, identity_raw): - identity_data = data_format.deserialise(identity_raw) - self.data.update(identity_data) - - def on_badge_text(self, wid, text): - if text: - if self.badge is not None: - self.badge.text = text - else: - self.badge = NotifLabel( - pos_hint={"right": 0.8, "y": 0}, - text=text, - ) - self.avatar_layout.add_widget(self.badge) - else: - if self.badge is not None: - self.avatar_layout.remove_widget(self.badge) - self.badge = None - - def __lt__(self, other): - return self.jid < other.jid - - -class ContactButton(ButtonBehavior, ContactItem): - pass - - -class JidItem(BoxLayout): - bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1]) - color = properties.ListProperty([1, 1, 1, 1]) - jid = properties.StringProperty() - profile = properties.StringProperty() - nick = properties.StringProperty() - avatar = properties.ObjectProperty() - - def on_avatar(self, wid, jid_): - if self.jid and self.profile: - self.get_image() - - def on_jid(self, wid, jid_): - if self.profile and self.avatar: - self.get_image() - - def on_profile(self, wid, profile): - if self.jid and self.avatar: - self.get_image() - - def get_image(self): - host = G.host - if host.contact_lists[self.profile].is_room(self.jid.bare): - self.avatar.opacity = 0 - self.avatar.source = "" - else: - self.avatar.source = ( - host.get_avatar(self.jid, profile=self.profile) - or host.get_default_avatar(self.jid) - ) - - -class JidButton(ButtonBehavior, JidItem): - pass - - -class JidToggle(ToggleButtonBehavior, JidItem): - selected_color = properties.ListProperty(C.COLOR_SEC_DARK) - - -class Symbol(Label): - symbol_map = None - symbol = properties.StringProperty() - - def __init__(self, **kwargs): - if self.symbol_map is None: - with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f: - fontello_conf = json.load(f) - Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']} - - super(Symbol, self).__init__(**kwargs) - - def on_symbol(self, instance, symbol): - try: - code = self.symbol_map[symbol] - except KeyError: - log.warning(_("Invalid symbol {symbol}").format(symbol=symbol)) - else: - self.text = chr(code) - - -class SymbolButton(ButtonBehavior, Symbol): - pass - - -class SymbolLabel(BoxLayout): - symbol = properties.StringProperty("") - text = properties.StringProperty("") - color = properties.ListProperty(C.COLOR_SEC) - bold = properties.BooleanProperty(True) - symbol_wid = properties.ObjectProperty() - label = properties.ObjectProperty() - - -class SymbolButtonLabel(ButtonBehavior, SymbolLabel): - pass - - -class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel): - pass - - -class ActionSymbol(Symbol): - pass - - -class ActionIcon(BoxLayout): - plugin_info = properties.DictProperty() - - def on_plugin_info(self, instance, plugin_info): - self.clear_widgets() - try: - symbol = plugin_info['icon_symbol'] - except KeyError: - icon_src = plugin_info['icon_medium'] - icon_wid = Image(source=icon_src, allow_stretch=True) - self.add_widget(icon_wid) - else: - icon_wid = ActionSymbol(symbol=symbol) - self.add_widget(icon_wid) - - -class SizedImage(AsyncImage): - """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT""" - # following properties are desired height/width - # i.e. the ones specified in height/width attributes of <img> - # (or wanted for whatever reason) - # set to None to ignore them - target_height = properties.NumericProperty(allownone=True) - target_width = properties.NumericProperty(allownone=True) - - def __init__(self, **kwargs): - # best calculated size - self._best_width = self._best_height = 100 - super().__init__(**kwargs) - - def on_texture(self, instance, texture): - """Adapt the size according to max size and target_*""" - if texture is None: - return - max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT) - width, height = texture.size - if self.target_width: - width = min(width, self.target_width) - if width > max_width: - width = C.IMG_MAX_WIDTH - - height = width / self.image_ratio - - if self.target_height: - height = min(height, self.target_height) - - if height > max_height: - height = max_height - width = height * self.image_ratio - - self.width, self.height = self._best_width, self._best_height = width, height - - def on_parent(self, instance, parent): - if parent is not None: - parent.bind(width=self.on_parent_width) - - def on_parent_width(self, instance, width): - if self._best_width > width: - self.width = width - self.height = width / self.image_ratio - else: - self.width, self.height = self._best_width, self._best_height - - -class JidSelectorCategoryLayout(StackLayout): - pass - - -class JidSelector(ScrollView, EventDispatcher): - layout = properties.ObjectProperty(None) - # if item_class is changed, the properties must be the same as for ContactButton - # and ordering must be supported - item_class = properties.ObjectProperty(ContactButton) - add_separators = properties.ObjectProperty(True) - # list of item to show, can be: - # - a well-known string which can be: - # * "roster": all roster jids - # * "opened_chats": all opened chat widgets - # * "bookmarks": MUC bookmarks - # A layout will be created each time and stored in the attribute of the same - # name. - # If add_separators is True, a CategorySeparator will be added on top of each - # layout. - # - a kivy Widget, which will be added to the layout (notable useful with - # common_widgets.CategorySeparator) - # - a callable, which must return an iterable of kwargs for ContactButton - to_show = properties.ListProperty(['roster']) - - # TODO: roster and bookmarks must be updated in real time, like for opened_chats - - - def __init__(self, **kwargs): - self.register_event_type('on_select') - # list of layouts containing items - self.items_layouts = [] - # jid to list of ContactButton instances map - self.items_map = {} - super().__init__(**kwargs) - - def on_kv_post(self, wid): - self.update() - - def on_select(self, wid): - pass - - def on_parent(self, wid, parent): - if parent is None: - log.debug("removing listeners") - G.host.removeListener("contactsFilled", self.on_contacts_filled) - G.host.removeListener("notification", self.on_notification) - G.host.removeListener("notificationsClear", self.on_notifications_clear) - G.host.removeListener( - "widgetNew", self.on_widget_new, ignore_missing=True) - G.host.removeListener( - "widgetDeleted", self.on_widget_deleted, ignore_missing=True) - else: - log.debug("adding listeners") - G.host.addListener("contactsFilled", self.on_contacts_filled) - G.host.addListener("notification", self.on_notification) - G.host.addListener("notificationsClear", self.on_notifications_clear) - - def on_contacts_filled(self, profile): - log.debug("on_contacts_filled event received") - self.update() - - def on_notification(self, entity, notification_data, profile): - for item in self.items_map.get(entity.bare, []): - notifs = list(G.host.get_notifs(entity.bare, profile=profile)) - item.badge_text = str(len(notifs)) - - def on_notifications_clear(self, entity, type_, profile): - for item in self.items_map.get(entity.bare, []): - item.badge_text = '' - - def on_widget_new(self, wid): - if not isinstance(wid, quick_chat.QuickChat): - return - item = self.get_item_from_wid(wid) - if item is None: - return - idx = 0 - for child in self.opened_chats.children: - if isinstance(child, self.item_class) and child < item: - break - idx+=1 - self.opened_chats.add_widget(item, index=idx) - - def on_widget_deleted(self, wid): - if not isinstance(wid, quick_chat.QuickChat): - return - - for child in self.opened_chats.children: - if not isinstance(child, self.item_class): - continue - if child.jid.bare == wid.target.bare: - self.opened_chats.remove_widget(child) - break - - def _create_item(self, **kwargs): - item = self.item_class(**kwargs) - jid = kwargs['jid'] - self.items_map.setdefault(jid, []).append(item) - return item - - def update(self): - log.debug("starting update") - self.layout.clear_widgets() - for item in self.to_show: - if isinstance(item, str): - if item == 'roster': - self.add_roster_items() - elif item == 'bookmarks': - self.add_bookmarks_items() - elif item == 'opened_chats': - self.add_opened_chats_items() - else: - log.error(f'unknown "to_show" magic string {item!r}') - elif isinstance(item, Widget): - self.layout.add_widget(item) - elif callable(item): - items_kwargs = item() - for item_kwargs in items_kwargs: - item = self._create_item(**items_kwargs) - item.bind(on_press=partial(self.dispatch, 'on_select')) - self.layout.add_widget(item) - else: - log.error(f"unmanaged to_show item type: {item!r}") - - def add_category_layout(self, label=None): - category_layout = JidSelectorCategoryLayout() - - if label and self.add_separators: - category_layout.add_widget(CategorySeparator(text=label)) - - self.layout.add_widget(category_layout) - self.items_layouts.append(category_layout) - return category_layout - - def get_item_from_wid(self, wid): - """create JidSelector item from QuickChat widget""" - contact_list = G.host.contact_lists[wid.profile] - try: - data=contact_list.get_item(wid.target) - except KeyError: - log.warning(f"Can't find item data for {wid.target}") - data={} - try: - item = self._create_item( - jid=wid.target, - data=data, - profile=wid.profile, - ) - except Exception as e: - log.warning(f"Can't add contact {wid.target}: {e}") - return - notifs = list(G.host.get_notifs(wid.target, profile=wid.profile)) - if notifs: - item.badge_text = str(len(notifs)) - item.bind(on_press=partial(self.dispatch, 'on_select')) - return item - - def add_opened_chats_items(self): - G.host.addListener("widgetNew", self.on_widget_new) - G.host.addListener("widgetDeleted", self.on_widget_deleted) - self.opened_chats = category_layout = self.add_category_layout(_("Opened chats")) - widgets = sorted(G.host.widgets.get_widgets( - quick_chat.QuickChat, - profiles = G.host.profiles, - with_duplicates=False)) - - for wid in widgets: - item = self.get_item_from_wid(wid) - if item is None: - continue - category_layout.add_widget(item) - - def add_roster_items(self): - self.roster = category_layout = self.add_category_layout(_("Your contacts")) - for profile in G.host.profiles: - contact_list = G.host.contact_lists[profile] - for entity_jid in sorted(contact_list.roster): - item = self._create_item( - jid=entity_jid, - data=contact_list.get_item(entity_jid), - profile=profile, - ) - item.bind(on_press=partial(self.dispatch, 'on_select')) - category_layout.add_widget(item) - - def add_bookmarks_items(self): - self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms")) - for profile in G.host.profiles: - profile_manager = G.host.profiles[profile] - try: - bookmarks = profile_manager._bookmarks - except AttributeError: - log.warning(f"no bookmark in cache for profile {profile}") - continue - - contact_list = G.host.contact_lists[profile] - for entity_jid in bookmarks: - try: - cache = contact_list.get_item(entity_jid) - except KeyError: - cache = {} - item = self._create_item( - jid=entity_jid, - data=cache, - profile=profile, - ) - item.bind(on_press=partial(self.dispatch, 'on_select')) - category_layout.add_widget(item)
--- a/cagou/core/common_widgets.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""common advanced widgets, which can be reused everywhere.""" - -from kivy.clock import Clock -from kivy import properties -from kivy.metrics import dp -from kivy.uix.scatterlayout import ScatterLayout -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.label import Label -from sat.core.i18n import _ -from sat.core import log as logging -from cagou import G -from .behaviors import TouchMenuItemBehavior - -log = logging.getLogger(__name__) - - -class DelayedBoxLayout(BoxLayout): - """A BoxLayout with delayed layout, to avoid slowing down during resize""" - # XXX: thanks to Alexander Taylor for his blog post at - # https://blog.kivy.org/2019/07/a-delayed-resize-layout-in-kivy/ - - do_layout_event = properties.ObjectProperty(None, allownone=True) - layout_delay_s = properties.NumericProperty(0.2) - #: set this to X to force next X layouts to be done without delay - dont_delay_next_layouts = properties.NumericProperty(0) - - def do_layout(self, *args, **kwargs): - if self.do_layout_event is not None: - self.do_layout_event.cancel() - if self.dont_delay_next_layouts>0: - self.dont_delay_next_layouts-=1 - super().do_layout() - else: - real_do_layout = super().do_layout - self.do_layout_event = Clock.schedule_once( - lambda dt: real_do_layout(*args, **kwargs), - self.layout_delay_s) - - -class Identities(object): - - def __init__(self, entity_ids): - identities = {} - for cat, type_, name in entity_ids: - identities.setdefault(cat, {}).setdefault(type_, []).append(name) - client = identities.get('client', {}) - if 'pc' in client: - self.type = 'desktop' - elif 'phone' in client: - self.type = 'phone' - elif 'web' in client: - self.type = 'web' - elif 'console' in client: - self.type = 'console' - else: - self.type = 'desktop' - - self.identities = identities - - @property - def name(self): - first_identity = next(iter(self.identities.values())) - names = next(iter(first_identity.values())) - return names[0] - - -class ItemWidget(TouchMenuItemBehavior, BoxLayout): - name = properties.StringProperty() - base_width = properties.NumericProperty(dp(100)) - - -class DeviceWidget(ItemWidget): - - def __init__(self, main_wid, entity_jid, identities, **kw): - self.entity_jid = entity_jid - self.identities = identities - own_jid = next(iter(G.host.profiles.values())).whoami - self.own_device = entity_jid.bare == own_jid.bare - if self.own_device: - name = self.identities.name - elif self.entity_jid.node: - name = self.entity_jid.node - elif self.entity_jid == own_jid.domain: - name = _("your server") - else: - name = entity_jid - - super(DeviceWidget, self).__init__(name=name, main_wid=main_wid, **kw) - - @property - def profile(self): - return self.main_wid.profile - - def get_symbol(self): - if self.identities.type == 'desktop': - return 'desktop' - elif self.identities.type == 'phone': - return 'mobile' - elif self.identities.type == 'web': - return 'globe' - elif self.identities.type == 'console': - return 'terminal' - else: - return 'desktop' - - def do_item_action(self, touch): - pass - - -class CategorySeparator(Label): - pass - - -class ImageViewer(ScatterLayout): - source = properties.StringProperty() - - def on_touch_down(self, touch): - if touch.is_double_tap: - self.reset() - return True - return super().on_touch_down(touch) - - def reset(self): - self.rotation = 0 - self.scale = 1 - self.x = 0 - self.y = 0 - - -class ImagesGallery(BoxLayout): - """Show list of images in a Carousel, with some controls to downloads""" - sources = properties.ListProperty() - carousel = properties.ObjectProperty() - previous_slide = None - - def on_kv_post(self, __): - self.on_sources(None, self.sources) - self.previous_slide = self.carousel.current_slide - self.carousel.bind(current_slide=self.on_slide_change) - - def on_parent(self, __, parent): - # we hide the head widget to have full screen - G.host.app.show_head_widget(not bool(parent), animation=False) - - def on_sources(self, __, sources): - if not sources or not self.carousel: - return - self.carousel.clear_widgets() - for source in sources: - img = ImageViewer( - source=source, - ) - self.carousel.add_widget(img) - - def on_slide_change(self, __, slide): - if isinstance(self.previous_slide, ImageViewer): - self.previous_slide.reset() - - self.previous_slide = slide - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - G.host.close_ui() - return True
--- a/cagou/core/config.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -#!/usr//bin/env python2 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""This module keep an open instance of sat configuration""" - -from sat.tools import config -sat_conf = config.parse_main_conf() - - -def config_get(section, name, default): - return config.config_get(sat_conf, section, name, default)
--- a/cagou/core/constants.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: a SàT frontend -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from sat_frontends.quick_frontend import constants -import cagou - -# Kivy must not be imported here due to log hijacking see core/kivy_hack.py - - -class Const(constants.Const): - APP_NAME = "Libervia Desktop" - APP_COMPONENT = "desktop/mobile" - APP_NAME_ALT = "Cagou" - APP_NAME_FILE = "libervia_desktop" - APP_VERSION = cagou.__version__ - LOG_OPT_SECTION = APP_NAME.lower() - CONFIG_SECTION = "desktop" - WID_SELECTOR = 'selector' - ICON_SIZES = ('small', 'medium') # small = 32, medium = 44 - DEFAULT_WIDGET_ICON = '{media}/misc/black.png' - - BTN_HEIGHT = '35dp' - - PLUG_TYPE_WID = 'wid' - PLUG_TYPE_TRANSFER = 'transfer' - - TRANSFER_UPLOAD = "upload" - TRANSFER_SEND = "send" - - COLOR_PRIM = (0.98, 0.98, 0.98, 1) - COLOR_PRIM_LIGHT = (1, 1, 1, 1) - COLOR_PRIM_DARK = (0.78, 0.78, 0.78, 1) - COLOR_SEC = (0.27, 0.54, 1.0, 1) - COLOR_SEC_LIGHT = (0.51, 0.73, 1.0, 1) - COLOR_SEC_DARK = (0.0, 0.37, 0.8, 1) - - COLOR_INFO = COLOR_PRIM_LIGHT - COLOR_WARNING = (1.0, 1.0, 0.0, 1) - COLOR_ERROR = (1.0, 0.0, 0.0, 1) - - COLOR_BTN_LIGHT = (0.4, 0.4, 0.4, 1) - - # values are in dp - IMG_MAX_WIDTH = 400 - IMG_MAX_HEIGHT = 400 - - # files - FILE_DEST_DOWNLOAD = "DOWNLOAD" - FILE_DEST_CACHE = "CACHE"
--- a/cagou/core/dialog.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,44 +0,0 @@ -#!/usr//bin/env python2 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""generic dialogs""" - -from sat.core.i18n import _ -from cagou.core.constants import Const as C -from kivy.uix.boxlayout import BoxLayout -from kivy import properties -from sat.core import log as logging - -log = logging.getLogger(__name__) - - -class MessageDialog(BoxLayout): - title = properties.StringProperty() - message = properties.StringProperty() - level = properties.OptionProperty(C.XMLUI_DATA_LVL_INFO, options=C.XMLUI_DATA_LVLS) - close_cb = properties.ObjectProperty() - - -class ConfirmDialog(BoxLayout): - title = properties.StringProperty() - message = properties.StringProperty(_("Are you sure?")) - # callback for no/cancel - no_cb = properties.ObjectProperty() - # callback for yes/ok - yes_cb = properties.ObjectProperty()
--- a/cagou/core/image.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -import mimetypes -from functools import partial -from kivy.uix import image as kivy_img -from sat.core import log as logging -from sat.tools.common import data_format -from cagou import G - -log = logging.getLogger(__name__) - - -class Image(kivy_img.Image): - """Image widget which accept source without extension""" - SVG_CONVERT_EXTRA = {'width': 128, 'height': 128} - - def __init__(self, **kwargs): - self.register_event_type('on_error') - super().__init__(**kwargs) - - def _image_convert_cb(self, path): - self.source = path - - def texture_update(self, *largs): - if self.source: - if mimetypes.guess_type(self.source, strict=False)[0] == 'image/svg+xml': - log.debug(f"Converting SVG image at {self.source} to PNG") - G.host.bridge.image_convert( - self.source, - "", - data_format.serialise(self.SVG_CONVERT_EXTRA), - "", - callback=self._image_convert_cb, - errback=partial( - G.host.errback, - message=f"Can't load image at {self.source}: {{msg}}" - ) - ) - return - - super().texture_update(*largs) - if self.source and self.texture is None: - log.warning( - f"Image {self.source} has not been imported correctly, replacing by " - f"empty one") - # FIXME: temporary image, to be replaced by something showing that something - # went wrong - self.source = G.host.app.expand( - "{media}/misc/borders/border_hollow_black.png") - self.dispatch('on_error', Exception(f"Can't load source {self.source}")) - - def on_error(self, err): - pass - - -class AsyncImage(kivy_img.AsyncImage): - """AsyncImage which accept file:// schema""" - - def _load_source(self, *args): - if self.source.startswith('file://'): - self.source = self.source[7:] - else: - super(AsyncImage, self)._load_source(*args)
--- a/cagou/core/kivy_hack.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -#!/usr//bin/env python2 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -CONF_KIVY_LEVEL = 'log_kivy_level' - - -def do_hack(): - """work around Kivy hijacking of logs and arguments""" - # we remove args so kivy doesn't use them - # this is need to avoid kivy breaking QuickApp args handling - import sys - ori_argv = sys.argv[:] - sys.argv = sys.argv[:1] - from .constants import Const as C - from sat.core import log_config - log_config.sat_configure(C.LOG_BACKEND_STANDARD, C) - - from . import config - kivy_level = config.config_get(C.CONFIG_SECTION, CONF_KIVY_LEVEL, 'follow').upper() - - # kivy handles its own loggers, we don't want that! - import logging - root_logger = logging.root - kivy_logger = logging.getLogger('kivy') - ori_addHandler = kivy_logger.addHandler - kivy_logger.addHandler = lambda __: None - ori_setLevel = kivy_logger.setLevel - if kivy_level == 'FOLLOW': - # level is following SàT level - kivy_logger.setLevel = lambda level: None - elif kivy_level == 'KIVY': - # level will be set by Kivy according to its own conf - pass - elif kivy_level in C.LOG_LEVELS: - kivy_logger.setLevel(kivy_level) - kivy_logger.setLevel = lambda level: None - else: - raise ValueError("Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level)) - - # during import kivy set its logging stuff - import kivy - kivy # to avoid pyflakes warning - - # we want to separate kivy logs from other logs - logging.root = root_logger - from kivy import logger - sys.stderr = logger.previous_stderr - - # we restore original methods - kivy_logger.addHandler = ori_addHandler - kivy_logger.setLevel = ori_setLevel - - # we restore original arguments - sys.argv = ori_argv
--- a/cagou/core/menu.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,348 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core.i18n import _ -from sat.core import log as logging -from cagou.core.constants import Const as C -from cagou.core.common import JidToggle -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.label import Label -from kivy.uix.button import Button -from kivy.uix.popup import Popup -from .behaviors import FilterBehavior -from kivy import properties -from kivy.core.window import Window -from kivy.animation import Animation -from kivy.metrics import dp -from cagou import G -from functools import partial -import webbrowser - -log = logging.getLogger(__name__) - -ABOUT_TITLE = _("About {}").format(C.APP_NAME) -ABOUT_CONTENT = _("""[b]{app_name} ({app_name_alt})[/b] - -[u]{app_name} version[/u]: -{version} - -[u]backend version[/u]: -{backend_version} - -{app_name} is a libre communication tool based on libre standard XMPP. - -{app_name} is part of the "Libervia" project ({app_component} frontend) -more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color] -""") - - -class AboutContent(Label): - - def on_ref_press(self, value): - if value == "website": - webbrowser.open("https://salut-a-toi.org") - - -class AboutPopup(Popup): - - def on_touch_down(self, touch): - if self.collide_point(*touch.pos): - self.dismiss() - return super(AboutPopup, self).on_touch_down(touch) - - -class TransferItem(BoxLayout): - plug_info = properties.DictProperty() - - def on_touch_up(self, touch): - if not self.collide_point(*touch.pos): - return super(TransferItem, self).on_touch_up(touch) - else: - transfer_menu = self.parent - while not isinstance(transfer_menu, TransferMenu): - transfer_menu = transfer_menu.parent - transfer_menu.do_callback(self.plug_info) - return True - - -class SideMenu(BoxLayout): - size_hint_close = (0, 1) - size_hint_open = (0.4, 1) - size_close = (100, 100) - size_open = (0, 0) - bg_color = properties.ListProperty([0, 0, 0, 1]) - # callback will be called with arguments relevant to menu - callback = properties.ObjectProperty() - # call do_callback even when menu is cancelled - callback_on_close = properties.BooleanProperty(False) - # cancel callback need to remove the widget for UI - # will be called with the widget to remove as argument - cancel_cb = properties.ObjectProperty() - - def __init__(self, **kwargs): - super(SideMenu, self).__init__(**kwargs) - if self.cancel_cb is None: - self.cancel_cb = self.on_menu_cancelled - - def _set_anim_kw(self, kw, size_hint, size): - """Set animation keywords - - for each value of size_hint it is used if not None, - else size is used. - If one value of size is bigger than the respective one of Window - the one of Window is used - """ - size_hint_x, size_hint_y = size_hint - width, height = size - if size_hint_x is not None: - kw['size_hint_x'] = size_hint_x - elif width is not None: - kw['width'] = min(width, Window.width) - - if size_hint_y is not None: - kw['size_hint_y'] = size_hint_y - elif height is not None: - kw['height'] = min(height, Window.height) - - def show(self, caller_wid=None): - Window.bind(on_keyboard=self.key_input) - G.host.app.root.add_widget(self) - kw = {'d': 0.3, 't': 'out_back'} - self._set_anim_kw(kw, self.size_hint_open, self.size_open) - Animation(**kw).start(self) - - def _remove_from_parent(self, anim, menu): - # self.parent can already be None if the widget has been removed by a callback - # before the animation started. - if self.parent is not None: - self.parent.remove_widget(self) - - def hide(self): - Window.unbind(on_keyboard=self.key_input) - kw = {'d': 0.2} - self._set_anim_kw(kw, self.size_hint_close, self.size_close) - anim = Animation(**kw) - anim.bind(on_complete=self._remove_from_parent) - anim.start(self) - if self.callback_on_close: - self.do_callback() - - def on_touch_down(self, touch): - # we remove the menu if we click outside - # else we want to handle the event, but not - # transmit it to parents - if not self.collide_point(*touch.pos): - self.hide() - else: - return super(SideMenu, self).on_touch_down(touch) - return True - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - self.hide() - return True - - def on_menu_cancelled(self, wid, cleaning_cb=None): - self._close_ui(wid) - if cleaning_cb is not None: - cleaning_cb() - - def _close_ui(self, wid): - G.host.close_ui() - - def do_callback(self, *args, **kwargs): - log.warning("callback not implemented") - - -class ExtraMenuItem(Button): - pass - - -class ExtraSideMenu(SideMenu): - """Menu with general app actions like showing the about widget""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - G.local_platform.on_extra_menu_init(self) - - def add_item(self, label, callback): - self.add_widget( - ExtraMenuItem( - text=label, - on_press=partial(self.on_item_press, callback=callback), - ), - # we want the new item above "About" and last empty Widget - index=2) - - def on_item_press(self, *args, callback): - self.hide() - callback() - - def on_about(self): - self.hide() - about = AboutPopup() - about.title = ABOUT_TITLE - about.content = AboutContent( - text=ABOUT_CONTENT.format( - app_name = C.APP_NAME, - app_name_alt = C.APP_NAME_ALT, - app_component = C.APP_COMPONENT, - backend_version = G.host.backend_version, - version=G.host.version - ), - markup=True) - about.open() - - -class TransferMenu(SideMenu): - """transfer menu which handle display and callbacks""" - # callback will be called with path to file to transfer - # profiles if set will be sent to transfer widget, may be used to get specific files - profiles = properties.ObjectProperty() - transfer_txt = properties.StringProperty() - transfer_info = properties.ObjectProperty() - upload_btn = properties.ObjectProperty() - encrypted = properties.BooleanProperty(False) - items_layout = properties.ObjectProperty() - size_hint_close = (1, 0) - size_hint_open = (1, 0.5) - - def __init__(self, **kwargs): - super(TransferMenu, self).__init__(**kwargs) - if self.profiles is None: - self.profiles = iter(G.host.profiles) - for plug_info in G.host.get_plugged_widgets(type_=C.PLUG_TYPE_TRANSFER): - item = TransferItem( - plug_info = plug_info - ) - self.items_layout.add_widget(item) - - def on_kv_post(self, __): - self.update_transfer_info() - - def get_transfer_info(self): - if self.upload_btn.state == "down": - # upload - if self.encrypted: - return _( - "The file will be [color=00aa00][b]encrypted[/b][/color] and sent to " - "your server\nServer admin(s) can delete the file, but they won't be " - "able to see its content" - ) - else: - return _( - "Beware! The file will be sent to your server and stay " - "[color=ff0000][b]unencrypted[/b][/color] there\nServer admin(s) " - "can see the file, and they choose how, when and if it will be " - "deleted" - ) - else: - # P2P - if self.encrypted: - return _( - "The file will be sent [color=ff0000][b]unencrypted[/b][/color] " - "directly to your contact (it may be transiting by the " - "server if direct connection is not possible).\n[color=ff0000]" - "Please note that end-to-end encryption is not yet implemented for " - "P2P transfer." - ) - else: - return _( - "The file will be sent [color=ff0000][b]unencrypted[/b][/color] " - "directly to your contact (it [i]may be[/i] transiting by the " - "server if direct connection is not possible)." - ) - - def update_transfer_info(self): - self.transfer_info.text = self.get_transfer_info() - - def _on_transfer_cb(self, file_path, cleaning_cb=None, external=False, wid_cont=None): - if not external: - wid = wid_cont[0] - self._close_ui(wid) - self.callback( - file_path, - transfer_type = (C.TRANSFER_UPLOAD - if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND), - cleaning_cb=cleaning_cb, - ) - - def _check_plugin_permissions_cb(self, plug_info): - external = plug_info.get('external', False) - wid_cont = [] - wid_cont.append(plug_info['factory']( - plug_info, - partial(self._on_transfer_cb, external=external, wid_cont=wid_cont), - self.cancel_cb, - self.profiles)) - if not external: - G.host.show_extra_ui(wid_cont[0]) - - def do_callback(self, plug_info): - self.parent.remove_widget(self) - if self.callback is None: - log.warning("TransferMenu callback is not set") - else: - G.local_platform.check_plugin_permissions( - plug_info, - callback=partial(self._check_plugin_permissions_cb, plug_info), - errback=lambda: G.host.add_note( - _("permission refused"), - _("this transfer menu can't be used if you refuse the requested " - "permission"), - C.XMLUI_DATA_LVL_WARNING) - ) - - -class EntitiesSelectorMenu(SideMenu, FilterBehavior): - """allow to select entities from roster""" - profiles = properties.ObjectProperty() - layout = properties.ObjectProperty() - instructions = properties.StringProperty(_("Please select entities")) - filter_input = properties.ObjectProperty() - size_hint_close = (None, 1) - size_hint_open = (None, 1) - size_open = (dp(250), 100) - size_close = (0, 100) - - def __init__(self, **kwargs): - super(EntitiesSelectorMenu, self).__init__(**kwargs) - self.filter_input.bind(text=self.do_filter_input) - if self.profiles is None: - self.profiles = iter(G.host.profiles) - for profile in self.profiles: - for jid_, jid_data in G.host.contact_lists[profile].all_iter: - jid_wid = JidToggle( - jid=jid_, - profile=profile) - self.layout.add_widget(jid_wid) - - def do_callback(self): - if self.callback is not None: - jids = [c.jid for c in self.layout.children if c.state == 'down'] - self.callback(jids) - - def do_filter_input(self, filter_input, text): - self.layout.spacing = 0 if text else dp(5) - self.do_filter(self.layout, - text, - lambda c: c.jid, - width_cb=lambda c: c.width, - height_cb=lambda c: dp(70))
--- a/cagou/core/patches.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import urllib.request, urllib.error, urllib.parse -import ssl - - -def disable_tls_validation(): - # allow to disable certificate validation - ctx_no_verify = ssl.create_default_context() - ctx_no_verify.check_hostname = False - ctx_no_verify.verify_mode = ssl.CERT_NONE - - class HTTPSHandler(urllib.request.HTTPSHandler): - no_certificate_check = False - - def __init__(self, *args, **kwargs): - urllib.request._HTTPSHandler_ori.__init__(self, *args, **kwargs) - if self.no_certificate_check: - self._context = ctx_no_verify - - urllib.request._HTTPSHandler_ori = urllib.request.HTTPSHandler - urllib.request.HTTPSHandler = HTTPSHandler - urllib.request.HTTPSHandler.no_certificate_check = True
--- a/cagou/core/platform_/__init__.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from kivy import utils as kivy_utils - - -def create(): - """Factory method to create the platform instance adapted to running one""" - if kivy_utils.platform == "android": - from .android import Platform - return Platform() - else: - from .base import Platform - return Platform()
--- a/cagou/core/platform_/android.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,487 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import sys -import os -import socket -import json -from functools import partial -from urllib.parse import urlparse -from pathlib import Path -import shutil -import mimetypes -from jnius import autoclass, cast, JavaException -from android import activity -from android.permissions import request_permissions, Permission -from kivy.clock import Clock -from kivy.uix.label import Label -from sat.core.i18n import _ -from sat.core import log as logging -from sat.tools.common import data_format -from sat_frontends.tools import jid -from cagou.core.constants import Const as C -from cagou.core import dialog -from cagou import G -from .base import Platform as BasePlatform - - -log = logging.getLogger(__name__) - -# permission that are necessary to have Cagou running properly -PERMISSION_MANDATORY = [ - Permission.READ_EXTERNAL_STORAGE, - Permission.WRITE_EXTERNAL_STORAGE, -] - -service = autoclass('org.libervia.cagou.ServiceBackend') -PythonActivity = autoclass('org.kivy.android.PythonActivity') -mActivity = PythonActivity.mActivity -Intent = autoclass('android.content.Intent') -AndroidString = autoclass('java.lang.String') -Uri = autoclass('android.net.Uri') -ImagesMedia = autoclass('android.provider.MediaStore$Images$Media') -AudioMedia = autoclass('android.provider.MediaStore$Audio$Media') -VideoMedia = autoclass('android.provider.MediaStore$Video$Media') -URLConnection = autoclass('java.net.URLConnection') - -DISPLAY_NAME = '_display_name' -DATA = '_data' - - -STATE_RUNNING = b"running" -STATE_PAUSED = b"paused" -STATE_STOPPED = b"stopped" -SOCKET_DIR = "/data/data/org.libervia.cagou/" -SOCKET_FILE = ".socket" -INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction") - - -class Platform(BasePlatform): - send_button_visible = True - - def __init__(self): - super().__init__() - # cache for callbacks to run when profile is plugged - self.cache = [] - - def init_platform(self): - # sys.platform is "linux" on android by default - # so we change it to allow backend to detect android - sys.platform = "android" - C.PLUGIN_EXT = 'pyc' - - def on_host_init(self, host): - argument = '' - service.start(mActivity, argument) - - activity.bind(on_new_intent=self.on_new_intent) - self.cache.append((self.on_new_intent, mActivity.getIntent())) - self.last_selected_wid = None - self.restore_selected_wid = True - host.addListener('profile_plugged', self.on_profile_plugged) - host.addListener('selected', self.on_selected_widget) - local_dir = Path(host.config_get('', 'local_dir')).resolve() - self.tmp_dir = local_dir / 'tmp' - # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line - # above - assert self.tmp_dir.resolve() != local_dir - # we reset tmp dir on each run, to be sure that there is no residual file - if self.tmp_dir.exists(): - shutil.rmtree(self.tmp_dir) - self.tmp_dir.mkdir(0o700, parents=True) - - def on_init_frontend_state(self): - # XXX: we use a separated socket instead of bridge because if we - # try to call a bridge method in on_pause method, the call data - # is not written before the actual pause - s = self._frontend_status_socket = socket.socket( - socket.AF_UNIX, socket.SOCK_STREAM) - s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE)) - s.sendall(STATE_RUNNING) - - def profile_autoconnect_get_cb(self, profile=None): - if profile is not None: - G.host.options.profile = profile - G.host.post_init() - - def profile_autoconnect_get_eb(self, failure_): - log.error(f"Error while getting profile to autoconnect: {failure_}") - G.host.post_init() - - def _show_perm_warning(self, permissions): - root_wid = G.host.app.root - perm_warning = Label( - size_hint=(1, 1), - text_size=(root_wid.width, root_wid.height), - font_size='22sp', - bold=True, - color=(0.67, 0, 0, 1), - halign='center', - valign='center', - text=_( - "Requested permissions are mandatory to run Cagou, if you don't " - "accept them, Cagou can't run properly. Please accept following " - "permissions, or set them in Android settings for Cagou:\n" - "{permissions}\n\nCagou will be closed in 20 s").format( - permissions='\n'.join(p.split('.')[-1] for p in permissions))) - root_wid.clear_widgets() - root_wid.add_widget(perm_warning) - Clock.schedule_once(lambda *args: G.host.app.stop(), 20) - - def permission_cb(self, permissions, grant_results): - if not all(grant_results): - # we keep asking until they are accepted, as we can't run properly - # without them - # TODO: a message explaining why permission is needed should be printed - # TODO: the storage permission is mainly used to set download_dir, we should - # be able to run Cagou without it. - if not hasattr(self, 'perms_counter'): - self.perms_counter = 0 - self.perms_counter += 1 - if self.perms_counter > 5: - Clock.schedule_once( - lambda *args: self._show_perm_warning(permissions), - 0) - return - - perm_dict = dict(zip(permissions, grant_results)) - log.warning( - f"not all mandatory permissions are granted, requesting again: " - f"{perm_dict}") - request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) - return - - Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get( - callback=self.profile_autoconnect_get_cb, - errback=self.profile_autoconnect_get_eb), - 0) - - def do_post_init(self): - request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) - return False - - def private_data_get_cb(self, data_s, profile): - data = data_format.deserialise(data_s, type_check=None) - if data is not None and self.restore_selected_wid: - log.debug(f"restoring previous widget {data}") - try: - name = data['name'] - target = data['target'] - except KeyError as e: - log.error(f"Bad data format for selected widget: {e}\ndata={data}") - return - if target: - target = jid.JID(data['target']) - plugin_info = G.host.get_plugin_info(name=name) - if plugin_info is None: - log.warning("Can't restore unknown plugin: {name}") - return - factory = plugin_info['factory'] - G.host.switch_widget( - None, - factory(plugin_info, target=target, profiles=[profile]) - ) - - def on_profile_plugged(self, profile): - log.debug("ANDROID profile_plugged") - G.host.bridge.param_set( - "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile, - callback=lambda: log.info(f"profile {profile} autoconnection set"), - errback=lambda: log.error(f"can't set {profile} autoconnection")) - for method, *args in self.cache: - method(*args) - del self.cache - G.host.removeListener("profile_plugged", self.on_profile_plugged) - # we restore the stored widget if any - # user will then go back to where they was when the frontend was closed - G.host.bridge.private_data_get( - "cagou", "selected_widget", profile, - callback=partial(self.private_data_get_cb, profile=profile), - errback=partial( - G.host.errback, - title=_("can't get selected widget"), - message=_("error while retrieving selected widget: {msg}")) - ) - - def on_selected_widget(self, wid): - """Store selected widget in backend, to restore it on next startup""" - if self.last_selected_wid == None: - self.last_selected_wid = wid - # we skip the first selected widget, as we'll restore stored one if possible - return - - self.last_selected_wid = wid - - try: - plugin_info = wid.plugin_info - except AttributeError: - log.warning(f"No plugin info found for {wid}, can't store selected widget") - return - - try: - profile = next(iter(wid.profiles)) - except (AttributeError, StopIteration): - profile = None - - if profile is None: - try: - profile = next(iter(G.host.profiles)) - except StopIteration: - log.debug("No profile plugged yet, can't store selected widget") - return - try: - target = wid.target - except AttributeError: - target = None - - data = { - "name": plugin_info["name"], - "target": target, - } - - G.host.bridge.private_data_set( - "cagou", "selected_widget", data_format.serialise(data), profile, - errback=partial( - G.host.errback, - title=_("can set selected widget"), - message=_("error while setting selected widget: {msg}")) - ) - - def on_pause(self): - G.host.sync = False - self._frontend_status_socket.sendall(STATE_PAUSED) - return True - - def on_resume(self): - self._frontend_status_socket.sendall(STATE_RUNNING) - G.host.sync = True - - def on_stop(self): - self._frontend_status_socket.sendall(STATE_STOPPED) - self._frontend_status_socket.close() - - def on_key_back_root(self): - PythonActivity.moveTaskToBack(True) - return True - - def on_key_back_share(self, share_widget): - share_widget.close() - PythonActivity.moveTaskToBack(True) - return True - - def _disconnect(self, profile): - G.host.bridge.param_set( - "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile, - callback=lambda: log.info(f"profile {profile} autoconnection unset"), - errback=lambda: log.error(f"can't unset {profile} autoconnection")) - G.host.profiles.unplug(profile) - G.host.bridge.disconnect(profile) - G.host.app.show_profile_manager() - G.host.close_ui() - - def _on_disconnect(self): - current_profile = next(iter(G.host.profiles)) - wid = dialog.ConfirmDialog( - title=_("Are you sure to disconnect?"), - message=_( - "If you disconnect the current user ({profile}), you won't receive " - "any notification until you connect it again, is this really what you " - "want?").format(profile=current_profile), - yes_cb=partial(self._disconnect, profile=current_profile), - no_cb=G.host.close_ui, - ) - G.host.show_extra_ui(wid) - - def on_extra_menu_init(self, extra_menu): - extra_menu.add_item(_('disconnect'), self._on_disconnect) - - def update_params_extra(self, extra): - # on Android, we handle autoconnection automatically, - # user must not modify those parameters - extra.update( - { - "ignore": [ - ["Connection", "autoconnect_backend"], - ["Connection", "autoconnect"], - ["Connection", "autodisconnect"], - ], - } - ) - - def get_col_data_from_uri(self, uri, col_name): - cursor = mActivity.getContentResolver().query(uri, None, None, None, None) - if cursor is None: - return None - try: - cursor.moveToFirst() - col_idx = cursor.getColumnIndex(col_name); - if col_idx == -1: - return None - return cursor.getString(col_idx) - finally: - cursor.close() - - def get_filename_from_uri(self, uri, media_type): - filename = self.get_col_data_from_uri(uri, DISPLAY_NAME) - if filename is None: - uri_p = Path(uri.toString()) - filename = uri_p.name or "unnamed" - if not uri_p.suffix and media_type: - suffix = mimetypes.guess_extension(media_type, strict=False) - if suffix: - filename = filename + suffix - return filename - - def get_path_from_uri(self, uri): - # FIXME: using DATA is not recommended (and DATA is deprecated) - # we should read directly the file with - # ContentResolver#openFileDescriptor(Uri, String) - path = self.get_col_data_from_uri(uri, DATA) - return uri.getPath() if path is None else path - - def on_new_intent(self, intent): - log.debug("on_new_intent") - action = intent.getAction(); - intent_type = intent.getType(); - if action == Intent.ACTION_MAIN: - action_str = intent.getStringExtra(INTENT_EXTRA_ACTION) - if action_str is not None: - action = json.loads(action_str) - log.debug(f"Extra action found: {action}") - action_type = action.get('type') - if action_type == "open": - try: - widget = action['widget'] - target = action['target'] - except KeyError as e: - log.warning(f"incomplete action {action}: {e}") - else: - # we don't want stored selected widget to be displayed after this - # one - log.debug("cancelling restoration of previous widget") - self.restore_selected_wid = False - # and now we open the widget linked to the intent - current_profile = next(iter(G.host.profiles)) - Clock.schedule_once( - lambda *args: G.host.do_action( - widget, jid.JID(target), [current_profile]), - 0) - else: - log.warning(f"unexpected action: {action}") - - text = None - uri = None - path = None - elif action == Intent.ACTION_SEND: - # we have receiving data to share, we parse the intent data - # and show the share widget - data = {} - text = intent.getStringExtra(Intent.EXTRA_TEXT) - if text is not None: - data['text'] = text - - item = intent.getParcelableExtra(Intent.EXTRA_STREAM) - if item is not None: - uri = cast('android.net.Uri', item) - if uri.getScheme() == 'content': - # Android content, we'll dump it to a temporary file - filename = self.get_filename_from_uri(uri, intent_type) - filepath = self.tmp_dir / filename - input_stream = mActivity.getContentResolver().openInputStream(uri) - buff = bytearray(4096) - with open(filepath, 'wb') as f: - while True: - ret = input_stream.read(buff, 0, 4096) - if ret != -1: - f.write(buff[:ret]) - else: - break - input_stream.close() - data['path'] = path = str(filepath) - else: - data['uri'] = uri.toString() - path = self.get_path_from_uri(uri) - if path is not None and path not in data: - data['path'] = path - else: - uri = None - path = None - - - Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0) - else: - text = None - uri = None - path = None - - msg = (f"NEW INTENT RECEIVED\n" - f"type: {intent_type}\n" - f"action: {action}\n" - f"text: {text}\n" - f"uri: {uri}\n" - f"path: {path}") - - log.debug(msg) - - def check_plugin_permissions(self, plug_info, callback, errback): - perms = plug_info.get("android_permissons") - if not perms: - callback() - return - perms = [f"android.permission.{p}" if '.' not in p else p for p in perms] - - def request_permissions_cb(permissions, granted): - if all(granted): - Clock.schedule_once(lambda *args: callback()) - else: - Clock.schedule_once(lambda *args: errback()) - - request_permissions(perms, callback=request_permissions_cb) - - def open_url(self, url, wid=None): - parsed_url = urlparse(url) - if parsed_url.scheme == "aesgcm": - return super().open_url(url, wid) - else: - media_type = mimetypes.guess_type(url, strict=False)[0] - if media_type is None: - log.debug( - f"media_type for {url!r} not found with python mimetypes, trying " - f"guessContentTypeFromName") - media_type = URLConnection.guessContentTypeFromName(url) - intent = Intent(Intent.ACTION_VIEW) - if media_type is not None: - log.debug(f"file {url!r} is of type {media_type}") - intent.setDataAndType(Uri.parse(url), media_type) - else: - log.debug(f"can't guess media type for {url!r}") - intent.setData(Uri.parse(url)) - if mActivity.getPackageManager() is not None: - activity = cast('android.app.Activity', mActivity) - try: - activity.startActivity(intent) - except JavaException as e: - if e.classname != "android.content.ActivityNotFoundException": - raise e - log.debug( - f"activity not found for url {url!r}, we'll try generic opener") - else: - return - - # if nothing else worked, we default to base open_url - super().open_url(url, wid)
--- a/cagou/core/platform_/base.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import sys -import webbrowser -import subprocess -import shutil -from urllib import parse -from kivy.config import Config as KivyConfig -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.core import exceptions -from sat_frontends.quick_frontend.quick_widgets import QuickWidget -from cagou import G - - -log = getLogger(__name__) - - -class Platform: - """Base class to handle platform specific behaviours""" - # set to True to always show the send button in chat - send_button_visible = False - - def init_platform(self): - # we don't want multi-touch emulation with mouse - - # this option doesn't make sense on Android and cause troubles, so we only - # activate it for other platforms (cf. https://github.com/kivy/kivy/issues/6229) - KivyConfig.set('input', 'mouse', 'mouse,disable_multitouch') - - def on_app_build(self, Wid): - pass - - def on_host_init(self, host): - pass - - def on_init_frontend_state(self): - pass - - def do_post_init(self): - return True - - def on_pause(self): - pass - - def on_resume(self): - pass - - def on_stop(self): - pass - - def on_key_back_root(self): - """Back key is called while being on root widget""" - return True - - def on_key_back_share(self, share_widget): - """Back key is called while being on share widget""" - share_widget.close() - return True - - def _on_new_window(self): - """Launch a new instance of Cagou to have an extra window""" - subprocess.Popen(sys.argv) - - def on_extra_menu_init(self, extra_menu): - extra_menu.add_item(_('new window'), self._on_new_window) - - def update_params_extra(self, extra): - pass - - def check_plugin_permissions(self, plug_info, callback, errback): - """Check that plugin permissions for this platform are granted""" - callback() - - def _open(self, path): - """Open url or path with appropriate application if possible""" - try: - opener = self._opener - except AttributeError: - xdg_open_path = shutil.which("xdg-open") - if xdg_open_path is not None: - log.debug("xdg-open found, it will be used to open files") - opener = lambda path: subprocess.Popen([xdg_open_path, path]) - else: - log.debug("files will be opened with webbrower.open") - opener = webbrowser.open - self._opener = opener - - opener(path) - - - def open_url(self, url, wid=None): - """Open an URL in the way appropriate for the platform - - @param url(str): URL to open - @param wid(CagouWidget, None): widget requesting the opening - it may influence the way the URL is opened - """ - parsed_url = parse.urlparse(url) - if parsed_url.scheme == "aesgcm" and wid is not None: - # aesgcm files need to be decrypted first - # so we download them before opening - quick_widget = G.host.get_ancestor_widget(wid, QuickWidget) - if quick_widget is None: - msg = f"Can't find ancestor QuickWidget of {wid}" - log.error(msg) - G.host.errback(exceptions.InternalError(msg)) - return - G.host.download_url( - parsed_url, self.open_url, G.host.errback, profile=quick_widget.profile - ) - else: - self._open(url)
--- a/cagou/core/profile_manager.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from .constants import Const as C -from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.togglebutton import ToggleButton -from kivy.uix.screenmanager import ScreenManager, Screen -from kivy.metrics import sp -from kivy import properties -from cagou import G - - -class ProfileItem(ToggleButton): - ps = properties.ObjectProperty() - index = properties.NumericProperty(0) - - -class NewProfileScreen(Screen): - profile_name = properties.ObjectProperty(None) - jid = properties.ObjectProperty(None) - password = properties.ObjectProperty(None) - error_msg = properties.StringProperty('') - - def __init__(self, pm): - super(NewProfileScreen, self).__init__(name='new_profile') - self.pm = pm - - def on_creation_failure(self, failure): - msg = [l for l in str(failure).split('\n') if l][-1] - self.error_msg = str(msg) - - def on_creation_success(self, profile): - self.pm.profiles_screen.reload() - G.host.bridge.profile_start_session( - self.password.text, profile, - callback=lambda __: self._session_started(profile), - errback=self.on_creation_failure) - - def _session_started(self, profile): - jid = self.jid.text.strip() - G.host.bridge.param_set("JabberID", jid, "Connection", -1, profile) - G.host.bridge.param_set("Password", self.password.text, "Connection", -1, profile) - self.pm.screen_manager.transition.direction = 'right' - self.pm.screen_manager.current = 'profiles' - - def doCreate(self): - name = self.profile_name.text.strip() - # XXX: we use XMPP password for profile password to simplify - # if user want to change profile password, he can do it in preferences - G.host.bridge.profile_create( - name, self.password.text, '', - callback=lambda: self.on_creation_success(name), - errback=self.on_creation_failure) - - -class DeleteProfilesScreen(Screen): - - def __init__(self, pm): - self.pm = pm - super(DeleteProfilesScreen, self).__init__(name='delete_profiles') - - def do_delete(self): - """This method will delete *ALL* selected profiles""" - to_delete = self.pm.get_profiles() - deleted = [0] - - def delete_inc(): - deleted[0] += 1 - if deleted[0] == len(to_delete): - self.pm.profiles_screen.reload() - self.pm.screen_manager.transition.direction = 'right' - self.pm.screen_manager.current = 'profiles' - - for profile in to_delete: - log.info("Deleteing profile [{}]".format(profile)) - G.host.bridge.profile_delete_async( - profile, callback=delete_inc, errback=delete_inc) - - -class ProfilesScreen(Screen): - layout = properties.ObjectProperty(None) - profiles = properties.ListProperty() - - def __init__(self, pm): - self.pm = pm - super(ProfilesScreen, self).__init__(name='profiles') - self.reload() - - def _profiles_list_get_cb(self, profiles): - profiles.sort() - self.profiles = profiles - for idx, profile in enumerate(profiles): - item = ProfileItem(ps=self, index=idx, text=profile, group='profiles') - self.layout.add_widget(item) - - def converter(self, row_idx, obj): - return {'text': obj, - 'size_hint_y': None, - 'height': sp(40)} - - def reload(self): - """Reload profiles list""" - self.layout.clear_widgets() - G.host.bridge.profiles_list_get(callback=self._profiles_list_get_cb) - - -class ProfileManager(QuickProfileManager, BoxLayout): - selected = properties.ObjectProperty(None) - - def __init__(self, autoconnect=None): - QuickProfileManager.__init__(self, G.host, autoconnect) - BoxLayout.__init__(self, orientation="vertical") - self.screen_manager = ScreenManager() - self.profiles_screen = ProfilesScreen(self) - self.new_profile_screen = NewProfileScreen(self) - self.delete_profiles_screen = DeleteProfilesScreen(self) - self.xmlui_screen = Screen(name='xmlui') - self.screen_manager.add_widget(self.profiles_screen) - self.screen_manager.add_widget(self.xmlui_screen) - self.screen_manager.add_widget(self.new_profile_screen) - self.screen_manager.add_widget(self.delete_profiles_screen) - self.add_widget(self.screen_manager) - - def close_ui(self, xmlui, reason=None): - self.screen_manager.transition.direction = 'right' - self.screen_manager.current = 'profiles' - - def show_ui(self, xmlui): - xmlui.set_close_cb(self.close_ui) - if xmlui.type == 'popup': - xmlui.bind(on_touch_up=lambda obj, value: self.close_ui(xmlui)) - self.xmlui_screen.clear_widgets() - self.xmlui_screen.add_widget(xmlui) - self.screen_manager.transition.direction = 'left' - self.screen_manager.current = 'xmlui' - - def select_profile(self, profile_item): - if not profile_item.selected: - return - def authenticate_cb(data, cb_id, profile): - if not C.bool(data.pop('validated', C.BOOL_FALSE)): - # profile didn't validate, we unselect it - profile_item.state = 'normal' - self.selected = '' - else: - # state may have been modified so we need to be sure it's down - profile_item.state = 'down' - self.selected = profile_item - G.host.action_manager(data, callback=authenticate_cb, ui_show_cb=self.show_ui, - profile=profile) - - G.host.action_launch(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, - profile=profile_item.text) - - def get_profiles(self): - # for now we restrict to a single profile in Cagou - # TODO: handle multi-profiles - return [self.selected.text] if self.selected else []
--- a/cagou/core/share_widget.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from pathlib import Path -from functools import partial -from sat.core import log as logging -from sat.core.i18n import _ -from sat.tools.common import data_format -from sat_frontends.tools import jid -from kivy.uix.boxlayout import BoxLayout -from kivy.properties import StringProperty, DictProperty, ObjectProperty -from kivy.metrics import dp -from .constants import Const as C -from cagou import G - - -log = logging.getLogger(__name__) - - -PLUGIN_INFO = { - "name": _("share"), - "main": "Share", - "description": _("share a file"), - "icon_symbol": "share", -} - - -class TextPreview(BoxLayout): - """Widget previewing shared text""" - text = StringProperty() - - -class ImagePreview(BoxLayout): - """Widget previewing shared image""" - path = StringProperty() - reduce_layout = ObjectProperty() - reduce_checkbox = ObjectProperty() - - def _check_image_cb(self, report_raw): - self.report = data_format.deserialise(report_raw) - if self.report['too_large']: - self.reduce_layout.opacity = 1 - self.reduce_layout.height = self.reduce_layout.minimum_height + dp(10) - self.reduce_layout.padding = [0, dp(5)] - - def _check_image_eb(self, failure_): - log.error(f"Can't check image: {failure_}") - - def on_path(self, wid, path): - G.host.bridge.image_check( - path, callback=self._check_image_cb, errback=self._check_image_eb) - - def resize_image(self, data, callback, errback): - - def image_resize_cb(new_path): - new_path = Path(new_path) - log.debug(f"image {data['path']} resized at {new_path}") - data['path'] = new_path - data['cleaning_cb'] = lambda: new_path.unlink() - callback(data) - - path = data['path'] - width, height = self.report['recommended_size'] - G.host.bridge.image_resize( - path, width, height, - callback=image_resize_cb, - errback=errback - ) - - def get_filter(self): - if self.report['too_large'] and self.reduce_checkbox.active: - return self.resize_image - else: - return lambda data, callback, errback: callback(data) - - -class GenericPreview(BoxLayout): - """Widget previewing shared image""" - path = StringProperty() - - -class ShareWidget(BoxLayout): - media_type = StringProperty() - data = DictProperty() - preview_box = ObjectProperty() - - def on_kv_post(self, wid): - self.type, self.subtype = self.media_type.split('/') - if self.type == 'text' and 'text' in self.data: - self.preview_box.add_widget(TextPreview(text=self.data['text'])) - elif self.type == 'image': - self.preview_box.add_widget(ImagePreview(path=self.data['path'])) - else: - self.preview_box.add_widget(GenericPreview(path=self.data['path'])) - - def close(self): - G.host.close_ui() - - def get_filtered_data(self, callback, errback): - """Apply filter if suitable, and call callback with with modified data""" - try: - get_filter = self.preview_box.children[0].get_filter - except AttributeError: - callback(self.data) - else: - filter_ = get_filter() - filter_(self.data, callback=callback, errback=errback) - - def filter_data_cb(self, data, contact_jid, profile): - chat_wid = G.host.do_action('chat', contact_jid, [profile]) - - if self.type == 'text' and 'text' in self.data: - text = self.data['text'] - chat_wid.message_input.text += text - else: - path = self.data['path'] - chat_wid.transfer_file(path, cleaning_cb=data.get('cleaning_cb')) - self.close() - - def filter_data_eb(self, failure_): - G.host.add_note( - _("file filter error"), - _("Can't apply filter to file: {msg}").format(msg=failure_), - level=C.XMLUI_DATA_LVL_ERROR) - - def on_select(self, contact_button): - contact_jid = jid.JID(contact_button.jid) - self.get_filtered_data( - partial( - self.filter_data_cb, - contact_jid=contact_jid, - profile=contact_button.profile), - self.filter_data_eb - ) - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - return G.local_platform.on_key_back_share(self)
--- a/cagou/core/simple_xhtml.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,417 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from xml.etree import ElementTree as ET -from kivy.uix.stacklayout import StackLayout -from kivy.uix.label import Label -from kivy.utils import escape_markup -from kivy.metrics import sp -from kivy import properties -from sat.core import log as logging -from sat_frontends.tools import css_color, strings as sat_strings -from cagou import G -from cagou.core.common import SizedImage - - -log = logging.getLogger(__name__) - - -class Escape(str): - """Class used to mark that a message need to be escaped""" - - -class SimpleXHTMLWidgetEscapedText(Label): - - def on_parent(self, instance, parent): - if parent is not None: - self.font_size = parent.font_size - - def _add_url_markup(self, text): - text_elts = [] - idx = 0 - links = 0 - while True: - m = sat_strings.RE_URL.search(text[idx:]) - if m is not None: - text_elts.append(escape_markup(m.string[0:m.start()])) - link_key = 'link_' + str(links) - url = m.group() - escaped_url = escape_markup(url) - text_elts.append( - f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]') - if not links: - self.ref_urls = {link_key: url} - else: - self.ref_urls[link_key] = url - links += 1 - idx += m.end() - else: - if links: - text_elts.append(escape_markup(text[idx:])) - self.markup = True - self.text = ''.join(text_elts) - break - - def on_text(self, instance, text): - # do NOT call the method if self.markup is set - # this would result in infinite loop (because self.text - # is changed if an URL is found, and in this case markup too) - if text and not self.markup: - self._add_url_markup(text) - - def on_ref_press(self, ref): - url = self.ref_urls[ref] - G.local_platform.open_url(url, self) - - -class SimpleXHTMLWidgetText(Label): - - def on_parent(self, instance, parent): - if parent is not None: - self.font_size = parent.font_size - - -class SimpleXHTMLWidget(StackLayout): - """widget handling simple XHTML parsing""" - xhtml = properties.StringProperty() - color = properties.ListProperty([1, 1, 1, 1]) - # XXX: bold is only used for escaped text - bold = properties.BooleanProperty(False) - font_size = properties.NumericProperty(sp(14)) - - # text/XHTML input - - def on_xhtml(self, instance, xhtml): - """parse xhtml and set content accordingly - - if xhtml is an instance of Escape, a Label with no markup will be used - """ - self.clear_widgets() - if isinstance(xhtml, Escape): - label = SimpleXHTMLWidgetEscapedText( - text=xhtml, color=self.color, bold=self.bold) - self.bind(font_size=label.setter('font_size')) - self.bind(color=label.setter('color')) - self.bind(bold=label.setter('bold')) - self.add_widget(label) - else: - xhtml = ET.fromstring(xhtml.encode()) - self.current_wid = None - self.styles = [] - self._call_parse_method(xhtml) - if len(self.children) > 1: - self._do_split_labels() - - def escape(self, text): - """mark that a text need to be escaped (i.e. no markup)""" - return Escape(text) - - def _do_split_labels(self): - """Split labels so their content can flow with images""" - # XXX: to make things easier, we split labels in words - log.debug("labels splitting start") - children = self.children[::-1] - self.clear_widgets() - for child in children: - if isinstance(child, Label): - log.debug("label before split: {}".format(child.text)) - styles = [] - tag = False - new_text = [] - current_tag = [] - current_value = [] - current_wid = self._create_text() - value = False - close = False - # we will parse the text and create a new widget - # on each new word (actually each space) - # FIXME: handle '\n' and other white chars - for c in child.text: - if tag: - # we are parsing a markup tag - if c == ']': - current_tag_s = ''.join(current_tag) - current_style = (current_tag_s, ''.join(current_value)) - if close: - for idx, s in enumerate(reversed(styles)): - if s[0] == current_tag_s: - del styles[len(styles) - idx - 1] - break - else: - styles.append(current_style) - current_tag = [] - current_value = [] - tag = False - value = False - close = False - elif c == '/': - close = True - elif c == '=': - value = True - elif value: - current_value.append(c) - else: - current_tag.append(c) - new_text.append(c) - else: - # we are parsing regular text - if c == '[': - new_text.append(c) - tag = True - elif c == ' ': - # new word, we do a new widget - new_text.append(' ') - for t, v in reversed(styles): - new_text.append('[/{}]'.format(t)) - current_wid.text = ''.join(new_text) - new_text = [] - self.add_widget(current_wid) - log.debug("new widget: {}".format(current_wid.text)) - current_wid = self._create_text() - for t, v in styles: - new_text.append('[{tag}{value}]'.format( - tag = t, - value = '={}'.format(v) if v else '')) - else: - new_text.append(c) - if current_wid.text: - # we may have a remaining widget after the parsing - close_styles = [] - for t, v in reversed(styles): - close_styles.append('[/{}]'.format(t)) - current_wid.text = ''.join(close_styles) - self.add_widget(current_wid) - log.debug("new widget: {}".format(current_wid.text)) - else: - # non Label widgets, we just add them - self.add_widget(child) - self.splitted = True - log.debug("split OK") - - # XHTML parsing methods - - def _call_parse_method(self, e): - """Call the suitable method to parse the element - - self.xhtml_[tag] will be called if it exists, else - self.xhtml_generic will be used - @param e(ET.Element): element to parse - """ - try: - method = getattr(self, f"xhtml_{e.tag}") - except AttributeError: - log.warning(f"Unhandled XHTML tag: {e.tag}") - method = self.xhtml_generic - method(e) - - def _add_style(self, tag, value=None, append_to_list=True): - """add a markup style to label - - @param tag(unicode): markup tag - @param value(unicode): markup value if suitable - @param append_to_list(bool): if True style we be added to self.styles - self.styles is needed to keep track of styles to remove - should most probably be set to True - """ - label = self._get_label() - label.text += '[{tag}{value}]'.format( - tag = tag, - value = '={}'.format(value) if value else '' - ) - if append_to_list: - self.styles.append((tag, value)) - - def _remove_style(self, tag, remove_from_list=True): - """remove a markup style from the label - - @param tag(unicode): markup tag to remove - @param remove_from_list(bool): if True, remove from self.styles too - should most probably be set to True - """ - label = self._get_label() - label.text += '[/{tag}]'.format( - tag = tag - ) - if remove_from_list: - for rev_idx, style in enumerate(reversed(self.styles)): - if style[0] == tag: - tag_idx = len(self.styles) - 1 - rev_idx - del self.styles[tag_idx] - break - - def _get_label(self): - """get current Label if it exists, or create a new one""" - if not isinstance(self.current_wid, Label): - self._add_label() - return self.current_wid - - def _add_label(self): - """add a new Label - - current styles will be closed and reopened if needed - """ - self._close_label() - self.current_wid = self._create_text() - for tag, value in self.styles: - self._add_style(tag, value, append_to_list=False) - self.add_widget(self.current_wid) - - def _create_text(self): - label = SimpleXHTMLWidgetText(color=self.color, markup=True) - self.bind(color=label.setter('color')) - label.bind(texture_size=label.setter('size')) - return label - - def _close_label(self): - """close current style tags in current label - - needed when you change label to keep style between - different widgets - """ - if isinstance(self.current_wid, Label): - for tag, value in reversed(self.styles): - self._remove_style(tag, remove_from_list=False) - - def _parse_css(self, e): - """parse CSS found in "style" attribute of element - - self._css_styles will be created and contained markup styles added by this method - @param e(ET.Element): element which may have a "style" attribute - """ - styles_limit = len(self.styles) - styles = e.attrib['style'].split(';') - for style in styles: - try: - prop, value = style.split(':') - except ValueError: - log.warning(f"can't parse style: {style}") - continue - prop = prop.strip().replace('-', '_') - value = value.strip() - try: - method = getattr(self, f"css_{prop}") - except AttributeError: - log.warning(f"Unhandled CSS: {prop}") - else: - method(e, value) - self._css_styles = self.styles[styles_limit:] - - def _close_css(self): - """removed CSS styles - - styles in self._css_styles will be removed - and the attribute will be deleted - """ - for tag, __ in reversed(self._css_styles): - self._remove_style(tag) - del self._css_styles - - def xhtml_generic(self, elem, style=True, markup=None): - """Generic method for adding HTML elements - - this method handle content, style and children parsing - @param elem(ET.Element): element to add - @param style(bool): if True handle style attribute (CSS) - @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use - """ - # we first add markup and CSS style - if markup is not None: - if isinstance(markup, str): - tag, value = markup, None - else: - tag, value = markup - self._add_style(tag, value) - style_ = 'style' in elem.attrib and style - if style_: - self._parse_css(elem) - - # then content - if elem.text: - self._get_label().text += escape_markup(elem.text) - - # we parse the children - for child in elem: - self._call_parse_method(child) - - # closing CSS style and markup - if style_: - self._close_css() - if markup is not None: - self._remove_style(tag) - - # and the tail, which is regular text - if elem.tail: - self._get_label().text += escape_markup(elem.tail) - - # method handling XHTML elements - - def xhtml_br(self, elem): - label = self._get_label() - label.text+='\n' - self.xhtml_generic(elem, style=False) - - def xhtml_em(self, elem): - self.xhtml_generic(elem, markup='i') - - def xhtml_img(self, elem): - try: - src = elem.attrib['src'] - except KeyError: - log.warning("<img> element without src: {}".format(ET.tostring(elem))) - return - try: - target_height = int(elem.get('height', 0)) - except ValueError: - log.warning(f"Can't parse image height: {elem.get('height')}") - target_height = None - try: - target_width = int(elem.get('width', 0)) - except ValueError: - log.warning(f"Can't parse image width: {elem.get('width')}") - target_width = None - - img = SizedImage( - source=src, target_height=target_height, target_width=target_width) - self.current_wid = img - self.add_widget(img) - - def xhtml_p(self, elem): - if isinstance(self.current_wid, Label): - self.current_wid.text+="\n\n" - self.xhtml_generic(elem) - - def xhtml_span(self, elem): - self.xhtml_generic(elem) - - def xhtml_strong(self, elem): - self.xhtml_generic(elem, markup='b') - - # methods handling CSS properties - - def css_color(self, elem, value): - self._add_style("color", css_color.parse(value)) - - def css_text_decoration(self, elem, value): - if value == 'underline': - self._add_style('u') - elif value == 'line-through': - self._add_style('s') - else: - log.warning("unhandled text decoration: {}".format(value))
--- a/cagou/core/widgets_handler.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,621 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -from sat.core import exceptions -from sat_frontends.quick_frontend import quick_widgets -from kivy.graphics import Color, Ellipse -from kivy.uix.layout import Layout -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.stencilview import StencilView -from kivy.uix.carousel import Carousel -from kivy.uix.screenmanager import ScreenManager, Screen -from kivy.metrics import dp -from kivy import properties -from cagou import G -from .constants import Const as C -from . import cagou_widget - -log = logging.getLogger(__name__) - - -REMOVE_WID_LIMIT = dp(50) -MIN_WIDTH = MIN_HEIGHT = dp(70) - - -class BoxStencil(BoxLayout, StencilView): - pass - - -class WHWrapper(BoxLayout): - main_container = properties.ObjectProperty(None) - screen_manager = properties.ObjectProperty(None, allownone=True) - carousel = properties.ObjectProperty(None, allownone=True) - split_size = properties.NumericProperty(dp(1)) - split_margin = properties.NumericProperty(dp(2)) - split_color = properties.ListProperty([0.8, 0.8, 0.8, 1]) - split_color_move = C.COLOR_SEC_DARK - split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1]) - # sp stands for "split point" - sp_size = properties.NumericProperty(dp(1)) - sp_space = properties.NumericProperty(dp(4)) - sp_zone = properties.NumericProperty(dp(30)) - _split = properties.OptionProperty('None', options=['None', 'left', 'top']) - _split_del = properties.BooleanProperty(False) - - def __init__(self, **kwargs): - idx = kwargs.pop('_wid_idx') - self._wid_idx = idx - super(WHWrapper, self).__init__(**kwargs) - self._left_wids = set() - self._top_wids = set() - self._right_wids = set() - self._bottom_wids = set() - self._clear_attributes() - - def _clear_attributes(self): - self._former_slide = None - - def __repr__(self): - return "WHWrapper_{idx}".format(idx=self._wid_idx) - - def _main_wid(self, wid_list): - """return main widget of a side list - - main widget is either the widget currently splitted - or any widget if none is split - @return (WHWrapper, None): main widget or None - if there is not widget - """ - if not wid_list: - return None - for wid in wid_list: - if wid._split != 'None': - return wid - return next(iter(wid_list)) - - def on_parent(self, __, new_parent): - if new_parent is None: - # we detach all children so CagouWidget.whwrapper won't link to this one - # anymore - self.clear_widgets() - - @property - def _left_wid(self): - return self._main_wid(self._left_wids) - - @property - def _top_wid(self): - return self._main_wid(self._top_wids) - - @property - def _right_wid(self): - return self._main_wid(self._right_wids) - - @property - def _bottom_wid(self): - return self._main_wid(self._bottom_wids) - - @property - def current_slide(self): - if (self.carousel is not None - and (self.screen_manager is None or self.screen_manager.current == '')): - return self.carousel.current_slide - elif self.screen_manager is not None: - # we should have exactly one children in current_screen, else there is a bug - return self.screen_manager.current_screen.children[0] - else: - try: - return self.main_container.children[0] - except IndexError: - log.error("No child found, this should not happen") - return None - - @property - def carousel_active(self): - """Return True if Carousel is used and active""" - if self.carousel is None: - return False - if self.screen_manager is not None and self.screen_manager.current != '': - return False - return True - - @property - def former_screen_wid(self): - """Return widget currently active for former screen""" - if self.screen_manager is None: - raise exceptions.InternalError( - "former_screen_wid can only be used if ScreenManager is used") - if self._former_screen_name is None: - return None - return self.get_screen_widget(self._former_screen_name) - - def get_screen_widget(self, screen_name): - """Return screen main widget, handling carousel if necessary""" - if self.carousel is not None and screen_name == '': - return self.carousel.current_slide - try: - return self.screen_manager.get_screen(screen_name).children[0] - except IndexError: - return None - - def _draw_ellipse(self): - """draw split ellipse""" - color = self.split_color_del if self._split_del else self.split_color_move - try: - self.canvas.after.remove(self.ellipse) - except AttributeError: - pass - if self._split == "top": - with self.canvas.after: - Color(*color) - self.ellipse = Ellipse(angle_start=90, angle_end=270, - pos=(self.x + self.width/2 - self.sp_zone/2, - self.y + self.height - self.sp_zone/2), - size=(self.sp_zone, self.sp_zone)) - elif self._split == "left": - with self.canvas.after: - Color(*color) - self.ellipse = Ellipse(angle_end=180, - pos=(self.x + -self.sp_zone/2, - self.y + self.height/2 - self.sp_zone/2), - size = (self.sp_zone, self.sp_zone)) - else: - raise exceptions.InternalError('unexpected split value') - - def on_touch_down(self, touch): - """activate split if touch is on a split zone""" - if not self.collide_point(*touch.pos): - return - log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format( - self._wid_idx, - 'None' if not self._left_wids else [w._wid_idx for w in self._left_wids], - 'None' if not self._top_wids else [w._wid_idx for w in self._top_wids], - 'None' if not self._right_wids else [w._wid_idx for w in self._right_wids], - 'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids], - self.pos, - self.size, - )) - touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True) - if (touch_ry <= self.height and - touch_ry >= self.height - self.split_size - self.split_margin or - touch_ry <= self.height and - touch_ry >= self.height - self.sp_zone and - touch_rx >= self.width//2 - self.sp_zone//2 and - touch_rx <= self.width//2 + self.sp_zone//2): - # split area is touched, we activate top split mode - self._split = "top" - self._draw_ellipse() - elif (touch_rx >= 0 and - touch_rx <= self.split_size + self.split_margin or - touch_rx >= 0 and - touch_rx <= self.sp_zone and - touch_ry >= self.height//2 - self.sp_zone//2 and - touch_ry <= self.height//2 + self.sp_zone//2): - # split area is touched, we activate left split mode - self._split = "left" - touch.ud['ori_width'] = self.width - self._draw_ellipse() - else: - if self.carousel_active and len(self.carousel.slides) <= 1: - # we don't want swipe of carousel if there is only one slide - return StencilView.on_touch_down(self.carousel, touch) - else: - return super(WHWrapper, self).on_touch_down(touch) - - def on_touch_move(self, touch): - """handle size change and widget creation on split""" - if self._split == 'None': - return super(WHWrapper, self).on_touch_move(touch) - - elif self._split == 'top': - new_height = touch.y - self.y - - if new_height < MIN_HEIGHT: - return - - # we must not pass the top widget/border - if self._top_wids: - top = next(iter(self._top_wids)) - y_limit = top.y + top.height - - if top.height <= REMOVE_WID_LIMIT: - # we are in remove zone, we add visual hint for that - if not self._split_del and self._top_wids: - self._split_del = True - self._draw_ellipse() - else: - if self._split_del: - self._split_del = False - self._draw_ellipse() - else: - y_limit = self.y + self.height - - if touch.y >= y_limit: - return - - # all right, we can change size - self.height = new_height - self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2) - - if not self._top_wids: - # we are the last widget on the top - # so we create a new widget - new_wid = self.parent.add_widget() - self._top_wids.add(new_wid) - new_wid._bottom_wids.add(self) - for w in self._right_wids: - new_wid._right_wids.add(w) - w._left_wids.add(new_wid) - for w in self._left_wids: - new_wid._left_wids.add(w) - w._right_wids.add(new_wid) - - elif self._split == 'left': - ori_width = touch.ud['ori_width'] - new_x = touch.x - new_width = ori_width - (touch.x - touch.ox) - - if new_width < MIN_WIDTH: - return - - # we must not pass the left widget/border - if self._left_wids: - left = next(iter(self._left_wids)) - x_limit = left.x - - if left.width <= REMOVE_WID_LIMIT: - # we are in remove zone, we add visual hint for that - if not self._split_del and self._left_wids: - self._split_del = True - self._draw_ellipse() - else: - if self._split_del: - self._split_del = False - self._draw_ellipse() - else: - x_limit = self.x - - if new_x <= x_limit: - return - - # all right, we can change position/size - self.x = new_x - self.width = new_width - self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1]) - - if not self._left_wids: - # we are the last widget on the left - # so we create a new widget - new_wid = self.parent.add_widget() - self._left_wids.add(new_wid) - new_wid._right_wids.add(self) - for w in self._top_wids: - new_wid._top_wids.add(w) - w._bottom_wids.add(new_wid) - for w in self._bottom_wids: - new_wid._bottom_wids.add(w) - w._top_wids.add(new_wid) - - else: - raise Exception.InternalError('invalid _split value') - - def on_touch_up(self, touch): - if self._split == 'None': - return super(WHWrapper, self).on_touch_up(touch) - if self._split == 'top': - # we remove all top widgets in delete zone, - # and update there side widgets list - for top in self._top_wids.copy(): - if top.height <= REMOVE_WID_LIMIT: - G.host._remove_visible_widget(top.current_slide) - for w in top._top_wids: - w._bottom_wids.remove(top) - w._bottom_wids.update(top._bottom_wids) - for w in top._bottom_wids: - w._top_wids.remove(top) - w._top_wids.update(top._top_wids) - for w in top._left_wids: - w._right_wids.remove(top) - for w in top._right_wids: - w._left_wids.remove(top) - self.parent.remove_widget(top) - elif self._split == 'left': - # we remove all left widgets in delete zone, - # and update there side widgets list - for left in self._left_wids.copy(): - if left.width <= REMOVE_WID_LIMIT: - G.host._remove_visible_widget(left.current_slide) - for w in left._left_wids: - w._right_wids.remove(left) - w._right_wids.update(left._right_wids) - for w in left._right_wids: - w._left_wids.remove(left) - w._left_wids.update(left._left_wids) - for w in left._top_wids: - w._bottom_wids.remove(left) - for w in left._bottom_wids: - w._top_wids.remove(left) - self.parent.remove_widget(left) - self._split = 'None' - self.canvas.after.remove(self.ellipse) - del self.ellipse - - def clear_widgets(self): - current_slide = self.current_slide - if current_slide is not None: - G.host._remove_visible_widget(current_slide, ignore_missing=True) - - super().clear_widgets() - - self.screen_manager = None - self.carousel = None - self._clear_attributes() - - def set_widget(self, wid, index=0): - assert len(self.children) == 0 - - if wid.collection_carousel or wid.global_screen_manager: - self.main_container = self - else: - self.main_container = BoxStencil() - self.add_widget(self.main_container) - - if self.carousel is not None: - return self.carousel.add_widget(wid, index) - - if wid.global_screen_manager: - if self.screen_manager is None: - self.screen_manager = ScreenManager() - self.main_container.add_widget(self.screen_manager) - parent = Screen() - self.screen_manager.add_widget(parent) - self._former_screen_name = '' - self.screen_manager.bind(current=self.on_screen_change) - wid.screen_manager_init(self.screen_manager) - else: - parent = self.main_container - - if wid.collection_carousel: - # a Carousel is requested, and this is the first widget that we add - # so we need to create the carousel - self.carousel = Carousel( - direction = "right", - ignore_perpendicular_swipes = True, - loop = True, - ) - self._slides_update_lock = 0 - self.carousel.bind(current_slide=self.on_slide_change) - parent.add_widget(self.carousel) - self.carousel.add_widget(wid, index) - else: - # no Carousel requested, we add the widget as a direct child - parent.add_widget(wid) - G.host._add_visible_widget(wid) - - def change_widget(self, new_widget): - """Change currently displayed widget - - slides widgets will be updated - """ - if (self.carousel is not None - and self.carousel.current_slide.__class__ == new_widget.__class__): - # we have the same class, we reuse carousel and screen manager setting - - if self.carousel.current_slide != new_widget: - # slides update need to be blocked to avoid the update in on_slide_change - # which would mess the removal of current widgets - self._slides_update_lock += 1 - new_wid = None - for w in self.carousel.slides[:]: - if w.widget_hash == new_widget.widget_hash: - new_wid = w - continue - self.carousel.remove_widget(w) - if isinstance(w, quick_widgets.QuickWidget): - G.host.widgets.delete_widget(w) - if new_wid is None: - new_wid = G.host.get_or_clone(new_widget) - self.carousel.add_widget(new_wid) - self._update_hidden_slides() - self._slides_update_lock -= 1 - - if self.screen_manager is not None: - self.screen_manager.clear_widgets([ - s for s in self.screen_manager.screens if s.name != '']) - new_wid.screen_manager_init(self.screen_manager) - else: - # else, we restart fresh - self.clear_widgets() - self.set_widget(G.host.get_or_clone(new_widget)) - - def on_screen_change(self, screen_manager, new_screen): - try: - new_screen_wid = self.current_slide - except IndexError: - new_screen_wid = None - log.warning("Switching to a screen without children") - if new_screen == '' and self.carousel is not None: - # carousel may have been changed in the background, so we update slides - self._update_hidden_slides() - former_screen_wid = self.former_screen_wid - if isinstance(former_screen_wid, cagou_widget.CagouWidget): - G.host._remove_visible_widget(former_screen_wid) - if isinstance(new_screen_wid, cagou_widget.CagouWidget): - G.host._add_visible_widget(new_screen_wid) - self._former_screen_name = new_screen - G.host.selected_widget = new_screen_wid - - def on_slide_change(self, handler, new_slide): - if self._former_slide is new_slide: - # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide - # binding now calls on_slide_change twice with the same widget (here - # "new_slide"). To be checked with Kivy team. - return - log.debug(f"Slide change: new_slide = {new_slide}") - if self._former_slide is not None: - G.host._remove_visible_widget(self._former_slide, ignore_missing=True) - self._former_slide = new_slide - if self.carousel_active: - G.host.selected_widget = new_slide - if new_slide is not None: - G.host._add_visible_widget(new_slide) - self._update_hidden_slides() - - def hidden_list(self, visible_list, ignore=None): - """return widgets of same class as carousel current one, if they are hidden - - @param visible_list(list[QuickWidget]): widgets visible - @param ignore(QuickWidget, None): do no return this widget - @return (iter[QuickWidget]): widgets hidden - """ - # we want to avoid recreated widgets - added = [w.widget_hash for w in visible_list] - current_slide = self.carousel.current_slide - for w in G.host.widgets.get_widgets(current_slide.__class__, - profiles=current_slide.profiles): - wid_hash = w.widget_hash - if w in visible_list or wid_hash in added: - continue - if wid_hash == ignore.widget_hash: - continue - yield w - - - def _update_hidden_slides(self): - """adjust carousel slides according to visible widgets""" - if self._slides_update_lock or not self.carousel_active: - return - current_slide = self.carousel.current_slide - if not isinstance(current_slide, quick_widgets.QuickWidget): - return - # lock must be used here to avoid recursions - self._slides_update_lock += 1 - visible_list = G.host.get_visible_list(current_slide.__class__) - # we ignore current_slide as it may not be visible yet (e.g. if an other - # screen is shown - hidden = list(self.hidden_list(visible_list, ignore=current_slide)) - slides_sorted = sorted(set(hidden + [current_slide])) - to_remove = set(self.carousel.slides).difference({current_slide}) - for w in to_remove: - self.carousel.remove_widget(w) - if hidden: - # no need to add more than two widgets (next and previous), - # as the list will be updated on each new visible widget - current_idx = slides_sorted.index(current_slide) - try: - next_slide = slides_sorted[current_idx+1] - except IndexError: - next_slide = slides_sorted[0] - self.carousel.add_widget(G.host.get_or_clone(next_slide)) - if len(hidden)>1: - previous_slide = slides_sorted[current_idx-1] - self.carousel.add_widget(G.host.get_or_clone(previous_slide)) - - self._slides_update_lock -= 1 - - -class WidgetsHandlerLayout(Layout): - count = 0 - - def __init__(self, **kwargs): - super(WidgetsHandlerLayout, self).__init__(**kwargs) - self._layout_size = None # size used for the last layout - fbind = self.fbind - update = self._trigger_layout - fbind('children', update) - fbind('parent', update) - fbind('size', self.adjust_prop) - fbind('pos', update) - - @property - def default_widget(self): - return G.host.default_wid['factory'](G.host.default_wid, None, None) - - def adjust_prop(self, handler, new_size): - """Adjust children proportion - - useful when this widget is resized (e.g. when going to fullscreen) - """ - if len(self.children) > 1: - old_width, old_height = self._layout_size - if not old_width or not old_height: - # we don't want division by zero - return self._trigger_layout(handler, new_size) - width_factor = float(self.width) / old_width - height_factor = float(self.height) / old_height - for child in self.children: - child.width *= width_factor - child.height *= height_factor - child.x *= width_factor - child.y *= height_factor - self._trigger_layout(handler, new_size) - - def do_layout(self, *args): - self._layout_size = self.size[:] - for child in self.children: - # XXX: left must be calculated before right and bottom before top - # because they are the pos, and are used to caculate size (right and top) - # left - left = child._left_wid - left_end_x = self.x-1 if left is None else left.right - if child.x != left_end_x + 1 and child._split == "None": - child.x = left_end_x + 1 - # right - right = child._right_wid - right_x = self.right + 1 if right is None else right.x - if child.right != right_x - 1: - child.width = right_x - child.x - 1 - # bottom - bottom = child._bottom_wid - if bottom is None: - if child.y != self.y: - child.y = self.y - else: - if child.y != bottom.top + 1: - child.y = bottom.top + 1 - # top - top = child._top_wid - top_y = self.top+1 if top is None else top.y - if child.top != top_y - 1: - if child._split == "None": - child.height = top_y - child.y - 1 - - def remove_widget(self, wid): - super(WidgetsHandlerLayout, self).remove_widget(wid) - log.debug("widget deleted ({})".format(wid._wid_idx)) - - def add_widget(self, wid=None, index=0): - WidgetsHandlerLayout.count += 1 - if wid is None: - wid = self.default_widget - if G.host.selected_widget is None: - G.host.selected_widget = wid - wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count) - log.debug("WHWrapper created ({})".format(wrapper._wid_idx)) - wrapper.set_widget(wid) - super(WidgetsHandlerLayout, self).add_widget(wrapper, index) - return wrapper - - -class WidgetsHandler(WidgetsHandlerLayout): - - def __init__(self, **kw): - super(WidgetsHandler, self).__init__(**kw) - self.add_widget()
--- a/cagou/core/xmlui.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,624 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: a SàT frontend -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from sat.core.i18n import _ -from .constants import Const as C -from sat.core.log import getLogger -from sat_frontends.tools import xmlui -from kivy.uix.scrollview import ScrollView -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.gridlayout import GridLayout -from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem -from kivy.uix.textinput import TextInput -from kivy.uix.label import Label -from kivy.uix.button import Button -from kivy.uix.togglebutton import ToggleButton -from kivy.uix.widget import Widget -from kivy.uix.switch import Switch -from kivy import properties -from cagou import G -from cagou.core import dialog -from functools import partial - -log = getLogger(__name__) - -## Widgets ## - - -class TextInputOnChange(object): - - def __init__(self): - self._xmlui_onchange_cb = None - self._got_focus = False - - def _xmlui_on_change(self, callback): - self._xmlui_onchange_cb = callback - - def on_focus(self, instance, focus): - # we need to wait for first focus, else initial value - # will trigger a on_text - if not self._got_focus and focus: - self._got_focus = True - - def on_text(self, instance, new_text): - if self._xmlui_onchange_cb is not None and self._got_focus: - self._xmlui_onchange_cb(self) - - -class EmptyWidget(xmlui.EmptyWidget, Widget): - - def __init__(self, _xmlui_parent): - Widget.__init__(self) - - -class TextWidget(xmlui.TextWidget, Label): - - def __init__(self, xmlui_parent, value): - Label.__init__(self, text=value) - - -class LabelWidget(xmlui.LabelWidget, TextWidget): - pass - - -class JidWidget(xmlui.JidWidget, TextWidget): - pass - - -class StringWidget(xmlui.StringWidget, TextInput, TextInputOnChange): - - def __init__(self, xmlui_parent, value, read_only=False): - TextInput.__init__(self, text=value) - TextInputOnChange.__init__(self) - self.readonly = read_only - - def _xmlui_set_value(self, value): - self.text = value - - def _xmlui_get_value(self): - return self.text - - -class TextBoxWidget(xmlui.TextBoxWidget, StringWidget): - pass - - -class JidInputWidget(xmlui.JidInputWidget, StringWidget): - pass - - -class ButtonWidget(xmlui.ButtonWidget, Button): - - def __init__(self, _xmlui_parent, value, click_callback): - Button.__init__(self) - self.text = value - self.callback = click_callback - - def _xmlui_on_click(self, callback): - self.callback = callback - - def on_release(self): - self.callback(self) - - -class DividerWidget(xmlui.DividerWidget, Widget): - # FIXME: not working properly + only 'line' is handled - style = properties.OptionProperty('line', - options=['line', 'dot', 'dash', 'plain', 'blank']) - - def __init__(self, _xmlui_parent, style="line"): - Widget.__init__(self, style=style) - - -class ListWidgetItem(ToggleButton): - value = properties.StringProperty() - - def on_release(self): - parent = self.parent - while parent is not None and not isinstance(parent, ListWidget): - parent = parent.parent - - if parent is not None: - parent.select(self) - return super(ListWidgetItem, self).on_release() - - @property - def selected(self): - return self.state == 'down' - - @selected.setter - def selected(self, value): - self.state = 'down' if value else 'normal' - - -class ListWidget(xmlui.ListWidget, ScrollView): - layout = properties.ObjectProperty() - - def __init__(self, _xmlui_parent, options, selected, flags): - ScrollView.__init__(self) - self.multi = 'single' not in flags - self._values = [] - for option in options: - self.add_value(option) - self._xmlui_select_values(selected) - self._on_change = None - - @property - def items(self): - return self.layout.children - - def select(self, item): - if not self.multi: - self._xmlui_select_values([item.value]) - if self._on_change is not None: - self._on_change(self) - - def add_value(self, option, selected=False): - """add a value in the list - - @param option(tuple): value, label in a tuple - """ - self._values.append(option) - item = ListWidgetItem() - item.value, item.text = option - item.selected = selected - self.layout.add_widget(item) - - def _xmlui_select_value(self, value): - self._xmlui_select_values([value]) - - def _xmlui_select_values(self, values): - for item in self.items: - item.selected = item.value in values - if item.selected and not self.multi: - self.text = item.text - - def _xmlui_get_selected_values(self): - return [item.value for item in self.items if item.selected] - - def _xmlui_add_values(self, values, select=True): - values = set(values).difference([c.value for c in self.items]) - for v in values: - self.add_value(v, select) - - def _xmlui_on_change(self, callback): - self._on_change = callback - - -class JidsListWidget(ListWidget): - # TODO: real list dedicated to jids - - def __init__(self, _xmlui_parent, jids, flags): - ListWidget.__init__(self, _xmlui_parent, [(j,j) for j in jids], [], flags) - - -class PasswordWidget(xmlui.PasswordWidget, TextInput, TextInputOnChange): - - def __init__(self, _xmlui_parent, value, read_only=False): - TextInput.__init__(self, password=True, multiline=False, - text=value, readonly=read_only, size=(100,25), size_hint=(1,None)) - TextInputOnChange.__init__(self) - - def _xmlui_set_value(self, value): - self.text = value - - def _xmlui_get_value(self): - return self.text - - -class BoolWidget(xmlui.BoolWidget, Switch): - - def __init__(self, _xmlui_parent, state, read_only=False): - Switch.__init__(self, active=state) - if read_only: - self.disabled = True - - def _xmlui_set_value(self, value): - self.active = value - - def _xmlui_get_value(self): - return C.BOOL_TRUE if self.active else C.BOOL_FALSE - - def _xmlui_on_change(self, callback): - self.bind(active=lambda instance, value: callback(instance)) - - -class IntWidget(xmlui.IntWidget, TextInput, TextInputOnChange): - - def __init__(self, _xmlui_parent, value, read_only=False): - TextInput.__init__(self, text=value, input_filter='int', multiline=False) - TextInputOnChange.__init__(self) - if read_only: - self.disabled = True - - def _xmlui_set_value(self, value): - self.text = value - - def _xmlui_get_value(self): - return self.text - - -## Containers ## - - -class VerticalContainer(xmlui.VerticalContainer, BoxLayout): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - BoxLayout.__init__(self) - - def _xmlui_append(self, widget): - self.add_widget(widget) - - -class PairsContainer(xmlui.PairsContainer, GridLayout): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - GridLayout.__init__(self) - - def _xmlui_append(self, widget): - self.add_widget(widget) - - -class LabelContainer(PairsContainer, xmlui.LabelContainer): - pass - - -class TabsPanelContainer(TabbedPanelItem): - layout = properties.ObjectProperty(None) - - def _xmlui_append(self, widget): - self.layout.add_widget(widget) - - -class TabsContainer(xmlui.TabsContainer, TabbedPanel): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - TabbedPanel.__init__(self, do_default_tab=False) - - def _xmlui_add_tab(self, label, selected): - tab = TabsPanelContainer(text=label) - self.add_widget(tab) - return tab - - -class AdvancedListRow(BoxLayout): - global_index = 0 - index = properties.ObjectProperty() - selected = properties.BooleanProperty(False) - - def __init__(self, **kwargs): - self.global_index = AdvancedListRow.global_index - AdvancedListRow.global_index += 1 - super(AdvancedListRow, self).__init__(**kwargs) - - def on_touch_down(self, touch): - if self.collide_point(*touch.pos): - parent = self.parent - while parent is not None and not isinstance(parent, AdvancedListContainer): - parent = parent.parent - if parent is None: - log.error("Can't find parent AdvancedListContainer") - else: - if parent.selectable: - self.selected = parent._xmlui_toggle_selected(self) - - return super(AdvancedListRow, self).on_touch_down(touch) - - -class AdvancedListContainer(xmlui.AdvancedListContainer, BoxLayout): - - def __init__(self, xmlui_parent, columns, selectable='no'): - self.xmlui_parent = xmlui_parent - BoxLayout.__init__(self) - self._columns = columns - self.selectable = selectable != 'no' - self._current_row = None - self._selected = [] - self._xmlui_select_cb = None - - def _xmlui_toggle_selected(self, row): - """inverse selection status of an AdvancedListRow - - @param row(AdvancedListRow): row to (un)select - @return (bool): True if row is selected - """ - try: - self._selected.remove(row) - except ValueError: - self._selected.append(row) - if self._xmlui_select_cb is not None: - self._xmlui_select_cb(self) - return True - else: - return False - - def _xmlui_append(self, widget): - if self._current_row is None: - log.error("No row set, ignoring append") - return - self._current_row.add_widget(widget) - - def _xmlui_add_row(self, idx): - self._current_row = AdvancedListRow() - self._current_row.cols = self._columns - self._current_row.index = idx - self.add_widget(self._current_row) - - def _xmlui_get_selected_widgets(self): - return self._selected - - def _xmlui_get_selected_index(self): - if not self._selected: - return None - return self._selected[0].index - - def _xmlui_on_select(self, callback): - """ Call callback with widget as only argument """ - self._xmlui_select_cb = callback - - -## Dialogs ## - - -class NoteDialog(xmlui.NoteDialog): - - def __init__(self, _xmlui_parent, title, message, level): - xmlui.NoteDialog.__init__(self, _xmlui_parent) - self.title, self.message, self.level = title, message, level - - def _xmlui_show(self): - G.host.add_note(self.title, self.message, self.level) - - -class MessageDialog(xmlui.MessageDialog, dialog.MessageDialog): - - def __init__(self, _xmlui_parent, title, message, level): - dialog.MessageDialog.__init__(self, - title=title, - message=message, - level=level, - close_cb = self.close_cb) - xmlui.MessageDialog.__init__(self, _xmlui_parent) - - def close_cb(self): - self._xmlui_close() - - def _xmlui_show(self): - G.host.add_notif_ui(self) - - def _xmlui_close(self, reason=None): - G.host.close_ui() - - def show(self, *args, **kwargs): - G.host.show_ui(self) - - -class ConfirmDialog(xmlui.ConfirmDialog, dialog.ConfirmDialog): - - def __init__(self, _xmlui_parent, title, message, level, buttons_set): - dialog.ConfirmDialog.__init__(self) - xmlui.ConfirmDialog.__init__(self, _xmlui_parent) - self.title=title - self.message=message - self.no_cb = self.no_cb - self.yes_cb = self.yes_cb - - def no_cb(self): - G.host.close_ui() - self._xmlui_cancelled() - - def yes_cb(self): - G.host.close_ui() - self._xmlui_validated() - - def _xmlui_show(self): - G.host.add_notif_ui(self) - - def _xmlui_close(self, reason=None): - G.host.close_ui() - - def show(self, *args, **kwargs): - assert kwargs["force"] - G.host.show_ui(self) - - -class FileDialog(xmlui.FileDialog, BoxLayout): - message = properties.ObjectProperty() - - def __init__(self, _xmlui_parent, title, message, level, filetype): - xmlui.FileDialog.__init__(self, _xmlui_parent) - BoxLayout.__init__(self) - self.message.text = message - if filetype == C.XMLUI_DATA_FILETYPE_DIR: - self.file_chooser.dirselect = True - - def _xmlui_show(self): - G.host.add_notif_ui(self) - - def _xmlui_close(self, reason=None): - # FIXME: notif UI is not removed if dialog is not shown yet - G.host.close_ui() - - def on_select(self, path): - try: - path = path[0] - except IndexError: - path = None - if not path: - self._xmlui_cancelled() - else: - self._xmlui_validated({'path': path}) - - def show(self, *args, **kwargs): - assert kwargs["force"] - G.host.show_ui(self) - - -## Factory ## - - -class WidgetFactory(object): - - def __getattr__(self, attr): - if attr.startswith("create"): - cls = globals()[attr[6:]] - return cls - - -## Core ## - - -class Title(Label): - - def __init__(self, *args, **kwargs): - kwargs['size'] = (100, 25) - kwargs['size_hint'] = (1,None) - super(Title, self).__init__(*args, **kwargs) - - -class FormButton(Button): - pass - -class SubmitButton(FormButton): - pass - -class CancelButton(FormButton): - pass - -class SaveButton(FormButton): - pass - - -class XMLUIPanel(xmlui.XMLUIPanel, ScrollView): - widget_factory = WidgetFactory() - layout = properties.ObjectProperty() - - def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, - ignore=None, whitelist=None, profile=C.PROF_KEY_NONE): - ScrollView.__init__(self) - self.close_cb = None - self._post_treats = [] # list of callback to call after UI is constructed - - # used to workaround touch issues when a ScrollView is used inside this - # one. This happens notably when a TabsContainer is used as main container - # (this is the case with settings). - self._skip_scroll_events = False - xmlui.XMLUIPanel.__init__(self, - host, - parsed_xml, - title=title, - flags=flags, - callback=callback, - ignore=ignore, - whitelist=whitelist, - profile=profile) - self.bind(height=self.on_height) - - def on_touch_down(self, touch, after=False): - if self._skip_scroll_events: - return super(ScrollView, self).on_touch_down(touch) - else: - return super(XMLUIPanel, self).on_touch_down(touch) - - def on_touch_up(self, touch, after=False): - if self._skip_scroll_events: - return super(ScrollView, self).on_touch_up(touch) - else: - return super(XMLUIPanel, self).on_touch_up(touch) - - def on_touch_move(self, touch, after=False): - if self._skip_scroll_events: - return super(ScrollView, self).on_touch_move(touch) - else: - return super(XMLUIPanel, self).on_touch_move(touch) - - def set_close_cb(self, close_cb): - self.close_cb = close_cb - - def _xmlui_close(self, __=None, reason=None): - if self.close_cb is not None: - self.close_cb(self, reason) - else: - G.host.close_ui() - - def on_param_change(self, ctrl): - super(XMLUIPanel, self).on_param_change(ctrl) - self.save_btn.disabled = False - - def add_post_treat(self, callback): - self._post_treats.append(callback) - - def _post_treat_cb(self): - for cb in self._post_treats: - cb() - del self._post_treats - - def _save_button_cb(self, button): - button.disabled = True - self.on_save_params(button) - - def construct_ui(self, parsed_dom): - xmlui.XMLUIPanel.construct_ui(self, parsed_dom, self._post_treat_cb) - if self.xmlui_title: - self.layout.add_widget(Title(text=self.xmlui_title)) - if isinstance(self.main_cont, TabsContainer): - # cf. comments above - self._skip_scroll_events = True - self.layout.add_widget(self.main_cont) - if self.type == 'form': - submit_btn = SubmitButton() - submit_btn.bind(on_press=self.on_form_submitted) - self.layout.add_widget(submit_btn) - if not 'NO_CANCEL' in self.flags: - cancel_btn = CancelButton(text=_("Cancel")) - cancel_btn.bind(on_press=self.on_form_cancelled) - self.layout.add_widget(cancel_btn) - elif self.type == 'param': - self.save_btn = SaveButton(text=_("Save"), disabled=True) - self.save_btn.bind(on_press=self._save_button_cb) - self.layout.add_widget(self.save_btn) - elif self.type == 'window': - cancel_btn = CancelButton(text=_("Cancel")) - cancel_btn.bind( - on_press=partial(self._xmlui_close, reason=C.XMLUI_DATA_CANCELLED)) - self.layout.add_widget(cancel_btn) - - def on_height(self, __, height): - if isinstance(self.main_cont, TabsContainer): - other_children_height = sum([c.height for c in self.layout.children - if c is not self.main_cont]) - self.main_cont.height = height - other_children_height - - def show(self, *args, **kwargs): - if not self.user_action and not kwargs.get("force", False): - G.host.add_notif_ui(self) - else: - G.host.show_ui(self) - - -class XMLUIDialog(xmlui.XMLUIDialog): - dialog_factory = WidgetFactory() - - -create = partial(xmlui.create, class_map={ - xmlui.CLASS_PANEL: XMLUIPanel, - xmlui.CLASS_DIALOG: XMLUIDialog})
--- a/cagou/kv/base.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -<Label>: - color: 0, 0, 0, 1 - -<Button>: - color: 1, 1, 1, 1 - -<TextInput>: - background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
--- a/cagou/kv/behaviors.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,28 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -<TouchMenu>: - creation_direction: -1 - radius: dp(25) - creation_timeout: .4 - cancel_color: app.c_sec_light[:3] + [0.3] - color: app.c_sec - line_width: dp(2) - -<ModernMenuLabel>: - bg_color: app.c_sec[:3] + [0.9] - padding: dp(5), dp(5) - radius: dp(100)
--- a/cagou/kv/cagou_widget.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import C cagou.core.constants.Const - - -<HeaderChoice>: - canvas.before: - Color: - rgba: 1, 1, 1, 1 - BorderImage: - pos: self.pos - size: self.size - source: 'atlas://data/images/defaulttheme/button' - size_hint_y: None - height: dp(44) - spacing: dp(20) - padding: dp(5), dp(3), dp(10), dp(3) - -<HeaderChoiceWidget>: - ActionIcon: - plugin_info: root.plugin_info - size_hint: None, 1 - width: self.height - Label: - size_hint: None, 1 - text: root.plugin_info['name'] - color: 1, 1, 1, 1 - bold: True - size: self.texture_size - halign: "center" - valign: "middle" - -<HeaderChoiceExtraMenu>: - ActionSymbol: - symbol: "dot-3-vert" - size_hint: None, 1 - width: self.height - Label: - size_hint: None, 1 - text: _("extra") - color: 1, 1, 1, 1 - bold: True - size: self.texture_size - halign: "center" - valign: "middle" - -<HeaderWidgetSelector>: - size_hint: None, None - auto_width: False - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - -<CagouWidget>: - header_box: header_box - orientation: "vertical" - BoxLayout: - id: header_box - size_hint: 1, None - height: dp(32) - spacing: dp(3) - padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 - HeaderWidgetCurrent: - plugin_info: root.plugin_info - size_hint: None, 1 - width: self.height - on_release: root.selector.open(self)
--- a/cagou/kv/common.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,164 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -<NotifLabel>: - background_color: app.c_sec_light - size_hint: None, None - text_size: None, root.height - padding_x: sp(5) - size: self.texture_size - bold: True - canvas.before: - Color: - # self.background_color doesn't seem initialized correctly on startup - # (maybe a Kivy bug? to be checked), thus we use the "or" below - rgb: self.background_color or app.c_sec_light - Ellipse: - size: self.size - pos: self.pos - - -<ContactItem>: - size_hint: None, None - width: self.base_width - height: self.minimum_height - orientation: 'vertical' - avatar: avatar - avatar_layout: avatar_layout - FloatLayout: - id: avatar_layout - size_hint: 1, None - height: dp(60) - Avatar: - id: avatar - pos_hint: {'x': 0, 'y': 0} - data: root.data.get('avatar') - allow_stretch: True - BoxLayout: - id: label_box - size_hint: 1, None - height: self.minimum_height - Label: - size_hint: 1, None - height: self.font_size + sp(5) - text_size: self.size - shorten: True - shorten_from: "right" - text: root.data.get('nick', root.jid.node or root.jid) - bold: True - valign: 'middle' - halign: 'center' - - -<JidItem>: - size_hint: 1, None - height: dp(68) - avatar: avatar - padding: 0, dp(2), 0, dp(2) - canvas.before: - Color: - rgba: self.bg_color - Rectangle: - pos: self.pos - size: self.size - Image: - id: avatar - size_hint: None, None - size: dp(64), dp(64) - Label: - size_hint: 1, 1 - text_size: self.size - color: root.color - bold: True - text: root.jid - halign: 'left' - valign: 'middle' - padding_x: dp(5) - -<JidToggle>: - canvas.before: - Color: - rgba: self.selected_color if self.state == 'down' else self.bg_color - Rectangle: - pos: self.pos - size: self.size - -<Symbol>: - width: dp(35) - height: dp(35) - font_name: app.expand('{media}/fonts/fontello/font/fontello.ttf') - text_size: self.size - font_size: dp(30) - halign: 'center' - valign: 'middle' - bg_color: 0, 0, 0, 0 - canvas.before: - Color: - rgba: self.bg_color - Rectangle: - pos: self.pos - size: self.size - -<SymbolLabel>: - size_hint: None, 1 - width: self.minimum_width - symbol_wid: symbol_wid - label: label - Symbol: - id: symbol_wid - size_hint: None, 1 - symbol: root.symbol - color: root.color - Label: - id: label - size_hint: None, 1 - text_size: None, root.height - size: self.texture_size - padding_x: dp(5) - valign: 'middle' - text: root.text - bold: root.bold - -<SymbolToggleLabel>: - color: 0, 0, 0, 1 - canvas.before: - Color: - rgba: app.c_sec_light if self.state == 'down' else (0, 0, 0, 0) - RoundedRectangle: - pos: self.pos - size: self.size - -<ActionSymbol>: - bg_color: 0, 0, 0, 0 - color: app.c_sec_light - -<SizedImage>: - size_hint: None, None - - -<JidSelectorCategoryLayout>: - size_hint: 1, None - height: self.minimum_height - spacing: 0 - -<JidSelector>: - layout: layout - StackLayout: - id: layout - size_hint: 1, None - height: self.minimum_height - spacing: 0
--- a/cagou/kv/common_widgets.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,65 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -<ItemWidget>: - size_hint: None, None - width: self.base_width - height: self.minimum_height - orientation: 'vertical' - - -<DeviceWidget>: - Symbol: - size_hint: 1, None - height: dp(80) - font_size: dp(40) - symbol: root.get_symbol() - color: 0, 0, 0, 1 - Label: - size_hint: None, None - width: dp(100) - font_size: sp(14) - text_size: dp(95), None - size: self.texture_size - text: root.name - halign: 'center' - - -<CategorySeparator>: - size_hint: 1, None - height: sp(35) - bold: True - font_size: sp(20) - color: app.c_sec - -<ImageViewer>: - do_rotation: False - AsyncImage: - source: root.source - allow_stretch: True, - - -<ImagesGallery>: - carousel: carousel - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - Carousel: - id: carousel
--- a/cagou/kv/dialog.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,95 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import _ sat.core.i18n._ - - -<MessageDialog>: - orientation: "vertical" - spacing: dp(5) - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - font_size: sp(20) - padding: dp(5), dp(10) - color: 1, 1, 1, 1 - text: root.title - halign: "center" - italic: True - bold: True - Label: - text: root.message - text_size: root.width, None - size: self.texture_size - padding: dp(25), 0 - font_size: sp(20) - color: 1, 1, 1, 1 - Button: - size_hint: 1, None - height: dp(50) - background_color: 0.33, 1.0, 0.0, 1 - text: _("Close") - bold: True - on_release: root.close_cb() - - -<ConfirmDialog>: - orientation: "vertical" - spacing: dp(5) - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - font_size: sp(20) - padding: dp(5), dp(10) - color: 1, 1, 1, 1 - text: root.title - halign: "center" - italic: True - bold: True - Label: - text: root.message - text_size: root.width, None - size: self.texture_size - padding: dp(25), 0 - font_size: sp(20) - color: 1, 1, 1, 1 - Button: - size_hint: 1, None - height: dp(50) - background_color: 0.33, 1.0, 0.0, 1 - text: _("Yes") - bold: True - on_release: root.yes_cb() if root.yes_cb is not None else None - Button: - size_hint: 1, None - height: dp(50) - text: _("No") - bold: True - on_release: root.no_cb() if root.no_cb is not None else None
--- a/cagou/kv/menu.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,152 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import _ sat.core.i18n._ -#:import C cagou.core.constants.Const - -<AboutContent>: - text_size: self.size - color: 1, 1, 1, 1 - halign: "center" - valign: "middle" - -<AboutPopup>: - title_align: "center" - size_hint: 0.8, 0.8 - -<ExtraMenuItem>: - size_hint: 1, None - height: dp(30) - -<ExtraSideMenu>: - bg_color: 0.23, 0.23, 0.23, 1 - ExtraMenuItem: - text: _("About") - on_press: root.on_about() - Widget: - # to push content to the top - -<TransferMenu>: - items_layout: items_layout - orientation: "vertical" - bg_color: app.c_prim - size_hint: 1, 0.5 - padding: [app.MARGIN_LEFT, 3, app.MARGIN_RIGHT, 0] - spacing: dp(5) - transfer_info: transfer_info - upload_btn: upload_btn - on_encrypted: self.update_transfer_info() - canvas.after: - Color: - rgba: app.c_prim_dark - Line: - points: 0, self.y + self.height, self.width + self.x, self.y + self.height - width: 1 - BoxLayout: - size_hint: 1, None - height: dp(50) - spacing: dp(10) - Widget: - SymbolToggleLabel - id: upload_btn - symbol: "upload" - text: _(u"upload") - group: "transfer" - state: "down" - on_state: root.update_transfer_info() - SymbolToggleLabel - id: send_btn - symbol: "loop-alt" - text: _(u"send") - group: "transfer" - Widget: - Label: - id: transfer_info - size_hint: 1, None - padding: 0, dp(5) - markup: True - text_size: self.width, None - size: self.texture_size - halign: 'center' - canvas.before: - Color: - rgba: app.c_prim_dark - RoundedRectangle: - pos: self.pos - size: self.size - ScrollView: - do_scroll_x: False - StackLayout: - size_hint: 1, None - padding: 20, 0 - spacing: 15, 5 - id: items_layout - -<TransferItem>: - orientation: "vertical" - size_hint: None, None - size: dp(50), dp(90) - IconButton: - source: root.plug_info['icon_medium'] - allow_stretch: True - size_hint: 1, None - height: dp(50) - Label: - color: 0, 0, 0, 1 - text: root.plug_info['name'] - text_size: self.size - halign: "center" - valign: "top" - - -<SideMenu>: - orientation: "vertical" - size_hint: self.size_hint_close - canvas.before: - Color: - rgba: self.bg_color - Rectangle: - pos: self.pos - size: self.size - - -<EntitiesSelectorMenu>: - bg_color: 0, 0, 0, 0.9 - filter_input: filter_input - layout: layout - callback_on_close: True - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - padding: dp(5), dp(5) - color: 1, 1, 1, 1 - text: root.instructions - halign: "center" - TextInput: - id: filter_input - size_hint: 1, None - height: dp(32) - multiline: False - hint_text: _(u"enter filter here") - ScrollView: - size_hint: 1, 1 - BoxLayout: - id: layout - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height - spacing: dp(5)
--- a/cagou/kv/profile_manager.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,197 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -<ProfileManager>: - Label: - size_hint: 1, None - text_size: root.width, None - width: self.texture_size[0] - height: self.texture_size[1] + dp(20) - text: "Profile Manager" - halign: "center" - bold: True - -<PMLabel@Label>: - size_hint: 1, None - height: sp(30) - -<PMInput@TextInput>: - multiline: False - size_hint: 1, None - height: sp(30) - write_tab: False - -<PMButton@Button>: - size_hint: 1, None - height: dp(40) - - -<NewProfileScreen>: - profile_name: profile_name - jid: jid - password: password - - BoxLayout: - orientation: "vertical" - - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - text: "Creation of a new profile" - halign: "center" - Label: - text: root.error_msg - bold: True - size_hint: 1, None - height: dp(40) - color: 1,0,0,1 - GridLayout: - cols: 2 - - PMLabel: - text: "Profile name" - PMInput: - id: profile_name - - PMLabel: - text: "JID" - PMInput: - id: jid - - PMLabel: - text: "Password" - PMInput: - id: password - password: True - - Widget: - size_hint: 1, None - height: dp(50) - - Widget: - size_hint: 1, None - height: dp(50) - - PMButton: - text: "OK" - on_press: root.doCreate() - - PMButton: - text: "Cancel" - on_press: - root.pm.screen_manager.transition.direction = 'right' - root.pm.screen_manager.current = 'profiles' - - Widget: - - -<DeleteProfilesScreen>: - BoxLayout: - orientation: "vertical" - - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - text: "Are you sure you want to delete the following profiles?" - halign: "center" - - Label: - size_hint: 1, None - text_size: root.width, None - height: self.texture_size[1] + dp(60) - width: self.texture_size[0] - halign: "center" - # for now we only handle single selection - text: u'\n'.join([i.text for i in [root.pm.selected]]) if root.pm.selected else u'' - bold: True - - Label: - size_hint: 1, None - text_size: root.width, dp(30) - height: self.texture_size[1] - text: u'/!\\ WARNING: this operation is irreversible' - color: 1,0,0,1 - bold: True - halign: "center" - valign: "top" - GridLayout: - cols: 2 - PMButton: - text: "Delete" - on_press: root.do_delete() - - PMButton: - text: "Cancel" - on_press: - root.pm.screen_manager.transition.direction = 'right' - root.pm.screen_manager.current = 'profiles' - - -<ProfilesScreen>: - layout: layout - BoxLayout: - orientation: 'vertical' - - Label: - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - text: "Select a profile or create a new one" - halign: "center" - - GridLayout: - cols: 2 - size_hint: 1, None - height: dp(40) - Button: - text: "New" - on_press: - root.pm.screen_manager.transition.direction = 'left' - root.pm.screen_manager.current = 'new_profile' - Button: - disabled: not root.pm.selected - text: "Delete" - on_press: - root.pm.screen_manager.transition.direction = 'left' - root.pm.screen_manager.current = 'delete_profiles' - ScrollView - BoxLayout: - size_hint: 1, None - height: self.minimum_height - orientation: "vertical" - id: layout - Button - text: "Connect" - size_hint: 1, None - height: dp(40) - disabled: not root.pm.selected - on_press: root.pm._on_connect_profiles() - - -<ProfileItem>: - size_hint: 1, None - background_normal: "" - background_down: "" - deselected_color: (1,1,1,1) if self.index%2 else (0.87,0.87,0.87,1) - selected_color: 0.67,1.0,1.0,1 - selected: self.state == 'down' - color: 0,0,0,1 - background_color: self.selected_color if self.selected else self.deselected_color - on_press: self.ps.pm.select_profile(self) - height: dp(30)
--- a/cagou/kv/root_widget.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,128 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import IconButton cagou.core.common.IconButton -#:import C cagou.core.constants.Const - -# <NotifIcon>: -# source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") -# size_hint: None, None -# size: self.texture_size - -<Note>: - text: self.message - text_size: self.parent.size if self.parent else (100, 100) - halign: 'center' - padding_x: dp(5) - shorten: True - shorten_from: 'right' - -<NoteDrop>: - orientation: 'horizontal' - size_hint: 1, None - height: max(label.height, dp(45)) - symbol: symbol - canvas.before: - BorderImage: - pos: self.pos - size: self.size - source: 'atlas://data/images/defaulttheme/button' - Widget: - size_hint: None, 1 - width: dp(20) - Symbol: - id: symbol - size_hint: None, 1 - width: dp(30) - padding_y: dp(10) - valign: 'top' - haligh: 'right' - symbol: root.symbol or root.level - color: - C.COLOR_PRIM_LIGHT if root.symbol is None else \ - {C.XMLUI_DATA_LVL_INFO: app.c_prim_light,\ - C.XMLUI_DATA_LVL_WARNING: C.COLOR_WARNING,\ - C.XMLUI_DATA_LVL_ERROR: C.COLOR_ERROR}[root.level] - Label: - id: label - size_hint: 1, None - color: 1, 1, 1, 1 - text: root.message - text_size: self.width, None - halign: 'center' - size: self.texture_size - padding: dp(2), dp(10) - -<NotesDrop>: - clear_btn: clear_btn.__self__ - auto_width: False - size_hint: 0.9, None - size_hint_max_x: dp(400) - canvas.before: - Color: - rgba: 0.8, 0.8, 0.8, 1 - Rectangle: - pos: self.pos - size: self.size - Button: - id: clear_btn - text: "clear" - bold: True - size_hint: 1, None - height: dp(50) - on_release: del root.notes[:]; root.dismiss() - -<RootHeadWidget>: - manager: manager - notifs_icon: notifs_icon - size_hint: 1, None - height: self.HEIGHT - padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 - IconButton: - source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_48.png") - allow_stretch: True - size_hint: None, None - pos_hint: {'center_y': .5} - height: dp(25) - width: dp(35) if root.notes else 0 - opacity: 1 if root.notes else 0 - on_release: root.notes_drop.open(self) if root.notes else None - ScreenManager: - id: manager - NotifsIcon: - id: notifs_icon - allow_stretch: True - source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_miroir_48.png") - size_hint: None, None - pos_hint: {'center_y': .5} - height: dp(25) - width: dp(35) if self.notifs else 0 - opacity: 1 if self.notifs else 0 - Symbol: - id: disconnected_icon - size_hint: None, 1 - pos_hint: {'center_y': .5} - font_size: dp(23) - width: 0 if app.connected else dp(30) - opacity: 0 if app.connected else 1 - symbol: "plug" - color: 0.80, 0.0, 0.0, 1 - -<CagouRootWidget>: - root_body: root_body - RootBody: - id: root_body - orientation: "vertical"
--- a/cagou/kv/share_widget.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,114 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import Path pathlib.Path -#:import _ sat.core.i18n._ -#:import C cagou.core.constants.Const - - -<ShareWidget>: - preview_box: preview_box - orientation: 'vertical' - Label: - size_hint: 1, None - text_size: self.size - halign: 'center' - text: _("share") - height: self.font_size + dp(5) - bold: True - font_size: '35sp' - BoxLayout: - id: preview_box - size_hint: 1, None - height: self.minimum_height - orientation: 'vertical' - text: str(root.data) - Label: - size_hint: 1, None - text_size: self.size - halign: 'center' - text: _("with") - height: self.font_size + dp(5) - bold: True - font_size: '25sp' - JidSelector: - on_select: root.on_select(args[1]) - Button: - size_hint: 1, None - height: C.BTN_HEIGHT - text: _("cancel") - on_press: app.host.close_ui() - - -<TextPreview>: - size_hint: 1, None - height: min(data.height, dp(100)) - ScrollView - Label: - id: data - size_hint: 1, None - text: root.text - text_size: self.width, None - size: self.texture_size - font_size: sp(20) - padding_x: dp(10) - padding_y: dp(5) - halign: 'center' - canvas.before: - Color: - rgba: 0.95, 0.95, 0.95, 1 - Rectangle: - pos: self.pos - size: self.size - -<ImagePreview>: - reduce_layout: reduce_layout - reduce_checkbox: reduce_checkbox - size_hint: 1, None - height: dp(120) - orientation: "vertical" - Image: - source: root.path - BoxLayout - id: reduce_layout - size_hint: 1, None - padding_y: None - opacity: 0 - height: 0 - Widget: - CheckBox: - id: reduce_checkbox - size_hint: None, 1 - width: dp(20) - active: True - Label: - size_hint: None, None - text: _("reduce image size") - text_size: None, None - size: self.texture_size - padding_x: dp(10) - font_size: sp(15) - Widget: - -<GenericPreview>: - size_hint: 1, None - height: dp(100) - Widget: - SymbolButtonLabel: - symbol: "doc" - text: Path(root.path).name - Widget: -
--- a/cagou/kv/simple_xhtml.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,31 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import C cagou.core.constants.Const - - -<SimpleXHTMLWidget>: - size_hint: 1, None - height: self.minimum_height - -<SimpleXHTMLWidgetEscapedText>: - size_hint: 1, None - text_size: self.width, None - height: self.texture_size[1] if self.text else 0 - -<SimpleXHTMLWidgetText>: - size_hint: None, None - size: self.texture_size
--- a/cagou/kv/widgets_handler.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,43 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -<WHWrapper>: - _sp_top_y: self.y + self.height - self.sp_size - padding: self.split_size + self.split_margin, self.split_size + self.split_margin, 0, 0 - - canvas.before: - # 2 lines to indicate the split zones - Color: - rgba: self.split_color if self._split != 'left' else self.split_color_del if self._split_del else self.split_color_move - Rectangle: - pos: self.pos - size: self.split_size, self.height - Color: - rgba: self.split_color if self._split != 'top' else self.split_color_del if self._split_del else self.split_color_move - Rectangle: - pos: self.x, self.y + self.height - self.split_size - size: self.width, self.split_size - # 3 dots to indicate the main split points - Color: - rgba: 0, 0, 0, 1 - Point: - # left - points: self.x + self.sp_size, self.y + self.height / 2 - self.sp_size - self.sp_space, self.x + self.sp_size, self.y + self.height / 2, self.x + self.sp_size, self.y + self.height / 2 + self.sp_size + self.sp_space - pointsize: self.sp_size - Point: - # top - points: self.x + self.width / 2 - self.sp_size - self.sp_space, root._sp_top_y, self.x + self.width / 2, root._sp_top_y, self.x + self.width / 2 + self.sp_size + self.sp_space, root._sp_top_y - pointsize: self.sp_size
--- a/cagou/kv/xmlui.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,206 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:set common_height 30 -#:set button_height 50 - - -<EmptyWidget,StringWidget,PasswordWidget,JidInputWidget>: - size_hint: 1, None - height: dp(common_height) - - -<TextWidget,LabelWidget,JidWidget>: - size_hint: 1, 1 - size_hint_min_y: max(dp(common_height), self.texture_size[1]) - text_size: self.width, None - - -<StringWidget,PasswordWidget,IntWidget>: - multiline: False - background_normal: app.expand('atlas://data/images/defaulttheme/textinput') - - -<TextBoxWidget>: - multiline: True - height: dp(common_height) * 5 - - -<ButtonWidget>: - size_hint: 1, None - height: dp(button_height) - - -<BoolWidget>: - size_hint: 1, 1 - - -<DividerWidget>: - size_hint: 1, None - height: dp(12) - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Line - points: self.x, self.y + dp(5), self.x + self.width, self.y + dp(5) - width: dp(2) - - -<ListWidgetItem>: - size_hint_y: None - height: dp(button_height) - - -<ListWidget>: - size_hint: 1, None - layout: layout - height: min(layout.minimum_height, dp(250)) - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - BoxLayout: - id: layout - size_hint: 1, None - height: self.minimum_height - orientation: "vertical" - padding: dp(10) - - -<AdvancedListRow>: - orientation: "horizontal" - size_hint: 1, None - height: self.minimum_height - canvas.before: - Color: - rgba: app.c_prim_light if self.global_index%2 else app.c_prim_dark - Rectangle: - pos: self.pos - size: self.size - canvas.after: - Color: - rgba: 0, 0, 1, 0.5 if self.selected else 0 - Rectangle: - pos: self.pos - size: self.size - - -<AdvancedListContainer>: - size_hint: 1, None - height: self.minimum_height - orientation: "vertical" - - -<VerticalContainer>: - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height - -<PairsContainer>: - cols: 2 - size_hint: 1, None - height: self.minimum_height - padding: dp(10) - - -<TabsContainer>: - size_hint: 1, None - height: dp(200) - -<TabsPanelContainer>: - layout: layout - ScrollView: - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - canvas.before: - Color: - rgba: 1, 1, 1, 1 - Rectangle: - pos: self.pos - size: self.size - BoxLayout: - id: layout - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height - canvas.before: - Color: - rgba: 1, 1, 1, 1 - Rectangle: - pos: self.pos - size: self.size - - -<FormButton>: - size_hint: 1, None - height: dp(button_height) - color: 0, 0, 0, 1 - bold: True - - -<SubmitButton>: - text: _(u"Submit") - background_normal: '' - background_color: 0.33, 0.67, 0.0, 1 - - -<CancelButton>: - text: _(u"Cancel") - color: 1, 1, 1, 1 - bold: False - - -<SaveButton>: - text: _(u"Save") - background_normal: '' - background_color: 0.33, 0.67, 0.0, 1 - - -<FileDialog>: - orientation: "vertical" - message: message - file_chooser: file_chooser - Label: - id: message - size_hint: 1, None - text_size: root.width, None - size: self.texture_size - FileChooserListView: - id: file_chooser - Button: - size_hint: 1, None - height: dp(50) - text: "choose" - on_release: root.on_select(file_chooser.selection) - Button: - size_hint: 1, None - height: dp(50) - text: "cancel" - on_release: root.onCancel() - - -<XMLUIPanel>: - size_hint: 1, 1 - layout: layout - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - BoxLayout: - id: layout - orientation: "vertical" - size_hint: 1, None - padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 - height: self.minimum_height
--- a/cagou/plugins/plugin_transfer_android_gallery.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -import sys -import tempfile -import os -import os.path -if sys.platform=="android": - from jnius import autoclass - from android import activity, mActivity - - Intent = autoclass('android.content.Intent') - OpenableColumns = autoclass('android.provider.OpenableColumns') - PHOTO_GALLERY = 1 - RESULT_OK = -1 - - - -PLUGIN_INFO = { - "name": _("gallery"), - "main": "AndroidGallery", - "platforms": ('android',), - "external": True, - "description": _("upload a photo from photo gallery"), - "icon_medium": "{media}/icons/muchoslava/png/gallery_50.png", -} - - -class AndroidGallery: - - def __init__(self, callback, cancel_cb): - self.callback = callback - self.cancel_cb = cancel_cb - activity.bind(on_activity_result=self.on_activity_result) - intent = Intent() - intent.setType('image/*') - intent.setAction(Intent.ACTION_GET_CONTENT) - mActivity.startActivityForResult(intent, PHOTO_GALLERY); - - def on_activity_result(self, requestCode, resultCode, data): - activity.unbind(on_activity_result=self.on_activity_result) - # TODO: move file dump to a thread or use async callbacks during file writting - if requestCode == PHOTO_GALLERY and resultCode == RESULT_OK: - if data is None: - log.warning("No data found in activity result") - self.cancel_cb(self, None) - return - uri = data.getData() - - # we get filename in the way explained at https://developer.android.com/training/secure-file-sharing/retrieve-info.html - cursor = mActivity.getContentResolver().query(uri, None, None, None, None ) - name_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.moveToFirst() - filename = cursor.getString(name_idx) - - # we save data in a temporary file that we send to callback - # the file will be removed once upload is done (or if an error happens) - input_stream = mActivity.getContentResolver().openInputStream(uri) - tmp_dir = tempfile.mkdtemp() - tmp_file = os.path.join(tmp_dir, filename) - def cleaning(): - os.unlink(tmp_file) - os.rmdir(tmp_dir) - log.debug('temporary file cleaned') - buff = bytearray(4096) - with open(tmp_file, 'wb') as f: - while True: - ret = input_stream.read(buff, 0, 4096) - if ret != -1: - f.write(buff) - else: - break - input_stream.close() - self.callback(tmp_file, cleaning) - else: - self.cancel_cb(self, None)
--- a/cagou/plugins/plugin_transfer_android_photo.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -import sys -import os -import os.path -import time -if sys.platform == "android": - from plyer import camera - from jnius import autoclass - Environment = autoclass('android.os.Environment') -else: - import tempfile - - -PLUGIN_INFO = { - "name": _("take photo"), - "main": "AndroidPhoto", - "platforms": ('android',), - "external": True, - "description": _("upload a photo from photo application"), - "icon_medium": "{media}/icons/muchoslava/png/camera_off_50.png", -} - - -class AndroidPhoto(object): - - def __init__(self, callback, cancel_cb): - self.callback = callback - self.cancel_cb = cancel_cb - filename = time.strftime("%Y-%m-%d_%H:%M:%S.jpg", time.gmtime()) - tmp_dir = self.get_tmp_dir() - tmp_file = os.path.join(tmp_dir, filename) - log.debug("Picture will be saved to {}".format(tmp_file)) - camera.take_picture(tmp_file, self.callback) - # we don't delete the file, as it is nice to keep it locally - - def get_tmp_dir(self): - if sys.platform == "android": - dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() - return dcim_path - else: - return tempfile.mkdtemp()
--- a/cagou/plugins/plugin_transfer_android_video.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -import sys -import os -import os.path -import time -if sys.platform == "android": - from plyer import camera - from jnius import autoclass - Environment = autoclass('android.os.Environment') -else: - import tempfile - - -PLUGIN_INFO = { - "name": _("take video"), - "main": "AndroidVideo", - "platforms": ('android',), - "external": True, - "description": _("upload a video from video application"), - "icon_medium": "{media}/icons/muchoslava/png/film_camera_off_50.png", -} - - -class AndroidVideo(object): - - def __init__(self, callback, cancel_cb): - self.callback = callback - self.cancel_cb = cancel_cb - filename = time.strftime("%Y-%m-%d_%H:%M:%S.mpg", time.gmtime()) - tmp_dir = self.get_tmp_dir() - tmp_file = os.path.join(tmp_dir, filename) - log.debug("Video will be saved to {}".format(tmp_file)) - camera.take_video(tmp_file, self.callback) - # we don't delete the file, as it is nice to keep it locally - - def get_tmp_dir(self): - if sys.platform == "android": - dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() - return dcim_path - else: - return tempfile.mkdtemp()
--- a/cagou/plugins/plugin_transfer_file.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import expanduser os.path.expanduser -#:import platform kivy.utils.platform - - -<FileChooserBox>: - orientation: "vertical" - FileChooserListView: - id: filechooser - path: root.default_path - Button: - text: "choose" - size_hint: 1, None - height: dp(50) - on_release: root.callback(filechooser.selection) - Button: - text: "cancel" - size_hint: 1, None - height: dp(50) - on_release: root.cancel_cb()
--- a/cagou/plugins/plugin_transfer_file.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import threading -import sys -from functools import partial -from sat.core import log as logging -from sat.core.i18n import _ -from kivy.uix.boxlayout import BoxLayout -from kivy import properties -from kivy.clock import Clock -from plyer import filechooser, storagepath - -log = logging.getLogger(__name__) - - -PLUGIN_INFO = { - "name": _("file"), - "main": "FileTransmitter", - "description": _("transmit a local file"), - "icon_medium": "{media}/icons/muchoslava/png/fichier_50.png", -} - - -class FileChooserBox(BoxLayout): - callback = properties.ObjectProperty() - cancel_cb = properties.ObjectProperty() - default_path = properties.StringProperty() - - -class FileTransmitter(BoxLayout): - callback = properties.ObjectProperty() - cancel_cb = properties.ObjectProperty() - native_filechooser = True - default_path = storagepath.get_home_dir() - - def __init__(self, *args, **kwargs): - if sys.platform == 'android': - self.native_filechooser = False - self.default_path = storagepath.get_downloads_dir() - - super(FileTransmitter, self).__init__(*args, **kwargs) - - if self.native_filechooser: - thread = threading.Thread(target=self._native_file_chooser) - thread.start() - else: - self.add_widget(FileChooserBox(default_path = self.default_path, - callback=self.on_files, - cancel_cb=partial(self.cancel_cb, self))) - - def _native_file_chooser(self, *args, **kwargs): - title=_("Please select a file to upload") - files = filechooser.open_file(title=title, - path=self.default_path, - multiple=False, - preview=True) - # we want to leave the thread when calling on_files, so we use Clock - Clock.schedule_once(lambda *args: self.on_files(files=files), 0) - - def on_files(self, files): - if files: - self.callback(files[0]) - else: - self.cancel_cb(self)
--- a/cagou/plugins/plugin_transfer_voice.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,72 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import _ sat.core.i18n._ -#:import IconButton cagou.core.common.IconButton - - -<VoiceRecorder>: - orientation: "vertical" - counter: counter - Label: - size_hint: 1, 0.4 - text_size: self.size - halign: 'center' - valign: 'top' - text: _(u"Push the microphone button to start the record, then push it again to stop it.\nWhen you are satisfied, click on the transmit button") - Label: - id: counter - size_hint: 1, None - height: dp(60) - bold: True - font_size: sp(40) - text_size: self.size - text: u"{}:{:02}".format(root.time//60, root.time%60) - halign: 'center' - valign: 'middle' - BoxLayout: - size_hint: 1, None - height: dp(60) - spacing: dp(5) - Widget - IconButton: - source: app.expand("{media}/icons/muchoslava/png/") + ("micro_on_50.png" if root.recording else "micro_off_50.png") - allow_stretch: True - size_hint: None, None - size: dp(60), dp(60) - on_release: root.switch_recording() - IconButton: - opacity: 0 if root.recording or not root.time and not root.playing else 1 - source: app.expand("{media}/icons/muchoslava/png/") + ("stop_50.png" if root.playing else "play_50.png") - allow_stretch: True - size_hint: None, None - size: dp(60), dp(60) - on_release: root.play_record() - Widget - Widget: - size_hint: 1, None - height: dp(50) - Button: - text: _("transmit") - size_hint: 1, None - height: dp(50) - on_release: root.callback(root.audio.file_path) - Button: - text: _("cancel") - size_hint: 1, None - height: dp(50) - on_release: root.cancel_cb(root) - Widget
--- a/cagou/plugins/plugin_transfer_voice.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from kivy.uix.boxlayout import BoxLayout -import sys -import time -from kivy.clock import Clock -from kivy import properties -if sys.platform == "android": - from plyer import audio - - -PLUGIN_INFO = { - "name": _("voice"), - "main": "VoiceRecorder", - "platforms": ["android"], - "description": _("transmit a voice record"), - "icon_medium": "{media}/icons/muchoslava/png/micro_off_50.png", - "android_permissons": ["RECORD_AUDIO"], -} - - -class VoiceRecorder(BoxLayout): - callback = properties.ObjectProperty() - cancel_cb = properties.ObjectProperty() - recording = properties.BooleanProperty(False) - playing = properties.BooleanProperty(False) - time = properties.NumericProperty(0) - - def __init__(self, **kwargs): - super(VoiceRecorder, self).__init__(**kwargs) - self._started_at = None - self._counter_timer = None - self._play_timer = None - self.record_time = None - self.audio = audio - self.audio.file_path = "/sdcard/cagou_record.3gp" - - def _update_timer(self, dt): - self.time = int(time.time() - self._started_at) - - def switch_recording(self): - if self.playing: - self._stop_playing() - if self.recording: - try: - audio.stop() - except Exception as e: - # an exception can happen if record is pressed - # repeatedly in a short time (not a normal use) - log.warning("Exception on stop: {}".format(e)) - self._counter_timer.cancel() - self.time = self.time + 1 - else: - audio.start() - self._started_at = time.time() - self.time = 0 - self._counter_timer = Clock.schedule_interval(self._update_timer, 1) - - self.recording = not self.recording - - def _stop_playing(self, __=None): - if self.record_time is None: - log.error("_stop_playing should no be called when record_time is None") - return - audio.stop() - self.playing = False - self.time = self.record_time - if self._counter_timer is not None: - self._counter_timer.cancel() - - def play_record(self): - if self.recording: - return - if self.playing: - self._stop_playing() - else: - try: - audio.play() - except Exception as e: - # an exception can happen in the same situation - # as for audio.stop() above (i.e. bad record) - log.warning("Exception on play: {}".format(e)) - self.time = 0 - return - - self.playing = True - self.record_time = self.time - Clock.schedule_once(self._stop_playing, self.time + 1) - self._started_at = time.time() - self.time = 0 - self._counter_timer = Clock.schedule_interval(self._update_timer, 0.5)
--- a/cagou/plugins/plugin_wid_blog.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,168 +0,0 @@ -# desktop/mobile frontend for Libervia XMPP client -# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import date_fmt sat.tools.common.date_utils.date_fmt - -<SearchButton>: - size_hint: None, 1 - symbol: "search" - width: dp(30) - font_size: dp(25) - color: 0.4, 0.4, 0.4, 1 - - -<NewPostButton>: - size_hint: None, 1 - symbol: "pencil" - width: dp(30) - font_size: dp(25) - color: 0.4, 0.4, 0.4, 1 - -<NewPosttMenu>: - padding: dp(20) - spacing: dp(10) - e2ee: e2ee_checkbox - Label: - size_hint: 1, None - color: 1, 1, 1, 1 - text: _("Publish a new post on {node} node of {service}").format(node=root.blog.node or "personal blog", service=root.blog.service or root.blog.profile) - text_size: root.width, None - size: self.texture_size - halign: "center" - bold: True - TextInput: - id: title - size_hint: 1, None - height: sp(30) - hint_text: _("title of your post (optional)") - TextInput: - id: content - size_hint: 1, None - height: sp(300) - hint_text: _("body of your post (markdown syntax allowed)") - BoxLayout - id: e2ee - size_hint: 1, None - padding_y: None - height: dp(25) - Widget: - CheckBox: - id: e2ee_checkbox - size_hint: None, 1 - width: dp(20) - active: False - color: 1, 1, 1, 1 - Label: - size_hint: None, None - text: _("encrypt post") - text_size: None, None - size: self.texture_size - padding_x: dp(10) - font_size: sp(15) - color: 1, 1, 1, 1 - Widget: - Button: - size_hint: 1, None - height: sp(50) - text: _("publish") - on_release: root.publish(title.text, content.text, e2ee=e2ee_checkbox.active) - Widget: - - -<BlogPostAvatar>: - size_hint: None, None - size: dp(30), dp(30) - canvas.before: - Color: - rgba: (0.87,0.87,0.87,1) - RoundedRectangle: - radius: [dp(5)] - pos: self.pos - size: self.size - -<BlogPostWidget>: - size_hint: 1, None - avatar: avatar - header_box: header_box - height: self.minimum_height - orientation: "vertical" - Label: - color: 0, 0, 0, 1 - bold: True - font_size: root.title_font_size - text_size: None, None - size_hint: None, None - size: self.texture_size[0], self.texture_size[1] if root.blog_data.get("title") else 0 - opacity: 1 if root.blog_data.get("title") else 0 - padding: dp(5), 0 - text: root.blog_data.get("title", "") - BoxLayout: - id: header_box - size_hint: 1, None - height: dp(40) - BoxLayout: - orientation: 'vertical' - width: avatar.width - size_hint: None, 1 - BlogPostAvatar: - id: avatar - source: app.default_avatar - Label: - id: created_ts - color: (0, 0, 0, 1) - font_size: root.font_size - text_size: None, None - size_hint: None, None - size: self.texture_size - padding: dp(5), 0 - markup: True - valign: 'middle' - text: f"published on [b]{date_fmt(root.blog_data.get('published', 0), 'auto_day')}[/b]" - Symbol: - size_hint: None, None - height: created_ts.height - width: self.height - id: encrypted - symbol: 'lock-filled' if root.blog_data.get("encrypted") else 'lock-open' - font_size: created_ts.height - opacity: 1 if root.blog_data.get("encrypted") else 0 - color: 0.29,0.87,0.0,1 - SimpleXHTMLWidget: - size_hint: 1, None - height: self.minimum_height - xhtml: root.blog_data.get("content_xhtml") or self.escape(root.blog_data.get("content", "")) - color: (0, 0, 0, 1) - padding: dp(5), dp(5) - - -<Blog>: - float_layout: float_layout - orientation: 'vertical' - posts_widget: posts_widget - FloatLayout: - id: float_layout - ScrollView: - size_hint: 1, 1 - pos_hint: {'x': 0, 'y': 0} - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - BoxLayout: - id: posts_widget - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height - spacing: dp(10)
--- a/cagou/plugins/plugin_wid_blog.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 - -#desktop/mobile frontend for Libervia XMPP client -# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from functools import partial -import json -from typing import Any, Dict, Optional - -from kivy import properties -from kivy.metrics import sp -from kivy.uix.behaviors import ButtonBehavior -from kivy.uix.boxlayout import BoxLayout -from sat.core import log as logging -from sat.core.i18n import _ -from sat.tools.common import data_format -from sat_frontends.bridge.bridge_frontend import BridgeException -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.tools import jid - -from cagou import G -from cagou.core.menu import SideMenu - -from ..core import cagou_widget -from ..core.common import SymbolButton -from ..core.constants import Const as C -from ..core.image import Image - -log = logging.getLogger(__name__) - -PLUGIN_INFO = { - "name": _("blog"), - "main": "Blog", - "description": _("(micro)blog"), - "icon_symbol": "pencil", -} - - -class SearchButton(SymbolButton): - blog = properties.ObjectProperty() - - def on_release(self, *args): - self.blog.header_input.dispatch('on_text_validate') - - -class NewPostButton(SymbolButton): - blog = properties.ObjectProperty() - - def on_release(self, *args): - self.blog.show_new_post_menu() - - -class NewPosttMenu(SideMenu): - blog = properties.ObjectProperty() - size_hint_close = (1, 0) - size_hint_open = (1, 0.9) - - def _publish_cb(self, item_id: str) -> None: - G.host.add_note( - _("blog post published"), - _("your blog post has been published with ID {item_id}").format( - item_id=item_id - ) - ) - self.blog.load_blog() - - def _publish_eb(self, exc: BridgeException) -> None: - G.host.add_note( - _("Problem while publish blog post"), - _("Can't publish blog post at {node!r} from {service}: {problem}").format( - node=self.blog.node or G.host.ns_map.get("microblog"), - service=( - self.blog.service if self.blog.service - else G.host.profiles[self.blog.profile].whoami, - ), - problem=exc - ), - C.XMLUI_DATA_LVL_ERROR - ) - - def publish( - self, - title: str, - content: str, - e2ee: bool = False - ) -> None: - self.hide() - mb_data: Dict[str, Any] = {"content_rich": content} - if e2ee: - mb_data["encrypted"] = True - title = title.strip() - if title: - mb_data["title_rich"] = title - G.host.bridge.mb_send( - self.blog.service, - self.blog.node, - data_format.serialise(mb_data), - self.blog.profile, - callback=self._publish_cb, - errback=self._publish_eb, - ) - - -class BlogPostAvatar(ButtonBehavior, Image): - pass - - -class BlogPostWidget(BoxLayout): - blog_data = properties.DictProperty() - font_size = properties.NumericProperty(sp(12)) - title_font_size = properties.NumericProperty(sp(14)) - - -class Blog(quick_widgets.QuickWidget, cagou_widget.CagouWidget): - posts_widget = properties.ObjectProperty() - service = properties.StringProperty() - node = properties.StringProperty() - use_header_input = True - - def __init__(self, host, target, profiles): - quick_widgets.QuickWidget.__init__(self, G.host, target, profiles) - cagou_widget.CagouWidget.__init__(self) - search_btn = SearchButton(blog=self) - self.header_input_add_extra(search_btn) - new_post_btn = NewPostButton(blog=self) - self.header_input_add_extra(new_post_btn) - self.load_blog() - - def on_kv_post(self, __): - self.bind( - service=lambda __, value: self.load_blog(), - node=lambda __, value: self.load_blog(), - ) - - def on_header_wid_input(self): - text = self.header_input.text.strip() - # for now we only use text as node - self.node = text - - def show_new_post_menu(self): - """Show the "add a contact" menu""" - NewPosttMenu(blog=self).show() - - def _mb_get_cb(self, blog_data_s: str) -> None: - blog_data = json.loads(blog_data_s) - for item in blog_data["items"]: - self.posts_widget.add_widget(BlogPostWidget(blog_data=item)) - - def _mb_get_eb( - self, - exc: BridgeException, - ) -> None: - G.host.add_note( - _("Problem while getting blog data"), - _("Can't get blog for {node!r} at {service}: {problem}").format( - node=self.node or G.host.ns_map.get("microblog"), - service=self.service if self.service else G.host.profiles[self.profile].whoami, - problem=exc - ), - C.XMLUI_DATA_LVL_ERROR - ) - - def load_blog( - self, - ) -> None: - """Retrieve a blog and display it""" - extra = {} - self.posts_widget.clear_widgets() - G.host.bridge.mb_get( - self.service, - self.node, - 20, - [], - data_format.serialise(extra), - self.profile, - callback=self._mb_get_cb, - errback=self._mb_get_eb, - )
--- a/cagou/plugins/plugin_wid_chat.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,349 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import _ sat.core.i18n._ -#:import C cagou.core.constants.Const -#:import G cagou.G -#:import escape kivy.utils.escape_markup -#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget -#:import DelayedBoxLayout cagou.core.common_widgets.DelayedBoxLayout -#:import ScrollEffect kivy.effects.scroll.ScrollEffect -#:import CategorySeparator cagou.core.common_widgets.CategorySeparator - - -# Chat - - -<BaseAttachmentItem>: - size_hint: None, None - size: self.minimum_width, dp(50) - - -<AttachmentItem>: - canvas.before: - Color: - rgb: app.c_prim_dark - Line: - rounded_rectangle: self.x + dp(1), self.y + dp(1), self.width - dp(2), self.height - dp(2), 10 - Color: - rgb: app.c_sec_light - RoundedRectangle: - pos: self.x + dp(1), self.y + dp(1) - size: (self.width - dp(2)) * root.progress / 100, self.height - dp(2) - SymbolButtonLabel: - symbol: root.get_symbol(root.data) - color: 0, 0, 0, 1 - text: root.data.get('name', _('unnamed')) - bold: False - on_press: root.on_press() - - -<AttachmentImageItem>: - size: self.minimum_width, self.minimum_height - image: image - orientation: "vertical" - SizedImage: - id: image - anim_delay: -1 - source: "data/images/image-loading.gif" - - -<AttachmentImagesCollectionItem>: - cols: 2 - size_hint: None, None - size: dp(150), dp(150) - padding: dp(5) - spacing: dp(2) - canvas.before: - Color: - rgb: app.c_prim - RoundedRectangle: - radius: [dp(5)] - pos: self.pos - size: self.size - Color: - rgb: 0, 0, 0, 1 - Line: - rounded_rectangle: self.x, self.y, self.width, self.height, dp(5) - - -<AttachmentsLayout>: - attachments: self - size_hint: 1, None - height: self.minimum_height - spacing: dp(5) - - -<MessAvatar>: - size_hint: None, None - size: dp(30), dp(30) - canvas.before: - Color: - rgba: (0.87,0.87,0.87,1) - RoundedRectangle: - radius: [dp(5)] - pos: self.pos - size: self.size - - -<MessageWidget>: - size_hint: 1, None - avatar: avatar - delivery: delivery - mess_xhtml: mess_xhtml - right_part: right_part - header_box: header_box - height: self.minimum_height - BoxLayout: - orientation: 'vertical' - width: avatar.width - size_hint: None, 1 - MessAvatar: - id: avatar - source: root.mess_data.avatar['path'] if root.mess_data and root.mess_data.avatar else app.default_avatar - on_press: root.chat.add_nick(root.nick) - Widget: - # use to push the avatar on the top - size_hint: 1, 1 - BoxLayout: - size_hint: 1, None - orientation: 'vertical' - id: right_part - height: self.minimum_height - BoxLayout: - id: header_box - size_hint: 1, None - height: time_label.height if root.mess_type != C.MESS_TYPE_INFO else 0 - opacity: 1 if root.mess_type != C.MESS_TYPE_INFO else 0 - Label: - id: time_label - color: (0, 0, 0, 1) if root.own_mess else (0.55,0.55,0.55,1) - font_size: root.font_size - text_size: None, None - size_hint: None, None - size: self.texture_size - padding: dp(5), 0 - markup: True - valign: 'middle' - text: u"[b]{}[/b], {}".format(escape(root.nick), root.time_text) - Symbol: - size_hint_x: None - width: self.height - id: encrypted - symbol: 'lock-filled' if root.mess_data.encrypted else 'lock-open' - font_size: self.height - dp(3) - color: (1, 0, 0, 1) if not root.mess_data.encrypted and root.chat.encrypted else (0.55,0.55,0.55,1) - Label: - id: delivery - color: C.COLOR_BTN_LIGHT - font_size: root.font_size - text_size: None, None - size_hint: None, None - size: self.texture_size - padding: dp(5), 0 - # XXX: DejaVuSans font is needed as check mark is not in Roboto - # this can be removed when Kivy will be able to handle fallback mechanism - # which will allow us to use fonts with more unicode characters - font_name: "DejaVuSans" - text: u'' - SimpleXHTMLWidget: - id: mess_xhtml - size_hint: 1, None - height: self.minimum_height - xhtml: root.message_xhtml or self.escape(root.message or '') - color: (0.74,0.74,0.24,1) if root.mess_type == "info" else (0, 0, 0, 1) - padding: root.mess_padding - bold: True if root.mess_type == "info" else False - - -<AttachmentToSendItem>: - SymbolButton: - opacity: 0 if root.sending else 1 - size_hint: None, 1 - symbol: "cancel-circled" - on_press: root.parent.remove_widget(root) - - -<AttachmentsToSend>: - attachments: attachments_layout.attachments - reduce_checkbox: reduce_checkbox - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height if self.attachments.children else 0 - opacity: 1 if self.attachments.children else 0 - padding: [app.MARGIN_LEFT, dp(5), app.MARGIN_RIGHT, dp(5)] - canvas.before: - Color: - rgba: app.c_prim - Rectangle: - pos: self.pos - size: self.size - Label: - size_hint: 1, None - size: self.texture_size - text: _("attachments:") - bold: True - AttachmentsLayout: - id: attachments_layout - BoxLayout: - id: resize_box - size_hint: 1, None - opacity: 1 if root.show_resize else 0 - height: dp(25) if root.show_resize else 0 - Widget: - CheckBox: - id: reduce_checkbox - size_hint: None, 1 - width: dp(20) - active: True - Label: - size_hint: None, 1 - text: _("reduce images size") - text_size: None, None - size: self.texture_size - valign: "middle" - padding_x: dp(10) - font_size: sp(15) - Widget: - -<Chat>: - attachments_to_send: attachments_to_send - message_input: message_input - messages_widget: messages_widget - history_scroll: history_scroll - send_button_visible: G.local_platform.send_button_visible or bool(attachments_to_send.attachments.children) - ScrollView: - id: history_scroll - scroll_y: 0 - on_scroll_y: root.on_scroll(*args) - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(10) - effect_cls: ScrollEffect - DelayedBoxLayout: - id: messages_widget - size_hint_y: None - padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)] - spacing: dp(10) - height: self.minimum_height - orientation: 'vertical' - AttachmentsToSend: - id: attachments_to_send - MessageInputBox: - size_hint: 1, None - height: self.minimum_height - spacing: dp(10) - padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)] - message_input: message_input - MessageInputWidget: - id: message_input - size_hint: 1, None - height: min(self.minimum_height, dp(250)) - multiline: True - hint_text: _(u"Enter your message here") - on_text_validate: root.on_send(args[0]) - SymbolButton: - # "send" button, permanent visibility depends on platform - symbol: "forward" - size_hint: None, 1 - width: dp(30) if root.send_button_visible else 0 - opacity: 1 if root.send_button_visible else 0 - font_size: dp(25) - on_release: self.parent.send_text() - - -# Buttons added in header - -<TransferButton>: - size_hint: None, 1 - symbol: "plus-circled" - width: dp(30) - font_size: dp(25) - color: 0.4, 0.4, 0.4, 1 - -<MenuButton@Button> - size_hint_y: None - height: dp(30) - on_texture_size: self.parent.parent.width = max(self.parent.parent.width, self.texture_size[0] + dp(10)) - -<ExtraMenu>: - auto_width: False - MenuButton: - text: _("bookmarks") - on_release: root.select("bookmark") - MenuButton: - text: _("close") - on_release: root.select("close") - -<ExtraButton>: - size_hint: None, 1 - symbol: "dot-3-vert" - width: dp(30) - font_size: dp(25) - color: 0.4, 0.4, 0.4, 1 - on_release: self.chat.extra_menu.open(self) - -<EncryptionMainButton>: - size_hint: None, 1 - width: dp(30) - color: self.get_color() - symbol: self.get_symbol() - -<TrustManagementButton>: - symbol: "shield" - padding: dp(5), dp(10) - bg_color: app.c_prim_dark - size_hint: None, 1 - width: dp(30) - on_release: self.parent.dispatch("on_trust_release") - -<EncryptionButton>: - size_hint: None, None - width: self.parent.parent.best_width if self.parent is not None else 30 - height: dp(30) - on_best_width: self.parent.parent.best_width = max(self.parent.parent.best_width, args[1]) - Button: - text: root.text - size_hint: 1, 1 - padding: dp(5), dp(10) - color: 0, 0, 0, 1 - bold: root.bold - background_normal: app.expand('{media}/misc/borders/border_filled_black.png') - background_color: app.c_sec if root.selected else app.c_prim_dark - on_release: root.dispatch("on_release") - on_texture_size: root.best_width = self.texture_size[0] + (dp(30) if root.trust_button else 0) - -<EncryptionMenu>: - size_hint_x: None - width: self.container.minimum_width - auto_width: False - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - - -# Chat Selector - -<ChatSelector>: - jid_selector: jid_selector - JidSelector: - id: jid_selector - on_select: root.on_select(args[1]) - to_show: ["opened_chats", "roster", "bookmarks"] -
--- a/cagou/plugins/plugin_wid_chat.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1280 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from functools import partial -from pathlib import Path -import sys -import uuid -import mimetypes -from urllib.parse import urlparse -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.gridlayout import GridLayout -from kivy.uix.screenmanager import Screen, NoTransition -from kivy.uix.textinput import TextInput -from kivy.uix.label import Label -from kivy.uix import screenmanager -from kivy.uix.behaviors import ButtonBehavior -from kivy.metrics import sp, dp -from kivy.clock import Clock -from kivy import properties -from kivy.uix.stacklayout import StackLayout -from kivy.uix.dropdown import DropDown -from kivy.core.window import Window -from sat.core import log as logging -from sat.core.i18n import _ -from sat.core import exceptions -from sat.tools.common import data_format -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend import quick_chat -from sat_frontends.tools import jid -from cagou import G -from ..core.constants import Const as C -from ..core import cagou_widget -from ..core import xmlui -from ..core.image import Image, AsyncImage -from ..core.common import Symbol, SymbolButton, JidButton, ContactButton -from ..core.behaviors import FilterBehavior -from ..core import menu -from ..core.common_widgets import ImagesGallery - -log = logging.getLogger(__name__) - -PLUGIN_INFO = { - "name": _("chat"), - "main": "Chat", - "description": _("instant messaging with one person or a group"), - "icon_symbol": "chat", -} - -# FIXME: OTR specific code is legacy, and only used nowadays for lock color -# we can probably get rid of them. -OTR_STATE_UNTRUSTED = 'untrusted' -OTR_STATE_TRUSTED = 'trusted' -OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED) -OTR_STATE_UNENCRYPTED = 'unencrypted' -OTR_STATE_ENCRYPTED = 'encrypted' -OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED) - -SYMBOL_UNENCRYPTED = 'lock-open' -SYMBOL_ENCRYPTED = 'lock' -SYMBOL_ENCRYPTED_TRUSTED = 'lock-filled' -COLOR_UNENCRYPTED = (0.4, 0.4, 0.4, 1) -COLOR_ENCRYPTED = (0.4, 0.4, 0.4, 1) -COLOR_ENCRYPTED_TRUSTED = (0.29,0.87,0.0,1) - -# below this limit, new messages will be prepended -INFINITE_SCROLL_LIMIT = dp(600) - -# File sending progress -PROGRESS_UPDATE = 0.2 # number of seconds before next progress update - - -# FIXME: a ScrollLayout was supposed to be used here, but due -# to https://github.com/kivy/kivy/issues/6745, a StackLayout is used for now -class AttachmentsLayout(StackLayout): - """Layout for attachments in a received message""" - padding = properties.VariableListProperty([dp(5), dp(5), 0, dp(5)]) - attachments = properties.ObjectProperty() - - -class AttachmentsToSend(BoxLayout): - """Layout for attachments to be sent with current message""" - attachments = properties.ObjectProperty() - reduce_checkbox = properties.ObjectProperty() - show_resize = properties.BooleanProperty(False) - - def on_kv_post(self, __): - self.attachments.bind(children=self.on_attachment) - - def on_attachment(self, __, attachments): - if len(attachments) == 0: - self.show_resize = False - - -class BaseAttachmentItem(BoxLayout): - data = properties.DictProperty() - progress = properties.NumericProperty(0) - - -class AttachmentItem(BaseAttachmentItem): - - def get_symbol(self, data): - media_type = data.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') - main_type = media_type.split('/', 1)[0] - if main_type == 'image': - return "file-image" - elif main_type == 'video': - return "file-video" - elif main_type == 'audio': - return "file-audio" - else: - return "doc" - - def on_press(self): - url = self.data.get('url') - if url: - G.local_platform.open_url(url, self) - else: - log.warning(f"can't find URL in {self.data}") - - -class AttachmentImageItem(ButtonBehavior, BaseAttachmentItem): - image = properties.ObjectProperty() - - def on_press(self): - full_size_source = self.data.get('path', self.data.get('url')) - gallery = ImagesGallery(sources=[full_size_source]) - G.host.show_extra_ui(gallery) - - def on_kv_post(self, __): - self.on_data(None, self.data) - - def on_data(self, __, data): - if self.image is None: - return - source = data.get('preview') or data.get('path') or data.get('url') - if source: - self.image.source = source - - -class AttachmentImagesCollectionItem(ButtonBehavior, GridLayout): - attachments = properties.ListProperty([]) - chat = properties.ObjectProperty() - mess_data = properties.ObjectProperty() - - def _set_preview(self, attachment, wid, preview_path): - attachment['preview'] = preview_path - wid.source = preview_path - - def _set_path(self, attachment, wid, path): - attachment['path'] = path - if wid is not None: - # we also need a preview for the widget - if 'preview' in attachment: - wid.source = attachment['preview'] - else: - G.host.bridge.image_generate_preview( - path, - self.chat.profile, - callback=partial(self._set_preview, attachment, wid), - ) - - def on_kv_post(self, __): - attachments = self.attachments - self.clear_widgets() - for idx, attachment in enumerate(attachments): - try: - url = attachment['url'] - except KeyError: - url = None - to_download = False - else: - if url.startswith("aesgcm:"): - del attachment['url'] - # if the file is encrypted, we need to download it for decryption - to_download = True - else: - to_download = False - - if idx < 3 or len(attachments) <= 4: - if ((self.mess_data.own_mess - or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): - wid = AsyncImage(size_hint=(1, 1), source="data/images/image-loading.gif") - if 'preview' in attachment: - wid.source = attachment["preview"] - elif 'path' in attachment: - G.host.bridge.image_generate_preview( - attachment['path'], - self.chat.profile, - callback=partial(self._set_preview, attachment, wid), - ) - elif url is None: - log.warning(f"Can't find any source for {attachment}") - else: - # we'll download the file, the preview will then be generated - to_download = True - else: - # we don't download automatically the image if the contact is not - # in roster, to avoid leaking the ip - wid = Symbol(symbol="file-image") - self.add_widget(wid) - else: - wid = None - - if to_download: - # the file needs to be downloaded, the widget source, - # attachment path, and preview will then be completed - G.host.download_url( - url, - callback=partial(self._set_path, attachment, wid), - dest=C.FILE_DEST_CACHE, - profile=self.chat.profile, - ) - - if len(attachments) > 4: - counter = Label( - bold=True, - text=f"+{len(attachments) - 3}", - ) - self.add_widget(counter) - - def on_press(self): - sources = [] - for attachment in self.attachments: - source = attachment.get('path') or attachment.get('url') - if not source: - log.warning(f"no source for {attachment}") - else: - sources.append(source) - gallery = ImagesGallery(sources=sources) - G.host.show_extra_ui(gallery) - - -class AttachmentToSendItem(AttachmentItem): - # True when the item is being sent - sending = properties.BooleanProperty(False) - - -class MessAvatar(ButtonBehavior, Image): - pass - - -class MessageWidget(quick_chat.MessageWidget, BoxLayout): - mess_data = properties.ObjectProperty() - mess_xhtml = properties.ObjectProperty() - mess_padding = (dp(5), dp(5)) - avatar = properties.ObjectProperty() - delivery = properties.ObjectProperty() - font_size = properties.NumericProperty(sp(12)) - right_part = properties.ObjectProperty() - header_box = properties.ObjectProperty() - - def on_kv_post(self, __): - if not self.mess_data: - raise exceptions.InternalError( - "mess_data must always be set in MessageWidget") - - self.mess_data.widgets.add(self) - self.add_attachments() - - @property - def chat(self): - """return parent Chat instance""" - return self.mess_data.parent - - def _get_from_mess_data(self, name, default): - if self.mess_data is None: - return default - return getattr(self.mess_data, name) - - def _get_message(self): - """Return currently displayed message""" - if self.mess_data is None: - return "" - return self.mess_data.main_message - - def _set_message(self, message): - if self.mess_data is None: - return False - if message == self.mess_data.message.get(""): - return False - self.mess_data.message = {"": message} - return True - - message = properties.AliasProperty( - partial(_get_from_mess_data, name="main_message", default=""), - _set_message, - bind=['mess_data'], - ) - message_xhtml = properties.AliasProperty( - partial(_get_from_mess_data, name="main_message_xhtml", default=""), - bind=['mess_data']) - mess_type = properties.AliasProperty( - partial(_get_from_mess_data, name="type", default=""), bind=['mess_data']) - own_mess = properties.AliasProperty( - partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data']) - nick = properties.AliasProperty( - partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data']) - time_text = properties.AliasProperty( - partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data']) - - @property - def info_type(self): - return self.mess_data.info_type - - def update(self, update_dict): - if 'avatar' in update_dict: - avatar_data = update_dict['avatar'] - if avatar_data is None: - source = G.host.get_default_avatar() - else: - source = avatar_data['path'] - self.avatar.source = source - if 'status' in update_dict: - status = update_dict['status'] - self.delivery.text = '\u2714' if status == 'delivered' else '' - - def _set_path(self, data, path): - """Set path of decrypted file to an item""" - data['path'] = path - - def add_attachments(self): - """Add attachments layout + attachments item""" - attachments = self.mess_data.attachments - if not attachments: - return - root_layout = AttachmentsLayout() - self.right_part.add_widget(root_layout) - layout = root_layout.attachments - - image_attachments = [] - other_attachments = [] - # we first separate images and other attachments, so we know if we need - # to use an image collection - for attachment in attachments: - media_type = attachment.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') - main_type = media_type.split('/', 1)[0] - # GIF images are really badly handled by Kivy, the memory - # consumption explode, and the images frequencies are not handled - # correctly, thus we can't display them and we consider them as - # other attachment, so user can open the item with appropriate - # software. - if main_type == 'image' and media_type != "image/gif": - image_attachments.append(attachment) - else: - other_attachments.append(attachment) - - if len(image_attachments) > 1: - collection = AttachmentImagesCollectionItem( - attachments=image_attachments, - chat=self.chat, - mess_data=self.mess_data, - ) - layout.add_widget(collection) - elif image_attachments: - attachment = image_attachments[0] - # to avoid leaking IP address, we only display image if the contact is in - # roster - if ((self.mess_data.own_mess - or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): - try: - url = urlparse(attachment['url']) - except KeyError: - item = AttachmentImageItem(data=attachment) - else: - if url.scheme == "aesgcm": - # we remove the URL now, we'll replace it by - # the local decrypted version - del attachment['url'] - item = AttachmentImageItem(data=attachment) - G.host.download_url( - url.geturl(), - callback=partial(self._set_path, item.data), - dest=C.FILE_DEST_CACHE, - profile=self.chat.profile, - ) - else: - item = AttachmentImageItem(data=attachment) - else: - item = AttachmentItem(data=attachment) - - layout.add_widget(item) - - for attachment in other_attachments: - item = AttachmentItem(data=attachment) - layout.add_widget(item) - - -class MessageInputBox(BoxLayout): - message_input = properties.ObjectProperty() - - def send_text(self): - self.message_input.send_text() - - -class MessageInputWidget(TextInput): - - def keyboard_on_key_down(self, window, keycode, text, modifiers): - # We don't send text when shift is pressed to be able to add line feeds - # (i.e. multi-lines messages). We don't send on Android either as the - # send button appears on this platform. - if (keycode[-1] == "enter" - and "shift" not in modifiers - and sys.platform != 'android'): - self.send_text() - else: - return super(MessageInputWidget, self).keyboard_on_key_down( - window, keycode, text, modifiers) - - def send_text(self): - self.dispatch('on_text_validate') - - -class TransferButton(SymbolButton): - chat = properties.ObjectProperty() - - def on_release(self, *args): - menu.TransferMenu( - encrypted=self.chat.encrypted, - callback=self.chat.transfer_file, - ).show(self) - - -class ExtraMenu(DropDown): - chat = properties.ObjectProperty() - - def on_select(self, menu): - if menu == 'bookmark': - G.host.bridge.menu_launch(C.MENU_GLOBAL, ("groups", "bookmarks"), - {}, C.NO_SECURITY_LIMIT, self.chat.profile, - callback=partial( - G.host.action_manager, profile=self.chat.profile), - errback=G.host.errback) - elif menu == 'close': - if self.chat.type == C.CHAT_GROUP: - # for MUC, we have to indicate the backend that we've left - G.host.bridge.muc_leave(self.chat.target, self.chat.profile) - else: - # for one2one, backend doesn't keep any state, so we just delete the - # widget here in the frontend - G.host.widgets.delete_widget( - self.chat, all_instances=True, explicit_close=True) - else: - raise exceptions.InternalError("Unknown menu: {}".format(menu)) - - -class ExtraButton(SymbolButton): - chat = properties.ObjectProperty() - - -class EncryptionMainButton(SymbolButton): - - def __init__(self, chat, **kwargs): - """ - @param chat(Chat): Chat instance - """ - self.chat = chat - self.encryption_menu = EncryptionMenu(chat) - super(EncryptionMainButton, self).__init__(**kwargs) - self.bind(on_release=self.encryption_menu.open) - - def select_algo(self, name): - """Mark an encryption algorithm as selected. - - This will also deselect all other button - @param name(unicode, None): encryption plugin name - None for plain text - """ - buttons = self.encryption_menu.container.children - buttons[-1].selected = name is None - for button in buttons[:-1]: - button.selected = button.text == name - - def get_color(self): - if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: - return (0.4, 0.4, 0.4, 1) - elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: - return (0.29,0.87,0.0,1) - else: - return (0.4, 0.4, 0.4, 1) - - def get_symbol(self): - if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: - return 'lock-open' - elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: - return 'lock-filled' - else: - return 'lock' - - -class TrustManagementButton(SymbolButton): - pass - - -class EncryptionButton(BoxLayout): - selected = properties.BooleanProperty(False) - text = properties.StringProperty() - trust_button = properties.BooleanProperty(False) - best_width = properties.NumericProperty(0) - bold = properties.BooleanProperty(True) - - def __init__(self, **kwargs): - super(EncryptionButton, self).__init__(**kwargs) - self.register_event_type('on_release') - self.register_event_type('on_trust_release') - if self.trust_button: - self.add_widget(TrustManagementButton()) - - def on_release(self): - pass - - def on_trust_release(self): - pass - - -class EncryptionMenu(DropDown): - # best with to display all algorithms buttons + trust buttons - best_width = properties.NumericProperty(0) - - def __init__(self, chat, **kwargs): - """ - @param chat(Chat): Chat instance - """ - self.chat = chat - super(EncryptionMenu, self).__init__(**kwargs) - btn = EncryptionButton( - text=_("unencrypted (plain text)"), - on_release=self.unencrypted, - selected=True, - bold=False, - ) - btn.bind( - on_release=self.unencrypted, - ) - self.add_widget(btn) - for plugin in G.host.encryption_plugins: - if chat.type == C.CHAT_GROUP and plugin["directed"]: - # directed plugins can't work with group chat - continue - btn = EncryptionButton( - text=plugin['name'], - trust_button=True, - ) - btn.bind( - on_release=partial(self.start_encryption, plugin=plugin), - on_trust_release=partial(self.get_trust_ui, plugin=plugin), - ) - self.add_widget(btn) - log.info("added encryption: {}".format(plugin['name'])) - - def message_encryption_stop_cb(self): - log.info(_("Session with {destinee} is now in plain text").format( - destinee = self.chat.target)) - - def message_encryption_stop_eb(self, failure_): - msg = _("Error while stopping encryption with {destinee}: {reason}").format( - destinee = self.chat.target, - reason = failure_) - log.warning(msg) - G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) - - def unencrypted(self, button): - self.dismiss() - G.host.bridge.message_encryption_stop( - str(self.chat.target), - self.chat.profile, - callback=self.message_encryption_stop_cb, - errback=self.message_encryption_stop_eb) - - def message_encryption_start_cb(self, plugin): - log.info(_("Session with {destinee} is now encrypted with {encr_name}").format( - destinee = self.chat.target, - encr_name = plugin['name'])) - - def message_encryption_start_eb(self, failure_): - msg = _("Session can't be encrypted with {destinee}: {reason}").format( - destinee = self.chat.target, - reason = failure_) - log.warning(msg) - G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) - - def start_encryption(self, button, plugin): - """Request encryption with given plugin for this session - - @param button(EncryptionButton): button which has been pressed - @param plugin(dict): plugin data - """ - self.dismiss() - G.host.bridge.message_encryption_start( - str(self.chat.target), - plugin['namespace'], - True, - self.chat.profile, - callback=partial(self.message_encryption_start_cb, plugin=plugin), - errback=self.message_encryption_start_eb) - - def encryption_trust_ui_get_cb(self, xmlui_raw): - xml_ui = xmlui.create( - G.host, xmlui_raw, profile=self.chat.profile) - xml_ui.show() - - def encryption_trust_ui_get_eb(self, failure_): - msg = _("Trust manager interface can't be retrieved: {reason}").format( - reason = failure_) - log.warning(msg) - G.host.add_note(_("encryption trust management problem"), msg, - C.XMLUI_DATA_LVL_ERROR) - - def get_trust_ui(self, button, plugin): - """Request and display trust management UI - - @param button(EncryptionButton): button which has been pressed - @param plugin(dict): plugin data - """ - self.dismiss() - G.host.bridge.encryption_trust_ui_get( - str(self.chat.target), - plugin['namespace'], - self.chat.profile, - callback=self.encryption_trust_ui_get_cb, - errback=self.encryption_trust_ui_get_eb) - - -class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget): - message_input = properties.ObjectProperty() - messages_widget = properties.ObjectProperty() - history_scroll = properties.ObjectProperty() - attachments_to_send = properties.ObjectProperty() - send_button_visible = properties.BooleanProperty() - use_header_input = True - global_screen_manager = True - collection_carousel = True - - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, - subject=None, statuses=None, profiles=None): - self.show_chat_selector = False - if statuses is None: - statuses = {} - quick_chat.QuickChat.__init__( - self, host, target, type_, nick, occupants, subject, statuses, - profiles=profiles) - self.otr_state_encryption = OTR_STATE_UNENCRYPTED - self.otr_state_trust = OTR_STATE_UNTRUSTED - # completion attributes - self._hi_comp_data = None - self._hi_comp_last = None - self._hi_comp_dropdown = DropDown() - self._hi_comp_allowed = True - cagou_widget.CagouWidget.__init__(self) - transfer_btn = TransferButton(chat=self) - self.header_input_add_extra(transfer_btn) - if (type_ == C.CHAT_ONE2ONE or "REALJID_PUBLIC" in statuses): - self.encryption_btn = EncryptionMainButton(self) - self.header_input_add_extra(self.encryption_btn) - self.extra_menu = ExtraMenu(chat=self) - extra_btn = ExtraButton(chat=self) - self.header_input_add_extra(extra_btn) - self.header_input.hint_text = target - self._history_prepend_lock = False - self.history_count = 0 - - def on_kv_post(self, __): - self.post_init() - - def screen_manager_init(self, screen_manager): - screen_manager.transition = screenmanager.SlideTransition(direction='down') - sel_screen = Screen(name='chat_selector') - chat_selector = ChatSelector(profile=self.profile) - sel_screen.add_widget(chat_selector) - screen_manager.add_widget(sel_screen) - if self.show_chat_selector: - transition = screen_manager.transition - screen_manager.transition = NoTransition() - screen_manager.current = 'chat_selector' - screen_manager.transition = transition - - def __str__(self): - return "Chat({})".format(self.target) - - def __repr__(self): - return self.__str__() - - @classmethod - def factory(cls, plugin_info, target, profiles): - profiles = list(profiles) - if len(profiles) > 1: - raise NotImplementedError("Multi-profiles is not available yet for chat") - if target is None: - show_chat_selector = True - target = G.host.profiles[profiles[0]].whoami - else: - show_chat_selector = False - wid = G.host.widgets.get_or_create_widget(cls, target, on_new_widget=None, - on_existing_widget=G.host.get_or_clone, - profiles=profiles) - wid.show_chat_selector = show_chat_selector - return wid - - @property - def message_widgets_rev(self): - return self.messages_widget.children - - ## keyboard ## - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - screen_manager = self.screen_manager - screen_manager.transition.direction = 'down' - screen_manager.current = 'chat_selector' - return True - - ## drop ## - - def on_drop_file(self, path): - self.add_attachment(path) - - ## header ## - - def change_widget(self, jid_): - """change current widget for a new one with given jid - - @param jid_(jid.JID): jid of the widget to create - """ - plugin_info = G.host.get_plugin_info(main=Chat) - factory = plugin_info['factory'] - G.host.switch_widget(self, factory(plugin_info, jid_, profiles=[self.profile])) - self.header_input.text = '' - - def on_header_wid_input(self): - text = self.header_input.text.strip() - try: - if text.count('@') != 1 or text.count(' '): - raise ValueError - jid_ = jid.JID(text) - except ValueError: - log.info("entered text is not a jid") - return - - def disco_cb(disco): - # TODO: check if plugin XEP-0045 is activated - if "conference" in [i[0] for i in disco[1]]: - G.host.bridge.muc_join(str(jid_), "", "", self.profile, - callback=self._muc_join_cb, errback=self._muc_join_eb) - else: - self.change_widget(jid_) - - def disco_eb(failure): - log.warning("Disco failure, ignore this text: {}".format(failure)) - - G.host.bridge.disco_infos( - jid_.domain, - profile_key=self.profile, - callback=disco_cb, - errback=disco_eb) - - def on_header_wid_input_completed(self, input_wid, completed_text): - self._hi_comp_allowed = False - input_wid.text = completed_text - self._hi_comp_allowed = True - self._hi_comp_dropdown.dismiss() - self.on_header_wid_input() - - def on_header_wid_input_complete(self, wid, text): - if not self._hi_comp_allowed: - return - text = text.lstrip() - if not text: - self._hi_comp_data = None - self._hi_comp_last = None - self._hi_comp_dropdown.dismiss() - return - - profile = list(self.profiles)[0] - - if self._hi_comp_data is None: - # first completion, we build the initial list - comp_data = self._hi_comp_data = [] - self._hi_comp_last = '' - for jid_, jid_data in G.host.contact_lists[profile].all_iter: - comp_data.append((jid_, jid_data)) - comp_data.sort(key=lambda datum: datum[0]) - else: - comp_data = self._hi_comp_data - - # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed, - # it works OK, but some optimisation may be done here - dropdown = self._hi_comp_dropdown - - if not text.startswith(self._hi_comp_last) or not self._hi_comp_last: - # text has changed or backspace has been pressed, we restart - dropdown.clear_widgets() - - for jid_, jid_data in comp_data: - nick = jid_data.get('nick', '') - if text in jid_.bare or text in nick.lower(): - btn = JidButton( - jid = jid_.bare, - profile = profile, - size_hint = (0.5, None), - nick = nick, - on_release=lambda __, txt=jid_.bare: self.on_header_wid_input_completed(wid, txt) - ) - dropdown.add_widget(btn) - else: - # more chars, we continue completion by removing unwanted widgets - to_remove = [] - for c in dropdown.children[0].children: - if text not in c.jid and text not in (c.nick or ''): - to_remove.append(c) - for c in to_remove: - dropdown.remove_widget(c) - if dropdown.attach_to is None: - dropdown.open(wid) - self._hi_comp_last = text - - def message_data_converter(self, idx, mess_id): - return {"mess_data": self.messages[mess_id]} - - def _on_history_printed(self): - """Refresh or scroll down the focus after the history is printed""" - # self.adapter.data = self.messages - for mess_data in self.messages.values(): - self.appendMessage(mess_data) - super(Chat, self)._on_history_printed() - - def create_message(self, message): - self.appendMessage(message) - # we need to render immediatly next 2 layouts to avoid an unpleasant flickering - # when sending or receiving a message - self.messages_widget.dont_delay_next_layouts = 2 - - def appendMessage(self, mess_data): - """Append a message Widget to the history - - @param mess_data(quick_chat.Message): message data - """ - if self.handle_user_moved(mess_data): - return - self.messages_widget.add_widget(MessageWidget(mess_data=mess_data)) - self.notify(mess_data) - - def prepend_message(self, mess_data): - """Prepend a message Widget to the history - - @param mess_data(quick_chat.Message): message data - """ - mess_wid = self.messages_widget - last_idx = len(mess_wid.children) - mess_wid.add_widget(MessageWidget(mess_data=mess_data), index=last_idx) - - def _get_notif_msg(self, mess_data): - return _("{nick}: {message}").format( - nick=mess_data.nick, - message=mess_data.main_message) - - def notify(self, mess_data): - """Notify user when suitable - - For one2one chat, notification will happen when window has not focus - or when one2one chat is not visible. A note is also there when widget - is not visible. - For group chat, note will be added on mention, with a desktop notification if - window has not focus or is not visible. - """ - visible_clones = [w for w in G.host.get_visible_list(self.__class__) - if w.target == self.target] - if len(visible_clones) > 1 and visible_clones.index(self) > 0: - # to avoid multiple notifications in case of multiple cloned widgets - # we only handle first clone - return - is_visible = bool(visible_clones) - if self.type == C.CHAT_ONE2ONE: - if (not Window.focus or not is_visible) and not mess_data.history: - notif_msg = self._get_notif_msg(mess_data) - G.host.notify( - type_=C.NOTIFY_MESSAGE, - entity=mess_data.from_jid, - message=notif_msg, - subject=_("private message"), - widget=self, - profile=self.profile - ) - if not is_visible: - G.host.add_note( - _("private message"), - notif_msg, - symbol = "chat", - action = { - "action": 'chat', - "target": self.target, - "profiles": self.profiles} - ) - else: - if mess_data.mention: - notif_msg = self._get_notif_msg(mess_data) - G.host.add_note( - _("mention"), - notif_msg, - symbol = "chat", - action = { - "action": 'chat', - "target": self.target, - "profiles": self.profiles} - ) - if not is_visible or not Window.focus: - subject=_("mention ({room_jid})").format(room_jid=self.target) - G.host.notify( - type_=C.NOTIFY_MENTION, - entity=self.target, - message=notif_msg, - subject=subject, - widget=self, - profile=self.profile - ) - - # message input - - def _attachment_progress_cb(self, item, metadata, profile): - item.parent.remove_widget(item) - log.info(f"item {item.data.get('path')} uploaded successfully") - - def _attachment_progress_eb(self, item, err_msg, profile): - item.parent.remove_widget(item) - path = item.data.get('path') - msg = _("item {path} could not be uploaded: {err_msg}").format( - path=path, err_msg=err_msg) - G.host.add_note(_("can't upload file"), msg, C.XMLUI_DATA_LVL_WARNING) - log.warning(msg) - - def _progress_get_cb(self, item, metadata): - try: - position = int(metadata["position"]) - size = int(metadata["size"]) - except KeyError: - # we got empty metadata, the progression is either not yet started or - # finished - if item.progress: - # if progress is already started, receiving empty metadata means - # that progression is finished - item.progress = 100 - return - else: - item.progress = position/size*100 - - if item.parent is not None: - # the item is not yet fully received, we reschedule an update - Clock.schedule_once( - partial(self._attachment_progress_update, item), - PROGRESS_UPDATE) - - def _attachment_progress_update(self, item, __): - G.host.bridge.progress_get( - item.data["progress_id"], - self.profile, - callback=partial(self._progress_get_cb, item), - errback=G.host.errback, - ) - - def add_nick(self, nick): - """Add a nickname to message_input if suitable""" - if (self.type == C.CHAT_GROUP and not self.message_input.text.startswith(nick)): - self.message_input.text = f'{nick}: {self.message_input.text}' - - def on_send(self, input_widget): - extra = {} - for item in self.attachments_to_send.attachments.children: - if item.sending: - # the item is already being sent - continue - item.sending = True - progress_id = item.data["progress_id"] = str(uuid.uuid4()) - attachments = extra.setdefault(C.KEY_ATTACHMENTS, []) - attachment = { - "path": str(item.data["path"]), - "progress_id": progress_id, - } - if 'media_type' in item.data: - attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = item.data['media_type'] - - if ((self.attachments_to_send.reduce_checkbox.active - and attachment.get('media_type', '').split('/')[0] == 'image')): - attachment[C.KEY_ATTACHMENTS_RESIZE] = True - - attachments.append(attachment) - - Clock.schedule_once( - partial(self._attachment_progress_update, item), - PROGRESS_UPDATE) - - G.host.register_progress_cbs( - progress_id, - callback=partial(self._attachment_progress_cb, item), - errback=partial(self._attachment_progress_eb, item) - ) - - - G.host.message_send( - self.target, - # TODO: handle language - {'': input_widget.text}, - # TODO: put this in QuickChat - mess_type= - C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, - extra=extra, - profile_key=self.profile - ) - input_widget.text = '' - - def _image_check_cb(self, report_raw): - report = data_format.deserialise(report_raw) - if report['too_large']: - self.attachments_to_send.show_resize=True - self.attachments_to_send.reduce_checkbox.active=True - - def add_attachment(self, file_path, media_type=None): - file_path = Path(file_path) - if media_type is None: - media_type = mimetypes.guess_type(str(file_path), strict=False)[0] - if not self.attachments_to_send.show_resize and media_type is not None: - # we check if the attachment is an image and if it's too large. - # If too large, the reduce size check box will be displayed, and checked by - # default. - main_type = media_type.split('/')[0] - if main_type == "image": - G.host.bridge.image_check( - str(file_path), - callback=self._image_check_cb, - errback=partial( - G.host.errback, - title=_("Can't check image size"), - message=_("Can't check image at {path}: {{msg}}").format( - path=file_path), - ) - ) - - data = { - "path": file_path, - "name": file_path.name, - } - - if media_type is not None: - data['media_type'] = media_type - - self.attachments_to_send.attachments.add_widget( - AttachmentToSendItem(data=data) - ) - - def transfer_file(self, file_path, transfer_type=C.TRANSFER_UPLOAD, cleaning_cb=None): - # FIXME: cleaning_cb is not managed - if transfer_type == C.TRANSFER_UPLOAD: - self.add_attachment(file_path) - elif transfer_type == C.TRANSFER_SEND: - if self.type == C.CHAT_GROUP: - log.warning("P2P transfer is not possible for group chat") - # TODO: show an error dialog to user, or better hide the send button for - # MUC - else: - jid_ = self.target - if not jid_.resource: - jid_ = G.host.contact_lists[self.profile].get_full_jid(jid_) - G.host.bridge.file_send(str(jid_), str(file_path), "", "", "", - profile=self.profile) - # TODO: notification of sending/failing - else: - raise log.error("transfer of type {} are not handled".format(transfer_type)) - - def message_encryption_started(self, plugin_data): - quick_chat.QuickChat.message_encryption_started(self, plugin_data) - self.encryption_btn.symbol = SYMBOL_ENCRYPTED - self.encryption_btn.color = COLOR_ENCRYPTED - self.encryption_btn.select_algo(plugin_data['name']) - - def message_encryption_stopped(self, plugin_data): - quick_chat.QuickChat.message_encryption_stopped(self, plugin_data) - self.encryption_btn.symbol = SYMBOL_UNENCRYPTED - self.encryption_btn.color = COLOR_UNENCRYPTED - self.encryption_btn.select_algo(None) - - def _muc_join_cb(self, joined_data): - joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data - self.host.muc_room_joined_handler(*joined_data[1:]) - jid_ = jid.JID(room_jid_s) - self.change_widget(jid_) - - def _muc_join_eb(self, failure): - log.warning("Can't join room: {}".format(failure)) - - def on_otr_state(self, state, dest_jid, profile): - assert profile in self.profiles - if state in OTR_STATE_ENCRYPTION: - self.otr_state_encryption = state - elif state in OTR_STATE_TRUST: - self.otr_state_trust = state - else: - log.error(_("Unknown OTR state received: {}".format(state))) - return - self.encryption_btn.symbol = self.encryption_btn.get_symbol() - self.encryption_btn.color = self.encryption_btn.get_color() - - def on_visible(self): - if not self.sync: - self.resync() - - def on_selected(self): - G.host.clear_notifs(self.target, profile=self.profile) - - def on_delete(self, **kwargs): - if kwargs.get('explicit_close', False): - wrapper = self.whwrapper - if wrapper is not None: - if len(wrapper.carousel.slides) == 1: - # if we delete the last opened chat, we need to show the selector - screen_manager = self.screen_manager - screen_manager.transition.direction = 'down' - screen_manager.current = 'chat_selector' - wrapper.carousel.remove_widget(self) - return True - # we always keep one widget, so it's available when swiping - # TODO: delete all widgets when chat is closed - nb_instances = sum(1 for _ in self.host.widgets.get_widget_instances(self)) - # we want to keep at least one instance of Chat by WHWrapper - nb_to_keep = len(G.host.widgets_handler.children) - if nb_instances <= nb_to_keep: - return False - - def _history_unlock(self, __): - self._history_prepend_lock = False - log.debug("history prepend unlocked") - # we call manually on_scroll, to check if we are still in the scrolling zone - self.on_scroll(self.history_scroll, self.history_scroll.scroll_y) - - def _history_scroll_adjust(self, __, scroll_start_height): - # history scroll position must correspond to where it was before new messages - # have been appended - self.history_scroll.scroll_y = ( - scroll_start_height / self.messages_widget.height - ) - - # we want a small delay before unlocking, to avoid re-fetching history - # again - Clock.schedule_once(self._history_unlock, 1.5) - - def _back_history_get_cb_post(self, __, history, scroll_start_height): - if len(history) == 0: - # we don't unlock self._history_prepend_lock if there is no history, as there - # is no sense to try to retrieve more in this case. - log.debug(f"we've reached top of history for {self.target.bare} chat") - else: - # we have to schedule again for _history_scroll_adjust, else messages_widget - # is not resized (self.messages_widget.height is not yet updated) - # as a result, the scroll_to can't work correctly - Clock.schedule_once(partial( - self._history_scroll_adjust, - scroll_start_height=scroll_start_height)) - log.debug( - f"{len(history)} messages prepended to history (last: {history[0][0]})") - - def _back_history_get_cb(self, history): - # TODO: factorise with QuickChat._history_get_cb - scroll_start_height = self.messages_widget.height * self.history_scroll.scroll_y - for data in reversed(history): - uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data - from_jid = jid.JID(from_jid) - to_jid = jid.JID(to_jid) - extra = data_format.deserialise(extra_s) - extra["history"] = True - self.messages[uid] = message = quick_chat.Message( - self, - uid, - timestamp, - from_jid, - to_jid, - message, - subject, - type_, - extra, - self.profile, - ) - self.messages.move_to_end(uid, last=False) - self.prepend_message(message) - Clock.schedule_once(partial( - self._back_history_get_cb_post, - history=history, - scroll_start_height=scroll_start_height)) - - def _back_history_get_eb(self, failure_): - G.host.add_note( - _("Problem while getting back history"), - _("Can't back history for {target}: {problem}").format( - target=self.target, problem=failure_), - C.XMLUI_DATA_LVL_ERROR) - # we don't unlock self._history_prepend_lock on purpose, no need - # to try to get more history if something is wrong - - def on_scroll(self, scroll_view, scroll_y): - if self._history_prepend_lock: - return - if (1-scroll_y) * self.messages_widget.height < INFINITE_SCROLL_LIMIT: - self._history_prepend_lock = True - log.debug(f"Retrieving back history for {self} [{self.history_count}]") - self.history_count += 1 - first_uid = next(iter(self.messages.keys())) - filters = self.history_filters.copy() - filters['before_uid'] = first_uid - self.host.bridge.history_get( - str(self.host.profiles[self.profile].whoami.bare), - str(self.target), - 30, - True, - {k: str(v) for k,v in filters.items()}, - self.profile, - callback=self._back_history_get_cb, - errback=self._back_history_get_eb, - ) - - -class ChatSelector(cagou_widget.CagouWidget, FilterBehavior): - jid_selector = properties.ObjectProperty() - profile = properties.StringProperty() - plugin_info_class = Chat - use_header_input = True - - def on_select(self, contact_button): - contact_jid = jid.JID(contact_button.jid) - plugin_info = G.host.get_plugin_info(main=Chat) - factory = plugin_info['factory'] - self.screen_manager.transition.direction = 'up' - carousel = self.whwrapper.carousel - current_slides = {w.target: w for w in carousel.slides} - if contact_jid in current_slides: - slide = current_slides[contact_jid] - idx = carousel.slides.index(slide) - carousel.index = idx - self.screen_manager.current = '' - else: - G.host.switch_widget( - self, factory(plugin_info, contact_jid, profiles=[self.profile])) - - - def on_header_wid_input(self): - text = self.header_input.text.strip() - try: - if text.count('@') != 1 or text.count(' '): - raise ValueError - jid_ = jid.JID(text) - except ValueError: - log.info("entered text is not a jid") - return - G.host.do_action("chat", jid_, [self.profile]) - - def on_header_wid_input_complete(self, wid, text, **kwargs): - """we filter items when text is entered in input box""" - for layout in self.jid_selector.items_layouts: - self.do_filter( - layout, - text, - # we append nick to jid to filter on both - lambda c: c.jid + c.data.get('nick', ''), - width_cb=lambda c: c.base_width, - height_cb=lambda c: c.minimum_height, - continue_tests=[lambda c: not isinstance(c, ContactButton)]) - - -PLUGIN_INFO["factory"] = Chat.factory -quick_widgets.register(quick_chat.QuickChat, Chat)
--- a/cagou/plugins/plugin_wid_contact_list.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,104 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import _ sat.core.i18n._ -#:import e kivy.utils.escape_markup - -<AddContactMenu>: - padding: dp(20) - spacing: dp(10) - Label: - size_hint: 1, None - color: 1, 1, 1, 1 - text: _("Please enter new contact JID") - text_size: root.width, None - size: self.texture_size - halign: "center" - bold: True - TextInput: - id: contact_jid - size_hint: 1, None - height: sp(30) - hint_text: _("enter here your new contact JID") - Button: - size_hint: 1, None - height: sp(50) - text: _("add this contact") - on_release: root.contact_add(contact_jid.text) - Widget: - - -<DelContactMenu>: - padding: dp(20) - spacing: dp(10) - Avatar: - id: avatar - size_hint: 1, None - height: dp(60) - data: root.contact_item.data.get('avatar') - allow_stretch: True - Label: - size_hint: 1, None - color: 1, 1, 1, 1 - text: _("Are you sure you wand to remove [b]{name}[/b] from your contact list?").format(name=e(root.contact_item.jid)) - markup: True - text_size: root.width, None - size: self.texture_size - halign: "center" - BoxLayout: - Button: - background_color: 1, 0, 0, 1 - size_hint: 0.5, None - height: sp(50) - text: _("yes, remove it") - bold: True - on_release: root.do_delete_contact() - Button: - size_hint: 0.5, None - height: sp(50) - text: _("no, keep it") - on_release: root.hide() - Widget: - - -<ContactList>: - float_layout: float_layout - layout: layout - orientation: 'vertical' - BoxLayout: - size_hint: 1, None - height: dp(35) - width: dp(35) - font_size: dp(30) - Widget: - SymbolButtonLabel: - symbol: 'plus-circled' - text: _("add a contact") - on_release: root.add_contact_menu() - Widget: - FloatLayout: - id: float_layout - ScrollView: - size_hint: 1, 1 - pos_hint: {'x': 0, 'y': 0} - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - StackLayout: - id: layout - size_hint: 1, None - height: self.minimum_height - spacing: 0
--- a/cagou/plugins/plugin_wid_contact_list.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,196 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from functools import partial -import bisect -import re -from sat.core import log as logging -from sat.core.i18n import _ -from sat_frontends.quick_frontend.quick_contact_list import QuickContactList -from sat_frontends.tools import jid -from kivy import properties -from cagou import G -from ..core import cagou_widget -from ..core.constants import Const as C -from ..core.common import ContactItem -from ..core.behaviors import FilterBehavior, TouchMenuBehavior, TouchMenuItemBehavior -from ..core.menu import SideMenu - - -log = logging.getLogger(__name__) - - -PLUGIN_INFO = { - "name": _("contacts"), - "main": "ContactList", - "description": _("list of contacts"), - "icon_medium": "{media}/icons/muchoslava/png/contact_list_no_border_blue_44.png" -} - - -class AddContactMenu(SideMenu): - profile = properties.StringProperty() - size_hint_close = (1, 0) - size_hint_open = (1, 0.5) - - def __init__(self, **kwargs): - super(AddContactMenu, self).__init__(**kwargs) - if self.profile is None: - log.warning(_("profile not set in AddContactMenu")) - self.profile = next(iter(G.host.profiles)) - - def contact_add(self, contact_jid): - """Actually add the contact - - @param contact_jid(unicode): jid of the contact to add - """ - self.hide() - contact_jid = contact_jid.strip() - # FIXME: trivial jid verification - if not contact_jid or not re.match(r"[^@ ]+@[^@ ]+", contact_jid): - return - contact_jid = jid.JID(contact_jid).bare - G.host.bridge.contact_add(str(contact_jid), - self.profile, - callback=lambda: G.host.add_note( - _("contact request"), - _("a contact request has been sent to {contact_jid}").format( - contact_jid=contact_jid)), - errback=partial(G.host.errback, - title=_("can't add contact"), - message=_("error while trying to add contact: {msg}"))) - - -class DelContactMenu(SideMenu): - size_hint_close = (1, 0) - size_hint_open = (1, 0.5) - - def __init__(self, contact_item, **kwargs): - self.contact_item = contact_item - super(DelContactMenu, self).__init__(**kwargs) - - def do_delete_contact(self): - self.hide() - G.host.bridge.contact_del(str(self.contact_item.jid.bare), - self.contact_item.profile, - callback=lambda: G.host.add_note( - _("contact removed"), - _("{contact_jid} has been removed from your contacts list").format( - contact_jid=self.contact_item.jid.bare)), - errback=partial(G.host.errback, - title=_("can't remove contact"), - message=_("error while trying to remove contact: {msg}"))) - - -class CLContactItem(TouchMenuItemBehavior, ContactItem): - - def do_item_action(self, touch): - assert self.profile - # XXX: for now clicking on an item launch the corresponding Chat widget - # behaviour should change in the future - G.host.do_action('chat', jid.JID(self.jid), [self.profile]) - - def get_menu_choices(self): - choices = [] - choices.append(dict(text=_('delete'), - index=len(choices)+1, - callback=self.main_wid.remove_contact)) - return choices - - -class ContactList(QuickContactList, cagou_widget.CagouWidget, FilterBehavior, - TouchMenuBehavior): - float_layout = properties.ObjectProperty() - layout = properties.ObjectProperty() - use_header_input = True - - def __init__(self, host, target, profiles): - QuickContactList.__init__(self, G.host, profiles) - cagou_widget.CagouWidget.__init__(self) - FilterBehavior.__init__(self) - self._wid_map = {} # (profile, bare_jid) to widget map - self.post_init() - if len(self.profiles) != 1: - raise NotImplementedError('multi profiles is not implemented yet') - self.update(profile=next(iter(self.profiles))) - - def add_contact_menu(self): - """Show the "add a contact" menu""" - # FIXME: for now we add contact to the first profile we find - profile = next(iter(self.profiles)) - AddContactMenu(profile=profile).show() - - def remove_contact(self, menu_label): - item = self.menu_item - self.clear_menu() - DelContactMenu(contact_item=item).show() - - def on_header_wid_input_complete(self, wid, text): - self.do_filter(self.layout, - text, - lambda c: c.jid, - width_cb=lambda c: c.base_width, - height_cb=lambda c: c.minimum_height, - ) - - def _add_contact_item(self, bare_jid, profile): - """Create a new CLContactItem instance, and add it - - item will be added in a sorted position - @param bare_jid(jid.JID): entity bare JID - @param profile(unicode): profile where the contact is - """ - data = G.host.contact_lists[profile].get_item(bare_jid) - wid = CLContactItem(profile=profile, data=data, jid=bare_jid, main_wid=self) - child_jids = [c.jid for c in reversed(self.layout.children)] - idx = bisect.bisect_right(child_jids, bare_jid) - self.layout.add_widget(wid, -idx) - self._wid_map[(profile, bare_jid)] = wid - - def update(self, entities=None, type_=None, profile=None): - log.debug("update: %s %s %s" % (entities, type_, profile)) - if type_ == None or type_ == C.UPDATE_STRUCTURE: - log.debug("full contact list update") - self.layout.clear_widgets() - for bare_jid, data in self.items_sorted.items(): - wid = CLContactItem( - profile=data['profile'], - data=data, - jid=bare_jid, - main_wid=self, - ) - self.layout.add_widget(wid) - self._wid_map[(profile, bare_jid)] = wid - elif type_ == C.UPDATE_MODIFY: - for entity in entities: - entity_bare = entity.bare - wid = self._wid_map[(profile, entity_bare)] - wid.data = G.host.contact_lists[profile].get_item(entity_bare) - elif type_ == C.UPDATE_ADD: - for entity in entities: - self._add_contact_item(entity.bare, profile) - elif type_ == C.UPDATE_DELETE: - for entity in entities: - try: - self.layout.remove_widget(self._wid_map.pop((profile, entity.bare))) - except KeyError: - log.debug("entity not found: {entity}".format(entity=entity.bare)) - else: - log.debug("update type not handled: {update_type}".format(update_type=type_))
--- a/cagou/plugins/plugin_wid_file_sharing.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,62 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -#:import ModernMenu kivy_garden.modernmenu.ModernMenu - - -<ModeBtn>: - width: self.texture_size[0] + sp(20) - size_hint: None, 1 - - -<FileSharing>: - float_layout: float_layout - layout: layout - FloatLayout: - id: float_layout - ScrollView: - size_hint: 1, 1 - pos_hint: {'x': 0, 'y': 0} - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - StackLayout: - id: layout - size_hint: 1, None - height: self.minimum_height - spacing: 0 - - -<PathWidget>: - shared: False - Symbol: - size_hint: 1, None - height: dp(80) - font_size: dp(40) - symbol: 'folder-open-empty' if root.is_dir else 'doc' - color: (1, 0, 0, 1) if root.shared else (0, 0, 0, 1) if root.is_dir else app.c_prim_dark - Label: - size_hint: None, None - width: dp(100) - font_size: sp(14) - text_size: dp(95), None - size: self.texture_size - text: root.name - halign: 'center' - - -<LocalPathWidget>: - shared: root.filepath in root.main_wid.shared_paths
--- a/cagou/plugins/plugin_wid_file_sharing.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,419 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from functools import partial -import os.path -import json -from sat.core import log as logging -from sat.core import exceptions -from sat.core.i18n import _ -from sat.tools.common import files_utils -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.tools import jid -from ..core.constants import Const as C -from ..core import cagou_widget -from ..core.menu import EntitiesSelectorMenu -from ..core.behaviors import TouchMenuBehavior, FilterBehavior -from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget, - CategorySeparator) -from cagou import G -from kivy import properties -from kivy.uix.label import Label -from kivy.uix.button import Button -from kivy import utils as kivy_utils - -log = logging.getLogger(__name__) - - -PLUGIN_INFO = { - "name": _("file sharing"), - "main": "FileSharing", - "description": _("share/transfer files between devices"), - "icon_symbol": "exchange", -} -MODE_VIEW = "view" -MODE_LOCAL = "local" -SELECT_INSTRUCTIONS = _("Please select entities to share with") - -if kivy_utils.platform == "android": - from jnius import autoclass - Environment = autoclass("android.os.Environment") - base_dir = Environment.getExternalStorageDirectory().getAbsolutePath() - def expanduser(path): - if path == '~' or path.startswith('~/'): - return path.replace('~', base_dir, 1) - return path -else: - expanduser = os.path.expanduser - - -class ModeBtn(Button): - - def __init__(self, parent, **kwargs): - super(ModeBtn, self).__init__(**kwargs) - parent.bind(mode=self.on_mode) - self.on_mode(parent, parent.mode) - - def on_mode(self, parent, new_mode): - if new_mode == MODE_VIEW: - self.text = _("view shared files") - elif new_mode == MODE_LOCAL: - self.text = _("share local files") - else: - exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode)) - - -class PathWidget(ItemWidget): - - def __init__(self, filepath, main_wid, **kw): - name = os.path.basename(filepath) - self.filepath = os.path.normpath(filepath) - if self.filepath == '.': - self.filepath = '' - super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw) - - @property - def is_dir(self): - raise NotImplementedError - - def do_item_action(self, touch): - if self.is_dir: - self.main_wid.current_dir = self.filepath - - def open_menu(self, touch, dt): - log.debug(_("opening menu for {path}").format(path=self.filepath)) - super(PathWidget, self).open_menu(touch, dt) - - -class LocalPathWidget(PathWidget): - - @property - def is_dir(self): - return os.path.isdir(self.filepath) - - def get_menu_choices(self): - choices = [] - if self.shared: - choices.append(dict(text=_('unshare'), - index=len(choices)+1, - callback=self.main_wid.unshare)) - else: - choices.append(dict(text=_('share'), - index=len(choices)+1, - callback=self.main_wid.share)) - return choices - - -class RemotePathWidget(PathWidget): - - def __init__(self, main_wid, filepath, type_, **kw): - self.type_ = type_ - super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw) - - @property - def is_dir(self): - return self.type_ == C.FILE_TYPE_DIRECTORY - - def do_item_action(self, touch): - if self.is_dir: - if self.filepath == '..': - self.main_wid.remote_entity = '' - else: - super(RemotePathWidget, self).do_item_action(touch) - else: - self.main_wid.request_item(self) - return True - -class SharingDeviceWidget(DeviceWidget): - - def do_item_action(self, touch): - self.main_wid.remote_entity = self.entity_jid - self.main_wid.remote_dir = '' - - -class FileSharing(quick_widgets.QuickWidget, cagou_widget.CagouWidget, FilterBehavior, - TouchMenuBehavior): - SINGLE=False - layout = properties.ObjectProperty() - mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL]) - local_dir = properties.StringProperty(expanduser('~')) - remote_dir = properties.StringProperty('') - remote_entity = properties.StringProperty('') - shared_paths = properties.ListProperty() - use_header_input = True - signals_registered = False - - def __init__(self, host, target, profiles): - quick_widgets.QuickWidget.__init__(self, host, target, profiles) - cagou_widget.CagouWidget.__init__(self) - FilterBehavior.__init__(self) - TouchMenuBehavior.__init__(self) - self.mode_btn = ModeBtn(self) - self.mode_btn.bind(on_release=self.change_mode) - self.header_input_add_extra(self.mode_btn) - self.bind(local_dir=self.update_view, - remote_dir=self.update_view, - remote_entity=self.update_view) - self.update_view() - if not FileSharing.signals_registered: - # FIXME: we use this hack (registering the signal for the whole class) now - # as there is currently no unregisterSignal available in bridges - G.host.register_signal("fis_shared_path_new", - handler=FileSharing.shared_path_new, - iface="plugin") - G.host.register_signal("fis_shared_path_removed", - handler=FileSharing.shared_path_removed, - iface="plugin") - FileSharing.signals_registered = True - G.host.bridge.fis_local_shares_get(self.profile, - callback=self.fill_paths, - errback=G.host.errback) - - @property - def current_dir(self): - return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir - - @current_dir.setter - def current_dir(self, new_dir): - if self.mode == MODE_LOCAL: - self.local_dir = new_dir - else: - self.remote_dir = new_dir - - def fill_paths(self, shared_paths): - self.shared_paths.extend(shared_paths) - - def change_mode(self, mode_btn): - self.clear_menu() - opt = self.__class__.mode.options - new_idx = (opt.index(self.mode)+1) % len(opt) - self.mode = opt[new_idx] - - def on_mode(self, instance, new_mode): - self.update_view(None, self.local_dir) - - def on_header_wid_input(self): - if '/' in self.header_input.text or self.header_input.text == '~': - self.current_dir = expanduser(self.header_input.text) - - def on_header_wid_input_complete(self, wid, text, **kwargs): - """we filter items when text is entered in input box""" - if '/' in text: - return - self.do_filter(self.layout, - text, - lambda c: c.name, - width_cb=lambda c: c.base_width, - height_cb=lambda c: c.minimum_height, - continue_tests=[lambda c: not isinstance(c, ItemWidget), - lambda c: c.name == '..']) - - - ## remote sharing callback ## - - def _disco_find_by_features_cb(self, data): - entities_services, entities_own, entities_roster = data - for entities_map, title in ((entities_services, - _('services')), - (entities_own, - _('your devices')), - (entities_roster, - _('your contacts devices'))): - if entities_map: - self.layout.add_widget(CategorySeparator(text=title)) - for entity_str, entity_ids in entities_map.items(): - entity_jid = jid.JID(entity_str) - item = SharingDeviceWidget( - self, entity_jid, Identities(entity_ids)) - self.layout.add_widget(item) - if not entities_services and not entities_own and not entities_roster: - self.layout.add_widget(Label( - size_hint=(1, 1), - halign='center', - text_size=self.size, - text=_("No sharing device found"))) - - def discover_devices(self): - """Looks for devices handling file "File Information Sharing" and display them""" - try: - namespace = self.host.ns_map['fis'] - except KeyError: - msg = _("can't find file information sharing namespace, " - "is the plugin running?") - log.warning(msg) - G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR) - return - self.host.bridge.disco_find_by_features( - [namespace], [], False, True, True, True, False, self.profile, - callback=self._disco_find_by_features_cb, - errback=partial(G.host.errback, - title=_("shared folder error"), - message=_("can't check sharing devices: {msg}"))) - - def fis_list_cb(self, files_data): - for file_data in files_data: - filepath = os.path.join(self.current_dir, file_data['name']) - item = RemotePathWidget( - filepath=filepath, - main_wid=self, - type_=file_data['type']) - self.layout.add_widget(item) - - def fis_list_eb(self, failure_): - self.remote_dir = '' - G.host.add_note( - _("shared folder error"), - _("can't list files for {remote_entity}: {msg}").format( - remote_entity=self.remote_entity, - msg=failure_), - level=C.XMLUI_DATA_LVL_WARNING) - - ## view generation ## - - def update_view(self, *args): - """update items according to current mode, entity and dir""" - log.debug('updating {}, {}'.format(self.current_dir, args)) - self.layout.clear_widgets() - self.header_input.text = '' - self.header_input.hint_text = self.current_dir - - if self.mode == MODE_LOCAL: - filepath = os.path.join(self.local_dir, '..') - self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self)) - try: - files = sorted(os.listdir(self.local_dir)) - except OSError as e: - msg = _("can't list files in \"{local_dir}\": {msg}").format( - local_dir=self.local_dir, - msg=e) - G.host.add_note( - _("shared folder error"), - msg, - level=C.XMLUI_DATA_LVL_WARNING) - self.local_dir = expanduser('~') - return - for f in files: - filepath = os.path.join(self.local_dir, f) - self.layout.add_widget(LocalPathWidget(filepath=filepath, - main_wid=self)) - elif self.mode == MODE_VIEW: - if not self.remote_entity: - self.discover_devices() - else: - # we always a way to go back - # so user can return to previous list even in case of error - parent_path = os.path.join(self.remote_dir, '..') - item = RemotePathWidget( - filepath = parent_path, - main_wid=self, - type_ = C.FILE_TYPE_DIRECTORY) - self.layout.add_widget(item) - self.host.bridge.fis_list( - str(self.remote_entity), - self.remote_dir, - {}, - self.profile, - callback=self.fis_list_cb, - errback=self.fis_list_eb) - - ## Share methods ## - - def do_share(self, entities_jids, item): - if entities_jids: - access = {'read': {'type': 'whitelist', - 'jids': entities_jids}} - else: - access = {} - - G.host.bridge.fis_share_path( - item.name, - item.filepath, - json.dumps(access, ensure_ascii=False), - self.profile, - callback=lambda name: G.host.add_note( - _("sharing folder"), - _("{name} is now shared").format(name=name)), - errback=partial(G.host.errback, - title=_("sharing folder"), - message=_("can't share folder: {msg}"))) - - def share(self, menu): - item = self.menu_item - self.clear_menu() - EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS, - callback=partial(self.do_share, item=item)).show() - - def unshare(self, menu): - item = self.menu_item - self.clear_menu() - G.host.bridge.fis_unshare_path( - item.filepath, - self.profile, - callback=lambda: G.host.add_note( - _("sharing folder"), - _("{name} is not shared anymore").format(name=item.name)), - errback=partial(G.host.errback, - title=_("sharing folder"), - message=_("can't unshare folder: {msg}"))) - - def file_jingle_request_cb(self, progress_id, item, dest_path): - G.host.add_note( - _("file request"), - _("{name} download started at {dest_path}").format( - name = item.name, - dest_path = dest_path)) - - def request_item(self, item): - """Retrieve an item from remote entity - - @param item(RemotePathWidget): item to retrieve - """ - path, name = os.path.split(item.filepath) - assert name - assert self.remote_entity - extra = {'path': path} - dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name)) - G.host.bridge.file_jingle_request(str(self.remote_entity), - str(dest_path), - name, - '', - '', - extra, - self.profile, - callback=partial(self.file_jingle_request_cb, - item=item, - dest_path=dest_path), - errback=partial(G.host.errback, - title = _("file request error"), - message = _("can't request file: {msg}"))) - - @classmethod - def shared_path_new(cls, shared_path, name, profile): - for wid in G.host.get_visible_list(cls): - if shared_path not in wid.shared_paths: - wid.shared_paths.append(shared_path) - - @classmethod - def shared_path_removed(cls, shared_path, profile): - for wid in G.host.get_visible_list(cls): - if shared_path in wid.shared_paths: - wid.shared_paths.remove(shared_path) - else: - log.warning(_("shared path {path} not found in {widget}".format( - path = shared_path, widget = wid)))
--- a/cagou/plugins/plugin_wid_remote.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,101 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -<RemoteControl>: - layout: layout - BoxLayout: - id: layout - - -<DevicesLayout>: - layout: layout - size_hint: 1, 1 - ScrollView: - size_hint: 1, 1 - pos_hint: {'x': 0, 'y': 0} - do_scroll_x: False - scroll_type: ['bars', 'content'] - bar_width: dp(6) - StackLayout: - id: layout - size_hint: 1, None - height: self.minimum_height - spacing: 0 - - -<RemoteItemWidget>: - shared: False - Symbol: - size_hint: 1, None - height: dp(80) - symbol: 'video' - color: 0, 0, 0, 1 - Label: - size_hint: None, None - width: dp(100) - font_size: sp(14) - text_size: dp(95), None - size: self.texture_size - text: root.name - halign: 'center' - - -<PlayerLabel@Label>: - size_hint: 1, None - text_size: self.width, None - size: self.texture_size - halign: 'center' - - -<PlayerButton@SymbolButton>: - size_hint: None, 1 - - -<MediaPlayerControlWidget>: - orientation: 'vertical' - PlayerLabel: - text: root.title - bold: True - font_size: '20sp' - PlayerLabel: - text: root.identity - font_size: '15sp' - Widget: - size_hint: 1, None - height: dp(50) - BoxLayout: - size_hint: 1, None - spacing: dp(20) - height: dp(30) - Widget: - PlayerButton: - symbol: "previous" - on_release: root.do_cmd("Previous") - PlayerButton: - symbol: "fast-bw" - on_release: root.do_cmd("GoBack") - PlayerButton: - symbol: root.status - on_release: root.do_cmd("PlayPause") - PlayerButton - symbol: "fast-fw" - on_release: root.do_cmd("GoFW") - PlayerButton - symbol: "next" - on_release: root.do_cmd("Next") - Widget: - Widget:
--- a/cagou/plugins/plugin_wid_remote.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -from sat.core.i18n import _ -from sat_frontends.quick_frontend import quick_widgets -from ..core import cagou_widget -from ..core.constants import Const as C -from ..core.behaviors import TouchMenuBehavior, FilterBehavior -from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget, - CategorySeparator) -from sat.tools.common import template_xmlui -from sat.tools.common import data_format -from cagou.core import xmlui -from sat_frontends.tools import jid -from kivy import properties -from kivy.uix.label import Label -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.floatlayout import FloatLayout -from cagou import G -from functools import partial - - -log = logging.getLogger(__name__) - -PLUGIN_INFO = { - "name": _("remote control"), - "main": "RemoteControl", - "description": _("universal remote control"), - "icon_symbol": "signal", -} - -NOTE_TITLE = _("Media Player Remote Control") - - -class RemoteItemWidget(ItemWidget): - - def __init__(self, device_jid, node, name, main_wid, **kw): - self.device_jid = device_jid - self.node = node - super(RemoteItemWidget, self).__init__(name=name, main_wid=main_wid, **kw) - - def do_item_action(self, touch): - self.main_wid.layout.clear_widgets() - player_wid = MediaPlayerControlWidget(main_wid=self.main_wid, remote_item=self) - self.main_wid.layout.add_widget(player_wid) - - -class MediaPlayerControlWidget(BoxLayout): - main_wid = properties.ObjectProperty() - remote_item = properties.ObjectProperty() - status = properties.OptionProperty("play", options=("play", "pause", "stop")) - title = properties.StringProperty() - identity = properties.StringProperty() - command = properties.DictProperty() - ui_tpl = properties.ObjectProperty() - - @property - def profile(self): - return self.main_wid.profile - - def update_ui(self, action_data_s): - action_data = data_format.deserialise(action_data_s) - xmlui_raw = action_data['xmlui'] - ui_tpl = template_xmlui.create(G.host, xmlui_raw) - self.ui_tpl = ui_tpl - for prop in ('Title', 'Identity'): - try: - setattr(self, prop.lower(), ui_tpl.widgets[prop].value) - except KeyError: - log.warning(_("Missing field: {name}").format(name=prop)) - playback_status = self.ui_tpl.widgets['PlaybackStatus'].value - if playback_status == "Playing": - self.status = "pause" - elif playback_status == "Paused": - self.status = "play" - elif playback_status == "Stopped": - self.status = "play" - else: - G.host.add_note( - title=NOTE_TITLE, - message=_("Unknown playback status: playback_status") - .format(playback_status=playback_status), - level=C.XMLUI_DATA_LVL_WARNING) - self.commands = {v:k for k,v in ui_tpl.widgets['command'].options} - - def ad_hoc_run_cb(self, xmlui_raw): - ui_tpl = template_xmlui.create(G.host, xmlui_raw) - data = {xmlui.XMLUIPanel.escape("media_player"): self.remote_item.node, - "session_id": ui_tpl.session_id} - G.host.bridge.action_launch( - ui_tpl.submit_id, data_format.serialise(data), - self.profile, callback=self.update_ui, - errback=self.main_wid.errback - ) - - def on_remote_item(self, __, remote): - NS_MEDIA_PLAYER = G.host.ns_map["mediaplayer"] - G.host.bridge.ad_hoc_run(str(remote.device_jid), NS_MEDIA_PLAYER, self.profile, - callback=self.ad_hoc_run_cb, - errback=self.main_wid.errback) - - def do_cmd(self, command): - try: - cmd_value = self.commands[command] - except KeyError: - G.host.add_note( - title=NOTE_TITLE, - message=_("{command} command is not managed").format(command=command), - level=C.XMLUI_DATA_LVL_WARNING) - else: - data = {xmlui.XMLUIPanel.escape("command"): cmd_value, - "session_id": self.ui_tpl.session_id} - # hidden values are normally transparently managed by XMLUIPanel - # but here we have to add them by hand - hidden = {xmlui.XMLUIPanel.escape(k):v - for k,v in self.ui_tpl.hidden.items()} - data.update(hidden) - G.host.bridge.action_launch( - self.ui_tpl.submit_id, data_format.serialise(data), self.profile, - callback=self.update_ui, errback=self.main_wid.errback - ) - - -class RemoteDeviceWidget(DeviceWidget): - - def xmlui_cb(self, data, cb_id, profile): - if 'xmlui' in data: - xml_ui = xmlui.create( - G.host, data['xmlui'], callback=self.xmlui_cb, profile=profile) - if isinstance(xml_ui, xmlui.XMLUIDialog): - self.main_wid.show_root_widget() - xml_ui.show() - else: - xml_ui.set_close_cb(self.on_close) - self.main_wid.layout.add_widget(xml_ui) - else: - if data: - log.warning(_("Unhandled data: {data}").format(data=data)) - self.main_wid.show_root_widget() - - def on_close(self, __, reason): - if reason == C.XMLUI_DATA_CANCELLED: - self.main_wid.show_root_widget() - else: - self.main_wid.layout.clear_widgets() - - def ad_hoc_run_cb(self, data): - xml_ui = xmlui.create(G.host, data, callback=self.xmlui_cb, profile=self.profile) - xml_ui.set_close_cb(self.on_close) - self.main_wid.layout.add_widget(xml_ui) - - def do_item_action(self, touch): - self.main_wid.layout.clear_widgets() - G.host.bridge.ad_hoc_run(str(self.entity_jid), '', self.profile, - callback=self.ad_hoc_run_cb, errback=self.main_wid.errback) - - -class DevicesLayout(FloatLayout): - """Layout used to show devices""" - layout = properties.ObjectProperty() - - -class RemoteControl(quick_widgets.QuickWidget, cagou_widget.CagouWidget, FilterBehavior, - TouchMenuBehavior): - SINGLE=False - layout = properties.ObjectProperty() - - def __init__(self, host, target, profiles): - quick_widgets.QuickWidget.__init__(self, host, target, profiles) - cagou_widget.CagouWidget.__init__(self) - FilterBehavior.__init__(self) - TouchMenuBehavior.__init__(self) - self.stack_layout = None - self.show_root_widget() - - def errback(self, failure_): - """Generic errback which add a warning note and go back to root widget""" - G.host.add_note( - title=NOTE_TITLE, - message=_("Can't use remote control: {reason}").format(reason=failure_), - level=C.XMLUI_DATA_LVL_WARNING) - self.show_root_widget() - - def key_input(self, window, key, scancode, codepoint, modifier): - if key == 27: - self.show_root_widget() - return True - - def show_root_widget(self): - self.layout.clear_widgets() - devices_layout = DevicesLayout() - self.stack_layout = devices_layout.layout - self.layout.add_widget(devices_layout) - found = [] - self.get_remotes(found) - self.discover_devices(found) - - def ad_hoc_remotes_get_cb(self, remotes_data, found): - found.insert(0, remotes_data) - if len(found) == 2: - self.show_devices(found) - - def ad_hoc_remotes_get_eb(self, failure_, found): - G.host.errback(failure_, title=_("discovery error"), - message=_("can't check remote controllers: {msg}")) - found.insert(0, []) - if len(found) == 2: - self.show_devices(found) - - def get_remotes(self, found): - self.host.bridge.ad_hoc_remotes_get( - self.profile, - callback=partial(self.ad_hoc_remotes_get_cb, found=found), - errback=partial(self.ad_hoc_remotes_get_eb,found=found)) - - def _disco_find_by_features_cb(self, data, found): - found.append(data) - if len(found) == 2: - self.show_devices(found) - - def _disco_find_by_features_eb(self, failure_, found): - G.host.errback(failure_, title=_("discovery error"), - message=_("can't check devices: {msg}")) - found.append(({}, {}, {})) - if len(found) == 2: - self.show_devices(found) - - def discover_devices(self, found): - """Looks for devices handling file "File Information Sharing" and display them""" - try: - namespace = self.host.ns_map['commands'] - except KeyError: - msg = _("can't find ad-hoc commands namespace, is the plugin running?") - log.warning(msg) - G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR) - return - self.host.bridge.disco_find_by_features( - [namespace], [], False, True, True, True, False, self.profile, - callback=partial(self._disco_find_by_features_cb, found=found), - errback=partial(self._disco_find_by_features_eb, found=found)) - - def show_devices(self, found): - remotes_data, (entities_services, entities_own, entities_roster) = found - if remotes_data: - title = _("media players remote controls") - self.stack_layout.add_widget(CategorySeparator(text=title)) - - for remote_data in remotes_data: - device_jid, node, name = remote_data - wid = RemoteItemWidget(device_jid, node, name, self) - self.stack_layout.add_widget(wid) - - for entities_map, title in ((entities_services, - _('services')), - (entities_own, - _('your devices')), - (entities_roster, - _('your contacts devices'))): - if entities_map: - self.stack_layout.add_widget(CategorySeparator(text=title)) - for entity_str, entity_ids in entities_map.items(): - entity_jid = jid.JID(entity_str) - item = RemoteDeviceWidget( - self, entity_jid, Identities(entity_ids)) - self.stack_layout.add_widget(item) - if (not remotes_data and not entities_services and not entities_own - and not entities_roster): - self.stack_layout.add_widget(Label( - size_hint=(1, 1), - halign='center', - text_size=self.size, - text=_("No sharing device found")))
--- a/cagou/plugins/plugin_wid_settings.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,15 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>.
--- a/cagou/plugins/plugin_wid_settings.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 - - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -from sat.core.i18n import _ -from sat.core.constants import Const as C -from sat.tools.common import data_format -from sat_frontends.quick_frontend import quick_widgets -from kivy.uix.label import Label -from kivy.uix.widget import Widget -from cagou.core import cagou_widget -from cagou import G - - -log = logging.getLogger(__name__) - - -PLUGIN_INFO = { - "name": _("settings"), - "main": "CagouSettings", - "description": _("Cagou/SàT settings"), - "icon_symbol": "wrench", -} - - -class CagouSettings(quick_widgets.QuickWidget, cagou_widget.CagouWidget): - # XXX: this class can't be called "Settings", because Kivy has already a class - # of this name, and the kv there would apply - - def __init__(self, host, target, profiles): - quick_widgets.QuickWidget.__init__(self, G.host, target, profiles) - cagou_widget.CagouWidget.__init__(self) - # the Widget() avoid CagouWidget header to be down at the beginning - # then up when the UI is loaded - self.loading_widget = Widget() - self.add_widget(self.loading_widget) - extra = {} - G.local_platform.update_params_extra(extra) - G.host.bridge.param_ui_get( - -1, C.APP_NAME, data_format.serialise(extra), self.profile, - callback=self.get_params_ui_cb, - errback=self.get_params_ui_eb) - - def change_widget(self, widget): - self.clear_widgets([self.loading_widget]) - del self.loading_widget - self.add_widget(widget) - - def get_params_ui_cb(self, xmlui): - G.host.action_manager({"xmlui": xmlui}, ui_show_cb=self.change_widget, profile=self.profile) - - def get_params_ui_eb(self, failure): - self.change_widget(Label( - text=_("Can't load parameters!"), - bold=True, - color=(1,0,0,1))) - G.host.show_dialog("Can't load params UI", str(failure), "error")
--- a/cagou/plugins/plugin_wid_widget_selector.kv Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,48 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -<WidgetSelItem>: - size_hint: (1, None) - height: dp(40) - item: item - Widget: - BoxLayout: - id: item - size_hint: None, 1 - spacing: dp(10) - ActionIcon: - plugin_info: root.plugin_info - size_hint: None, 1 - width: self.height - Label: - text: root.plugin_info["name"] - bold: True - valign: 'middle' - font_size: sp(20) - size_hint: None, 1 - width: self.texture_size[0] - Widget: - - -<WidgetSelector>: - spacing: dp(10) - container: container - ScrollView: - BoxLayout: - orientation: "vertical" - size_hint: 1, None - height: self.minimum_height - id: container
--- a/cagou/plugins/plugin_wid_widget_selector.py Fri Jun 02 17:53:09 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from cagou.core.constants import Const as C -from kivy.uix.widget import Widget -from kivy.uix.boxlayout import BoxLayout -from kivy import properties -from kivy.uix.behaviors import ButtonBehavior -from cagou.core import cagou_widget -from cagou import G - - -PLUGIN_INFO = { - "name": _("widget selector"), - "import_name": C.WID_SELECTOR, - "main": "WidgetSelector", - "description": _("show available widgets and allow to select one"), - "icon_medium": "{media}/icons/muchoslava/png/selector_no_border_blue_44.png" -} - - -class WidgetSelItem(ButtonBehavior, BoxLayout): - plugin_info = properties.DictProperty() - item = properties.ObjectProperty() - - def on_release(self, *args): - log.debug("widget selection: {}".format(self.plugin_info["name"])) - factory = self.plugin_info["factory"] - G.host.switch_widget( - self, factory(self.plugin_info, None, profiles=iter(G.host.profiles))) - - -class WidgetSelector(cagou_widget.CagouWidget): - container = properties.ObjectProperty() - - def __init__(self): - super(WidgetSelector, self).__init__() - self.items = [] - for plugin_info in G.host.get_plugged_widgets(except_cls=self.__class__): - item = WidgetSelItem(plugin_info=plugin_info) - self.items.append(item.item) - item.item.bind(minimum_width=self.adjust_width) - self.container.add_widget(item) - self.container.add_widget(Widget()) - - def adjust_width(self, label, texture_size): - width = max([i.minimum_width for i in self.items]) - for i in self.items: - i.width = width - - def key_input(self, window, key, scancode, codepoint, modifier): - # we pass to avoid default CagouWidget which is going back to default widget - # (which is this one) - pass - - @classmethod - def factory(cls, plugin_info, target, profiles): - return cls() - - -PLUGIN_INFO["factory"] = WidgetSelector.factory
--- a/doc/conf.py Fri Jun 02 17:53:09 2023 +0200 +++ b/doc/conf.py Fri Jun 02 18:26:16 2023 +0200 @@ -19,8 +19,8 @@ # -- Project information ----------------------------------------------------- -project = u'Cagou (Salut à Toi)' -copyright = u'2019-2021 Jérôme Poisson' +project = u'Libervia Desktop-Kivy' +copyright = u'2019-2023 Jérôme Poisson' author = u'Jérôme Poisson' # The short X.Y version @@ -101,7 +101,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'CagouSalutToidoc' +htmlhelp_basename = 'LiberviaDesktopKivySalutToidoc' # -- Options for LaTeX output ------------------------------------------------ @@ -128,7 +128,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'CagouSalutToi.tex', u'Cagou (Salut à Toi) Documentation', + (master_doc, 'LiberviaDesktopKivySalutToi.tex', u'LiberviaDesktopKivy (Salut à Toi) Documentation', u'Jérôme Poisson', 'manual'), ] @@ -138,7 +138,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'cagousaluttoi', u'Cagou (Salut à Toi) Documentation', + (master_doc, 'cagousaluttoi', u'LiberviaDesktopKivy (Salut à Toi) Documentation', [author], 1) ] @@ -149,8 +149,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'CagouSalutToi', u'Cagou (Salut à Toi) Documentation', - author, 'CagouSalutToi', 'One line description of project.', + (master_doc, 'LiberviaDesktopKivySalutToi', u'LiberviaDesktopKivy (Salut à Toi) Documentation', + author, 'LiberviaDesktopKivySalutToi', 'One line description of project.', 'Miscellaneous'), ]
--- a/doc/index.rst Fri Jun 02 17:53:09 2023 +0200 +++ b/doc/index.rst Fri Jun 02 18:26:16 2023 +0200 @@ -1,16 +1,16 @@ -.. Cagou (Salut à Toi) documentation master file, created by +.. Libervia Desktop-Kivy documentation master file, created by sphinx-quickstart on Tue Jul 23 20:07:36 2019. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Libervia Desktop/Mobile documentation +Libervia Desktop/Mobile (Kivy) documentation ===================================== -Welcome to Cagou's documentation. You'll find here both end-user end developer documentations. +Welcome to Libervia Desktop-Kivy's documentation. You'll find here both end-user end developer documentations. -Cagou is the desktop/mobile frontend of Salut à Toi (or SàT), a Libre communication ecosystem based on XMPP standard. +Libervia Desktop-Kivy is the desktop/mobile frontend for Libervia, a Libre communication ecosystem based on XMPP standard. -You can follow this documentation to learn more on it, or join our official XMPP room at `sat@chat.jabberfr.org <xmpp:sat@chat.jabberfr.org?join>`_ (also available via a `web link <https://chat.jabberfr.org/converse.js/sat@chat.jabberfr.org>`_) +You can follow this documentation to learn more on it, or join our official XMPP room at `libervia@chat.jabberfr.org <xmpp:libervia@chat.jabberfr.org?join>`_ (also available via a `web link <https://chat.jabberfr.org/converse.js/libervia@chat.jabberfr.org>`_) .. toctree::
--- a/doc/installation.rst Fri Jun 02 17:53:09 2023 +0200 +++ b/doc/installation.rst Fri Jun 02 18:26:16 2023 +0200 @@ -2,73 +2,50 @@ Installation ============ -This are the instructions to install Cagou (SàT) using Python. -Note that if you are using GNU/Linux, Cagou may already be present on your distribution. - -Cagou is a Salut à Toi frontend, the SàT backend must be installed first (if you -haven't installed it yet, it will be downloaded automatically as it is a dependency of -Cagou). Cagou and SàT backend must always have the same version. +This are the instructions to install Libervia Desktop-Kivy using Python. +Note that if you are using GNU/Linux, Libervia Desktop-Kivy may already be present on your distribution. -We recommend to use development version for now, until the release of 0.7 version which -will be "general public" version. +Libervia Desktop-Kivy is a frontend, the Libervia backend must be installed first (if you +haven't installed it yet, it will be downloaded automatically as it is a dependency of +Libervia Desktop-Kivy). Libervia Desktop-Kivy and Libervia Backend must always have the +same version. -Also note that Cagou as all SàT ecosystem is still using Python 2 (this will change for -0.8 version which will be Python 3 only), so all instructions below have to be made using -python 2. +We recommend to use development version for now. Development Version ------------------- -*Note for Arch users: a pkgbuild is available for your distribution on -AUR, check sat-cagou-hg (as well as other sat-\* packages).* - -You can install the latest development version using pip. Please check backend documentation -to see the system dependencies needed. - -You can use the same virtual environment as the one used for installing the backend. If -you haven't installed it yet, just select a location when you want to install it, for -instance your home directory:: - - $ cd +The simplest way to install Libervia Desktop-Kivy at the moment is with `pipx`_:: -And enter the following commands (note that *virtualenv2* may be named -*virtualenv* on some distributions, just be sure it's Python **2** version):: - - $ virtualenv2 env - $ source env/bin/activate - $ pip install hg+https://repos.goffi.org/cagou - -If you haven't done it for the backend, you need to install the media:: - - $ cd - $ hg clone https://repos.goffi.org/sat_media + $ pipx install --system-site-packages hg+https://repos.goffi.org/libervia-desktop#egg=libervia-desktop Usage ===== -To launch Cagou enter:: +To launch Libervia Desktop-Kivy enter:: - $ cagou + $ libervia-desktop_kivy If you want to connect directly a profile:: - $ cagou -p profile_name + $ libervia-desktop_kivy -p profile_name Once started, you can use ``F11`` to switch fullscreen mode. You can show/hide the menu with ``ALT + M`` and show/hide the notification bar with ``ALT + N``. -In Cagou, notifications appear on the top of the screen, in the *notification bar*. They -appear for a few seconds, but you can click on the left Cagou icon to see them entirely -and take your time to read them. +In Libervia Desktop-Kivy, notifications appear on the top of the screen, in the +*notification bar*. They appear for a few seconds, but you can click on the left Libervia +Desktop-Kivy icon to see them entirely and take your time to read them. -There is no focus stealing pop-up in Cagou, when some event requires a user action, a Cagou -icon will appear on the right of notification bar, so user can click and interact with it -when it is suitable. +There is no focus stealing pop-up in Libervia Desktop-Kivy, when some event requires a +user action, a Libervia Desktop-Kivy icon will appear on the right of notification bar, so +user can click and interact with it when it is suitable. -Cagou has a concept of **activities**. An activity is some kind of communication tool -(chat, file sharing, remote control, etc.). On top left of each activity you have an icon -representing the activity selected. Click on it to select something else. +Libervia Desktop-Kivy has a concept of **activities**. An activity is some kind of +communication tool (chat, file sharing, remote control, etc.). On top left of each +activity you have an icon representing the activity selected. Click on it to select +something else. You may have noticed the 3 small dots on top and left border of each activity. You can click (or touch) them, and drag to the bottom or right to create a new activity. This way @@ -76,3 +53,5 @@ file sharing and the chat at the same time). To close this extra activity, click again on the 3 dots and drag in the opposite direction until the top or left line become red, then release your mouse. + +.. _pipx: https://pypa.github.io/pipx/
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/VERSION Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,1 @@ +0.9.0D
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/__init__.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +# Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os.path + + +version_file = os.path.join(os.path.dirname(__file__), 'VERSION') +with open(version_file) as f: + __version__ = f.read().strip() + +class Global(object): + @property + def host(self): + return self._host +G = Global() + +# this import must be done after G is created +from .core import cagou_main + +def run(): + host = G._host = cagou_main.LiberviaDesktopKivy() + G.local_platform = cagou_main.local_platform + host.run()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/behaviors.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from kivy import properties +from kivy.animation import Animation +from kivy.clock import Clock +from kivy_garden import modernmenu +from functools import partial + + +class TouchMenu(modernmenu.ModernMenu): + pass + + +class TouchMenuItemBehavior: + """Class to use on every item where a menu may appear + + main_wid attribute must be set to the class inheriting from TouchMenuBehavior + do_item_action is the method called on simple click + get_menu_choices must return a list of menus for long press + menus there are dict as expected by ModernMenu + (translated text, index and callback) + """ + main_wid = properties.ObjectProperty() + click_timeout = properties.NumericProperty(0.4) + + def on_touch_down(self, touch): + if not self.collide_point(*touch.pos): + return + t = partial(self.open_menu, touch) + touch.ud['menu_timeout'] = t + Clock.schedule_once(t, self.click_timeout) + return super(TouchMenuItemBehavior, self).on_touch_down(touch) + + def do_item_action(self, touch): + pass + + def on_touch_up(self, touch): + if touch.ud.get('menu_timeout'): + Clock.unschedule(touch.ud['menu_timeout']) + if self.collide_point(*touch.pos) and self.main_wid.menu is None: + self.do_item_action(touch) + return super(TouchMenuItemBehavior, self).on_touch_up(touch) + + def open_menu(self, touch, dt): + self.main_wid.open_menu(self, touch) + del touch.ud['menu_timeout'] + + def get_menu_choices(self): + """return choice adapted to selected item + + @return (list[dict]): choices ad expected by ModernMenu + """ + return [] + + +class TouchMenuBehavior: + """Class to handle a menu appearing on long press on items + + classes using this behaviour need to have a float_layout property + pointing the main FloatLayout. + """ + float_layout = properties.ObjectProperty() + + def __init__(self, *args, **kwargs): + super(TouchMenuBehavior, self).__init__(*args, **kwargs) + self.menu = None + self.menu_item = None + + ## menu methods ## + + def clean_fl_children(self, layout, children): + """insure that self.menu and self.menu_item are None when menu is dimissed""" + if self.menu is not None and self.menu not in children: + self.menu = self.menu_item = None + + def clear_menu(self): + """remove menu if there is one""" + if self.menu is not None: + self.menu.dismiss() + self.menu = None + self.menu_item = None + + def open_menu(self, item, touch): + """open menu for item + + @param item(PathWidget): item when the menu has been requested + @param touch(kivy.input.MotionEvent): touch data + """ + if self.menu_item == item: + return + self.clear_menu() + pos = self.to_widget(*touch.pos) + choices = item.get_menu_choices() + if not choices: + return + self.menu = TouchMenu(choices=choices, + center=pos, + size_hint=(None, None)) + self.float_layout.add_widget(self.menu) + self.menu.start_display(touch) + self.menu_item = item + + def on_float_layout(self, wid, float_layout): + float_layout.bind(children=self.clean_fl_children) + + +class FilterBehavior(object): + """class to handle items filtering with animation""" + + def __init__(self, *args, **kwargs): + super(FilterBehavior, self).__init__(*args, **kwargs) + self._filter_last = {} + self._filter_anim = Animation(width = 0, + height = 0, + opacity = 0, + d = 0.5) + + def do_filter(self, parent, text, get_child_text, width_cb, height_cb, + continue_tests=None): + """filter the children + + filtered children will have a animation to set width, height and opacity to 0 + @param parent(kivy.uix.widget.Widget): parent layout of the widgets to filter + @param text(unicode): filter text (if this text is not present in a child, + the child is filtered out) + @param get_child_text(callable): must retrieve child text + child is used as sole argument + @param width_cb(callable, int, None): method to retrieve width when opened + child is used as sole argument, int can be used instead of callable + @param height_cb(callable, int, None): method to retrieve height when opened + child is used as sole argument, int can be used instead of callable + @param continue_tests(list[callable]): list of test to skip the item + all callables take child as sole argument. + if any of the callable return True, the child is skipped (i.e. not filtered) + """ + text = text.strip().lower() + filtering = len(text)>len(self._filter_last.get(parent, '')) + self._filter_last[parent] = text + for child in parent.children: + if continue_tests is not None and any((t(child) for t in continue_tests)): + continue + if text in get_child_text(child).lower(): + self._filter_anim.cancel(child) + for key, method in (('width', width_cb), + ('height', height_cb), + ('opacity', lambda c: 1)): + try: + setattr(child, key, method(child)) + except TypeError: + # method is not a callable, must be an int + setattr(child, key, method) + elif (filtering + and child.opacity > 0 + and not self._filter_anim.have_properties_to_animate(child)): + self._filter_anim.start(child)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/cagou_main.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,1176 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import os.path +import glob +import sys +from pathlib import Path +from urllib import parse as urlparse +from functools import partial +from libervia.backend.core.i18n import _ +from . import kivy_hack +kivy_hack.do_hack() +from .constants import Const as C +from libervia.backend.core import log as logging +from libervia.backend.core import exceptions +from libervia.frontends.quick_frontend.quick_app import QuickApp +from libervia.frontends.quick_frontend import quick_widgets +from libervia.frontends.quick_frontend import quick_chat +from libervia.frontends.quick_frontend import quick_utils +from libervia.frontends.tools import jid +from libervia.backend.tools import utils as libervia_utils +from libervia.backend.tools import config +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import dynamic_import +from libervia.backend.tools.common import files_utils +import kivy +kivy.require('1.11.0') +import kivy.support +main_config = config.parse_main_conf(log_filenames=True) +bridge_name = config.config_get(main_config, '', 'bridge', 'dbus') +# FIXME: event loop is choosen according to bridge_name, a better way should be used +if 'dbus' in bridge_name: + kivy.support.install_gobject_iteration() +elif bridge_name in ('pb', 'embedded'): + kivy.support.install_twisted_reactor() +from kivy.app import App +from kivy.lang import Builder +from kivy import properties +from . import xmlui +from .profile_manager import ProfileManager +from kivy.clock import Clock +from kivy.uix.label import Label +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.floatlayout import FloatLayout +from kivy.uix.screenmanager import (ScreenManager, Screen, + FallOutTransition, RiseInTransition) +from kivy.uix.dropdown import DropDown +from kivy.uix.behaviors import ButtonBehavior +from kivy.core.window import Window +from kivy.animation import Animation +from kivy.metrics import dp +from .cagou_widget import LiberviaDesktopKivyWidget +from .share_widget import ShareWidget +from . import widgets_handler +from .common import IconButton +from . import dialog +from importlib import import_module +import libervia.backend +import libervia.desktop_kivy +import libervia.desktop_kivy.plugins +import libervia.desktop_kivy.kv + + +log = logging.getLogger(__name__) + + +try: + from plyer import notification +except ImportError: + notification = None + log.warning(_("Can't import plyer, some features disabled")) + + +## platform specific settings ## + +from . import platform_ +local_platform = platform_.create() +local_platform.init_platform() + + +## General Configuration ## + +# we want white background by default +Window.clearcolor = (1, 1, 1, 1) + + +class NotifsIcon(IconButton): + notifs = properties.ListProperty() + + def on_release(self): + callback, args, kwargs = self.notifs.pop(0) + callback(*args, **kwargs) + + def add_notif(self, callback, *args, **kwargs): + self.notifs.append((callback, args, kwargs)) + + +class Note(Label): + title = properties.StringProperty() + message = properties.StringProperty() + level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, + options=list(C.XMLUI_DATA_LVLS)) + symbol = properties.StringProperty() + action = properties.ObjectProperty() + + +class NoteDrop(ButtonBehavior, BoxLayout): + title = properties.StringProperty() + message = properties.StringProperty() + level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, + options=list(C.XMLUI_DATA_LVLS)) + symbol = properties.StringProperty() + action = properties.ObjectProperty() + + def on_press(self): + if self.action is not None: + self.parent.parent.select(self.action) + + +class NotesDrop(DropDown): + clear_btn = properties.ObjectProperty() + + def __init__(self, notes): + super(NotesDrop, self).__init__() + self.notes = notes + + def open(self, widget): + self.clear_widgets() + for n in self.notes: + kwargs = { + 'title': n.title, + 'message': n.message, + 'level': n.level + } + if n.symbol is not None: + kwargs['symbol'] = n.symbol + if n.action is not None: + kwargs['action'] = n.action + self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level, + symbol=n.symbol, action=n.action)) + self.add_widget(self.clear_btn) + super(NotesDrop, self).open(widget) + + def on_select(self, action_kwargs): + app = App.get_running_app() + app.host.do_action(**action_kwargs) + + +class RootHeadWidget(BoxLayout): + """Notifications widget""" + manager = properties.ObjectProperty() + notifs_icon = properties.ObjectProperty() + notes = properties.ListProperty() + HEIGHT = dp(35) + + def __init__(self): + super(RootHeadWidget, self).__init__() + self.notes_last = None + self.notes_event = None + self.notes_drop = NotesDrop(self.notes) + + def add_notif(self, callback, *args, **kwargs): + """add a notification with a callback attached + + when notification is pressed, callback is called + @param *args, **kwargs: arguments of callback + """ + self.notifs_icon.add_notif(callback, *args, **kwargs) + + def add_note(self, title, message, level, symbol, action): + kwargs = { + 'title': title, + 'message': message, + 'level': level + } + if symbol is not None: + kwargs['symbol'] = symbol + if action is not None: + kwargs['action'] = action + note = Note(**kwargs) + self.notes.append(note) + if self.notes_event is None: + self.notes_event = Clock.schedule_interval(self._display_next_note, 5) + self._display_next_note() + + def add_notif_ui(self, ui): + self.notifs_icon.add_notif(ui.show, force=True) + + def add_notif_widget(self, widget): + app = App.get_running_app() + self.notifs_icon.add_notif(app.host.show_extra_ui, widget=widget) + + def _display_next_note(self, __=None): + screen = Screen() + try: + idx = self.notes.index(self.notes_last) + 1 + except ValueError: + idx = 0 + try: + note = self.notes_last = self.notes[idx] + except IndexError: + self.notes_event.cancel() + self.notes_event = None + else: + screen.add_widget(note) + self.manager.switch_to(screen) + + +class RootBody(BoxLayout): + pass + + +class LiberviaDesktopKivyRootWidget(FloatLayout): + root_body = properties.ObjectProperty + + def __init__(self, main_widget): + super(LiberviaDesktopKivyRootWidget, self).__init__() + # header + self.head_widget = RootHeadWidget() + self.root_body.add_widget(self.head_widget) + # body + self._manager = ScreenManager() + # main widgets + main_screen = Screen(name='main') + main_screen.add_widget(main_widget) + self._manager.add_widget(main_screen) + # backend XMLUI (popups, forms, etc) + xmlui_screen = Screen(name='xmlui') + self._manager.add_widget(xmlui_screen) + # extra (file chooser, audio record, etc) + extra_screen = Screen(name='extra') + self._manager.add_widget(extra_screen) + self.root_body.add_widget(self._manager) + + def change_widget(self, widget, screen_name="main"): + """change main widget""" + if self._manager.transition.is_active: + # FIXME: workaround for what seems a Kivy bug + # TODO: report this upstream + self._manager.transition.stop() + screen = self._manager.get_screen(screen_name) + screen.clear_widgets() + screen.add_widget(widget) + + def show(self, screen="main"): + if self._manager.transition.is_active: + # FIXME: workaround for what seems a Kivy bug + # TODO: report this upstream + self._manager.transition.stop() + if self._manager.current == screen: + return + if screen == "main": + self._manager.transition = FallOutTransition() + else: + self._manager.transition = RiseInTransition() + self._manager.current = screen + + def new_action(self, handler, action_data, id_, security_limit, profile): + """Add a notification for an action""" + self.head_widget.add_notif(handler, action_data, id_, security_limit, profile) + + def add_note(self, title, message, level, symbol, action): + self.head_widget.add_note(title, message, level, symbol, action) + + def add_notif_ui(self, ui): + self.head_widget.add_notif_ui(ui) + + def add_notif_widget(self, widget): + self.head_widget.add_notif_widget(widget) + + +class LiberviaDesktopKivyApp(App): + """Kivy App for LiberviaDesktopKivy""" + c_prim = properties.ListProperty(C.COLOR_PRIM) + c_prim_light = properties.ListProperty(C.COLOR_PRIM_LIGHT) + c_prim_dark = properties.ListProperty(C.COLOR_PRIM_DARK) + c_sec = properties.ListProperty(C.COLOR_SEC) + c_sec_light = properties.ListProperty(C.COLOR_SEC_LIGHT) + c_sec_dark = properties.ListProperty(C.COLOR_SEC_DARK) + connected = properties.BooleanProperty(False) + # we have to put those constants here and not in core/constants.py + # because of the use of dp(), which would import Kivy too early + # and prevent the log hack + MARGIN_LEFT = MARGIN_RIGHT = dp(10) + + def _install_settings_keys(self, window): + # we don't want default Kivy's behaviour of displaying + # a settings screen when pressing F1 or platform specific key + return + + def build(self): + Window.bind(on_keyboard=self.key_input) + Window.bind(on_dropfile=self.on_dropfile) + wid = LiberviaDesktopKivyRootWidget(Label(text=_("Loading please wait"))) + local_platform.on_app_build(wid) + return wid + + def show_profile_manager(self): + self._profile_manager = ProfileManager() + self.root.change_widget(self._profile_manager) + + def expand(self, path, *args, **kwargs): + """expand path and replace known values + + useful in kv. Values which can be used: + - {media}: media dir + @param path(unicode): path to expand + @param *args: additional arguments used in format + @param **kwargs: additional keyword arguments used in format + """ + return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs) + + def init_frontend_state(self): + """Init state to handle paused/stopped/running on mobile OSes""" + local_platform.on_init_frontend_state() + + def on_pause(self): + return local_platform.on_pause() + + def on_resume(self): + return local_platform.on_resume() + + def on_stop(self): + return local_platform.on_stop() + + def show_head_widget(self, show=None, animation=True): + """Show/Hide the head widget + + @param show(bool, None): True to show, False to hide, None to switch + @param animation(bool): animate the show/hide if True + """ + head = self.root.head_widget + if bool(self.root.head_widget.height) == show: + return + if head.height: + if animation: + Animation(height=0, opacity=0, duration=0.3).start(head) + else: + head.height = head.opacity = 0 + else: + if animation: + Animation(height=head.HEIGHT, opacity=1, duration=0.3).start(head) + else: + head.height = head.HEIGHT + head.opacity = 1 + + def key_input(self, window, key, scancode, codepoint, modifier): + + # we first check if selected widget handles the key + if ((self.host.selected_widget is not None + and hasattr(self.host.selected_widget, 'key_input') + and self.host.selected_widget.key_input(window, key, scancode, codepoint, + modifier))): + return True + + if key == 27: + if ((self.host.selected_widget is None + or self.host.selected_widget.__class__ == self.host.default_class)): + # we are on root widget, or nothing is selected + return local_platform.on_key_back_root() + + # we disable [esc] handling, because default action is to quit app + return True + elif key == 292: + # F11: full screen + if not Window.fullscreen: + Window.fullscreen = 'auto' + else: + Window.fullscreen = False + return True + elif key == 110 and 'alt' in modifier: + # M-n we hide/show notifications + self.show_head_widget() + return True + else: + return False + + def on_dropfile(self, __, file_path): + if self.host.selected_widget is not None: + try: + on_drop_file = self.host.selected_widget.on_drop_file + except AttributeError: + log.info( + f"Select widget {self.host.selected_widget} doesn't handle file " + f"dropping") + else: + on_drop_file(Path(file_path.decode())) + + +class LiberviaDesktopKivy(QuickApp): + MB_HANDLE = False + AUTO_RESYNC = False + + def __init__(self): + if bridge_name == 'embedded': + from libervia.backend.core import main + self.libervia_backend = main.LiberviaBackend() + + bridge_module = dynamic_import.bridge(bridge_name, 'libervia.frontends.bridge') + if bridge_module is None: + log.error(f"Can't import {bridge_name} bridge") + sys.exit(3) + else: + log.debug(f"Loading {bridge_name} bridge") + super(LiberviaDesktopKivy, self).__init__(bridge_factory=bridge_module.bridge, + xmlui=xmlui, + check_options=quick_utils.check_options, + connect_bridge=False) + self._import_kv() + self.app = LiberviaDesktopKivyApp() + self.app.host = self + self.media_dir = self.app.media_dir = config.config_get(main_config, '', + 'media_dir') + self.downloads_dir = self.app.downloads_dir = config.config_get(main_config, '', + 'downloads_dir') + if not os.path.exists(self.downloads_dir): + try: + os.makedirs(self.downloads_dir) + except OSError as e: + log.warnings(_("Can't create downloads dir: {reason}").format(reason=e)) + self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png") + self.app.icon = os.path.join( + self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png" + ) + self.app.title = C.APP_NAME + # main widgets plugins + self._plg_wids = [] + # transfer widgets plugins + self._plg_wids_transfer = [] + self._import_plugins() + # visible widgets by classes + self._visible_widgets = {} + # used to keep track of last selected widget in "main" screen when changing + # root screen + self._selected_widget_main = None + self.backend_version = libervia.backend.__version__ # will be replaced by version_get() + if C.APP_VERSION.endswith('D'): + self.version = "{} {}".format( + C.APP_VERSION, + libervia_utils.get_repository_data(libervia.desktop_kivy) + ) + else: + self.version = C.APP_VERSION + + self.tls_validation = not C.bool(config.config_get(main_config, + C.CONFIG_SECTION, + 'no_certificate_validation', + C.BOOL_FALSE)) + if not self.tls_validation: + from libervia.desktop_kivy.core import patches + patches.disable_tls_validation() + log.warning("SSL certificate validation is disabled, this is unsecure!") + + local_platform.on_host_init(self) + + @property + def visible_widgets(self): + for w_list in self._visible_widgets.values(): + for w in w_list: + yield w + + @property + def default_class(self): + if self.default_wid is None: + return None + return self.default_wid['main'] + + @QuickApp.sync.setter + def sync(self, state): + QuickApp.sync.fset(self, state) + # widget are resynchronised in on_visible event, + # so we must call resync for widgets which are already visible + if state: + for w in self.visible_widgets: + try: + resync = w.resync + except AttributeError: + pass + else: + resync() + self.contact_lists.fill() + + def config_get(self, section, name, default=None): + return config.config_get(main_config, section, name, default) + + def on_bridge_connected(self): + super(LiberviaDesktopKivy, self).on_bridge_connected() + self.register_signal("otr_state", iface="plugin") + + def _bridge_eb(self, failure): + if bridge_name == "pb" and sys.platform == "android": + try: + self.retried += 1 + except AttributeError: + self.retried = 1 + if ((isinstance(failure, exceptions.BridgeExceptionNoService) + and self.retried < 100)): + if self.retried % 20 == 0: + log.debug("backend not ready, retrying ({})".format(self.retried)) + Clock.schedule_once(lambda __: self.connect_bridge(), 0.05) + return + super(LiberviaDesktopKivy, self)._bridge_eb(failure) + + def run(self): + self.connect_bridge() + self.app.bind(on_stop=self.onStop) + self.app.run() + + def onStop(self, obj): + try: + libervia_instance = self.libervia_backend + except AttributeError: + pass + else: + libervia_instance.stopService() + + def _get_version_cb(self, version): + self.backend_version = version + + def on_backend_ready(self): + super().on_backend_ready() + self.app.show_profile_manager() + self.bridge.version_get(callback=self._get_version_cb) + self.app.init_frontend_state() + if local_platform.do_post_init(): + self.post_init() + + def post_init(self, __=None): + # FIXME: resize doesn't work with SDL2 on android, so we use below_target for now + self.app.root_window.softinput_mode = "below_target" + profile_manager = self.app._profile_manager + del self.app._profile_manager + super(LiberviaDesktopKivy, self).post_init(profile_manager) + + def profile_plugged(self, profile): + super().profile_plugged(profile) + # FIXME: this won't work with multiple profiles + self.app.connected = self.profiles[profile].connected + + def _bookmarks_list_cb(self, bookmarks_dict, profile): + bookmarks = set() + for data in bookmarks_dict.values(): + bookmarks.update({jid.JID(k) for k in data.keys()}) + self.profiles[profile]._bookmarks = sorted(bookmarks) + + def profile_connected(self, profile): + self.bridge.bookmarks_list( + "muc", "all", profile, + callback=partial(self._bookmarks_list_cb, profile=profile), + errback=partial(self.errback, title=_("Bookmark error"))) + + def _default_factory_main(self, plugin_info, target, profiles): + """default factory used to create main widgets instances + + used when PLUGIN_INFO["factory"] is not set + @param plugin_info(dict): plugin datas + @param target: QuickWidget target + @param profiles(iterable): list of profiles + """ + main_cls = plugin_info['main'] + return self.widgets.get_or_create_widget(main_cls, + target, + on_new_widget=None, + profiles=iter(self.profiles)) + + def _default_factory_transfer(self, plugin_info, callback, cancel_cb, profiles): + """default factory used to create transfer widgets instances + + @param plugin_info(dict): plugin datas + @param callback(callable): method to call with path to file to transfer + @param cancel_cb(callable): call when transfer is cancelled + transfer widget must be used as first argument + @param profiles(iterable): list of profiles + None if not specified + """ + main_cls = plugin_info['main'] + return main_cls(callback=callback, cancel_cb=cancel_cb) + + ## plugins & kv import ## + + def _import_kv(self): + """import all kv files in libervia.desktop_kivy.kv""" + path = os.path.dirname(libervia.desktop_kivy.kv.__file__) + kv_files = glob.glob(os.path.join(path, "*.kv")) + # we want to be sure that base.kv is loaded first + # as it override some Kivy widgets properties + for kv_file in kv_files: + if kv_file.endswith('base.kv'): + kv_files.remove(kv_file) + kv_files.insert(0, kv_file) + break + else: + raise exceptions.InternalError("base.kv is missing") + + for kv_file in kv_files: + Builder.load_file(kv_file) + log.debug(f"kv file {kv_file} loaded") + + def _import_plugins(self): + """import all plugins""" + self.default_wid = None + plugins_path = os.path.dirname(libervia.desktop_kivy.plugins.__file__) + plugin_glob = "plugin*." + C.PLUGIN_EXT + plug_lst = [os.path.splitext(p)[0] for p in + map(os.path.basename, glob.glob(os.path.join(plugins_path, + plugin_glob)))] + + imported_names_main = set() # used to avoid loading 2 times + # plugin with same import name + imported_names_transfer = set() + for plug in plug_lst: + plugin_path = 'libervia.desktop_kivy.plugins.' + plug + + # we get type from plugin name + suff = plug[7:] + if '_' not in suff: + log.error("invalid plugin name: {}, skipping".format(plug)) + continue + plugin_type = suff[:suff.find('_')] + + # and select the variable to use according to type + if plugin_type == C.PLUG_TYPE_WID: + imported_names = imported_names_main + default_factory = self._default_factory_main + elif plugin_type == C.PLUG_TYPE_TRANSFER: + imported_names = imported_names_transfer + default_factory = self._default_factory_transfer + else: + log.error("unknown plugin type {type_} for plugin {file_}, skipping" + .format( + type_ = plugin_type, + file_ = plug + )) + continue + plugins_set = self._get_plugins_set(plugin_type) + + mod = import_module(plugin_path) + try: + plugin_info = mod.PLUGIN_INFO + except AttributeError: + plugin_info = {} + + plugin_info['plugin_file'] = plug + plugin_info['plugin_type'] = plugin_type + + if 'platforms' in plugin_info: + if sys.platform not in plugin_info['platforms']: + log.info("{plugin_file} is not used on this platform, skipping" + .format(**plugin_info)) + continue + + # import name is used to differentiate plugins + if 'import_name' not in plugin_info: + plugin_info['import_name'] = plug + if plugin_info['import_name'] in imported_names: + log.warning(_("there is already a plugin named {}, " + "ignoring new one").format(plugin_info['import_name'])) + continue + if plugin_info['import_name'] == C.WID_SELECTOR: + if plugin_type != C.PLUG_TYPE_WID: + log.error("{import_name} import name can only be used with {type_} " + "type, skipping {name}".format(type_=C.PLUG_TYPE_WID, + **plugin_info)) + continue + # if WidgetSelector exists, it will be our default widget + self.default_wid = plugin_info + + # we want everything optional, so we use plugin file name + # if actual name is not found + if 'name' not in plugin_info: + name_start = 8 + len(plugin_type) + plugin_info['name'] = plug[name_start:] + + # we need to load the kv file + if 'kv_file' not in plugin_info: + plugin_info['kv_file'] = '{}.kv'.format(plug) + kv_path = os.path.join(plugins_path, plugin_info['kv_file']) + if not os.path.exists(kv_path): + log.debug("no kv found for {plugin_file}".format(**plugin_info)) + else: + Builder.load_file(kv_path) + + # what is the main class ? + main_cls = getattr(mod, plugin_info['main']) + plugin_info['main'] = main_cls + + # factory is used to create the instance + # if not found, we use a defaut one with get_or_create_widget + if 'factory' not in plugin_info: + plugin_info['factory'] = default_factory + + # icons + for size in ('small', 'medium'): + key = 'icon_{}'.format(size) + try: + path = plugin_info[key] + except KeyError: + path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) + else: + path = path.format(media=self.media_dir) + if not os.path.isfile(path): + path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) + plugin_info[key] = path + + plugins_set.append(plugin_info) + if not self._plg_wids: + log.error(_("no widget plugin found")) + return + + # we want widgets sorted by names + self._plg_wids.sort(key=lambda p: p['name'].lower()) + self._plg_wids_transfer.sort(key=lambda p: p['name'].lower()) + + if self.default_wid is None: + # we have no selector widget, we use the first widget as default + self.default_wid = self._plg_wids[0] + + def _get_plugins_set(self, type_): + if type_ == C.PLUG_TYPE_WID: + return self._plg_wids + elif type_ == C.PLUG_TYPE_TRANSFER: + return self._plg_wids_transfer + else: + raise KeyError("{} plugin type is unknown".format(type_)) + + def get_plugged_widgets(self, type_=C.PLUG_TYPE_WID, except_cls=None): + """get available widgets plugin infos + + @param type_(unicode): type of widgets to get + one of C.PLUG_TYPE_* constant + @param except_cls(None, class): if not None, + widgets from this class will be excluded + @return (iter[dict]): available widgets plugin infos + """ + plugins_set = self._get_plugins_set(type_) + for plugin_data in plugins_set: + if plugin_data['main'] == except_cls: + continue + yield plugin_data + + def get_plugin_info(self, type_=C.PLUG_TYPE_WID, **kwargs): + """get first plugin info corresponding to filters + + @param type_(unicode): type of widgets to get + one of C.PLUG_TYPE_* constant + @param **kwargs: filter(s) to use, each key present here must also + exist and be of the same value in requested plugin info + @return (dict, None): found plugin info or None + """ + plugins_set = self._get_plugins_set(type_) + for plugin_info in plugins_set: + for k, w in kwargs.items(): + try: + if plugin_info[k] != w: + continue + except KeyError: + continue + return plugin_info + + ## widgets handling + + def new_widget(self, widget): + log.debug("new widget created: {}".format(widget)) + if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: + self.add_note("", _("room {} has been joined").format(widget.target)) + + def switch_widget(self, old, new=None): + """Replace old widget by new one + + @param old(LiberviaDesktopKivyWidget, None): LiberviaDesktopKivyWidget instance or a child + None to select automatically widget to switch + @param new(LiberviaDesktopKivyWidget): new widget instance + None to use default widget + @return (LiberviaDesktopKivyWidget): new widget + """ + if old is None: + old = self.get_widget_to_switch() + if new is None: + factory = self.default_wid['factory'] + try: + profiles = old.profiles + except AttributeError: + profiles = None + new = factory(self.default_wid, None, profiles=profiles) + to_change = None + if isinstance(old, LiberviaDesktopKivyWidget): + to_change = old + else: + for w in old.walk_reverse(): + if isinstance(w, LiberviaDesktopKivyWidget): + to_change = w + break + + if to_change is None: + raise exceptions.InternalError("no LiberviaDesktopKivyWidget found when " + "trying to switch widget") + + # selected_widget can be modified in change_widget, so we need to set it before + self.selected_widget = new + if to_change == new: + log.debug("switch_widget called with old==new, nothing to do") + return new + to_change.whwrapper.change_widget(new) + return new + + def _add_visible_widget(self, widget): + """declare a widget visible + + for internal use only! + """ + assert isinstance(widget, LiberviaDesktopKivyWidget) + log.debug(f"Visible widget: {widget}") + self._visible_widgets.setdefault(widget.__class__, set()).add(widget) + log.debug(f"visible widgets list: {self.get_visible_list(None)}") + widget.on_visible() + + def _remove_visible_widget(self, widget, ignore_missing=False): + """declare a widget not visible anymore + + for internal use only! + """ + log.debug(f"Widget not visible anymore: {widget}") + try: + self._visible_widgets[widget.__class__].remove(widget) + except KeyError as e: + if not ignore_missing: + log.error(f"trying to remove a not visible widget ({widget}): {e}") + return + log.debug(f"visible widgets list: {self.get_visible_list(None)}") + if isinstance(widget, LiberviaDesktopKivyWidget): + widget.on_not_visible() + if isinstance(widget, quick_widgets.QuickWidget): + self.widgets.delete_widget(widget) + + def get_visible_list(self, cls): + """get list of visible widgets for a given class + + @param cls(type): type of widgets to get + None to get all visible widgets + @return (set[type]): visible widgets of this class + """ + if cls is None: + ret = set() + for widgets in self._visible_widgets.values(): + for w in widgets: + ret.add(w) + return ret + try: + return self._visible_widgets[cls] + except KeyError: + return set() + + def delete_unused_widget_instances(self, widget): + """Delete instance of this widget which are not attached to a WHWrapper + + @param widget(quick_widgets.QuickWidget): reference widget + other instance of this widget will be deleted if they have no parent + """ + to_delete = [] + if isinstance(widget, quick_widgets.QuickWidget): + for w in self.widgets.get_widget_instances(widget): + if w.whwrapper is None and w != widget: + to_delete.append(w) + for w in to_delete: + log.debug("cleaning widget: {wid}".format(wid=w)) + self.widgets.delete_widget(w) + + def get_or_clone(self, widget, **kwargs): + """Get a QuickWidget if it is not in a WHWrapper, else clone it + + if an other instance of this widget exist without being in a WHWrapper + (i.e. if it is not already in use) it will be used. + """ + if widget.whwrapper is None: + if widget.parent is not None: + widget.parent.remove_widget(widget) + self.delete_unused_widget_instances(widget) + return widget + for w in self.widgets.get_widget_instances(widget): + if w.whwrapper is None: + if w.parent is not None: + w.parent.remove_widget(w) + self.delete_unused_widget_instances(w) + return w + targets = list(widget.targets) + w = self.widgets.get_or_create_widget(widget.__class__, + targets[0], + on_new_widget=None, + on_existing_widget=C.WIDGET_RECREATE, + profiles=widget.profiles, + **kwargs) + for t in targets[1:]: + w.add_target(t) + return w + + def get_widget_to_switch(self): + """Choose best candidate when we need to switch widget and old is not specified + + @return (LiberviaDesktopKivyWidget): widget to switch + """ + if (self._selected_widget_main is not None + and self._selected_widget_main.whwrapper is not None): + # we are not on the main screen, we want to switch a widget from main screen + return self._selected_widget_main + elif (self.selected_widget is not None + and isinstance(self.selected_widget, LiberviaDesktopKivyWidget) + and self.selected_widget.whwrapper is not None): + return self.selected_widget + # no widget is selected we check if we have any default widget + default_cls = self.default_class + for w in self.visible_widgets: + if isinstance(w, default_cls): + return w + + # no default widget found, we return the first widget + return next(iter(self.visible_widgets)) + + def do_action(self, action, target, profiles): + """Launch an action handler by a plugin + + @param action(unicode): action to do, can be: + - chat: open a chat widget + @param target(unicode): target of the action + @param profiles(list[unicode]): profiles to use + @return (LiberviaDesktopKivyWidget, None): new widget + """ + try: + # FIXME: Q&D way to get chat plugin, should be replaced by a clean method + # in host + plg_infos = [p for p in self.get_plugged_widgets() + if action in p['import_name']][0] + except IndexError: + log.warning("No plugin widget found to do {action}".format(action=action)) + else: + try: + # does the widget already exist? + wid = next(self.widgets.get_widgets( + plg_infos['main'], + target=target, + profiles=profiles)) + except StopIteration: + # no, let's create a new one + factory = plg_infos['factory'] + wid = factory(plg_infos, target=target, profiles=profiles) + + return self.switch_widget(None, wid) + + ## bridge handlers ## + + def otr_state_handler(self, state, dest_jid, profile): + """OTR state has changed for on destinee""" + # XXX: this method could be in QuickApp but it's here as + # it's only used by LiberviaDesktopKivy so far + dest_jid = jid.JID(dest_jid) + bare_jid = dest_jid.bare + for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)): + if widget.type == C.CHAT_ONE2ONE and widget.target == bare_jid: + widget.on_otr_state(state, dest_jid, profile) + + def _debug_handler(self, action, parameters, profile): + if action == "visible_widgets_dump": + from pprint import pformat + log.info("Visible widgets dump:\n{data}".format( + data=pformat(self._visible_widgets))) + else: + return super(LiberviaDesktopKivy, self)._debug_handler(action, parameters, profile) + + def connected_handler(self, jid_s, profile): + # FIXME: this won't work with multiple profiles + super().connected_handler(jid_s, profile) + self.app.connected = True + + def disconnected_handler(self, profile): + # FIXME: this won't work with multiple profiles + super().disconnected_handler(profile) + self.app.connected = False + + ## misc ## + + def plugging_profiles(self): + self.widgets_handler = widgets_handler.WidgetsHandler() + self.app.root.change_widget(self.widgets_handler) + + def set_presence_status(self, show='', status=None, profile=C.PROF_KEY_NONE): + log.info("Profile presence status set to {show}/{status}".format(show=show, + status=status)) + + def errback(self, failure_, title=_('error'), + message=_('error while processing: {msg}')): + self.add_note(title, message.format(msg=failure_), level=C.XMLUI_DATA_LVL_WARNING) + + def add_note(self, title, message, level=C.XMLUI_DATA_LVL_INFO, symbol=None, + action=None): + """add a note (message which disappear) to root widget's header""" + self.app.root.add_note(title, message, level, symbol, action) + + def add_notif_ui(self, ui): + """add a notification with a XMLUI attached + + @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected + """ + self.app.root.add_notif_ui(ui) + + def add_notif_widget(self, widget): + """add a notification with a Kivy widget attached + + @param widget(kivy.uix.Widget): widget to attach to notification + """ + self.app.root.add_notif_widget(widget) + + def show_ui(self, ui): + """show a XMLUI""" + self.app.root.change_widget(ui, "xmlui") + self.app.root.show("xmlui") + self._selected_widget_main = self.selected_widget + self.selected_widget = ui + + def show_extra_ui(self, widget): + """show any extra widget""" + self.app.root.change_widget(widget, "extra") + self.app.root.show("extra") + self._selected_widget_main = self.selected_widget + self.selected_widget = widget + + def close_ui(self): + self.app.root.show() + self.selected_widget = self._selected_widget_main + self._selected_widget_main = None + screen = self.app.root._manager.get_screen("extra") + screen.clear_widgets() + + def get_default_avatar(self, entity=None): + return self.app.default_avatar + + def _dialog_cb(self, cb, *args, **kwargs): + """generic dialog callback + + close dialog then call the callback with given arguments + """ + def callback(): + self.close_ui() + cb(*args, **kwargs) + return callback + + def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None): + if type in ('info', 'warning', 'error'): + self.add_note(title, message, type) + elif type == "yes/no": + wid = dialog.ConfirmDialog(title=title, message=message, + yes_cb=self._dialog_cb(answer_cb, + True, + answer_data), + no_cb=self._dialog_cb(answer_cb, + False, + answer_data) + ) + self.add_notif_widget(wid) + else: + log.warning(_("unknown dialog type: {dialog_type}").format(dialog_type=type)) + + def share(self, media_type, data): + share_wid = ShareWidget(media_type=media_type, data=data) + try: + self.show_extra_ui(share_wid) + except Exception as e: + log.error(e) + self.close_ui() + + def download_url( + self, url, callback, errback=None, options=None, dest=C.FILE_DEST_DOWNLOAD, + profile=C.PROF_KEY_NONE): + """Download an URL (decrypt it if necessary) + + @param url(str, parse.SplitResult): url to download + @param callback(callable): method to call when download is complete + @param errback(callable, None): method to call in case of error + if None, default errback will be called + @param dest(str): where the file should be downloaded: + - C.FILE_DEST_DOWNLOAD: in platform download directory + - C.FILE_DEST_CACHE: in SàT cache + @param options(dict, None): options to pass to bridge.file_download_complete + """ + if not isinstance(url, urlparse.ParseResult): + url = urlparse.urlparse(url) + if errback is None: + errback = partial( + self.errback, + title=_("Download error"), + message=_("Error while downloading {url}: {{msg}}").format(url=url.geturl())) + name = Path(url.path).name.strip() or C.FILE_DEFAULT_NAME + log.info(f"downloading/decrypting file {name!r}") + if dest == C.FILE_DEST_DOWNLOAD: + dest_path = files_utils.get_unique_name(Path(self.downloads_dir)/name) + elif dest == C.FILE_DEST_CACHE: + dest_path = '' + else: + raise exceptions.InternalError(f"Invalid dest_path: {dest_path!r}") + self.bridge.file_download_complete( + data_format.serialise({"uri": url.geturl()}), + str(dest_path), + '' if not options else data_format.serialise(options), + profile, + callback=callback, + errback=errback + ) + + def notify(self, type_, entity=None, message=None, subject=None, callback=None, + cb_args=None, widget=None, profile=C.PROF_KEY_NONE): + super().notify( + type_=type_, entity=entity, message=message, subject=subject, + callback=callback, cb_args=cb_args, widget=widget, profile=profile) + self.desktop_notif(message, title=subject) + + def desktop_notif(self, message, title='', duration=5): + global notification + if notification is not None: + try: + log.debug( + f"sending desktop notification (duration: {duration}):\n" + f"{title}\n" + f"{message}" + ) + notification.notify(title=title, + message=message, + app_name=C.APP_NAME, + app_icon=self.app.icon, + timeout=duration) + except Exception as e: + log.warning(_("Can't use notifications, disabling: {msg}").format( + msg = e)) + notification = None + + def get_parent_wh_wrapper(self, wid): + """Retrieve parent WHWrapper instance managing a widget + + @param wid(Widget): widget to check + @return (WHWrapper, None): found instance if any, else None + """ + wh = self.get_ancestor_widget(wid, widgets_handler.WHWrapper) + if wh is None: + # we may have a screen + try: + sm = wid.screen_manager + except (exceptions.InternalError, exceptions.NotFound): + return None + else: + wh = self.get_ancestor_widget(sm, widgets_handler.WHWrapper) + return wh + + def get_ancestor_widget(self, wid, cls): + """Retrieve an ancestor of given class + + @param wid(Widget): current widget + @param cls(type): class of the ancestor to retrieve + @return (Widget, None): found instance or None + """ + parent = wid.parent + while parent and not isinstance(parent, cls): + parent = parent.parent + return parent
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/cagou_widget.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from functools import total_ordering +from libervia.backend.core import log as logging +from libervia.backend.core import exceptions +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.dropdown import DropDown +from kivy.uix.screenmanager import Screen +from kivy.uix.textinput import TextInput +from kivy import properties +from libervia.desktop_kivy import G +from .common import ActionIcon +from . import menu + + +log = logging.getLogger(__name__) + + +class HeaderChoice(ButtonBehavior, BoxLayout): + pass + + +class HeaderChoiceWidget(HeaderChoice): + cagou_widget = properties.ObjectProperty() + plugin_info = properties.ObjectProperty() + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.bind(on_release=lambda btn: self.cagou_widget.switch_widget( + self.plugin_info)) + + +class HeaderChoiceExtraMenu(HeaderChoice): + pass + + +class HeaderWidgetCurrent(ButtonBehavior, ActionIcon): + pass + + +class HeaderWidgetSelector(DropDown): + + def __init__(self, cagou_widget): + super(HeaderWidgetSelector, self).__init__() + plg_info_cls = cagou_widget.plugin_info_class or cagou_widget.__class__ + for plugin_info in G.host.get_plugged_widgets(except_cls=plg_info_cls): + choice = HeaderChoiceWidget( + cagou_widget=cagou_widget, + plugin_info=plugin_info, + ) + self.add_widget(choice) + main_menu = HeaderChoiceExtraMenu(on_press=self.on_extra_menu) + self.add_widget(main_menu) + + def add_widget(self, *args): + widget = args[0] + widget.bind(minimum_width=self.set_width) + return super(HeaderWidgetSelector, self).add_widget(*args) + + def set_width(self, choice, minimum_width): + self.width = max([c.minimum_width for c in self.container.children]) + + def on_extra_menu(self, *args): + self.dismiss() + menu.ExtraSideMenu().show() + + +@total_ordering +class LiberviaDesktopKivyWidget(BoxLayout): + main_container = properties.ObjectProperty(None) + header_input = properties.ObjectProperty(None) + header_box = properties.ObjectProperty(None) + use_header_input = False + # set to True if you want to be able to switch between visible widgets of this + # class using a carousel + collection_carousel = False + # set to True if you a global ScreenManager global to all widgets of this class. + # The screen manager is created in WHWrapper + global_screen_manager = False + # override this if a specific class (i.e. not self.__class__) must be used for + # plugin info. Useful when a LiberviaDesktopKivyWidget is used with global_screen_manager. + plugin_info_class = None + + def __init__(self, **kwargs): + plg_info_cls = self.plugin_info_class or self.__class__ + for p in G.host.get_plugged_widgets(): + if p['main'] == plg_info_cls: + self.plugin_info = p + break + super().__init__(**kwargs) + self.selector = HeaderWidgetSelector(self) + if self.use_header_input: + self.header_input = TextInput( + background_normal=G.host.app.expand( + '{media}/misc/borders/border_hollow_light.png'), + multiline=False, + ) + self.header_input.bind( + on_text_validate=lambda *args: self.on_header_wid_input(), + text=self.on_header_wid_input_complete, + ) + self.header_box.add_widget(self.header_input) + + def __lt__(self, other): + # XXX: sorting is notably used when collection_carousel is set + try: + target = str(self.target) + except AttributeError: + target = str(list(self.targets)[0]) + other_target = str(list(other.targets)[0]) + else: + other_target = str(other.target) + return target < other_target + + @property + def screen_manager(self): + if ((not self.global_screen_manager + and not (self.plugin_info_class is not None + and self.plugin_info_class.global_screen_manager))): + raise exceptions.InternalError( + "screen_manager property can't be used if global_screen_manager is not " + "set") + screen = self.get_ancestor(Screen) + if screen is None: + raise exceptions.NotFound("Can't find parent Screen") + if screen.manager is None: + raise exceptions.NotFound("Can't find parent ScreenManager") + return screen.manager + + @property + def whwrapper(self): + """Retrieve parent widget handler""" + return G.host.get_parent_wh_wrapper(self) + + def screen_manager_init(self, screen_manager): + """Override this method to do init when ScreenManager is instantiated + + This is only called once even if collection_carousel is used. + """ + if not self.global_screen_manager: + raise exceptions.InternalError("screen_manager_init should not be called") + + def get_ancestor(self, cls): + """Helper method to use host.get_ancestor_widget with self""" + return G.host.get_ancestor_widget(self, cls) + + def switch_widget(self, plugin_info): + self.selector.dismiss() + factory = plugin_info["factory"] + new_widget = factory(plugin_info, None, iter(G.host.profiles)) + G.host.switch_widget(self, new_widget) + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + # we go back to root screen + G.host.switch_widget(self) + return True + + def on_header_wid_input(self): + log.info("header input text entered") + + def on_header_wid_input_complete(self, wid, text): + return + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + G.host.selected_widget = self + return super(LiberviaDesktopKivyWidget, self).on_touch_down(touch) + + def header_input_add_extra(self, widget): + """add a widget on the right of header input""" + self.header_box.add_widget(widget) + + def on_visible(self): + pass + # log.debug(u"{self} is visible".format(self=self)) + + def on_not_visible(self): + pass + # log.debug(u"{self} is not visible anymore".format(self=self))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/common.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""common simple widgets""" + +import json +from functools import partial, total_ordering +from kivy.uix.widget import Widget +from kivy.uix.label import Label +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.behaviors import ToggleButtonBehavior +from kivy.uix.stacklayout import StackLayout +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.scrollview import ScrollView +from kivy.event import EventDispatcher +from kivy.metrics import dp +from kivy import properties +from libervia.backend.core.i18n import _ +from libervia.backend.core import log as logging +from libervia.backend.tools.common import data_format +from libervia.frontends.quick_frontend import quick_chat +from .constants import Const as C +from .common_widgets import CategorySeparator +from .image import Image, AsyncImage +from libervia.desktop_kivy import G + +log = logging.getLogger(__name__) + +UNKNOWN_SYMBOL = 'Unknown symbol name' + + +class IconButton(ButtonBehavior, Image): + pass + + +class Avatar(Image): + data = properties.DictProperty(allownone=True) + + def on_kv_post(self, __): + if not self.source: + self.source = G.host.get_default_avatar() + + def on_data(self, __, data): + if data is None: + self.source = G.host.get_default_avatar() + else: + self.source = data['path'] + + +class NotifLabel(Label): + pass + +@total_ordering +class ContactItem(BoxLayout): + """An item from ContactList + + The item will drawn as an icon (JID avatar) with its jid below. + If "badge_text" is set, a label with the text will be drawn above the avatar. + """ + base_width = dp(150) + avatar_layout = properties.ObjectProperty() + avatar = properties.ObjectProperty() + badge = properties.ObjectProperty(allownone=True) + badge_text = properties.StringProperty('') + profile = properties.StringProperty() + data = properties.DictProperty() + jid = properties.StringProperty('') + + def on_kv_post(self, __): + if ((self.profile and self.jid and self.data is not None + and ('avatar' not in self.data or 'nicknames' not in self.data))): + G.host.bridge.identity_get( + self.jid, ['avatar', 'nicknames'], True, self.profile, + callback=self._identity_get_cb, + errback=partial( + G.host.errback, + message=_("Can't retrieve identity for {jid}: {{msg}}").format( + jid=self.jid) + ) + ) + + def _identity_get_cb(self, identity_raw): + identity_data = data_format.deserialise(identity_raw) + self.data.update(identity_data) + + def on_badge_text(self, wid, text): + if text: + if self.badge is not None: + self.badge.text = text + else: + self.badge = NotifLabel( + pos_hint={"right": 0.8, "y": 0}, + text=text, + ) + self.avatar_layout.add_widget(self.badge) + else: + if self.badge is not None: + self.avatar_layout.remove_widget(self.badge) + self.badge = None + + def __lt__(self, other): + return self.jid < other.jid + + +class ContactButton(ButtonBehavior, ContactItem): + pass + + +class JidItem(BoxLayout): + bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1]) + color = properties.ListProperty([1, 1, 1, 1]) + jid = properties.StringProperty() + profile = properties.StringProperty() + nick = properties.StringProperty() + avatar = properties.ObjectProperty() + + def on_avatar(self, wid, jid_): + if self.jid and self.profile: + self.get_image() + + def on_jid(self, wid, jid_): + if self.profile and self.avatar: + self.get_image() + + def on_profile(self, wid, profile): + if self.jid and self.avatar: + self.get_image() + + def get_image(self): + host = G.host + if host.contact_lists[self.profile].is_room(self.jid.bare): + self.avatar.opacity = 0 + self.avatar.source = "" + else: + self.avatar.source = ( + host.get_avatar(self.jid, profile=self.profile) + or host.get_default_avatar(self.jid) + ) + + +class JidButton(ButtonBehavior, JidItem): + pass + + +class JidToggle(ToggleButtonBehavior, JidItem): + selected_color = properties.ListProperty(C.COLOR_SEC_DARK) + + +class Symbol(Label): + symbol_map = None + symbol = properties.StringProperty() + + def __init__(self, **kwargs): + if self.symbol_map is None: + with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f: + fontello_conf = json.load(f) + Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']} + + super(Symbol, self).__init__(**kwargs) + + def on_symbol(self, instance, symbol): + try: + code = self.symbol_map[symbol] + except KeyError: + log.warning(_("Invalid symbol {symbol}").format(symbol=symbol)) + else: + self.text = chr(code) + + +class SymbolButton(ButtonBehavior, Symbol): + pass + + +class SymbolLabel(BoxLayout): + symbol = properties.StringProperty("") + text = properties.StringProperty("") + color = properties.ListProperty(C.COLOR_SEC) + bold = properties.BooleanProperty(True) + symbol_wid = properties.ObjectProperty() + label = properties.ObjectProperty() + + +class SymbolButtonLabel(ButtonBehavior, SymbolLabel): + pass + + +class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel): + pass + + +class ActionSymbol(Symbol): + pass + + +class ActionIcon(BoxLayout): + plugin_info = properties.DictProperty() + + def on_plugin_info(self, instance, plugin_info): + self.clear_widgets() + try: + symbol = plugin_info['icon_symbol'] + except KeyError: + icon_src = plugin_info['icon_medium'] + icon_wid = Image(source=icon_src, allow_stretch=True) + self.add_widget(icon_wid) + else: + icon_wid = ActionSymbol(symbol=symbol) + self.add_widget(icon_wid) + + +class SizedImage(AsyncImage): + """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT""" + # following properties are desired height/width + # i.e. the ones specified in height/width attributes of <img> + # (or wanted for whatever reason) + # set to None to ignore them + target_height = properties.NumericProperty(allownone=True) + target_width = properties.NumericProperty(allownone=True) + + def __init__(self, **kwargs): + # best calculated size + self._best_width = self._best_height = 100 + super().__init__(**kwargs) + + def on_texture(self, instance, texture): + """Adapt the size according to max size and target_*""" + if texture is None: + return + max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT) + width, height = texture.size + if self.target_width: + width = min(width, self.target_width) + if width > max_width: + width = C.IMG_MAX_WIDTH + + height = width / self.image_ratio + + if self.target_height: + height = min(height, self.target_height) + + if height > max_height: + height = max_height + width = height * self.image_ratio + + self.width, self.height = self._best_width, self._best_height = width, height + + def on_parent(self, instance, parent): + if parent is not None: + parent.bind(width=self.on_parent_width) + + def on_parent_width(self, instance, width): + if self._best_width > width: + self.width = width + self.height = width / self.image_ratio + else: + self.width, self.height = self._best_width, self._best_height + + +class JidSelectorCategoryLayout(StackLayout): + pass + + +class JidSelector(ScrollView, EventDispatcher): + layout = properties.ObjectProperty(None) + # if item_class is changed, the properties must be the same as for ContactButton + # and ordering must be supported + item_class = properties.ObjectProperty(ContactButton) + add_separators = properties.ObjectProperty(True) + # list of item to show, can be: + # - a well-known string which can be: + # * "roster": all roster jids + # * "opened_chats": all opened chat widgets + # * "bookmarks": MUC bookmarks + # A layout will be created each time and stored in the attribute of the same + # name. + # If add_separators is True, a CategorySeparator will be added on top of each + # layout. + # - a kivy Widget, which will be added to the layout (notable useful with + # common_widgets.CategorySeparator) + # - a callable, which must return an iterable of kwargs for ContactButton + to_show = properties.ListProperty(['roster']) + + # TODO: roster and bookmarks must be updated in real time, like for opened_chats + + + def __init__(self, **kwargs): + self.register_event_type('on_select') + # list of layouts containing items + self.items_layouts = [] + # jid to list of ContactButton instances map + self.items_map = {} + super().__init__(**kwargs) + + def on_kv_post(self, wid): + self.update() + + def on_select(self, wid): + pass + + def on_parent(self, wid, parent): + if parent is None: + log.debug("removing listeners") + G.host.removeListener("contactsFilled", self.on_contacts_filled) + G.host.removeListener("notification", self.on_notification) + G.host.removeListener("notificationsClear", self.on_notifications_clear) + G.host.removeListener( + "widgetNew", self.on_widget_new, ignore_missing=True) + G.host.removeListener( + "widgetDeleted", self.on_widget_deleted, ignore_missing=True) + else: + log.debug("adding listeners") + G.host.addListener("contactsFilled", self.on_contacts_filled) + G.host.addListener("notification", self.on_notification) + G.host.addListener("notificationsClear", self.on_notifications_clear) + + def on_contacts_filled(self, profile): + log.debug("on_contacts_filled event received") + self.update() + + def on_notification(self, entity, notification_data, profile): + for item in self.items_map.get(entity.bare, []): + notifs = list(G.host.get_notifs(entity.bare, profile=profile)) + item.badge_text = str(len(notifs)) + + def on_notifications_clear(self, entity, type_, profile): + for item in self.items_map.get(entity.bare, []): + item.badge_text = '' + + def on_widget_new(self, wid): + if not isinstance(wid, quick_chat.QuickChat): + return + item = self.get_item_from_wid(wid) + if item is None: + return + idx = 0 + for child in self.opened_chats.children: + if isinstance(child, self.item_class) and child < item: + break + idx+=1 + self.opened_chats.add_widget(item, index=idx) + + def on_widget_deleted(self, wid): + if not isinstance(wid, quick_chat.QuickChat): + return + + for child in self.opened_chats.children: + if not isinstance(child, self.item_class): + continue + if child.jid.bare == wid.target.bare: + self.opened_chats.remove_widget(child) + break + + def _create_item(self, **kwargs): + item = self.item_class(**kwargs) + jid = kwargs['jid'] + self.items_map.setdefault(jid, []).append(item) + return item + + def update(self): + log.debug("starting update") + self.layout.clear_widgets() + for item in self.to_show: + if isinstance(item, str): + if item == 'roster': + self.add_roster_items() + elif item == 'bookmarks': + self.add_bookmarks_items() + elif item == 'opened_chats': + self.add_opened_chats_items() + else: + log.error(f'unknown "to_show" magic string {item!r}') + elif isinstance(item, Widget): + self.layout.add_widget(item) + elif callable(item): + items_kwargs = item() + for item_kwargs in items_kwargs: + item = self._create_item(**items_kwargs) + item.bind(on_press=partial(self.dispatch, 'on_select')) + self.layout.add_widget(item) + else: + log.error(f"unmanaged to_show item type: {item!r}") + + def add_category_layout(self, label=None): + category_layout = JidSelectorCategoryLayout() + + if label and self.add_separators: + category_layout.add_widget(CategorySeparator(text=label)) + + self.layout.add_widget(category_layout) + self.items_layouts.append(category_layout) + return category_layout + + def get_item_from_wid(self, wid): + """create JidSelector item from QuickChat widget""" + contact_list = G.host.contact_lists[wid.profile] + try: + data=contact_list.get_item(wid.target) + except KeyError: + log.warning(f"Can't find item data for {wid.target}") + data={} + try: + item = self._create_item( + jid=wid.target, + data=data, + profile=wid.profile, + ) + except Exception as e: + log.warning(f"Can't add contact {wid.target}: {e}") + return + notifs = list(G.host.get_notifs(wid.target, profile=wid.profile)) + if notifs: + item.badge_text = str(len(notifs)) + item.bind(on_press=partial(self.dispatch, 'on_select')) + return item + + def add_opened_chats_items(self): + G.host.addListener("widgetNew", self.on_widget_new) + G.host.addListener("widgetDeleted", self.on_widget_deleted) + self.opened_chats = category_layout = self.add_category_layout(_("Opened chats")) + widgets = sorted(G.host.widgets.get_widgets( + quick_chat.QuickChat, + profiles = G.host.profiles, + with_duplicates=False)) + + for wid in widgets: + item = self.get_item_from_wid(wid) + if item is None: + continue + category_layout.add_widget(item) + + def add_roster_items(self): + self.roster = category_layout = self.add_category_layout(_("Your contacts")) + for profile in G.host.profiles: + contact_list = G.host.contact_lists[profile] + for entity_jid in sorted(contact_list.roster): + item = self._create_item( + jid=entity_jid, + data=contact_list.get_item(entity_jid), + profile=profile, + ) + item.bind(on_press=partial(self.dispatch, 'on_select')) + category_layout.add_widget(item) + + def add_bookmarks_items(self): + self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms")) + for profile in G.host.profiles: + profile_manager = G.host.profiles[profile] + try: + bookmarks = profile_manager._bookmarks + except AttributeError: + log.warning(f"no bookmark in cache for profile {profile}") + continue + + contact_list = G.host.contact_lists[profile] + for entity_jid in bookmarks: + try: + cache = contact_list.get_item(entity_jid) + except KeyError: + cache = {} + item = self._create_item( + jid=entity_jid, + data=cache, + profile=profile, + ) + item.bind(on_press=partial(self.dispatch, 'on_select')) + category_layout.add_widget(item)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/common_widgets.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""common advanced widgets, which can be reused everywhere.""" + +from kivy.clock import Clock +from kivy import properties +from kivy.metrics import dp +from kivy.uix.scatterlayout import ScatterLayout +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label +from libervia.backend.core.i18n import _ +from libervia.backend.core import log as logging +from libervia.desktop_kivy import G +from .behaviors import TouchMenuItemBehavior + +log = logging.getLogger(__name__) + + +class DelayedBoxLayout(BoxLayout): + """A BoxLayout with delayed layout, to avoid slowing down during resize""" + # XXX: thanks to Alexander Taylor for his blog post at + # https://blog.kivy.org/2019/07/a-delayed-resize-layout-in-kivy/ + + do_layout_event = properties.ObjectProperty(None, allownone=True) + layout_delay_s = properties.NumericProperty(0.2) + #: set this to X to force next X layouts to be done without delay + dont_delay_next_layouts = properties.NumericProperty(0) + + def do_layout(self, *args, **kwargs): + if self.do_layout_event is not None: + self.do_layout_event.cancel() + if self.dont_delay_next_layouts>0: + self.dont_delay_next_layouts-=1 + super().do_layout() + else: + real_do_layout = super().do_layout + self.do_layout_event = Clock.schedule_once( + lambda dt: real_do_layout(*args, **kwargs), + self.layout_delay_s) + + +class Identities(object): + + def __init__(self, entity_ids): + identities = {} + for cat, type_, name in entity_ids: + identities.setdefault(cat, {}).setdefault(type_, []).append(name) + client = identities.get('client', {}) + if 'pc' in client: + self.type = 'desktop' + elif 'phone' in client: + self.type = 'phone' + elif 'web' in client: + self.type = 'web' + elif 'console' in client: + self.type = 'console' + else: + self.type = 'desktop' + + self.identities = identities + + @property + def name(self): + first_identity = next(iter(self.identities.values())) + names = next(iter(first_identity.values())) + return names[0] + + +class ItemWidget(TouchMenuItemBehavior, BoxLayout): + name = properties.StringProperty() + base_width = properties.NumericProperty(dp(100)) + + +class DeviceWidget(ItemWidget): + + def __init__(self, main_wid, entity_jid, identities, **kw): + self.entity_jid = entity_jid + self.identities = identities + own_jid = next(iter(G.host.profiles.values())).whoami + self.own_device = entity_jid.bare == own_jid.bare + if self.own_device: + name = self.identities.name + elif self.entity_jid.node: + name = self.entity_jid.node + elif self.entity_jid == own_jid.domain: + name = _("your server") + else: + name = entity_jid + + super(DeviceWidget, self).__init__(name=name, main_wid=main_wid, **kw) + + @property + def profile(self): + return self.main_wid.profile + + def get_symbol(self): + if self.identities.type == 'desktop': + return 'desktop' + elif self.identities.type == 'phone': + return 'mobile' + elif self.identities.type == 'web': + return 'globe' + elif self.identities.type == 'console': + return 'terminal' + else: + return 'desktop' + + def do_item_action(self, touch): + pass + + +class CategorySeparator(Label): + pass + + +class ImageViewer(ScatterLayout): + source = properties.StringProperty() + + def on_touch_down(self, touch): + if touch.is_double_tap: + self.reset() + return True + return super().on_touch_down(touch) + + def reset(self): + self.rotation = 0 + self.scale = 1 + self.x = 0 + self.y = 0 + + +class ImagesGallery(BoxLayout): + """Show list of images in a Carousel, with some controls to downloads""" + sources = properties.ListProperty() + carousel = properties.ObjectProperty() + previous_slide = None + + def on_kv_post(self, __): + self.on_sources(None, self.sources) + self.previous_slide = self.carousel.current_slide + self.carousel.bind(current_slide=self.on_slide_change) + + def on_parent(self, __, parent): + # we hide the head widget to have full screen + G.host.app.show_head_widget(not bool(parent), animation=False) + + def on_sources(self, __, sources): + if not sources or not self.carousel: + return + self.carousel.clear_widgets() + for source in sources: + img = ImageViewer( + source=source, + ) + self.carousel.add_widget(img) + + def on_slide_change(self, __, slide): + if isinstance(self.previous_slide, ImageViewer): + self.previous_slide.reset() + + self.previous_slide = slide + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + G.host.close_ui() + return True
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/config.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,27 @@ +#!/usr//bin/env python2 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""This module keep an open instance of sat configuration""" + +from libervia.backend.tools import config +sat_conf = config.parse_main_conf() + + +def config_get(section, name, default): + return config.config_get(sat_conf, section, name, default)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/constants.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +# Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from libervia.frontends.quick_frontend import constants +from libervia import desktop_kivy + +# Kivy must not be imported here due to log hijacking see core/kivy_hack.py + + +class Const(constants.Const): + APP_NAME = "Libervia Desktop" + APP_COMPONENT = "desktop/mobile" + APP_NAME_ALT = "LiberviaDesktopKivy" + APP_NAME_FILE = "libervia_desktop" + APP_VERSION = desktop_kivy.__version__ + LOG_OPT_SECTION = APP_NAME.lower() + CONFIG_SECTION = "desktop" + WID_SELECTOR = 'selector' + ICON_SIZES = ('small', 'medium') # small = 32, medium = 44 + DEFAULT_WIDGET_ICON = '{media}/misc/black.png' + + BTN_HEIGHT = '35dp' + + PLUG_TYPE_WID = 'wid' + PLUG_TYPE_TRANSFER = 'transfer' + + TRANSFER_UPLOAD = "upload" + TRANSFER_SEND = "send" + + COLOR_PRIM = (0.98, 0.98, 0.98, 1) + COLOR_PRIM_LIGHT = (1, 1, 1, 1) + COLOR_PRIM_DARK = (0.78, 0.78, 0.78, 1) + COLOR_SEC = (0.27, 0.54, 1.0, 1) + COLOR_SEC_LIGHT = (0.51, 0.73, 1.0, 1) + COLOR_SEC_DARK = (0.0, 0.37, 0.8, 1) + + COLOR_INFO = COLOR_PRIM_LIGHT + COLOR_WARNING = (1.0, 1.0, 0.0, 1) + COLOR_ERROR = (1.0, 0.0, 0.0, 1) + + COLOR_BTN_LIGHT = (0.4, 0.4, 0.4, 1) + + # values are in dp + IMG_MAX_WIDTH = 400 + IMG_MAX_HEIGHT = 400 + + # files + FILE_DEST_DOWNLOAD = "DOWNLOAD" + FILE_DEST_CACHE = "CACHE"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/dialog.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,44 @@ +#!/usr//bin/env python2 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""generic dialogs""" + +from libervia.backend.core.i18n import _ +from libervia.desktop_kivy.core.constants import Const as C +from kivy.uix.boxlayout import BoxLayout +from kivy import properties +from libervia.backend.core import log as logging + +log = logging.getLogger(__name__) + + +class MessageDialog(BoxLayout): + title = properties.StringProperty() + message = properties.StringProperty() + level = properties.OptionProperty(C.XMLUI_DATA_LVL_INFO, options=C.XMLUI_DATA_LVLS) + close_cb = properties.ObjectProperty() + + +class ConfirmDialog(BoxLayout): + title = properties.StringProperty() + message = properties.StringProperty(_("Are you sure?")) + # callback for no/cancel + no_cb = properties.ObjectProperty() + # callback for yes/ok + yes_cb = properties.ObjectProperty()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/image.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import mimetypes +from functools import partial +from kivy.uix import image as kivy_img +from libervia.backend.core import log as logging +from libervia.backend.tools.common import data_format +from libervia.desktop_kivy import G + +log = logging.getLogger(__name__) + + +class Image(kivy_img.Image): + """Image widget which accept source without extension""" + SVG_CONVERT_EXTRA = {'width': 128, 'height': 128} + + def __init__(self, **kwargs): + self.register_event_type('on_error') + super().__init__(**kwargs) + + def _image_convert_cb(self, path): + self.source = path + + def texture_update(self, *largs): + if self.source: + if mimetypes.guess_type(self.source, strict=False)[0] == 'image/svg+xml': + log.debug(f"Converting SVG image at {self.source} to PNG") + G.host.bridge.image_convert( + self.source, + "", + data_format.serialise(self.SVG_CONVERT_EXTRA), + "", + callback=self._image_convert_cb, + errback=partial( + G.host.errback, + message=f"Can't load image at {self.source}: {{msg}}" + ) + ) + return + + super().texture_update(*largs) + if self.source and self.texture is None: + log.warning( + f"Image {self.source} has not been imported correctly, replacing by " + f"empty one") + # FIXME: temporary image, to be replaced by something showing that something + # went wrong + self.source = G.host.app.expand( + "{media}/misc/borders/border_hollow_black.png") + self.dispatch('on_error', Exception(f"Can't load source {self.source}")) + + def on_error(self, err): + pass + + +class AsyncImage(kivy_img.AsyncImage): + """AsyncImage which accept file:// schema""" + + def _load_source(self, *args): + if self.source.startswith('file://'): + self.source = self.source[7:] + else: + super(AsyncImage, self)._load_source(*args)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/kivy_hack.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,70 @@ +#!/usr//bin/env python2 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +CONF_KIVY_LEVEL = 'log_kivy_level' + + +def do_hack(): + """work around Kivy hijacking of logs and arguments""" + # we remove args so kivy doesn't use them + # this is need to avoid kivy breaking QuickApp args handling + import sys + ori_argv = sys.argv[:] + sys.argv = sys.argv[:1] + from .constants import Const as C + from libervia.backend.core import log_config + log_config.libervia_configure(C.LOG_BACKEND_STANDARD, C) + + from . import config + kivy_level = config.config_get(C.CONFIG_SECTION, CONF_KIVY_LEVEL, 'follow').upper() + + # kivy handles its own loggers, we don't want that! + import logging + root_logger = logging.root + kivy_logger = logging.getLogger('kivy') + ori_addHandler = kivy_logger.addHandler + kivy_logger.addHandler = lambda __: None + ori_setLevel = kivy_logger.setLevel + if kivy_level == 'FOLLOW': + # level is following SàT level + kivy_logger.setLevel = lambda level: None + elif kivy_level == 'KIVY': + # level will be set by Kivy according to its own conf + pass + elif kivy_level in C.LOG_LEVELS: + kivy_logger.setLevel(kivy_level) + kivy_logger.setLevel = lambda level: None + else: + raise ValueError("Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level)) + + # during import kivy set its logging stuff + import kivy + kivy # to avoid pyflakes warning + + # we want to separate kivy logs from other logs + logging.root = root_logger + from kivy import logger + sys.stderr = logger.previous_stderr + + # we restore original methods + kivy_logger.addHandler = ori_addHandler + kivy_logger.setLevel = ori_setLevel + + # we restore original arguments + sys.argv = ori_argv
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/menu.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core.i18n import _ +from libervia.backend.core import log as logging +from libervia.desktop_kivy.core.constants import Const as C +from libervia.desktop_kivy.core.common import JidToggle +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label +from kivy.uix.button import Button +from kivy.uix.popup import Popup +from .behaviors import FilterBehavior +from kivy import properties +from kivy.core.window import Window +from kivy.animation import Animation +from kivy.metrics import dp +from libervia.desktop_kivy import G +from functools import partial +import webbrowser + +log = logging.getLogger(__name__) + +ABOUT_TITLE = _("About {}").format(C.APP_NAME) +ABOUT_CONTENT = _("""[b]{app_name} ({app_name_alt})[/b] + +[u]{app_name} version[/u]: +{version} + +[u]backend version[/u]: +{backend_version} + +{app_name} is a libre communication tool based on libre standard XMPP. + +{app_name} is part of the "Libervia" project ({app_component} frontend) +more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color] +""") + + +class AboutContent(Label): + + def on_ref_press(self, value): + if value == "website": + webbrowser.open("https://salut-a-toi.org") + + +class AboutPopup(Popup): + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + self.dismiss() + return super(AboutPopup, self).on_touch_down(touch) + + +class TransferItem(BoxLayout): + plug_info = properties.DictProperty() + + def on_touch_up(self, touch): + if not self.collide_point(*touch.pos): + return super(TransferItem, self).on_touch_up(touch) + else: + transfer_menu = self.parent + while not isinstance(transfer_menu, TransferMenu): + transfer_menu = transfer_menu.parent + transfer_menu.do_callback(self.plug_info) + return True + + +class SideMenu(BoxLayout): + size_hint_close = (0, 1) + size_hint_open = (0.4, 1) + size_close = (100, 100) + size_open = (0, 0) + bg_color = properties.ListProperty([0, 0, 0, 1]) + # callback will be called with arguments relevant to menu + callback = properties.ObjectProperty() + # call do_callback even when menu is cancelled + callback_on_close = properties.BooleanProperty(False) + # cancel callback need to remove the widget for UI + # will be called with the widget to remove as argument + cancel_cb = properties.ObjectProperty() + + def __init__(self, **kwargs): + super(SideMenu, self).__init__(**kwargs) + if self.cancel_cb is None: + self.cancel_cb = self.on_menu_cancelled + + def _set_anim_kw(self, kw, size_hint, size): + """Set animation keywords + + for each value of size_hint it is used if not None, + else size is used. + If one value of size is bigger than the respective one of Window + the one of Window is used + """ + size_hint_x, size_hint_y = size_hint + width, height = size + if size_hint_x is not None: + kw['size_hint_x'] = size_hint_x + elif width is not None: + kw['width'] = min(width, Window.width) + + if size_hint_y is not None: + kw['size_hint_y'] = size_hint_y + elif height is not None: + kw['height'] = min(height, Window.height) + + def show(self, caller_wid=None): + Window.bind(on_keyboard=self.key_input) + G.host.app.root.add_widget(self) + kw = {'d': 0.3, 't': 'out_back'} + self._set_anim_kw(kw, self.size_hint_open, self.size_open) + Animation(**kw).start(self) + + def _remove_from_parent(self, anim, menu): + # self.parent can already be None if the widget has been removed by a callback + # before the animation started. + if self.parent is not None: + self.parent.remove_widget(self) + + def hide(self): + Window.unbind(on_keyboard=self.key_input) + kw = {'d': 0.2} + self._set_anim_kw(kw, self.size_hint_close, self.size_close) + anim = Animation(**kw) + anim.bind(on_complete=self._remove_from_parent) + anim.start(self) + if self.callback_on_close: + self.do_callback() + + def on_touch_down(self, touch): + # we remove the menu if we click outside + # else we want to handle the event, but not + # transmit it to parents + if not self.collide_point(*touch.pos): + self.hide() + else: + return super(SideMenu, self).on_touch_down(touch) + return True + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + self.hide() + return True + + def on_menu_cancelled(self, wid, cleaning_cb=None): + self._close_ui(wid) + if cleaning_cb is not None: + cleaning_cb() + + def _close_ui(self, wid): + G.host.close_ui() + + def do_callback(self, *args, **kwargs): + log.warning("callback not implemented") + + +class ExtraMenuItem(Button): + pass + + +class ExtraSideMenu(SideMenu): + """Menu with general app actions like showing the about widget""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + G.local_platform.on_extra_menu_init(self) + + def add_item(self, label, callback): + self.add_widget( + ExtraMenuItem( + text=label, + on_press=partial(self.on_item_press, callback=callback), + ), + # we want the new item above "About" and last empty Widget + index=2) + + def on_item_press(self, *args, callback): + self.hide() + callback() + + def on_about(self): + self.hide() + about = AboutPopup() + about.title = ABOUT_TITLE + about.content = AboutContent( + text=ABOUT_CONTENT.format( + app_name = C.APP_NAME, + app_name_alt = C.APP_NAME_ALT, + app_component = C.APP_COMPONENT, + backend_version = G.host.backend_version, + version=G.host.version + ), + markup=True) + about.open() + + +class TransferMenu(SideMenu): + """transfer menu which handle display and callbacks""" + # callback will be called with path to file to transfer + # profiles if set will be sent to transfer widget, may be used to get specific files + profiles = properties.ObjectProperty() + transfer_txt = properties.StringProperty() + transfer_info = properties.ObjectProperty() + upload_btn = properties.ObjectProperty() + encrypted = properties.BooleanProperty(False) + items_layout = properties.ObjectProperty() + size_hint_close = (1, 0) + size_hint_open = (1, 0.5) + + def __init__(self, **kwargs): + super(TransferMenu, self).__init__(**kwargs) + if self.profiles is None: + self.profiles = iter(G.host.profiles) + for plug_info in G.host.get_plugged_widgets(type_=C.PLUG_TYPE_TRANSFER): + item = TransferItem( + plug_info = plug_info + ) + self.items_layout.add_widget(item) + + def on_kv_post(self, __): + self.update_transfer_info() + + def get_transfer_info(self): + if self.upload_btn.state == "down": + # upload + if self.encrypted: + return _( + "The file will be [color=00aa00][b]encrypted[/b][/color] and sent to " + "your server\nServer admin(s) can delete the file, but they won't be " + "able to see its content" + ) + else: + return _( + "Beware! The file will be sent to your server and stay " + "[color=ff0000][b]unencrypted[/b][/color] there\nServer admin(s) " + "can see the file, and they choose how, when and if it will be " + "deleted" + ) + else: + # P2P + if self.encrypted: + return _( + "The file will be sent [color=ff0000][b]unencrypted[/b][/color] " + "directly to your contact (it may be transiting by the " + "server if direct connection is not possible).\n[color=ff0000]" + "Please note that end-to-end encryption is not yet implemented for " + "P2P transfer." + ) + else: + return _( + "The file will be sent [color=ff0000][b]unencrypted[/b][/color] " + "directly to your contact (it [i]may be[/i] transiting by the " + "server if direct connection is not possible)." + ) + + def update_transfer_info(self): + self.transfer_info.text = self.get_transfer_info() + + def _on_transfer_cb(self, file_path, cleaning_cb=None, external=False, wid_cont=None): + if not external: + wid = wid_cont[0] + self._close_ui(wid) + self.callback( + file_path, + transfer_type = (C.TRANSFER_UPLOAD + if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND), + cleaning_cb=cleaning_cb, + ) + + def _check_plugin_permissions_cb(self, plug_info): + external = plug_info.get('external', False) + wid_cont = [] + wid_cont.append(plug_info['factory']( + plug_info, + partial(self._on_transfer_cb, external=external, wid_cont=wid_cont), + self.cancel_cb, + self.profiles)) + if not external: + G.host.show_extra_ui(wid_cont[0]) + + def do_callback(self, plug_info): + self.parent.remove_widget(self) + if self.callback is None: + log.warning("TransferMenu callback is not set") + else: + G.local_platform.check_plugin_permissions( + plug_info, + callback=partial(self._check_plugin_permissions_cb, plug_info), + errback=lambda: G.host.add_note( + _("permission refused"), + _("this transfer menu can't be used if you refuse the requested " + "permission"), + C.XMLUI_DATA_LVL_WARNING) + ) + + +class EntitiesSelectorMenu(SideMenu, FilterBehavior): + """allow to select entities from roster""" + profiles = properties.ObjectProperty() + layout = properties.ObjectProperty() + instructions = properties.StringProperty(_("Please select entities")) + filter_input = properties.ObjectProperty() + size_hint_close = (None, 1) + size_hint_open = (None, 1) + size_open = (dp(250), 100) + size_close = (0, 100) + + def __init__(self, **kwargs): + super(EntitiesSelectorMenu, self).__init__(**kwargs) + self.filter_input.bind(text=self.do_filter_input) + if self.profiles is None: + self.profiles = iter(G.host.profiles) + for profile in self.profiles: + for jid_, jid_data in G.host.contact_lists[profile].all_iter: + jid_wid = JidToggle( + jid=jid_, + profile=profile) + self.layout.add_widget(jid_wid) + + def do_callback(self): + if self.callback is not None: + jids = [c.jid for c in self.layout.children if c.state == 'down'] + self.callback(jids) + + def do_filter_input(self, filter_input, text): + self.layout.spacing = 0 if text else dp(5) + self.do_filter(self.layout, + text, + lambda c: c.jid, + width_cb=lambda c: c.width, + height_cb=lambda c: dp(70))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/patches.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import urllib.request, urllib.error, urllib.parse +import ssl + + +def disable_tls_validation(): + # allow to disable certificate validation + ctx_no_verify = ssl.create_default_context() + ctx_no_verify.check_hostname = False + ctx_no_verify.verify_mode = ssl.CERT_NONE + + class HTTPSHandler(urllib.request.HTTPSHandler): + no_certificate_check = False + + def __init__(self, *args, **kwargs): + urllib.request._HTTPSHandler_ori.__init__(self, *args, **kwargs) + if self.no_certificate_check: + self._context = ctx_no_verify + + urllib.request._HTTPSHandler_ori = urllib.request.HTTPSHandler + urllib.request.HTTPSHandler = HTTPSHandler + urllib.request.HTTPSHandler.no_certificate_check = True
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/platform_/__init__.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from kivy import utils as kivy_utils + + +def create(): + """Factory method to create the platform instance adapted to running one""" + if kivy_utils.platform == "android": + from .android import Platform + return Platform() + else: + from .base import Platform + return Platform()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/platform_/android.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import os +import socket +import json +from functools import partial +from urllib.parse import urlparse +from pathlib import Path +import shutil +import mimetypes +from jnius import autoclass, cast, JavaException +from android import activity +from android.permissions import request_permissions, Permission +from kivy.clock import Clock +from kivy.uix.label import Label +from libervia.backend.core.i18n import _ +from libervia.backend.core import log as logging +from libervia.backend.tools.common import data_format +from libervia.frontends.tools import jid +from libervia.desktop_kivy.core.constants import Const as C +from libervia.desktop_kivy.core import dialog +from libervia.desktop_kivy import G +from .base import Platform as BasePlatform + + +log = logging.getLogger(__name__) + +# permission that are necessary to have LiberviaDesktopKivy running properly +PERMISSION_MANDATORY = [ + Permission.READ_EXTERNAL_STORAGE, + Permission.WRITE_EXTERNAL_STORAGE, +] + +service = autoclass('org.libervia.libervia.desktop_kivy.ServiceBackend') +PythonActivity = autoclass('org.kivy.android.PythonActivity') +mActivity = PythonActivity.mActivity +Intent = autoclass('android.content.Intent') +AndroidString = autoclass('java.lang.String') +Uri = autoclass('android.net.Uri') +ImagesMedia = autoclass('android.provider.MediaStore$Images$Media') +AudioMedia = autoclass('android.provider.MediaStore$Audio$Media') +VideoMedia = autoclass('android.provider.MediaStore$Video$Media') +URLConnection = autoclass('java.net.URLConnection') + +DISPLAY_NAME = '_display_name' +DATA = '_data' + + +STATE_RUNNING = b"running" +STATE_PAUSED = b"paused" +STATE_STOPPED = b"stopped" +SOCKET_DIR = "/data/data/org.libervia.cagou/" +SOCKET_FILE = ".socket" +INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction") + + +class Platform(BasePlatform): + send_button_visible = True + + def __init__(self): + super().__init__() + # cache for callbacks to run when profile is plugged + self.cache = [] + + def init_platform(self): + # sys.platform is "linux" on android by default + # so we change it to allow backend to detect android + sys.platform = "android" + C.PLUGIN_EXT = 'pyc' + + def on_host_init(self, host): + argument = '' + service.start(mActivity, argument) + + activity.bind(on_new_intent=self.on_new_intent) + self.cache.append((self.on_new_intent, mActivity.getIntent())) + self.last_selected_wid = None + self.restore_selected_wid = True + host.addListener('profile_plugged', self.on_profile_plugged) + host.addListener('selected', self.on_selected_widget) + local_dir = Path(host.config_get('', 'local_dir')).resolve() + self.tmp_dir = local_dir / 'tmp' + # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line + # above + assert self.tmp_dir.resolve() != local_dir + # we reset tmp dir on each run, to be sure that there is no residual file + if self.tmp_dir.exists(): + shutil.rmtree(self.tmp_dir) + self.tmp_dir.mkdir(0o700, parents=True) + + def on_init_frontend_state(self): + # XXX: we use a separated socket instead of bridge because if we + # try to call a bridge method in on_pause method, the call data + # is not written before the actual pause + s = self._frontend_status_socket = socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE)) + s.sendall(STATE_RUNNING) + + def profile_autoconnect_get_cb(self, profile=None): + if profile is not None: + G.host.options.profile = profile + G.host.post_init() + + def profile_autoconnect_get_eb(self, failure_): + log.error(f"Error while getting profile to autoconnect: {failure_}") + G.host.post_init() + + def _show_perm_warning(self, permissions): + root_wid = G.host.app.root + perm_warning = Label( + size_hint=(1, 1), + text_size=(root_wid.width, root_wid.height), + font_size='22sp', + bold=True, + color=(0.67, 0, 0, 1), + halign='center', + valign='center', + text=_( + "Requested permissions are mandatory to run LiberviaDesktopKivy, if you don't " + "accept them, LiberviaDesktopKivy can't run properly. Please accept following " + "permissions, or set them in Android settings for LiberviaDesktopKivy:\n" + "{permissions}\n\nLiberviaDesktopKivy will be closed in 20 s").format( + permissions='\n'.join(p.split('.')[-1] for p in permissions))) + root_wid.clear_widgets() + root_wid.add_widget(perm_warning) + Clock.schedule_once(lambda *args: G.host.app.stop(), 20) + + def permission_cb(self, permissions, grant_results): + if not all(grant_results): + # we keep asking until they are accepted, as we can't run properly + # without them + # TODO: a message explaining why permission is needed should be printed + # TODO: the storage permission is mainly used to set download_dir, we should + # be able to run LiberviaDesktopKivy without it. + if not hasattr(self, 'perms_counter'): + self.perms_counter = 0 + self.perms_counter += 1 + if self.perms_counter > 5: + Clock.schedule_once( + lambda *args: self._show_perm_warning(permissions), + 0) + return + + perm_dict = dict(zip(permissions, grant_results)) + log.warning( + f"not all mandatory permissions are granted, requesting again: " + f"{perm_dict}") + request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) + return + + Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get( + callback=self.profile_autoconnect_get_cb, + errback=self.profile_autoconnect_get_eb), + 0) + + def do_post_init(self): + request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) + return False + + def private_data_get_cb(self, data_s, profile): + data = data_format.deserialise(data_s, type_check=None) + if data is not None and self.restore_selected_wid: + log.debug(f"restoring previous widget {data}") + try: + name = data['name'] + target = data['target'] + except KeyError as e: + log.error(f"Bad data format for selected widget: {e}\ndata={data}") + return + if target: + target = jid.JID(data['target']) + plugin_info = G.host.get_plugin_info(name=name) + if plugin_info is None: + log.warning("Can't restore unknown plugin: {name}") + return + factory = plugin_info['factory'] + G.host.switch_widget( + None, + factory(plugin_info, target=target, profiles=[profile]) + ) + + def on_profile_plugged(self, profile): + log.debug("ANDROID profile_plugged") + G.host.bridge.param_set( + "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile, + callback=lambda: log.info(f"profile {profile} autoconnection set"), + errback=lambda: log.error(f"can't set {profile} autoconnection")) + for method, *args in self.cache: + method(*args) + del self.cache + G.host.removeListener("profile_plugged", self.on_profile_plugged) + # we restore the stored widget if any + # user will then go back to where they was when the frontend was closed + G.host.bridge.private_data_get( + "cagou", "selected_widget", profile, + callback=partial(self.private_data_get_cb, profile=profile), + errback=partial( + G.host.errback, + title=_("can't get selected widget"), + message=_("error while retrieving selected widget: {msg}")) + ) + + def on_selected_widget(self, wid): + """Store selected widget in backend, to restore it on next startup""" + if self.last_selected_wid == None: + self.last_selected_wid = wid + # we skip the first selected widget, as we'll restore stored one if possible + return + + self.last_selected_wid = wid + + try: + plugin_info = wid.plugin_info + except AttributeError: + log.warning(f"No plugin info found for {wid}, can't store selected widget") + return + + try: + profile = next(iter(wid.profiles)) + except (AttributeError, StopIteration): + profile = None + + if profile is None: + try: + profile = next(iter(G.host.profiles)) + except StopIteration: + log.debug("No profile plugged yet, can't store selected widget") + return + try: + target = wid.target + except AttributeError: + target = None + + data = { + "name": plugin_info["name"], + "target": target, + } + + G.host.bridge.private_data_set( + "cagou", "selected_widget", data_format.serialise(data), profile, + errback=partial( + G.host.errback, + title=_("can set selected widget"), + message=_("error while setting selected widget: {msg}")) + ) + + def on_pause(self): + G.host.sync = False + self._frontend_status_socket.sendall(STATE_PAUSED) + return True + + def on_resume(self): + self._frontend_status_socket.sendall(STATE_RUNNING) + G.host.sync = True + + def on_stop(self): + self._frontend_status_socket.sendall(STATE_STOPPED) + self._frontend_status_socket.close() + + def on_key_back_root(self): + PythonActivity.moveTaskToBack(True) + return True + + def on_key_back_share(self, share_widget): + share_widget.close() + PythonActivity.moveTaskToBack(True) + return True + + def _disconnect(self, profile): + G.host.bridge.param_set( + "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile, + callback=lambda: log.info(f"profile {profile} autoconnection unset"), + errback=lambda: log.error(f"can't unset {profile} autoconnection")) + G.host.profiles.unplug(profile) + G.host.bridge.disconnect(profile) + G.host.app.show_profile_manager() + G.host.close_ui() + + def _on_disconnect(self): + current_profile = next(iter(G.host.profiles)) + wid = dialog.ConfirmDialog( + title=_("Are you sure to disconnect?"), + message=_( + "If you disconnect the current user ({profile}), you won't receive " + "any notification until you connect it again, is this really what you " + "want?").format(profile=current_profile), + yes_cb=partial(self._disconnect, profile=current_profile), + no_cb=G.host.close_ui, + ) + G.host.show_extra_ui(wid) + + def on_extra_menu_init(self, extra_menu): + extra_menu.add_item(_('disconnect'), self._on_disconnect) + + def update_params_extra(self, extra): + # on Android, we handle autoconnection automatically, + # user must not modify those parameters + extra.update( + { + "ignore": [ + ["Connection", "autoconnect_backend"], + ["Connection", "autoconnect"], + ["Connection", "autodisconnect"], + ], + } + ) + + def get_col_data_from_uri(self, uri, col_name): + cursor = mActivity.getContentResolver().query(uri, None, None, None, None) + if cursor is None: + return None + try: + cursor.moveToFirst() + col_idx = cursor.getColumnIndex(col_name); + if col_idx == -1: + return None + return cursor.getString(col_idx) + finally: + cursor.close() + + def get_filename_from_uri(self, uri, media_type): + filename = self.get_col_data_from_uri(uri, DISPLAY_NAME) + if filename is None: + uri_p = Path(uri.toString()) + filename = uri_p.name or "unnamed" + if not uri_p.suffix and media_type: + suffix = mimetypes.guess_extension(media_type, strict=False) + if suffix: + filename = filename + suffix + return filename + + def get_path_from_uri(self, uri): + # FIXME: using DATA is not recommended (and DATA is deprecated) + # we should read directly the file with + # ContentResolver#openFileDescriptor(Uri, String) + path = self.get_col_data_from_uri(uri, DATA) + return uri.getPath() if path is None else path + + def on_new_intent(self, intent): + log.debug("on_new_intent") + action = intent.getAction(); + intent_type = intent.getType(); + if action == Intent.ACTION_MAIN: + action_str = intent.getStringExtra(INTENT_EXTRA_ACTION) + if action_str is not None: + action = json.loads(action_str) + log.debug(f"Extra action found: {action}") + action_type = action.get('type') + if action_type == "open": + try: + widget = action['widget'] + target = action['target'] + except KeyError as e: + log.warning(f"incomplete action {action}: {e}") + else: + # we don't want stored selected widget to be displayed after this + # one + log.debug("cancelling restoration of previous widget") + self.restore_selected_wid = False + # and now we open the widget linked to the intent + current_profile = next(iter(G.host.profiles)) + Clock.schedule_once( + lambda *args: G.host.do_action( + widget, jid.JID(target), [current_profile]), + 0) + else: + log.warning(f"unexpected action: {action}") + + text = None + uri = None + path = None + elif action == Intent.ACTION_SEND: + # we have receiving data to share, we parse the intent data + # and show the share widget + data = {} + text = intent.getStringExtra(Intent.EXTRA_TEXT) + if text is not None: + data['text'] = text + + item = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if item is not None: + uri = cast('android.net.Uri', item) + if uri.getScheme() == 'content': + # Android content, we'll dump it to a temporary file + filename = self.get_filename_from_uri(uri, intent_type) + filepath = self.tmp_dir / filename + input_stream = mActivity.getContentResolver().openInputStream(uri) + buff = bytearray(4096) + with open(filepath, 'wb') as f: + while True: + ret = input_stream.read(buff, 0, 4096) + if ret != -1: + f.write(buff[:ret]) + else: + break + input_stream.close() + data['path'] = path = str(filepath) + else: + data['uri'] = uri.toString() + path = self.get_path_from_uri(uri) + if path is not None and path not in data: + data['path'] = path + else: + uri = None + path = None + + + Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0) + else: + text = None + uri = None + path = None + + msg = (f"NEW INTENT RECEIVED\n" + f"type: {intent_type}\n" + f"action: {action}\n" + f"text: {text}\n" + f"uri: {uri}\n" + f"path: {path}") + + log.debug(msg) + + def check_plugin_permissions(self, plug_info, callback, errback): + perms = plug_info.get("android_permissons") + if not perms: + callback() + return + perms = [f"android.permission.{p}" if '.' not in p else p for p in perms] + + def request_permissions_cb(permissions, granted): + if all(granted): + Clock.schedule_once(lambda *args: callback()) + else: + Clock.schedule_once(lambda *args: errback()) + + request_permissions(perms, callback=request_permissions_cb) + + def open_url(self, url, wid=None): + parsed_url = urlparse(url) + if parsed_url.scheme == "aesgcm": + return super().open_url(url, wid) + else: + media_type = mimetypes.guess_type(url, strict=False)[0] + if media_type is None: + log.debug( + f"media_type for {url!r} not found with python mimetypes, trying " + f"guessContentTypeFromName") + media_type = URLConnection.guessContentTypeFromName(url) + intent = Intent(Intent.ACTION_VIEW) + if media_type is not None: + log.debug(f"file {url!r} is of type {media_type}") + intent.setDataAndType(Uri.parse(url), media_type) + else: + log.debug(f"can't guess media type for {url!r}") + intent.setData(Uri.parse(url)) + if mActivity.getPackageManager() is not None: + activity = cast('android.app.Activity', mActivity) + try: + activity.startActivity(intent) + except JavaException as e: + if e.classname != "android.content.ActivityNotFoundException": + raise e + log.debug( + f"activity not found for url {url!r}, we'll try generic opener") + else: + return + + # if nothing else worked, we default to base open_url + super().open_url(url, wid)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/platform_/base.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import webbrowser +import subprocess +import shutil +from urllib import parse +from kivy.config import Config as KivyConfig +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.core import exceptions +from libervia.frontends.quick_frontend.quick_widgets import QuickWidget +from libervia.desktop_kivy import G + + +log = getLogger(__name__) + + +class Platform: + """Base class to handle platform specific behaviours""" + # set to True to always show the send button in chat + send_button_visible = False + + def init_platform(self): + # we don't want multi-touch emulation with mouse + + # this option doesn't make sense on Android and cause troubles, so we only + # activate it for other platforms (cf. https://github.com/kivy/kivy/issues/6229) + KivyConfig.set('input', 'mouse', 'mouse,disable_multitouch') + + def on_app_build(self, Wid): + pass + + def on_host_init(self, host): + pass + + def on_init_frontend_state(self): + pass + + def do_post_init(self): + return True + + def on_pause(self): + pass + + def on_resume(self): + pass + + def on_stop(self): + pass + + def on_key_back_root(self): + """Back key is called while being on root widget""" + return True + + def on_key_back_share(self, share_widget): + """Back key is called while being on share widget""" + share_widget.close() + return True + + def _on_new_window(self): + """Launch a new instance of LiberviaDesktopKivy to have an extra window""" + subprocess.Popen(sys.argv) + + def on_extra_menu_init(self, extra_menu): + extra_menu.add_item(_('new window'), self._on_new_window) + + def update_params_extra(self, extra): + pass + + def check_plugin_permissions(self, plug_info, callback, errback): + """Check that plugin permissions for this platform are granted""" + callback() + + def _open(self, path): + """Open url or path with appropriate application if possible""" + try: + opener = self._opener + except AttributeError: + xdg_open_path = shutil.which("xdg-open") + if xdg_open_path is not None: + log.debug("xdg-open found, it will be used to open files") + opener = lambda path: subprocess.Popen([xdg_open_path, path]) + else: + log.debug("files will be opened with webbrower.open") + opener = webbrowser.open + self._opener = opener + + opener(path) + + + def open_url(self, url, wid=None): + """Open an URL in the way appropriate for the platform + + @param url(str): URL to open + @param wid(LiberviaDesktopKivyWidget, None): widget requesting the opening + it may influence the way the URL is opened + """ + parsed_url = parse.urlparse(url) + if parsed_url.scheme == "aesgcm" and wid is not None: + # aesgcm files need to be decrypted first + # so we download them before opening + quick_widget = G.host.get_ancestor_widget(wid, QuickWidget) + if quick_widget is None: + msg = f"Can't find ancestor QuickWidget of {wid}" + log.error(msg) + G.host.errback(exceptions.InternalError(msg)) + return + G.host.download_url( + parsed_url, self.open_url, G.host.errback, profile=quick_widget.profile + ) + else: + self._open(url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/profile_manager.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from .constants import Const as C +from libervia.frontends.quick_frontend.quick_profile_manager import QuickProfileManager +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.togglebutton import ToggleButton +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.metrics import sp +from kivy import properties +from libervia.desktop_kivy import G + + +class ProfileItem(ToggleButton): + ps = properties.ObjectProperty() + index = properties.NumericProperty(0) + + +class NewProfileScreen(Screen): + profile_name = properties.ObjectProperty(None) + jid = properties.ObjectProperty(None) + password = properties.ObjectProperty(None) + error_msg = properties.StringProperty('') + + def __init__(self, pm): + super(NewProfileScreen, self).__init__(name='new_profile') + self.pm = pm + + def on_creation_failure(self, failure): + msg = [l for l in str(failure).split('\n') if l][-1] + self.error_msg = str(msg) + + def on_creation_success(self, profile): + self.pm.profiles_screen.reload() + G.host.bridge.profile_start_session( + self.password.text, profile, + callback=lambda __: self._session_started(profile), + errback=self.on_creation_failure) + + def _session_started(self, profile): + jid = self.jid.text.strip() + G.host.bridge.param_set("JabberID", jid, "Connection", -1, profile) + G.host.bridge.param_set("Password", self.password.text, "Connection", -1, profile) + self.pm.screen_manager.transition.direction = 'right' + self.pm.screen_manager.current = 'profiles' + + def doCreate(self): + name = self.profile_name.text.strip() + # XXX: we use XMPP password for profile password to simplify + # if user want to change profile password, he can do it in preferences + G.host.bridge.profile_create( + name, self.password.text, '', + callback=lambda: self.on_creation_success(name), + errback=self.on_creation_failure) + + +class DeleteProfilesScreen(Screen): + + def __init__(self, pm): + self.pm = pm + super(DeleteProfilesScreen, self).__init__(name='delete_profiles') + + def do_delete(self): + """This method will delete *ALL* selected profiles""" + to_delete = self.pm.get_profiles() + deleted = [0] + + def delete_inc(): + deleted[0] += 1 + if deleted[0] == len(to_delete): + self.pm.profiles_screen.reload() + self.pm.screen_manager.transition.direction = 'right' + self.pm.screen_manager.current = 'profiles' + + for profile in to_delete: + log.info("Deleteing profile [{}]".format(profile)) + G.host.bridge.profile_delete_async( + profile, callback=delete_inc, errback=delete_inc) + + +class ProfilesScreen(Screen): + layout = properties.ObjectProperty(None) + profiles = properties.ListProperty() + + def __init__(self, pm): + self.pm = pm + super(ProfilesScreen, self).__init__(name='profiles') + self.reload() + + def _profiles_list_get_cb(self, profiles): + profiles.sort() + self.profiles = profiles + for idx, profile in enumerate(profiles): + item = ProfileItem(ps=self, index=idx, text=profile, group='profiles') + self.layout.add_widget(item) + + def converter(self, row_idx, obj): + return {'text': obj, + 'size_hint_y': None, + 'height': sp(40)} + + def reload(self): + """Reload profiles list""" + self.layout.clear_widgets() + G.host.bridge.profiles_list_get(callback=self._profiles_list_get_cb) + + +class ProfileManager(QuickProfileManager, BoxLayout): + selected = properties.ObjectProperty(None) + + def __init__(self, autoconnect=None): + QuickProfileManager.__init__(self, G.host, autoconnect) + BoxLayout.__init__(self, orientation="vertical") + self.screen_manager = ScreenManager() + self.profiles_screen = ProfilesScreen(self) + self.new_profile_screen = NewProfileScreen(self) + self.delete_profiles_screen = DeleteProfilesScreen(self) + self.xmlui_screen = Screen(name='xmlui') + self.screen_manager.add_widget(self.profiles_screen) + self.screen_manager.add_widget(self.xmlui_screen) + self.screen_manager.add_widget(self.new_profile_screen) + self.screen_manager.add_widget(self.delete_profiles_screen) + self.add_widget(self.screen_manager) + + def close_ui(self, xmlui, reason=None): + self.screen_manager.transition.direction = 'right' + self.screen_manager.current = 'profiles' + + def show_ui(self, xmlui): + xmlui.set_close_cb(self.close_ui) + if xmlui.type == 'popup': + xmlui.bind(on_touch_up=lambda obj, value: self.close_ui(xmlui)) + self.xmlui_screen.clear_widgets() + self.xmlui_screen.add_widget(xmlui) + self.screen_manager.transition.direction = 'left' + self.screen_manager.current = 'xmlui' + + def select_profile(self, profile_item): + if not profile_item.selected: + return + def authenticate_cb(data, cb_id, profile): + if not C.bool(data.pop('validated', C.BOOL_FALSE)): + # profile didn't validate, we unselect it + profile_item.state = 'normal' + self.selected = '' + else: + # state may have been modified so we need to be sure it's down + profile_item.state = 'down' + self.selected = profile_item + G.host.action_manager(data, callback=authenticate_cb, ui_show_cb=self.show_ui, + profile=profile) + + G.host.action_launch(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, + profile=profile_item.text) + + def get_profiles(self): + # for now we restrict to a single profile in LiberviaDesktopKivy + # TODO: handle multi-profiles + return [self.selected.text] if self.selected else []
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/share_widget.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from pathlib import Path +from functools import partial +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.frontends.tools import jid +from kivy.uix.boxlayout import BoxLayout +from kivy.properties import StringProperty, DictProperty, ObjectProperty +from kivy.metrics import dp +from .constants import Const as C +from libervia.desktop_kivy import G + + +log = logging.getLogger(__name__) + + +PLUGIN_INFO = { + "name": _("share"), + "main": "Share", + "description": _("share a file"), + "icon_symbol": "share", +} + + +class TextPreview(BoxLayout): + """Widget previewing shared text""" + text = StringProperty() + + +class ImagePreview(BoxLayout): + """Widget previewing shared image""" + path = StringProperty() + reduce_layout = ObjectProperty() + reduce_checkbox = ObjectProperty() + + def _check_image_cb(self, report_raw): + self.report = data_format.deserialise(report_raw) + if self.report['too_large']: + self.reduce_layout.opacity = 1 + self.reduce_layout.height = self.reduce_layout.minimum_height + dp(10) + self.reduce_layout.padding = [0, dp(5)] + + def _check_image_eb(self, failure_): + log.error(f"Can't check image: {failure_}") + + def on_path(self, wid, path): + G.host.bridge.image_check( + path, callback=self._check_image_cb, errback=self._check_image_eb) + + def resize_image(self, data, callback, errback): + + def image_resize_cb(new_path): + new_path = Path(new_path) + log.debug(f"image {data['path']} resized at {new_path}") + data['path'] = new_path + data['cleaning_cb'] = lambda: new_path.unlink() + callback(data) + + path = data['path'] + width, height = self.report['recommended_size'] + G.host.bridge.image_resize( + path, width, height, + callback=image_resize_cb, + errback=errback + ) + + def get_filter(self): + if self.report['too_large'] and self.reduce_checkbox.active: + return self.resize_image + else: + return lambda data, callback, errback: callback(data) + + +class GenericPreview(BoxLayout): + """Widget previewing shared image""" + path = StringProperty() + + +class ShareWidget(BoxLayout): + media_type = StringProperty() + data = DictProperty() + preview_box = ObjectProperty() + + def on_kv_post(self, wid): + self.type, self.subtype = self.media_type.split('/') + if self.type == 'text' and 'text' in self.data: + self.preview_box.add_widget(TextPreview(text=self.data['text'])) + elif self.type == 'image': + self.preview_box.add_widget(ImagePreview(path=self.data['path'])) + else: + self.preview_box.add_widget(GenericPreview(path=self.data['path'])) + + def close(self): + G.host.close_ui() + + def get_filtered_data(self, callback, errback): + """Apply filter if suitable, and call callback with with modified data""" + try: + get_filter = self.preview_box.children[0].get_filter + except AttributeError: + callback(self.data) + else: + filter_ = get_filter() + filter_(self.data, callback=callback, errback=errback) + + def filter_data_cb(self, data, contact_jid, profile): + chat_wid = G.host.do_action('chat', contact_jid, [profile]) + + if self.type == 'text' and 'text' in self.data: + text = self.data['text'] + chat_wid.message_input.text += text + else: + path = self.data['path'] + chat_wid.transfer_file(path, cleaning_cb=data.get('cleaning_cb')) + self.close() + + def filter_data_eb(self, failure_): + G.host.add_note( + _("file filter error"), + _("Can't apply filter to file: {msg}").format(msg=failure_), + level=C.XMLUI_DATA_LVL_ERROR) + + def on_select(self, contact_button): + contact_jid = jid.JID(contact_button.jid) + self.get_filtered_data( + partial( + self.filter_data_cb, + contact_jid=contact_jid, + profile=contact_button.profile), + self.filter_data_eb + ) + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + return G.local_platform.on_key_back_share(self)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/simple_xhtml.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from xml.etree import ElementTree as ET +from kivy.uix.stacklayout import StackLayout +from kivy.uix.label import Label +from kivy.utils import escape_markup +from kivy.metrics import sp +from kivy import properties +from libervia.backend.core import log as logging +from libervia.frontends.tools import css_color, strings as sat_strings +from libervia.desktop_kivy import G +from libervia.desktop_kivy.core.common import SizedImage + + +log = logging.getLogger(__name__) + + +class Escape(str): + """Class used to mark that a message need to be escaped""" + + +class SimpleXHTMLWidgetEscapedText(Label): + + def on_parent(self, instance, parent): + if parent is not None: + self.font_size = parent.font_size + + def _add_url_markup(self, text): + text_elts = [] + idx = 0 + links = 0 + while True: + m = sat_strings.RE_URL.search(text[idx:]) + if m is not None: + text_elts.append(escape_markup(m.string[0:m.start()])) + link_key = 'link_' + str(links) + url = m.group() + escaped_url = escape_markup(url) + text_elts.append( + f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]') + if not links: + self.ref_urls = {link_key: url} + else: + self.ref_urls[link_key] = url + links += 1 + idx += m.end() + else: + if links: + text_elts.append(escape_markup(text[idx:])) + self.markup = True + self.text = ''.join(text_elts) + break + + def on_text(self, instance, text): + # do NOT call the method if self.markup is set + # this would result in infinite loop (because self.text + # is changed if an URL is found, and in this case markup too) + if text and not self.markup: + self._add_url_markup(text) + + def on_ref_press(self, ref): + url = self.ref_urls[ref] + G.local_platform.open_url(url, self) + + +class SimpleXHTMLWidgetText(Label): + + def on_parent(self, instance, parent): + if parent is not None: + self.font_size = parent.font_size + + +class SimpleXHTMLWidget(StackLayout): + """widget handling simple XHTML parsing""" + xhtml = properties.StringProperty() + color = properties.ListProperty([1, 1, 1, 1]) + # XXX: bold is only used for escaped text + bold = properties.BooleanProperty(False) + font_size = properties.NumericProperty(sp(14)) + + # text/XHTML input + + def on_xhtml(self, instance, xhtml): + """parse xhtml and set content accordingly + + if xhtml is an instance of Escape, a Label with no markup will be used + """ + self.clear_widgets() + if isinstance(xhtml, Escape): + label = SimpleXHTMLWidgetEscapedText( + text=xhtml, color=self.color, bold=self.bold) + self.bind(font_size=label.setter('font_size')) + self.bind(color=label.setter('color')) + self.bind(bold=label.setter('bold')) + self.add_widget(label) + else: + xhtml = ET.fromstring(xhtml.encode()) + self.current_wid = None + self.styles = [] + self._call_parse_method(xhtml) + if len(self.children) > 1: + self._do_split_labels() + + def escape(self, text): + """mark that a text need to be escaped (i.e. no markup)""" + return Escape(text) + + def _do_split_labels(self): + """Split labels so their content can flow with images""" + # XXX: to make things easier, we split labels in words + log.debug("labels splitting start") + children = self.children[::-1] + self.clear_widgets() + for child in children: + if isinstance(child, Label): + log.debug("label before split: {}".format(child.text)) + styles = [] + tag = False + new_text = [] + current_tag = [] + current_value = [] + current_wid = self._create_text() + value = False + close = False + # we will parse the text and create a new widget + # on each new word (actually each space) + # FIXME: handle '\n' and other white chars + for c in child.text: + if tag: + # we are parsing a markup tag + if c == ']': + current_tag_s = ''.join(current_tag) + current_style = (current_tag_s, ''.join(current_value)) + if close: + for idx, s in enumerate(reversed(styles)): + if s[0] == current_tag_s: + del styles[len(styles) - idx - 1] + break + else: + styles.append(current_style) + current_tag = [] + current_value = [] + tag = False + value = False + close = False + elif c == '/': + close = True + elif c == '=': + value = True + elif value: + current_value.append(c) + else: + current_tag.append(c) + new_text.append(c) + else: + # we are parsing regular text + if c == '[': + new_text.append(c) + tag = True + elif c == ' ': + # new word, we do a new widget + new_text.append(' ') + for t, v in reversed(styles): + new_text.append('[/{}]'.format(t)) + current_wid.text = ''.join(new_text) + new_text = [] + self.add_widget(current_wid) + log.debug("new widget: {}".format(current_wid.text)) + current_wid = self._create_text() + for t, v in styles: + new_text.append('[{tag}{value}]'.format( + tag = t, + value = '={}'.format(v) if v else '')) + else: + new_text.append(c) + if current_wid.text: + # we may have a remaining widget after the parsing + close_styles = [] + for t, v in reversed(styles): + close_styles.append('[/{}]'.format(t)) + current_wid.text = ''.join(close_styles) + self.add_widget(current_wid) + log.debug("new widget: {}".format(current_wid.text)) + else: + # non Label widgets, we just add them + self.add_widget(child) + self.splitted = True + log.debug("split OK") + + # XHTML parsing methods + + def _call_parse_method(self, e): + """Call the suitable method to parse the element + + self.xhtml_[tag] will be called if it exists, else + self.xhtml_generic will be used + @param e(ET.Element): element to parse + """ + try: + method = getattr(self, f"xhtml_{e.tag}") + except AttributeError: + log.warning(f"Unhandled XHTML tag: {e.tag}") + method = self.xhtml_generic + method(e) + + def _add_style(self, tag, value=None, append_to_list=True): + """add a markup style to label + + @param tag(unicode): markup tag + @param value(unicode): markup value if suitable + @param append_to_list(bool): if True style we be added to self.styles + self.styles is needed to keep track of styles to remove + should most probably be set to True + """ + label = self._get_label() + label.text += '[{tag}{value}]'.format( + tag = tag, + value = '={}'.format(value) if value else '' + ) + if append_to_list: + self.styles.append((tag, value)) + + def _remove_style(self, tag, remove_from_list=True): + """remove a markup style from the label + + @param tag(unicode): markup tag to remove + @param remove_from_list(bool): if True, remove from self.styles too + should most probably be set to True + """ + label = self._get_label() + label.text += '[/{tag}]'.format( + tag = tag + ) + if remove_from_list: + for rev_idx, style in enumerate(reversed(self.styles)): + if style[0] == tag: + tag_idx = len(self.styles) - 1 - rev_idx + del self.styles[tag_idx] + break + + def _get_label(self): + """get current Label if it exists, or create a new one""" + if not isinstance(self.current_wid, Label): + self._add_label() + return self.current_wid + + def _add_label(self): + """add a new Label + + current styles will be closed and reopened if needed + """ + self._close_label() + self.current_wid = self._create_text() + for tag, value in self.styles: + self._add_style(tag, value, append_to_list=False) + self.add_widget(self.current_wid) + + def _create_text(self): + label = SimpleXHTMLWidgetText(color=self.color, markup=True) + self.bind(color=label.setter('color')) + label.bind(texture_size=label.setter('size')) + return label + + def _close_label(self): + """close current style tags in current label + + needed when you change label to keep style between + different widgets + """ + if isinstance(self.current_wid, Label): + for tag, value in reversed(self.styles): + self._remove_style(tag, remove_from_list=False) + + def _parse_css(self, e): + """parse CSS found in "style" attribute of element + + self._css_styles will be created and contained markup styles added by this method + @param e(ET.Element): element which may have a "style" attribute + """ + styles_limit = len(self.styles) + styles = e.attrib['style'].split(';') + for style in styles: + try: + prop, value = style.split(':') + except ValueError: + log.warning(f"can't parse style: {style}") + continue + prop = prop.strip().replace('-', '_') + value = value.strip() + try: + method = getattr(self, f"css_{prop}") + except AttributeError: + log.warning(f"Unhandled CSS: {prop}") + else: + method(e, value) + self._css_styles = self.styles[styles_limit:] + + def _close_css(self): + """removed CSS styles + + styles in self._css_styles will be removed + and the attribute will be deleted + """ + for tag, __ in reversed(self._css_styles): + self._remove_style(tag) + del self._css_styles + + def xhtml_generic(self, elem, style=True, markup=None): + """Generic method for adding HTML elements + + this method handle content, style and children parsing + @param elem(ET.Element): element to add + @param style(bool): if True handle style attribute (CSS) + @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use + """ + # we first add markup and CSS style + if markup is not None: + if isinstance(markup, str): + tag, value = markup, None + else: + tag, value = markup + self._add_style(tag, value) + style_ = 'style' in elem.attrib and style + if style_: + self._parse_css(elem) + + # then content + if elem.text: + self._get_label().text += escape_markup(elem.text) + + # we parse the children + for child in elem: + self._call_parse_method(child) + + # closing CSS style and markup + if style_: + self._close_css() + if markup is not None: + self._remove_style(tag) + + # and the tail, which is regular text + if elem.tail: + self._get_label().text += escape_markup(elem.tail) + + # method handling XHTML elements + + def xhtml_br(self, elem): + label = self._get_label() + label.text+='\n' + self.xhtml_generic(elem, style=False) + + def xhtml_em(self, elem): + self.xhtml_generic(elem, markup='i') + + def xhtml_img(self, elem): + try: + src = elem.attrib['src'] + except KeyError: + log.warning("<img> element without src: {}".format(ET.tostring(elem))) + return + try: + target_height = int(elem.get('height', 0)) + except ValueError: + log.warning(f"Can't parse image height: {elem.get('height')}") + target_height = None + try: + target_width = int(elem.get('width', 0)) + except ValueError: + log.warning(f"Can't parse image width: {elem.get('width')}") + target_width = None + + img = SizedImage( + source=src, target_height=target_height, target_width=target_width) + self.current_wid = img + self.add_widget(img) + + def xhtml_p(self, elem): + if isinstance(self.current_wid, Label): + self.current_wid.text+="\n\n" + self.xhtml_generic(elem) + + def xhtml_span(self, elem): + self.xhtml_generic(elem) + + def xhtml_strong(self, elem): + self.xhtml_generic(elem, markup='b') + + # methods handling CSS properties + + def css_color(self, elem, value): + self._add_style("color", css_color.parse(value)) + + def css_text_decoration(self, elem, value): + if value == 'underline': + self._add_style('u') + elif value == 'line-through': + self._add_style('s') + else: + log.warning("unhandled text decoration: {}".format(value))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/widgets_handler.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +from libervia.backend.core import exceptions +from libervia.frontends.quick_frontend import quick_widgets +from kivy.graphics import Color, Ellipse +from kivy.uix.layout import Layout +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.stencilview import StencilView +from kivy.uix.carousel import Carousel +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.metrics import dp +from kivy import properties +from libervia.desktop_kivy import G +from .constants import Const as C +from . import cagou_widget + +log = logging.getLogger(__name__) + + +REMOVE_WID_LIMIT = dp(50) +MIN_WIDTH = MIN_HEIGHT = dp(70) + + +class BoxStencil(BoxLayout, StencilView): + pass + + +class WHWrapper(BoxLayout): + main_container = properties.ObjectProperty(None) + screen_manager = properties.ObjectProperty(None, allownone=True) + carousel = properties.ObjectProperty(None, allownone=True) + split_size = properties.NumericProperty(dp(1)) + split_margin = properties.NumericProperty(dp(2)) + split_color = properties.ListProperty([0.8, 0.8, 0.8, 1]) + split_color_move = C.COLOR_SEC_DARK + split_color_del = properties.ListProperty([0.8, 0.0, 0.0, 1]) + # sp stands for "split point" + sp_size = properties.NumericProperty(dp(1)) + sp_space = properties.NumericProperty(dp(4)) + sp_zone = properties.NumericProperty(dp(30)) + _split = properties.OptionProperty('None', options=['None', 'left', 'top']) + _split_del = properties.BooleanProperty(False) + + def __init__(self, **kwargs): + idx = kwargs.pop('_wid_idx') + self._wid_idx = idx + super(WHWrapper, self).__init__(**kwargs) + self._left_wids = set() + self._top_wids = set() + self._right_wids = set() + self._bottom_wids = set() + self._clear_attributes() + + def _clear_attributes(self): + self._former_slide = None + + def __repr__(self): + return "WHWrapper_{idx}".format(idx=self._wid_idx) + + def _main_wid(self, wid_list): + """return main widget of a side list + + main widget is either the widget currently splitted + or any widget if none is split + @return (WHWrapper, None): main widget or None + if there is not widget + """ + if not wid_list: + return None + for wid in wid_list: + if wid._split != 'None': + return wid + return next(iter(wid_list)) + + def on_parent(self, __, new_parent): + if new_parent is None: + # we detach all children so LiberviaDesktopKivyWidget.whwrapper won't link to this one + # anymore + self.clear_widgets() + + @property + def _left_wid(self): + return self._main_wid(self._left_wids) + + @property + def _top_wid(self): + return self._main_wid(self._top_wids) + + @property + def _right_wid(self): + return self._main_wid(self._right_wids) + + @property + def _bottom_wid(self): + return self._main_wid(self._bottom_wids) + + @property + def current_slide(self): + if (self.carousel is not None + and (self.screen_manager is None or self.screen_manager.current == '')): + return self.carousel.current_slide + elif self.screen_manager is not None: + # we should have exactly one children in current_screen, else there is a bug + return self.screen_manager.current_screen.children[0] + else: + try: + return self.main_container.children[0] + except IndexError: + log.error("No child found, this should not happen") + return None + + @property + def carousel_active(self): + """Return True if Carousel is used and active""" + if self.carousel is None: + return False + if self.screen_manager is not None and self.screen_manager.current != '': + return False + return True + + @property + def former_screen_wid(self): + """Return widget currently active for former screen""" + if self.screen_manager is None: + raise exceptions.InternalError( + "former_screen_wid can only be used if ScreenManager is used") + if self._former_screen_name is None: + return None + return self.get_screen_widget(self._former_screen_name) + + def get_screen_widget(self, screen_name): + """Return screen main widget, handling carousel if necessary""" + if self.carousel is not None and screen_name == '': + return self.carousel.current_slide + try: + return self.screen_manager.get_screen(screen_name).children[0] + except IndexError: + return None + + def _draw_ellipse(self): + """draw split ellipse""" + color = self.split_color_del if self._split_del else self.split_color_move + try: + self.canvas.after.remove(self.ellipse) + except AttributeError: + pass + if self._split == "top": + with self.canvas.after: + Color(*color) + self.ellipse = Ellipse(angle_start=90, angle_end=270, + pos=(self.x + self.width/2 - self.sp_zone/2, + self.y + self.height - self.sp_zone/2), + size=(self.sp_zone, self.sp_zone)) + elif self._split == "left": + with self.canvas.after: + Color(*color) + self.ellipse = Ellipse(angle_end=180, + pos=(self.x + -self.sp_zone/2, + self.y + self.height/2 - self.sp_zone/2), + size = (self.sp_zone, self.sp_zone)) + else: + raise exceptions.InternalError('unexpected split value') + + def on_touch_down(self, touch): + """activate split if touch is on a split zone""" + if not self.collide_point(*touch.pos): + return + log.debug("WIDGET IDX: {} (left: {}, top: {}, right: {}, bottom: {}), pos: {}, size: {}".format( + self._wid_idx, + 'None' if not self._left_wids else [w._wid_idx for w in self._left_wids], + 'None' if not self._top_wids else [w._wid_idx for w in self._top_wids], + 'None' if not self._right_wids else [w._wid_idx for w in self._right_wids], + 'None' if not self._bottom_wids else [w._wid_idx for w in self._bottom_wids], + self.pos, + self.size, + )) + touch_rx, touch_ry = self.to_widget(*touch.pos, relative=True) + if (touch_ry <= self.height and + touch_ry >= self.height - self.split_size - self.split_margin or + touch_ry <= self.height and + touch_ry >= self.height - self.sp_zone and + touch_rx >= self.width//2 - self.sp_zone//2 and + touch_rx <= self.width//2 + self.sp_zone//2): + # split area is touched, we activate top split mode + self._split = "top" + self._draw_ellipse() + elif (touch_rx >= 0 and + touch_rx <= self.split_size + self.split_margin or + touch_rx >= 0 and + touch_rx <= self.sp_zone and + touch_ry >= self.height//2 - self.sp_zone//2 and + touch_ry <= self.height//2 + self.sp_zone//2): + # split area is touched, we activate left split mode + self._split = "left" + touch.ud['ori_width'] = self.width + self._draw_ellipse() + else: + if self.carousel_active and len(self.carousel.slides) <= 1: + # we don't want swipe of carousel if there is only one slide + return StencilView.on_touch_down(self.carousel, touch) + else: + return super(WHWrapper, self).on_touch_down(touch) + + def on_touch_move(self, touch): + """handle size change and widget creation on split""" + if self._split == 'None': + return super(WHWrapper, self).on_touch_move(touch) + + elif self._split == 'top': + new_height = touch.y - self.y + + if new_height < MIN_HEIGHT: + return + + # we must not pass the top widget/border + if self._top_wids: + top = next(iter(self._top_wids)) + y_limit = top.y + top.height + + if top.height <= REMOVE_WID_LIMIT: + # we are in remove zone, we add visual hint for that + if not self._split_del and self._top_wids: + self._split_del = True + self._draw_ellipse() + else: + if self._split_del: + self._split_del = False + self._draw_ellipse() + else: + y_limit = self.y + self.height + + if touch.y >= y_limit: + return + + # all right, we can change size + self.height = new_height + self.ellipse.pos = (self.ellipse.pos[0], touch.y - self.sp_zone/2) + + if not self._top_wids: + # we are the last widget on the top + # so we create a new widget + new_wid = self.parent.add_widget() + self._top_wids.add(new_wid) + new_wid._bottom_wids.add(self) + for w in self._right_wids: + new_wid._right_wids.add(w) + w._left_wids.add(new_wid) + for w in self._left_wids: + new_wid._left_wids.add(w) + w._right_wids.add(new_wid) + + elif self._split == 'left': + ori_width = touch.ud['ori_width'] + new_x = touch.x + new_width = ori_width - (touch.x - touch.ox) + + if new_width < MIN_WIDTH: + return + + # we must not pass the left widget/border + if self._left_wids: + left = next(iter(self._left_wids)) + x_limit = left.x + + if left.width <= REMOVE_WID_LIMIT: + # we are in remove zone, we add visual hint for that + if not self._split_del and self._left_wids: + self._split_del = True + self._draw_ellipse() + else: + if self._split_del: + self._split_del = False + self._draw_ellipse() + else: + x_limit = self.x + + if new_x <= x_limit: + return + + # all right, we can change position/size + self.x = new_x + self.width = new_width + self.ellipse.pos = (touch.x - self.sp_zone/2, self.ellipse.pos[1]) + + if not self._left_wids: + # we are the last widget on the left + # so we create a new widget + new_wid = self.parent.add_widget() + self._left_wids.add(new_wid) + new_wid._right_wids.add(self) + for w in self._top_wids: + new_wid._top_wids.add(w) + w._bottom_wids.add(new_wid) + for w in self._bottom_wids: + new_wid._bottom_wids.add(w) + w._top_wids.add(new_wid) + + else: + raise Exception.InternalError('invalid _split value') + + def on_touch_up(self, touch): + if self._split == 'None': + return super(WHWrapper, self).on_touch_up(touch) + if self._split == 'top': + # we remove all top widgets in delete zone, + # and update there side widgets list + for top in self._top_wids.copy(): + if top.height <= REMOVE_WID_LIMIT: + G.host._remove_visible_widget(top.current_slide) + for w in top._top_wids: + w._bottom_wids.remove(top) + w._bottom_wids.update(top._bottom_wids) + for w in top._bottom_wids: + w._top_wids.remove(top) + w._top_wids.update(top._top_wids) + for w in top._left_wids: + w._right_wids.remove(top) + for w in top._right_wids: + w._left_wids.remove(top) + self.parent.remove_widget(top) + elif self._split == 'left': + # we remove all left widgets in delete zone, + # and update there side widgets list + for left in self._left_wids.copy(): + if left.width <= REMOVE_WID_LIMIT: + G.host._remove_visible_widget(left.current_slide) + for w in left._left_wids: + w._right_wids.remove(left) + w._right_wids.update(left._right_wids) + for w in left._right_wids: + w._left_wids.remove(left) + w._left_wids.update(left._left_wids) + for w in left._top_wids: + w._bottom_wids.remove(left) + for w in left._bottom_wids: + w._top_wids.remove(left) + self.parent.remove_widget(left) + self._split = 'None' + self.canvas.after.remove(self.ellipse) + del self.ellipse + + def clear_widgets(self): + current_slide = self.current_slide + if current_slide is not None: + G.host._remove_visible_widget(current_slide, ignore_missing=True) + + super().clear_widgets() + + self.screen_manager = None + self.carousel = None + self._clear_attributes() + + def set_widget(self, wid, index=0): + assert len(self.children) == 0 + + if wid.collection_carousel or wid.global_screen_manager: + self.main_container = self + else: + self.main_container = BoxStencil() + self.add_widget(self.main_container) + + if self.carousel is not None: + return self.carousel.add_widget(wid, index) + + if wid.global_screen_manager: + if self.screen_manager is None: + self.screen_manager = ScreenManager() + self.main_container.add_widget(self.screen_manager) + parent = Screen() + self.screen_manager.add_widget(parent) + self._former_screen_name = '' + self.screen_manager.bind(current=self.on_screen_change) + wid.screen_manager_init(self.screen_manager) + else: + parent = self.main_container + + if wid.collection_carousel: + # a Carousel is requested, and this is the first widget that we add + # so we need to create the carousel + self.carousel = Carousel( + direction = "right", + ignore_perpendicular_swipes = True, + loop = True, + ) + self._slides_update_lock = 0 + self.carousel.bind(current_slide=self.on_slide_change) + parent.add_widget(self.carousel) + self.carousel.add_widget(wid, index) + else: + # no Carousel requested, we add the widget as a direct child + parent.add_widget(wid) + G.host._add_visible_widget(wid) + + def change_widget(self, new_widget): + """Change currently displayed widget + + slides widgets will be updated + """ + if (self.carousel is not None + and self.carousel.current_slide.__class__ == new_widget.__class__): + # we have the same class, we reuse carousel and screen manager setting + + if self.carousel.current_slide != new_widget: + # slides update need to be blocked to avoid the update in on_slide_change + # which would mess the removal of current widgets + self._slides_update_lock += 1 + new_wid = None + for w in self.carousel.slides[:]: + if w.widget_hash == new_widget.widget_hash: + new_wid = w + continue + self.carousel.remove_widget(w) + if isinstance(w, quick_widgets.QuickWidget): + G.host.widgets.delete_widget(w) + if new_wid is None: + new_wid = G.host.get_or_clone(new_widget) + self.carousel.add_widget(new_wid) + self._update_hidden_slides() + self._slides_update_lock -= 1 + + if self.screen_manager is not None: + self.screen_manager.clear_widgets([ + s for s in self.screen_manager.screens if s.name != '']) + new_wid.screen_manager_init(self.screen_manager) + else: + # else, we restart fresh + self.clear_widgets() + self.set_widget(G.host.get_or_clone(new_widget)) + + def on_screen_change(self, screen_manager, new_screen): + try: + new_screen_wid = self.current_slide + except IndexError: + new_screen_wid = None + log.warning("Switching to a screen without children") + if new_screen == '' and self.carousel is not None: + # carousel may have been changed in the background, so we update slides + self._update_hidden_slides() + former_screen_wid = self.former_screen_wid + if isinstance(former_screen_wid, cagou_widget.LiberviaDesktopKivyWidget): + G.host._remove_visible_widget(former_screen_wid) + if isinstance(new_screen_wid, cagou_widget.LiberviaDesktopKivyWidget): + G.host._add_visible_widget(new_screen_wid) + self._former_screen_name = new_screen + G.host.selected_widget = new_screen_wid + + def on_slide_change(self, handler, new_slide): + if self._former_slide is new_slide: + # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide + # binding now calls on_slide_change twice with the same widget (here + # "new_slide"). To be checked with Kivy team. + return + log.debug(f"Slide change: new_slide = {new_slide}") + if self._former_slide is not None: + G.host._remove_visible_widget(self._former_slide, ignore_missing=True) + self._former_slide = new_slide + if self.carousel_active: + G.host.selected_widget = new_slide + if new_slide is not None: + G.host._add_visible_widget(new_slide) + self._update_hidden_slides() + + def hidden_list(self, visible_list, ignore=None): + """return widgets of same class as carousel current one, if they are hidden + + @param visible_list(list[QuickWidget]): widgets visible + @param ignore(QuickWidget, None): do no return this widget + @return (iter[QuickWidget]): widgets hidden + """ + # we want to avoid recreated widgets + added = [w.widget_hash for w in visible_list] + current_slide = self.carousel.current_slide + for w in G.host.widgets.get_widgets(current_slide.__class__, + profiles=current_slide.profiles): + wid_hash = w.widget_hash + if w in visible_list or wid_hash in added: + continue + if wid_hash == ignore.widget_hash: + continue + yield w + + + def _update_hidden_slides(self): + """adjust carousel slides according to visible widgets""" + if self._slides_update_lock or not self.carousel_active: + return + current_slide = self.carousel.current_slide + if not isinstance(current_slide, quick_widgets.QuickWidget): + return + # lock must be used here to avoid recursions + self._slides_update_lock += 1 + visible_list = G.host.get_visible_list(current_slide.__class__) + # we ignore current_slide as it may not be visible yet (e.g. if an other + # screen is shown + hidden = list(self.hidden_list(visible_list, ignore=current_slide)) + slides_sorted = sorted(set(hidden + [current_slide])) + to_remove = set(self.carousel.slides).difference({current_slide}) + for w in to_remove: + self.carousel.remove_widget(w) + if hidden: + # no need to add more than two widgets (next and previous), + # as the list will be updated on each new visible widget + current_idx = slides_sorted.index(current_slide) + try: + next_slide = slides_sorted[current_idx+1] + except IndexError: + next_slide = slides_sorted[0] + self.carousel.add_widget(G.host.get_or_clone(next_slide)) + if len(hidden)>1: + previous_slide = slides_sorted[current_idx-1] + self.carousel.add_widget(G.host.get_or_clone(previous_slide)) + + self._slides_update_lock -= 1 + + +class WidgetsHandlerLayout(Layout): + count = 0 + + def __init__(self, **kwargs): + super(WidgetsHandlerLayout, self).__init__(**kwargs) + self._layout_size = None # size used for the last layout + fbind = self.fbind + update = self._trigger_layout + fbind('children', update) + fbind('parent', update) + fbind('size', self.adjust_prop) + fbind('pos', update) + + @property + def default_widget(self): + return G.host.default_wid['factory'](G.host.default_wid, None, None) + + def adjust_prop(self, handler, new_size): + """Adjust children proportion + + useful when this widget is resized (e.g. when going to fullscreen) + """ + if len(self.children) > 1: + old_width, old_height = self._layout_size + if not old_width or not old_height: + # we don't want division by zero + return self._trigger_layout(handler, new_size) + width_factor = float(self.width) / old_width + height_factor = float(self.height) / old_height + for child in self.children: + child.width *= width_factor + child.height *= height_factor + child.x *= width_factor + child.y *= height_factor + self._trigger_layout(handler, new_size) + + def do_layout(self, *args): + self._layout_size = self.size[:] + for child in self.children: + # XXX: left must be calculated before right and bottom before top + # because they are the pos, and are used to caculate size (right and top) + # left + left = child._left_wid + left_end_x = self.x-1 if left is None else left.right + if child.x != left_end_x + 1 and child._split == "None": + child.x = left_end_x + 1 + # right + right = child._right_wid + right_x = self.right + 1 if right is None else right.x + if child.right != right_x - 1: + child.width = right_x - child.x - 1 + # bottom + bottom = child._bottom_wid + if bottom is None: + if child.y != self.y: + child.y = self.y + else: + if child.y != bottom.top + 1: + child.y = bottom.top + 1 + # top + top = child._top_wid + top_y = self.top+1 if top is None else top.y + if child.top != top_y - 1: + if child._split == "None": + child.height = top_y - child.y - 1 + + def remove_widget(self, wid): + super(WidgetsHandlerLayout, self).remove_widget(wid) + log.debug("widget deleted ({})".format(wid._wid_idx)) + + def add_widget(self, wid=None, index=0): + WidgetsHandlerLayout.count += 1 + if wid is None: + wid = self.default_widget + if G.host.selected_widget is None: + G.host.selected_widget = wid + wrapper = WHWrapper(_wid_idx=WidgetsHandlerLayout.count) + log.debug("WHWrapper created ({})".format(wrapper._wid_idx)) + wrapper.set_widget(wid) + super(WidgetsHandlerLayout, self).add_widget(wrapper, index) + return wrapper + + +class WidgetsHandler(WidgetsHandlerLayout): + + def __init__(self, **kw): + super(WidgetsHandler, self).__init__(**kw) + self.add_widget()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/xmlui.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 + + +# Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from libervia.backend.core.i18n import _ +from .constants import Const as C +from libervia.backend.core.log import getLogger +from libervia.frontends.tools import xmlui +from kivy.uix.scrollview import ScrollView +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem +from kivy.uix.textinput import TextInput +from kivy.uix.label import Label +from kivy.uix.button import Button +from kivy.uix.togglebutton import ToggleButton +from kivy.uix.widget import Widget +from kivy.uix.switch import Switch +from kivy import properties +from libervia.desktop_kivy import G +from libervia.desktop_kivy.core import dialog +from functools import partial + +log = getLogger(__name__) + +## Widgets ## + + +class TextInputOnChange(object): + + def __init__(self): + self._xmlui_onchange_cb = None + self._got_focus = False + + def _xmlui_on_change(self, callback): + self._xmlui_onchange_cb = callback + + def on_focus(self, instance, focus): + # we need to wait for first focus, else initial value + # will trigger a on_text + if not self._got_focus and focus: + self._got_focus = True + + def on_text(self, instance, new_text): + if self._xmlui_onchange_cb is not None and self._got_focus: + self._xmlui_onchange_cb(self) + + +class EmptyWidget(xmlui.EmptyWidget, Widget): + + def __init__(self, _xmlui_parent): + Widget.__init__(self) + + +class TextWidget(xmlui.TextWidget, Label): + + def __init__(self, xmlui_parent, value): + Label.__init__(self, text=value) + + +class LabelWidget(xmlui.LabelWidget, TextWidget): + pass + + +class JidWidget(xmlui.JidWidget, TextWidget): + pass + + +class StringWidget(xmlui.StringWidget, TextInput, TextInputOnChange): + + def __init__(self, xmlui_parent, value, read_only=False): + TextInput.__init__(self, text=value) + TextInputOnChange.__init__(self) + self.readonly = read_only + + def _xmlui_set_value(self, value): + self.text = value + + def _xmlui_get_value(self): + return self.text + + +class TextBoxWidget(xmlui.TextBoxWidget, StringWidget): + pass + + +class JidInputWidget(xmlui.JidInputWidget, StringWidget): + pass + + +class ButtonWidget(xmlui.ButtonWidget, Button): + + def __init__(self, _xmlui_parent, value, click_callback): + Button.__init__(self) + self.text = value + self.callback = click_callback + + def _xmlui_on_click(self, callback): + self.callback = callback + + def on_release(self): + self.callback(self) + + +class DividerWidget(xmlui.DividerWidget, Widget): + # FIXME: not working properly + only 'line' is handled + style = properties.OptionProperty('line', + options=['line', 'dot', 'dash', 'plain', 'blank']) + + def __init__(self, _xmlui_parent, style="line"): + Widget.__init__(self, style=style) + + +class ListWidgetItem(ToggleButton): + value = properties.StringProperty() + + def on_release(self): + parent = self.parent + while parent is not None and not isinstance(parent, ListWidget): + parent = parent.parent + + if parent is not None: + parent.select(self) + return super(ListWidgetItem, self).on_release() + + @property + def selected(self): + return self.state == 'down' + + @selected.setter + def selected(self, value): + self.state = 'down' if value else 'normal' + + +class ListWidget(xmlui.ListWidget, ScrollView): + layout = properties.ObjectProperty() + + def __init__(self, _xmlui_parent, options, selected, flags): + ScrollView.__init__(self) + self.multi = 'single' not in flags + self._values = [] + for option in options: + self.add_value(option) + self._xmlui_select_values(selected) + self._on_change = None + + @property + def items(self): + return self.layout.children + + def select(self, item): + if not self.multi: + self._xmlui_select_values([item.value]) + if self._on_change is not None: + self._on_change(self) + + def add_value(self, option, selected=False): + """add a value in the list + + @param option(tuple): value, label in a tuple + """ + self._values.append(option) + item = ListWidgetItem() + item.value, item.text = option + item.selected = selected + self.layout.add_widget(item) + + def _xmlui_select_value(self, value): + self._xmlui_select_values([value]) + + def _xmlui_select_values(self, values): + for item in self.items: + item.selected = item.value in values + if item.selected and not self.multi: + self.text = item.text + + def _xmlui_get_selected_values(self): + return [item.value for item in self.items if item.selected] + + def _xmlui_add_values(self, values, select=True): + values = set(values).difference([c.value for c in self.items]) + for v in values: + self.add_value(v, select) + + def _xmlui_on_change(self, callback): + self._on_change = callback + + +class JidsListWidget(ListWidget): + # TODO: real list dedicated to jids + + def __init__(self, _xmlui_parent, jids, flags): + ListWidget.__init__(self, _xmlui_parent, [(j,j) for j in jids], [], flags) + + +class PasswordWidget(xmlui.PasswordWidget, TextInput, TextInputOnChange): + + def __init__(self, _xmlui_parent, value, read_only=False): + TextInput.__init__(self, password=True, multiline=False, + text=value, readonly=read_only, size=(100,25), size_hint=(1,None)) + TextInputOnChange.__init__(self) + + def _xmlui_set_value(self, value): + self.text = value + + def _xmlui_get_value(self): + return self.text + + +class BoolWidget(xmlui.BoolWidget, Switch): + + def __init__(self, _xmlui_parent, state, read_only=False): + Switch.__init__(self, active=state) + if read_only: + self.disabled = True + + def _xmlui_set_value(self, value): + self.active = value + + def _xmlui_get_value(self): + return C.BOOL_TRUE if self.active else C.BOOL_FALSE + + def _xmlui_on_change(self, callback): + self.bind(active=lambda instance, value: callback(instance)) + + +class IntWidget(xmlui.IntWidget, TextInput, TextInputOnChange): + + def __init__(self, _xmlui_parent, value, read_only=False): + TextInput.__init__(self, text=value, input_filter='int', multiline=False) + TextInputOnChange.__init__(self) + if read_only: + self.disabled = True + + def _xmlui_set_value(self, value): + self.text = value + + def _xmlui_get_value(self): + return self.text + + +## Containers ## + + +class VerticalContainer(xmlui.VerticalContainer, BoxLayout): + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + BoxLayout.__init__(self) + + def _xmlui_append(self, widget): + self.add_widget(widget) + + +class PairsContainer(xmlui.PairsContainer, GridLayout): + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + GridLayout.__init__(self) + + def _xmlui_append(self, widget): + self.add_widget(widget) + + +class LabelContainer(PairsContainer, xmlui.LabelContainer): + pass + + +class TabsPanelContainer(TabbedPanelItem): + layout = properties.ObjectProperty(None) + + def _xmlui_append(self, widget): + self.layout.add_widget(widget) + + +class TabsContainer(xmlui.TabsContainer, TabbedPanel): + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + TabbedPanel.__init__(self, do_default_tab=False) + + def _xmlui_add_tab(self, label, selected): + tab = TabsPanelContainer(text=label) + self.add_widget(tab) + return tab + + +class AdvancedListRow(BoxLayout): + global_index = 0 + index = properties.ObjectProperty() + selected = properties.BooleanProperty(False) + + def __init__(self, **kwargs): + self.global_index = AdvancedListRow.global_index + AdvancedListRow.global_index += 1 + super(AdvancedListRow, self).__init__(**kwargs) + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + parent = self.parent + while parent is not None and not isinstance(parent, AdvancedListContainer): + parent = parent.parent + if parent is None: + log.error("Can't find parent AdvancedListContainer") + else: + if parent.selectable: + self.selected = parent._xmlui_toggle_selected(self) + + return super(AdvancedListRow, self).on_touch_down(touch) + + +class AdvancedListContainer(xmlui.AdvancedListContainer, BoxLayout): + + def __init__(self, xmlui_parent, columns, selectable='no'): + self.xmlui_parent = xmlui_parent + BoxLayout.__init__(self) + self._columns = columns + self.selectable = selectable != 'no' + self._current_row = None + self._selected = [] + self._xmlui_select_cb = None + + def _xmlui_toggle_selected(self, row): + """inverse selection status of an AdvancedListRow + + @param row(AdvancedListRow): row to (un)select + @return (bool): True if row is selected + """ + try: + self._selected.remove(row) + except ValueError: + self._selected.append(row) + if self._xmlui_select_cb is not None: + self._xmlui_select_cb(self) + return True + else: + return False + + def _xmlui_append(self, widget): + if self._current_row is None: + log.error("No row set, ignoring append") + return + self._current_row.add_widget(widget) + + def _xmlui_add_row(self, idx): + self._current_row = AdvancedListRow() + self._current_row.cols = self._columns + self._current_row.index = idx + self.add_widget(self._current_row) + + def _xmlui_get_selected_widgets(self): + return self._selected + + def _xmlui_get_selected_index(self): + if not self._selected: + return None + return self._selected[0].index + + def _xmlui_on_select(self, callback): + """ Call callback with widget as only argument """ + self._xmlui_select_cb = callback + + +## Dialogs ## + + +class NoteDialog(xmlui.NoteDialog): + + def __init__(self, _xmlui_parent, title, message, level): + xmlui.NoteDialog.__init__(self, _xmlui_parent) + self.title, self.message, self.level = title, message, level + + def _xmlui_show(self): + G.host.add_note(self.title, self.message, self.level) + + +class MessageDialog(xmlui.MessageDialog, dialog.MessageDialog): + + def __init__(self, _xmlui_parent, title, message, level): + dialog.MessageDialog.__init__(self, + title=title, + message=message, + level=level, + close_cb = self.close_cb) + xmlui.MessageDialog.__init__(self, _xmlui_parent) + + def close_cb(self): + self._xmlui_close() + + def _xmlui_show(self): + G.host.add_notif_ui(self) + + def _xmlui_close(self, reason=None): + G.host.close_ui() + + def show(self, *args, **kwargs): + G.host.show_ui(self) + + +class ConfirmDialog(xmlui.ConfirmDialog, dialog.ConfirmDialog): + + def __init__(self, _xmlui_parent, title, message, level, buttons_set): + dialog.ConfirmDialog.__init__(self) + xmlui.ConfirmDialog.__init__(self, _xmlui_parent) + self.title=title + self.message=message + self.no_cb = self.no_cb + self.yes_cb = self.yes_cb + + def no_cb(self): + G.host.close_ui() + self._xmlui_cancelled() + + def yes_cb(self): + G.host.close_ui() + self._xmlui_validated() + + def _xmlui_show(self): + G.host.add_notif_ui(self) + + def _xmlui_close(self, reason=None): + G.host.close_ui() + + def show(self, *args, **kwargs): + assert kwargs["force"] + G.host.show_ui(self) + + +class FileDialog(xmlui.FileDialog, BoxLayout): + message = properties.ObjectProperty() + + def __init__(self, _xmlui_parent, title, message, level, filetype): + xmlui.FileDialog.__init__(self, _xmlui_parent) + BoxLayout.__init__(self) + self.message.text = message + if filetype == C.XMLUI_DATA_FILETYPE_DIR: + self.file_chooser.dirselect = True + + def _xmlui_show(self): + G.host.add_notif_ui(self) + + def _xmlui_close(self, reason=None): + # FIXME: notif UI is not removed if dialog is not shown yet + G.host.close_ui() + + def on_select(self, path): + try: + path = path[0] + except IndexError: + path = None + if not path: + self._xmlui_cancelled() + else: + self._xmlui_validated({'path': path}) + + def show(self, *args, **kwargs): + assert kwargs["force"] + G.host.show_ui(self) + + +## Factory ## + + +class WidgetFactory(object): + + def __getattr__(self, attr): + if attr.startswith("create"): + cls = globals()[attr[6:]] + return cls + + +## Core ## + + +class Title(Label): + + def __init__(self, *args, **kwargs): + kwargs['size'] = (100, 25) + kwargs['size_hint'] = (1,None) + super(Title, self).__init__(*args, **kwargs) + + +class FormButton(Button): + pass + +class SubmitButton(FormButton): + pass + +class CancelButton(FormButton): + pass + +class SaveButton(FormButton): + pass + + +class XMLUIPanel(xmlui.XMLUIPanel, ScrollView): + widget_factory = WidgetFactory() + layout = properties.ObjectProperty() + + def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, + ignore=None, whitelist=None, profile=C.PROF_KEY_NONE): + ScrollView.__init__(self) + self.close_cb = None + self._post_treats = [] # list of callback to call after UI is constructed + + # used to workaround touch issues when a ScrollView is used inside this + # one. This happens notably when a TabsContainer is used as main container + # (this is the case with settings). + self._skip_scroll_events = False + xmlui.XMLUIPanel.__init__(self, + host, + parsed_xml, + title=title, + flags=flags, + callback=callback, + ignore=ignore, + whitelist=whitelist, + profile=profile) + self.bind(height=self.on_height) + + def on_touch_down(self, touch, after=False): + if self._skip_scroll_events: + return super(ScrollView, self).on_touch_down(touch) + else: + return super(XMLUIPanel, self).on_touch_down(touch) + + def on_touch_up(self, touch, after=False): + if self._skip_scroll_events: + return super(ScrollView, self).on_touch_up(touch) + else: + return super(XMLUIPanel, self).on_touch_up(touch) + + def on_touch_move(self, touch, after=False): + if self._skip_scroll_events: + return super(ScrollView, self).on_touch_move(touch) + else: + return super(XMLUIPanel, self).on_touch_move(touch) + + def set_close_cb(self, close_cb): + self.close_cb = close_cb + + def _xmlui_close(self, __=None, reason=None): + if self.close_cb is not None: + self.close_cb(self, reason) + else: + G.host.close_ui() + + def on_param_change(self, ctrl): + super(XMLUIPanel, self).on_param_change(ctrl) + self.save_btn.disabled = False + + def add_post_treat(self, callback): + self._post_treats.append(callback) + + def _post_treat_cb(self): + for cb in self._post_treats: + cb() + del self._post_treats + + def _save_button_cb(self, button): + button.disabled = True + self.on_save_params(button) + + def construct_ui(self, parsed_dom): + xmlui.XMLUIPanel.construct_ui(self, parsed_dom, self._post_treat_cb) + if self.xmlui_title: + self.layout.add_widget(Title(text=self.xmlui_title)) + if isinstance(self.main_cont, TabsContainer): + # cf. comments above + self._skip_scroll_events = True + self.layout.add_widget(self.main_cont) + if self.type == 'form': + submit_btn = SubmitButton() + submit_btn.bind(on_press=self.on_form_submitted) + self.layout.add_widget(submit_btn) + if not 'NO_CANCEL' in self.flags: + cancel_btn = CancelButton(text=_("Cancel")) + cancel_btn.bind(on_press=self.on_form_cancelled) + self.layout.add_widget(cancel_btn) + elif self.type == 'param': + self.save_btn = SaveButton(text=_("Save"), disabled=True) + self.save_btn.bind(on_press=self._save_button_cb) + self.layout.add_widget(self.save_btn) + elif self.type == 'window': + cancel_btn = CancelButton(text=_("Cancel")) + cancel_btn.bind( + on_press=partial(self._xmlui_close, reason=C.XMLUI_DATA_CANCELLED)) + self.layout.add_widget(cancel_btn) + + def on_height(self, __, height): + if isinstance(self.main_cont, TabsContainer): + other_children_height = sum([c.height for c in self.layout.children + if c is not self.main_cont]) + self.main_cont.height = height - other_children_height + + def show(self, *args, **kwargs): + if not self.user_action and not kwargs.get("force", False): + G.host.add_notif_ui(self) + else: + G.host.show_ui(self) + + +class XMLUIDialog(xmlui.XMLUIDialog): + dialog_factory = WidgetFactory() + + +create = partial(xmlui.create, class_map={ + xmlui.CLASS_PANEL: XMLUIPanel, + xmlui.CLASS_DIALOG: XMLUIDialog})
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/base.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,25 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +<Label>: + color: 0, 0, 0, 1 + +<Button>: + color: 1, 1, 1, 1 + +<TextInput>: + background_normal: app.expand('{media}/misc/borders/border_filled_black.png')
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/behaviors.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,28 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +<TouchMenu>: + creation_direction: -1 + radius: dp(25) + creation_timeout: .4 + cancel_color: app.c_sec_light[:3] + [0.3] + color: app.c_sec + line_width: dp(2) + +<ModernMenuLabel>: + bg_color: app.c_sec[:3] + [0.9] + padding: dp(5), dp(5) + radius: dp(100)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/cagou_widget.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,84 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import C libervia.desktop_kivy.core.constants.Const + + +<HeaderChoice>: + canvas.before: + Color: + rgba: 1, 1, 1, 1 + BorderImage: + pos: self.pos + size: self.size + source: 'atlas://data/images/defaulttheme/button' + size_hint_y: None + height: dp(44) + spacing: dp(20) + padding: dp(5), dp(3), dp(10), dp(3) + +<HeaderChoiceWidget>: + ActionIcon: + plugin_info: root.plugin_info + size_hint: None, 1 + width: self.height + Label: + size_hint: None, 1 + text: root.plugin_info['name'] + color: 1, 1, 1, 1 + bold: True + size: self.texture_size + halign: "center" + valign: "middle" + +<HeaderChoiceExtraMenu>: + ActionSymbol: + symbol: "dot-3-vert" + size_hint: None, 1 + width: self.height + Label: + size_hint: None, 1 + text: _("extra") + color: 1, 1, 1, 1 + bold: True + size: self.texture_size + halign: "center" + valign: "middle" + +<HeaderWidgetSelector>: + size_hint: None, None + auto_width: False + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + +<LiberviaDesktopKivyWidget>: + header_box: header_box + orientation: "vertical" + BoxLayout: + id: header_box + size_hint: 1, None + height: dp(32) + spacing: dp(3) + padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 + HeaderWidgetCurrent: + plugin_info: root.plugin_info + size_hint: None, 1 + width: self.height + on_release: root.selector.open(self)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/common.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,164 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +<NotifLabel>: + background_color: app.c_sec_light + size_hint: None, None + text_size: None, root.height + padding_x: sp(5) + size: self.texture_size + bold: True + canvas.before: + Color: + # self.background_color doesn't seem initialized correctly on startup + # (maybe a Kivy bug? to be checked), thus we use the "or" below + rgb: self.background_color or app.c_sec_light + Ellipse: + size: self.size + pos: self.pos + + +<ContactItem>: + size_hint: None, None + width: self.base_width + height: self.minimum_height + orientation: 'vertical' + avatar: avatar + avatar_layout: avatar_layout + FloatLayout: + id: avatar_layout + size_hint: 1, None + height: dp(60) + Avatar: + id: avatar + pos_hint: {'x': 0, 'y': 0} + data: root.data.get('avatar') + allow_stretch: True + BoxLayout: + id: label_box + size_hint: 1, None + height: self.minimum_height + Label: + size_hint: 1, None + height: self.font_size + sp(5) + text_size: self.size + shorten: True + shorten_from: "right" + text: root.data.get('nick', root.jid.node or root.jid) + bold: True + valign: 'middle' + halign: 'center' + + +<JidItem>: + size_hint: 1, None + height: dp(68) + avatar: avatar + padding: 0, dp(2), 0, dp(2) + canvas.before: + Color: + rgba: self.bg_color + Rectangle: + pos: self.pos + size: self.size + Image: + id: avatar + size_hint: None, None + size: dp(64), dp(64) + Label: + size_hint: 1, 1 + text_size: self.size + color: root.color + bold: True + text: root.jid + halign: 'left' + valign: 'middle' + padding_x: dp(5) + +<JidToggle>: + canvas.before: + Color: + rgba: self.selected_color if self.state == 'down' else self.bg_color + Rectangle: + pos: self.pos + size: self.size + +<Symbol>: + width: dp(35) + height: dp(35) + font_name: app.expand('{media}/fonts/fontello/font/fontello.ttf') + text_size: self.size + font_size: dp(30) + halign: 'center' + valign: 'middle' + bg_color: 0, 0, 0, 0 + canvas.before: + Color: + rgba: self.bg_color + Rectangle: + pos: self.pos + size: self.size + +<SymbolLabel>: + size_hint: None, 1 + width: self.minimum_width + symbol_wid: symbol_wid + label: label + Symbol: + id: symbol_wid + size_hint: None, 1 + symbol: root.symbol + color: root.color + Label: + id: label + size_hint: None, 1 + text_size: None, root.height + size: self.texture_size + padding_x: dp(5) + valign: 'middle' + text: root.text + bold: root.bold + +<SymbolToggleLabel>: + color: 0, 0, 0, 1 + canvas.before: + Color: + rgba: app.c_sec_light if self.state == 'down' else (0, 0, 0, 0) + RoundedRectangle: + pos: self.pos + size: self.size + +<ActionSymbol>: + bg_color: 0, 0, 0, 0 + color: app.c_sec_light + +<SizedImage>: + size_hint: None, None + + +<JidSelectorCategoryLayout>: + size_hint: 1, None + height: self.minimum_height + spacing: 0 + +<JidSelector>: + layout: layout + StackLayout: + id: layout + size_hint: 1, None + height: self.minimum_height + spacing: 0
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/common_widgets.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,65 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +<ItemWidget>: + size_hint: None, None + width: self.base_width + height: self.minimum_height + orientation: 'vertical' + + +<DeviceWidget>: + Symbol: + size_hint: 1, None + height: dp(80) + font_size: dp(40) + symbol: root.get_symbol() + color: 0, 0, 0, 1 + Label: + size_hint: None, None + width: dp(100) + font_size: sp(14) + text_size: dp(95), None + size: self.texture_size + text: root.name + halign: 'center' + + +<CategorySeparator>: + size_hint: 1, None + height: sp(35) + bold: True + font_size: sp(20) + color: app.c_sec + +<ImageViewer>: + do_rotation: False + AsyncImage: + source: root.source + allow_stretch: True, + + +<ImagesGallery>: + carousel: carousel + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + Carousel: + id: carousel
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/dialog.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,95 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import _ libervia.backend.core.i18n._ + + +<MessageDialog>: + orientation: "vertical" + spacing: dp(5) + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + font_size: sp(20) + padding: dp(5), dp(10) + color: 1, 1, 1, 1 + text: root.title + halign: "center" + italic: True + bold: True + Label: + text: root.message + text_size: root.width, None + size: self.texture_size + padding: dp(25), 0 + font_size: sp(20) + color: 1, 1, 1, 1 + Button: + size_hint: 1, None + height: dp(50) + background_color: 0.33, 1.0, 0.0, 1 + text: _("Close") + bold: True + on_release: root.close_cb() + + +<ConfirmDialog>: + orientation: "vertical" + spacing: dp(5) + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + font_size: sp(20) + padding: dp(5), dp(10) + color: 1, 1, 1, 1 + text: root.title + halign: "center" + italic: True + bold: True + Label: + text: root.message + text_size: root.width, None + size: self.texture_size + padding: dp(25), 0 + font_size: sp(20) + color: 1, 1, 1, 1 + Button: + size_hint: 1, None + height: dp(50) + background_color: 0.33, 1.0, 0.0, 1 + text: _("Yes") + bold: True + on_release: root.yes_cb() if root.yes_cb is not None else None + Button: + size_hint: 1, None + height: dp(50) + text: _("No") + bold: True + on_release: root.no_cb() if root.no_cb is not None else None
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/menu.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,152 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import _ libervia.backend.core.i18n._ +#:import C libervia.desktop_kivy.core.constants.Const + +<AboutContent>: + text_size: self.size + color: 1, 1, 1, 1 + halign: "center" + valign: "middle" + +<AboutPopup>: + title_align: "center" + size_hint: 0.8, 0.8 + +<ExtraMenuItem>: + size_hint: 1, None + height: dp(30) + +<ExtraSideMenu>: + bg_color: 0.23, 0.23, 0.23, 1 + ExtraMenuItem: + text: _("About") + on_press: root.on_about() + Widget: + # to push content to the top + +<TransferMenu>: + items_layout: items_layout + orientation: "vertical" + bg_color: app.c_prim + size_hint: 1, 0.5 + padding: [app.MARGIN_LEFT, 3, app.MARGIN_RIGHT, 0] + spacing: dp(5) + transfer_info: transfer_info + upload_btn: upload_btn + on_encrypted: self.update_transfer_info() + canvas.after: + Color: + rgba: app.c_prim_dark + Line: + points: 0, self.y + self.height, self.width + self.x, self.y + self.height + width: 1 + BoxLayout: + size_hint: 1, None + height: dp(50) + spacing: dp(10) + Widget: + SymbolToggleLabel + id: upload_btn + symbol: "upload" + text: _(u"upload") + group: "transfer" + state: "down" + on_state: root.update_transfer_info() + SymbolToggleLabel + id: send_btn + symbol: "loop-alt" + text: _(u"send") + group: "transfer" + Widget: + Label: + id: transfer_info + size_hint: 1, None + padding: 0, dp(5) + markup: True + text_size: self.width, None + size: self.texture_size + halign: 'center' + canvas.before: + Color: + rgba: app.c_prim_dark + RoundedRectangle: + pos: self.pos + size: self.size + ScrollView: + do_scroll_x: False + StackLayout: + size_hint: 1, None + padding: 20, 0 + spacing: 15, 5 + id: items_layout + +<TransferItem>: + orientation: "vertical" + size_hint: None, None + size: dp(50), dp(90) + IconButton: + source: root.plug_info['icon_medium'] + allow_stretch: True + size_hint: 1, None + height: dp(50) + Label: + color: 0, 0, 0, 1 + text: root.plug_info['name'] + text_size: self.size + halign: "center" + valign: "top" + + +<SideMenu>: + orientation: "vertical" + size_hint: self.size_hint_close + canvas.before: + Color: + rgba: self.bg_color + Rectangle: + pos: self.pos + size: self.size + + +<EntitiesSelectorMenu>: + bg_color: 0, 0, 0, 0.9 + filter_input: filter_input + layout: layout + callback_on_close: True + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + padding: dp(5), dp(5) + color: 1, 1, 1, 1 + text: root.instructions + halign: "center" + TextInput: + id: filter_input + size_hint: 1, None + height: dp(32) + multiline: False + hint_text: _(u"enter filter here") + ScrollView: + size_hint: 1, 1 + BoxLayout: + id: layout + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height + spacing: dp(5)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/profile_manager.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,197 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +<ProfileManager>: + Label: + size_hint: 1, None + text_size: root.width, None + width: self.texture_size[0] + height: self.texture_size[1] + dp(20) + text: "Profile Manager" + halign: "center" + bold: True + +<PMLabel@Label>: + size_hint: 1, None + height: sp(30) + +<PMInput@TextInput>: + multiline: False + size_hint: 1, None + height: sp(30) + write_tab: False + +<PMButton@Button>: + size_hint: 1, None + height: dp(40) + + +<NewProfileScreen>: + profile_name: profile_name + jid: jid + password: password + + BoxLayout: + orientation: "vertical" + + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + text: "Creation of a new profile" + halign: "center" + Label: + text: root.error_msg + bold: True + size_hint: 1, None + height: dp(40) + color: 1,0,0,1 + GridLayout: + cols: 2 + + PMLabel: + text: "Profile name" + PMInput: + id: profile_name + + PMLabel: + text: "JID" + PMInput: + id: jid + + PMLabel: + text: "Password" + PMInput: + id: password + password: True + + Widget: + size_hint: 1, None + height: dp(50) + + Widget: + size_hint: 1, None + height: dp(50) + + PMButton: + text: "OK" + on_press: root.doCreate() + + PMButton: + text: "Cancel" + on_press: + root.pm.screen_manager.transition.direction = 'right' + root.pm.screen_manager.current = 'profiles' + + Widget: + + +<DeleteProfilesScreen>: + BoxLayout: + orientation: "vertical" + + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + text: "Are you sure you want to delete the following profiles?" + halign: "center" + + Label: + size_hint: 1, None + text_size: root.width, None + height: self.texture_size[1] + dp(60) + width: self.texture_size[0] + halign: "center" + # for now we only handle single selection + text: u'\n'.join([i.text for i in [root.pm.selected]]) if root.pm.selected else u'' + bold: True + + Label: + size_hint: 1, None + text_size: root.width, dp(30) + height: self.texture_size[1] + text: u'/!\\ WARNING: this operation is irreversible' + color: 1,0,0,1 + bold: True + halign: "center" + valign: "top" + GridLayout: + cols: 2 + PMButton: + text: "Delete" + on_press: root.do_delete() + + PMButton: + text: "Cancel" + on_press: + root.pm.screen_manager.transition.direction = 'right' + root.pm.screen_manager.current = 'profiles' + + +<ProfilesScreen>: + layout: layout + BoxLayout: + orientation: 'vertical' + + Label: + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + text: "Select a profile or create a new one" + halign: "center" + + GridLayout: + cols: 2 + size_hint: 1, None + height: dp(40) + Button: + text: "New" + on_press: + root.pm.screen_manager.transition.direction = 'left' + root.pm.screen_manager.current = 'new_profile' + Button: + disabled: not root.pm.selected + text: "Delete" + on_press: + root.pm.screen_manager.transition.direction = 'left' + root.pm.screen_manager.current = 'delete_profiles' + ScrollView + BoxLayout: + size_hint: 1, None + height: self.minimum_height + orientation: "vertical" + id: layout + Button + text: "Connect" + size_hint: 1, None + height: dp(40) + disabled: not root.pm.selected + on_press: root.pm._on_connect_profiles() + + +<ProfileItem>: + size_hint: 1, None + background_normal: "" + background_down: "" + deselected_color: (1,1,1,1) if self.index%2 else (0.87,0.87,0.87,1) + selected_color: 0.67,1.0,1.0,1 + selected: self.state == 'down' + color: 0,0,0,1 + background_color: self.selected_color if self.selected else self.deselected_color + on_press: self.ps.pm.select_profile(self) + height: dp(30)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/root_widget.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,128 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import IconButton libervia.desktop_kivy.core.common.IconButton +#:import C libervia.desktop_kivy.core.constants.Const + +# <NotifIcon>: +# source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") +# size_hint: None, None +# size: self.texture_size + +<Note>: + text: self.message + text_size: self.parent.size if self.parent else (100, 100) + halign: 'center' + padding_x: dp(5) + shorten: True + shorten_from: 'right' + +<NoteDrop>: + orientation: 'horizontal' + size_hint: 1, None + height: max(label.height, dp(45)) + symbol: symbol + canvas.before: + BorderImage: + pos: self.pos + size: self.size + source: 'atlas://data/images/defaulttheme/button' + Widget: + size_hint: None, 1 + width: dp(20) + Symbol: + id: symbol + size_hint: None, 1 + width: dp(30) + padding_y: dp(10) + valign: 'top' + haligh: 'right' + symbol: root.symbol or root.level + color: + C.COLOR_PRIM_LIGHT if root.symbol is None else \ + {C.XMLUI_DATA_LVL_INFO: app.c_prim_light,\ + C.XMLUI_DATA_LVL_WARNING: C.COLOR_WARNING,\ + C.XMLUI_DATA_LVL_ERROR: C.COLOR_ERROR}[root.level] + Label: + id: label + size_hint: 1, None + color: 1, 1, 1, 1 + text: root.message + text_size: self.width, None + halign: 'center' + size: self.texture_size + padding: dp(2), dp(10) + +<NotesDrop>: + clear_btn: clear_btn.__self__ + auto_width: False + size_hint: 0.9, None + size_hint_max_x: dp(400) + canvas.before: + Color: + rgba: 0.8, 0.8, 0.8, 1 + Rectangle: + pos: self.pos + size: self.size + Button: + id: clear_btn + text: "clear" + bold: True + size_hint: 1, None + height: dp(50) + on_release: del root.notes[:]; root.dismiss() + +<RootHeadWidget>: + manager: manager + notifs_icon: notifs_icon + size_hint: 1, None + height: self.HEIGHT + padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 + IconButton: + source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_48.png") + allow_stretch: True + size_hint: None, None + pos_hint: {'center_y': .5} + height: dp(25) + width: dp(35) if root.notes else 0 + opacity: 1 if root.notes else 0 + on_release: root.notes_drop.open(self) if root.notes else None + ScreenManager: + id: manager + NotifsIcon: + id: notifs_icon + allow_stretch: True + source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_miroir_48.png") + size_hint: None, None + pos_hint: {'center_y': .5} + height: dp(25) + width: dp(35) if self.notifs else 0 + opacity: 1 if self.notifs else 0 + Symbol: + id: disconnected_icon + size_hint: None, 1 + pos_hint: {'center_y': .5} + font_size: dp(23) + width: 0 if app.connected else dp(30) + opacity: 0 if app.connected else 1 + symbol: "plug" + color: 0.80, 0.0, 0.0, 1 + +<LiberviaDesktopKivyRootWidget>: + root_body: root_body + RootBody: + id: root_body + orientation: "vertical"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/share_widget.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,114 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import Path pathlib.Path +#:import _ libervia.backend.core.i18n._ +#:import C libervia.desktop_kivy.core.constants.Const + + +<ShareWidget>: + preview_box: preview_box + orientation: 'vertical' + Label: + size_hint: 1, None + text_size: self.size + halign: 'center' + text: _("share") + height: self.font_size + dp(5) + bold: True + font_size: '35sp' + BoxLayout: + id: preview_box + size_hint: 1, None + height: self.minimum_height + orientation: 'vertical' + text: str(root.data) + Label: + size_hint: 1, None + text_size: self.size + halign: 'center' + text: _("with") + height: self.font_size + dp(5) + bold: True + font_size: '25sp' + JidSelector: + on_select: root.on_select(args[1]) + Button: + size_hint: 1, None + height: C.BTN_HEIGHT + text: _("cancel") + on_press: app.host.close_ui() + + +<TextPreview>: + size_hint: 1, None + height: min(data.height, dp(100)) + ScrollView + Label: + id: data + size_hint: 1, None + text: root.text + text_size: self.width, None + size: self.texture_size + font_size: sp(20) + padding_x: dp(10) + padding_y: dp(5) + halign: 'center' + canvas.before: + Color: + rgba: 0.95, 0.95, 0.95, 1 + Rectangle: + pos: self.pos + size: self.size + +<ImagePreview>: + reduce_layout: reduce_layout + reduce_checkbox: reduce_checkbox + size_hint: 1, None + height: dp(120) + orientation: "vertical" + Image: + source: root.path + BoxLayout + id: reduce_layout + size_hint: 1, None + padding_y: None + opacity: 0 + height: 0 + Widget: + CheckBox: + id: reduce_checkbox + size_hint: None, 1 + width: dp(20) + active: True + Label: + size_hint: None, None + text: _("reduce image size") + text_size: None, None + size: self.texture_size + padding_x: dp(10) + font_size: sp(15) + Widget: + +<GenericPreview>: + size_hint: 1, None + height: dp(100) + Widget: + SymbolButtonLabel: + symbol: "doc" + text: Path(root.path).name + Widget: +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/simple_xhtml.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,31 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import C libervia.desktop_kivy.core.constants.Const + + +<SimpleXHTMLWidget>: + size_hint: 1, None + height: self.minimum_height + +<SimpleXHTMLWidgetEscapedText>: + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] if self.text else 0 + +<SimpleXHTMLWidgetText>: + size_hint: None, None + size: self.texture_size
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/widgets_handler.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,43 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +<WHWrapper>: + _sp_top_y: self.y + self.height - self.sp_size + padding: self.split_size + self.split_margin, self.split_size + self.split_margin, 0, 0 + + canvas.before: + # 2 lines to indicate the split zones + Color: + rgba: self.split_color if self._split != 'left' else self.split_color_del if self._split_del else self.split_color_move + Rectangle: + pos: self.pos + size: self.split_size, self.height + Color: + rgba: self.split_color if self._split != 'top' else self.split_color_del if self._split_del else self.split_color_move + Rectangle: + pos: self.x, self.y + self.height - self.split_size + size: self.width, self.split_size + # 3 dots to indicate the main split points + Color: + rgba: 0, 0, 0, 1 + Point: + # left + points: self.x + self.sp_size, self.y + self.height / 2 - self.sp_size - self.sp_space, self.x + self.sp_size, self.y + self.height / 2, self.x + self.sp_size, self.y + self.height / 2 + self.sp_size + self.sp_space + pointsize: self.sp_size + Point: + # top + points: self.x + self.width / 2 - self.sp_size - self.sp_space, root._sp_top_y, self.x + self.width / 2, root._sp_top_y, self.x + self.width / 2 + self.sp_size + self.sp_space, root._sp_top_y + pointsize: self.sp_size
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/kv/xmlui.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,206 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:set common_height 30 +#:set button_height 50 + + +<EmptyWidget,StringWidget,PasswordWidget,JidInputWidget>: + size_hint: 1, None + height: dp(common_height) + + +<TextWidget,LabelWidget,JidWidget>: + size_hint: 1, 1 + size_hint_min_y: max(dp(common_height), self.texture_size[1]) + text_size: self.width, None + + +<StringWidget,PasswordWidget,IntWidget>: + multiline: False + background_normal: app.expand('atlas://data/images/defaulttheme/textinput') + + +<TextBoxWidget>: + multiline: True + height: dp(common_height) * 5 + + +<ButtonWidget>: + size_hint: 1, None + height: dp(button_height) + + +<BoolWidget>: + size_hint: 1, 1 + + +<DividerWidget>: + size_hint: 1, None + height: dp(12) + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Line + points: self.x, self.y + dp(5), self.x + self.width, self.y + dp(5) + width: dp(2) + + +<ListWidgetItem>: + size_hint_y: None + height: dp(button_height) + + +<ListWidget>: + size_hint: 1, None + layout: layout + height: min(layout.minimum_height, dp(250)) + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + BoxLayout: + id: layout + size_hint: 1, None + height: self.minimum_height + orientation: "vertical" + padding: dp(10) + + +<AdvancedListRow>: + orientation: "horizontal" + size_hint: 1, None + height: self.minimum_height + canvas.before: + Color: + rgba: app.c_prim_light if self.global_index%2 else app.c_prim_dark + Rectangle: + pos: self.pos + size: self.size + canvas.after: + Color: + rgba: 0, 0, 1, 0.5 if self.selected else 0 + Rectangle: + pos: self.pos + size: self.size + + +<AdvancedListContainer>: + size_hint: 1, None + height: self.minimum_height + orientation: "vertical" + + +<VerticalContainer>: + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height + +<PairsContainer>: + cols: 2 + size_hint: 1, None + height: self.minimum_height + padding: dp(10) + + +<TabsContainer>: + size_hint: 1, None + height: dp(200) + +<TabsPanelContainer>: + layout: layout + ScrollView: + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + canvas.before: + Color: + rgba: 1, 1, 1, 1 + Rectangle: + pos: self.pos + size: self.size + BoxLayout: + id: layout + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height + canvas.before: + Color: + rgba: 1, 1, 1, 1 + Rectangle: + pos: self.pos + size: self.size + + +<FormButton>: + size_hint: 1, None + height: dp(button_height) + color: 0, 0, 0, 1 + bold: True + + +<SubmitButton>: + text: _(u"Submit") + background_normal: '' + background_color: 0.33, 0.67, 0.0, 1 + + +<CancelButton>: + text: _(u"Cancel") + color: 1, 1, 1, 1 + bold: False + + +<SaveButton>: + text: _(u"Save") + background_normal: '' + background_color: 0.33, 0.67, 0.0, 1 + + +<FileDialog>: + orientation: "vertical" + message: message + file_chooser: file_chooser + Label: + id: message + size_hint: 1, None + text_size: root.width, None + size: self.texture_size + FileChooserListView: + id: file_chooser + Button: + size_hint: 1, None + height: dp(50) + text: "choose" + on_release: root.on_select(file_chooser.selection) + Button: + size_hint: 1, None + height: dp(50) + text: "cancel" + on_release: root.onCancel() + + +<XMLUIPanel>: + size_hint: 1, 1 + layout: layout + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + BoxLayout: + id: layout + orientation: "vertical" + size_hint: 1, None + padding: app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, 0 + height: self.minimum_height
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_gallery.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from libervia.backend.core.i18n import _ +import sys +import tempfile +import os +import os.path +if sys.platform=="android": + from jnius import autoclass + from android import activity, mActivity + + Intent = autoclass('android.content.Intent') + OpenableColumns = autoclass('android.provider.OpenableColumns') + PHOTO_GALLERY = 1 + RESULT_OK = -1 + + + +PLUGIN_INFO = { + "name": _("gallery"), + "main": "AndroidGallery", + "platforms": ('android',), + "external": True, + "description": _("upload a photo from photo gallery"), + "icon_medium": "{media}/icons/muchoslava/png/gallery_50.png", +} + + +class AndroidGallery: + + def __init__(self, callback, cancel_cb): + self.callback = callback + self.cancel_cb = cancel_cb + activity.bind(on_activity_result=self.on_activity_result) + intent = Intent() + intent.setType('image/*') + intent.setAction(Intent.ACTION_GET_CONTENT) + mActivity.startActivityForResult(intent, PHOTO_GALLERY); + + def on_activity_result(self, requestCode, resultCode, data): + activity.unbind(on_activity_result=self.on_activity_result) + # TODO: move file dump to a thread or use async callbacks during file writting + if requestCode == PHOTO_GALLERY and resultCode == RESULT_OK: + if data is None: + log.warning("No data found in activity result") + self.cancel_cb(self, None) + return + uri = data.getData() + + # we get filename in the way explained at https://developer.android.com/training/secure-file-sharing/retrieve-info.html + cursor = mActivity.getContentResolver().query(uri, None, None, None, None ) + name_idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + filename = cursor.getString(name_idx) + + # we save data in a temporary file that we send to callback + # the file will be removed once upload is done (or if an error happens) + input_stream = mActivity.getContentResolver().openInputStream(uri) + tmp_dir = tempfile.mkdtemp() + tmp_file = os.path.join(tmp_dir, filename) + def cleaning(): + os.unlink(tmp_file) + os.rmdir(tmp_dir) + log.debug('temporary file cleaned') + buff = bytearray(4096) + with open(tmp_file, 'wb') as f: + while True: + ret = input_stream.read(buff, 0, 4096) + if ret != -1: + f.write(buff) + else: + break + input_stream.close() + self.callback(tmp_file, cleaning) + else: + self.cancel_cb(self, None)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_photo.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from libervia.backend.core.i18n import _ +import sys +import os +import os.path +import time +if sys.platform == "android": + from plyer import camera + from jnius import autoclass + Environment = autoclass('android.os.Environment') +else: + import tempfile + + +PLUGIN_INFO = { + "name": _("take photo"), + "main": "AndroidPhoto", + "platforms": ('android',), + "external": True, + "description": _("upload a photo from photo application"), + "icon_medium": "{media}/icons/muchoslava/png/camera_off_50.png", +} + + +class AndroidPhoto(object): + + def __init__(self, callback, cancel_cb): + self.callback = callback + self.cancel_cb = cancel_cb + filename = time.strftime("%Y-%m-%d_%H:%M:%S.jpg", time.gmtime()) + tmp_dir = self.get_tmp_dir() + tmp_file = os.path.join(tmp_dir, filename) + log.debug("Picture will be saved to {}".format(tmp_file)) + camera.take_picture(tmp_file, self.callback) + # we don't delete the file, as it is nice to keep it locally + + def get_tmp_dir(self): + if sys.platform == "android": + dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + return dcim_path + else: + return tempfile.mkdtemp()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_android_video.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from libervia.backend.core.i18n import _ +import sys +import os +import os.path +import time +if sys.platform == "android": + from plyer import camera + from jnius import autoclass + Environment = autoclass('android.os.Environment') +else: + import tempfile + + +PLUGIN_INFO = { + "name": _("take video"), + "main": "AndroidVideo", + "platforms": ('android',), + "external": True, + "description": _("upload a video from video application"), + "icon_medium": "{media}/icons/muchoslava/png/film_camera_off_50.png", +} + + +class AndroidVideo(object): + + def __init__(self, callback, cancel_cb): + self.callback = callback + self.cancel_cb = cancel_cb + filename = time.strftime("%Y-%m-%d_%H:%M:%S.mpg", time.gmtime()) + tmp_dir = self.get_tmp_dir() + tmp_file = os.path.join(tmp_dir, filename) + log.debug("Video will be saved to {}".format(tmp_file)) + camera.take_video(tmp_file, self.callback) + # we don't delete the file, as it is nice to keep it locally + + def get_tmp_dir(self): + if sys.platform == "android": + dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + return dcim_path + else: + return tempfile.mkdtemp()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_file.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,35 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import expanduser os.path.expanduser +#:import platform kivy.utils.platform + + +<FileChooserBox>: + orientation: "vertical" + FileChooserListView: + id: filechooser + path: root.default_path + Button: + text: "choose" + size_hint: 1, None + height: dp(50) + on_release: root.callback(filechooser.selection) + Button: + text: "cancel" + size_hint: 1, None + height: dp(50) + on_release: root.cancel_cb()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_file.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import threading +import sys +from functools import partial +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from kivy.uix.boxlayout import BoxLayout +from kivy import properties +from kivy.clock import Clock +from plyer import filechooser, storagepath + +log = logging.getLogger(__name__) + + +PLUGIN_INFO = { + "name": _("file"), + "main": "FileTransmitter", + "description": _("transmit a local file"), + "icon_medium": "{media}/icons/muchoslava/png/fichier_50.png", +} + + +class FileChooserBox(BoxLayout): + callback = properties.ObjectProperty() + cancel_cb = properties.ObjectProperty() + default_path = properties.StringProperty() + + +class FileTransmitter(BoxLayout): + callback = properties.ObjectProperty() + cancel_cb = properties.ObjectProperty() + native_filechooser = True + default_path = storagepath.get_home_dir() + + def __init__(self, *args, **kwargs): + if sys.platform == 'android': + self.native_filechooser = False + self.default_path = storagepath.get_downloads_dir() + + super(FileTransmitter, self).__init__(*args, **kwargs) + + if self.native_filechooser: + thread = threading.Thread(target=self._native_file_chooser) + thread.start() + else: + self.add_widget(FileChooserBox(default_path = self.default_path, + callback=self.on_files, + cancel_cb=partial(self.cancel_cb, self))) + + def _native_file_chooser(self, *args, **kwargs): + title=_("Please select a file to upload") + files = filechooser.open_file(title=title, + path=self.default_path, + multiple=False, + preview=True) + # we want to leave the thread when calling on_files, so we use Clock + Clock.schedule_once(lambda *args: self.on_files(files=files), 0) + + def on_files(self, files): + if files: + self.callback(files[0]) + else: + self.cancel_cb(self)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_voice.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,72 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import _ libervia.backend.core.i18n._ +#:import IconButton libervia.desktop_kivy.core.common.IconButton + + +<VoiceRecorder>: + orientation: "vertical" + counter: counter + Label: + size_hint: 1, 0.4 + text_size: self.size + halign: 'center' + valign: 'top' + text: _(u"Push the microphone button to start the record, then push it again to stop it.\nWhen you are satisfied, click on the transmit button") + Label: + id: counter + size_hint: 1, None + height: dp(60) + bold: True + font_size: sp(40) + text_size: self.size + text: u"{}:{:02}".format(root.time//60, root.time%60) + halign: 'center' + valign: 'middle' + BoxLayout: + size_hint: 1, None + height: dp(60) + spacing: dp(5) + Widget + IconButton: + source: app.expand("{media}/icons/muchoslava/png/") + ("micro_on_50.png" if root.recording else "micro_off_50.png") + allow_stretch: True + size_hint: None, None + size: dp(60), dp(60) + on_release: root.switch_recording() + IconButton: + opacity: 0 if root.recording or not root.time and not root.playing else 1 + source: app.expand("{media}/icons/muchoslava/png/") + ("stop_50.png" if root.playing else "play_50.png") + allow_stretch: True + size_hint: None, None + size: dp(60), dp(60) + on_release: root.play_record() + Widget + Widget: + size_hint: 1, None + height: dp(50) + Button: + text: _("transmit") + size_hint: 1, None + height: dp(50) + on_release: root.callback(root.audio.file_path) + Button: + text: _("cancel") + size_hint: 1, None + height: dp(50) + on_release: root.cancel_cb(root) + Widget
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_transfer_voice.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from libervia.backend.core.i18n import _ +from kivy.uix.boxlayout import BoxLayout +import sys +import time +from kivy.clock import Clock +from kivy import properties +if sys.platform == "android": + from plyer import audio + + +PLUGIN_INFO = { + "name": _("voice"), + "main": "VoiceRecorder", + "platforms": ["android"], + "description": _("transmit a voice record"), + "icon_medium": "{media}/icons/muchoslava/png/micro_off_50.png", + "android_permissons": ["RECORD_AUDIO"], +} + + +class VoiceRecorder(BoxLayout): + callback = properties.ObjectProperty() + cancel_cb = properties.ObjectProperty() + recording = properties.BooleanProperty(False) + playing = properties.BooleanProperty(False) + time = properties.NumericProperty(0) + + def __init__(self, **kwargs): + super(VoiceRecorder, self).__init__(**kwargs) + self._started_at = None + self._counter_timer = None + self._play_timer = None + self.record_time = None + self.audio = audio + self.audio.file_path = "/sdcard/cagou_record.3gp" + + def _update_timer(self, dt): + self.time = int(time.time() - self._started_at) + + def switch_recording(self): + if self.playing: + self._stop_playing() + if self.recording: + try: + audio.stop() + except Exception as e: + # an exception can happen if record is pressed + # repeatedly in a short time (not a normal use) + log.warning("Exception on stop: {}".format(e)) + self._counter_timer.cancel() + self.time = self.time + 1 + else: + audio.start() + self._started_at = time.time() + self.time = 0 + self._counter_timer = Clock.schedule_interval(self._update_timer, 1) + + self.recording = not self.recording + + def _stop_playing(self, __=None): + if self.record_time is None: + log.error("_stop_playing should no be called when record_time is None") + return + audio.stop() + self.playing = False + self.time = self.record_time + if self._counter_timer is not None: + self._counter_timer.cancel() + + def play_record(self): + if self.recording: + return + if self.playing: + self._stop_playing() + else: + try: + audio.play() + except Exception as e: + # an exception can happen in the same situation + # as for audio.stop() above (i.e. bad record) + log.warning("Exception on play: {}".format(e)) + self.time = 0 + return + + self.playing = True + self.record_time = self.time + Clock.schedule_once(self._stop_playing, self.time + 1) + self._started_at = time.time() + self.time = 0 + self._counter_timer = Clock.schedule_interval(self._update_timer, 0.5)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_blog.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,168 @@ +# desktop/mobile frontend for Libervia XMPP client +# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import date_fmt libervia.backend.tools.common.date_utils.date_fmt + +<SearchButton>: + size_hint: None, 1 + symbol: "search" + width: dp(30) + font_size: dp(25) + color: 0.4, 0.4, 0.4, 1 + + +<NewPostButton>: + size_hint: None, 1 + symbol: "pencil" + width: dp(30) + font_size: dp(25) + color: 0.4, 0.4, 0.4, 1 + +<NewPosttMenu>: + padding: dp(20) + spacing: dp(10) + e2ee: e2ee_checkbox + Label: + size_hint: 1, None + color: 1, 1, 1, 1 + text: _("Publish a new post on {node} node of {service}").format(node=root.blog.node or "personal blog", service=root.blog.service or root.blog.profile) + text_size: root.width, None + size: self.texture_size + halign: "center" + bold: True + TextInput: + id: title + size_hint: 1, None + height: sp(30) + hint_text: _("title of your post (optional)") + TextInput: + id: content + size_hint: 1, None + height: sp(300) + hint_text: _("body of your post (markdown syntax allowed)") + BoxLayout + id: e2ee + size_hint: 1, None + padding_y: None + height: dp(25) + Widget: + CheckBox: + id: e2ee_checkbox + size_hint: None, 1 + width: dp(20) + active: False + color: 1, 1, 1, 1 + Label: + size_hint: None, None + text: _("encrypt post") + text_size: None, None + size: self.texture_size + padding_x: dp(10) + font_size: sp(15) + color: 1, 1, 1, 1 + Widget: + Button: + size_hint: 1, None + height: sp(50) + text: _("publish") + on_release: root.publish(title.text, content.text, e2ee=e2ee_checkbox.active) + Widget: + + +<BlogPostAvatar>: + size_hint: None, None + size: dp(30), dp(30) + canvas.before: + Color: + rgba: (0.87,0.87,0.87,1) + RoundedRectangle: + radius: [dp(5)] + pos: self.pos + size: self.size + +<BlogPostWidget>: + size_hint: 1, None + avatar: avatar + header_box: header_box + height: self.minimum_height + orientation: "vertical" + Label: + color: 0, 0, 0, 1 + bold: True + font_size: root.title_font_size + text_size: None, None + size_hint: None, None + size: self.texture_size[0], self.texture_size[1] if root.blog_data.get("title") else 0 + opacity: 1 if root.blog_data.get("title") else 0 + padding: dp(5), 0 + text: root.blog_data.get("title", "") + BoxLayout: + id: header_box + size_hint: 1, None + height: dp(40) + BoxLayout: + orientation: 'vertical' + width: avatar.width + size_hint: None, 1 + BlogPostAvatar: + id: avatar + source: app.default_avatar + Label: + id: created_ts + color: (0, 0, 0, 1) + font_size: root.font_size + text_size: None, None + size_hint: None, None + size: self.texture_size + padding: dp(5), 0 + markup: True + valign: 'middle' + text: f"published on [b]{date_fmt(root.blog_data.get('published', 0), 'auto_day')}[/b]" + Symbol: + size_hint: None, None + height: created_ts.height + width: self.height + id: encrypted + symbol: 'lock-filled' if root.blog_data.get("encrypted") else 'lock-open' + font_size: created_ts.height + opacity: 1 if root.blog_data.get("encrypted") else 0 + color: 0.29,0.87,0.0,1 + SimpleXHTMLWidget: + size_hint: 1, None + height: self.minimum_height + xhtml: root.blog_data.get("content_xhtml") or self.escape(root.blog_data.get("content", "")) + color: (0, 0, 0, 1) + padding: dp(5), dp(5) + + +<Blog>: + float_layout: float_layout + orientation: 'vertical' + posts_widget: posts_widget + FloatLayout: + id: float_layout + ScrollView: + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + BoxLayout: + id: posts_widget + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height + spacing: dp(10)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_blog.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +#desktop/mobile frontend for Libervia XMPP client +# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from functools import partial +import json +from typing import Any, Dict, Optional + +from kivy import properties +from kivy.metrics import sp +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.frontends.quick_frontend import quick_widgets +from libervia.frontends.tools import jid + +from libervia.desktop_kivy import G +from libervia.desktop_kivy.core.menu import SideMenu + +from ..core import cagou_widget +from ..core.common import SymbolButton +from ..core.constants import Const as C +from ..core.image import Image + +log = logging.getLogger(__name__) + +PLUGIN_INFO = { + "name": _("blog"), + "main": "Blog", + "description": _("(micro)blog"), + "icon_symbol": "pencil", +} + + +class SearchButton(SymbolButton): + blog = properties.ObjectProperty() + + def on_release(self, *args): + self.blog.header_input.dispatch('on_text_validate') + + +class NewPostButton(SymbolButton): + blog = properties.ObjectProperty() + + def on_release(self, *args): + self.blog.show_new_post_menu() + + +class NewPosttMenu(SideMenu): + blog = properties.ObjectProperty() + size_hint_close = (1, 0) + size_hint_open = (1, 0.9) + + def _publish_cb(self, item_id: str) -> None: + G.host.add_note( + _("blog post published"), + _("your blog post has been published with ID {item_id}").format( + item_id=item_id + ) + ) + self.blog.load_blog() + + def _publish_eb(self, exc: BridgeException) -> None: + G.host.add_note( + _("Problem while publish blog post"), + _("Can't publish blog post at {node!r} from {service}: {problem}").format( + node=self.blog.node or G.host.ns_map.get("microblog"), + service=( + self.blog.service if self.blog.service + else G.host.profiles[self.blog.profile].whoami, + ), + problem=exc + ), + C.XMLUI_DATA_LVL_ERROR + ) + + def publish( + self, + title: str, + content: str, + e2ee: bool = False + ) -> None: + self.hide() + mb_data: Dict[str, Any] = {"content_rich": content} + if e2ee: + mb_data["encrypted"] = True + title = title.strip() + if title: + mb_data["title_rich"] = title + G.host.bridge.mb_send( + self.blog.service, + self.blog.node, + data_format.serialise(mb_data), + self.blog.profile, + callback=self._publish_cb, + errback=self._publish_eb, + ) + + +class BlogPostAvatar(ButtonBehavior, Image): + pass + + +class BlogPostWidget(BoxLayout): + blog_data = properties.DictProperty() + font_size = properties.NumericProperty(sp(12)) + title_font_size = properties.NumericProperty(sp(14)) + + +class Blog(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget): + posts_widget = properties.ObjectProperty() + service = properties.StringProperty() + node = properties.StringProperty() + use_header_input = True + + def __init__(self, host, target, profiles): + quick_widgets.QuickWidget.__init__(self, G.host, target, profiles) + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + search_btn = SearchButton(blog=self) + self.header_input_add_extra(search_btn) + new_post_btn = NewPostButton(blog=self) + self.header_input_add_extra(new_post_btn) + self.load_blog() + + def on_kv_post(self, __): + self.bind( + service=lambda __, value: self.load_blog(), + node=lambda __, value: self.load_blog(), + ) + + def on_header_wid_input(self): + text = self.header_input.text.strip() + # for now we only use text as node + self.node = text + + def show_new_post_menu(self): + """Show the "add a contact" menu""" + NewPosttMenu(blog=self).show() + + def _mb_get_cb(self, blog_data_s: str) -> None: + blog_data = json.loads(blog_data_s) + for item in blog_data["items"]: + self.posts_widget.add_widget(BlogPostWidget(blog_data=item)) + + def _mb_get_eb( + self, + exc: BridgeException, + ) -> None: + G.host.add_note( + _("Problem while getting blog data"), + _("Can't get blog for {node!r} at {service}: {problem}").format( + node=self.node or G.host.ns_map.get("microblog"), + service=self.service if self.service else G.host.profiles[self.profile].whoami, + problem=exc + ), + C.XMLUI_DATA_LVL_ERROR + ) + + def load_blog( + self, + ) -> None: + """Retrieve a blog and display it""" + extra = {} + self.posts_widget.clear_widgets() + G.host.bridge.mb_get( + self.service, + self.node, + 20, + [], + data_format.serialise(extra), + self.profile, + callback=self._mb_get_cb, + errback=self._mb_get_eb, + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_chat.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,349 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import _ libervia.backend.core.i18n._ +#:import C libervia.desktop_kivy.core.constants.Const +#:import G libervia.desktop_kivy.G +#:import escape kivy.utils.escape_markup +#:import SimpleXHTMLWidget libervia.desktop_kivy.core.simple_xhtml.SimpleXHTMLWidget +#:import DelayedBoxLayout libervia.desktop_kivy.core.common_widgets.DelayedBoxLayout +#:import ScrollEffect kivy.effects.scroll.ScrollEffect +#:import CategorySeparator libervia.desktop_kivy.core.common_widgets.CategorySeparator + + +# Chat + + +<BaseAttachmentItem>: + size_hint: None, None + size: self.minimum_width, dp(50) + + +<AttachmentItem>: + canvas.before: + Color: + rgb: app.c_prim_dark + Line: + rounded_rectangle: self.x + dp(1), self.y + dp(1), self.width - dp(2), self.height - dp(2), 10 + Color: + rgb: app.c_sec_light + RoundedRectangle: + pos: self.x + dp(1), self.y + dp(1) + size: (self.width - dp(2)) * root.progress / 100, self.height - dp(2) + SymbolButtonLabel: + symbol: root.get_symbol(root.data) + color: 0, 0, 0, 1 + text: root.data.get('name', _('unnamed')) + bold: False + on_press: root.on_press() + + +<AttachmentImageItem>: + size: self.minimum_width, self.minimum_height + image: image + orientation: "vertical" + SizedImage: + id: image + anim_delay: -1 + source: "data/images/image-loading.gif" + + +<AttachmentImagesCollectionItem>: + cols: 2 + size_hint: None, None + size: dp(150), dp(150) + padding: dp(5) + spacing: dp(2) + canvas.before: + Color: + rgb: app.c_prim + RoundedRectangle: + radius: [dp(5)] + pos: self.pos + size: self.size + Color: + rgb: 0, 0, 0, 1 + Line: + rounded_rectangle: self.x, self.y, self.width, self.height, dp(5) + + +<AttachmentsLayout>: + attachments: self + size_hint: 1, None + height: self.minimum_height + spacing: dp(5) + + +<MessAvatar>: + size_hint: None, None + size: dp(30), dp(30) + canvas.before: + Color: + rgba: (0.87,0.87,0.87,1) + RoundedRectangle: + radius: [dp(5)] + pos: self.pos + size: self.size + + +<MessageWidget>: + size_hint: 1, None + avatar: avatar + delivery: delivery + mess_xhtml: mess_xhtml + right_part: right_part + header_box: header_box + height: self.minimum_height + BoxLayout: + orientation: 'vertical' + width: avatar.width + size_hint: None, 1 + MessAvatar: + id: avatar + source: root.mess_data.avatar['path'] if root.mess_data and root.mess_data.avatar else app.default_avatar + on_press: root.chat.add_nick(root.nick) + Widget: + # use to push the avatar on the top + size_hint: 1, 1 + BoxLayout: + size_hint: 1, None + orientation: 'vertical' + id: right_part + height: self.minimum_height + BoxLayout: + id: header_box + size_hint: 1, None + height: time_label.height if root.mess_type != C.MESS_TYPE_INFO else 0 + opacity: 1 if root.mess_type != C.MESS_TYPE_INFO else 0 + Label: + id: time_label + color: (0, 0, 0, 1) if root.own_mess else (0.55,0.55,0.55,1) + font_size: root.font_size + text_size: None, None + size_hint: None, None + size: self.texture_size + padding: dp(5), 0 + markup: True + valign: 'middle' + text: u"[b]{}[/b], {}".format(escape(root.nick), root.time_text) + Symbol: + size_hint_x: None + width: self.height + id: encrypted + symbol: 'lock-filled' if root.mess_data.encrypted else 'lock-open' + font_size: self.height - dp(3) + color: (1, 0, 0, 1) if not root.mess_data.encrypted and root.chat.encrypted else (0.55,0.55,0.55,1) + Label: + id: delivery + color: C.COLOR_BTN_LIGHT + font_size: root.font_size + text_size: None, None + size_hint: None, None + size: self.texture_size + padding: dp(5), 0 + # XXX: DejaVuSans font is needed as check mark is not in Roboto + # this can be removed when Kivy will be able to handle fallback mechanism + # which will allow us to use fonts with more unicode characters + font_name: "DejaVuSans" + text: u'' + SimpleXHTMLWidget: + id: mess_xhtml + size_hint: 1, None + height: self.minimum_height + xhtml: root.message_xhtml or self.escape(root.message or '') + color: (0.74,0.74,0.24,1) if root.mess_type == "info" else (0, 0, 0, 1) + padding: root.mess_padding + bold: True if root.mess_type == "info" else False + + +<AttachmentToSendItem>: + SymbolButton: + opacity: 0 if root.sending else 1 + size_hint: None, 1 + symbol: "cancel-circled" + on_press: root.parent.remove_widget(root) + + +<AttachmentsToSend>: + attachments: attachments_layout.attachments + reduce_checkbox: reduce_checkbox + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height if self.attachments.children else 0 + opacity: 1 if self.attachments.children else 0 + padding: [app.MARGIN_LEFT, dp(5), app.MARGIN_RIGHT, dp(5)] + canvas.before: + Color: + rgba: app.c_prim + Rectangle: + pos: self.pos + size: self.size + Label: + size_hint: 1, None + size: self.texture_size + text: _("attachments:") + bold: True + AttachmentsLayout: + id: attachments_layout + BoxLayout: + id: resize_box + size_hint: 1, None + opacity: 1 if root.show_resize else 0 + height: dp(25) if root.show_resize else 0 + Widget: + CheckBox: + id: reduce_checkbox + size_hint: None, 1 + width: dp(20) + active: True + Label: + size_hint: None, 1 + text: _("reduce images size") + text_size: None, None + size: self.texture_size + valign: "middle" + padding_x: dp(10) + font_size: sp(15) + Widget: + +<Chat>: + attachments_to_send: attachments_to_send + message_input: message_input + messages_widget: messages_widget + history_scroll: history_scroll + send_button_visible: G.local_platform.send_button_visible or bool(attachments_to_send.attachments.children) + ScrollView: + id: history_scroll + scroll_y: 0 + on_scroll_y: root.on_scroll(*args) + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(10) + effect_cls: ScrollEffect + DelayedBoxLayout: + id: messages_widget + size_hint_y: None + padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)] + spacing: dp(10) + height: self.minimum_height + orientation: 'vertical' + AttachmentsToSend: + id: attachments_to_send + MessageInputBox: + size_hint: 1, None + height: self.minimum_height + spacing: dp(10) + padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)] + message_input: message_input + MessageInputWidget: + id: message_input + size_hint: 1, None + height: min(self.minimum_height, dp(250)) + multiline: True + hint_text: _(u"Enter your message here") + on_text_validate: root.on_send(args[0]) + SymbolButton: + # "send" button, permanent visibility depends on platform + symbol: "forward" + size_hint: None, 1 + width: dp(30) if root.send_button_visible else 0 + opacity: 1 if root.send_button_visible else 0 + font_size: dp(25) + on_release: self.parent.send_text() + + +# Buttons added in header + +<TransferButton>: + size_hint: None, 1 + symbol: "plus-circled" + width: dp(30) + font_size: dp(25) + color: 0.4, 0.4, 0.4, 1 + +<MenuButton@Button> + size_hint_y: None + height: dp(30) + on_texture_size: self.parent.parent.width = max(self.parent.parent.width, self.texture_size[0] + dp(10)) + +<ExtraMenu>: + auto_width: False + MenuButton: + text: _("bookmarks") + on_release: root.select("bookmark") + MenuButton: + text: _("close") + on_release: root.select("close") + +<ExtraButton>: + size_hint: None, 1 + symbol: "dot-3-vert" + width: dp(30) + font_size: dp(25) + color: 0.4, 0.4, 0.4, 1 + on_release: self.chat.extra_menu.open(self) + +<EncryptionMainButton>: + size_hint: None, 1 + width: dp(30) + color: self.get_color() + symbol: self.get_symbol() + +<TrustManagementButton>: + symbol: "shield" + padding: dp(5), dp(10) + bg_color: app.c_prim_dark + size_hint: None, 1 + width: dp(30) + on_release: self.parent.dispatch("on_trust_release") + +<EncryptionButton>: + size_hint: None, None + width: self.parent.parent.best_width if self.parent is not None else 30 + height: dp(30) + on_best_width: self.parent.parent.best_width = max(self.parent.parent.best_width, args[1]) + Button: + text: root.text + size_hint: 1, 1 + padding: dp(5), dp(10) + color: 0, 0, 0, 1 + bold: root.bold + background_normal: app.expand('{media}/misc/borders/border_filled_black.png') + background_color: app.c_sec if root.selected else app.c_prim_dark + on_release: root.dispatch("on_release") + on_texture_size: root.best_width = self.texture_size[0] + (dp(30) if root.trust_button else 0) + +<EncryptionMenu>: + size_hint_x: None + width: self.container.minimum_width + auto_width: False + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + + +# Chat Selector + +<ChatSelector>: + jid_selector: jid_selector + JidSelector: + id: jid_selector + on_select: root.on_select(args[1]) + to_show: ["opened_chats", "roster", "bookmarks"] +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_chat.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,1280 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from functools import partial +from pathlib import Path +import sys +import uuid +import mimetypes +from urllib.parse import urlparse +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.screenmanager import Screen, NoTransition +from kivy.uix.textinput import TextInput +from kivy.uix.label import Label +from kivy.uix import screenmanager +from kivy.uix.behaviors import ButtonBehavior +from kivy.metrics import sp, dp +from kivy.clock import Clock +from kivy import properties +from kivy.uix.stacklayout import StackLayout +from kivy.uix.dropdown import DropDown +from kivy.core.window import Window +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.backend.tools.common import data_format +from libervia.frontends.quick_frontend import quick_widgets +from libervia.frontends.quick_frontend import quick_chat +from libervia.frontends.tools import jid +from libervia.desktop_kivy import G +from ..core.constants import Const as C +from ..core import cagou_widget +from ..core import xmlui +from ..core.image import Image, AsyncImage +from ..core.common import Symbol, SymbolButton, JidButton, ContactButton +from ..core.behaviors import FilterBehavior +from ..core import menu +from ..core.common_widgets import ImagesGallery + +log = logging.getLogger(__name__) + +PLUGIN_INFO = { + "name": _("chat"), + "main": "Chat", + "description": _("instant messaging with one person or a group"), + "icon_symbol": "chat", +} + +# FIXME: OTR specific code is legacy, and only used nowadays for lock color +# we can probably get rid of them. +OTR_STATE_UNTRUSTED = 'untrusted' +OTR_STATE_TRUSTED = 'trusted' +OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED) +OTR_STATE_UNENCRYPTED = 'unencrypted' +OTR_STATE_ENCRYPTED = 'encrypted' +OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED) + +SYMBOL_UNENCRYPTED = 'lock-open' +SYMBOL_ENCRYPTED = 'lock' +SYMBOL_ENCRYPTED_TRUSTED = 'lock-filled' +COLOR_UNENCRYPTED = (0.4, 0.4, 0.4, 1) +COLOR_ENCRYPTED = (0.4, 0.4, 0.4, 1) +COLOR_ENCRYPTED_TRUSTED = (0.29,0.87,0.0,1) + +# below this limit, new messages will be prepended +INFINITE_SCROLL_LIMIT = dp(600) + +# File sending progress +PROGRESS_UPDATE = 0.2 # number of seconds before next progress update + + +# FIXME: a ScrollLayout was supposed to be used here, but due +# to https://github.com/kivy/kivy/issues/6745, a StackLayout is used for now +class AttachmentsLayout(StackLayout): + """Layout for attachments in a received message""" + padding = properties.VariableListProperty([dp(5), dp(5), 0, dp(5)]) + attachments = properties.ObjectProperty() + + +class AttachmentsToSend(BoxLayout): + """Layout for attachments to be sent with current message""" + attachments = properties.ObjectProperty() + reduce_checkbox = properties.ObjectProperty() + show_resize = properties.BooleanProperty(False) + + def on_kv_post(self, __): + self.attachments.bind(children=self.on_attachment) + + def on_attachment(self, __, attachments): + if len(attachments) == 0: + self.show_resize = False + + +class BaseAttachmentItem(BoxLayout): + data = properties.DictProperty() + progress = properties.NumericProperty(0) + + +class AttachmentItem(BaseAttachmentItem): + + def get_symbol(self, data): + media_type = data.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') + main_type = media_type.split('/', 1)[0] + if main_type == 'image': + return "file-image" + elif main_type == 'video': + return "file-video" + elif main_type == 'audio': + return "file-audio" + else: + return "doc" + + def on_press(self): + url = self.data.get('url') + if url: + G.local_platform.open_url(url, self) + else: + log.warning(f"can't find URL in {self.data}") + + +class AttachmentImageItem(ButtonBehavior, BaseAttachmentItem): + image = properties.ObjectProperty() + + def on_press(self): + full_size_source = self.data.get('path', self.data.get('url')) + gallery = ImagesGallery(sources=[full_size_source]) + G.host.show_extra_ui(gallery) + + def on_kv_post(self, __): + self.on_data(None, self.data) + + def on_data(self, __, data): + if self.image is None: + return + source = data.get('preview') or data.get('path') or data.get('url') + if source: + self.image.source = source + + +class AttachmentImagesCollectionItem(ButtonBehavior, GridLayout): + attachments = properties.ListProperty([]) + chat = properties.ObjectProperty() + mess_data = properties.ObjectProperty() + + def _set_preview(self, attachment, wid, preview_path): + attachment['preview'] = preview_path + wid.source = preview_path + + def _set_path(self, attachment, wid, path): + attachment['path'] = path + if wid is not None: + # we also need a preview for the widget + if 'preview' in attachment: + wid.source = attachment['preview'] + else: + G.host.bridge.image_generate_preview( + path, + self.chat.profile, + callback=partial(self._set_preview, attachment, wid), + ) + + def on_kv_post(self, __): + attachments = self.attachments + self.clear_widgets() + for idx, attachment in enumerate(attachments): + try: + url = attachment['url'] + except KeyError: + url = None + to_download = False + else: + if url.startswith("aesgcm:"): + del attachment['url'] + # if the file is encrypted, we need to download it for decryption + to_download = True + else: + to_download = False + + if idx < 3 or len(attachments) <= 4: + if ((self.mess_data.own_mess + or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): + wid = AsyncImage(size_hint=(1, 1), source="data/images/image-loading.gif") + if 'preview' in attachment: + wid.source = attachment["preview"] + elif 'path' in attachment: + G.host.bridge.image_generate_preview( + attachment['path'], + self.chat.profile, + callback=partial(self._set_preview, attachment, wid), + ) + elif url is None: + log.warning(f"Can't find any source for {attachment}") + else: + # we'll download the file, the preview will then be generated + to_download = True + else: + # we don't download automatically the image if the contact is not + # in roster, to avoid leaking the ip + wid = Symbol(symbol="file-image") + self.add_widget(wid) + else: + wid = None + + if to_download: + # the file needs to be downloaded, the widget source, + # attachment path, and preview will then be completed + G.host.download_url( + url, + callback=partial(self._set_path, attachment, wid), + dest=C.FILE_DEST_CACHE, + profile=self.chat.profile, + ) + + if len(attachments) > 4: + counter = Label( + bold=True, + text=f"+{len(attachments) - 3}", + ) + self.add_widget(counter) + + def on_press(self): + sources = [] + for attachment in self.attachments: + source = attachment.get('path') or attachment.get('url') + if not source: + log.warning(f"no source for {attachment}") + else: + sources.append(source) + gallery = ImagesGallery(sources=sources) + G.host.show_extra_ui(gallery) + + +class AttachmentToSendItem(AttachmentItem): + # True when the item is being sent + sending = properties.BooleanProperty(False) + + +class MessAvatar(ButtonBehavior, Image): + pass + + +class MessageWidget(quick_chat.MessageWidget, BoxLayout): + mess_data = properties.ObjectProperty() + mess_xhtml = properties.ObjectProperty() + mess_padding = (dp(5), dp(5)) + avatar = properties.ObjectProperty() + delivery = properties.ObjectProperty() + font_size = properties.NumericProperty(sp(12)) + right_part = properties.ObjectProperty() + header_box = properties.ObjectProperty() + + def on_kv_post(self, __): + if not self.mess_data: + raise exceptions.InternalError( + "mess_data must always be set in MessageWidget") + + self.mess_data.widgets.add(self) + self.add_attachments() + + @property + def chat(self): + """return parent Chat instance""" + return self.mess_data.parent + + def _get_from_mess_data(self, name, default): + if self.mess_data is None: + return default + return getattr(self.mess_data, name) + + def _get_message(self): + """Return currently displayed message""" + if self.mess_data is None: + return "" + return self.mess_data.main_message + + def _set_message(self, message): + if self.mess_data is None: + return False + if message == self.mess_data.message.get(""): + return False + self.mess_data.message = {"": message} + return True + + message = properties.AliasProperty( + partial(_get_from_mess_data, name="main_message", default=""), + _set_message, + bind=['mess_data'], + ) + message_xhtml = properties.AliasProperty( + partial(_get_from_mess_data, name="main_message_xhtml", default=""), + bind=['mess_data']) + mess_type = properties.AliasProperty( + partial(_get_from_mess_data, name="type", default=""), bind=['mess_data']) + own_mess = properties.AliasProperty( + partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data']) + nick = properties.AliasProperty( + partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data']) + time_text = properties.AliasProperty( + partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data']) + + @property + def info_type(self): + return self.mess_data.info_type + + def update(self, update_dict): + if 'avatar' in update_dict: + avatar_data = update_dict['avatar'] + if avatar_data is None: + source = G.host.get_default_avatar() + else: + source = avatar_data['path'] + self.avatar.source = source + if 'status' in update_dict: + status = update_dict['status'] + self.delivery.text = '\u2714' if status == 'delivered' else '' + + def _set_path(self, data, path): + """Set path of decrypted file to an item""" + data['path'] = path + + def add_attachments(self): + """Add attachments layout + attachments item""" + attachments = self.mess_data.attachments + if not attachments: + return + root_layout = AttachmentsLayout() + self.right_part.add_widget(root_layout) + layout = root_layout.attachments + + image_attachments = [] + other_attachments = [] + # we first separate images and other attachments, so we know if we need + # to use an image collection + for attachment in attachments: + media_type = attachment.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') + main_type = media_type.split('/', 1)[0] + # GIF images are really badly handled by Kivy, the memory + # consumption explode, and the images frequencies are not handled + # correctly, thus we can't display them and we consider them as + # other attachment, so user can open the item with appropriate + # software. + if main_type == 'image' and media_type != "image/gif": + image_attachments.append(attachment) + else: + other_attachments.append(attachment) + + if len(image_attachments) > 1: + collection = AttachmentImagesCollectionItem( + attachments=image_attachments, + chat=self.chat, + mess_data=self.mess_data, + ) + layout.add_widget(collection) + elif image_attachments: + attachment = image_attachments[0] + # to avoid leaking IP address, we only display image if the contact is in + # roster + if ((self.mess_data.own_mess + or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): + try: + url = urlparse(attachment['url']) + except KeyError: + item = AttachmentImageItem(data=attachment) + else: + if url.scheme == "aesgcm": + # we remove the URL now, we'll replace it by + # the local decrypted version + del attachment['url'] + item = AttachmentImageItem(data=attachment) + G.host.download_url( + url.geturl(), + callback=partial(self._set_path, item.data), + dest=C.FILE_DEST_CACHE, + profile=self.chat.profile, + ) + else: + item = AttachmentImageItem(data=attachment) + else: + item = AttachmentItem(data=attachment) + + layout.add_widget(item) + + for attachment in other_attachments: + item = AttachmentItem(data=attachment) + layout.add_widget(item) + + +class MessageInputBox(BoxLayout): + message_input = properties.ObjectProperty() + + def send_text(self): + self.message_input.send_text() + + +class MessageInputWidget(TextInput): + + def keyboard_on_key_down(self, window, keycode, text, modifiers): + # We don't send text when shift is pressed to be able to add line feeds + # (i.e. multi-lines messages). We don't send on Android either as the + # send button appears on this platform. + if (keycode[-1] == "enter" + and "shift" not in modifiers + and sys.platform != 'android'): + self.send_text() + else: + return super(MessageInputWidget, self).keyboard_on_key_down( + window, keycode, text, modifiers) + + def send_text(self): + self.dispatch('on_text_validate') + + +class TransferButton(SymbolButton): + chat = properties.ObjectProperty() + + def on_release(self, *args): + menu.TransferMenu( + encrypted=self.chat.encrypted, + callback=self.chat.transfer_file, + ).show(self) + + +class ExtraMenu(DropDown): + chat = properties.ObjectProperty() + + def on_select(self, menu): + if menu == 'bookmark': + G.host.bridge.menu_launch(C.MENU_GLOBAL, ("groups", "bookmarks"), + {}, C.NO_SECURITY_LIMIT, self.chat.profile, + callback=partial( + G.host.action_manager, profile=self.chat.profile), + errback=G.host.errback) + elif menu == 'close': + if self.chat.type == C.CHAT_GROUP: + # for MUC, we have to indicate the backend that we've left + G.host.bridge.muc_leave(self.chat.target, self.chat.profile) + else: + # for one2one, backend doesn't keep any state, so we just delete the + # widget here in the frontend + G.host.widgets.delete_widget( + self.chat, all_instances=True, explicit_close=True) + else: + raise exceptions.InternalError("Unknown menu: {}".format(menu)) + + +class ExtraButton(SymbolButton): + chat = properties.ObjectProperty() + + +class EncryptionMainButton(SymbolButton): + + def __init__(self, chat, **kwargs): + """ + @param chat(Chat): Chat instance + """ + self.chat = chat + self.encryption_menu = EncryptionMenu(chat) + super(EncryptionMainButton, self).__init__(**kwargs) + self.bind(on_release=self.encryption_menu.open) + + def select_algo(self, name): + """Mark an encryption algorithm as selected. + + This will also deselect all other button + @param name(unicode, None): encryption plugin name + None for plain text + """ + buttons = self.encryption_menu.container.children + buttons[-1].selected = name is None + for button in buttons[:-1]: + button.selected = button.text == name + + def get_color(self): + if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: + return (0.4, 0.4, 0.4, 1) + elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: + return (0.29,0.87,0.0,1) + else: + return (0.4, 0.4, 0.4, 1) + + def get_symbol(self): + if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: + return 'lock-open' + elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: + return 'lock-filled' + else: + return 'lock' + + +class TrustManagementButton(SymbolButton): + pass + + +class EncryptionButton(BoxLayout): + selected = properties.BooleanProperty(False) + text = properties.StringProperty() + trust_button = properties.BooleanProperty(False) + best_width = properties.NumericProperty(0) + bold = properties.BooleanProperty(True) + + def __init__(self, **kwargs): + super(EncryptionButton, self).__init__(**kwargs) + self.register_event_type('on_release') + self.register_event_type('on_trust_release') + if self.trust_button: + self.add_widget(TrustManagementButton()) + + def on_release(self): + pass + + def on_trust_release(self): + pass + + +class EncryptionMenu(DropDown): + # best with to display all algorithms buttons + trust buttons + best_width = properties.NumericProperty(0) + + def __init__(self, chat, **kwargs): + """ + @param chat(Chat): Chat instance + """ + self.chat = chat + super(EncryptionMenu, self).__init__(**kwargs) + btn = EncryptionButton( + text=_("unencrypted (plain text)"), + on_release=self.unencrypted, + selected=True, + bold=False, + ) + btn.bind( + on_release=self.unencrypted, + ) + self.add_widget(btn) + for plugin in G.host.encryption_plugins: + if chat.type == C.CHAT_GROUP and plugin["directed"]: + # directed plugins can't work with group chat + continue + btn = EncryptionButton( + text=plugin['name'], + trust_button=True, + ) + btn.bind( + on_release=partial(self.start_encryption, plugin=plugin), + on_trust_release=partial(self.get_trust_ui, plugin=plugin), + ) + self.add_widget(btn) + log.info("added encryption: {}".format(plugin['name'])) + + def message_encryption_stop_cb(self): + log.info(_("Session with {destinee} is now in plain text").format( + destinee = self.chat.target)) + + def message_encryption_stop_eb(self, failure_): + msg = _("Error while stopping encryption with {destinee}: {reason}").format( + destinee = self.chat.target, + reason = failure_) + log.warning(msg) + G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) + + def unencrypted(self, button): + self.dismiss() + G.host.bridge.message_encryption_stop( + str(self.chat.target), + self.chat.profile, + callback=self.message_encryption_stop_cb, + errback=self.message_encryption_stop_eb) + + def message_encryption_start_cb(self, plugin): + log.info(_("Session with {destinee} is now encrypted with {encr_name}").format( + destinee = self.chat.target, + encr_name = plugin['name'])) + + def message_encryption_start_eb(self, failure_): + msg = _("Session can't be encrypted with {destinee}: {reason}").format( + destinee = self.chat.target, + reason = failure_) + log.warning(msg) + G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) + + def start_encryption(self, button, plugin): + """Request encryption with given plugin for this session + + @param button(EncryptionButton): button which has been pressed + @param plugin(dict): plugin data + """ + self.dismiss() + G.host.bridge.message_encryption_start( + str(self.chat.target), + plugin['namespace'], + True, + self.chat.profile, + callback=partial(self.message_encryption_start_cb, plugin=plugin), + errback=self.message_encryption_start_eb) + + def encryption_trust_ui_get_cb(self, xmlui_raw): + xml_ui = xmlui.create( + G.host, xmlui_raw, profile=self.chat.profile) + xml_ui.show() + + def encryption_trust_ui_get_eb(self, failure_): + msg = _("Trust manager interface can't be retrieved: {reason}").format( + reason = failure_) + log.warning(msg) + G.host.add_note(_("encryption trust management problem"), msg, + C.XMLUI_DATA_LVL_ERROR) + + def get_trust_ui(self, button, plugin): + """Request and display trust management UI + + @param button(EncryptionButton): button which has been pressed + @param plugin(dict): plugin data + """ + self.dismiss() + G.host.bridge.encryption_trust_ui_get( + str(self.chat.target), + plugin['namespace'], + self.chat.profile, + callback=self.encryption_trust_ui_get_cb, + errback=self.encryption_trust_ui_get_eb) + + +class Chat(quick_chat.QuickChat, cagou_widget.LiberviaDesktopKivyWidget): + message_input = properties.ObjectProperty() + messages_widget = properties.ObjectProperty() + history_scroll = properties.ObjectProperty() + attachments_to_send = properties.ObjectProperty() + send_button_visible = properties.BooleanProperty() + use_header_input = True + global_screen_manager = True + collection_carousel = True + + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, + subject=None, statuses=None, profiles=None): + self.show_chat_selector = False + if statuses is None: + statuses = {} + quick_chat.QuickChat.__init__( + self, host, target, type_, nick, occupants, subject, statuses, + profiles=profiles) + self.otr_state_encryption = OTR_STATE_UNENCRYPTED + self.otr_state_trust = OTR_STATE_UNTRUSTED + # completion attributes + self._hi_comp_data = None + self._hi_comp_last = None + self._hi_comp_dropdown = DropDown() + self._hi_comp_allowed = True + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + transfer_btn = TransferButton(chat=self) + self.header_input_add_extra(transfer_btn) + if (type_ == C.CHAT_ONE2ONE or "REALJID_PUBLIC" in statuses): + self.encryption_btn = EncryptionMainButton(self) + self.header_input_add_extra(self.encryption_btn) + self.extra_menu = ExtraMenu(chat=self) + extra_btn = ExtraButton(chat=self) + self.header_input_add_extra(extra_btn) + self.header_input.hint_text = target + self._history_prepend_lock = False + self.history_count = 0 + + def on_kv_post(self, __): + self.post_init() + + def screen_manager_init(self, screen_manager): + screen_manager.transition = screenmanager.SlideTransition(direction='down') + sel_screen = Screen(name='chat_selector') + chat_selector = ChatSelector(profile=self.profile) + sel_screen.add_widget(chat_selector) + screen_manager.add_widget(sel_screen) + if self.show_chat_selector: + transition = screen_manager.transition + screen_manager.transition = NoTransition() + screen_manager.current = 'chat_selector' + screen_manager.transition = transition + + def __str__(self): + return "Chat({})".format(self.target) + + def __repr__(self): + return self.__str__() + + @classmethod + def factory(cls, plugin_info, target, profiles): + profiles = list(profiles) + if len(profiles) > 1: + raise NotImplementedError("Multi-profiles is not available yet for chat") + if target is None: + show_chat_selector = True + target = G.host.profiles[profiles[0]].whoami + else: + show_chat_selector = False + wid = G.host.widgets.get_or_create_widget(cls, target, on_new_widget=None, + on_existing_widget=G.host.get_or_clone, + profiles=profiles) + wid.show_chat_selector = show_chat_selector + return wid + + @property + def message_widgets_rev(self): + return self.messages_widget.children + + ## keyboard ## + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + screen_manager = self.screen_manager + screen_manager.transition.direction = 'down' + screen_manager.current = 'chat_selector' + return True + + ## drop ## + + def on_drop_file(self, path): + self.add_attachment(path) + + ## header ## + + def change_widget(self, jid_): + """change current widget for a new one with given jid + + @param jid_(jid.JID): jid of the widget to create + """ + plugin_info = G.host.get_plugin_info(main=Chat) + factory = plugin_info['factory'] + G.host.switch_widget(self, factory(plugin_info, jid_, profiles=[self.profile])) + self.header_input.text = '' + + def on_header_wid_input(self): + text = self.header_input.text.strip() + try: + if text.count('@') != 1 or text.count(' '): + raise ValueError + jid_ = jid.JID(text) + except ValueError: + log.info("entered text is not a jid") + return + + def disco_cb(disco): + # TODO: check if plugin XEP-0045 is activated + if "conference" in [i[0] for i in disco[1]]: + G.host.bridge.muc_join(str(jid_), "", "", self.profile, + callback=self._muc_join_cb, errback=self._muc_join_eb) + else: + self.change_widget(jid_) + + def disco_eb(failure): + log.warning("Disco failure, ignore this text: {}".format(failure)) + + G.host.bridge.disco_infos( + jid_.domain, + profile_key=self.profile, + callback=disco_cb, + errback=disco_eb) + + def on_header_wid_input_completed(self, input_wid, completed_text): + self._hi_comp_allowed = False + input_wid.text = completed_text + self._hi_comp_allowed = True + self._hi_comp_dropdown.dismiss() + self.on_header_wid_input() + + def on_header_wid_input_complete(self, wid, text): + if not self._hi_comp_allowed: + return + text = text.lstrip() + if not text: + self._hi_comp_data = None + self._hi_comp_last = None + self._hi_comp_dropdown.dismiss() + return + + profile = list(self.profiles)[0] + + if self._hi_comp_data is None: + # first completion, we build the initial list + comp_data = self._hi_comp_data = [] + self._hi_comp_last = '' + for jid_, jid_data in G.host.contact_lists[profile].all_iter: + comp_data.append((jid_, jid_data)) + comp_data.sort(key=lambda datum: datum[0]) + else: + comp_data = self._hi_comp_data + + # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed, + # it works OK, but some optimisation may be done here + dropdown = self._hi_comp_dropdown + + if not text.startswith(self._hi_comp_last) or not self._hi_comp_last: + # text has changed or backspace has been pressed, we restart + dropdown.clear_widgets() + + for jid_, jid_data in comp_data: + nick = jid_data.get('nick', '') + if text in jid_.bare or text in nick.lower(): + btn = JidButton( + jid = jid_.bare, + profile = profile, + size_hint = (0.5, None), + nick = nick, + on_release=lambda __, txt=jid_.bare: self.on_header_wid_input_completed(wid, txt) + ) + dropdown.add_widget(btn) + else: + # more chars, we continue completion by removing unwanted widgets + to_remove = [] + for c in dropdown.children[0].children: + if text not in c.jid and text not in (c.nick or ''): + to_remove.append(c) + for c in to_remove: + dropdown.remove_widget(c) + if dropdown.attach_to is None: + dropdown.open(wid) + self._hi_comp_last = text + + def message_data_converter(self, idx, mess_id): + return {"mess_data": self.messages[mess_id]} + + def _on_history_printed(self): + """Refresh or scroll down the focus after the history is printed""" + # self.adapter.data = self.messages + for mess_data in self.messages.values(): + self.appendMessage(mess_data) + super(Chat, self)._on_history_printed() + + def create_message(self, message): + self.appendMessage(message) + # we need to render immediatly next 2 layouts to avoid an unpleasant flickering + # when sending or receiving a message + self.messages_widget.dont_delay_next_layouts = 2 + + def appendMessage(self, mess_data): + """Append a message Widget to the history + + @param mess_data(quick_chat.Message): message data + """ + if self.handle_user_moved(mess_data): + return + self.messages_widget.add_widget(MessageWidget(mess_data=mess_data)) + self.notify(mess_data) + + def prepend_message(self, mess_data): + """Prepend a message Widget to the history + + @param mess_data(quick_chat.Message): message data + """ + mess_wid = self.messages_widget + last_idx = len(mess_wid.children) + mess_wid.add_widget(MessageWidget(mess_data=mess_data), index=last_idx) + + def _get_notif_msg(self, mess_data): + return _("{nick}: {message}").format( + nick=mess_data.nick, + message=mess_data.main_message) + + def notify(self, mess_data): + """Notify user when suitable + + For one2one chat, notification will happen when window has not focus + or when one2one chat is not visible. A note is also there when widget + is not visible. + For group chat, note will be added on mention, with a desktop notification if + window has not focus or is not visible. + """ + visible_clones = [w for w in G.host.get_visible_list(self.__class__) + if w.target == self.target] + if len(visible_clones) > 1 and visible_clones.index(self) > 0: + # to avoid multiple notifications in case of multiple cloned widgets + # we only handle first clone + return + is_visible = bool(visible_clones) + if self.type == C.CHAT_ONE2ONE: + if (not Window.focus or not is_visible) and not mess_data.history: + notif_msg = self._get_notif_msg(mess_data) + G.host.notify( + type_=C.NOTIFY_MESSAGE, + entity=mess_data.from_jid, + message=notif_msg, + subject=_("private message"), + widget=self, + profile=self.profile + ) + if not is_visible: + G.host.add_note( + _("private message"), + notif_msg, + symbol = "chat", + action = { + "action": 'chat', + "target": self.target, + "profiles": self.profiles} + ) + else: + if mess_data.mention: + notif_msg = self._get_notif_msg(mess_data) + G.host.add_note( + _("mention"), + notif_msg, + symbol = "chat", + action = { + "action": 'chat', + "target": self.target, + "profiles": self.profiles} + ) + if not is_visible or not Window.focus: + subject=_("mention ({room_jid})").format(room_jid=self.target) + G.host.notify( + type_=C.NOTIFY_MENTION, + entity=self.target, + message=notif_msg, + subject=subject, + widget=self, + profile=self.profile + ) + + # message input + + def _attachment_progress_cb(self, item, metadata, profile): + item.parent.remove_widget(item) + log.info(f"item {item.data.get('path')} uploaded successfully") + + def _attachment_progress_eb(self, item, err_msg, profile): + item.parent.remove_widget(item) + path = item.data.get('path') + msg = _("item {path} could not be uploaded: {err_msg}").format( + path=path, err_msg=err_msg) + G.host.add_note(_("can't upload file"), msg, C.XMLUI_DATA_LVL_WARNING) + log.warning(msg) + + def _progress_get_cb(self, item, metadata): + try: + position = int(metadata["position"]) + size = int(metadata["size"]) + except KeyError: + # we got empty metadata, the progression is either not yet started or + # finished + if item.progress: + # if progress is already started, receiving empty metadata means + # that progression is finished + item.progress = 100 + return + else: + item.progress = position/size*100 + + if item.parent is not None: + # the item is not yet fully received, we reschedule an update + Clock.schedule_once( + partial(self._attachment_progress_update, item), + PROGRESS_UPDATE) + + def _attachment_progress_update(self, item, __): + G.host.bridge.progress_get( + item.data["progress_id"], + self.profile, + callback=partial(self._progress_get_cb, item), + errback=G.host.errback, + ) + + def add_nick(self, nick): + """Add a nickname to message_input if suitable""" + if (self.type == C.CHAT_GROUP and not self.message_input.text.startswith(nick)): + self.message_input.text = f'{nick}: {self.message_input.text}' + + def on_send(self, input_widget): + extra = {} + for item in self.attachments_to_send.attachments.children: + if item.sending: + # the item is already being sent + continue + item.sending = True + progress_id = item.data["progress_id"] = str(uuid.uuid4()) + attachments = extra.setdefault(C.KEY_ATTACHMENTS, []) + attachment = { + "path": str(item.data["path"]), + "progress_id": progress_id, + } + if 'media_type' in item.data: + attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = item.data['media_type'] + + if ((self.attachments_to_send.reduce_checkbox.active + and attachment.get('media_type', '').split('/')[0] == 'image')): + attachment[C.KEY_ATTACHMENTS_RESIZE] = True + + attachments.append(attachment) + + Clock.schedule_once( + partial(self._attachment_progress_update, item), + PROGRESS_UPDATE) + + G.host.register_progress_cbs( + progress_id, + callback=partial(self._attachment_progress_cb, item), + errback=partial(self._attachment_progress_eb, item) + ) + + + G.host.message_send( + self.target, + # TODO: handle language + {'': input_widget.text}, + # TODO: put this in QuickChat + mess_type= + C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, + extra=extra, + profile_key=self.profile + ) + input_widget.text = '' + + def _image_check_cb(self, report_raw): + report = data_format.deserialise(report_raw) + if report['too_large']: + self.attachments_to_send.show_resize=True + self.attachments_to_send.reduce_checkbox.active=True + + def add_attachment(self, file_path, media_type=None): + file_path = Path(file_path) + if media_type is None: + media_type = mimetypes.guess_type(str(file_path), strict=False)[0] + if not self.attachments_to_send.show_resize and media_type is not None: + # we check if the attachment is an image and if it's too large. + # If too large, the reduce size check box will be displayed, and checked by + # default. + main_type = media_type.split('/')[0] + if main_type == "image": + G.host.bridge.image_check( + str(file_path), + callback=self._image_check_cb, + errback=partial( + G.host.errback, + title=_("Can't check image size"), + message=_("Can't check image at {path}: {{msg}}").format( + path=file_path), + ) + ) + + data = { + "path": file_path, + "name": file_path.name, + } + + if media_type is not None: + data['media_type'] = media_type + + self.attachments_to_send.attachments.add_widget( + AttachmentToSendItem(data=data) + ) + + def transfer_file(self, file_path, transfer_type=C.TRANSFER_UPLOAD, cleaning_cb=None): + # FIXME: cleaning_cb is not managed + if transfer_type == C.TRANSFER_UPLOAD: + self.add_attachment(file_path) + elif transfer_type == C.TRANSFER_SEND: + if self.type == C.CHAT_GROUP: + log.warning("P2P transfer is not possible for group chat") + # TODO: show an error dialog to user, or better hide the send button for + # MUC + else: + jid_ = self.target + if not jid_.resource: + jid_ = G.host.contact_lists[self.profile].get_full_jid(jid_) + G.host.bridge.file_send(str(jid_), str(file_path), "", "", "", + profile=self.profile) + # TODO: notification of sending/failing + else: + raise log.error("transfer of type {} are not handled".format(transfer_type)) + + def message_encryption_started(self, plugin_data): + quick_chat.QuickChat.message_encryption_started(self, plugin_data) + self.encryption_btn.symbol = SYMBOL_ENCRYPTED + self.encryption_btn.color = COLOR_ENCRYPTED + self.encryption_btn.select_algo(plugin_data['name']) + + def message_encryption_stopped(self, plugin_data): + quick_chat.QuickChat.message_encryption_stopped(self, plugin_data) + self.encryption_btn.symbol = SYMBOL_UNENCRYPTED + self.encryption_btn.color = COLOR_UNENCRYPTED + self.encryption_btn.select_algo(None) + + def _muc_join_cb(self, joined_data): + joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data + self.host.muc_room_joined_handler(*joined_data[1:]) + jid_ = jid.JID(room_jid_s) + self.change_widget(jid_) + + def _muc_join_eb(self, failure): + log.warning("Can't join room: {}".format(failure)) + + def on_otr_state(self, state, dest_jid, profile): + assert profile in self.profiles + if state in OTR_STATE_ENCRYPTION: + self.otr_state_encryption = state + elif state in OTR_STATE_TRUST: + self.otr_state_trust = state + else: + log.error(_("Unknown OTR state received: {}".format(state))) + return + self.encryption_btn.symbol = self.encryption_btn.get_symbol() + self.encryption_btn.color = self.encryption_btn.get_color() + + def on_visible(self): + if not self.sync: + self.resync() + + def on_selected(self): + G.host.clear_notifs(self.target, profile=self.profile) + + def on_delete(self, **kwargs): + if kwargs.get('explicit_close', False): + wrapper = self.whwrapper + if wrapper is not None: + if len(wrapper.carousel.slides) == 1: + # if we delete the last opened chat, we need to show the selector + screen_manager = self.screen_manager + screen_manager.transition.direction = 'down' + screen_manager.current = 'chat_selector' + wrapper.carousel.remove_widget(self) + return True + # we always keep one widget, so it's available when swiping + # TODO: delete all widgets when chat is closed + nb_instances = sum(1 for _ in self.host.widgets.get_widget_instances(self)) + # we want to keep at least one instance of Chat by WHWrapper + nb_to_keep = len(G.host.widgets_handler.children) + if nb_instances <= nb_to_keep: + return False + + def _history_unlock(self, __): + self._history_prepend_lock = False + log.debug("history prepend unlocked") + # we call manually on_scroll, to check if we are still in the scrolling zone + self.on_scroll(self.history_scroll, self.history_scroll.scroll_y) + + def _history_scroll_adjust(self, __, scroll_start_height): + # history scroll position must correspond to where it was before new messages + # have been appended + self.history_scroll.scroll_y = ( + scroll_start_height / self.messages_widget.height + ) + + # we want a small delay before unlocking, to avoid re-fetching history + # again + Clock.schedule_once(self._history_unlock, 1.5) + + def _back_history_get_cb_post(self, __, history, scroll_start_height): + if len(history) == 0: + # we don't unlock self._history_prepend_lock if there is no history, as there + # is no sense to try to retrieve more in this case. + log.debug(f"we've reached top of history for {self.target.bare} chat") + else: + # we have to schedule again for _history_scroll_adjust, else messages_widget + # is not resized (self.messages_widget.height is not yet updated) + # as a result, the scroll_to can't work correctly + Clock.schedule_once(partial( + self._history_scroll_adjust, + scroll_start_height=scroll_start_height)) + log.debug( + f"{len(history)} messages prepended to history (last: {history[0][0]})") + + def _back_history_get_cb(self, history): + # TODO: factorise with QuickChat._history_get_cb + scroll_start_height = self.messages_widget.height * self.history_scroll.scroll_y + for data in reversed(history): + uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data + from_jid = jid.JID(from_jid) + to_jid = jid.JID(to_jid) + extra = data_format.deserialise(extra_s) + extra["history"] = True + self.messages[uid] = message = quick_chat.Message( + self, + uid, + timestamp, + from_jid, + to_jid, + message, + subject, + type_, + extra, + self.profile, + ) + self.messages.move_to_end(uid, last=False) + self.prepend_message(message) + Clock.schedule_once(partial( + self._back_history_get_cb_post, + history=history, + scroll_start_height=scroll_start_height)) + + def _back_history_get_eb(self, failure_): + G.host.add_note( + _("Problem while getting back history"), + _("Can't back history for {target}: {problem}").format( + target=self.target, problem=failure_), + C.XMLUI_DATA_LVL_ERROR) + # we don't unlock self._history_prepend_lock on purpose, no need + # to try to get more history if something is wrong + + def on_scroll(self, scroll_view, scroll_y): + if self._history_prepend_lock: + return + if (1-scroll_y) * self.messages_widget.height < INFINITE_SCROLL_LIMIT: + self._history_prepend_lock = True + log.debug(f"Retrieving back history for {self} [{self.history_count}]") + self.history_count += 1 + first_uid = next(iter(self.messages.keys())) + filters = self.history_filters.copy() + filters['before_uid'] = first_uid + self.host.bridge.history_get( + str(self.host.profiles[self.profile].whoami.bare), + str(self.target), + 30, + True, + {k: str(v) for k,v in filters.items()}, + self.profile, + callback=self._back_history_get_cb, + errback=self._back_history_get_eb, + ) + + +class ChatSelector(cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior): + jid_selector = properties.ObjectProperty() + profile = properties.StringProperty() + plugin_info_class = Chat + use_header_input = True + + def on_select(self, contact_button): + contact_jid = jid.JID(contact_button.jid) + plugin_info = G.host.get_plugin_info(main=Chat) + factory = plugin_info['factory'] + self.screen_manager.transition.direction = 'up' + carousel = self.whwrapper.carousel + current_slides = {w.target: w for w in carousel.slides} + if contact_jid in current_slides: + slide = current_slides[contact_jid] + idx = carousel.slides.index(slide) + carousel.index = idx + self.screen_manager.current = '' + else: + G.host.switch_widget( + self, factory(plugin_info, contact_jid, profiles=[self.profile])) + + + def on_header_wid_input(self): + text = self.header_input.text.strip() + try: + if text.count('@') != 1 or text.count(' '): + raise ValueError + jid_ = jid.JID(text) + except ValueError: + log.info("entered text is not a jid") + return + G.host.do_action("chat", jid_, [self.profile]) + + def on_header_wid_input_complete(self, wid, text, **kwargs): + """we filter items when text is entered in input box""" + for layout in self.jid_selector.items_layouts: + self.do_filter( + layout, + text, + # we append nick to jid to filter on both + lambda c: c.jid + c.data.get('nick', ''), + width_cb=lambda c: c.base_width, + height_cb=lambda c: c.minimum_height, + continue_tests=[lambda c: not isinstance(c, ContactButton)]) + + +PLUGIN_INFO["factory"] = Chat.factory +quick_widgets.register(quick_chat.QuickChat, Chat)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_contact_list.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,104 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import _ libervia.backend.core.i18n._ +#:import e kivy.utils.escape_markup + +<AddContactMenu>: + padding: dp(20) + spacing: dp(10) + Label: + size_hint: 1, None + color: 1, 1, 1, 1 + text: _("Please enter new contact JID") + text_size: root.width, None + size: self.texture_size + halign: "center" + bold: True + TextInput: + id: contact_jid + size_hint: 1, None + height: sp(30) + hint_text: _("enter here your new contact JID") + Button: + size_hint: 1, None + height: sp(50) + text: _("add this contact") + on_release: root.contact_add(contact_jid.text) + Widget: + + +<DelContactMenu>: + padding: dp(20) + spacing: dp(10) + Avatar: + id: avatar + size_hint: 1, None + height: dp(60) + data: root.contact_item.data.get('avatar') + allow_stretch: True + Label: + size_hint: 1, None + color: 1, 1, 1, 1 + text: _("Are you sure you wand to remove [b]{name}[/b] from your contact list?").format(name=e(root.contact_item.jid)) + markup: True + text_size: root.width, None + size: self.texture_size + halign: "center" + BoxLayout: + Button: + background_color: 1, 0, 0, 1 + size_hint: 0.5, None + height: sp(50) + text: _("yes, remove it") + bold: True + on_release: root.do_delete_contact() + Button: + size_hint: 0.5, None + height: sp(50) + text: _("no, keep it") + on_release: root.hide() + Widget: + + +<ContactList>: + float_layout: float_layout + layout: layout + orientation: 'vertical' + BoxLayout: + size_hint: 1, None + height: dp(35) + width: dp(35) + font_size: dp(30) + Widget: + SymbolButtonLabel: + symbol: 'plus-circled' + text: _("add a contact") + on_release: root.add_contact_menu() + Widget: + FloatLayout: + id: float_layout + ScrollView: + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + StackLayout: + id: layout + size_hint: 1, None + height: self.minimum_height + spacing: 0
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_contact_list.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from functools import partial +import bisect +import re +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.frontends.quick_frontend.quick_contact_list import QuickContactList +from libervia.frontends.tools import jid +from kivy import properties +from libervia.desktop_kivy import G +from ..core import cagou_widget +from ..core.constants import Const as C +from ..core.common import ContactItem +from ..core.behaviors import FilterBehavior, TouchMenuBehavior, TouchMenuItemBehavior +from ..core.menu import SideMenu + + +log = logging.getLogger(__name__) + + +PLUGIN_INFO = { + "name": _("contacts"), + "main": "ContactList", + "description": _("list of contacts"), + "icon_medium": "{media}/icons/muchoslava/png/contact_list_no_border_blue_44.png" +} + + +class AddContactMenu(SideMenu): + profile = properties.StringProperty() + size_hint_close = (1, 0) + size_hint_open = (1, 0.5) + + def __init__(self, **kwargs): + super(AddContactMenu, self).__init__(**kwargs) + if self.profile is None: + log.warning(_("profile not set in AddContactMenu")) + self.profile = next(iter(G.host.profiles)) + + def contact_add(self, contact_jid): + """Actually add the contact + + @param contact_jid(unicode): jid of the contact to add + """ + self.hide() + contact_jid = contact_jid.strip() + # FIXME: trivial jid verification + if not contact_jid or not re.match(r"[^@ ]+@[^@ ]+", contact_jid): + return + contact_jid = jid.JID(contact_jid).bare + G.host.bridge.contact_add(str(contact_jid), + self.profile, + callback=lambda: G.host.add_note( + _("contact request"), + _("a contact request has been sent to {contact_jid}").format( + contact_jid=contact_jid)), + errback=partial(G.host.errback, + title=_("can't add contact"), + message=_("error while trying to add contact: {msg}"))) + + +class DelContactMenu(SideMenu): + size_hint_close = (1, 0) + size_hint_open = (1, 0.5) + + def __init__(self, contact_item, **kwargs): + self.contact_item = contact_item + super(DelContactMenu, self).__init__(**kwargs) + + def do_delete_contact(self): + self.hide() + G.host.bridge.contact_del(str(self.contact_item.jid.bare), + self.contact_item.profile, + callback=lambda: G.host.add_note( + _("contact removed"), + _("{contact_jid} has been removed from your contacts list").format( + contact_jid=self.contact_item.jid.bare)), + errback=partial(G.host.errback, + title=_("can't remove contact"), + message=_("error while trying to remove contact: {msg}"))) + + +class CLContactItem(TouchMenuItemBehavior, ContactItem): + + def do_item_action(self, touch): + assert self.profile + # XXX: for now clicking on an item launch the corresponding Chat widget + # behaviour should change in the future + G.host.do_action('chat', jid.JID(self.jid), [self.profile]) + + def get_menu_choices(self): + choices = [] + choices.append(dict(text=_('delete'), + index=len(choices)+1, + callback=self.main_wid.remove_contact)) + return choices + + +class ContactList(QuickContactList, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior, + TouchMenuBehavior): + float_layout = properties.ObjectProperty() + layout = properties.ObjectProperty() + use_header_input = True + + def __init__(self, host, target, profiles): + QuickContactList.__init__(self, G.host, profiles) + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + FilterBehavior.__init__(self) + self._wid_map = {} # (profile, bare_jid) to widget map + self.post_init() + if len(self.profiles) != 1: + raise NotImplementedError('multi profiles is not implemented yet') + self.update(profile=next(iter(self.profiles))) + + def add_contact_menu(self): + """Show the "add a contact" menu""" + # FIXME: for now we add contact to the first profile we find + profile = next(iter(self.profiles)) + AddContactMenu(profile=profile).show() + + def remove_contact(self, menu_label): + item = self.menu_item + self.clear_menu() + DelContactMenu(contact_item=item).show() + + def on_header_wid_input_complete(self, wid, text): + self.do_filter(self.layout, + text, + lambda c: c.jid, + width_cb=lambda c: c.base_width, + height_cb=lambda c: c.minimum_height, + ) + + def _add_contact_item(self, bare_jid, profile): + """Create a new CLContactItem instance, and add it + + item will be added in a sorted position + @param bare_jid(jid.JID): entity bare JID + @param profile(unicode): profile where the contact is + """ + data = G.host.contact_lists[profile].get_item(bare_jid) + wid = CLContactItem(profile=profile, data=data, jid=bare_jid, main_wid=self) + child_jids = [c.jid for c in reversed(self.layout.children)] + idx = bisect.bisect_right(child_jids, bare_jid) + self.layout.add_widget(wid, -idx) + self._wid_map[(profile, bare_jid)] = wid + + def update(self, entities=None, type_=None, profile=None): + log.debug("update: %s %s %s" % (entities, type_, profile)) + if type_ == None or type_ == C.UPDATE_STRUCTURE: + log.debug("full contact list update") + self.layout.clear_widgets() + for bare_jid, data in self.items_sorted.items(): + wid = CLContactItem( + profile=data['profile'], + data=data, + jid=bare_jid, + main_wid=self, + ) + self.layout.add_widget(wid) + self._wid_map[(profile, bare_jid)] = wid + elif type_ == C.UPDATE_MODIFY: + for entity in entities: + entity_bare = entity.bare + wid = self._wid_map[(profile, entity_bare)] + wid.data = G.host.contact_lists[profile].get_item(entity_bare) + elif type_ == C.UPDATE_ADD: + for entity in entities: + self._add_contact_item(entity.bare, profile) + elif type_ == C.UPDATE_DELETE: + for entity in entities: + try: + self.layout.remove_widget(self._wid_map.pop((profile, entity.bare))) + except KeyError: + log.debug("entity not found: {entity}".format(entity=entity.bare)) + else: + log.debug("update type not handled: {update_type}".format(update_type=type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_file_sharing.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,62 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +#:import ModernMenu kivy_garden.modernmenu.ModernMenu + + +<ModeBtn>: + width: self.texture_size[0] + sp(20) + size_hint: None, 1 + + +<FileSharing>: + float_layout: float_layout + layout: layout + FloatLayout: + id: float_layout + ScrollView: + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + StackLayout: + id: layout + size_hint: 1, None + height: self.minimum_height + spacing: 0 + + +<PathWidget>: + shared: False + Symbol: + size_hint: 1, None + height: dp(80) + font_size: dp(40) + symbol: 'folder-open-empty' if root.is_dir else 'doc' + color: (1, 0, 0, 1) if root.shared else (0, 0, 0, 1) if root.is_dir else app.c_prim_dark + Label: + size_hint: None, None + width: dp(100) + font_size: sp(14) + text_size: dp(95), None + size: self.texture_size + text: root.name + halign: 'center' + + +<LocalPathWidget>: + shared: root.filepath in root.main_wid.shared_paths
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_file_sharing.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from functools import partial +import os.path +import json +from libervia.backend.core import log as logging +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import files_utils +from libervia.frontends.quick_frontend import quick_widgets +from libervia.frontends.tools import jid +from ..core.constants import Const as C +from ..core import cagou_widget +from ..core.menu import EntitiesSelectorMenu +from ..core.behaviors import TouchMenuBehavior, FilterBehavior +from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget, + CategorySeparator) +from libervia.desktop_kivy import G +from kivy import properties +from kivy.uix.label import Label +from kivy.uix.button import Button +from kivy import utils as kivy_utils + +log = logging.getLogger(__name__) + + +PLUGIN_INFO = { + "name": _("file sharing"), + "main": "FileSharing", + "description": _("share/transfer files between devices"), + "icon_symbol": "exchange", +} +MODE_VIEW = "view" +MODE_LOCAL = "local" +SELECT_INSTRUCTIONS = _("Please select entities to share with") + +if kivy_utils.platform == "android": + from jnius import autoclass + Environment = autoclass("android.os.Environment") + base_dir = Environment.getExternalStorageDirectory().getAbsolutePath() + def expanduser(path): + if path == '~' or path.startswith('~/'): + return path.replace('~', base_dir, 1) + return path +else: + expanduser = os.path.expanduser + + +class ModeBtn(Button): + + def __init__(self, parent, **kwargs): + super(ModeBtn, self).__init__(**kwargs) + parent.bind(mode=self.on_mode) + self.on_mode(parent, parent.mode) + + def on_mode(self, parent, new_mode): + if new_mode == MODE_VIEW: + self.text = _("view shared files") + elif new_mode == MODE_LOCAL: + self.text = _("share local files") + else: + exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode)) + + +class PathWidget(ItemWidget): + + def __init__(self, filepath, main_wid, **kw): + name = os.path.basename(filepath) + self.filepath = os.path.normpath(filepath) + if self.filepath == '.': + self.filepath = '' + super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw) + + @property + def is_dir(self): + raise NotImplementedError + + def do_item_action(self, touch): + if self.is_dir: + self.main_wid.current_dir = self.filepath + + def open_menu(self, touch, dt): + log.debug(_("opening menu for {path}").format(path=self.filepath)) + super(PathWidget, self).open_menu(touch, dt) + + +class LocalPathWidget(PathWidget): + + @property + def is_dir(self): + return os.path.isdir(self.filepath) + + def get_menu_choices(self): + choices = [] + if self.shared: + choices.append(dict(text=_('unshare'), + index=len(choices)+1, + callback=self.main_wid.unshare)) + else: + choices.append(dict(text=_('share'), + index=len(choices)+1, + callback=self.main_wid.share)) + return choices + + +class RemotePathWidget(PathWidget): + + def __init__(self, main_wid, filepath, type_, **kw): + self.type_ = type_ + super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw) + + @property + def is_dir(self): + return self.type_ == C.FILE_TYPE_DIRECTORY + + def do_item_action(self, touch): + if self.is_dir: + if self.filepath == '..': + self.main_wid.remote_entity = '' + else: + super(RemotePathWidget, self).do_item_action(touch) + else: + self.main_wid.request_item(self) + return True + +class SharingDeviceWidget(DeviceWidget): + + def do_item_action(self, touch): + self.main_wid.remote_entity = self.entity_jid + self.main_wid.remote_dir = '' + + +class FileSharing(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior, + TouchMenuBehavior): + SINGLE=False + layout = properties.ObjectProperty() + mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL]) + local_dir = properties.StringProperty(expanduser('~')) + remote_dir = properties.StringProperty('') + remote_entity = properties.StringProperty('') + shared_paths = properties.ListProperty() + use_header_input = True + signals_registered = False + + def __init__(self, host, target, profiles): + quick_widgets.QuickWidget.__init__(self, host, target, profiles) + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + FilterBehavior.__init__(self) + TouchMenuBehavior.__init__(self) + self.mode_btn = ModeBtn(self) + self.mode_btn.bind(on_release=self.change_mode) + self.header_input_add_extra(self.mode_btn) + self.bind(local_dir=self.update_view, + remote_dir=self.update_view, + remote_entity=self.update_view) + self.update_view() + if not FileSharing.signals_registered: + # FIXME: we use this hack (registering the signal for the whole class) now + # as there is currently no unregisterSignal available in bridges + G.host.register_signal("fis_shared_path_new", + handler=FileSharing.shared_path_new, + iface="plugin") + G.host.register_signal("fis_shared_path_removed", + handler=FileSharing.shared_path_removed, + iface="plugin") + FileSharing.signals_registered = True + G.host.bridge.fis_local_shares_get(self.profile, + callback=self.fill_paths, + errback=G.host.errback) + + @property + def current_dir(self): + return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir + + @current_dir.setter + def current_dir(self, new_dir): + if self.mode == MODE_LOCAL: + self.local_dir = new_dir + else: + self.remote_dir = new_dir + + def fill_paths(self, shared_paths): + self.shared_paths.extend(shared_paths) + + def change_mode(self, mode_btn): + self.clear_menu() + opt = self.__class__.mode.options + new_idx = (opt.index(self.mode)+1) % len(opt) + self.mode = opt[new_idx] + + def on_mode(self, instance, new_mode): + self.update_view(None, self.local_dir) + + def on_header_wid_input(self): + if '/' in self.header_input.text or self.header_input.text == '~': + self.current_dir = expanduser(self.header_input.text) + + def on_header_wid_input_complete(self, wid, text, **kwargs): + """we filter items when text is entered in input box""" + if '/' in text: + return + self.do_filter(self.layout, + text, + lambda c: c.name, + width_cb=lambda c: c.base_width, + height_cb=lambda c: c.minimum_height, + continue_tests=[lambda c: not isinstance(c, ItemWidget), + lambda c: c.name == '..']) + + + ## remote sharing callback ## + + def _disco_find_by_features_cb(self, data): + entities_services, entities_own, entities_roster = data + for entities_map, title in ((entities_services, + _('services')), + (entities_own, + _('your devices')), + (entities_roster, + _('your contacts devices'))): + if entities_map: + self.layout.add_widget(CategorySeparator(text=title)) + for entity_str, entity_ids in entities_map.items(): + entity_jid = jid.JID(entity_str) + item = SharingDeviceWidget( + self, entity_jid, Identities(entity_ids)) + self.layout.add_widget(item) + if not entities_services and not entities_own and not entities_roster: + self.layout.add_widget(Label( + size_hint=(1, 1), + halign='center', + text_size=self.size, + text=_("No sharing device found"))) + + def discover_devices(self): + """Looks for devices handling file "File Information Sharing" and display them""" + try: + namespace = self.host.ns_map['fis'] + except KeyError: + msg = _("can't find file information sharing namespace, " + "is the plugin running?") + log.warning(msg) + G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR) + return + self.host.bridge.disco_find_by_features( + [namespace], [], False, True, True, True, False, self.profile, + callback=self._disco_find_by_features_cb, + errback=partial(G.host.errback, + title=_("shared folder error"), + message=_("can't check sharing devices: {msg}"))) + + def fis_list_cb(self, files_data): + for file_data in files_data: + filepath = os.path.join(self.current_dir, file_data['name']) + item = RemotePathWidget( + filepath=filepath, + main_wid=self, + type_=file_data['type']) + self.layout.add_widget(item) + + def fis_list_eb(self, failure_): + self.remote_dir = '' + G.host.add_note( + _("shared folder error"), + _("can't list files for {remote_entity}: {msg}").format( + remote_entity=self.remote_entity, + msg=failure_), + level=C.XMLUI_DATA_LVL_WARNING) + + ## view generation ## + + def update_view(self, *args): + """update items according to current mode, entity and dir""" + log.debug('updating {}, {}'.format(self.current_dir, args)) + self.layout.clear_widgets() + self.header_input.text = '' + self.header_input.hint_text = self.current_dir + + if self.mode == MODE_LOCAL: + filepath = os.path.join(self.local_dir, '..') + self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self)) + try: + files = sorted(os.listdir(self.local_dir)) + except OSError as e: + msg = _("can't list files in \"{local_dir}\": {msg}").format( + local_dir=self.local_dir, + msg=e) + G.host.add_note( + _("shared folder error"), + msg, + level=C.XMLUI_DATA_LVL_WARNING) + self.local_dir = expanduser('~') + return + for f in files: + filepath = os.path.join(self.local_dir, f) + self.layout.add_widget(LocalPathWidget(filepath=filepath, + main_wid=self)) + elif self.mode == MODE_VIEW: + if not self.remote_entity: + self.discover_devices() + else: + # we always a way to go back + # so user can return to previous list even in case of error + parent_path = os.path.join(self.remote_dir, '..') + item = RemotePathWidget( + filepath = parent_path, + main_wid=self, + type_ = C.FILE_TYPE_DIRECTORY) + self.layout.add_widget(item) + self.host.bridge.fis_list( + str(self.remote_entity), + self.remote_dir, + {}, + self.profile, + callback=self.fis_list_cb, + errback=self.fis_list_eb) + + ## Share methods ## + + def do_share(self, entities_jids, item): + if entities_jids: + access = {'read': {'type': 'whitelist', + 'jids': entities_jids}} + else: + access = {} + + G.host.bridge.fis_share_path( + item.name, + item.filepath, + json.dumps(access, ensure_ascii=False), + self.profile, + callback=lambda name: G.host.add_note( + _("sharing folder"), + _("{name} is now shared").format(name=name)), + errback=partial(G.host.errback, + title=_("sharing folder"), + message=_("can't share folder: {msg}"))) + + def share(self, menu): + item = self.menu_item + self.clear_menu() + EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS, + callback=partial(self.do_share, item=item)).show() + + def unshare(self, menu): + item = self.menu_item + self.clear_menu() + G.host.bridge.fis_unshare_path( + item.filepath, + self.profile, + callback=lambda: G.host.add_note( + _("sharing folder"), + _("{name} is not shared anymore").format(name=item.name)), + errback=partial(G.host.errback, + title=_("sharing folder"), + message=_("can't unshare folder: {msg}"))) + + def file_jingle_request_cb(self, progress_id, item, dest_path): + G.host.add_note( + _("file request"), + _("{name} download started at {dest_path}").format( + name = item.name, + dest_path = dest_path)) + + def request_item(self, item): + """Retrieve an item from remote entity + + @param item(RemotePathWidget): item to retrieve + """ + path, name = os.path.split(item.filepath) + assert name + assert self.remote_entity + extra = {'path': path} + dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name)) + G.host.bridge.file_jingle_request(str(self.remote_entity), + str(dest_path), + name, + '', + '', + extra, + self.profile, + callback=partial(self.file_jingle_request_cb, + item=item, + dest_path=dest_path), + errback=partial(G.host.errback, + title = _("file request error"), + message = _("can't request file: {msg}"))) + + @classmethod + def shared_path_new(cls, shared_path, name, profile): + for wid in G.host.get_visible_list(cls): + if shared_path not in wid.shared_paths: + wid.shared_paths.append(shared_path) + + @classmethod + def shared_path_removed(cls, shared_path, profile): + for wid in G.host.get_visible_list(cls): + if shared_path in wid.shared_paths: + wid.shared_paths.remove(shared_path) + else: + log.warning(_("shared path {path} not found in {widget}".format( + path = shared_path, widget = wid)))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_remote.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,101 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +<RemoteControl>: + layout: layout + BoxLayout: + id: layout + + +<DevicesLayout>: + layout: layout + size_hint: 1, 1 + ScrollView: + size_hint: 1, 1 + pos_hint: {'x': 0, 'y': 0} + do_scroll_x: False + scroll_type: ['bars', 'content'] + bar_width: dp(6) + StackLayout: + id: layout + size_hint: 1, None + height: self.minimum_height + spacing: 0 + + +<RemoteItemWidget>: + shared: False + Symbol: + size_hint: 1, None + height: dp(80) + symbol: 'video' + color: 0, 0, 0, 1 + Label: + size_hint: None, None + width: dp(100) + font_size: sp(14) + text_size: dp(95), None + size: self.texture_size + text: root.name + halign: 'center' + + +<PlayerLabel@Label>: + size_hint: 1, None + text_size: self.width, None + size: self.texture_size + halign: 'center' + + +<PlayerButton@SymbolButton>: + size_hint: None, 1 + + +<MediaPlayerControlWidget>: + orientation: 'vertical' + PlayerLabel: + text: root.title + bold: True + font_size: '20sp' + PlayerLabel: + text: root.identity + font_size: '15sp' + Widget: + size_hint: 1, None + height: dp(50) + BoxLayout: + size_hint: 1, None + spacing: dp(20) + height: dp(30) + Widget: + PlayerButton: + symbol: "previous" + on_release: root.do_cmd("Previous") + PlayerButton: + symbol: "fast-bw" + on_release: root.do_cmd("GoBack") + PlayerButton: + symbol: root.status + on_release: root.do_cmd("PlayPause") + PlayerButton + symbol: "fast-fw" + on_release: root.do_cmd("GoFW") + PlayerButton + symbol: "next" + on_release: root.do_cmd("Next") + Widget: + Widget:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_remote.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.frontends.quick_frontend import quick_widgets +from ..core import cagou_widget +from ..core.constants import Const as C +from ..core.behaviors import TouchMenuBehavior, FilterBehavior +from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget, + CategorySeparator) +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import data_format +from libervia.desktop_kivy.core import xmlui +from libervia.frontends.tools import jid +from kivy import properties +from kivy.uix.label import Label +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.floatlayout import FloatLayout +from libervia.desktop_kivy import G +from functools import partial + + +log = logging.getLogger(__name__) + +PLUGIN_INFO = { + "name": _("remote control"), + "main": "RemoteControl", + "description": _("universal remote control"), + "icon_symbol": "signal", +} + +NOTE_TITLE = _("Media Player Remote Control") + + +class RemoteItemWidget(ItemWidget): + + def __init__(self, device_jid, node, name, main_wid, **kw): + self.device_jid = device_jid + self.node = node + super(RemoteItemWidget, self).__init__(name=name, main_wid=main_wid, **kw) + + def do_item_action(self, touch): + self.main_wid.layout.clear_widgets() + player_wid = MediaPlayerControlWidget(main_wid=self.main_wid, remote_item=self) + self.main_wid.layout.add_widget(player_wid) + + +class MediaPlayerControlWidget(BoxLayout): + main_wid = properties.ObjectProperty() + remote_item = properties.ObjectProperty() + status = properties.OptionProperty("play", options=("play", "pause", "stop")) + title = properties.StringProperty() + identity = properties.StringProperty() + command = properties.DictProperty() + ui_tpl = properties.ObjectProperty() + + @property + def profile(self): + return self.main_wid.profile + + def update_ui(self, action_data_s): + action_data = data_format.deserialise(action_data_s) + xmlui_raw = action_data['xmlui'] + ui_tpl = template_xmlui.create(G.host, xmlui_raw) + self.ui_tpl = ui_tpl + for prop in ('Title', 'Identity'): + try: + setattr(self, prop.lower(), ui_tpl.widgets[prop].value) + except KeyError: + log.warning(_("Missing field: {name}").format(name=prop)) + playback_status = self.ui_tpl.widgets['PlaybackStatus'].value + if playback_status == "Playing": + self.status = "pause" + elif playback_status == "Paused": + self.status = "play" + elif playback_status == "Stopped": + self.status = "play" + else: + G.host.add_note( + title=NOTE_TITLE, + message=_("Unknown playback status: playback_status") + .format(playback_status=playback_status), + level=C.XMLUI_DATA_LVL_WARNING) + self.commands = {v:k for k,v in ui_tpl.widgets['command'].options} + + def ad_hoc_run_cb(self, xmlui_raw): + ui_tpl = template_xmlui.create(G.host, xmlui_raw) + data = {xmlui.XMLUIPanel.escape("media_player"): self.remote_item.node, + "session_id": ui_tpl.session_id} + G.host.bridge.action_launch( + ui_tpl.submit_id, data_format.serialise(data), + self.profile, callback=self.update_ui, + errback=self.main_wid.errback + ) + + def on_remote_item(self, __, remote): + NS_MEDIA_PLAYER = G.host.ns_map["mediaplayer"] + G.host.bridge.ad_hoc_run(str(remote.device_jid), NS_MEDIA_PLAYER, self.profile, + callback=self.ad_hoc_run_cb, + errback=self.main_wid.errback) + + def do_cmd(self, command): + try: + cmd_value = self.commands[command] + except KeyError: + G.host.add_note( + title=NOTE_TITLE, + message=_("{command} command is not managed").format(command=command), + level=C.XMLUI_DATA_LVL_WARNING) + else: + data = {xmlui.XMLUIPanel.escape("command"): cmd_value, + "session_id": self.ui_tpl.session_id} + # hidden values are normally transparently managed by XMLUIPanel + # but here we have to add them by hand + hidden = {xmlui.XMLUIPanel.escape(k):v + for k,v in self.ui_tpl.hidden.items()} + data.update(hidden) + G.host.bridge.action_launch( + self.ui_tpl.submit_id, data_format.serialise(data), self.profile, + callback=self.update_ui, errback=self.main_wid.errback + ) + + +class RemoteDeviceWidget(DeviceWidget): + + def xmlui_cb(self, data, cb_id, profile): + if 'xmlui' in data: + xml_ui = xmlui.create( + G.host, data['xmlui'], callback=self.xmlui_cb, profile=profile) + if isinstance(xml_ui, xmlui.XMLUIDialog): + self.main_wid.show_root_widget() + xml_ui.show() + else: + xml_ui.set_close_cb(self.on_close) + self.main_wid.layout.add_widget(xml_ui) + else: + if data: + log.warning(_("Unhandled data: {data}").format(data=data)) + self.main_wid.show_root_widget() + + def on_close(self, __, reason): + if reason == C.XMLUI_DATA_CANCELLED: + self.main_wid.show_root_widget() + else: + self.main_wid.layout.clear_widgets() + + def ad_hoc_run_cb(self, data): + xml_ui = xmlui.create(G.host, data, callback=self.xmlui_cb, profile=self.profile) + xml_ui.set_close_cb(self.on_close) + self.main_wid.layout.add_widget(xml_ui) + + def do_item_action(self, touch): + self.main_wid.layout.clear_widgets() + G.host.bridge.ad_hoc_run(str(self.entity_jid), '', self.profile, + callback=self.ad_hoc_run_cb, errback=self.main_wid.errback) + + +class DevicesLayout(FloatLayout): + """Layout used to show devices""" + layout = properties.ObjectProperty() + + +class RemoteControl(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior, + TouchMenuBehavior): + SINGLE=False + layout = properties.ObjectProperty() + + def __init__(self, host, target, profiles): + quick_widgets.QuickWidget.__init__(self, host, target, profiles) + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + FilterBehavior.__init__(self) + TouchMenuBehavior.__init__(self) + self.stack_layout = None + self.show_root_widget() + + def errback(self, failure_): + """Generic errback which add a warning note and go back to root widget""" + G.host.add_note( + title=NOTE_TITLE, + message=_("Can't use remote control: {reason}").format(reason=failure_), + level=C.XMLUI_DATA_LVL_WARNING) + self.show_root_widget() + + def key_input(self, window, key, scancode, codepoint, modifier): + if key == 27: + self.show_root_widget() + return True + + def show_root_widget(self): + self.layout.clear_widgets() + devices_layout = DevicesLayout() + self.stack_layout = devices_layout.layout + self.layout.add_widget(devices_layout) + found = [] + self.get_remotes(found) + self.discover_devices(found) + + def ad_hoc_remotes_get_cb(self, remotes_data, found): + found.insert(0, remotes_data) + if len(found) == 2: + self.show_devices(found) + + def ad_hoc_remotes_get_eb(self, failure_, found): + G.host.errback(failure_, title=_("discovery error"), + message=_("can't check remote controllers: {msg}")) + found.insert(0, []) + if len(found) == 2: + self.show_devices(found) + + def get_remotes(self, found): + self.host.bridge.ad_hoc_remotes_get( + self.profile, + callback=partial(self.ad_hoc_remotes_get_cb, found=found), + errback=partial(self.ad_hoc_remotes_get_eb,found=found)) + + def _disco_find_by_features_cb(self, data, found): + found.append(data) + if len(found) == 2: + self.show_devices(found) + + def _disco_find_by_features_eb(self, failure_, found): + G.host.errback(failure_, title=_("discovery error"), + message=_("can't check devices: {msg}")) + found.append(({}, {}, {})) + if len(found) == 2: + self.show_devices(found) + + def discover_devices(self, found): + """Looks for devices handling file "File Information Sharing" and display them""" + try: + namespace = self.host.ns_map['commands'] + except KeyError: + msg = _("can't find ad-hoc commands namespace, is the plugin running?") + log.warning(msg) + G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR) + return + self.host.bridge.disco_find_by_features( + [namespace], [], False, True, True, True, False, self.profile, + callback=partial(self._disco_find_by_features_cb, found=found), + errback=partial(self._disco_find_by_features_eb, found=found)) + + def show_devices(self, found): + remotes_data, (entities_services, entities_own, entities_roster) = found + if remotes_data: + title = _("media players remote controls") + self.stack_layout.add_widget(CategorySeparator(text=title)) + + for remote_data in remotes_data: + device_jid, node, name = remote_data + wid = RemoteItemWidget(device_jid, node, name, self) + self.stack_layout.add_widget(wid) + + for entities_map, title in ((entities_services, + _('services')), + (entities_own, + _('your devices')), + (entities_roster, + _('your contacts devices'))): + if entities_map: + self.stack_layout.add_widget(CategorySeparator(text=title)) + for entity_str, entity_ids in entities_map.items(): + entity_jid = jid.JID(entity_str) + item = RemoteDeviceWidget( + self, entity_jid, Identities(entity_ids)) + self.stack_layout.add_widget(item) + if (not remotes_data and not entities_services and not entities_own + and not entities_roster): + self.stack_layout.add_widget(Label( + size_hint=(1, 1), + halign='center', + text_size=self.size, + text=_("No sharing device found")))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_settings.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,15 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_settings.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +from libervia.backend.core.i18n import _ +from libervia.backend.core.constants import Const as C +from libervia.backend.tools.common import data_format +from libervia.frontends.quick_frontend import quick_widgets +from kivy.uix.label import Label +from kivy.uix.widget import Widget +from libervia.desktop_kivy.core import cagou_widget +from libervia.desktop_kivy import G + + +log = logging.getLogger(__name__) + + +PLUGIN_INFO = { + "name": _("settings"), + "main": "LiberviaDesktopKivySettings", + "description": _("LiberviaDesktopKivy/SàT settings"), + "icon_symbol": "wrench", +} + + +class LiberviaDesktopKivySettings(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget): + # XXX: this class can't be called "Settings", because Kivy has already a class + # of this name, and the kv there would apply + + def __init__(self, host, target, profiles): + quick_widgets.QuickWidget.__init__(self, G.host, target, profiles) + cagou_widget.LiberviaDesktopKivyWidget.__init__(self) + # the Widget() avoid LiberviaDesktopKivyWidget header to be down at the beginning + # then up when the UI is loaded + self.loading_widget = Widget() + self.add_widget(self.loading_widget) + extra = {} + G.local_platform.update_params_extra(extra) + G.host.bridge.param_ui_get( + -1, C.APP_NAME, data_format.serialise(extra), self.profile, + callback=self.get_params_ui_cb, + errback=self.get_params_ui_eb) + + def change_widget(self, widget): + self.clear_widgets([self.loading_widget]) + del self.loading_widget + self.add_widget(widget) + + def get_params_ui_cb(self, xmlui): + G.host.action_manager({"xmlui": xmlui}, ui_show_cb=self.change_widget, profile=self.profile) + + def get_params_ui_eb(self, failure): + self.change_widget(Label( + text=_("Can't load parameters!"), + bold=True, + color=(1,0,0,1))) + G.host.show_dialog("Can't load params UI", str(failure), "error")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_widget_selector.kv Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,48 @@ +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +<WidgetSelItem>: + size_hint: (1, None) + height: dp(40) + item: item + Widget: + BoxLayout: + id: item + size_hint: None, 1 + spacing: dp(10) + ActionIcon: + plugin_info: root.plugin_info + size_hint: None, 1 + width: self.height + Label: + text: root.plugin_info["name"] + bold: True + valign: 'middle' + font_size: sp(20) + size_hint: None, 1 + width: self.texture_size[0] + Widget: + + +<WidgetSelector>: + spacing: dp(10) + container: container + ScrollView: + BoxLayout: + orientation: "vertical" + size_hint: 1, None + height: self.minimum_height + id: container
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/plugins/plugin_wid_widget_selector.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from libervia.backend.core import log as logging +log = logging.getLogger(__name__) +from libervia.backend.core.i18n import _ +from libervia.desktop_kivy.core.constants import Const as C +from kivy.uix.widget import Widget +from kivy.uix.boxlayout import BoxLayout +from kivy import properties +from kivy.uix.behaviors import ButtonBehavior +from libervia.desktop_kivy.core import cagou_widget +from libervia.desktop_kivy import G + + +PLUGIN_INFO = { + "name": _("widget selector"), + "import_name": C.WID_SELECTOR, + "main": "WidgetSelector", + "description": _("show available widgets and allow to select one"), + "icon_medium": "{media}/icons/muchoslava/png/selector_no_border_blue_44.png" +} + + +class WidgetSelItem(ButtonBehavior, BoxLayout): + plugin_info = properties.DictProperty() + item = properties.ObjectProperty() + + def on_release(self, *args): + log.debug("widget selection: {}".format(self.plugin_info["name"])) + factory = self.plugin_info["factory"] + G.host.switch_widget( + self, factory(self.plugin_info, None, profiles=iter(G.host.profiles))) + + +class WidgetSelector(cagou_widget.LiberviaDesktopKivyWidget): + container = properties.ObjectProperty() + + def __init__(self): + super(WidgetSelector, self).__init__() + self.items = [] + for plugin_info in G.host.get_plugged_widgets(except_cls=self.__class__): + item = WidgetSelItem(plugin_info=plugin_info) + self.items.append(item.item) + item.item.bind(minimum_width=self.adjust_width) + self.container.add_widget(item) + self.container.add_widget(Widget()) + + def adjust_width(self, label, texture_size): + width = max([i.minimum_width for i in self.items]) + for i in self.items: + i.width = width + + def key_input(self, window, key, scancode, codepoint, modifier): + # we pass to avoid default LiberviaDesktopKivyWidget which is going back to default widget + # (which is this one) + pass + + @classmethod + def factory(cls, plugin_info, target, profiles): + return cls() + + +PLUGIN_INFO["factory"] = WidgetSelector.factory
--- a/main.py Fri Jun 02 17:53:09 2023 +0200 +++ b/main.py Fri Jun 02 18:26:16 2023 +0200 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +#Libervia Desktop-Kivy # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import cagou +import libervia.desktop_kivy if __name__ == "__main__": - cagou.run() + libervia.desktop_kivy.run()
--- a/service/main.py Fri Jun 02 17:53:09 2023 +0200 +++ b/service/main.py Fri Jun 02 18:26:16 2023 +0200 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +#Libervia Desktop-Kivy # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify @@ -18,13 +18,13 @@ import sys import os -# we want the service to access the modules from parent dir (sat, etc.) +# we want the service to access the modules from parent dir (libervia.backend, etc.) os.chdir('..') sys.path.insert(0, '') -from sat.core.constants import Const as C -from sat.core import log_config +from libervia.backend.core.constants import Const as C +from libervia.backend.core import log_config # SàT log conf must be done before calling Kivy -log_config.sat_configure(C.LOG_BACKEND_STANDARD, C) +log_config.libervia_configure(C.LOG_BACKEND_STANDARD, C) # if this module is called, we should be on android, # but just in case... from kivy import utils as kivy_utils @@ -33,7 +33,7 @@ # so we change it to allow backend to detect android sys.platform = "android" C.PLUGIN_EXT = "pyc" -from sat.core import sat_main +from libervia.backend.core import main from twisted.internet import reactor from twisted.application import app, service from jnius import autoclass @@ -43,8 +43,8 @@ PythonService.mService.setAutoRestartService(True) -sat = sat_main.SAT() -application = service.Application("SàT backend") -sat.setServiceParent(application) +libervia_backend = main.LiberviaBackend() +application = service.Application("Libervia backend") +libervia_backend.setServiceParent(application) app.startApplication(application, None) reactor.run()
--- a/setup.py Fri Jun 02 17:53:09 2023 +0200 +++ b/setup.py Fri Jun 02 18:26:16 2023 +0200 @@ -21,9 +21,9 @@ import os import textwrap -NAME = "libervia-desktop" +NAME = "libervia-desktop_kivy" # NOTE: directory is still "cagou" for compatibility reason, should be changed for 0.9 -DIR_NAME = "cagou" +DIR_NAME = "libervia/desktop_kivy" install_requires = [ 'kivy >=2.0.0, <2.2.0', @@ -38,7 +38,7 @@ is_dev_version = VERSION.endswith('D') -def cagou_dev_version(): +def desktop_kivy_dev_version(): """Use mercurial data to compute version""" def version_scheme(version): return VERSION.replace('D', '.dev0') @@ -55,16 +55,17 @@ setup( name=NAME, version=VERSION, - description="Desktop/Android frontend for Libervia XMPP client", + description="Desktop/Android frontend (Kivy version) for Libervia XMPP client", long_description=textwrap.dedent("""\ - Libervia Desktop (Cagou) is a desktop/Android frontend for Libervia. + Libervia Desktop Kivy is a Desktop/Android frontend for Libervia. + This is an alternative version using the Kivy framework. It provides native graphical interface with a modern user interface, using touch screen abilitiy when available, and with split ability inspired from Blender """), - author="Association « Salut à Toi »", + author="Libervia Team", author_email="contact@goffi.org", - url="https://salut-a-toi.org", + url="https://libervia.org", classifiers=[ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", @@ -83,15 +84,15 @@ packages=find_packages(), entry_points={ "console_scripts": [ - "libervia-desktop = cagou:run", - "libervia-mobile = cagou:run", - "cagou = cagou:run", + "libervia-desktop_kivy = libervia.desktop_kivy:run", + "libervia-mobile = libervia.desktop_kivy:run", + "cagou = libervia.desktop_kivy:run", ], }, zip_safe=False, setup_requires=["setuptools_scm"] if is_dev_version else [], - use_scm_version=cagou_dev_version if is_dev_version else False, + use_scm_version=desktop_kivy_dev_version if is_dev_version else False, install_requires=install_requires, - package_data={"": ["*.kv"], "cagou": ["VERSION"]}, + package_data={"": ["*.kv"], "libervia.desktop_kivy": ["VERSION"]}, python_requires=">=3.7", )