# HG changeset patch # User Goffi # Date 1522941081 -7200 # Node ID cd99f70ea592988f1a5b103b22d1a9f797289db5 # Parent b6e6afb0dc4666be7c017768d82692d94a129c8e global file reorganisation: - follow common convention by puttin cagou in "cagou" instead of "src/cagou" - added VERSION in cagou with current version - updated dates - moved main executable in /bin - moved buildozer files in root directory - temporary moved platform to assets/platform diff -r b6e6afb0dc46 -r cd99f70ea592 assets/platform/android/sat.conf --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/assets/platform/android/sat.conf Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,8 @@ +[DEFAULT] +bridge = pb +log_level = debug +log_fmt = #[%%(levelname)s][%%(name)s] %%(message)s + +[cagou] +log_level = debug +log_fmt = [%%(levelname)s][%%(name)s] %%(message)s diff -r b6e6afb0dc46 -r cd99f70ea592 bin/cagou --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/cagou Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,23 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import cagou + +if __name__ == "__main__": + cagou.run() diff -r b6e6afb0dc46 -r cd99f70ea592 buildozer.spec --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/buildozer.spec Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,236 @@ +[app] + +# (str) Title of your application +title = Cagou + +# (str) Package name +package.name = cagou + +# (str) Package domain (needed for android/ios packaging) +package.domain = org.goffi.cagou + +# (str) Source code where the main.py live +source.dir = . + +# (list) Source files to include (let empty to include all the files) +source.include_exts = py,png,jpg,kv,atlas,conf +# FIXME: check if we can do sat.conf only, without every .conf + +# (list) List of inclusions using pattern matching +#source.include_patterns = assets/*,images/*.png +source.include_patterns = media + +# (list) Source files to exclude (let empty to not exclude anything) +#source.exclude_exts = spec + +# (list) List of directory to exclude (let empty to not exclude anything) +#source.exclude_dirs = tests, bin + +# (list) List of exclusions using pattern matching +#source.exclude_patterns = license,images/*/*.jpg + +# (str) Application versioning (method 1) +version = 0.1 + +# (str) Application versioning (method 2) +# version.regex = __version__ = ['"](.*)['"] +# version.filename = %(source.dir)s/main.py + +# (list) Application requirements +# comma seperated e.g. requirements = sqlite3,kivy +requirements = kivy, sqlite3, twisted, wokkel, pil, lxml, pyxdg, markdown, html2text, python-dateutil, pycrypto, pyopenssl, plyer, potr + +# (str) Custom source folders for requirements +# Sets custom source for any requirements with recipes +# requirements.source.kivy = ../../kivy + +# (list) Garden requirements +#garden_requirements = + +# (str) Presplash of the application +presplash.filename = %(source.dir)s/media/icons/muchoslava/png/cagou_profil_bleu_512.png + +# (str) Icon of the application +icon.filename = %(source.dir)s/media/icons/muchoslava/png/cagou_profil_bleu_96.png + +# (str) Supported orientation (one of landscape, portrait or all) +orientation = portrait + +# (list) List of service to declare +#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY + +# +# OSX Specific +# + +# +# author = © Copyright Info + +# +# Android specific +# + +# (bool) Indicate if the application should be fullscreen or not +fullscreen = 1 + +# (list) Permissions +android.permissions = INTERNET, ACCESS_NETWORK_STATE, VIBRATE, RECORD_AUDIO + +# (int) Android API to use +#android.api = 19 + +# (int) Minimum API required +#android.minapi = 9 + +# (int) Android SDK version to use +#android.sdk = 20 + +# (str) Android NDK version to use +#android.ndk = 9c + +# (bool) Use --private data storage (True) or --dir public storage (False) +#android.private_storage = True + +# (str) Android NDK directory (if empty, it will be automatically downloaded.) +#android.ndk_path = + +# (str) Android SDK directory (if empty, it will be automatically downloaded.) +#android.sdk_path = + +# (str) ANT directory (if empty, it will be automatically downloaded.) +#android.ant_path = + +# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github) +# we use our own p4a and mount in root dir on docker image +android.p4a_dir = /python-for-android-old + +# (str) The directory in which python-for-android should look for your own build recipes (if any) +#p4a.local_recipes = + +# (list) python-for-android whitelist +#android.p4a_whitelist = + +# (bool) If True, then skip trying to update the Android sdk +# This can be useful to avoid excess Internet downloads or save time +# when an update is due and you just want to test/build your package +# android.skip_update = False + +# (str) Bootstrap to use for android builds (android_new only) +# android.bootstrap = sdl2 + +# (str) Android entry point, default is ok for Kivy-based app +#android.entrypoint = org.renpy.android.PythonActivity + +# (list) List of Java .jar files to add to the libs so that pyjnius can access +# their classes. Don't add jars that you do not need, since extra jars can slow +# down the build process. Allows wildcards matching, for example: +# OUYA-ODK/libs/*.jar +#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar + +# (list) List of Java files to add to the android project (can be java or a +# directory containing the files) +#android.add_src = + +# (str) python-for-android branch to use, if not master, useful to try +# not yet merged features. +#android.branch = master + +# (str) OUYA Console category. Should be one of GAME or APP +# If you leave this blank, OUYA support will not be enabled +#android.ouya.category = GAME + +# (str) Filename of OUYA Console icon. It must be a 732x412 png image. +#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png + +# (str) XML file to include as an intent filters in tag +#android.manifest.intent_filters = + +# (list) Android additionnal libraries to copy into libs/armeabi +#android.add_libs_armeabi = libs/android/*.so +#android.add_libs_armeabi_v7a = libs/android-v7/*.so +#android.add_libs_x86 = libs/android-x86/*.so +#android.add_libs_mips = libs/android-mips/*.so + +# (bool) Indicate whether the screen should stay on +# Don't forget to add the WAKE_LOCK permission if you set this to True +#android.wakelock = False + +# (list) Android application meta-data to set (key=value format) +#android.meta_data = + +# (list) Android library project to add (will be added in the +# project.properties automatically.) +#android.library_references = + +# (str) Android logcat filters to use +#android.logcat_filters = *:S python:D + +# (bool) Copy library instead of making a libpymodules.so +#android.copy_libs = 1 + +# +# iOS specific +# + +# (str) Path to a custom kivy-ios folder +#ios.kivy_ios_dir = ../kivy-ios + +# (str) Name of the certificate to use for signing the debug version +# Get a list of available identities: buildozer ios list_identities +#ios.codesign.debug = "iPhone Developer: ()" + +# (str) Name of the certificate to use for signing the release version +#ios.codesign.release = %(ios.codesign.debug)s + + +[buildozer] + +# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) +log_level = 1 + +# (int) Display warning if buildozer is run as root (0 = False, 1 = True) +warn_on_root = 1 + +# (str) Path to build artifact storage, absolute or relative to spec file +# build_dir = ./.buildozer + +# (str) Path to build output (i.e. .apk, .ipa) storage +# bin_dir = ./bin + +# ----------------------------------------------------------------------------- +# List as sections +# +# You can define all the "list" as [section:key]. +# Each line will be considered as a option to the list. +# Let's take [app] / source.exclude_patterns. +# Instead of doing: +# +#[app] +#source.exclude_patterns = license,data/audio/*.wav,data/images/original/* +# +# This can be translated into: +# +#[app:source.exclude_patterns] +#license +#data/audio/*.wav +#data/images/original/* +# + + +# ----------------------------------------------------------------------------- +# Profiles +# +# You can extend section / key with a profile +# For example, you want to deploy a demo version of your application without +# HD content. You could first change the title to add "(demo)" in the name +# and extend the excluded directories to remove the HD content. +# +#[app@demo] +#title = My Application (demo) +# +#[app:source.exclude_patterns@demo] +#images/hd/* +# +# Then, invoke the command line with the "demo" profile: +# +#buildozer --profile demo android debug diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/VERSION --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/VERSION Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,1 @@ +0.7.0D diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/__init__.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,33 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + + +class Global(object): + @property + def host(self): + return self._host +G = Global() + + +from core import cagou_main + + +def run(): + host = G._host = cagou_main.Cagou() + host.run() diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/cagou_main.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/cagou_main.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,666 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core.i18n import _ +from . import kivy_hack +kivy_hack.do_hack() +from constants import Const as C +from sat.core import log as logging +log = logging.getLogger(__name__) +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.tools import config +from sat.tools.common import dynamic_import +import kivy +kivy.require('1.9.1') +import kivy.support +main_config = config.parseMainConf() +bridge_name = config.getConfig(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 +import xmlui +from profile_manager import ProfileManager +from widgets_handler import WidgetsHandler +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 cagou_widget import CagouWidget +from . import widgets_handler +from .common import IconButton +from . import menu +from importlib import import_module +import os.path +import glob +import cagou.plugins +import cagou.kv +from kivy import utils as kivy_utils +import sys +if kivy_utils.platform == "android": + # FIXME: move to separate android module + kivy.support.install_android() + # sys.platform is "linux" on android by default + # so we change it to allow backend to detect android + sys.platform = "android" + import mmap + C.PLUGIN_EXT = 'pyo' + + +class NotifsIcon(IconButton): + notifs = properties.ListProperty() + + def on_release(self): + callback, args, kwargs = self.notifs.pop(0) + callback(*args, **kwargs) + + def addNotif(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)) + + +class NoteDrop(Note): + pass + + +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: + self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level)) + self.add_widget(self.clear_btn) + super(NotesDrop, self).open(widget) + + +class RootHeadWidget(BoxLayout): + """Notifications widget""" + manager = properties.ObjectProperty() + notifs_icon = properties.ObjectProperty() + notes = properties.ListProperty() + + def __init__(self): + super(RootHeadWidget, self).__init__() + self.notes_last = None + self.notes_event = None + self.notes_drop = NotesDrop(self.notes) + + def addNotif(self, callback, *args, **kwargs): + self.notifs_icon.addNotif(callback, *args, **kwargs) + + def addNote(self, title, message, level): + note = Note(title=title, message=message, level=level) + self.notes.append(note) + if len(self.notes) > 10: + del self.notes[:-10] + if self.notes_event is None: + self.notes_event = Clock.schedule_interval(self._displayNextNote, 5) + self._displayNextNote() + + def addNotifUI(self, ui): + self.notifs_icon.addNotif(ui.show, force=True) + + def _displayNextNote(self, dummy=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 RootMenus(menu.MenusWidget): + pass + + +class RootBody(BoxLayout): + pass + + +class CagouRootWidget(FloatLayout): + root_menus = properties.ObjectProperty() + 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 changeWidget(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 newAction(self, handler, action_data, id_, security_limit, profile): + """Add a notification for an action""" + self._head_widget.addNotif(handler, action_data, id_, security_limit, profile) + + def addNote(self, title, message, level): + self._head_widget.addNote(title, message, level) + + def addNotifUI(self, ui): + self._head_widget.addNotifUI(ui) + + +class CagouApp(App): + """Kivy App for Cagou""" + + def build(self): + return CagouRootWidget(Label(text=u"Loading please wait")) + + def showWidget(self): + self._profile_manager = ProfileManager() + self.root.changeWidget(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 on_start(self): + if sys.platform == "android": + # XXX: we use memory map 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 + # we create a memory map on .cagou_status file with a 1 byte status + # status is: + # R => running + # P => paused + # S => stopped + self._first_pause = True + self.cagou_status_fd = open('.cagou_status', 'wb+') + self.cagou_status_fd.write('R') + self.cagou_status_fd.flush() + self.cagou_status = mmap.mmap(self.cagou_status_fd.fileno(), 1, prot=mmap.PROT_WRITE) + + def on_pause(self): + self.cagou_status[0] = 'P' + return True + + def on_resume(self): + self.cagou_status[0] = 'R' + + def on_stop(self): + if sys.platform == "android": + self.cagou_status[0] = 'S' + self.cagou_status.flush() + self.cagou_status_fd.close() + + +class Cagou(QuickApp): + MB_HANDLE = False + + def __init__(self): + if bridge_name == 'embedded': + from sat.core import sat_main + self.sat = sat_main.SAT() + if sys.platform == 'android': + from android import AndroidService + service = AndroidService(u'Cagou (SàT)'.encode('utf-8'), u'Salut à Toi backend'.encode('utf-8')) + service.start(u'service started') + self.service = service + + bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') + if bridge_module is None: + log.error(u"Can't import {} bridge".format(bridge_name)) + sys.exit(3) + else: + log.debug(u"Loading {} bridge".format(bridge_name)) + 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.getConfig(main_config, '', 'media_dir') + 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._plg_wids = [] # main widgets plugins + self._plg_wids_transfer = [] # transfer widgets plugins + self._import_plugins() + self._visible_widgets = {} # visible widgets by classes + + @property + def visible_widgets(self): + for w_list in self._visible_widgets.itervalues(): + for w in w_list: + yield w + + def onBridgeConnected(self): + self.registerSignal("otrState", iface="plugin") + self.bridge.getReady(self.onBackendReady) + + def _bridgeEb(self, failure): + if bridge_name == "pb" and sys.platform == "android": + try: + self.retried += 1 + except AttributeError: + self.retried = 1 + from twisted.internet.error import ConnectionRefusedError + if failure.check(ConnectionRefusedError) and self.retried < 100: + if self.retried % 20 == 0: + log.debug("backend not ready, retrying ({})".format(self.retried)) + Clock.schedule_once(lambda dummy: self.connectBridge(), 0.05) + return + super(Cagou, self)._bridgeEb(failure) + + def run(self): + self.connectBridge() + 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 onBackendReady(self): + self.app.showWidget() + self.postInit() + + def postInit(self, dummy=None): + # FIXME: resize seem to bug 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).postInit(profile_manager) + + def _defaultFactoryMain(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.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles)) + + def _defaultFactoryTransfer(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__) + for kv_path in glob.glob(os.path.join(path, "*.kv")): + Builder.load_file(kv_path) + log.debug(u"kv file {} loaded".format(kv_path)) + + def _import_plugins(self): + """import all plugins""" + self.default_wid = None + plugins_path = os.path.dirname(cagou.plugins.__file__) + plugin_glob = u"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 u'_' not in suff: + log.error(u"invalid plugin name: {}, skipping".format(plug)) + continue + plugin_type = suff[:suff.find(u'_')] + + # and select the variable to use according to type + if plugin_type == C.PLUG_TYPE_WID: + imported_names = imported_names_main + default_factory = self._defaultFactoryMain + elif plugin_type == C.PLUG_TYPE_TRANSFER: + imported_names = imported_names_transfer + default_factory = self._defaultFactoryTransfer + else: + log.error(u"unknown plugin type {type_} for plugin {file_}, skipping".format( + type_ = plugin_type, + file_ = plug + )) + continue + plugins_set = self._getPluginsSet(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(u"{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(_(u"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(u"{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'] = u'{}.kv'.format(plug) + kv_path = os.path.join(plugins_path, plugin_info['kv_file']) + if not os.path.exists(kv_path): + log.debug(u"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 getOrCreateWidget + if 'factory' not in plugin_info: + plugin_info['factory'] = default_factory + + # icons + for size in ('small', 'medium'): + key = u'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(_(u"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 _getPluginsSet(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(u"{} plugin type is unknown".format(type_)) + + def getPluggedWidgets(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._getPluginsSet(type_) + for plugin_data in plugins_set: + if plugin_data['main'] == except_cls: + continue + yield plugin_data + + def getPluginInfo(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._getPluginsSet(type_) + for plugin_info in plugins_set: + for k, w in kwargs.iteritems(): + try: + if plugin_info[k] != w: + continue + except KeyError: + continue + return plugin_info + + ## widgets handling + + def newWidget(self, widget): + log.debug(u"new widget created: {}".format(widget)) + if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: + self.addNote(u"", _(u"room {} has been joined").format(widget.target)) + + def getParentHandler(self, widget): + """Return handler holding this widget + + @return (WidgetsHandler): handler + """ + w_handler = widget.parent + while w_handler and not(isinstance(w_handler, widgets_handler.WidgetsHandler)): + w_handler = w_handler.parent + return w_handler + + def switchWidget(self, old, new): + """Replace old widget by new one + + old(CagouWidget): CagouWidget instance or a child + new(CagouWidget): new widget instance + """ + 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(u"no CagouWidget found when trying to switch widget") + handler = self.getParentHandler(to_change) + handler.changeWidget(new) + + def addVisibleWidget(self, widget): + """declare a widget visible + + for internal use only! + """ + assert isinstance(widget, quick_widgets.QuickWidget) + self._visible_widgets.setdefault(widget.__class__, []).append(widget) + + def removeVisibleWidget(self, widget): + """declare a widget not visible anymore + + for internal use only! + """ + self._visible_widgets[widget.__class__].remove(widget) + self.widgets.deleteWidget(widget) + + def getVisibleList(self, cls): + """get list of visible widgets for a given class + + @param cls(QuickWidget class): type of widgets to get + @return (list[QuickWidget class]): visible widgets of this class + """ + try: + return self._visible_widgets[cls] + except KeyError: + return [] + + def getOrClone(self, widget): + """Get a QuickWidget if it has not parent set else clone it""" + if widget.parent is None: + return widget + targets = list(widget.targets) + w = self.widgets.getOrCreateWidget(widget.__class__, targets[0], on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=widget.profiles) + for t in targets[1:]: + w.addTarget(t) + return w + + ## menus ## + + def _menusGetCb(self, backend_menus): + main_menu = self.app.root.root_menus + self.menus.addMenus(backend_menus) + self.menus.addMenu(C.MENU_GLOBAL, (_(u"Help"), _(u"About")), callback=main_menu.onAbout) + main_menu.update(C.MENU_GLOBAL) + + ## bridge handlers ## + + def otrStateHandler(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 + for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)): + widget.onOTRState(state, dest_jid, profile) + + ## misc ## + + def plugging_profiles(self): + self.app.root.changeWidget(WidgetsHandler()) + self.bridge.menusGet("", C.NO_SECURITY_LIMIT, callback=self._menusGetCb) + + def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): + log.info(u"Profile presence status set to {show}/{status}".format(show=show, status=status)) + + def addNote(self, title, message, level=C.XMLUI_DATA_LVL_INFO): + """add a note (message which disappear) to root widget's header""" + self.app.root.addNote(title, message, level) + + def addNotifUI(self, ui): + """add a notification with a XMLUI attached + + @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected + """ + self.app.root.addNotifUI(ui) + + def showUI(self, ui): + """show a XMLUI""" + self.app.root.changeWidget(ui, "xmlui") + self.app.root.show("xmlui") + + def showExtraUI(self, widget): + """show any extra widget""" + self.app.root.changeWidget(widget, "extra") + self.app.root.show("extra") + + def closeUI(self): + self.app.root.show() + + def getDefaultAvatar(self, entity=None): + return self.app.default_avatar + + def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None): + # TODO + log.info(u"FIXME: showDialog not implemented") + log.info(u"message: {} -- {}".format(title, message)) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/cagou_widget.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/cagou_widget.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,82 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from kivy.uix.image import Image +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.dropdown import DropDown +from kivy import properties +from cagou import G + + +class HeaderWidgetChoice(ButtonBehavior, BoxLayout): + def __init__(self, cagou_widget, plugin_info): + self.plugin_info = plugin_info + super(HeaderWidgetChoice, self).__init__() + self.bind(on_release=lambda btn: cagou_widget.switchWidget(plugin_info)) + + +class HeaderWidgetCurrent(ButtonBehavior, Image): + pass + + +class HeaderWidgetSelector(DropDown): + + def __init__(self, cagou_widget): + super(HeaderWidgetSelector, self).__init__() + for plugin_info in G.host.getPluggedWidgets(except_cls=cagou_widget.__class__): + choice = HeaderWidgetChoice(cagou_widget, plugin_info) + self.add_widget(choice) + + +class CagouWidget(BoxLayout): + header_input = properties.ObjectProperty(None) + header_box = properties.ObjectProperty(None) + + def __init__(self): + for p in G.host.getPluggedWidgets(): + if p['main'] == self.__class__: + self.plugin_info = p + break + BoxLayout.__init__(self, orientation="vertical") + self.selector = HeaderWidgetSelector(self) + + def switchWidget(self, plugin_info): + self.selector.dismiss() + factory = plugin_info["factory"] + new_widget = factory(plugin_info, None, iter(G.host.profiles)) + G.host.switchWidget(self, new_widget) + + def onHeaderInput(self): + log.info(u"header input text entered") + + def onHeaderInputComplete(self, wid, text): + return + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + G.host.selected_widget = self + super(CagouWidget, self).on_touch_down(touch) + + def headerInputAddExtra(self, widget): + """add a widget on the right of header input""" + self.header_box.add_widget(widget) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/common.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/common.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,46 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""common widgets, which can be reused everywhere""" + +from kivy.uix.image import Image +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.boxlayout import BoxLayout +from cagou import G + + +class IconButton(ButtonBehavior, Image): + pass + + +class JidWidget(ButtonBehavior, BoxLayout): + + def __init__(self, jid, profile, **kwargs): + self.jid = jid + self.profile = profile + self.nick = kwargs.get('nick') + super(JidWidget, self).__init__(**kwargs) + + def getImage(self, wid): + host = G.host + if host.contact_lists[self.profile].isRoom(self.jid.bare): + wid.opacity = 0 + return "" + else: + return host.getAvatar(self.jid, profile=self.profile) or host.getDefaultAvatar(self.jid) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/config.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/config.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,27 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""This module keep an open instance of sat configuration""" + +from sat.tools import config +sat_conf = config.parseMainConf() + + +def getConfig(section, name, default): + return config.getConfig(sat_conf, section, name, default) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/constants.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/constants.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,35 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Primitivus: a SAT frontend +# Copyright (C) 2009-2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat_frontends.quick_frontend import constants + + +class Const(constants.Const): + APP_NAME = u"Cagou" + LOG_OPT_SECTION = APP_NAME.lower() + CONFIG_SECTION = APP_NAME.lower() + WID_SELECTOR = u'selector' + ICON_SIZES = (u'small', u'medium') # small = 32, medium = 44 + DEFAULT_WIDGET_ICON = u'{media}/misc/black.png' + + PLUG_TYPE_WID = u'wid' + PLUG_TYPE_TRANSFER = u'transfer' + + TRANSFER_UPLOAD = u"upload" + TRANSFER_SEND = u"send" diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/image.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/image.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,84 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from kivy.uix import image as kivy_img +from kivy.core.image import Image as CoreImage +from kivy.resources import resource_find +import io +import PIL + + +class Image(kivy_img.Image): + """Image widget which accept source without extension""" + + def texture_update(self, *largs): + if not self.source: + self.texture = None + else: + filename = resource_find(self.source) + self._loops = 0 + if filename is None: + return log.error('Image: Error reading file {filename}'. + format(filename=self.source)) + mipmap = self.mipmap + if self._coreimage is not None: + self._coreimage.unbind(on_texture=self._on_tex_change) + try: + self._coreimage = ci = CoreImage(filename, mipmap=mipmap, + anim_delay=self.anim_delay, + keep_data=self.keep_data, + nocache=self.nocache) + except Exception as e: + # loading failed probably because of unmanaged extention, + # we try our luck with with PIL + try: + im = PIL.Image.open(filename) + ext = im.format.lower() + del im + # we can't use im.tobytes as it would use the + # internal decompressed representation from pillow + # and im.save would need processing to handle format + data = io.BytesIO(open(filename, "rb").read()) + cache_filename = u"{}.{}".format(filename,ext) # needed for kivy's Image to use cache + self._coreimage = ci = CoreImage(data, ext=ext, + filename=cache_filename, mipmap=mipmap, + anim_delay=self.anim_delay, + keep_data=self.keep_data, + nocache=self.nocache) + except Exception as e: + log.warning(u"Can't load image: {}".format(e)) + self._coreimage = ci = None + + if ci: + ci.bind(on_texture=self._on_tex_change) + self.texture = ci.texture + + +class AsyncImage(kivy_img.AsyncImage): + """AsyncImage which accept file:// schema""" + + def _load_source(self, *args): + if self.source.startswith('file://'): + self.source = self.source[7:] + else: + super(AsyncImage, self)._load_source(*args) + diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/kivy_hack.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/kivy_hack.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,70 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +CONF_KIVY_LEVEL = 'log_kivy_level' + + +def do_hack(): + """work around Kivy hijacking of logs and arguments""" + # we remove args so kivy doesn't use them + # this is need to avoid kivy breaking QuickApp args handling + import sys + ori_argv = sys.argv[:] + sys.argv = sys.argv[:1] + from constants import Const as C + from sat.core import log_config + log_config.satConfigure(C.LOG_BACKEND_STANDARD, C) + + import config + kivy_level = config.getConfig(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 dummy: 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(u"Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level)) + + # during import kivy set its logging stuff + import kivy + kivy # to avoid pyflakes warning + + # we want to separate kivy logs from other logs + logging.root = root_logger + from kivy import logger + sys.stderr = logger.previous_stderr + + # we restore original methods + kivy_logger.addHandler = ori_addHandler + kivy_logger.setLevel = ori_setLevel + + # we restore original arguments + sys.argv = ori_argv diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/menu.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/menu.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,239 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core.i18n import _ +from sat.core import log as logging +log = logging.getLogger(__name__) +from cagou.core.constants import Const as C +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label +from kivy.uix.popup import Popup +from kivy import properties +from kivy.garden import contextmenu +from sat_frontends.quick_frontend import quick_menus +from cagou import G +import webbrowser + +ABOUT_TITLE = _(u"About {}".format(C.APP_NAME)) +ABOUT_CONTENT = _(u"""Cagou (Salut à Toi) v{} + +Cagou is a libre communication tool based on libre standard XMPP. + +Cagou is part of the "Salut à Toi" project +more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color] +""").format(C.APP_VERSION) + + +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 MainMenu(contextmenu.AppMenu): + pass + + +class MenuItem(contextmenu.ContextMenuTextItem): + item = properties.ObjectProperty() + + def on_item(self, instance, item): + self.text = item.name + + def on_release(self): + super(MenuItem, self).on_release() + self.parent.hide() + selected = G.host.selected_widget + profile = None + if selected is not None: + try: + profile = selected.profile + except AttributeError: + pass + + if profile is None: + try: + profile = list(selected.profiles)[0] + except (AttributeError, IndexError): + try: + profile = list(G.host.profiles)[0] + except IndexError: + log.warning(u"Can't find profile") + self.item.call(selected, profile) + + +class MenuSeparator(contextmenu.ContextMenuDivider): + pass + + +class RootMenuContainer(contextmenu.AppMenuTextItem): + pass + + +class MenuContainer(contextmenu.ContextMenuTextItem): + pass + + +class MenusWidget(BoxLayout): + + def update(self, type_, caller=None): + """Method to call when menus have changed + + @param type_(unicode): menu type like in sat.core.sat_main.importMenu + @param caller(Widget): instance linked to the menus + """ + self.menus_container = G.host.menus.getMainContainer(type_) + self.createMenus(caller) + + def _buildMenus(self, container, caller=None): + """Recursively build menus of the container + + @param container(quick_menus.MenuContainer): menu container + @param caller(Widget): instance linked to the menus + """ + if caller is None: + main_menu = MainMenu() + self.add_widget(main_menu) + caller = main_menu + else: + context_menu = contextmenu.ContextMenu() + caller.add_widget(context_menu) + # FIXME: next line is needed after parent is set to avoid a display bug in contextmenu + # TODO: fix this upstream + context_menu._on_visible(False) + + caller = context_menu + + for child in container.getActiveMenus(): + if isinstance(child, quick_menus.MenuContainer): + if isinstance(caller, MainMenu): + menu_container = RootMenuContainer() + else: + menu_container = MenuContainer() + menu_container.text = child.name + caller.add_widget(menu_container) + self._buildMenus(child, caller=menu_container) + elif isinstance(child, quick_menus.MenuSeparator): + wid = MenuSeparator() + caller.add_widget(wid) + elif isinstance(child, quick_menus.MenuItem): + wid = MenuItem(item=child) + caller.add_widget(wid) + else: + log.error(u"Unknown child type: {}".format(child)) + + def createMenus(self, caller): + self.clear_widgets() + self._buildMenus(self.menus_container, caller) + + def onAbout(self): + about = AboutPopup() + about.title = ABOUT_TITLE + about.content = AboutContent(text=ABOUT_CONTENT, markup=True) + about.open() + + +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 TransferMenu(BoxLayout): + """transfer menu which handle display and callbacks""" + # callback will be called with path to file to transfer + callback = properties.ObjectProperty() + # cancel callback need to remove the widget for UI + # will be called with the widget to remove as argument + cancel_cb = properties.ObjectProperty() + # profiles if set will be sent to transfer widget, may be used to get specific files + profiles = properties.ObjectProperty() + transfer_txt = _(u"Beware! The file will be sent to your server and stay unencrypted there\nServer admin(s) can see the file, and they choose how, when and if it will be deleted") + send_txt = _(u"The file will be sent unencrypted directly to your contact (without transiting by the server), except in some cases") + items_layout = properties.ObjectProperty() + + def __init__(self, **kwargs): + super(TransferMenu, self).__init__(**kwargs) + if self.cancel_cb is None: + self.cancel_cb = self.onTransferCancelled + if self.profiles is None: + self.profiles = iter(G.host.profiles) + for plug_info in G.host.getPluggedWidgets(type_=C.PLUG_TYPE_TRANSFER): + item = TransferItem( + plug_info = plug_info + ) + self.items_layout.add_widget(item) + + def show(self, caller_wid=None): + self.visible = True + G.host.app.root.add_widget(self) + + 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.parent.remove_widget(self) + else: + return super(TransferMenu, self).on_touch_down(touch) + return True + + def _closeUI(self, wid): + G.host.closeUI() + + def onTransferCancelled(self, wid, cleaning_cb=None): + self._closeUI(wid) + if cleaning_cb is not None: + cleaning_cb() + + def do_callback(self, plug_info): + self.parent.remove_widget(self) + if self.callback is None: + log.warning(u"TransferMenu callback is not set") + else: + wid = None + external = plug_info.get('external', False) + def onTransferCb(file_path, cleaning_cb=None): + if not external: + self._closeUI(wid) + self.callback( + file_path, + cleaning_cb, + transfer_type = C.TRANSFER_UPLOAD if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND) + wid = plug_info['factory'](plug_info, onTransferCb, self.cancel_cb, self.profiles) + if not external: + G.host.showExtraUI(wid) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/profile_manager.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/profile_manager.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,179 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from .constants import Const as C +from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager +from kivy.uix.boxlayout import BoxLayout +from kivy.uix import listview +from kivy.uix.button import Button +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.adapters import listadapter +from kivy.metrics import sp +from kivy import properties +from cagou import G + + +class ProfileItem(listview.ListItemButton): + pass + + +class ProfileListAdapter(listadapter.ListAdapter): + + def __init__(self, pm, *args, **kwargs): + super(ProfileListAdapter, self).__init__(*args, **kwargs) + self.pm = pm + + def closeUI(self, xmlui): + self.pm.screen_manager.transition.direction = 'right' + self.pm.screen_manager.current = 'profiles' + + def showUI(self, xmlui): + xmlui.setCloseCb(self.closeUI) + if xmlui.type == 'popup': + xmlui.bind(on_touch_up=lambda obj, value: self.closeUI(xmlui)) + self.pm.xmlui_screen.clear_widgets() + self.pm.xmlui_screen.add_widget(xmlui) + self.pm.screen_manager.transition.direction = 'left' + self.pm.screen_manager.current = 'xmlui' + + def select_item_view(self, view): + def authenticate_cb(data, cb_id, profile): + if C.bool(data.pop('validated', C.BOOL_FALSE)): + super(ProfileListAdapter, self).select_item_view(view) + G.host.actionManager(data, callback=authenticate_cb, ui_show_cb=self.showUI, profile=profile) + + G.host.launchAction(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=view.text) + + +class ConnectButton(Button): + + def __init__(self, profile_screen): + self.profile_screen = profile_screen + self.pm = profile_screen.pm + super(ConnectButton, self).__init__() + + +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=u'new_profile') + self.pm = pm + + def onCreationFailure(self, failure): + msg = [l for l in unicode(failure).split('\n') if l][-1] + self.error_msg = unicode(msg) + + def onCreationSuccess(self, profile): + self.pm.profiles_screen.reload() + G.host.bridge.profileStartSession(self.password.text, profile, callback=lambda dummy: self._sessionStarted(profile), errback=self.onCreationFailure) + + def _sessionStarted(self, profile): + jid = self.jid.text.strip() + G.host.bridge.setParam("JabberID", jid, "Connection", -1, profile) + G.host.bridge.setParam("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.asyncCreateProfile(name, self.password.text, callback=lambda: self.onCreationSuccess(name), errback=self.onCreationFailure) + + +class DeleteProfilesScreen(Screen): + + def __init__(self, pm): + self.pm = pm + super(DeleteProfilesScreen, self).__init__(name=u'delete_profiles') + + def doDelete(self): + """This method will delete *ALL* selected profiles""" + to_delete = self.pm.getProfiles() + deleted = [0] + + def deleteInc(): + 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(u"Deleteing profile [{}]".format(profile)) + G.host.bridge.asyncDeleteProfile(profile, callback=deleteInc, errback=deleteInc) + + +class ProfilesScreen(Screen): + layout = properties.ObjectProperty(None) + + def __init__(self, pm): + self.pm = pm + self.list_adapter = ProfileListAdapter(pm, + data=[], + cls=ProfileItem, + args_converter=self.converter, + selection_mode='multiple', + allow_empty_selection=True, + ) + super(ProfilesScreen, self).__init__(name=u'profiles') + self.layout.add_widget(listview.ListView(adapter=self.list_adapter)) + connect_btn = ConnectButton(self) + self.layout.add_widget(connect_btn) + self.reload() + + def _profilesListGetCb(self, profiles): + profiles.sort() + self.list_adapter.data = profiles + + def converter(self, row_idx, obj): + return {'text': obj, + 'size_hint_y': None, + 'height': sp(40)} + + def reload(self): + """Reload profiles list""" + G.host.bridge.profilesListGet(callback=self._profilesListGetCb) + + +class ProfileManager(QuickProfileManager, BoxLayout): + + 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=u'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 getProfiles(self): + return [pi.text for pi in self.profiles_screen.list_adapter.selection] diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/simple_xhtml.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/simple_xhtml.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,498 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from kivy.uix.stacklayout import StackLayout +from kivy.uix.label import Label +from kivy.utils import escape_markup +from kivy import properties +from xml.etree import ElementTree as ET +from sat_frontends.tools import css_color, strings as sat_strings +from cagou.core.image import AsyncImage +import webbrowser + + +class Escape(unicode): + """Class used to mark that a message need to be escaped""" + + def __init__(self, text): + super(Escape, self).__init__(text) + + +class SimpleXHTMLWidgetEscapedText(Label): + + def _addUrlMarkup(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 = u'link_' + unicode(links) + url = m.group() + text_elts.append(u'[color=5500ff][ref={link}]{url}[/ref][/color]'.format( + link = link_key, + url = url + )) + 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 = u''.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._addUrlMarkup(text) + + def on_ref_press(self, ref): + url = self.ref_urls[ref] + webbrowser.open(url) + + +class SimpleXHTMLWidgetText(Label): + pass + + +class SimpleXHTMLWidgetImage(AsyncImage): + # following properties are desired height/width + # i.e. the ones specified in height/width attributes of + # (or wanted for whatever reason) + # set to 0 to ignore them + target_height = properties.NumericProperty() + target_width = properties.NumericProperty() + + def _get_parent_container(self): + """get parent SimpleXHTMLWidget instance + + @param warning(bool): if True display a log.error if nothing found + @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance + """ + parent = self.parent + while parent and not isinstance(parent, SimpleXHTMLWidget): + parent = parent.parent + if parent is None: + log.error(u"no SimpleXHTMLWidget parent found") + return parent + + def _on_source_load(self, value): + # this method is called when image is loaded + super(SimpleXHTMLWidgetImage, self)._on_source_load(value) + if self.parent is not None: + container = self._get_parent_container() + # image is loaded, we need to recalculate size + self.on_container_width(container, container.width) + + def on_container_width(self, container, container_width): + """adapt size according to container width + + called when parent container (SimpleXHTMLWidget) width change + """ + target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height) + padding = container.padding + padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0] + width = container_width - padding_h + if target_size[0] < width: + self.size = target_size + else: + height = width / self.image_ratio + self.size = (width, height) + + def on_parent(self, instance, parent): + if parent is not None: + container = self._get_parent_container() + container.bind(width=self.on_container_width) + + +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) + content_width = properties.NumericProperty(0) + + # 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 not markup + will be used + """ + self.clear_widgets() + if isinstance(xhtml, Escape): + label = SimpleXHTMLWidgetEscapedText(text=xhtml, color=self.color) + self.bind(color=label.setter('color')) + self.bind(bold=label.setter('bold')) + self.add_widget(label) + else: + xhtml = ET.fromstring(xhtml.encode('utf-8')) + self.current_wid = None + self.styles = [] + self._callParseMethod(xhtml) + + def escape(self, text): + """mark that a text need to be escaped (i.e. no markup)""" + return Escape(text) + + # sizing + + def on_width(self, instance, width): + if len(self.children) == 1: + wid = self.children[0] + if isinstance(wid, Label): + # we have simple text + try: + full_width = wid._full_width + except AttributeError: + # on first time, we need the required size + # for the full text, without width limit + wid.size_hint = (None, None) + wid.texture_update() + full_width = wid._full_width = wid.texture_size[0] + + if full_width > width: + wid.text_size = width, None + wid.width = width + else: + wid.text_size = None, None + wid.texture_update() + wid.width = wid.texture_size[0] + self.content_width = wid.width + self.padding[0] + self.padding[2] + else: + wid.size_hint = (1, None) + wid.height = 100 + self.content_width = self.width + else: + self._do_complexe_sizing(width) + + def _do_complexe_sizing(self, width): + try: + self.splitted + except AttributeError: + # XXX: to make things easier, we split labels in words + log.debug(u"split start") + children = self.children[::-1] + self.clear_widgets() + for child in children: + if isinstance(child, Label): + log.debug(u"label before split: {}".format(child.text)) + styles = [] + tag = False + new_text = [] + current_tag = [] + current_value = [] + current_wid = self._createText() + 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 == u']': + current_tag_s = u''.join(current_tag) + current_style = (current_tag_s, u''.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 == u'/': + close = True + elif c == u'=': + 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 == u'[': + new_text.append(c) + tag = True + elif c == u' ': + # new word, we do a new widget + new_text.append(u' ') + for t, v in reversed(styles): + new_text.append(u'[/{}]'.format(t)) + current_wid.text = u''.join(new_text) + new_text = [] + self.add_widget(current_wid) + log.debug(u"new widget: {}".format(current_wid.text)) + current_wid = self._createText() + for t, v in styles: + new_text.append(u'[{tag}{value}]'.format( + tag = t, + value = u'={}'.format(v) if v else u'')) + 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(u'[/{}]'.format(t)) + current_wid.text = u''.join(close_styles) + self.add_widget(current_wid) + log.debug(u"new widget: {}".format(current_wid.text)) + else: + # non Label widgets, we just add them + self.add_widget(child) + self.splitted = True + log.debug(u"split OK") + + # we now set the content width + # FIXME: for now we just use the full width + self.content_width = width + + # XHTML parsing methods + + def _callParseMethod(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, "xhtml_{}".format(e.tag)) + except AttributeError: + log.warning(u"Unhandled XHTML tag: {}".format(e.tag)) + method = self.xhtml_generic + method(e) + + def _addStyle(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._getLabel() + label.text += u'[{tag}{value}]'.format( + tag = tag, + value = u'={}'.format(value) if value else '' + ) + if append_to_list: + self.styles.append((tag, value)) + + def _removeStyle(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._getLabel() + label.text += u'[/{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 _getLabel(self): + """get current Label if it exists, or create a new one""" + if not isinstance(self.current_wid, Label): + self._addLabel() + return self.current_wid + + def _addLabel(self): + """add a new Label + + current styles will be closed and reopened if needed + """ + self._closeLabel() + self.current_wid = self._createText() + for tag, value in self.styles: + self._addStyle(tag, value, append_to_list=False) + self.add_widget(self.current_wid) + + def _createText(self): + label = SimpleXHTMLWidgetText(color=self.color, markup=True) + self.bind(color=label.setter('color')) + label.bind(texture_size=label.setter('size')) + return label + + def _closeLabel(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._removeStyle(tag, remove_from_list=False) + + def _parseCSS(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(u';') + for style in styles: + try: + prop, value = style.split(u':') + except ValueError: + log.warning(u"can't parse style: {}".format(style)) + continue + prop = prop.strip().replace(u'-', '_') + value = value.strip() + try: + method = getattr(self, "css_{}".format(prop)) + except AttributeError: + log.warning(u"Unhandled CSS: {}".format(prop)) + else: + method(e, value) + self._css_styles = self.styles[styles_limit:] + + def _closeCSS(self): + """removed CSS styles + + styles in self._css_styles will be removed + and the attribute will be deleted + """ + for tag, dummy in reversed(self._css_styles): + self._removeStyle(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, basestring): + tag, value = markup, None + else: + tag, value = markup + self._addStyle(tag, value) + style_ = 'style' in elem.attrib and style + if style_: + self._parseCSS(elem) + + # then content + if elem.text: + self._getLabel().text += escape_markup(elem.text) + + # we parse the children + for child in elem: + self._callParseMethod(child) + + # closing CSS style and markup + if style_: + self._closeCSS() + if markup is not None: + self._removeStyle(tag) + + # and the tail, which is regular text + if elem.tail: + self._getLabel().text += escape_markup(elem.tail) + + # method handling XHTML elements + + def xhtml_br(self, elem): + label = self._getLabel() + label.text+='\n' + self.xhtml_generic(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(u" element without src: {}".format(ET.tostring(elem))) + return + try: + target_height = int(elem.get(u'height', 0)) + except ValueError: + log.warning(u"Can't parse image height: {}".format(elem.get(u'height'))) + target_height = 0 + try: + target_width = int(elem.get(u'width', 0)) + except ValueError: + log.warning(u"Can't parse image width: {}".format(elem.get(u'width'))) + target_width = 0 + + img = SimpleXHTMLWidgetImage(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._addStyle(u"color", css_color.parse(value)) + + def css_text_decoration(self, elem, value): + if value == u'underline': + log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) + # FIXME: activate when 1.9.2 is out + # self._addStyle('u') + elif value == u'line-through': + log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) + # FIXME: activate when 1.9.2 is out + # self._addStyle('s') + else: + log.warning(u"unhandled text decoration: {}".format(value)) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/widgets_handler.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/widgets_handler.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,230 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat_frontends.quick_frontend import quick_widgets +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.button import Button +from kivy.uix.carousel import Carousel +from kivy.metrics import dp +from kivy import properties +from cagou import G + + +CAROUSEL_SCROLL_DISTANCE = dp(50) +CAROUSEL_SCROLL_TIMEOUT = 80 +NEW_WIDGET_DIST = 10 +REMOVE_WIDGET_DIST = NEW_WIDGET_DIST + + +class WHSplitter(Button): + horizontal=properties.BooleanProperty(True) + thickness=properties.NumericProperty(dp(20)) + split_move = None # we handle one split at a time, so we use a class attribute + + def __init__(self, handler, **kwargs): + super(WHSplitter, self).__init__(**kwargs) + self.handler = handler + + def getPos(self, touch): + if self.horizontal: + relative_y = self.handler.to_local(*touch.pos, relative=True)[1] + return self.handler.height - relative_y + else: + return touch.x + + def on_touch_move(self, touch): + if self.split_move is None and self.collide_point(*touch.opos): + WHSplitter.split_move = self + + if self.split_move is self: + pos = self.getPos(touch) + if pos > NEW_WIDGET_DIST: + # we are above minimal distance, we resize the widget + self.handler.setWidgetSize(self.horizontal, pos) + + def on_touch_up(self, touch): + if self.split_move is self: + pos = self.getPos(touch) + if pos <= REMOVE_WIDGET_DIST: + # if we go under minimal distance, the widget is not wanted anymore + self.handler.removeWidget(self.horizontal) + WHSplitter.split_move=None + return super(WHSplitter, self).on_touch_up(touch) + + +class HandlerCarousel(Carousel): + + def __init__(self, *args, **kwargs): + super(HandlerCarousel, self).__init__( + *args, + direction='right', + loop=True, + **kwargs) + self._former_slide = None + self.bind(current_slide=self.onSlideChange) + self._slides_update_lock = False + + def changeWidget(self, new_widget): + """Change currently displayed widget + + slides widgets will be updated + """ + # slides update need to be blocked to avoid the update in onSlideChange + # which would mess the removal of current widgets + self._slides_update_lock = True + current = self.current_slide + for w in self.slides: + if w == current or w == new_widget: + continue + if isinstance(w, quick_widgets.QuickWidget): + G.host.widgets.deleteWidget(w) + self.clear_widgets() + self.add_widget(new_widget) + self._slides_update_lock = False + self.updateHiddenSlides() + + def onSlideChange(self, handler, new_slide): + if isinstance(self._former_slide, quick_widgets.QuickWidget): + G.host.removeVisibleWidget(self._former_slide) + self._former_slide = new_slide + if isinstance(new_slide, quick_widgets.QuickWidget): + G.host.addVisibleWidget(new_slide) + self.updateHiddenSlides() + + def hiddenList(self, visible_list): + """return widgets of same class as holded one which are hidden + + @param visible_list(list[QuickWidget]): widgets visible + @return (iter[QuickWidget]): widgets hidden + """ + added = [(w.targets, w.profiles) for w in visible_list] # we want to avoid recreated widgets + for w in G.host.widgets.getWidgets(self.current_slide.__class__, profiles=self.current_slide.profiles): + if w in visible_list or (w.targets, w.profiles) in added: + continue + yield w + + def widgets_sort(self, widget): + """method used as key to sort the widgets + + order of the widgets when changing slide is affected + @param widget(QuickWidget): widget to sort + @return: a value which will be used for sorting + """ + try: + return unicode(widget.target).lower() + except AttributeError: + return unicode(list(widget.targets)[0]).lower() + + def updateHiddenSlides(self): + """adjust carousel slides according to visible widgets""" + if self._slides_update_lock: + return + if not isinstance(self.current_slide, quick_widgets.QuickWidget): + return + # lock must be used here to avoid recursions + self._slides_update_lock = True + visible_list = G.host.getVisibleList(self.current_slide.__class__) + hidden = list(self.hiddenList(visible_list)) + slides_sorted = sorted(hidden + [self.current_slide], key=self.widgets_sort) + to_remove = set(self.slides).difference({self.current_slide}) + for w in to_remove: + self.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(self.current_slide) + try: + next_slide = slides_sorted[current_idx+1] + except IndexError: + next_slide = slides_sorted[0] + self.add_widget(G.host.getOrClone(next_slide)) + if len(hidden)>1: + previous_slide = slides_sorted[current_idx-1] + self.add_widget(G.host.getOrClone(previous_slide)) + + if len(self.slides) == 1: + # we block carousel with high scroll_distance to avoid swiping + # when the is not other instance of the widget + self.scroll_distance=2**32 + self.scroll_timeout=0 + else: + self.scroll_distance = CAROUSEL_SCROLL_DISTANCE + self.scroll_timeout=CAROUSEL_SCROLL_TIMEOUT + self._slides_update_lock = False + + +class WidgetsHandler(BoxLayout): + + def __init__(self, wid=None, **kw): + if wid is None: + wid=self.default_widget + self.vert_wid = self.hor_wid = None + BoxLayout.__init__(self, orientation="vertical", **kw) + self.blh = BoxLayout(orientation="horizontal") + self.blv = BoxLayout(orientation="vertical") + self.blv.add_widget(WHSplitter(self)) + self.carousel = HandlerCarousel() + self.blv.add_widget(self.carousel) + self.blh.add_widget(WHSplitter(self, horizontal=False)) + self.blh.add_widget(self.blv) + self.add_widget(self.blh) + self.changeWidget(wid) + + @property + def default_widget(self): + return G.host.default_wid['factory'](G.host.default_wid, None, None) + + @property + def cagou_widget(self): + """get holded CagouWidget""" + return self.carousel.current_slide + + def changeWidget(self, new_widget): + self.carousel.changeWidget(new_widget) + + def removeWidget(self, vertical): + if vertical and self.vert_wid is not None: + self.remove_widget(self.vert_wid) + self.vert_wid.onDelete() + self.vert_wid = None + elif self.hor_wid is not None: + self.blh.remove_widget(self.hor_wid) + self.hor_wid.onDelete() + self.hor_wid = None + + def setWidgetSize(self, vertical, size): + if vertical: + if self.vert_wid is None: + self.vert_wid = WidgetsHandler(self.default_widget, size_hint=(1, None)) + self.add_widget(self.vert_wid, len(self.children)) + self.vert_wid.height=size + else: + if self.hor_wid is None: + self.hor_wid = WidgetsHandler(self.default_widget, size_hint=(None, 1)) + self.blh.add_widget(self.hor_wid, len(self.blh.children)) + self.hor_wid.width=size + + def onDelete(self): + # when this handler is deleted, we need to delete the holded CagouWidget + cagou_widget = self.cagou_widget + if isinstance(cagou_widget, quick_widgets.QuickWidget): + G.host.removeVisibleWidget(cagou_widget) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/core/xmlui.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/xmlui.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,561 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: a SàT frontend +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat.core.i18n import _ +from .constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +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.dropdown import DropDown +from kivy.uix.switch import Switch +from kivy import properties +from cagou import G + + +## Widgets ## + + +class TextInputOnChange(object): + + def __init__(self): + self._xmlui_onchange_cb = None + self._got_focus = False + + def _xmluiOnChange(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): + log.debug("on_text: %s" % 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, multiline=False) + TextInputOnChange.__init__(self) + self.readonly = read_only + + def _xmluiSetValue(self, value): + self.text = value + + def _xmluiGetValue(self): + return self.text + + +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 _xmluiOnClick(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): + super(ListWidgetItem, self).on_release() + parent = self.parent + while parent is not None and not isinstance(parent, DropDown): + parent = parent.parent + + if parent is not None and parent.attach_to is not None: + parent.select(self) + + @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, Button): + + def __init__(self, _xmlui_parent, options, selected, flags): + Button.__init__(self) + self.text = _(u"open list") + self._dropdown = DropDown() + self._dropdown.auto_dismiss = False + self._dropdown.bind(on_select = self.on_select) + self.multi = 'single' not in flags + self._dropdown.dismiss_on_select = not self.multi + self._values = [] + for option in options: + self.addValue(option) + self._xmluiSelectValues(selected) + self._on_change = None + + @property + def items(self): + return self._dropdown.children[0].children + + def on_touch_down(self, touch): + # we simulate auto-dismiss ourself because dropdown + # will dismiss even if attached button is touched + # resulting in a dismiss just before a toggle in on_release + # so the dropbox would always be opened, we don't want that! + if super(ListWidget, self).on_touch_down(touch): + return True + if self._dropdown.parent: + self._dropdown.dismiss() + + def on_release(self): + if self._dropdown.parent is not None: + # we want to close a list already opened + self._dropdown.dismiss() + else: + self._dropdown.open(self) + + def on_select(self, drop_down, item): + if not self.multi: + self._xmluiSelectValues([item.value]) + if self._on_change is not None: + self._on_change(self) + + def addValue(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._dropdown.add_widget(item) + + def _xmluiSelectValue(self, value): + self._xmluiSelectValues([value]) + + def _xmluiSelectValues(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 _xmluiGetSelectedValues(self): + return [item.value for item in self.items if item.selected] + + def _xmluiAddValues(self, values, select=True): + values = set(values).difference([c.value for c in self.items]) + for v in values: + self.addValue(v, select) + + def _xmluiOnChange(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 _xmluiSetValue(self, value): + self.text = value + + def _xmluiGetValue(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 _xmluiSetValue(self, value): + self.active = value + + def _xmluiGetValue(self): + return C.BOOL_TRUE if self.active else C.BOOL_FALSE + + def _xmluiOnChange(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 _xmluiSetValue(self, value): + self.text = value + + def _xmluiGetValue(self): + return self.text + + +## Containers ## + + +class VerticalContainer(xmlui.VerticalContainer, GridLayout): + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + GridLayout.__init__(self) + + def _xmluiAppend(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 _xmluiAppend(self, widget): + self.add_widget(widget) + + +class LabelContainer(PairsContainer, xmlui.LabelContainer): + pass + + +class TabsPanelContainer(TabbedPanelItem): + + def _xmluiAppend(self, widget): + self.add_widget(widget) + + +class TabsContainer(xmlui.TabsContainer, TabbedPanel): + + def __init__(self, xmlui_parent): + self.xmlui_parent = xmlui_parent + xmlui_panel = xmlui_parent + while not isinstance(xmlui_panel, XMLUIPanel): + xmlui_panel = xmlui_panel.xmlui_parent + xmlui_panel.addPostTreat(self._postTreat) + TabbedPanel.__init__(self, do_default_tab=False) + + def _xmluiAddTab(self, label, selected): + tab = TabsPanelContainer(text=label) + self.add_widget(tab) + return tab + + def _postTreat(self): + """bind minimum height of tabs' content so self.height is adapted""" + # we need to do this in postTreat because contents exists after UI construction + for t in self.tab_list: + t.content.bind(minimum_height=self._updateHeight) + + def _updateHeight(self, instance, height): + """Called after UI is constructed (so height can be calculated)""" + # needed because TabbedPanel doesn't have a minimum_height property + self.height = max([t.content.minimum_height for t in self.tab_list]) + self.tab_height + 5 + + +class AdvancedListRow(GridLayout): + 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(u"Can't find parent AdvancedListContainer") + else: + if parent.selectable: + self.selected = parent._xmluiToggleSelected(self) + + return super(AdvancedListRow, self).on_touch_down(touch) + + +class AdvancedListContainer(xmlui.AdvancedListContainer, GridLayout): + + def __init__(self, xmlui_parent, columns, selectable='no'): + self.xmlui_parent = xmlui_parent + GridLayout.__init__(self) + self._columns = columns + self.selectable = selectable != 'no' + self._current_row = None + self._selected = [] + self._xmlui_select_cb = None + + def _xmluiToggleSelected(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 _xmluiAppend(self, widget): + if self._current_row is None: + log.error(u"No row set, ignoring append") + return + self._current_row.add_widget(widget) + + def _xmluiAddRow(self, idx): + self._current_row = AdvancedListRow() + self._current_row.cols = self._columns + self._current_row.index = idx + self.add_widget(self._current_row) + + def _xmluiGetSelectedWidgets(self): + return self._selected + + def _xmluiGetSelectedIndex(self): + if not self._selected: + return None + return self._selected[0].index + + def _xmluiOnSelect(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 _xmluiShow(self): + G.host.addNote(self.title, self.message, self.level) + + +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 _xmluiShow(self): + G.host.addNotifUI(self) + + def _xmluiClose(self): + # FIXME: notif UI is not removed if dialog is not shown yet + G.host.closeUI() + + def onSelect(self, path): + try: + path = path[0] + except IndexError: + path = None + if not path: + self._xmluiCancelled() + else: + self._xmluiValidated({'path': path}) + + def show(self, *args, **kwargs): + assert kwargs["force"] + G.host.showUI(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 XMLUIPanelGrid(GridLayout): + pass + +class XMLUIPanel(xmlui.XMLUIPanel, ScrollView): + widget_factory = WidgetFactory() + + def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, ignore=None, profile=C.PROF_KEY_NONE): + ScrollView.__init__(self) + self.close_cb = None + self._grid = XMLUIPanelGrid() + self._post_treats = [] # list of callback to call after UI is constructed + ScrollView.add_widget(self, self._grid) + xmlui.XMLUIPanel.__init__(self, + host, + parsed_xml, + title=title, + flags=flags, + callback=callback, + ignore=ignore, + profile=profile) + + def add_widget(self, wid): + self._grid.add_widget(wid) + + def setCloseCb(self, close_cb): + self.close_cb = close_cb + + def _xmluiClose(self): + if self.close_cb is not None: + self.close_cb(self) + else: + G.host.closeUI() + + def onParamChange(self, ctrl): + super(XMLUIPanel, self).onParamChange(ctrl) + self.save_btn.disabled = False + + def addPostTreat(self, callback): + self._post_treats.append(callback) + + def _postTreatCb(self): + for cb in self._post_treats: + cb() + del self._post_treats + + def _saveButtonCb(self, button): + button.disabled = True + self.onSaveParams(button) + + def constructUI(self, parsed_dom): + xmlui.XMLUIPanel.constructUI(self, parsed_dom, self._postTreatCb) + if self.xmlui_title: + self.add_widget(Title(text=self.xmlui_title)) + self.add_widget(self.main_cont) + if self.type == 'form': + submit_btn = FormButton(text=_(u"Submit")) + submit_btn.bind(on_press=self.onFormSubmitted) + self.add_widget(submit_btn) + if not 'NO_CANCEL' in self.flags: + cancel_btn = FormButton(text=_(u"Cancel")) + cancel_btn.bind(on_press=self.onFormCancelled) + self.add_widget(cancel_btn) + elif self.type == 'param': + self.save_btn = FormButton(text=_(u"Save"), disabled=True) + self.save_btn.bind(on_press=self._saveButtonCb) + self.add_widget(self.save_btn) + self.add_widget(Widget()) # to have elements on the top + + def show(self, *args, **kwargs): + if not self.user_action and not kwargs.get("force", False): + G.host.addNotifUI(self) + else: + G.host.showUI(self) + + +class XMLUIDialog(xmlui.XMLUIDialog): + dialog_factory = WidgetFactory() + + +xmlui.registerClass(xmlui.CLASS_PANEL, XMLUIPanel) +xmlui.registerClass(xmlui.CLASS_DIALOG, XMLUIDialog) +create = xmlui.create diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/images/button.png Binary file cagou/images/button.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/images/button_selected.png Binary file cagou/images/button_selected.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/cagou_widget.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/cagou_widget.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,68 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + + +: + 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) + Image: + size_hint: None, 1 + source: root.plugin_info['icon_medium'] + allow_stretch: True + width: self.texture_size[0]*self.height/(self.texture_size[1] or 1) + Label: + size_hint: 1, 1 + text: root.plugin_info['name'] + bold: True + text_size: self.size + halign: "center" + valign: "middle" + +: + size_hint: 0.3, None + auto_width: False + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + +: + header_input: header_input + header_box: header_box + BoxLayout: + id: header_box + size_hint: 1, None + height: dp(32) + HeaderWidgetCurrent: + on_release: root.selector.open(self) + source: root.plugin_info['icon_small'] + size_hint: None, 1 + allow_stretch: True + width: self.texture_size[0]*self.height/(self.texture_size[1] or 1) + TextInput: + id: header_input + multiline: False + on_text_validate: root.onHeaderInput() + on_text: root.onHeaderInputComplete(*args) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/common.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/common.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,37 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + + +: + size_hint: None, None + height: dp(70) + canvas.before: + Color: + rgba: 0.2, 0.2, 0.2, 1 + Rectangle: + pos: self.pos + size: self.size + Image: + source: root.getImage(self) + size_hint: None, None + size: dp(64), dp(64) + Label: + bold: True + text: root.jid + text_size: self.size + halign: 'left' + valign: 'middle' + padding_x: dp(20) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/menu.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/menu.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,88 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +#:import _ sat.core.i18n._ + +: + text_size: self.size + halign: "center" + valign: "middle" + +: + title_align: "center" + size_hint: 0.8, 0.8 + +: + # following is need to fix a bug in contextmenu + size_hint: 1, None + +: + height: self.children[0].height if self.children else 30 + +: + cancel_handler_widget: self.parent + +: + items_layout: items_layout + orientation: "vertical" + pos_hint: {"top": 0.5} + size_hint: 1, 0.5 + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + BoxLayout: + size_hint: 1, None + height: dp(50) + ToggleButton: + id: upload_btn + text: _(u"upload") + group: "transfer" + state: "down" + ToggleButton: + id: send_btn + text: _(u"send") + group: "transfer" + Label: + size_hint: 1, 0.3 + text: root.transfer_txt if upload_btn.state == 'down' else root.send_txt + text_size: self.size + halign: 'center' + valign: 'top' + ScrollView: + do_scroll_x: False + StackLayout: + size_hint: 1, None + padding: 20, 0 + spacing: 15, 5 + id: items_layout + +: + 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: + text: root.plug_info['name'] + text_size: self.size + halign: "center" + valign: "top" diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/profile_manager.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/profile_manager.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,168 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + + +: + Label: + text: "Profile Manager" + size_hint: 1,0.05 + +: + size_hint: 1, None + height: sp(30) + +: + multiline: False + size_hint: 1, None + height: sp(30) + write_tab: False + +: + size_hint: 1, 0.2 + + +: + profile_name: profile_name + jid: jid + password: password + + BoxLayout: + orientation: "vertical" + + Label: + text: "Creation of a new profile" + bold: True + size_hint: 1, 0.1 + Label: + text: root.error_msg + bold: True + size_hint: 1, 0.1 + 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, 0.2 + + Widget: + size_hint: 1, 0.2 + + 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: + + +: + BoxLayout: + orientation: "vertical" + + Label: + text: "Are you sure you want to delete the following profiles?" + size_hint: 1, 0.1 + + Label: + text: u'\n'.join([i.text for i in root.pm.profiles_screen.list_adapter.selection]) + bold: True + + Label: + text: u'/!\\ WARNING: this operation is irreversible' + color: 1,0,0,1 + bold: True + size_hint: 1, 0.2 + + GridLayout: + cols: 2 + + Button: + text: "Delete" + size_hint: 1, 0.2 + on_press: root.doDelete() + + Button: + text: "Cancel" + size_hint: 1, 0.2 + on_press: + root.pm.screen_manager.transition.direction = 'right' + root.pm.screen_manager.current = 'profiles' + + Widget: + + +: + layout: layout + BoxLayout: + id: layout + orientation: 'vertical' + + Label: + text: "Select a profile or create a new one" + size_hint: 1,0.05 + + GridLayout: + cols: 2 + size_hint: 1, 0.1 + Button: + size_hint: 1, 0.1 + text: "New" + on_press: + root.pm.screen_manager.transition.direction = 'left' + root.pm.screen_manager.current = 'new_profile' + Button: + disabled: not root.list_adapter.selection + text: "Delete" + size_hint: 1, 0.1 + on_press: + root.pm.screen_manager.transition.direction = 'left' + root.pm.screen_manager.current = 'delete_profiles' + + +: + text: "Connect" + size_hint: 1, 0.1 + disabled: not self.profile_screen.list_adapter.selection + on_press: self.pm._onConnectProfiles() + + +: + # FIXME: using cagou/images path for now, will use atlas later + background_normal: "cagou/images/button_selected.png" if self.is_selected else "cagou/images/button.png" + deselected_color: 1,1,1,1 + selected_color: 1,1,1,1 + color: 0,0,0,1 diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/root_widget.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/root_widget.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,95 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +#:import IconButton cagou.core.common.IconButton + +# : +# source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") +# size_hint: None, None +# size: self.texture_size + +: + text: self.message + +: + canvas.before: + Color: + rgba: 1, 1, 1, 1 + BorderImage: + pos: self.pos + size: self.size + source: 'atlas://data/images/defaulttheme/button' + size_hint: 1, None + text_size: self.width, None + halign: 'center' + height: self.texture_size[1] + padding: dp(2), dp(10) + +: + clear_btn: clear_btn.__self__ + auto_width: False + size_hint: 0.8, None + 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() + +: + manager: manager + notifs_icon: notifs_icon + size_hint: 1, None + height: dp(35) + IconButton: + source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") if root.notes else app.expand("{media}/misc/black.png") + allow_stretch: True + size_hint: None, 1 + width: self.norm_image_size[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_32.png") if self.notifs else app.expand("{media}/misc/black.png") + size_hint: None, 1 + width: self.norm_image_size[0] + +: + size_hint: 1, None + pos_hint: {'top': 1} + +: + root_body: root_body + root_menus: root_menus + # main body + RootBody: + id: root_body + orientation: "vertical" + size_hint: 1, None + height: root.height - root_menus.height + # general menus + # need to be added at the end so it's drawed above other widgets + RootMenus: + id: root_menus diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/simple_xhtml.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/simple_xhtml.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,27 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + + +: + size_hint: None, None + size: self.texture_size + +: + size_hint: None, None + size: self.texture_size + +: + size_hint: None, None diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/widgets_handler.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/widgets_handler.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,28 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + +: + border: (3, 3, 3, 3) + horizontal_suff: '_h' if self.horizontal else '' + background_normal: 'atlas://data/images/defaulttheme/splitter{}{}'.format('_disabled' if self.disabled else '', self.horizontal_suff) + background_down: 'atlas://data/images/defaulttheme/splitter_down{}{}'.format('_disabled' if self.disabled else '', self.horizontal_suff) + size_hint: (1, None) if self.horizontal else (None, 1) + size: (100, self.thickness) if self.horizontal else (self.thickness, 100) + Image: + pos: root.pos + size: root.size + allow_stretch: True + source: 'atlas://data/images/defaulttheme/splitter_grip' + root.horizontal_suff diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/kv/xmlui.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/kv/xmlui.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,128 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + +#:set common_height 30 +#:set button_height 50 + +: + size_hint: 1, None + height: dp(common_height) + + +: + size_hint: 1, None + height: dp(button_height) + + +: + size_hint: 1, 1 + + +: + size_hint: 1, None + height: dp(20) + canvas.before: + Color: + rgba: 1, 1, 1, 0.8 + Line + points: 0, dp(10), self.width, dp(10) + width: dp(3) + + +: + size_hint_y: None + height: dp(button_height) + + +: + size_hint: 1, None + height: dp(button_height) + + +: + canvas.before: + Color: + rgba: 1, 1, 1, 0.2 if self.global_index%2 else 0.1 + Rectangle: + pos: self.pos + size: self.size + size_hint: 1, None + height: self.minimum_height + rows: 1 + canvas.after: + Color: + rgba: 0, 0, 1, 0.5 if self.selected else 0 + Rectangle: + pos: self.pos + size: self.size + + +: + cols: 1 + size_hint: 1, None + height: self.minimum_height + + +: + cols: 1 + size_hint: 1, None + height: self.minimum_height + + +: + cols: 2 + size_hint: 1, None + height: self.minimum_height + + +: + size_hint: 1, None + height: 100 + + +: + size_hint: 1, None + height: dp(button_height) + + +: + 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.onSelect(file_chooser.selection) + Button: + size_hint: 1, None + height: dp(50) + text: "cancel" + on_release: root.onCancel() + + + +: + cols: 1 + size_hint: 1, None + height: self.minimum_height diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_android_gallery.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_android_gallery.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,95 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from 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": _(u"gallery"), + "main": "AndroidGallery", + "platforms": ('android',), + "external": True, + "description": _(u"upload a photo from photo gallery"), + "icon_medium": u"{media}/icons/muchoslava/png/gallery_50.png", +} + + +class AndroidGallery(object): + + 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): + # 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(u"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(u'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) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_android_photo.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_android_photo.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,63 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from 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": _(u"take photo"), + "main": "AndroidPhoto", + "platforms": ('android',), + "external": True, + "description": _(u"upload a photo from photo application"), + "icon_medium": u"{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.getTmpDir() + tmp_file = os.path.join(tmp_dir, filename) + log.debug(u"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 getTmpDir(self): + if sys.platform == "android": + dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + return dcim_path + else: + return tempfile.mkdtemp() diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_android_video.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_android_video.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,63 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from 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": _(u"take video"), + "main": "AndroidVideo", + "platforms": ('android',), + "external": True, + "description": _(u"upload a video from video application"), + "icon_medium": u"{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.getTmpDir() + tmp_file = os.path.join(tmp_dir, filename) + log.debug(u"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 getTmpDir(self): + if sys.platform == "android": + dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + return dcim_path + else: + return tempfile.mkdtemp() diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_file.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_file.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,35 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +#:import expanduser os.path.expanduser +#:import platform kivy.utils.platform + + +: + orientation: "vertical" + FileChooserListView: + id: filechooser + rootpath: "/" if platform == 'android' else expanduser('~') + Button: + text: "choose" + size_hint: 1, None + height: dp(50) + on_release: root.onTransmitOK(filechooser) + Button: + text: "cancel" + size_hint: 1, None + height: dp(50) + on_release: root.cancel_cb(root) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_file.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_file.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,43 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core.i18n import _ +from kivy.uix.boxlayout import BoxLayout +from kivy import properties + + +PLUGIN_INFO = { + "name": _(u"file"), + "main": "FileTransmitter", + "description": _(u"transmit a local file"), + "icon_medium": u"{media}/icons/muchoslava/png/fichier_50.png", +} + + +class FileTransmitter(BoxLayout): + callback = properties.ObjectProperty() + cancel_cb = properties.ObjectProperty() + + def onTransmitOK(self, filechooser): + if filechooser.selection: + file_path = filechooser.selection[0] + self.callback(file_path) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_voice.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_voice.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,71 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +#:import _ sat.core.i18n._ +#:import IconButton cagou.core.common.IconButton + + +: + 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) + 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.switchRecording() + 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.playRecord() + 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 diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_transfer_voice.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_transfer_voice.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,111 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from 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": _(u"voice"), + "main": "VoiceRecorder", + "platforms": ["android"], + "description": _(u"transmit a voice record"), + "icon_medium": u"{media}/icons/muchoslava/png/micro_off_50.png", +} + + +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 _updateTimer(self, dt): + self.time = int(time.time() - self._started_at) + + def switchRecording(self): + if self.playing: + self._stopPlaying() + 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(u"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._updateTimer, 1) + + self.recording = not self.recording + + def _stopPlaying(self, dummy=None): + if self.record_time is None: + log.error("_stopPlaying 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 playRecord(self): + if self.recording: + return + if self.playing: + self._stopPlaying() + 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(u"Exception on play: {}".format(e)) + self.time = 0 + return + + self.playing = True + self.record_time = self.time + Clock.schedule_once(self._stopPlaying, self.time + 1) + self._started_at = time.time() + self.time = 0 + self._counter_timer = Clock.schedule_interval(self._updateTimer, 0.5) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_chat.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_chat.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,146 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +#:import TransferMenu cagou.core.menu.TransferMenu +#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget +#:import _ sat.core.i18n._ +#:import C cagou.core.constants.Const + + +: + size_hint: None, None + size: dp(30), dp(30) + +: + cols: 1 + padding: dp(10) + spacing: dp(5) + size_hint: 1, None + height: self.minimum_height + +: + cols: 1 + mess_xhtml: mess_xhtml + padding: dp(10) + spacing: dp(5) + size_hint: 1, None + height: self.minimum_height + on_width: self.widthAdjust() + avatar: avatar + delivery: delivery + BoxLayout: + id: header_box + size_hint: 1, None + height: avatar.height if root.mess_data.type != C.MESS_TYPE_INFO else 0 + opacity: 1 if root.mess_data.type != C.MESS_TYPE_INFO else 0 + MessAvatar: + id: avatar + Label: + id: time_label + text_size: None, None + size_hint: None, None + size: self.texture_size + padding: dp(5), 0 + text: u"{}, {}".format(root.mess_data.nick, root.mess_data.time_text) + Label: + id: delivery + 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'' + color: 0, 1, 0, 1 + BoxLayout: + # BoxLayout is needed here, else GridLayout won't let the Label choose its width + size_hint: 1, None + height: mess_xhtml.height + on_size: root.widthAdjust() + SimpleXHTMLWidget: + canvas.before: + Color: + rgba: 1, 1, 1, 1 + BorderImage: + source: app.expand("{media}/misc/black.png") if root.mess_data.type == "info" else app.expand("{media}/misc/borders/{}.jpg", "blue" if root.mess_data.own_mess else "gray") + pos: self.pos + size: self.content_width, self.height + id: mess_xhtml + size_hint: 0.8, None + height: self.minimum_height + xhtml: root.message_xhtml or self.escape(root.message or u' ') + color: (0.74,0.74,0.24,1) if root.mess_data.type == "info" else (0, 0, 0, 1) + padding: root.mess_padding + bold: True if root.mess_data.type == "info" else False + +: + messages_widget: messages_widget + ScrollView: + size_hint: 1, 0.8 + scroll_y: 0 + do_scroll_x: False + MessagesWidget: + id: messages_widget + MessageInputBox: + size_hint: 1, None + height: dp(40) + message_input: message_input + MessageInputWidget: + id: message_input + size_hint: 1, 1 + hint_text: _(u"Enter your message here") + on_text_validate: root.onSend(args[0]) + IconButton + # transfer button + source: app.expand("{media}/icons/tango/actions/32/list-add.png") + allow_stretch: True + size_hint: None, 1 + width: max(self.texture_size[0], dp(40)) + on_release: TransferMenu(callback=root.onTransferOK).show(self) + +: + size_hint: None, 1 + width: dp(30) + allow_stretch: True + source: self.getIconSource() + +: + size_hint: None, None + size: self.texture_size + padding: dp(5), dp(10) + +: + size_hint_x: None + width: start_btn.width + auto_width: False + canvas.before: + Color: + rgba: 0, 0, 0, 1 + Rectangle: + pos: self.pos + size: self.size + OtrButton: + id: start_btn + text: _(u"Start/Refresh encrypted session") + on_release: root.otr_start() + OtrButton: + text: _(u"Finish encrypted session") + on_release: root.otr_end() + OtrButton: + text: _(u"Authenticate destinee") + on_release: root.otr_authenticate() diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_chat.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_chat.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,460 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core.i18n import _ +from cagou.core.constants import Const as C +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.textinput import TextInput +from kivy.metrics import dp +from kivy import properties +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.quick_frontend import quick_chat +from sat_frontends.tools import jid +from cagou.core import cagou_widget +from cagou.core.image import Image +from cagou.core.common import IconButton, JidWidget +from kivy.uix.dropdown import DropDown +from cagou import G +import mimetypes + + +PLUGIN_INFO = { + "name": _(u"chat"), + "main": "Chat", + "description": _(u"instant messaging with one person or a group"), + "icon_small": u"{media}/icons/muchoslava/png/chat_new_32.png", + "icon_medium": u"{media}/icons/muchoslava/png/chat_new_44.png" +} + +# following const are here temporary, they should move to quick frontend +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) + + +class MessAvatar(Image): + pass + + +class MessageWidget(GridLayout): + mess_data = properties.ObjectProperty() + mess_xhtml = properties.ObjectProperty() + mess_padding = (dp(5), dp(5)) + avatar = properties.ObjectProperty() + delivery = properties.ObjectProperty() + + def __init__(self, **kwargs): + # self must be registered in widgets before kv is parsed + kwargs['mess_data'].widgets.add(self) + super(MessageWidget, self).__init__(**kwargs) + avatar_path = self.mess_data.avatar + if avatar_path is not None: + self.avatar.source = avatar_path + + @property + def chat(self): + """return parent Chat instance""" + return self.mess_data.parent + + @property + def message(self): + """Return currently displayed message""" + return self.mess_data.main_message + + @property + def message_xhtml(self): + """Return currently displayed message""" + return self.mess_data.main_message_xhtml + + def widthAdjust(self): + """this widget grows up with its children""" + pass + # parent = self.mess_xhtml.parent + # padding_x = self.mess_padding[0] + # text_width, text_height = self.mess_xhtml.texture_size + # if text_width > parent.width: + # self.mess_xhtml.text_size = (parent.width - padding_x, None) + # self.text_max = text_width + # elif self.mess_xhtml.text_size[0] is not None and text_width < parent.width - padding_x: + # if text_width < self.text_max: + # self.mess_xhtml.text_size = (None, None) + # else: + # self.mess_xhtml.text_size = (parent.width - padding_x, None) + + def update(self, update_dict): + if 'avatar' in update_dict: + self.avatar.source = update_dict['avatar'] + if 'status' in update_dict: + status = update_dict['status'] + self.delivery.text = u'\u2714' if status == 'delivered' else u'' + + +class MessageInputBox(BoxLayout): + pass + + +class MessageInputWidget(TextInput): + + def _key_down(self, key, repeat=False): + displayed_str, internal_str, internal_action, scale = key + if internal_action == 'enter': + self.dispatch('on_text_validate') + else: + super(MessageInputWidget, self)._key_down(key, repeat) + + +class MessagesWidget(GridLayout): + pass + + +class EncryptionButton(IconButton): + + def __init__(self, chat, **kwargs): + """ + @param chat(Chat): Chat instance + """ + self.chat = chat + # for now we do a simple ContextMenu as we have only OTR + self.otr_menu = OtrMenu(chat) + super(EncryptionButton, self).__init__(**kwargs) + self.bind(on_release=self.otr_menu.open) + + def getIconSource(self): + """get path of icon""" + # TODO: use a more generic method to get icon name + if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: + icon_name = 'cadenas_ouvert' + else: + if self.chat.otr_state_trust == OTR_STATE_TRUSTED: + icon_name = 'cadenas_ferme' + else: + icon_name = 'cadenas_ferme_pas_authenthifie' + + return G.host.app.expand("{media}/icons/muchoslava/png/" + icon_name + "_30.png") + + +class OtrMenu(DropDown): + + def __init__(self, chat, **kwargs): + """ + @param chat(Chat): Chat instance + """ + self.chat = chat + super(OtrMenu, self).__init__(**kwargs) + + def otr_start(self): + self.dismiss() + G.host.launchMenu( + C.MENU_SINGLE, + (u"otr", u"start/refresh"), + {u'jid': unicode(self.chat.target)}, + None, + C.NO_SECURITY_LIMIT, + self.chat.profile + ) + + def otr_end(self): + self.dismiss() + G.host.launchMenu( + C.MENU_SINGLE, + (u"otr", u"end session"), + {u'jid': unicode(self.chat.target)}, + None, + C.NO_SECURITY_LIMIT, + self.chat.profile + ) + + def otr_authenticate(self): + self.dismiss() + G.host.launchMenu( + C.MENU_SINGLE, + (u"otr", u"authenticate"), + {u'jid': unicode(self.chat.target)}, + None, + C.NO_SECURITY_LIMIT, + self.chat.profile + ) + + +class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget): + message_input = properties.ObjectProperty() + messages_widget = properties.ObjectProperty() + + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None): + quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles) + self.otr_state_encryption = OTR_STATE_UNENCRYPTED + self.otr_state_trust = OTR_STATE_UNTRUSTED + cagou_widget.CagouWidget.__init__(self) + if type_ == C.CHAT_ONE2ONE: + self.encryption_btn = EncryptionButton(self) + self.headerInputAddExtra(self.encryption_btn) + self.header_input.hint_text = u"{}".format(target) + self.host.addListener('progressError', self.onProgressError, profiles) + self.host.addListener('progressFinished', self.onProgressFinished, profiles) + self._waiting_pids = {} # waiting progress ids + self.postInit() + # completion attribtues + self._hi_comp_data = None + self._hi_comp_last = None + self._hi_comp_dropdown = DropDown() + self._hi_comp_allowed = True + + @classmethod + def factory(cls, plugin_info, target, profiles): + profiles = list(profiles) + if len(profiles) > 1: + raise NotImplementedError(u"Multi-profiles is not available yet for chat") + if target is None: + target = G.host.profiles[profiles[0]].whoami + return G.host.widgets.getOrCreateWidget(cls, target, on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=profiles) + + ## header ## + + def changeWidget(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.getPluginInfo(main=Chat) + factory = plugin_info['factory'] + G.host.switchWidget(self, factory(plugin_info, jid_, profiles=[self.profile])) + self.header_input.text = '' + + def onHeaderInput(self): + text = self.header_input.text.strip() + try: + if text.count(u'@') != 1 or text.count(u' '): + raise ValueError + jid_ = jid.JID(text) + except ValueError: + log.info(u"entered text is not a jid") + return + + def discoCb(disco): + # TODO: check if plugin XEP-0045 is activated + if "conference" in [i[0] for i in disco[1]]: + G.host.bridge.mucJoin(unicode(jid_), "", "", self.profile, callback=self._mucJoinCb, errback=self._mucJoinEb) + else: + self.changeWidget(jid_) + + def discoEb(failure): + log.warning(u"Disco failure, ignore this text: {}".format(failure)) + + G.host.bridge.discoInfos(jid_.domain, self.profile, callback=discoCb, errback=discoEb) + + def onHeaderInputCompleted(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.onHeaderInput() + + def onHeaderInputComplete(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 + 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(u'nick', u'') + if text in jid_.bare or text in nick.lower(): + btn = JidWidget( + jid = jid_.bare, + profile = profile, + size_hint = (0.5, None), + nick = nick, + on_release=lambda dummy, txt=jid_.bare: self.onHeaderInputCompleted(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) + + dropdown.open(wid) + self._hi_comp_last = text + + def messageDataConverter(self, idx, mess_id): + return {"mess_data": self.messages[mess_id]} + + def _onHistoryPrinted(self): + """Refresh or scroll down the focus after the history is printed""" + # self.adapter.data = self.messages + for mess_data in self.messages.itervalues(): + self.appendMessage(mess_data) + super(Chat, self)._onHistoryPrinted() + + def createMessage(self, message): + self.appendMessage(message) + + def appendMessage(self, mess_data): + self.messages_widget.add_widget(MessageWidget(mess_data=mess_data)) + + def onSend(self, input_widget): + G.host.messageSend( + self.target, + {'': input_widget.text}, # TODO: handle language + mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat + profile_key=self.profile + ) + input_widget.text = '' + + def onProgressFinished(self, progress_id, metadata, profile): + try: + callback, cleaning_cb = self._waiting_pids.pop(progress_id) + except KeyError: + return + if cleaning_cb is not None: + cleaning_cb() + callback(metadata, profile) + + def onProgressError(self, progress_id, err_msg, profile): + try: + dummy, cleaning_cb = self._waiting_pids[progress_id] + except KeyError: + return + else: + del self._waiting_pids[progress_id] + if cleaning_cb is not None: + cleaning_cb() + # TODO: display message to user + log.warning(u"Can't transfer file: {}".format(err_msg)) + + def fileTransferDone(self, metadata, profile): + log.debug("file transfered: {}".format(metadata)) + extra = {} + + # FIXME: Q&D way of getting file type, upload plugins shouls give it + mime_type = mimetypes.guess_type(metadata['url'])[0] + if mime_type is not None: + if mime_type.split(u'/')[0] == 'image': + # we generate url ourselves, so this formatting is safe + extra['xhtml'] = u"".format(**metadata) + + G.host.messageSend( + self.target, + {'': metadata['url']}, + mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, + extra = extra, + profile_key=profile + ) + + def fileTransferCb(self, progress_data, cleaning_cb): + try: + progress_id = progress_data['progress'] + except KeyError: + xmlui = progress_data['xmlui'] + G.host.showUI(xmlui) + else: + self._waiting_pids[progress_id] = (self.fileTransferDone, cleaning_cb) + + def onTransferOK(self, file_path, cleaning_cb, transfer_type): + if transfer_type == C.TRANSFER_UPLOAD: + G.host.bridge.fileUpload( + file_path, + "", + "", + {"ignore_tls_errors": C.BOOL_TRUE}, # FIXME: should not be the default + self.profile, + callback = lambda progress_data: self.fileTransferCb(progress_data, cleaning_cb) + ) + elif transfer_type == C.TRANSFER_SEND: + if self.type == C.CHAT_GROUP: + log.warning(u"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].getFullJid(jid_) + G.host.bridge.fileSend(unicode(jid_), file_path, "", "", profile=self.profile) + # TODO: notification of sending/failing + else: + raise log.error(u"transfer of type {} are not handled".format(transfer_type)) + + + def _mucJoinCb(self, joined_data): + joined, room_jid_s, occupants, user_nick, subject, profile = joined_data + self.host.mucRoomJoinedHandler(*joined_data[1:]) + jid_ = jid.JID(room_jid_s) + self.changeWidget(jid_) + + def _mucJoinEb(self, failure): + log.warning(u"Can't join room: {}".format(failure)) + + def _onDelete(self): + self.host.removeListener('progressFinished', self.onProgressFinished) + self.host.removeListener('progressError', self.onProgressError) + return super(Chat, self).onDelete() + + def onOTRState(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(_(u"Unknown OTR state received: {}".format(state))) + return + self.encryption_btn.source = self.encryption_btn.getIconSource() + + def onDelete(self, force=False): + if force==True: + return self._onDelete() + if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1: + # we don't keep duplicate widgets + return self._onDelete() + return False + + +PLUGIN_INFO["factory"] = Chat.factory +quick_widgets.register(quick_chat.QuickChat, Chat) diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_contact_list.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_contact_list.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,34 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + +: + row_height: dp(50) + +: + padding: dp(10), dp(3) + size_hint: 1, None + height: dp(50) + Avatar: + source: root.data.get('avatar', app.default_avatar) + size_hint: None, 1 + width: dp(60) + Label: + id: jid_label + padding: dp(5), 0 + text: root.jid + text_size: self.size + bold: True + valign: "middle" diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_contact_list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_contact_list.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,111 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core.i18n import _ +from sat_frontends.quick_frontend.quick_contact_list import QuickContactList +from sat_frontends.tools import jid +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.listview import ListView +from kivy.adapters.listadapter import ListAdapter +from kivy.metrics import dp +from kivy import properties +from cagou.core import cagou_widget +from cagou.core import image +from cagou import G + + +PLUGIN_INFO = { + "name": _(u"contacts"), + "main": "ContactList", + "description": _(u"list of contacts"), + "icon_small": u"{media}/icons/muchoslava/png/contact_list_new_32.png", + "icon_medium": u"{media}/icons/muchoslava/png/contact_list_new_44.png" +} + + +class Avatar(image.Image): + pass + + +class ContactItem(BoxLayout): + data = properties.DictProperty() + jid = properties.StringProperty('') + + def __init__(self, **kwargs): + super(ContactItem, self).__init__(**kwargs) + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + # XXX: for now clicking on an item launch the corresponding Chat widget + # behaviour should change in the future + try: + # FIXME: Q&D way to get chat plugin, should be replaced by a clean method + # in host + plg_infos = [p for p in G.host.getPluggedWidgets() if 'chat' in p['import_name']][0] + except IndexError: + log.warning(u"No plugin widget found to display chat") + else: + factory = plg_infos['factory'] + G.host.switchWidget(self, factory(plg_infos, jid.JID(self.jid), profiles=iter(G.host.profiles))) + + +class ContactListView(ListView): + pass + + +class ContactList(QuickContactList, cagou_widget.CagouWidget): + + def __init__(self, host, target, profiles): + QuickContactList.__init__(self, G.host, profiles) + cagou_widget.CagouWidget.__init__(self) + self.adapter = ListAdapter(data={}, + cls=ContactItem, + args_converter=self.contactDataConverter, + selection_mode='multiple', + allow_empty_selection=True, + ) + self.add_widget(ContactListView(adapter=self.adapter)) + self.postInit() + self.update() + + def onHeaderInputComplete(self, wid, text): + # FIXME: this is implementation dependent, need to be done properly + items = self.children[0].children[0].children[0].children + + for item in items: + if text not in item.ids.jid_label.text: + item.height = 0 + item.opacity = 0 + else: + item.height = dp(50) + item.opacity = 1 + + def contactDataConverter(self, idx, bare_jid): + return {"jid": bare_jid, "data": self._items_cache[bare_jid]} + + def update(self, entities=None, type_=None, profile=None): + log.debug("update: %s %s %s" % (entities, type_, profile)) + # FIXME: for now we update on each event + # if entities is None and type_ is None: + self._items_cache = self.items_sorted + self.adapter.data = self.items_sorted.keys() + diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_settings.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_settings.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,15 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_settings.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_settings.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,65 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core.i18n import _ +from sat.core.constants import Const as C +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 + + +PLUGIN_INFO = { + "name": _(u"settings"), + "main": "Settings", + "description": _(u"Cagou/SàT settings"), + "icon_small": u"{media}/icons/muchoslava/png/settings_32.png", + "icon_medium": u"{media}/icons/muchoslava/png/settings_44.png" +} + + +class Settings(quick_widgets.QuickWidget, cagou_widget.CagouWidget): + + 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) + G.host.bridge.getParamsUI(-1, C.APP_NAME, self.profile, callback=self.getParamsUICb, errback=self.getParamsUIEb) + + def changeWidget(self, widget): + self.clear_widgets([self.loading_widget]) + del self.loading_widget + self.add_widget(widget) + + def getParamsUICb(self, xmlui): + G.host.actionManager({"xmlui": xmlui}, ui_show_cb=self.changeWidget, profile=self.profile) + + def getParamsUIEb(self, failure): + self.changeWidget(Label( + text=_(u"Can't load parameters!"), + bold=True, + color=(1,0,0,1))) + G.host.showDialog(u"Can't load params UI", failure, "error") diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_widget_selector.kv --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_widget_selector.kv Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,30 @@ +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 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 . + +: + size_hint: (1, None) + height: dp(40) + Widget: + Image: + source: root.plugin_info["icon_medium"] + allow_stretch: True + keep_ratio: True + width: self.texture_size[0] + Label: + text: root.plugin_info["name"] + bold: True + font_size: sp(20) + Widget: diff -r b6e6afb0dc46 -r cd99f70ea592 cagou/plugins/plugin_wid_widget_selector.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/plugins/plugin_wid_widget_selector.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,80 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core.i18n import _ +from cagou.core.constants import Const as C +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.listview import ListView +from kivy.adapters.listadapter import ListAdapter +from kivy import properties +from kivy.uix.behaviors import ButtonBehavior +from cagou.core import cagou_widget +from cagou import G + + +PLUGIN_INFO = { + "name": _(u"widget selector"), + "import_name": C.WID_SELECTOR, + "main": "WidgetSelector", + "description": _(u"show available widgets and allow to select one"), + "icon_small": u"{media}/icons/muchoslava/png/selector_new_32.png", + "icon_medium": u"{media}/icons/muchoslava/png/selector_new_44.png" +} + + +class WidgetSelItem(ButtonBehavior, BoxLayout): + plugin_info = properties.DictProperty() + + def __init__(self, **kwargs): + super(WidgetSelItem, self).__init__(**kwargs) + + def select(self, *args): + log.debug(u"widget selection: {}".format(self.plugin_info["name"])) + factory = self.plugin_info["factory"] + G.host.switchWidget(self, factory(self.plugin_info, None, profiles=iter(G.host.profiles))) + + def deselect(self, *args): + pass + + +class WidgetSelector(cagou_widget.CagouWidget): + + def __init__(self): + super(WidgetSelector, self).__init__() + self.adapter = ListAdapter( + data=G.host.getPluggedWidgets(except_cls=self.__class__), + cls=WidgetSelItem, + args_converter=self.dataConverter, + selection_mode='single', + allow_empty_selection=True, + ) + self.add_widget(ListView(adapter=self.adapter)) + + @classmethod + def factory(cls, plugin_info, target, profiles): + return cls() + + def dataConverter(self, idx, plugin_info): + return {"plugin_info": plugin_info} + + +PLUGIN_INFO["factory"] = WidgetSelector.factory diff -r b6e6afb0dc46 -r cd99f70ea592 service/main.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/main.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,41 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import sys +import os +# we want the service to access the modules from parent dir (sat, etc.) +os.chdir('..') +sys.path.insert(0, '') +from sat.core.constants import Const as C +from sat.core import log_config +# SàT log conf must be done before calling Kivy +log_config.satConfigure(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 +if kivy_utils.platform == "android": + # sys.platform is "linux" on android by default + # so we change it to allow backend to detect android + sys.platform = "android" + C.PLUGIN_EXT = "pyo" +from sat.core import sat_main +from twisted.internet import reactor + +sat = sat_main.SAT() +reactor.run() diff -r b6e6afb0dc46 -r cd99f70ea592 src/buildozer.spec --- a/src/buildozer.spec Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,236 +0,0 @@ -[app] - -# (str) Title of your application -title = Cagou - -# (str) Package name -package.name = cagou - -# (str) Package domain (needed for android/ios packaging) -package.domain = org.goffi.cagou - -# (str) Source code where the main.py live -source.dir = . - -# (list) Source files to include (let empty to include all the files) -source.include_exts = py,png,jpg,kv,atlas,conf -# FIXME: check if we can do sat.conf only, without every .conf - -# (list) List of inclusions using pattern matching -#source.include_patterns = assets/*,images/*.png -source.include_patterns = media - -# (list) Source files to exclude (let empty to not exclude anything) -#source.exclude_exts = spec - -# (list) List of directory to exclude (let empty to not exclude anything) -#source.exclude_dirs = tests, bin - -# (list) List of exclusions using pattern matching -#source.exclude_patterns = license,images/*/*.jpg - -# (str) Application versioning (method 1) -version = 0.1 - -# (str) Application versioning (method 2) -# version.regex = __version__ = ['"](.*)['"] -# version.filename = %(source.dir)s/main.py - -# (list) Application requirements -# comma seperated e.g. requirements = sqlite3,kivy -requirements = kivy, sqlite3, twisted, wokkel, pil, lxml, pyxdg, markdown, html2text, python-dateutil, pycrypto, pyopenssl, plyer, potr - -# (str) Custom source folders for requirements -# Sets custom source for any requirements with recipes -# requirements.source.kivy = ../../kivy - -# (list) Garden requirements -#garden_requirements = - -# (str) Presplash of the application -presplash.filename = %(source.dir)s/media/icons/muchoslava/png/cagou_profil_bleu_512.png - -# (str) Icon of the application -icon.filename = %(source.dir)s/media/icons/muchoslava/png/cagou_profil_bleu_96.png - -# (str) Supported orientation (one of landscape, portrait or all) -orientation = portrait - -# (list) List of service to declare -#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY - -# -# OSX Specific -# - -# -# author = © Copyright Info - -# -# Android specific -# - -# (bool) Indicate if the application should be fullscreen or not -fullscreen = 1 - -# (list) Permissions -android.permissions = INTERNET, ACCESS_NETWORK_STATE, VIBRATE, RECORD_AUDIO - -# (int) Android API to use -#android.api = 19 - -# (int) Minimum API required -#android.minapi = 9 - -# (int) Android SDK version to use -#android.sdk = 20 - -# (str) Android NDK version to use -#android.ndk = 9c - -# (bool) Use --private data storage (True) or --dir public storage (False) -#android.private_storage = True - -# (str) Android NDK directory (if empty, it will be automatically downloaded.) -#android.ndk_path = - -# (str) Android SDK directory (if empty, it will be automatically downloaded.) -#android.sdk_path = - -# (str) ANT directory (if empty, it will be automatically downloaded.) -#android.ant_path = - -# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github) -# we use our own p4a and mount in root dir on docker image -android.p4a_dir = /python-for-android-old - -# (str) The directory in which python-for-android should look for your own build recipes (if any) -#p4a.local_recipes = - -# (list) python-for-android whitelist -#android.p4a_whitelist = - -# (bool) If True, then skip trying to update the Android sdk -# This can be useful to avoid excess Internet downloads or save time -# when an update is due and you just want to test/build your package -# android.skip_update = False - -# (str) Bootstrap to use for android builds (android_new only) -# android.bootstrap = sdl2 - -# (str) Android entry point, default is ok for Kivy-based app -#android.entrypoint = org.renpy.android.PythonActivity - -# (list) List of Java .jar files to add to the libs so that pyjnius can access -# their classes. Don't add jars that you do not need, since extra jars can slow -# down the build process. Allows wildcards matching, for example: -# OUYA-ODK/libs/*.jar -#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar - -# (list) List of Java files to add to the android project (can be java or a -# directory containing the files) -#android.add_src = - -# (str) python-for-android branch to use, if not master, useful to try -# not yet merged features. -#android.branch = master - -# (str) OUYA Console category. Should be one of GAME or APP -# If you leave this blank, OUYA support will not be enabled -#android.ouya.category = GAME - -# (str) Filename of OUYA Console icon. It must be a 732x412 png image. -#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png - -# (str) XML file to include as an intent filters in tag -#android.manifest.intent_filters = - -# (list) Android additionnal libraries to copy into libs/armeabi -#android.add_libs_armeabi = libs/android/*.so -#android.add_libs_armeabi_v7a = libs/android-v7/*.so -#android.add_libs_x86 = libs/android-x86/*.so -#android.add_libs_mips = libs/android-mips/*.so - -# (bool) Indicate whether the screen should stay on -# Don't forget to add the WAKE_LOCK permission if you set this to True -#android.wakelock = False - -# (list) Android application meta-data to set (key=value format) -#android.meta_data = - -# (list) Android library project to add (will be added in the -# project.properties automatically.) -#android.library_references = - -# (str) Android logcat filters to use -#android.logcat_filters = *:S python:D - -# (bool) Copy library instead of making a libpymodules.so -#android.copy_libs = 1 - -# -# iOS specific -# - -# (str) Path to a custom kivy-ios folder -#ios.kivy_ios_dir = ../kivy-ios - -# (str) Name of the certificate to use for signing the debug version -# Get a list of available identities: buildozer ios list_identities -#ios.codesign.debug = "iPhone Developer: ()" - -# (str) Name of the certificate to use for signing the release version -#ios.codesign.release = %(ios.codesign.debug)s - - -[buildozer] - -# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output)) -log_level = 1 - -# (int) Display warning if buildozer is run as root (0 = False, 1 = True) -warn_on_root = 1 - -# (str) Path to build artifact storage, absolute or relative to spec file -# build_dir = ./.buildozer - -# (str) Path to build output (i.e. .apk, .ipa) storage -# bin_dir = ./bin - -# ----------------------------------------------------------------------------- -# List as sections -# -# You can define all the "list" as [section:key]. -# Each line will be considered as a option to the list. -# Let's take [app] / source.exclude_patterns. -# Instead of doing: -# -#[app] -#source.exclude_patterns = license,data/audio/*.wav,data/images/original/* -# -# This can be translated into: -# -#[app:source.exclude_patterns] -#license -#data/audio/*.wav -#data/images/original/* -# - - -# ----------------------------------------------------------------------------- -# Profiles -# -# You can extend section / key with a profile -# For example, you want to deploy a demo version of your application without -# HD content. You could first change the title to add "(demo)" in the name -# and extend the excluded directories to remove the HD content. -# -#[app@demo] -#title = My Application (demo) -# -#[app:source.exclude_patterns@demo] -#images/hd/* -# -# Then, invoke the command line with the "demo" profile: -# -#buildozer --profile demo android debug diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/__init__.py --- a/src/cagou/__init__.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,33 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - - -class Global(object): - @property - def host(self): - return self._host -G = Global() - - -from core import cagou_main - - -def run(): - host = G._host = cagou_main.Cagou() - host.run() diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/cagou_main.py --- a/src/cagou/core/cagou_main.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,666 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core.i18n import _ -from . import kivy_hack -kivy_hack.do_hack() -from constants import Const as C -from sat.core import log as logging -log = logging.getLogger(__name__) -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.tools import config -from sat.tools.common import dynamic_import -import kivy -kivy.require('1.9.1') -import kivy.support -main_config = config.parseMainConf() -bridge_name = config.getConfig(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 -import xmlui -from profile_manager import ProfileManager -from widgets_handler import WidgetsHandler -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 cagou_widget import CagouWidget -from . import widgets_handler -from .common import IconButton -from . import menu -from importlib import import_module -import os.path -import glob -import cagou.plugins -import cagou.kv -from kivy import utils as kivy_utils -import sys -if kivy_utils.platform == "android": - # FIXME: move to separate android module - kivy.support.install_android() - # sys.platform is "linux" on android by default - # so we change it to allow backend to detect android - sys.platform = "android" - import mmap - C.PLUGIN_EXT = 'pyo' - - -class NotifsIcon(IconButton): - notifs = properties.ListProperty() - - def on_release(self): - callback, args, kwargs = self.notifs.pop(0) - callback(*args, **kwargs) - - def addNotif(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)) - - -class NoteDrop(Note): - pass - - -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: - self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level)) - self.add_widget(self.clear_btn) - super(NotesDrop, self).open(widget) - - -class RootHeadWidget(BoxLayout): - """Notifications widget""" - manager = properties.ObjectProperty() - notifs_icon = properties.ObjectProperty() - notes = properties.ListProperty() - - def __init__(self): - super(RootHeadWidget, self).__init__() - self.notes_last = None - self.notes_event = None - self.notes_drop = NotesDrop(self.notes) - - def addNotif(self, callback, *args, **kwargs): - self.notifs_icon.addNotif(callback, *args, **kwargs) - - def addNote(self, title, message, level): - note = Note(title=title, message=message, level=level) - self.notes.append(note) - if len(self.notes) > 10: - del self.notes[:-10] - if self.notes_event is None: - self.notes_event = Clock.schedule_interval(self._displayNextNote, 5) - self._displayNextNote() - - def addNotifUI(self, ui): - self.notifs_icon.addNotif(ui.show, force=True) - - def _displayNextNote(self, dummy=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 RootMenus(menu.MenusWidget): - pass - - -class RootBody(BoxLayout): - pass - - -class CagouRootWidget(FloatLayout): - root_menus = properties.ObjectProperty() - 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 changeWidget(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 newAction(self, handler, action_data, id_, security_limit, profile): - """Add a notification for an action""" - self._head_widget.addNotif(handler, action_data, id_, security_limit, profile) - - def addNote(self, title, message, level): - self._head_widget.addNote(title, message, level) - - def addNotifUI(self, ui): - self._head_widget.addNotifUI(ui) - - -class CagouApp(App): - """Kivy App for Cagou""" - - def build(self): - return CagouRootWidget(Label(text=u"Loading please wait")) - - def showWidget(self): - self._profile_manager = ProfileManager() - self.root.changeWidget(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 on_start(self): - if sys.platform == "android": - # XXX: we use memory map 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 - # we create a memory map on .cagou_status file with a 1 byte status - # status is: - # R => running - # P => paused - # S => stopped - self._first_pause = True - self.cagou_status_fd = open('.cagou_status', 'wb+') - self.cagou_status_fd.write('R') - self.cagou_status_fd.flush() - self.cagou_status = mmap.mmap(self.cagou_status_fd.fileno(), 1, prot=mmap.PROT_WRITE) - - def on_pause(self): - self.cagou_status[0] = 'P' - return True - - def on_resume(self): - self.cagou_status[0] = 'R' - - def on_stop(self): - if sys.platform == "android": - self.cagou_status[0] = 'S' - self.cagou_status.flush() - self.cagou_status_fd.close() - - -class Cagou(QuickApp): - MB_HANDLE = False - - def __init__(self): - if bridge_name == 'embedded': - from sat.core import sat_main - self.sat = sat_main.SAT() - if sys.platform == 'android': - from android import AndroidService - service = AndroidService(u'Cagou (SàT)'.encode('utf-8'), u'Salut à Toi backend'.encode('utf-8')) - service.start(u'service started') - self.service = service - - bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') - if bridge_module is None: - log.error(u"Can't import {} bridge".format(bridge_name)) - sys.exit(3) - else: - log.debug(u"Loading {} bridge".format(bridge_name)) - 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.getConfig(main_config, '', 'media_dir') - 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._plg_wids = [] # main widgets plugins - self._plg_wids_transfer = [] # transfer widgets plugins - self._import_plugins() - self._visible_widgets = {} # visible widgets by classes - - @property - def visible_widgets(self): - for w_list in self._visible_widgets.itervalues(): - for w in w_list: - yield w - - def onBridgeConnected(self): - self.registerSignal("otrState", iface="plugin") - self.bridge.getReady(self.onBackendReady) - - def _bridgeEb(self, failure): - if bridge_name == "pb" and sys.platform == "android": - try: - self.retried += 1 - except AttributeError: - self.retried = 1 - from twisted.internet.error import ConnectionRefusedError - if failure.check(ConnectionRefusedError) and self.retried < 100: - if self.retried % 20 == 0: - log.debug("backend not ready, retrying ({})".format(self.retried)) - Clock.schedule_once(lambda dummy: self.connectBridge(), 0.05) - return - super(Cagou, self)._bridgeEb(failure) - - def run(self): - self.connectBridge() - 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 onBackendReady(self): - self.app.showWidget() - self.postInit() - - def postInit(self, dummy=None): - # FIXME: resize seem to bug 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).postInit(profile_manager) - - def _defaultFactoryMain(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.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles)) - - def _defaultFactoryTransfer(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__) - for kv_path in glob.glob(os.path.join(path, "*.kv")): - Builder.load_file(kv_path) - log.debug(u"kv file {} loaded".format(kv_path)) - - def _import_plugins(self): - """import all plugins""" - self.default_wid = None - plugins_path = os.path.dirname(cagou.plugins.__file__) - plugin_glob = u"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 u'_' not in suff: - log.error(u"invalid plugin name: {}, skipping".format(plug)) - continue - plugin_type = suff[:suff.find(u'_')] - - # and select the variable to use according to type - if plugin_type == C.PLUG_TYPE_WID: - imported_names = imported_names_main - default_factory = self._defaultFactoryMain - elif plugin_type == C.PLUG_TYPE_TRANSFER: - imported_names = imported_names_transfer - default_factory = self._defaultFactoryTransfer - else: - log.error(u"unknown plugin type {type_} for plugin {file_}, skipping".format( - type_ = plugin_type, - file_ = plug - )) - continue - plugins_set = self._getPluginsSet(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(u"{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(_(u"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(u"{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'] = u'{}.kv'.format(plug) - kv_path = os.path.join(plugins_path, plugin_info['kv_file']) - if not os.path.exists(kv_path): - log.debug(u"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 getOrCreateWidget - if 'factory' not in plugin_info: - plugin_info['factory'] = default_factory - - # icons - for size in ('small', 'medium'): - key = u'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(_(u"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 _getPluginsSet(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(u"{} plugin type is unknown".format(type_)) - - def getPluggedWidgets(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._getPluginsSet(type_) - for plugin_data in plugins_set: - if plugin_data['main'] == except_cls: - continue - yield plugin_data - - def getPluginInfo(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._getPluginsSet(type_) - for plugin_info in plugins_set: - for k, w in kwargs.iteritems(): - try: - if plugin_info[k] != w: - continue - except KeyError: - continue - return plugin_info - - ## widgets handling - - def newWidget(self, widget): - log.debug(u"new widget created: {}".format(widget)) - if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: - self.addNote(u"", _(u"room {} has been joined").format(widget.target)) - - def getParentHandler(self, widget): - """Return handler holding this widget - - @return (WidgetsHandler): handler - """ - w_handler = widget.parent - while w_handler and not(isinstance(w_handler, widgets_handler.WidgetsHandler)): - w_handler = w_handler.parent - return w_handler - - def switchWidget(self, old, new): - """Replace old widget by new one - - old(CagouWidget): CagouWidget instance or a child - new(CagouWidget): new widget instance - """ - 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(u"no CagouWidget found when trying to switch widget") - handler = self.getParentHandler(to_change) - handler.changeWidget(new) - - def addVisibleWidget(self, widget): - """declare a widget visible - - for internal use only! - """ - assert isinstance(widget, quick_widgets.QuickWidget) - self._visible_widgets.setdefault(widget.__class__, []).append(widget) - - def removeVisibleWidget(self, widget): - """declare a widget not visible anymore - - for internal use only! - """ - self._visible_widgets[widget.__class__].remove(widget) - self.widgets.deleteWidget(widget) - - def getVisibleList(self, cls): - """get list of visible widgets for a given class - - @param cls(QuickWidget class): type of widgets to get - @return (list[QuickWidget class]): visible widgets of this class - """ - try: - return self._visible_widgets[cls] - except KeyError: - return [] - - def getOrClone(self, widget): - """Get a QuickWidget if it has not parent set else clone it""" - if widget.parent is None: - return widget - targets = list(widget.targets) - w = self.widgets.getOrCreateWidget(widget.__class__, targets[0], on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=widget.profiles) - for t in targets[1:]: - w.addTarget(t) - return w - - ## menus ## - - def _menusGetCb(self, backend_menus): - main_menu = self.app.root.root_menus - self.menus.addMenus(backend_menus) - self.menus.addMenu(C.MENU_GLOBAL, (_(u"Help"), _(u"About")), callback=main_menu.onAbout) - main_menu.update(C.MENU_GLOBAL) - - ## bridge handlers ## - - def otrStateHandler(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 - for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)): - widget.onOTRState(state, dest_jid, profile) - - ## misc ## - - def plugging_profiles(self): - self.app.root.changeWidget(WidgetsHandler()) - self.bridge.menusGet("", C.NO_SECURITY_LIMIT, callback=self._menusGetCb) - - def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): - log.info(u"Profile presence status set to {show}/{status}".format(show=show, status=status)) - - def addNote(self, title, message, level=C.XMLUI_DATA_LVL_INFO): - """add a note (message which disappear) to root widget's header""" - self.app.root.addNote(title, message, level) - - def addNotifUI(self, ui): - """add a notification with a XMLUI attached - - @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected - """ - self.app.root.addNotifUI(ui) - - def showUI(self, ui): - """show a XMLUI""" - self.app.root.changeWidget(ui, "xmlui") - self.app.root.show("xmlui") - - def showExtraUI(self, widget): - """show any extra widget""" - self.app.root.changeWidget(widget, "extra") - self.app.root.show("extra") - - def closeUI(self): - self.app.root.show() - - def getDefaultAvatar(self, entity=None): - return self.app.default_avatar - - def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None): - # TODO - log.info(u"FIXME: showDialog not implemented") - log.info(u"message: {} -- {}".format(title, message)) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/cagou_widget.py --- a/src/cagou/core/cagou_widget.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,82 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from kivy.uix.image import Image -from kivy.uix.behaviors import ButtonBehavior -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.dropdown import DropDown -from kivy import properties -from cagou import G - - -class HeaderWidgetChoice(ButtonBehavior, BoxLayout): - def __init__(self, cagou_widget, plugin_info): - self.plugin_info = plugin_info - super(HeaderWidgetChoice, self).__init__() - self.bind(on_release=lambda btn: cagou_widget.switchWidget(plugin_info)) - - -class HeaderWidgetCurrent(ButtonBehavior, Image): - pass - - -class HeaderWidgetSelector(DropDown): - - def __init__(self, cagou_widget): - super(HeaderWidgetSelector, self).__init__() - for plugin_info in G.host.getPluggedWidgets(except_cls=cagou_widget.__class__): - choice = HeaderWidgetChoice(cagou_widget, plugin_info) - self.add_widget(choice) - - -class CagouWidget(BoxLayout): - header_input = properties.ObjectProperty(None) - header_box = properties.ObjectProperty(None) - - def __init__(self): - for p in G.host.getPluggedWidgets(): - if p['main'] == self.__class__: - self.plugin_info = p - break - BoxLayout.__init__(self, orientation="vertical") - self.selector = HeaderWidgetSelector(self) - - def switchWidget(self, plugin_info): - self.selector.dismiss() - factory = plugin_info["factory"] - new_widget = factory(plugin_info, None, iter(G.host.profiles)) - G.host.switchWidget(self, new_widget) - - def onHeaderInput(self): - log.info(u"header input text entered") - - def onHeaderInputComplete(self, wid, text): - return - - def on_touch_down(self, touch): - if self.collide_point(*touch.pos): - G.host.selected_widget = self - super(CagouWidget, self).on_touch_down(touch) - - def headerInputAddExtra(self, widget): - """add a widget on the right of header input""" - self.header_box.add_widget(widget) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/common.py --- a/src/cagou/core/common.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,46 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""common widgets, which can be reused everywhere""" - -from kivy.uix.image import Image -from kivy.uix.behaviors import ButtonBehavior -from kivy.uix.boxlayout import BoxLayout -from cagou import G - - -class IconButton(ButtonBehavior, Image): - pass - - -class JidWidget(ButtonBehavior, BoxLayout): - - def __init__(self, jid, profile, **kwargs): - self.jid = jid - self.profile = profile - self.nick = kwargs.get('nick') - super(JidWidget, self).__init__(**kwargs) - - def getImage(self, wid): - host = G.host - if host.contact_lists[self.profile].isRoom(self.jid.bare): - wid.opacity = 0 - return "" - else: - return host.getAvatar(self.jid, profile=self.profile) or host.getDefaultAvatar(self.jid) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/config.py --- a/src/cagou/core/config.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -"""This module keep an open instance of sat configuration""" - -from sat.tools import config -sat_conf = config.parseMainConf() - - -def getConfig(section, name, default): - return config.getConfig(sat_conf, section, name, default) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/constants.py --- a/src/cagou/core/constants.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Primitivus: a SAT frontend -# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from sat_frontends.quick_frontend import constants - - -class Const(constants.Const): - APP_NAME = u"Cagou" - LOG_OPT_SECTION = APP_NAME.lower() - CONFIG_SECTION = APP_NAME.lower() - WID_SELECTOR = u'selector' - ICON_SIZES = (u'small', u'medium') # small = 32, medium = 44 - DEFAULT_WIDGET_ICON = u'{media}/misc/black.png' - - PLUG_TYPE_WID = u'wid' - PLUG_TYPE_TRANSFER = u'transfer' - - TRANSFER_UPLOAD = u"upload" - TRANSFER_SEND = u"send" diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/image.py --- a/src/cagou/core/image.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from kivy.uix import image as kivy_img -from kivy.core.image import Image as CoreImage -from kivy.resources import resource_find -import io -import PIL - - -class Image(kivy_img.Image): - """Image widget which accept source without extension""" - - def texture_update(self, *largs): - if not self.source: - self.texture = None - else: - filename = resource_find(self.source) - self._loops = 0 - if filename is None: - return log.error('Image: Error reading file {filename}'. - format(filename=self.source)) - mipmap = self.mipmap - if self._coreimage is not None: - self._coreimage.unbind(on_texture=self._on_tex_change) - try: - self._coreimage = ci = CoreImage(filename, mipmap=mipmap, - anim_delay=self.anim_delay, - keep_data=self.keep_data, - nocache=self.nocache) - except Exception as e: - # loading failed probably because of unmanaged extention, - # we try our luck with with PIL - try: - im = PIL.Image.open(filename) - ext = im.format.lower() - del im - # we can't use im.tobytes as it would use the - # internal decompressed representation from pillow - # and im.save would need processing to handle format - data = io.BytesIO(open(filename, "rb").read()) - cache_filename = u"{}.{}".format(filename,ext) # needed for kivy's Image to use cache - self._coreimage = ci = CoreImage(data, ext=ext, - filename=cache_filename, mipmap=mipmap, - anim_delay=self.anim_delay, - keep_data=self.keep_data, - nocache=self.nocache) - except Exception as e: - log.warning(u"Can't load image: {}".format(e)) - self._coreimage = ci = None - - if ci: - ci.bind(on_texture=self._on_tex_change) - self.texture = ci.texture - - -class AsyncImage(kivy_img.AsyncImage): - """AsyncImage which accept file:// schema""" - - def _load_source(self, *args): - if self.source.startswith('file://'): - self.source = self.source[7:] - else: - super(AsyncImage, self)._load_source(*args) - diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/kivy_hack.py --- a/src/cagou/core/kivy_hack.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -CONF_KIVY_LEVEL = 'log_kivy_level' - - -def do_hack(): - """work around Kivy hijacking of logs and arguments""" - # we remove args so kivy doesn't use them - # this is need to avoid kivy breaking QuickApp args handling - import sys - ori_argv = sys.argv[:] - sys.argv = sys.argv[:1] - from constants import Const as C - from sat.core import log_config - log_config.satConfigure(C.LOG_BACKEND_STANDARD, C) - - import config - kivy_level = config.getConfig(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 dummy: 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(u"Unknown value for {name}: {value}".format(name=CONF_KIVY_LEVEL, value=kivy_level)) - - # during import kivy set its logging stuff - import kivy - kivy # to avoid pyflakes warning - - # we want to separate kivy logs from other logs - logging.root = root_logger - from kivy import logger - sys.stderr = logger.previous_stderr - - # we restore original methods - kivy_logger.addHandler = ori_addHandler - kivy_logger.setLevel = ori_setLevel - - # we restore original arguments - sys.argv = ori_argv diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/menu.py --- a/src/cagou/core/menu.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,239 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core.i18n import _ -from sat.core import log as logging -log = logging.getLogger(__name__) -from cagou.core.constants import Const as C -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.label import Label -from kivy.uix.popup import Popup -from kivy import properties -from kivy.garden import contextmenu -from sat_frontends.quick_frontend import quick_menus -from cagou import G -import webbrowser - -ABOUT_TITLE = _(u"About {}".format(C.APP_NAME)) -ABOUT_CONTENT = _(u"""Cagou (Salut à Toi) v{} - -Cagou is a libre communication tool based on libre standard XMPP. - -Cagou is part of the "Salut à Toi" project -more informations at [color=5500ff][ref=website]salut-a-toi.org[/ref][/color] -""").format(C.APP_VERSION) - - -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 MainMenu(contextmenu.AppMenu): - pass - - -class MenuItem(contextmenu.ContextMenuTextItem): - item = properties.ObjectProperty() - - def on_item(self, instance, item): - self.text = item.name - - def on_release(self): - super(MenuItem, self).on_release() - self.parent.hide() - selected = G.host.selected_widget - profile = None - if selected is not None: - try: - profile = selected.profile - except AttributeError: - pass - - if profile is None: - try: - profile = list(selected.profiles)[0] - except (AttributeError, IndexError): - try: - profile = list(G.host.profiles)[0] - except IndexError: - log.warning(u"Can't find profile") - self.item.call(selected, profile) - - -class MenuSeparator(contextmenu.ContextMenuDivider): - pass - - -class RootMenuContainer(contextmenu.AppMenuTextItem): - pass - - -class MenuContainer(contextmenu.ContextMenuTextItem): - pass - - -class MenusWidget(BoxLayout): - - def update(self, type_, caller=None): - """Method to call when menus have changed - - @param type_(unicode): menu type like in sat.core.sat_main.importMenu - @param caller(Widget): instance linked to the menus - """ - self.menus_container = G.host.menus.getMainContainer(type_) - self.createMenus(caller) - - def _buildMenus(self, container, caller=None): - """Recursively build menus of the container - - @param container(quick_menus.MenuContainer): menu container - @param caller(Widget): instance linked to the menus - """ - if caller is None: - main_menu = MainMenu() - self.add_widget(main_menu) - caller = main_menu - else: - context_menu = contextmenu.ContextMenu() - caller.add_widget(context_menu) - # FIXME: next line is needed after parent is set to avoid a display bug in contextmenu - # TODO: fix this upstream - context_menu._on_visible(False) - - caller = context_menu - - for child in container.getActiveMenus(): - if isinstance(child, quick_menus.MenuContainer): - if isinstance(caller, MainMenu): - menu_container = RootMenuContainer() - else: - menu_container = MenuContainer() - menu_container.text = child.name - caller.add_widget(menu_container) - self._buildMenus(child, caller=menu_container) - elif isinstance(child, quick_menus.MenuSeparator): - wid = MenuSeparator() - caller.add_widget(wid) - elif isinstance(child, quick_menus.MenuItem): - wid = MenuItem(item=child) - caller.add_widget(wid) - else: - log.error(u"Unknown child type: {}".format(child)) - - def createMenus(self, caller): - self.clear_widgets() - self._buildMenus(self.menus_container, caller) - - def onAbout(self): - about = AboutPopup() - about.title = ABOUT_TITLE - about.content = AboutContent(text=ABOUT_CONTENT, markup=True) - about.open() - - -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 TransferMenu(BoxLayout): - """transfer menu which handle display and callbacks""" - # callback will be called with path to file to transfer - callback = properties.ObjectProperty() - # cancel callback need to remove the widget for UI - # will be called with the widget to remove as argument - cancel_cb = properties.ObjectProperty() - # profiles if set will be sent to transfer widget, may be used to get specific files - profiles = properties.ObjectProperty() - transfer_txt = _(u"Beware! The file will be sent to your server and stay unencrypted there\nServer admin(s) can see the file, and they choose how, when and if it will be deleted") - send_txt = _(u"The file will be sent unencrypted directly to your contact (without transiting by the server), except in some cases") - items_layout = properties.ObjectProperty() - - def __init__(self, **kwargs): - super(TransferMenu, self).__init__(**kwargs) - if self.cancel_cb is None: - self.cancel_cb = self.onTransferCancelled - if self.profiles is None: - self.profiles = iter(G.host.profiles) - for plug_info in G.host.getPluggedWidgets(type_=C.PLUG_TYPE_TRANSFER): - item = TransferItem( - plug_info = plug_info - ) - self.items_layout.add_widget(item) - - def show(self, caller_wid=None): - self.visible = True - G.host.app.root.add_widget(self) - - 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.parent.remove_widget(self) - else: - return super(TransferMenu, self).on_touch_down(touch) - return True - - def _closeUI(self, wid): - G.host.closeUI() - - def onTransferCancelled(self, wid, cleaning_cb=None): - self._closeUI(wid) - if cleaning_cb is not None: - cleaning_cb() - - def do_callback(self, plug_info): - self.parent.remove_widget(self) - if self.callback is None: - log.warning(u"TransferMenu callback is not set") - else: - wid = None - external = plug_info.get('external', False) - def onTransferCb(file_path, cleaning_cb=None): - if not external: - self._closeUI(wid) - self.callback( - file_path, - cleaning_cb, - transfer_type = C.TRANSFER_UPLOAD if self.ids['upload_btn'].state == "down" else C.TRANSFER_SEND) - wid = plug_info['factory'](plug_info, onTransferCb, self.cancel_cb, self.profiles) - if not external: - G.host.showExtraUI(wid) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/profile_manager.py --- a/src/cagou/core/profile_manager.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,179 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from .constants import Const as C -from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager -from kivy.uix.boxlayout import BoxLayout -from kivy.uix import listview -from kivy.uix.button import Button -from kivy.uix.screenmanager import ScreenManager, Screen -from kivy.adapters import listadapter -from kivy.metrics import sp -from kivy import properties -from cagou import G - - -class ProfileItem(listview.ListItemButton): - pass - - -class ProfileListAdapter(listadapter.ListAdapter): - - def __init__(self, pm, *args, **kwargs): - super(ProfileListAdapter, self).__init__(*args, **kwargs) - self.pm = pm - - def closeUI(self, xmlui): - self.pm.screen_manager.transition.direction = 'right' - self.pm.screen_manager.current = 'profiles' - - def showUI(self, xmlui): - xmlui.setCloseCb(self.closeUI) - if xmlui.type == 'popup': - xmlui.bind(on_touch_up=lambda obj, value: self.closeUI(xmlui)) - self.pm.xmlui_screen.clear_widgets() - self.pm.xmlui_screen.add_widget(xmlui) - self.pm.screen_manager.transition.direction = 'left' - self.pm.screen_manager.current = 'xmlui' - - def select_item_view(self, view): - def authenticate_cb(data, cb_id, profile): - if C.bool(data.pop('validated', C.BOOL_FALSE)): - super(ProfileListAdapter, self).select_item_view(view) - G.host.actionManager(data, callback=authenticate_cb, ui_show_cb=self.showUI, profile=profile) - - G.host.launchAction(C.AUTHENTICATE_PROFILE_ID, callback=authenticate_cb, profile=view.text) - - -class ConnectButton(Button): - - def __init__(self, profile_screen): - self.profile_screen = profile_screen - self.pm = profile_screen.pm - super(ConnectButton, self).__init__() - - -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=u'new_profile') - self.pm = pm - - def onCreationFailure(self, failure): - msg = [l for l in unicode(failure).split('\n') if l][-1] - self.error_msg = unicode(msg) - - def onCreationSuccess(self, profile): - self.pm.profiles_screen.reload() - G.host.bridge.profileStartSession(self.password.text, profile, callback=lambda dummy: self._sessionStarted(profile), errback=self.onCreationFailure) - - def _sessionStarted(self, profile): - jid = self.jid.text.strip() - G.host.bridge.setParam("JabberID", jid, "Connection", -1, profile) - G.host.bridge.setParam("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.asyncCreateProfile(name, self.password.text, callback=lambda: self.onCreationSuccess(name), errback=self.onCreationFailure) - - -class DeleteProfilesScreen(Screen): - - def __init__(self, pm): - self.pm = pm - super(DeleteProfilesScreen, self).__init__(name=u'delete_profiles') - - def doDelete(self): - """This method will delete *ALL* selected profiles""" - to_delete = self.pm.getProfiles() - deleted = [0] - - def deleteInc(): - 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(u"Deleteing profile [{}]".format(profile)) - G.host.bridge.asyncDeleteProfile(profile, callback=deleteInc, errback=deleteInc) - - -class ProfilesScreen(Screen): - layout = properties.ObjectProperty(None) - - def __init__(self, pm): - self.pm = pm - self.list_adapter = ProfileListAdapter(pm, - data=[], - cls=ProfileItem, - args_converter=self.converter, - selection_mode='multiple', - allow_empty_selection=True, - ) - super(ProfilesScreen, self).__init__(name=u'profiles') - self.layout.add_widget(listview.ListView(adapter=self.list_adapter)) - connect_btn = ConnectButton(self) - self.layout.add_widget(connect_btn) - self.reload() - - def _profilesListGetCb(self, profiles): - profiles.sort() - self.list_adapter.data = profiles - - def converter(self, row_idx, obj): - return {'text': obj, - 'size_hint_y': None, - 'height': sp(40)} - - def reload(self): - """Reload profiles list""" - G.host.bridge.profilesListGet(callback=self._profilesListGetCb) - - -class ProfileManager(QuickProfileManager, BoxLayout): - - 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=u'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 getProfiles(self): - return [pi.text for pi in self.profiles_screen.list_adapter.selection] diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/simple_xhtml.py --- a/src/cagou/core/simple_xhtml.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,498 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from kivy.uix.stacklayout import StackLayout -from kivy.uix.label import Label -from kivy.utils import escape_markup -from kivy import properties -from xml.etree import ElementTree as ET -from sat_frontends.tools import css_color, strings as sat_strings -from cagou.core.image import AsyncImage -import webbrowser - - -class Escape(unicode): - """Class used to mark that a message need to be escaped""" - - def __init__(self, text): - super(Escape, self).__init__(text) - - -class SimpleXHTMLWidgetEscapedText(Label): - - def _addUrlMarkup(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 = u'link_' + unicode(links) - url = m.group() - text_elts.append(u'[color=5500ff][ref={link}]{url}[/ref][/color]'.format( - link = link_key, - url = url - )) - 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 = u''.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._addUrlMarkup(text) - - def on_ref_press(self, ref): - url = self.ref_urls[ref] - webbrowser.open(url) - - -class SimpleXHTMLWidgetText(Label): - pass - - -class SimpleXHTMLWidgetImage(AsyncImage): - # following properties are desired height/width - # i.e. the ones specified in height/width attributes of - # (or wanted for whatever reason) - # set to 0 to ignore them - target_height = properties.NumericProperty() - target_width = properties.NumericProperty() - - def _get_parent_container(self): - """get parent SimpleXHTMLWidget instance - - @param warning(bool): if True display a log.error if nothing found - @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance - """ - parent = self.parent - while parent and not isinstance(parent, SimpleXHTMLWidget): - parent = parent.parent - if parent is None: - log.error(u"no SimpleXHTMLWidget parent found") - return parent - - def _on_source_load(self, value): - # this method is called when image is loaded - super(SimpleXHTMLWidgetImage, self)._on_source_load(value) - if self.parent is not None: - container = self._get_parent_container() - # image is loaded, we need to recalculate size - self.on_container_width(container, container.width) - - def on_container_width(self, container, container_width): - """adapt size according to container width - - called when parent container (SimpleXHTMLWidget) width change - """ - target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height) - padding = container.padding - padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0] - width = container_width - padding_h - if target_size[0] < width: - self.size = target_size - else: - height = width / self.image_ratio - self.size = (width, height) - - def on_parent(self, instance, parent): - if parent is not None: - container = self._get_parent_container() - container.bind(width=self.on_container_width) - - -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) - content_width = properties.NumericProperty(0) - - # 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 not markup - will be used - """ - self.clear_widgets() - if isinstance(xhtml, Escape): - label = SimpleXHTMLWidgetEscapedText(text=xhtml, color=self.color) - self.bind(color=label.setter('color')) - self.bind(bold=label.setter('bold')) - self.add_widget(label) - else: - xhtml = ET.fromstring(xhtml.encode('utf-8')) - self.current_wid = None - self.styles = [] - self._callParseMethod(xhtml) - - def escape(self, text): - """mark that a text need to be escaped (i.e. no markup)""" - return Escape(text) - - # sizing - - def on_width(self, instance, width): - if len(self.children) == 1: - wid = self.children[0] - if isinstance(wid, Label): - # we have simple text - try: - full_width = wid._full_width - except AttributeError: - # on first time, we need the required size - # for the full text, without width limit - wid.size_hint = (None, None) - wid.texture_update() - full_width = wid._full_width = wid.texture_size[0] - - if full_width > width: - wid.text_size = width, None - wid.width = width - else: - wid.text_size = None, None - wid.texture_update() - wid.width = wid.texture_size[0] - self.content_width = wid.width + self.padding[0] + self.padding[2] - else: - wid.size_hint = (1, None) - wid.height = 100 - self.content_width = self.width - else: - self._do_complexe_sizing(width) - - def _do_complexe_sizing(self, width): - try: - self.splitted - except AttributeError: - # XXX: to make things easier, we split labels in words - log.debug(u"split start") - children = self.children[::-1] - self.clear_widgets() - for child in children: - if isinstance(child, Label): - log.debug(u"label before split: {}".format(child.text)) - styles = [] - tag = False - new_text = [] - current_tag = [] - current_value = [] - current_wid = self._createText() - 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 == u']': - current_tag_s = u''.join(current_tag) - current_style = (current_tag_s, u''.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 == u'/': - close = True - elif c == u'=': - 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 == u'[': - new_text.append(c) - tag = True - elif c == u' ': - # new word, we do a new widget - new_text.append(u' ') - for t, v in reversed(styles): - new_text.append(u'[/{}]'.format(t)) - current_wid.text = u''.join(new_text) - new_text = [] - self.add_widget(current_wid) - log.debug(u"new widget: {}".format(current_wid.text)) - current_wid = self._createText() - for t, v in styles: - new_text.append(u'[{tag}{value}]'.format( - tag = t, - value = u'={}'.format(v) if v else u'')) - 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(u'[/{}]'.format(t)) - current_wid.text = u''.join(close_styles) - self.add_widget(current_wid) - log.debug(u"new widget: {}".format(current_wid.text)) - else: - # non Label widgets, we just add them - self.add_widget(child) - self.splitted = True - log.debug(u"split OK") - - # we now set the content width - # FIXME: for now we just use the full width - self.content_width = width - - # XHTML parsing methods - - def _callParseMethod(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, "xhtml_{}".format(e.tag)) - except AttributeError: - log.warning(u"Unhandled XHTML tag: {}".format(e.tag)) - method = self.xhtml_generic - method(e) - - def _addStyle(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._getLabel() - label.text += u'[{tag}{value}]'.format( - tag = tag, - value = u'={}'.format(value) if value else '' - ) - if append_to_list: - self.styles.append((tag, value)) - - def _removeStyle(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._getLabel() - label.text += u'[/{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 _getLabel(self): - """get current Label if it exists, or create a new one""" - if not isinstance(self.current_wid, Label): - self._addLabel() - return self.current_wid - - def _addLabel(self): - """add a new Label - - current styles will be closed and reopened if needed - """ - self._closeLabel() - self.current_wid = self._createText() - for tag, value in self.styles: - self._addStyle(tag, value, append_to_list=False) - self.add_widget(self.current_wid) - - def _createText(self): - label = SimpleXHTMLWidgetText(color=self.color, markup=True) - self.bind(color=label.setter('color')) - label.bind(texture_size=label.setter('size')) - return label - - def _closeLabel(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._removeStyle(tag, remove_from_list=False) - - def _parseCSS(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(u';') - for style in styles: - try: - prop, value = style.split(u':') - except ValueError: - log.warning(u"can't parse style: {}".format(style)) - continue - prop = prop.strip().replace(u'-', '_') - value = value.strip() - try: - method = getattr(self, "css_{}".format(prop)) - except AttributeError: - log.warning(u"Unhandled CSS: {}".format(prop)) - else: - method(e, value) - self._css_styles = self.styles[styles_limit:] - - def _closeCSS(self): - """removed CSS styles - - styles in self._css_styles will be removed - and the attribute will be deleted - """ - for tag, dummy in reversed(self._css_styles): - self._removeStyle(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, basestring): - tag, value = markup, None - else: - tag, value = markup - self._addStyle(tag, value) - style_ = 'style' in elem.attrib and style - if style_: - self._parseCSS(elem) - - # then content - if elem.text: - self._getLabel().text += escape_markup(elem.text) - - # we parse the children - for child in elem: - self._callParseMethod(child) - - # closing CSS style and markup - if style_: - self._closeCSS() - if markup is not None: - self._removeStyle(tag) - - # and the tail, which is regular text - if elem.tail: - self._getLabel().text += escape_markup(elem.tail) - - # method handling XHTML elements - - def xhtml_br(self, elem): - label = self._getLabel() - label.text+='\n' - self.xhtml_generic(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(u" element without src: {}".format(ET.tostring(elem))) - return - try: - target_height = int(elem.get(u'height', 0)) - except ValueError: - log.warning(u"Can't parse image height: {}".format(elem.get(u'height'))) - target_height = 0 - try: - target_width = int(elem.get(u'width', 0)) - except ValueError: - log.warning(u"Can't parse image width: {}".format(elem.get(u'width'))) - target_width = 0 - - img = SimpleXHTMLWidgetImage(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._addStyle(u"color", css_color.parse(value)) - - def css_text_decoration(self, elem, value): - if value == u'underline': - log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) - # FIXME: activate when 1.9.2 is out - # self._addStyle('u') - elif value == u'line-through': - log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) - # FIXME: activate when 1.9.2 is out - # self._addStyle('s') - else: - log.warning(u"unhandled text decoration: {}".format(value)) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/widgets_handler.py --- a/src/cagou/core/widgets_handler.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,230 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat_frontends.quick_frontend import quick_widgets -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.button import Button -from kivy.uix.carousel import Carousel -from kivy.metrics import dp -from kivy import properties -from cagou import G - - -CAROUSEL_SCROLL_DISTANCE = dp(50) -CAROUSEL_SCROLL_TIMEOUT = 80 -NEW_WIDGET_DIST = 10 -REMOVE_WIDGET_DIST = NEW_WIDGET_DIST - - -class WHSplitter(Button): - horizontal=properties.BooleanProperty(True) - thickness=properties.NumericProperty(dp(20)) - split_move = None # we handle one split at a time, so we use a class attribute - - def __init__(self, handler, **kwargs): - super(WHSplitter, self).__init__(**kwargs) - self.handler = handler - - def getPos(self, touch): - if self.horizontal: - relative_y = self.handler.to_local(*touch.pos, relative=True)[1] - return self.handler.height - relative_y - else: - return touch.x - - def on_touch_move(self, touch): - if self.split_move is None and self.collide_point(*touch.opos): - WHSplitter.split_move = self - - if self.split_move is self: - pos = self.getPos(touch) - if pos > NEW_WIDGET_DIST: - # we are above minimal distance, we resize the widget - self.handler.setWidgetSize(self.horizontal, pos) - - def on_touch_up(self, touch): - if self.split_move is self: - pos = self.getPos(touch) - if pos <= REMOVE_WIDGET_DIST: - # if we go under minimal distance, the widget is not wanted anymore - self.handler.removeWidget(self.horizontal) - WHSplitter.split_move=None - return super(WHSplitter, self).on_touch_up(touch) - - -class HandlerCarousel(Carousel): - - def __init__(self, *args, **kwargs): - super(HandlerCarousel, self).__init__( - *args, - direction='right', - loop=True, - **kwargs) - self._former_slide = None - self.bind(current_slide=self.onSlideChange) - self._slides_update_lock = False - - def changeWidget(self, new_widget): - """Change currently displayed widget - - slides widgets will be updated - """ - # slides update need to be blocked to avoid the update in onSlideChange - # which would mess the removal of current widgets - self._slides_update_lock = True - current = self.current_slide - for w in self.slides: - if w == current or w == new_widget: - continue - if isinstance(w, quick_widgets.QuickWidget): - G.host.widgets.deleteWidget(w) - self.clear_widgets() - self.add_widget(new_widget) - self._slides_update_lock = False - self.updateHiddenSlides() - - def onSlideChange(self, handler, new_slide): - if isinstance(self._former_slide, quick_widgets.QuickWidget): - G.host.removeVisibleWidget(self._former_slide) - self._former_slide = new_slide - if isinstance(new_slide, quick_widgets.QuickWidget): - G.host.addVisibleWidget(new_slide) - self.updateHiddenSlides() - - def hiddenList(self, visible_list): - """return widgets of same class as holded one which are hidden - - @param visible_list(list[QuickWidget]): widgets visible - @return (iter[QuickWidget]): widgets hidden - """ - added = [(w.targets, w.profiles) for w in visible_list] # we want to avoid recreated widgets - for w in G.host.widgets.getWidgets(self.current_slide.__class__, profiles=self.current_slide.profiles): - if w in visible_list or (w.targets, w.profiles) in added: - continue - yield w - - def widgets_sort(self, widget): - """method used as key to sort the widgets - - order of the widgets when changing slide is affected - @param widget(QuickWidget): widget to sort - @return: a value which will be used for sorting - """ - try: - return unicode(widget.target).lower() - except AttributeError: - return unicode(list(widget.targets)[0]).lower() - - def updateHiddenSlides(self): - """adjust carousel slides according to visible widgets""" - if self._slides_update_lock: - return - if not isinstance(self.current_slide, quick_widgets.QuickWidget): - return - # lock must be used here to avoid recursions - self._slides_update_lock = True - visible_list = G.host.getVisibleList(self.current_slide.__class__) - hidden = list(self.hiddenList(visible_list)) - slides_sorted = sorted(hidden + [self.current_slide], key=self.widgets_sort) - to_remove = set(self.slides).difference({self.current_slide}) - for w in to_remove: - self.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(self.current_slide) - try: - next_slide = slides_sorted[current_idx+1] - except IndexError: - next_slide = slides_sorted[0] - self.add_widget(G.host.getOrClone(next_slide)) - if len(hidden)>1: - previous_slide = slides_sorted[current_idx-1] - self.add_widget(G.host.getOrClone(previous_slide)) - - if len(self.slides) == 1: - # we block carousel with high scroll_distance to avoid swiping - # when the is not other instance of the widget - self.scroll_distance=2**32 - self.scroll_timeout=0 - else: - self.scroll_distance = CAROUSEL_SCROLL_DISTANCE - self.scroll_timeout=CAROUSEL_SCROLL_TIMEOUT - self._slides_update_lock = False - - -class WidgetsHandler(BoxLayout): - - def __init__(self, wid=None, **kw): - if wid is None: - wid=self.default_widget - self.vert_wid = self.hor_wid = None - BoxLayout.__init__(self, orientation="vertical", **kw) - self.blh = BoxLayout(orientation="horizontal") - self.blv = BoxLayout(orientation="vertical") - self.blv.add_widget(WHSplitter(self)) - self.carousel = HandlerCarousel() - self.blv.add_widget(self.carousel) - self.blh.add_widget(WHSplitter(self, horizontal=False)) - self.blh.add_widget(self.blv) - self.add_widget(self.blh) - self.changeWidget(wid) - - @property - def default_widget(self): - return G.host.default_wid['factory'](G.host.default_wid, None, None) - - @property - def cagou_widget(self): - """get holded CagouWidget""" - return self.carousel.current_slide - - def changeWidget(self, new_widget): - self.carousel.changeWidget(new_widget) - - def removeWidget(self, vertical): - if vertical and self.vert_wid is not None: - self.remove_widget(self.vert_wid) - self.vert_wid.onDelete() - self.vert_wid = None - elif self.hor_wid is not None: - self.blh.remove_widget(self.hor_wid) - self.hor_wid.onDelete() - self.hor_wid = None - - def setWidgetSize(self, vertical, size): - if vertical: - if self.vert_wid is None: - self.vert_wid = WidgetsHandler(self.default_widget, size_hint=(1, None)) - self.add_widget(self.vert_wid, len(self.children)) - self.vert_wid.height=size - else: - if self.hor_wid is None: - self.hor_wid = WidgetsHandler(self.default_widget, size_hint=(None, 1)) - self.blh.add_widget(self.hor_wid, len(self.blh.children)) - self.hor_wid.width=size - - def onDelete(self): - # when this handler is deleted, we need to delete the holded CagouWidget - cagou_widget = self.cagou_widget - if isinstance(cagou_widget, quick_widgets.QuickWidget): - G.host.removeVisibleWidget(cagou_widget) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/core/xmlui.py --- a/src/cagou/core/xmlui.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,561 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: a SàT frontend -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from sat.core.i18n import _ -from .constants import Const as C -from sat.core.log import getLogger -log = getLogger(__name__) -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.dropdown import DropDown -from kivy.uix.switch import Switch -from kivy import properties -from cagou import G - - -## Widgets ## - - -class TextInputOnChange(object): - - def __init__(self): - self._xmlui_onchange_cb = None - self._got_focus = False - - def _xmluiOnChange(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): - log.debug("on_text: %s" % 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, multiline=False) - TextInputOnChange.__init__(self) - self.readonly = read_only - - def _xmluiSetValue(self, value): - self.text = value - - def _xmluiGetValue(self): - return self.text - - -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 _xmluiOnClick(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): - super(ListWidgetItem, self).on_release() - parent = self.parent - while parent is not None and not isinstance(parent, DropDown): - parent = parent.parent - - if parent is not None and parent.attach_to is not None: - parent.select(self) - - @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, Button): - - def __init__(self, _xmlui_parent, options, selected, flags): - Button.__init__(self) - self.text = _(u"open list") - self._dropdown = DropDown() - self._dropdown.auto_dismiss = False - self._dropdown.bind(on_select = self.on_select) - self.multi = 'single' not in flags - self._dropdown.dismiss_on_select = not self.multi - self._values = [] - for option in options: - self.addValue(option) - self._xmluiSelectValues(selected) - self._on_change = None - - @property - def items(self): - return self._dropdown.children[0].children - - def on_touch_down(self, touch): - # we simulate auto-dismiss ourself because dropdown - # will dismiss even if attached button is touched - # resulting in a dismiss just before a toggle in on_release - # so the dropbox would always be opened, we don't want that! - if super(ListWidget, self).on_touch_down(touch): - return True - if self._dropdown.parent: - self._dropdown.dismiss() - - def on_release(self): - if self._dropdown.parent is not None: - # we want to close a list already opened - self._dropdown.dismiss() - else: - self._dropdown.open(self) - - def on_select(self, drop_down, item): - if not self.multi: - self._xmluiSelectValues([item.value]) - if self._on_change is not None: - self._on_change(self) - - def addValue(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._dropdown.add_widget(item) - - def _xmluiSelectValue(self, value): - self._xmluiSelectValues([value]) - - def _xmluiSelectValues(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 _xmluiGetSelectedValues(self): - return [item.value for item in self.items if item.selected] - - def _xmluiAddValues(self, values, select=True): - values = set(values).difference([c.value for c in self.items]) - for v in values: - self.addValue(v, select) - - def _xmluiOnChange(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 _xmluiSetValue(self, value): - self.text = value - - def _xmluiGetValue(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 _xmluiSetValue(self, value): - self.active = value - - def _xmluiGetValue(self): - return C.BOOL_TRUE if self.active else C.BOOL_FALSE - - def _xmluiOnChange(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 _xmluiSetValue(self, value): - self.text = value - - def _xmluiGetValue(self): - return self.text - - -## Containers ## - - -class VerticalContainer(xmlui.VerticalContainer, GridLayout): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - GridLayout.__init__(self) - - def _xmluiAppend(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 _xmluiAppend(self, widget): - self.add_widget(widget) - - -class LabelContainer(PairsContainer, xmlui.LabelContainer): - pass - - -class TabsPanelContainer(TabbedPanelItem): - - def _xmluiAppend(self, widget): - self.add_widget(widget) - - -class TabsContainer(xmlui.TabsContainer, TabbedPanel): - - def __init__(self, xmlui_parent): - self.xmlui_parent = xmlui_parent - xmlui_panel = xmlui_parent - while not isinstance(xmlui_panel, XMLUIPanel): - xmlui_panel = xmlui_panel.xmlui_parent - xmlui_panel.addPostTreat(self._postTreat) - TabbedPanel.__init__(self, do_default_tab=False) - - def _xmluiAddTab(self, label, selected): - tab = TabsPanelContainer(text=label) - self.add_widget(tab) - return tab - - def _postTreat(self): - """bind minimum height of tabs' content so self.height is adapted""" - # we need to do this in postTreat because contents exists after UI construction - for t in self.tab_list: - t.content.bind(minimum_height=self._updateHeight) - - def _updateHeight(self, instance, height): - """Called after UI is constructed (so height can be calculated)""" - # needed because TabbedPanel doesn't have a minimum_height property - self.height = max([t.content.minimum_height for t in self.tab_list]) + self.tab_height + 5 - - -class AdvancedListRow(GridLayout): - 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(u"Can't find parent AdvancedListContainer") - else: - if parent.selectable: - self.selected = parent._xmluiToggleSelected(self) - - return super(AdvancedListRow, self).on_touch_down(touch) - - -class AdvancedListContainer(xmlui.AdvancedListContainer, GridLayout): - - def __init__(self, xmlui_parent, columns, selectable='no'): - self.xmlui_parent = xmlui_parent - GridLayout.__init__(self) - self._columns = columns - self.selectable = selectable != 'no' - self._current_row = None - self._selected = [] - self._xmlui_select_cb = None - - def _xmluiToggleSelected(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 _xmluiAppend(self, widget): - if self._current_row is None: - log.error(u"No row set, ignoring append") - return - self._current_row.add_widget(widget) - - def _xmluiAddRow(self, idx): - self._current_row = AdvancedListRow() - self._current_row.cols = self._columns - self._current_row.index = idx - self.add_widget(self._current_row) - - def _xmluiGetSelectedWidgets(self): - return self._selected - - def _xmluiGetSelectedIndex(self): - if not self._selected: - return None - return self._selected[0].index - - def _xmluiOnSelect(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 _xmluiShow(self): - G.host.addNote(self.title, self.message, self.level) - - -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 _xmluiShow(self): - G.host.addNotifUI(self) - - def _xmluiClose(self): - # FIXME: notif UI is not removed if dialog is not shown yet - G.host.closeUI() - - def onSelect(self, path): - try: - path = path[0] - except IndexError: - path = None - if not path: - self._xmluiCancelled() - else: - self._xmluiValidated({'path': path}) - - def show(self, *args, **kwargs): - assert kwargs["force"] - G.host.showUI(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 XMLUIPanelGrid(GridLayout): - pass - -class XMLUIPanel(xmlui.XMLUIPanel, ScrollView): - widget_factory = WidgetFactory() - - def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, ignore=None, profile=C.PROF_KEY_NONE): - ScrollView.__init__(self) - self.close_cb = None - self._grid = XMLUIPanelGrid() - self._post_treats = [] # list of callback to call after UI is constructed - ScrollView.add_widget(self, self._grid) - xmlui.XMLUIPanel.__init__(self, - host, - parsed_xml, - title=title, - flags=flags, - callback=callback, - ignore=ignore, - profile=profile) - - def add_widget(self, wid): - self._grid.add_widget(wid) - - def setCloseCb(self, close_cb): - self.close_cb = close_cb - - def _xmluiClose(self): - if self.close_cb is not None: - self.close_cb(self) - else: - G.host.closeUI() - - def onParamChange(self, ctrl): - super(XMLUIPanel, self).onParamChange(ctrl) - self.save_btn.disabled = False - - def addPostTreat(self, callback): - self._post_treats.append(callback) - - def _postTreatCb(self): - for cb in self._post_treats: - cb() - del self._post_treats - - def _saveButtonCb(self, button): - button.disabled = True - self.onSaveParams(button) - - def constructUI(self, parsed_dom): - xmlui.XMLUIPanel.constructUI(self, parsed_dom, self._postTreatCb) - if self.xmlui_title: - self.add_widget(Title(text=self.xmlui_title)) - self.add_widget(self.main_cont) - if self.type == 'form': - submit_btn = FormButton(text=_(u"Submit")) - submit_btn.bind(on_press=self.onFormSubmitted) - self.add_widget(submit_btn) - if not 'NO_CANCEL' in self.flags: - cancel_btn = FormButton(text=_(u"Cancel")) - cancel_btn.bind(on_press=self.onFormCancelled) - self.add_widget(cancel_btn) - elif self.type == 'param': - self.save_btn = FormButton(text=_(u"Save"), disabled=True) - self.save_btn.bind(on_press=self._saveButtonCb) - self.add_widget(self.save_btn) - self.add_widget(Widget()) # to have elements on the top - - def show(self, *args, **kwargs): - if not self.user_action and not kwargs.get("force", False): - G.host.addNotifUI(self) - else: - G.host.showUI(self) - - -class XMLUIDialog(xmlui.XMLUIDialog): - dialog_factory = WidgetFactory() - - -xmlui.registerClass(xmlui.CLASS_PANEL, XMLUIPanel) -xmlui.registerClass(xmlui.CLASS_DIALOG, XMLUIDialog) -create = xmlui.create diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/images/button.png Binary file src/cagou/images/button.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/images/button_selected.png Binary file src/cagou/images/button_selected.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/cagou_widget.kv --- a/src/cagou/kv/cagou_widget.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,68 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - - -: - 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) - Image: - size_hint: None, 1 - source: root.plugin_info['icon_medium'] - allow_stretch: True - width: self.texture_size[0]*self.height/(self.texture_size[1] or 1) - Label: - size_hint: 1, 1 - text: root.plugin_info['name'] - bold: True - text_size: self.size - halign: "center" - valign: "middle" - -: - size_hint: 0.3, None - auto_width: False - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - -: - header_input: header_input - header_box: header_box - BoxLayout: - id: header_box - size_hint: 1, None - height: dp(32) - HeaderWidgetCurrent: - on_release: root.selector.open(self) - source: root.plugin_info['icon_small'] - size_hint: None, 1 - allow_stretch: True - width: self.texture_size[0]*self.height/(self.texture_size[1] or 1) - TextInput: - id: header_input - multiline: False - on_text_validate: root.onHeaderInput() - on_text: root.onHeaderInputComplete(*args) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/common.kv --- a/src/cagou/kv/common.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,37 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - - -: - size_hint: None, None - height: dp(70) - canvas.before: - Color: - rgba: 0.2, 0.2, 0.2, 1 - Rectangle: - pos: self.pos - size: self.size - Image: - source: root.getImage(self) - size_hint: None, None - size: dp(64), dp(64) - Label: - bold: True - text: root.jid - text_size: self.size - halign: 'left' - valign: 'middle' - padding_x: dp(20) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/menu.kv --- a/src/cagou/kv/menu.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,88 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -#:import _ sat.core.i18n._ - -: - text_size: self.size - halign: "center" - valign: "middle" - -: - title_align: "center" - size_hint: 0.8, 0.8 - -: - # following is need to fix a bug in contextmenu - size_hint: 1, None - -: - height: self.children[0].height if self.children else 30 - -: - cancel_handler_widget: self.parent - -: - items_layout: items_layout - orientation: "vertical" - pos_hint: {"top": 0.5} - size_hint: 1, 0.5 - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - BoxLayout: - size_hint: 1, None - height: dp(50) - ToggleButton: - id: upload_btn - text: _(u"upload") - group: "transfer" - state: "down" - ToggleButton: - id: send_btn - text: _(u"send") - group: "transfer" - Label: - size_hint: 1, 0.3 - text: root.transfer_txt if upload_btn.state == 'down' else root.send_txt - text_size: self.size - halign: 'center' - valign: 'top' - ScrollView: - do_scroll_x: False - StackLayout: - size_hint: 1, None - padding: 20, 0 - spacing: 15, 5 - id: items_layout - -: - 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: - text: root.plug_info['name'] - text_size: self.size - halign: "center" - valign: "top" diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/profile_manager.kv --- a/src/cagou/kv/profile_manager.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,168 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - - -: - Label: - text: "Profile Manager" - size_hint: 1,0.05 - -: - size_hint: 1, None - height: sp(30) - -: - multiline: False - size_hint: 1, None - height: sp(30) - write_tab: False - -: - size_hint: 1, 0.2 - - -: - profile_name: profile_name - jid: jid - password: password - - BoxLayout: - orientation: "vertical" - - Label: - text: "Creation of a new profile" - bold: True - size_hint: 1, 0.1 - Label: - text: root.error_msg - bold: True - size_hint: 1, 0.1 - 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, 0.2 - - Widget: - size_hint: 1, 0.2 - - 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: - - -: - BoxLayout: - orientation: "vertical" - - Label: - text: "Are you sure you want to delete the following profiles?" - size_hint: 1, 0.1 - - Label: - text: u'\n'.join([i.text for i in root.pm.profiles_screen.list_adapter.selection]) - bold: True - - Label: - text: u'/!\\ WARNING: this operation is irreversible' - color: 1,0,0,1 - bold: True - size_hint: 1, 0.2 - - GridLayout: - cols: 2 - - Button: - text: "Delete" - size_hint: 1, 0.2 - on_press: root.doDelete() - - Button: - text: "Cancel" - size_hint: 1, 0.2 - on_press: - root.pm.screen_manager.transition.direction = 'right' - root.pm.screen_manager.current = 'profiles' - - Widget: - - -: - layout: layout - BoxLayout: - id: layout - orientation: 'vertical' - - Label: - text: "Select a profile or create a new one" - size_hint: 1,0.05 - - GridLayout: - cols: 2 - size_hint: 1, 0.1 - Button: - size_hint: 1, 0.1 - text: "New" - on_press: - root.pm.screen_manager.transition.direction = 'left' - root.pm.screen_manager.current = 'new_profile' - Button: - disabled: not root.list_adapter.selection - text: "Delete" - size_hint: 1, 0.1 - on_press: - root.pm.screen_manager.transition.direction = 'left' - root.pm.screen_manager.current = 'delete_profiles' - - -: - text: "Connect" - size_hint: 1, 0.1 - disabled: not self.profile_screen.list_adapter.selection - on_press: self.pm._onConnectProfiles() - - -: - # FIXME: using cagou/images path for now, will use atlas later - background_normal: "cagou/images/button_selected.png" if self.is_selected else "cagou/images/button.png" - deselected_color: 1,1,1,1 - selected_color: 1,1,1,1 - color: 0,0,0,1 diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/root_widget.kv --- a/src/cagou/kv/root_widget.kv Thu Apr 05 15:59:50 2018 +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 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -#:import IconButton cagou.core.common.IconButton - -# : -# source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") -# size_hint: None, None -# size: self.texture_size - -: - text: self.message - -: - canvas.before: - Color: - rgba: 1, 1, 1, 1 - BorderImage: - pos: self.pos - size: self.size - source: 'atlas://data/images/defaulttheme/button' - size_hint: 1, None - text_size: self.width, None - halign: 'center' - height: self.texture_size[1] - padding: dp(2), dp(10) - -: - clear_btn: clear_btn.__self__ - auto_width: False - size_hint: 0.8, None - 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() - -: - manager: manager - notifs_icon: notifs_icon - size_hint: 1, None - height: dp(35) - IconButton: - source: app.expand("{media}/icons/muchoslava/png/cagou_profil_bleu_32.png") if root.notes else app.expand("{media}/misc/black.png") - allow_stretch: True - size_hint: None, 1 - width: self.norm_image_size[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_32.png") if self.notifs else app.expand("{media}/misc/black.png") - size_hint: None, 1 - width: self.norm_image_size[0] - -: - size_hint: 1, None - pos_hint: {'top': 1} - -: - root_body: root_body - root_menus: root_menus - # main body - RootBody: - id: root_body - orientation: "vertical" - size_hint: 1, None - height: root.height - root_menus.height - # general menus - # need to be added at the end so it's drawed above other widgets - RootMenus: - id: root_menus diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/simple_xhtml.kv --- a/src/cagou/kv/simple_xhtml.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - - -: - size_hint: None, None - size: self.texture_size - -: - size_hint: None, None - size: self.texture_size - -: - size_hint: None, None diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/widgets_handler.kv --- a/src/cagou/kv/widgets_handler.kv Thu Apr 05 15:59:50 2018 +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 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 . - -: - border: (3, 3, 3, 3) - horizontal_suff: '_h' if self.horizontal else '' - background_normal: 'atlas://data/images/defaulttheme/splitter{}{}'.format('_disabled' if self.disabled else '', self.horizontal_suff) - background_down: 'atlas://data/images/defaulttheme/splitter_down{}{}'.format('_disabled' if self.disabled else '', self.horizontal_suff) - size_hint: (1, None) if self.horizontal else (None, 1) - size: (100, self.thickness) if self.horizontal else (self.thickness, 100) - Image: - pos: root.pos - size: root.size - allow_stretch: True - source: 'atlas://data/images/defaulttheme/splitter_grip' + root.horizontal_suff diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/kv/xmlui.kv --- a/src/cagou/kv/xmlui.kv Thu Apr 05 15:59:50 2018 +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 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 . - -#:set common_height 30 -#:set button_height 50 - -: - size_hint: 1, None - height: dp(common_height) - - -: - size_hint: 1, None - height: dp(button_height) - - -: - size_hint: 1, 1 - - -: - size_hint: 1, None - height: dp(20) - canvas.before: - Color: - rgba: 1, 1, 1, 0.8 - Line - points: 0, dp(10), self.width, dp(10) - width: dp(3) - - -: - size_hint_y: None - height: dp(button_height) - - -: - size_hint: 1, None - height: dp(button_height) - - -: - canvas.before: - Color: - rgba: 1, 1, 1, 0.2 if self.global_index%2 else 0.1 - Rectangle: - pos: self.pos - size: self.size - size_hint: 1, None - height: self.minimum_height - rows: 1 - canvas.after: - Color: - rgba: 0, 0, 1, 0.5 if self.selected else 0 - Rectangle: - pos: self.pos - size: self.size - - -: - cols: 1 - size_hint: 1, None - height: self.minimum_height - - -: - cols: 1 - size_hint: 1, None - height: self.minimum_height - - -: - cols: 2 - size_hint: 1, None - height: self.minimum_height - - -: - size_hint: 1, None - height: 100 - - -: - size_hint: 1, None - height: dp(button_height) - - -: - 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.onSelect(file_chooser.selection) - Button: - size_hint: 1, None - height: dp(50) - text: "cancel" - on_release: root.onCancel() - - - -: - cols: 1 - size_hint: 1, None - height: self.minimum_height diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/__init__.py diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_android_gallery.py --- a/src/cagou/plugins/plugin_transfer_android_gallery.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,95 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from 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": _(u"gallery"), - "main": "AndroidGallery", - "platforms": ('android',), - "external": True, - "description": _(u"upload a photo from photo gallery"), - "icon_medium": u"{media}/icons/muchoslava/png/gallery_50.png", -} - - -class AndroidGallery(object): - - 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): - # 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(u"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(u'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) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_android_photo.py --- a/src/cagou/plugins/plugin_transfer_android_photo.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from 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": _(u"take photo"), - "main": "AndroidPhoto", - "platforms": ('android',), - "external": True, - "description": _(u"upload a photo from photo application"), - "icon_medium": u"{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.getTmpDir() - tmp_file = os.path.join(tmp_dir, filename) - log.debug(u"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 getTmpDir(self): - if sys.platform == "android": - dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() - return dcim_path - else: - return tempfile.mkdtemp() diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_android_video.py --- a/src/cagou/plugins/plugin_transfer_android_video.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from 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": _(u"take video"), - "main": "AndroidVideo", - "platforms": ('android',), - "external": True, - "description": _(u"upload a video from video application"), - "icon_medium": u"{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.getTmpDir() - tmp_file = os.path.join(tmp_dir, filename) - log.debug(u"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 getTmpDir(self): - if sys.platform == "android": - dcim_path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() - return dcim_path - else: - return tempfile.mkdtemp() diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_file.kv --- a/src/cagou/plugins/plugin_transfer_file.kv Thu Apr 05 15:59:50 2018 +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 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -#:import expanduser os.path.expanduser -#:import platform kivy.utils.platform - - -: - orientation: "vertical" - FileChooserListView: - id: filechooser - rootpath: "/" if platform == 'android' else expanduser('~') - Button: - text: "choose" - size_hint: 1, None - height: dp(50) - on_release: root.onTransmitOK(filechooser) - Button: - text: "cancel" - size_hint: 1, None - height: dp(50) - on_release: root.cancel_cb(root) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_file.py --- a/src/cagou/plugins/plugin_transfer_file.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,43 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from kivy.uix.boxlayout import BoxLayout -from kivy import properties - - -PLUGIN_INFO = { - "name": _(u"file"), - "main": "FileTransmitter", - "description": _(u"transmit a local file"), - "icon_medium": u"{media}/icons/muchoslava/png/fichier_50.png", -} - - -class FileTransmitter(BoxLayout): - callback = properties.ObjectProperty() - cancel_cb = properties.ObjectProperty() - - def onTransmitOK(self, filechooser): - if filechooser.selection: - file_path = filechooser.selection[0] - self.callback(file_path) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_voice.kv --- a/src/cagou/plugins/plugin_transfer_voice.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,71 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -#:import _ sat.core.i18n._ -#:import IconButton cagou.core.common.IconButton - - -: - 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) - 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.switchRecording() - 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.playRecord() - 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 diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_transfer_voice.py --- a/src/cagou/plugins/plugin_transfer_voice.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from 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": _(u"voice"), - "main": "VoiceRecorder", - "platforms": ["android"], - "description": _(u"transmit a voice record"), - "icon_medium": u"{media}/icons/muchoslava/png/micro_off_50.png", -} - - -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 _updateTimer(self, dt): - self.time = int(time.time() - self._started_at) - - def switchRecording(self): - if self.playing: - self._stopPlaying() - 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(u"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._updateTimer, 1) - - self.recording = not self.recording - - def _stopPlaying(self, dummy=None): - if self.record_time is None: - log.error("_stopPlaying 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 playRecord(self): - if self.recording: - return - if self.playing: - self._stopPlaying() - 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(u"Exception on play: {}".format(e)) - self.time = 0 - return - - self.playing = True - self.record_time = self.time - Clock.schedule_once(self._stopPlaying, self.time + 1) - self._started_at = time.time() - self.time = 0 - self._counter_timer = Clock.schedule_interval(self._updateTimer, 0.5) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_chat.kv --- a/src/cagou/plugins/plugin_wid_chat.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,146 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -#:import TransferMenu cagou.core.menu.TransferMenu -#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget -#:import _ sat.core.i18n._ -#:import C cagou.core.constants.Const - - -: - size_hint: None, None - size: dp(30), dp(30) - -: - cols: 1 - padding: dp(10) - spacing: dp(5) - size_hint: 1, None - height: self.minimum_height - -: - cols: 1 - mess_xhtml: mess_xhtml - padding: dp(10) - spacing: dp(5) - size_hint: 1, None - height: self.minimum_height - on_width: self.widthAdjust() - avatar: avatar - delivery: delivery - BoxLayout: - id: header_box - size_hint: 1, None - height: avatar.height if root.mess_data.type != C.MESS_TYPE_INFO else 0 - opacity: 1 if root.mess_data.type != C.MESS_TYPE_INFO else 0 - MessAvatar: - id: avatar - Label: - id: time_label - text_size: None, None - size_hint: None, None - size: self.texture_size - padding: dp(5), 0 - text: u"{}, {}".format(root.mess_data.nick, root.mess_data.time_text) - Label: - id: delivery - 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'' - color: 0, 1, 0, 1 - BoxLayout: - # BoxLayout is needed here, else GridLayout won't let the Label choose its width - size_hint: 1, None - height: mess_xhtml.height - on_size: root.widthAdjust() - SimpleXHTMLWidget: - canvas.before: - Color: - rgba: 1, 1, 1, 1 - BorderImage: - source: app.expand("{media}/misc/black.png") if root.mess_data.type == "info" else app.expand("{media}/misc/borders/{}.jpg", "blue" if root.mess_data.own_mess else "gray") - pos: self.pos - size: self.content_width, self.height - id: mess_xhtml - size_hint: 0.8, None - height: self.minimum_height - xhtml: root.message_xhtml or self.escape(root.message or u' ') - color: (0.74,0.74,0.24,1) if root.mess_data.type == "info" else (0, 0, 0, 1) - padding: root.mess_padding - bold: True if root.mess_data.type == "info" else False - -: - messages_widget: messages_widget - ScrollView: - size_hint: 1, 0.8 - scroll_y: 0 - do_scroll_x: False - MessagesWidget: - id: messages_widget - MessageInputBox: - size_hint: 1, None - height: dp(40) - message_input: message_input - MessageInputWidget: - id: message_input - size_hint: 1, 1 - hint_text: _(u"Enter your message here") - on_text_validate: root.onSend(args[0]) - IconButton - # transfer button - source: app.expand("{media}/icons/tango/actions/32/list-add.png") - allow_stretch: True - size_hint: None, 1 - width: max(self.texture_size[0], dp(40)) - on_release: TransferMenu(callback=root.onTransferOK).show(self) - -: - size_hint: None, 1 - width: dp(30) - allow_stretch: True - source: self.getIconSource() - -: - size_hint: None, None - size: self.texture_size - padding: dp(5), dp(10) - -: - size_hint_x: None - width: start_btn.width - auto_width: False - canvas.before: - Color: - rgba: 0, 0, 0, 1 - Rectangle: - pos: self.pos - size: self.size - OtrButton: - id: start_btn - text: _(u"Start/Refresh encrypted session") - on_release: root.otr_start() - OtrButton: - text: _(u"Finish encrypted session") - on_release: root.otr_end() - OtrButton: - text: _(u"Authenticate destinee") - on_release: root.otr_authenticate() diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_chat.py --- a/src/cagou/plugins/plugin_wid_chat.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,460 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from cagou.core.constants import Const as C -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.gridlayout import GridLayout -from kivy.uix.textinput import TextInput -from kivy.metrics import dp -from kivy import properties -from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.quick_frontend import quick_chat -from sat_frontends.tools import jid -from cagou.core import cagou_widget -from cagou.core.image import Image -from cagou.core.common import IconButton, JidWidget -from kivy.uix.dropdown import DropDown -from cagou import G -import mimetypes - - -PLUGIN_INFO = { - "name": _(u"chat"), - "main": "Chat", - "description": _(u"instant messaging with one person or a group"), - "icon_small": u"{media}/icons/muchoslava/png/chat_new_32.png", - "icon_medium": u"{media}/icons/muchoslava/png/chat_new_44.png" -} - -# following const are here temporary, they should move to quick frontend -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) - - -class MessAvatar(Image): - pass - - -class MessageWidget(GridLayout): - mess_data = properties.ObjectProperty() - mess_xhtml = properties.ObjectProperty() - mess_padding = (dp(5), dp(5)) - avatar = properties.ObjectProperty() - delivery = properties.ObjectProperty() - - def __init__(self, **kwargs): - # self must be registered in widgets before kv is parsed - kwargs['mess_data'].widgets.add(self) - super(MessageWidget, self).__init__(**kwargs) - avatar_path = self.mess_data.avatar - if avatar_path is not None: - self.avatar.source = avatar_path - - @property - def chat(self): - """return parent Chat instance""" - return self.mess_data.parent - - @property - def message(self): - """Return currently displayed message""" - return self.mess_data.main_message - - @property - def message_xhtml(self): - """Return currently displayed message""" - return self.mess_data.main_message_xhtml - - def widthAdjust(self): - """this widget grows up with its children""" - pass - # parent = self.mess_xhtml.parent - # padding_x = self.mess_padding[0] - # text_width, text_height = self.mess_xhtml.texture_size - # if text_width > parent.width: - # self.mess_xhtml.text_size = (parent.width - padding_x, None) - # self.text_max = text_width - # elif self.mess_xhtml.text_size[0] is not None and text_width < parent.width - padding_x: - # if text_width < self.text_max: - # self.mess_xhtml.text_size = (None, None) - # else: - # self.mess_xhtml.text_size = (parent.width - padding_x, None) - - def update(self, update_dict): - if 'avatar' in update_dict: - self.avatar.source = update_dict['avatar'] - if 'status' in update_dict: - status = update_dict['status'] - self.delivery.text = u'\u2714' if status == 'delivered' else u'' - - -class MessageInputBox(BoxLayout): - pass - - -class MessageInputWidget(TextInput): - - def _key_down(self, key, repeat=False): - displayed_str, internal_str, internal_action, scale = key - if internal_action == 'enter': - self.dispatch('on_text_validate') - else: - super(MessageInputWidget, self)._key_down(key, repeat) - - -class MessagesWidget(GridLayout): - pass - - -class EncryptionButton(IconButton): - - def __init__(self, chat, **kwargs): - """ - @param chat(Chat): Chat instance - """ - self.chat = chat - # for now we do a simple ContextMenu as we have only OTR - self.otr_menu = OtrMenu(chat) - super(EncryptionButton, self).__init__(**kwargs) - self.bind(on_release=self.otr_menu.open) - - def getIconSource(self): - """get path of icon""" - # TODO: use a more generic method to get icon name - if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: - icon_name = 'cadenas_ouvert' - else: - if self.chat.otr_state_trust == OTR_STATE_TRUSTED: - icon_name = 'cadenas_ferme' - else: - icon_name = 'cadenas_ferme_pas_authenthifie' - - return G.host.app.expand("{media}/icons/muchoslava/png/" + icon_name + "_30.png") - - -class OtrMenu(DropDown): - - def __init__(self, chat, **kwargs): - """ - @param chat(Chat): Chat instance - """ - self.chat = chat - super(OtrMenu, self).__init__(**kwargs) - - def otr_start(self): - self.dismiss() - G.host.launchMenu( - C.MENU_SINGLE, - (u"otr", u"start/refresh"), - {u'jid': unicode(self.chat.target)}, - None, - C.NO_SECURITY_LIMIT, - self.chat.profile - ) - - def otr_end(self): - self.dismiss() - G.host.launchMenu( - C.MENU_SINGLE, - (u"otr", u"end session"), - {u'jid': unicode(self.chat.target)}, - None, - C.NO_SECURITY_LIMIT, - self.chat.profile - ) - - def otr_authenticate(self): - self.dismiss() - G.host.launchMenu( - C.MENU_SINGLE, - (u"otr", u"authenticate"), - {u'jid': unicode(self.chat.target)}, - None, - C.NO_SECURITY_LIMIT, - self.chat.profile - ) - - -class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget): - message_input = properties.ObjectProperty() - messages_widget = properties.ObjectProperty() - - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None): - quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles) - self.otr_state_encryption = OTR_STATE_UNENCRYPTED - self.otr_state_trust = OTR_STATE_UNTRUSTED - cagou_widget.CagouWidget.__init__(self) - if type_ == C.CHAT_ONE2ONE: - self.encryption_btn = EncryptionButton(self) - self.headerInputAddExtra(self.encryption_btn) - self.header_input.hint_text = u"{}".format(target) - self.host.addListener('progressError', self.onProgressError, profiles) - self.host.addListener('progressFinished', self.onProgressFinished, profiles) - self._waiting_pids = {} # waiting progress ids - self.postInit() - # completion attribtues - self._hi_comp_data = None - self._hi_comp_last = None - self._hi_comp_dropdown = DropDown() - self._hi_comp_allowed = True - - @classmethod - def factory(cls, plugin_info, target, profiles): - profiles = list(profiles) - if len(profiles) > 1: - raise NotImplementedError(u"Multi-profiles is not available yet for chat") - if target is None: - target = G.host.profiles[profiles[0]].whoami - return G.host.widgets.getOrCreateWidget(cls, target, on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=profiles) - - ## header ## - - def changeWidget(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.getPluginInfo(main=Chat) - factory = plugin_info['factory'] - G.host.switchWidget(self, factory(plugin_info, jid_, profiles=[self.profile])) - self.header_input.text = '' - - def onHeaderInput(self): - text = self.header_input.text.strip() - try: - if text.count(u'@') != 1 or text.count(u' '): - raise ValueError - jid_ = jid.JID(text) - except ValueError: - log.info(u"entered text is not a jid") - return - - def discoCb(disco): - # TODO: check if plugin XEP-0045 is activated - if "conference" in [i[0] for i in disco[1]]: - G.host.bridge.mucJoin(unicode(jid_), "", "", self.profile, callback=self._mucJoinCb, errback=self._mucJoinEb) - else: - self.changeWidget(jid_) - - def discoEb(failure): - log.warning(u"Disco failure, ignore this text: {}".format(failure)) - - G.host.bridge.discoInfos(jid_.domain, self.profile, callback=discoCb, errback=discoEb) - - def onHeaderInputCompleted(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.onHeaderInput() - - def onHeaderInputComplete(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 - 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(u'nick', u'') - if text in jid_.bare or text in nick.lower(): - btn = JidWidget( - jid = jid_.bare, - profile = profile, - size_hint = (0.5, None), - nick = nick, - on_release=lambda dummy, txt=jid_.bare: self.onHeaderInputCompleted(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) - - dropdown.open(wid) - self._hi_comp_last = text - - def messageDataConverter(self, idx, mess_id): - return {"mess_data": self.messages[mess_id]} - - def _onHistoryPrinted(self): - """Refresh or scroll down the focus after the history is printed""" - # self.adapter.data = self.messages - for mess_data in self.messages.itervalues(): - self.appendMessage(mess_data) - super(Chat, self)._onHistoryPrinted() - - def createMessage(self, message): - self.appendMessage(message) - - def appendMessage(self, mess_data): - self.messages_widget.add_widget(MessageWidget(mess_data=mess_data)) - - def onSend(self, input_widget): - G.host.messageSend( - self.target, - {'': input_widget.text}, # TODO: handle language - mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat - profile_key=self.profile - ) - input_widget.text = '' - - def onProgressFinished(self, progress_id, metadata, profile): - try: - callback, cleaning_cb = self._waiting_pids.pop(progress_id) - except KeyError: - return - if cleaning_cb is not None: - cleaning_cb() - callback(metadata, profile) - - def onProgressError(self, progress_id, err_msg, profile): - try: - dummy, cleaning_cb = self._waiting_pids[progress_id] - except KeyError: - return - else: - del self._waiting_pids[progress_id] - if cleaning_cb is not None: - cleaning_cb() - # TODO: display message to user - log.warning(u"Can't transfer file: {}".format(err_msg)) - - def fileTransferDone(self, metadata, profile): - log.debug("file transfered: {}".format(metadata)) - extra = {} - - # FIXME: Q&D way of getting file type, upload plugins shouls give it - mime_type = mimetypes.guess_type(metadata['url'])[0] - if mime_type is not None: - if mime_type.split(u'/')[0] == 'image': - # we generate url ourselves, so this formatting is safe - extra['xhtml'] = u"".format(**metadata) - - G.host.messageSend( - self.target, - {'': metadata['url']}, - mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, - extra = extra, - profile_key=profile - ) - - def fileTransferCb(self, progress_data, cleaning_cb): - try: - progress_id = progress_data['progress'] - except KeyError: - xmlui = progress_data['xmlui'] - G.host.showUI(xmlui) - else: - self._waiting_pids[progress_id] = (self.fileTransferDone, cleaning_cb) - - def onTransferOK(self, file_path, cleaning_cb, transfer_type): - if transfer_type == C.TRANSFER_UPLOAD: - G.host.bridge.fileUpload( - file_path, - "", - "", - {"ignore_tls_errors": C.BOOL_TRUE}, # FIXME: should not be the default - self.profile, - callback = lambda progress_data: self.fileTransferCb(progress_data, cleaning_cb) - ) - elif transfer_type == C.TRANSFER_SEND: - if self.type == C.CHAT_GROUP: - log.warning(u"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].getFullJid(jid_) - G.host.bridge.fileSend(unicode(jid_), file_path, "", "", profile=self.profile) - # TODO: notification of sending/failing - else: - raise log.error(u"transfer of type {} are not handled".format(transfer_type)) - - - def _mucJoinCb(self, joined_data): - joined, room_jid_s, occupants, user_nick, subject, profile = joined_data - self.host.mucRoomJoinedHandler(*joined_data[1:]) - jid_ = jid.JID(room_jid_s) - self.changeWidget(jid_) - - def _mucJoinEb(self, failure): - log.warning(u"Can't join room: {}".format(failure)) - - def _onDelete(self): - self.host.removeListener('progressFinished', self.onProgressFinished) - self.host.removeListener('progressError', self.onProgressError) - return super(Chat, self).onDelete() - - def onOTRState(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(_(u"Unknown OTR state received: {}".format(state))) - return - self.encryption_btn.source = self.encryption_btn.getIconSource() - - def onDelete(self, force=False): - if force==True: - return self._onDelete() - if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1: - # we don't keep duplicate widgets - return self._onDelete() - return False - - -PLUGIN_INFO["factory"] = Chat.factory -quick_widgets.register(quick_chat.QuickChat, Chat) diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_contact_list.kv --- a/src/cagou/plugins/plugin_wid_contact_list.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,34 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - -: - row_height: dp(50) - -: - padding: dp(10), dp(3) - size_hint: 1, None - height: dp(50) - Avatar: - source: root.data.get('avatar', app.default_avatar) - size_hint: None, 1 - width: dp(60) - Label: - id: jid_label - padding: dp(5), 0 - text: root.jid - text_size: self.size - bold: True - valign: "middle" diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_contact_list.py --- a/src/cagou/plugins/plugin_wid_contact_list.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from sat_frontends.quick_frontend.quick_contact_list import QuickContactList -from sat_frontends.tools import jid -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.listview import ListView -from kivy.adapters.listadapter import ListAdapter -from kivy.metrics import dp -from kivy import properties -from cagou.core import cagou_widget -from cagou.core import image -from cagou import G - - -PLUGIN_INFO = { - "name": _(u"contacts"), - "main": "ContactList", - "description": _(u"list of contacts"), - "icon_small": u"{media}/icons/muchoslava/png/contact_list_new_32.png", - "icon_medium": u"{media}/icons/muchoslava/png/contact_list_new_44.png" -} - - -class Avatar(image.Image): - pass - - -class ContactItem(BoxLayout): - data = properties.DictProperty() - jid = properties.StringProperty('') - - def __init__(self, **kwargs): - super(ContactItem, self).__init__(**kwargs) - - def on_touch_down(self, touch): - if self.collide_point(*touch.pos): - # XXX: for now clicking on an item launch the corresponding Chat widget - # behaviour should change in the future - try: - # FIXME: Q&D way to get chat plugin, should be replaced by a clean method - # in host - plg_infos = [p for p in G.host.getPluggedWidgets() if 'chat' in p['import_name']][0] - except IndexError: - log.warning(u"No plugin widget found to display chat") - else: - factory = plg_infos['factory'] - G.host.switchWidget(self, factory(plg_infos, jid.JID(self.jid), profiles=iter(G.host.profiles))) - - -class ContactListView(ListView): - pass - - -class ContactList(QuickContactList, cagou_widget.CagouWidget): - - def __init__(self, host, target, profiles): - QuickContactList.__init__(self, G.host, profiles) - cagou_widget.CagouWidget.__init__(self) - self.adapter = ListAdapter(data={}, - cls=ContactItem, - args_converter=self.contactDataConverter, - selection_mode='multiple', - allow_empty_selection=True, - ) - self.add_widget(ContactListView(adapter=self.adapter)) - self.postInit() - self.update() - - def onHeaderInputComplete(self, wid, text): - # FIXME: this is implementation dependent, need to be done properly - items = self.children[0].children[0].children[0].children - - for item in items: - if text not in item.ids.jid_label.text: - item.height = 0 - item.opacity = 0 - else: - item.height = dp(50) - item.opacity = 1 - - def contactDataConverter(self, idx, bare_jid): - return {"jid": bare_jid, "data": self._items_cache[bare_jid]} - - def update(self, entities=None, type_=None, profile=None): - log.debug("update: %s %s %s" % (entities, type_, profile)) - # FIXME: for now we update on each event - # if entities is None and type_ is None: - self._items_cache = self.items_sorted - self.adapter.data = self.items_sorted.keys() - diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_settings.kv --- a/src/cagou/plugins/plugin_wid_settings.kv Thu Apr 05 15:59:50 2018 +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 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 . diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_settings.py --- a/src/cagou/plugins/plugin_wid_settings.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,65 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from sat.core.constants import Const as C -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 - - -PLUGIN_INFO = { - "name": _(u"settings"), - "main": "Settings", - "description": _(u"Cagou/SàT settings"), - "icon_small": u"{media}/icons/muchoslava/png/settings_32.png", - "icon_medium": u"{media}/icons/muchoslava/png/settings_44.png" -} - - -class Settings(quick_widgets.QuickWidget, cagou_widget.CagouWidget): - - 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) - G.host.bridge.getParamsUI(-1, C.APP_NAME, self.profile, callback=self.getParamsUICb, errback=self.getParamsUIEb) - - def changeWidget(self, widget): - self.clear_widgets([self.loading_widget]) - del self.loading_widget - self.add_widget(widget) - - def getParamsUICb(self, xmlui): - G.host.actionManager({"xmlui": xmlui}, ui_show_cb=self.changeWidget, profile=self.profile) - - def getParamsUIEb(self, failure): - self.changeWidget(Label( - text=_(u"Can't load parameters!"), - bold=True, - color=(1,0,0,1))) - G.host.showDialog(u"Can't load params UI", failure, "error") diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_widget_selector.kv --- a/src/cagou/plugins/plugin_wid_widget_selector.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,30 +0,0 @@ -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 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 . - -: - size_hint: (1, None) - height: dp(40) - Widget: - Image: - source: root.plugin_info["icon_medium"] - allow_stretch: True - keep_ratio: True - width: self.texture_size[0] - Label: - text: root.plugin_info["name"] - bold: True - font_size: sp(20) - Widget: diff -r b6e6afb0dc46 -r cd99f70ea592 src/cagou/plugins/plugin_wid_widget_selector.py --- a/src/cagou/plugins/plugin_wid_widget_selector.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,80 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - - -from sat.core import log as logging -log = logging.getLogger(__name__) -from sat.core.i18n import _ -from cagou.core.constants import Const as C -from kivy.uix.boxlayout import BoxLayout -from kivy.uix.listview import ListView -from kivy.adapters.listadapter import ListAdapter -from kivy import properties -from kivy.uix.behaviors import ButtonBehavior -from cagou.core import cagou_widget -from cagou import G - - -PLUGIN_INFO = { - "name": _(u"widget selector"), - "import_name": C.WID_SELECTOR, - "main": "WidgetSelector", - "description": _(u"show available widgets and allow to select one"), - "icon_small": u"{media}/icons/muchoslava/png/selector_new_32.png", - "icon_medium": u"{media}/icons/muchoslava/png/selector_new_44.png" -} - - -class WidgetSelItem(ButtonBehavior, BoxLayout): - plugin_info = properties.DictProperty() - - def __init__(self, **kwargs): - super(WidgetSelItem, self).__init__(**kwargs) - - def select(self, *args): - log.debug(u"widget selection: {}".format(self.plugin_info["name"])) - factory = self.plugin_info["factory"] - G.host.switchWidget(self, factory(self.plugin_info, None, profiles=iter(G.host.profiles))) - - def deselect(self, *args): - pass - - -class WidgetSelector(cagou_widget.CagouWidget): - - def __init__(self): - super(WidgetSelector, self).__init__() - self.adapter = ListAdapter( - data=G.host.getPluggedWidgets(except_cls=self.__class__), - cls=WidgetSelItem, - args_converter=self.dataConverter, - selection_mode='single', - allow_empty_selection=True, - ) - self.add_widget(ListView(adapter=self.adapter)) - - @classmethod - def factory(cls, plugin_info, target, profiles): - return cls() - - def dataConverter(self, idx, plugin_info): - return {"plugin_info": plugin_info} - - -PLUGIN_INFO["factory"] = WidgetSelector.factory diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/README --- a/src/libs/garden/README Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ -Files in this directory are from Kivy garden (http://kivy-garden.github.io/), and are included here to make distribution more easy. -These modules may be sometime modified, we propose our patch upstream if this is the case. -Files in this directory hierarchy (this directory and all subdirectories) all follow Kivy licence (MIT), check the corresponding LICENSE files. - -This licence *does not apply outside of this "garden" directory and subdirectories*. Cagou code itself is licenced under AGPLv3+ as specified in the LICENSE file in the root directory. diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/.gitignore --- a/src/libs/garden/garden.contextmenu/.gitignore Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,58 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/LICENSE --- a/src/libs/garden/garden.contextmenu/LICENSE Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,22 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015 Kivy Garden - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/README.md --- a/src/libs/garden/garden.contextmenu/README.md Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,221 +0,0 @@ -# garden.contextmenu - -Collection of classes for easy creating **context** and **application** menus. - -## Context Menu - -![Example of context menu](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/context-menu-01.png) - -Context menu is represented by `ContextMenu` widget that wraps all menu items as `ContextMenuTextItem` widgets. Context menus can be nested, each `ContextMenuTextItem` can contain maximum one `ContextMenu` widget. - -```python -import kivy -from kivy.app import App -from kivy.lang import Builder -import kivy.garden.contextmenu - -kv = """ -FloatLayout: - id: layout - Label: - pos: 10, self.parent.height - self.height - 10 - text: "Left click anywhere outside the context menu to close it" - size_hint: None, None - size: self.texture_size - - Button: - size_hint: None, None - pos_hint: {"center_x": 0.5, "center_y": 0.8 } - size: 300, 40 - text: "Click me to show the context menu" - on_release: context_menu.show(*app.root_window.mouse_pos) - - ContextMenu: - id: context_menu - visible: False - cancel_handler_widget: layout - - ContextMenuTextItem: - text: "SubMenu #2" - ContextMenuTextItem: - text: "SubMenu #3" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #5" - ContextMenuTextItem: - text: "SubMenu #6" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #9" - ContextMenuTextItem: - text: "SubMenu #10" - ContextMenuTextItem: - text: "SubMenu #11" - ContextMenuTextItem: - text: "Hello, World!" - on_release: app.say_hello(self.text) - ContextMenuTextItem: - text: "SubMenu #12" - ContextMenuTextItem: - text: "SubMenu #7" - ContextMenuTextItem: - text: "SubMenu #4" -""" - -class MyApp(App): - def build(self): - self.title = 'Simple context menu example' - return Builder.load_string(kv) - - def say_hello(self, text): - print(text) - self.root.ids['context_menu'].hide() - -if __name__ == '__main__': - MyApp().run() -``` - -Arrows that symbolize that an item has sub menu is created automatically. `ContextMenuTextItem` inherits from [ButtonBehavior](http://kivy.org/docs/api-kivy.uix.behaviors.html#kivy.uix.behaviors.ButtonBehavior) so you can use `on_release` to bind actions to it. - -The root context menu can use `cancel_handler_widget` parameter. This adds `on_touch_down` event to it that closes the menu when you click anywhere outside the menu. - - -## Application Menu - -![Example of application menu](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/app-menu-01.png) - -Creating application menus is very similar to context menus. Use `AppMenu` and `AppMenuTextItem` widgets to create the top level menu. Then each `AppMenuTextItem` can contain one `ContextMenu` widget as we saw above. `AppMenuTextItem` without `ContextMenu` are disabled by default - -```python -import kivy -from kivy.app import App -from kivy.lang import Builder -import kivy.garden.contextmenu - -kv = """ -FloatLayout: - id: layout - AppMenu: - id: app_menu - top: root.height - cancel_handler_widget: layout - - AppMenuTextItem: - text: "Menu #1" - ContextMenu: - ContextMenuTextItem: - text: "Item #11" - ContextMenuTextItem: - text: "Item #12" - AppMenuTextItem: - text: "Menu Menu Menu #2" - ContextMenu: - ContextMenuTextItem: - text: "Item #21" - ContextMenuTextItem: - text: "Item #22" - ContextMenuTextItem: - text: "ItemItemItem #23" - ContextMenuTextItem: - text: "Item #24" - ContextMenu: - ContextMenuTextItem: - text: "Item #241" - ContextMenuTextItem: - text: "Hello, World!" - on_release: app.say_hello(self.text) - # ... - ContextMenuTextItem: - text: "Item #5" - AppMenuTextItem: - text: "Menu Menu #3" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #31" - ContextMenuDivider: - ContextMenuTextItem: - text: "SubMenu #32" - # ... - AppMenuTextItem: - text: "Menu #4" - # ... - # The rest follows as usually -""" - -class MyApp(App): - def build(self): - self.title = 'Simple app menu example' - return Builder.load_string(kv) - - def say_hello(self, text): - print(text) - self.root.ids['app_menu'].close_all() - -if __name__ == '__main__': - MyApp().run() -``` - -## All classes - -`garden.contextmenu` provides you with a set of classes and mixins for creating your own customised menu items for both context and application menus. - -### context_menu.AbstractMenu - -Mixin class that represents basic functionality for all menus. It cannot be used by itself and needs to be extended with a layout. Provides `cancel_handler_widget` property. See [AppMenu](https://github.com/kivy-garden/garden.contextmenu/blob/master/app_menu.py) or [ContextMenu](https://github.com/kivy-garden/garden.contextmenu/blob/master/context_menu.py). - -### context_menu.ContextMenu - -Implementation of a context menu. - -### context_menu.AbstractMenuItem - -Mixin class that represents a single menu item. Needs to be extended to be any useful. It's a base class for all menu items for both context and application menus. - -If you want to extend this class you need to override the `content_width` property which tells the parent `ContextMenu` what is the expected width of this item. It needs to know this to set it's own width. - -### context_menu.ContextMenuItem - -Single context menu item. Automatically draws an arrow if contains a `ContextMenu` children. If you want to create a custom menu item extend this class. - -### context_menu.AbstractMenuItemHoverable - -Mixin class that makes any class that inherits `ContextMenuItem` to change background color on mouse hover. - -### context_menu.ContextMenuText - -Menu item with `Label` widget without any extra functionality. - -### context_menu.ContextMenuDivider - -Menu widget that splits two parts of a context/app menu. - -![Example of ContextMenuDivider without text](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/menu-divider-01.png) - -It also contains an instance of `Label` which is not visible if you don't set it any text. - -```python -ContextMenuTextItem: - text: "SubMenu #33" -ContextMenuDivider: - text: "More options" -ContextMenuTextItem: - text: "SubMenu #34" -``` - -![Example of ContextMenuDivider with text](https://raw.githubusercontent.com/kivy-garden/garden.contextmenu/master/doc/menu-divider-02.png) - -### context_menu.ContextMenuTextItem - -Menu item with text. You'll be most of the time just fine using this class for all your menu items. You can also see it used in [all examples here](https://github.com/kivy-garden/garden.contextmenu/tree/master/examples). Contains a `Label` widget and copies `text`, `font_size` and `color` properties to it automatically. - -### app_menu.AppMenu - -Application menu widget. By default it fills the entire parent's width. - -### app_menu.AppMenuTextItem - -Application menu item width text. Contains a `Label` widget and copies `text`, `font_size` and `color` properties to it automatically. - -# License - -garden.contextmenu is licensed under MIT license. \ No newline at end of file diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/__init__.py --- a/src/libs/garden/garden.contextmenu/__init__.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,11 +0,0 @@ -from .context_menu import ContextMenu, \ - AbstractMenu, \ - AbstractMenuItem, \ - AbstractMenuItemHoverable, \ - ContextMenuItem, \ - ContextMenuDivider, \ - ContextMenuText, \ - ContextMenuTextItem - -from .app_menu import AppMenu, \ - AppMenuTextItem \ No newline at end of file diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/app_menu.kv --- a/src/libs/garden/garden.contextmenu/app_menu.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -: - height: dp(30) - size_hint: 1, None - - canvas.before: - Color: - rgb: 0.2, 0.2, 0.2 - Rectangle: - pos: self.pos - size: self.size - - -: - disabled: True - size_hint: None, None - on_children: self._check_submenu() - font_size: '15sp' - background_normal: "" - background_down: "" - background_color: (0.2, 0.71, 0.9, 1.0) if self.state == 'down' else (0.2, 0.2, 0.2, 1.0) - background_disabled_normal: "" - background_disabled_down: "" - border: (0, 0, 0, 0) - size: self.texture_size[0], dp(30) - padding_x: dp(10) diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/app_menu.py --- a/src/libs/garden/garden.contextmenu/app_menu.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,117 +0,0 @@ -from kivy.uix.relativelayout import RelativeLayout -from kivy.uix.stacklayout import StackLayout -from kivy.uix.behaviors import ToggleButtonBehavior -from kivy.uix.togglebutton import ToggleButton -from kivy.lang import Builder -import kivy.properties as kp -import os - -from .context_menu import AbstractMenu, AbstractMenuItem, AbstractMenuItemHoverable - - -class AppMenu(StackLayout, AbstractMenu): - bounding_box = kp.ObjectProperty(None) - - def __init__(self, *args, **kwargs): - super(AppMenu, self).__init__(*args, **kwargs) - self.hovered_menu_item = None - - def update_height(self): - max_height = 0 - for widget in self.menu_item_widgets: - if widget.height > max_height: - max_height = widget.height - return max_height - - def on_children(self, obj, new_children): - for w in new_children: - # bind events that update app menu height when any of its children resize - w.bind(on_size=self.update_height) - w.bind(on_height=self.update_height) - - def get_context_menu_root_parent(self): - return self - - def self_or_submenu_collide_with_point(self, x, y): - collide_widget = None - - # Iterate all siblings and all children - for widget in self.menu_item_widgets: - widget_pos = widget.to_window(0, 0) - if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: - if self.hovered_menu_item is None: - self.hovered_menu_item = widget - - if self.hovered_menu_item != widget: - self.hovered_menu_item = widget - for sibling in widget.siblings: - sibling.state = 'normal' - - if widget.state == 'normal': - widget.state = 'down' - widget.on_release() - - for sib in widget.siblings: - sib.hovered = False - elif widget.get_submenu() is not None and not widget.get_submenu().visible: - widget.state = 'normal' - - return collide_widget - - def close_all(self): - for submenu in [w.get_submenu() for w in self.menu_item_widgets if w.get_submenu() is not None]: - submenu.hide() - for w in self.menu_item_widgets: - w.state = 'normal' - - def hide_app_menus(self, obj, pos): - if not self.collide_point(pos.x, pos.y): - for w in [w for w in self.menu_item_widgets if not w.disabled and w.get_submenu().visible]: - submenu = w.get_submenu() - if submenu.self_or_submenu_collide_with_point(pos.x, pos.y) is None: - self.close_all() - self._cancel_hover_timer() - - -class AppMenuTextItem(ToggleButton, AbstractMenuItem): - label = kp.ObjectProperty(None) - text = kp.StringProperty('') - font_size = kp.NumericProperty(14) - color = kp.ListProperty([1, 1, 1, 1]) - - def on_release(self): - submenu = self.get_submenu() - - if self.state == 'down': - root = self._root_parent - submenu.bounding_box_widget = root.bounding_box if root.bounding_box else root.parent - - submenu.bind(visible=self.on_visible) - submenu.show(self.x, self.y - 1) - - for sibling in self.siblings: - if sibling.get_submenu() is not None: - sibling.state = 'normal' - sibling.get_submenu().hide() - - self.parent._setup_hover_timer() - else: - self.parent._cancel_hover_timer() - submenu.hide() - - def on_visible(self, *args): - submenu = self.get_submenu() - if self.width > submenu.get_max_width(): - submenu.width = self.width - - def _check_submenu(self): - super(AppMenuTextItem, self)._check_submenu() - self.disabled = (self.get_submenu() is None) - - # def on_mouse_down(self): - # print('on_mouse_down') - # return True - - -_path = os.path.dirname(os.path.realpath(__file__)) -Builder.load_file(os.path.join(_path, 'app_menu.kv')) diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/context_menu.kv --- a/src/libs/garden/garden.contextmenu/context_menu.kv Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,125 +0,0 @@ -: - cols: 1 - size_hint: None, None - spacing: 0, 0 - spacer: _spacer - on_visible: self._on_visible(args[1]) - on_parent: self._on_visible(self.visible) - - Widget: - id: _spacer - size_hint: 1, None - height: dp(3) - canvas.before: - Color: - rgb: 0.2, 0.71, 0.9 - Rectangle: - pos: self.pos - size: self.size - - -: - size_hint: None, None - submenu_arrow: _submenu_arrow - on_children: self._check_submenu() - on_parent: self._check_submenu() - canvas.before: - Color: - rgb: (0.15, 0.15, 0.15) - Rectangle: - pos: 0,0 - size: self.size - - Widget: - id: _submenu_arrow - size_hint: None, None - width: dp(6) - height: dp(11) - pos: self.parent.width - self.width - dp(5), (self.parent.height - self.height) / 2 - canvas.before: - Translate: - xy: self.pos - Color: - rgb: (0.35, 0.35, 0.35) if self.disabled else (1, 1, 1) - Triangle: - points: [0,0, self.width,self.height/2, 0,self.height] - Translate: - xy: (-self.pos[0], -self.pos[1]) - - -: - label: _label - width: self.parent.width if self.parent else 0 - height: dp(26) - font_size: '15sp' - - Label: - pos: 0,0 - id: _label - text: self.parent.text - color: self.parent.color - font_size: self.parent.font_size - padding: dp(10), 0 - halign: 'left' - valign: 'middle' - size: self.texture_size - size_hint: None, 1 - - -: - on_hovered: self._on_hovered(args[1]) - canvas.before: - Color: - rgb: (0.25, 0.25, 0.25) if self.hovered and not self.disabled else (0.15, 0.15, 0.15) - Rectangle: - pos: 0,0 - size: self.size - - -: - font_size: '10sp' - height: dp(20) if len(self.label.text) > 0 else dp(1) - canvas.before: - Color: - rgb: (0.25, 0.25, 0.25) - Rectangle: - pos: 0,self.height - 1 - size: self.width, 1 - - -: - size_hint: None, None - font_size: '12sp' - height: dp(20) - background_normal: "" - background_down: "" - background_color: 0.2, 0.71, 0.9, 1.0 - border: (0, 0, 0, 0) - on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 - on_release: self.background_color = 0.2, 0.71, 0.9, 1.0 - - -: - size_hint: None, None - font_size: '12sp' - size: dp(30), dp(20) - background_normal: "" - background_down: "" - background_color: (0.2, 0.71, 0.9, 1.0) if self.state == 'down' else (0.25, 0.25, 0.25, 1.0) - border: (0, 0, 0, 0) - on_press: self.background_color = 0.10, 0.6, 0.8, 1.0 - on_release: self.background_color = 0.2, 0.71, 0.9, 1.0 - - -: - size: self.texture_size[0], dp(18) - size_hint: None, None - font_size: '12sp' - - -: - size_hint: None, None - height: dp(22) - font_size: '12sp' - padding: dp(7), dp(3) - multiline: False diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/context_menu.py --- a/src/libs/garden/garden.contextmenu/context_menu.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,282 +0,0 @@ -from kivy.uix.gridlayout import GridLayout -from kivy.uix.relativelayout import RelativeLayout -from kivy.core.window import Window -from kivy.uix.behaviors import ButtonBehavior -from kivy.lang import Builder -from kivy.clock import Clock -from functools import partial - -import kivy.properties as kp -import os - - -class AbstractMenu(object): - cancel_handler_widget = kp.ObjectProperty(None) - bounding_box_widget = kp.ObjectProperty(None) - - def __init__(self, *args, **kwargs): - self.clock_event = None - - def add_item(self, widget): - self.add_widget(widget) - - def add_text_item(self, text, on_release=None): - item = ContextMenuTextItem(text=text) - if on_release: - item.bind(on_release=on_release) - self.add_item(item) - - def get_height(self): - height = 0 - for widget in self.children: - height += widget.height - return height - - def hide_submenus(self): - for widget in self.menu_item_widgets: - widget.hovered = False - widget.hide_submenu() - - def self_or_submenu_collide_with_point(self, x, y): - raise NotImplementedError() - - def on_cancel_handler_widget(self, obj, widget): - self.cancel_handler_widget.bind(on_touch_down=self.hide_app_menus) - - def hide_app_menus(self, obj, pos): - raise NotImplementedError() - - @property - def menu_item_widgets(self): - """ - Return all children that are subclasses of ContextMenuItem - """ - return [w for w in self.children if issubclass(w.__class__, AbstractMenuItem)] - - def _setup_hover_timer(self): - if self.clock_event is None: - self.clock_event = Clock.schedule_interval(partial(self._check_mouse_hover), 0.05) - - def _check_mouse_hover(self, obj): - self.self_or_submenu_collide_with_point(*Window.mouse_pos) - - def _cancel_hover_timer(self): - if self.clock_event: - self.clock_event.cancel() - self.clock_event = None - - -class ContextMenu(GridLayout, AbstractMenu): - visible = kp.BooleanProperty(False) - spacer = kp.ObjectProperty(None) - - def __init__(self, *args, **kwargs): - super(ContextMenu, self).__init__(*args, **kwargs) - self.orig_parent = None - # self._on_visible(False) - - def hide(self): - self.visible = False - - def show(self, x=None, y=None): - self.visible = True - self._add_to_parent() - self.hide_submenus() - - root_parent = self.bounding_box_widget if self.bounding_box_widget is not None else self.get_context_menu_root_parent() - if root_parent is None: - return - - point_relative_to_root = root_parent.to_local(*self.to_window(x, y)) - - # Choose the best position to open the menu - if x is not None and y is not None: - if point_relative_to_root[0] + self.width < root_parent.width: - pos_x = x - else: - pos_x = x - self.width - if issubclass(self.parent.__class__, AbstractMenuItem): - pos_x -= self.parent.width - - if point_relative_to_root[1] - self.height < 0: - pos_y = y - if issubclass(self.parent.__class__, AbstractMenuItem): - pos_y -= self.parent.height + self.spacer.height - else: - pos_y = y - self.height - - self.pos = pos_x, pos_y - - def self_or_submenu_collide_with_point(self, x, y): - queue = self.menu_item_widgets - collide_widget = None - - # Iterate all siblings and all children - while len(queue) > 0: - widget = queue.pop(0) - submenu = widget.get_submenu() - if submenu is not None and widget.hovered: - queue += submenu.menu_item_widgets - - widget_pos = widget.to_window(0, 0) - if widget.collide_point(x - widget_pos[0], y - widget_pos[1]) and not widget.disabled: - widget.hovered = True - - collide_widget = widget - for sib in widget.siblings: - sib.hovered = False - elif submenu and submenu.visible: - widget.hovered = True - else: - widget.hovered = False - - return collide_widget - - def _on_visible(self, new_visibility): - if new_visibility: - self.size = self.get_max_width(), self.get_height() - self._add_to_parent() - # @todo: Do we need to remove self from self.parent.__context_menus? Probably not. - - elif self.parent and not new_visibility: - self.orig_parent = self.parent - - ''' - We create a set that holds references to all context menus in the parent widget. - It's necessary to keep at least one reference to this context menu. Otherwise when - removed from parent it might get de-allocated by GC. - ''' - if not hasattr(self.parent, '_ContextMenu__context_menus'): - self.parent.__context_menus = set() - self.parent.__context_menus.add(self) - - self.parent.remove_widget(self) - self.hide_submenus() - self._cancel_hover_timer() - - def _add_to_parent(self): - if not self.parent: - self.orig_parent.add_widget(self) - self.orig_parent = None - - # Create the timer on the outer most menu object - if self._get_root_context_menu() == self: - self._setup_hover_timer() - - def get_max_width(self): - max_width = 0 - for widget in self.menu_item_widgets: - width = widget.content_width if widget.content_width is not None else widget.width - if width is not None and width > max_width: - max_width = width - - return max_width - - def get_context_menu_root_parent(self): - """ - Return the bounding box widget for positioning sub menus. By default it's root context menu's parent. - """ - if self.bounding_box_widget is not None: - return self.bounding_box_widget - root_context_menu = self._get_root_context_menu() - return root_context_menu.bounding_box_widget if root_context_menu.bounding_box_widget else root_context_menu.parent - - def _get_root_context_menu(self): - """ - Return the outer most context menu object - """ - root = self - while issubclass(root.parent.__class__, ContextMenuItem) \ - or issubclass(root.parent.__class__, ContextMenu): - root = root.parent - return root - - def hide_app_menus(self, obj, pos): - return self.self_or_submenu_collide_with_point(pos.x, pos.y) is None and self.hide() - - -class AbstractMenuItem(object): - submenu = kp.ObjectProperty(None) - - def get_submenu(self): - return self.submenu if self.submenu != "" else None - - def show_submenu(self, x=None, y=None): - if self.get_submenu(): - self.get_submenu().show(*self._root_parent.to_local(x, y)) - - def hide_submenu(self): - submenu = self.get_submenu() - if submenu: - submenu.visible = False - submenu.hide_submenus() - - def _check_submenu(self): - if self.parent is not None and len(self.children) > 0: - submenus = [w for w in self.children if issubclass(w.__class__, ContextMenu)] - if len(submenus) > 1: - raise Exception('Menu item (ContextMenuItem) can have maximum one submenu (ContextMenu)') - elif len(submenus) == 1: - self.submenu = submenus[0] - - @property - def siblings(self): - return [w for w in self.parent.children if issubclass(w.__class__, AbstractMenuItem) and w != self] - - @property - def content_width(self): - return None - - @property - def _root_parent(self): - return self.parent.get_context_menu_root_parent() - - -class ContextMenuItem(RelativeLayout, AbstractMenuItem): - submenu_arrow = kp.ObjectProperty(None) - - def _check_submenu(self): - super(ContextMenuItem, self)._check_submenu() - if self.get_submenu() is None: - self.submenu_arrow.opacity = 0 - else: - self.submenu_arrow.opacity = 1 - - -class AbstractMenuItemHoverable(object): - hovered = kp.BooleanProperty(False) - - def _on_hovered(self, new_hovered): - if new_hovered: - spacer_height = self.parent.spacer.height if self.parent.spacer else 0 - self.show_submenu(self.width, self.height + spacer_height) - else: - self.hide_submenu() - - -class ContextMenuText(ContextMenuItem): - label = kp.ObjectProperty(None) - submenu_postfix = kp.StringProperty(' ...') - text = kp.StringProperty('') - font_size = kp.NumericProperty(14) - color = kp.ListProperty([1,1,1,1]) - - def __init__(self, *args, **kwargs): - super(ContextMenuText, self).__init__(*args, **kwargs) - - @property - def content_width(self): - # keep little space for eventual arrow for submenus - return self.label.texture_size[0] + 10 - - -class ContextMenuDivider(ContextMenuText): - pass - - -class ContextMenuTextItem(ButtonBehavior, ContextMenuText, AbstractMenuItemHoverable): - pass - - -_path = os.path.dirname(os.path.realpath(__file__)) -Builder.load_file(os.path.join(_path, 'context_menu.kv')) diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/doc/app-menu-01.png Binary file src/libs/garden/garden.contextmenu/doc/app-menu-01.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/doc/context-menu-01.png Binary file src/libs/garden/garden.contextmenu/doc/context-menu-01.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/doc/menu-divider-01.png Binary file src/libs/garden/garden.contextmenu/doc/menu-divider-01.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/doc/menu-divider-02.png Binary file src/libs/garden/garden.contextmenu/doc/menu-divider-02.png has changed diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/examples/simple_app_menu.py --- a/src/libs/garden/garden.contextmenu/examples/simple_app_menu.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -import kivy -from kivy.app import App -from kivy.lang import Builder -from kivy.logger import Logger -import logging - -kivy.require('1.9.0') -# Logger.setLevel(logging.DEBUG) - -import kivy.garden.contextmenu - -kv = """ -FloatLayout: - id: layout - AppMenu: - id: app_menu - top: root.height - cancel_handler_widget: layout - - AppMenuTextItem: - text: "Menu #1" - ContextMenu: - ContextMenuTextItem: - text: "Item #11" - ContextMenuTextItem: - text: "Item #12" - AppMenuTextItem: - text: "Menu Menu Menu #2" - ContextMenu: - ContextMenuTextItem: - text: "Item #21" - ContextMenuTextItem: - text: "Item #22" - ContextMenuTextItem: - text: "ItemItemItem #23" - ContextMenuTextItem: - text: "Item #24" - ContextMenu: - ContextMenuTextItem: - text: "Item #241" - ContextMenuTextItem: - text: "Hello, World!" - on_release: app.say_hello(self.text) - ContextMenuTextItem: - text: "Item #243" - ContextMenuTextItem: - text: "Item #244" - ContextMenuTextItem: - text: "Item #5" - AppMenuTextItem: - text: "Menu Menu #3" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #31" - ContextMenuTextItem: - text: "SubMenu #32" - ContextMenuTextItem: - text: "SubMenu #33" - ContextMenuDivider: - ContextMenuTextItem: - text: "SubMenu #34" - AppMenuTextItem: - text: "Menu #4" - - Label: - pos: 10, 10 - text: "Left click anywhere outside the context menu to close it" - size_hint: None, None - size: self.texture_size -""" - - -class MyApp(App): - - def build(self): - self.title = 'Simple app menu example' - return Builder.load_string(kv) - - def say_hello(self, text): - print(text) - self.root.ids['app_menu'].close_all() - -if __name__ == '__main__': - MyApp().run() \ No newline at end of file diff -r b6e6afb0dc46 -r cd99f70ea592 src/libs/garden/garden.contextmenu/examples/simple_context_menu.py --- a/src/libs/garden/garden.contextmenu/examples/simple_context_menu.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -import kivy -from kivy.app import App -from kivy.lang import Builder - -kivy.require('1.9.0') - -import kivy.garden.contextmenu - - -kv = """ -FloatLayout: - id: layout - Label: - pos: 10, self.parent.height - self.height - 10 - text: "Left click anywhere outside the context menu to close it" - size_hint: None, None - size: self.texture_size - - Button: - size_hint: None, None - pos_hint: {"center_x": 0.5, "center_y": 0.8 } - size: 300, 40 - text: "Click me to show the context menu" - on_release: context_menu.show(*app.root_window.mouse_pos) - - ContextMenu: - id: context_menu - visible: False - cancel_handler_widget: layout - - ContextMenuTextItem: - text: "SubMenu #2" - ContextMenuTextItem: - text: "SubMenu #3" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #5" - ContextMenuTextItem: - text: "SubMenu #6" - ContextMenu: - ContextMenuTextItem: - text: "SubMenu #9" - ContextMenuTextItem: - text: "SubMenu #10" - ContextMenuTextItem: - text: "SubMenu #11" - ContextMenuTextItem: - text: "Hello, World!" - on_release: app.say_hello(self.text) - ContextMenuTextItem: - text: "SubMenu #12" - ContextMenuTextItem: - text: "SubMenu #7" - ContextMenuTextItem: - text: "SubMenu #4" -""" - -class MyApp(App): - - def build(self): - self.title = 'Simple context menu example' - return Builder.load_string(kv) - - def say_hello(self, text): - print(text) - self.root.ids['context_menu'].hide() - - -if __name__ == '__main__': - MyApp().run() \ No newline at end of file diff -r b6e6afb0dc46 -r cd99f70ea592 src/main.py --- a/src/main.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,23 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import cagou - -if __name__ == "__main__": - cagou.run() diff -r b6e6afb0dc46 -r cd99f70ea592 src/platform/android/sat.conf --- a/src/platform/android/sat.conf Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,8 +0,0 @@ -[DEFAULT] -bridge = pb -log_level = debug -log_fmt = #[%%(levelname)s][%%(name)s] %%(message)s - -[cagou] -log_level = debug -log_fmt = [%%(levelname)s][%%(name)s] %%(message)s diff -r b6e6afb0dc46 -r cd99f70ea592 src/service/main.py --- a/src/service/main.py Thu Apr 05 15:59:50 2018 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,41 +0,0 @@ -#!/usr//bin/env python2 -# -*- coding: utf-8 -*- - -# Cagou: desktop/mobile frontend for Salut à Toi XMPP client -# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import sys -import os -# we want the service to access the modules from parent dir (sat, etc.) -os.chdir('..') -sys.path.insert(0, '') -from sat.core.constants import Const as C -from sat.core import log_config -# SàT log conf must be done before calling Kivy -log_config.satConfigure(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 -if kivy_utils.platform == "android": - # sys.platform is "linux" on android by default - # so we change it to allow backend to detect android - sys.platform = "android" - C.PLUGIN_EXT = "pyo" -from sat.core import sat_main -from twisted.internet import reactor - -sat = sat_main.SAT() -reactor.run()