diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..0b45358 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,13 @@ +FROM ubuntu:16.04 + +RUN apt-get update && \ +apt-get install build-essential libtool autotools-dev automake checkinstall cmake check git yasm libsodium-dev libopus-dev libvpx-dev pkg-config -y && \ +git clone https://github.com/ingvar1995/toxcore.git --branch=ngc_rebase && \ +cd toxcore && mkdir _build && cd _build && \ +cmake .. && make && make install + +RUN apt-get install portaudio19-dev python3-pyqt5 python3-pyaudio python3-pip -y && \ +pip3 install numpy pydenticon opencv-python pyinstaller + +RUN useradd -ms /bin/bash toxygen +USER toxygen diff --git a/build/build.sh b/build/build.sh new file mode 100644 index 0000000..fb6c4b2 --- /dev/null +++ b/build/build.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +cd ~ +git clone https://github.com/toxygen-project/toxygen.git --branch=next_gen +cd toxygen/toxygen + +pyinstaller --windowed --icon=images/icon.ico main.py + +cp -r styles dist/main/ +find . -type f ! -name '*.qss' -delete +cp -r plugins dist/main/ +mkdir -p dist/main/ui/views +cp -r ui/views dist/main/ui/ +cp -r sounds dist/main/ +cp -r smileys dist/main/ +cp -r stickers dist/main/ +cp -r bootstrap dist/main/ +find . -type f ! -name '*.json' -delete +cp -r images dist/main/ +cp -r translations dist/main/ +find . -name "*.ts" -type f -delete + +cd dist +mv main toxygen +cd toxygen +mv main toxygen +wget -O updater https://github.com/toxygen-project/toxygen_updater/releases/download/v0.1/toxygen_updater_linux_64 +echo "[Paths]" >> qt.conf +echo "Prefix = PyQt5/Qt" >> qt.conf +cd .. + +tar -zcvf toxygen_linux_64.tar.gz toxygen > /dev/null +rm -rf toxygen diff --git a/toxygen/av/__init__.py b/toxygen/av/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/av/call.py b/toxygen/av/call.py new file mode 100644 index 0000000..d3e023b --- /dev/null +++ b/toxygen/av/call.py @@ -0,0 +1,58 @@ + + +class Call: + + def __init__(self, out_audio, out_video, in_audio=False, in_video=False): + self._in_audio = in_audio + self._in_video = in_video + self._out_audio = out_audio + self._out_video = out_video + self._is_active = False + + def get_is_active(self): + return self._is_active + + def set_is_active(self, value): + self._is_active = value + + is_active = property(get_is_active, set_is_active) + + # ----------------------------------------------------------------------------------------------------------------- + # Audio + # ----------------------------------------------------------------------------------------------------------------- + + def get_in_audio(self): + return self._in_audio + + def set_in_audio(self, value): + self._in_audio = value + + in_audio = property(get_in_audio, set_in_audio) + + def get_out_audio(self): + return self._out_audio + + def set_out_audio(self, value): + self._out_audio = value + + out_audio = property(get_out_audio, set_out_audio) + + # ----------------------------------------------------------------------------------------------------------------- + # Video + # ----------------------------------------------------------------------------------------------------------------- + + def get_in_video(self): + return self._in_video + + def set_in_video(self, value): + self._in_video = value + + in_video = property(get_in_video, set_in_video) + + def get_out_video(self): + return self._out_video + + def set_out_video(self, value): + self._out_video = value + + out_video = property(get_out_video, set_out_video) diff --git a/toxygen/av/calls.py b/toxygen/av/calls.py new file mode 100644 index 0000000..d5f2fe7 --- /dev/null +++ b/toxygen/av/calls.py @@ -0,0 +1,281 @@ +import pyaudio +import time +import threading +from wrapper.toxav_enums import * +import cv2 +import itertools +import numpy as np +from av import screen_sharing +from av.call import Call +import common.tox_save + + +class AV(common.tox_save.ToxAvSave): + + def __init__(self, toxav, settings): + super().__init__(toxav) + self._settings = settings + self._running = True + + self._calls = {} # dict: key - friend number, value - Call instance + + self._audio = None + self._audio_stream = None + self._audio_thread = None + self._audio_running = False + self._out_stream = None + + self._audio_rate = 8000 + self._audio_channels = 1 + self._audio_duration = 60 + self._audio_sample_count = self._audio_rate * self._audio_channels * self._audio_duration // 1000 + + self._video = None + self._video_thread = None + self._video_running = False + + self._video_width = 640 + self._video_height = 480 + + def stop(self): + self._running = False + self.stop_audio_thread() + self.stop_video_thread() + + def __contains__(self, friend_number): + return friend_number in self._calls + + # ----------------------------------------------------------------------------------------------------------------- + # Calls + # ----------------------------------------------------------------------------------------------------------------- + + def __call__(self, friend_number, audio, video): + """Call friend with specified number""" + self._toxav.call(friend_number, 32 if audio else 0, 5000 if video else 0) + self._calls[friend_number] = Call(audio, video) + threading.Timer(30.0, lambda: self.finish_not_started_call(friend_number)).start() + + def accept_call(self, friend_number, audio_enabled, video_enabled): + if self._running: + self._calls[friend_number] = Call(audio_enabled, video_enabled) + self._toxav.answer(friend_number, 32 if audio_enabled else 0, 5000 if video_enabled else 0) + if audio_enabled: + self.start_audio_thread() + if video_enabled: + self.start_video_thread() + + def finish_call(self, friend_number, by_friend=False): + if not by_friend: + self._toxav.call_control(friend_number, TOXAV_CALL_CONTROL['CANCEL']) + if friend_number in self._calls: + del self._calls[friend_number] + if not len(list(filter(lambda c: c.out_audio, self._calls))): + self.stop_audio_thread() + if not len(list(filter(lambda c: c.out_video, self._calls))): + self.stop_video_thread() + + def finish_not_started_call(self, friend_number): + if friend_number in self: + call = self._calls[friend_number] + if not call.is_active: + self.finish_call(friend_number) + + def toxav_call_state_cb(self, friend_number, state): + """ + New call state + """ + call = self._calls[friend_number] + call.is_active = True + + call.in_audio = state | TOXAV_FRIEND_CALL_STATE['SENDING_A'] > 0 + call.in_video = state | TOXAV_FRIEND_CALL_STATE['SENDING_V'] > 0 + + if state | TOXAV_FRIEND_CALL_STATE['ACCEPTING_A'] and call.out_audio: + self.start_audio_thread() + + if state | TOXAV_FRIEND_CALL_STATE['ACCEPTING_V'] and call.out_video: + self.start_video_thread() + + def is_video_call(self, number): + return number in self and self._calls[number].in_video + + # ----------------------------------------------------------------------------------------------------------------- + # Threads + # ----------------------------------------------------------------------------------------------------------------- + + def start_audio_thread(self): + """ + Start audio sending + """ + if self._audio_thread is not None: + return + + self._audio_running = True + + self._audio = pyaudio.PyAudio() + self._audio_stream = self._audio.open(format=pyaudio.paInt16, + rate=self._audio_rate, + channels=self._audio_channels, + input=True, + input_device_index=self._settings.audio['input'], + frames_per_buffer=self._audio_sample_count * 10) + + self._audio_thread = threading.Thread(target=self.send_audio) + self._audio_thread.start() + + def stop_audio_thread(self): + + if self._audio_thread is None: + return + + self._audio_running = False + + self._audio_thread.join() + + self._audio_thread = None + self._audio_stream = None + self._audio = None + + if self._out_stream is not None: + self._out_stream.stop_stream() + self._out_stream.close() + self._out_stream = None + + def start_video_thread(self): + if self._video_thread is not None: + return + + self._video_running = True + self._video_width = s.video['width'] + self._video_height = s.video['height'] + + if s.video['device'] == -1: + self._video = screen_sharing.DesktopGrabber(self._settings.video['x'], self._settings.video['y'], + self._settings.video['width'], self._settings.video['height']) + else: + self._video = cv2.VideoCapture(self._settings.video['device']) + self._video.set(cv2.CAP_PROP_FPS, 25) + self._video.set(cv2.CAP_PROP_FRAME_WIDTH, self._video_width) + self._video.set(cv2.CAP_PROP_FRAME_HEIGHT, self._video_height) + + self._video_thread = threading.Thread(target=self.send_video) + self._video_thread.start() + + def stop_video_thread(self): + if self._video_thread is None: + return + + self._video_running = False + self._video_thread.join() + self._video_thread = None + self._video = None + + # ----------------------------------------------------------------------------------------------------------------- + # Incoming chunks + # ----------------------------------------------------------------------------------------------------------------- + + def audio_chunk(self, samples, channels_count, rate): + """ + Incoming chunk + """ + + if self._out_stream is None: + self._out_stream = self._audio.open(format=pyaudio.paInt16, + channels=channels_count, + rate=rate, + output_device_index=self._settings.audio['output'], + output=True) + self._out_stream.write(samples) + + # ----------------------------------------------------------------------------------------------------------------- + # AV sending + # ----------------------------------------------------------------------------------------------------------------- + + def send_audio(self): + """ + This method sends audio to friends + """ + + while self._audio_running: + try: + pcm = self._audio_stream.read(self._audio_sample_count) + if pcm: + for friend_num in self._calls: + if self._calls[friend_num].out_audio: + try: + self._toxav.audio_send_frame(friend_num, pcm, self._audio_sample_count, + self._audio_channels, self._audio_rate) + except: + pass + except: + pass + + time.sleep(0.01) + + def send_video(self): + """ + This method sends video to friends + """ + while self._video_running: + try: + result, frame = self._video.read() + if result: + height, width, channels = frame.shape + for friend_num in self._calls: + if self._calls[friend_num].out_video: + try: + y, u, v = self.convert_bgr_to_yuv(frame) + self._toxav.video_send_frame(friend_num, width, height, y, u, v) + except: + pass + except: + pass + + time.sleep(0.01) + + def convert_bgr_to_yuv(self, frame): + """ + :param frame: input bgr frame + :return y, u, v: y, u, v values of frame + + How this function works: + OpenCV creates YUV420 frame from BGR + This frame has following structure and size: + width, height - dim of input frame + width, height * 1.5 - dim of output frame + + width + ------------------------- + | | + | Y | height + | | + ------------------------- + | | | + | U even | U odd | height // 4 + | | | + ------------------------- + | | | + | V even | V odd | height // 4 + | | | + ------------------------- + + width // 2 width // 2 + + Y, U, V can be extracted using slices and joined in one list using itertools.chain.from_iterable() + Function returns bytes(y), bytes(u), bytes(v), because it is required for ctypes + """ + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420) + + y = frame[:self._video_height, :] + y = list(itertools.chain.from_iterable(y)) + + u = np.zeros((self._video_height // 2, self._video_width // 2), dtype=np.int) + u[::2, :] = frame[self._video_height:self._video_height * 5 // 4, :self._video_width // 2] + u[1::2, :] = frame[self._video_height:self._video_height * 5 // 4, self._video_width // 2:] + u = list(itertools.chain.from_iterable(u)) + v = np.zeros((self._video_height // 2, self._video_width // 2), dtype=np.int) + v[::2, :] = frame[self._video_height * 5 // 4:, :self._video_width // 2] + v[1::2, :] = frame[self._video_height * 5 // 4:, self._video_width // 2:] + v = list(itertools.chain.from_iterable(v)) + + return bytes(y), bytes(u), bytes(v) diff --git a/toxygen/av/calls_manager.py b/toxygen/av/calls_manager.py new file mode 100644 index 0000000..5a48672 --- /dev/null +++ b/toxygen/av/calls_manager.py @@ -0,0 +1,116 @@ +import threading +import cv2 +import av.calls +from messenger.messages import * +from ui import av_widgets +import common.event as event + + +class CallsManager: + + def __init__(self, toxav, settings, screen, contacts_manager): + self._call = av.calls.AV(toxav, settings) # object with data about calls + self._call_widgets = {} # dict of incoming call widgets + self._incoming_calls = set() + self._settings = settings + self._screen = screen + self._contacts_manager = contacts_manager + self._call_started_event = event.Event() # friend_number, audio, video, is_outgoing + self._call_finished_event = event.Event() # friend_number, is_declined + + def set_toxav(self, toxav): + self._call.set_toxav(toxav) + + # ----------------------------------------------------------------------------------------------------------------- + # Events + # ----------------------------------------------------------------------------------------------------------------- + + def get_call_started_event(self): + return self._call_started_event + + call_started_event = property(get_call_started_event) + + def get_call_finished_event(self): + return self._call_finished_event + + call_finished_event = property(get_call_finished_event) + + # ----------------------------------------------------------------------------------------------------------------- + # AV support + # ----------------------------------------------------------------------------------------------------------------- + + def call_click(self, audio=True, video=False): + """User clicked audio button in main window""" + num = self._contacts_manager.get_active_number() + if not self._contacts_manager.is_active_a_friend(): + return + if num not in self._call and self._contacts_manager.is_active_online(): # start call + if not self._settings.audio['enabled']: + return + self._call(num, audio, video) + self._screen.active_call() + self._call_started_event(num, audio, video, True) + elif num in self._call: # finish or cancel call if you call with active friend + self.stop_call(num, False) + + def incoming_call(self, audio, video, friend_number): + """ + Incoming call from friend. + """ + if not self._settings.audio['enabled']: + return + friend = self._contacts_manager.get_friend_by_number(friend_number) + self._call_started_event(friend_number, audio, video, False) + self._incoming_calls.add(friend_number) + if friend_number == self._contacts_manager.get_active_number(): + self._screen.incoming_call() + else: + friend.actions = True + text = util_ui.tr("Incoming video call") if video else util_ui.tr("Incoming audio call") + self._call_widgets[friend_number] = self._get_incoming_call_widget(friend_number, text, friend.name) + self._call_widgets[friend_number].set_pixmap(friend.get_pixmap()) + self._call_widgets[friend_number].show() + + def accept_call(self, friend_number, audio, video): + """ + Accept incoming call with audio or video + """ + self._call.accept_call(friend_number, audio, video) + self._screen.active_call() + if friend_number in self._incoming_calls: + self._incoming_calls.remove(friend_number) + del self._call_widgets[friend_number] + + def stop_call(self, friend_number, by_friend): + """ + Stop call with friend + """ + if friend_number in self._incoming_calls: + self._incoming_calls.remove(friend_number) + is_declined = True + else: + is_declined = False + self._screen.call_finished() + is_video = self._call.is_video_call(friend_number) + self._call.finish_call(friend_number, by_friend) # finish or decline call + if friend_number in self._call_widgets: + self._call_widgets[friend_number].close() + del self._call_widgets[friend_number] + + def destroy_window(): + if is_video: + cv2.destroyWindow(str(friend_number)) + + threading.Timer(2.0, destroy_window).start() + self._call_finished_event(friend_number, is_declined) + + def friend_exit(self, friend_number): + if friend_number in self._call: + self._call.finish_call(friend_number, True) + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _get_incoming_call_widget(self, friend_number, text, friend_name): + return av_widgets.IncomingCallWidget(self._settings, self, friend_number, text, friend_name) diff --git a/toxygen/av/screen_sharing.py b/toxygen/av/screen_sharing.py new file mode 100644 index 0000000..265658c --- /dev/null +++ b/toxygen/av/screen_sharing.py @@ -0,0 +1,22 @@ +import numpy as np +from PyQt5 import QtWidgets + + +class DesktopGrabber: + + def __init__(self, x, y, width, height): + self._x = x + self._y = y + self._width = width + self._height = height + self._width -= width % 4 + self._height -= height % 4 + self._screen = QtWidgets.QApplication.primaryScreen() + + def read(self): + pixmap = self._screen.grabWindow(0, self._x, self._y, self._width, self._height) + image = pixmap.toImage() + s = image.bits().asstring(self._width * self._height * 4) + arr = np.fromstring(s, dtype=np.uint8).reshape((self._height, self._width, 4)) + + return True, arr diff --git a/toxygen/bootstrap/__init__.py b/toxygen/bootstrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/bootstrap/bootstrap.py b/toxygen/bootstrap/bootstrap.py new file mode 100644 index 0000000..fad68c4 --- /dev/null +++ b/toxygen/bootstrap/bootstrap.py @@ -0,0 +1,83 @@ +import random +import urllib.request +from utils.util import * +from PyQt5 import QtNetwork, QtCore +import json + + +DEFAULT_NODES_COUNT = 4 + + +class Node: + + def __init__(self, node): + self._ip, self._port, self._tox_key = node['ipv4'], node['port'], node['public_key'] + self._priority = random.randint(1, 1000000) if node['status_tcp'] and node['status_udp'] else 0 + + def get_priority(self): + return self._priority + + priority = property(get_priority) + + def get_data(self): + return self._ip, self._port, self._tox_key + + +def generate_nodes(nodes_count=DEFAULT_NODES_COUNT): + with open(_get_nodes_path(), 'rt') as fl: + json_nodes = json.loads(fl.read())['nodes'] + nodes = map(lambda json_node: Node(json_node), json_nodes) + nodes = filter(lambda n: n.priority > 0, nodes) + sorted_nodes = sorted(nodes, key=lambda x: x.priority) + if nodes_count is not None: + sorted_nodes = sorted_nodes[-DEFAULT_NODES_COUNT:] + for node in sorted_nodes: + yield node.get_data() + + +def download_nodes_list(settings): + url = 'https://nodes.tox.chat/json' + if not settings['download_nodes_list']: + return + + if not settings['proxy_type']: # no proxy + try: + req = urllib.request.Request(url) + req.add_header('Content-Type', 'application/json') + response = urllib.request.urlopen(req) + result = response.read() + _save_nodes(result) + except Exception as ex: + log('TOX nodes loading error: ' + str(ex)) + else: # proxy + netman = QtNetwork.QNetworkAccessManager() + proxy = QtNetwork.QNetworkProxy() + proxy.setType( + QtNetwork.QNetworkProxy.Socks5Proxy if settings['proxy_type'] == 2 else QtNetwork.QNetworkProxy.HttpProxy) + proxy.setHostName(settings['proxy_host']) + proxy.setPort(settings['proxy_port']) + netman.setProxy(proxy) + try: + request = QtNetwork.QNetworkRequest() + request.setUrl(QtCore.QUrl(url)) + reply = netman.get(request) + + while not reply.isFinished(): + QtCore.QThread.msleep(1) + QtCore.QCoreApplication.processEvents() + data = bytes(reply.readAll().data()) + _save_nodes(data) + except Exception as ex: + log('TOX nodes loading error: ' + str(ex)) + + +def _get_nodes_path(): + return join_path(curr_directory(__file__), 'nodes.json') + + +def _save_nodes(nodes): + if not nodes: + return + print('Saving nodes...') + with open(_get_nodes_path(), 'wb') as fl: + fl.write(nodes) diff --git a/toxygen/bootstrap/nodes.json b/toxygen/bootstrap/nodes.json new file mode 100644 index 0000000..5314998 --- /dev/null +++ b/toxygen/bootstrap/nodes.json @@ -0,0 +1 @@ +{"nodes":[{"ipv4":"80.211.19.83","ipv6":"-","port":33445,"public_key":"A2D7BF17C10A12C339B9F4E8DD77DEEE8457D580535A6F0D0F9AF04B8B4C4420","status_udp":true,"status_tcp":true}]} \ No newline at end of file diff --git a/toxygen/common/__init__.py b/toxygen/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/common/event.py b/toxygen/common/event.py new file mode 100644 index 0000000..687a34d --- /dev/null +++ b/toxygen/common/event.py @@ -0,0 +1,26 @@ + + +class Event: + + def __init__(self): + self._callbacks = set() + + def __iadd__(self, callback): + self.add_callback(callback) + + return self + + def __isub__(self, callback): + self.remove_callback(callback) + + return self + + def __call__(self, *args, **kwargs): + for callback in self._callbacks: + callback(*args, **kwargs) + + def add_callback(self, callback): + self._callbacks.add(callback) + + def remove_callback(self, callback): + self._callbacks.discard(callback) diff --git a/toxygen/common/provider.py b/toxygen/common/provider.py new file mode 100644 index 0000000..d16edb4 --- /dev/null +++ b/toxygen/common/provider.py @@ -0,0 +1,13 @@ + + +class Provider: + + def __init__(self, get_item_action): + self._get_item_action = get_item_action + self._item = None + + def get_item(self): + if self._item is None: + self._item = self._get_item_action() + + return self._item diff --git a/toxygen/common/tox_save.py b/toxygen/common/tox_save.py new file mode 100644 index 0000000..09c159b --- /dev/null +++ b/toxygen/common/tox_save.py @@ -0,0 +1,18 @@ + + +class ToxSave: + + def __init__(self, tox): + self._tox = tox + + def set_tox(self, tox): + self._tox = tox + + +class ToxAvSave: + + def __init__(self, toxav): + self._toxav = toxav + + def set_toxav(self, toxav): + self._toxav = toxav diff --git a/toxygen/contacts/__init__.py b/toxygen/contacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/contacts/basecontact.py b/toxygen/contacts/basecontact.py new file mode 100644 index 0000000..2058890 --- /dev/null +++ b/toxygen/contacts/basecontact.py @@ -0,0 +1,180 @@ +from user_data.settings import * +from PyQt5 import QtCore, QtGui +from wrapper.toxcore_enums_and_consts import TOX_PUBLIC_KEY_SIZE +import utils.util as util +import common.event as event +import contacts.common as common + + +class BaseContact: + """ + Class encapsulating TOX contact + Properties: name (alias of contact or name), status_message, status (connection status) + widget - widget for update, tox id (or public key) + Base class for all contacts. + """ + + def __init__(self, profile_manager, name, status_message, widget, tox_id): + """ + :param name: name, example: 'Toxygen user' + :param status_message: status message, example: 'Toxing on Toxygen' + :param widget: ContactItem instance + :param tox_id: tox id of contact + """ + self._profile_manager = profile_manager + self._name, self._status_message = name, status_message + self._status, self._widget = None, widget + self._tox_id = tox_id + self._name_changed_event = event.Event() + self._status_message_changed_event = event.Event() + self._status_changed_event = event.Event() + self._avatar_changed_event = event.Event() + self.init_widget() + + # ----------------------------------------------------------------------------------------------------------------- + # Name - current name or alias of user + # ----------------------------------------------------------------------------------------------------------------- + + def get_name(self): + return self._name + + def set_name(self, value): + if self._name == value: + return + self._name = value + self._widget.name.setText(self._name) + self._widget.name.repaint() + self._name_changed_event(self._name) + + name = property(get_name, set_name) + + def get_name_changed_event(self): + return self._name_changed_event + + name_changed_event = property(get_name_changed_event) + + # ----------------------------------------------------------------------------------------------------------------- + # Status message + # ----------------------------------------------------------------------------------------------------------------- + + def get_status_message(self): + return self._status_message + + def set_status_message(self, value): + if self._status_message == value: + return + self._status_message = value + self._widget.status_message.setText(self._status_message) + self._widget.status_message.repaint() + self._status_message_changed_event(self._status_message) + + status_message = property(get_status_message, set_status_message) + + def get_status_message_changed_event(self): + return self._status_message_changed_event + + status_message_changed_event = property(get_status_message_changed_event) + + # ----------------------------------------------------------------------------------------------------------------- + # Status + # ----------------------------------------------------------------------------------------------------------------- + + def get_status(self): + return self._status + + def set_status(self, value): + if self._status == value: + return + self._status = value + self._widget.connection_status.update(value) + self._status_changed_event(self._status) + + status = property(get_status, set_status) + + def get_status_changed_event(self): + return self._status_changed_event + + status_changed_event = property(get_status_changed_event) + + # ----------------------------------------------------------------------------------------------------------------- + # TOX ID. WARNING: for friend it will return public key, for profile - full address + # ----------------------------------------------------------------------------------------------------------------- + + def get_tox_id(self): + return self._tox_id + + tox_id = property(get_tox_id) + + # ----------------------------------------------------------------------------------------------------------------- + # Avatars + # ----------------------------------------------------------------------------------------------------------------- + + def load_avatar(self): + """ + Tries to load avatar of contact or uses default avatar + """ + avatar_path = self.get_avatar_path() + width = self._widget.avatar_label.width() + pixmap = QtGui.QPixmap(avatar_path) + self._widget.avatar_label.setPixmap(pixmap.scaled(width, width, QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation)) + self._widget.avatar_label.repaint() + self._avatar_changed_event(avatar_path) + + def reset_avatar(self, generate_new): + avatar_path = self.get_avatar_path() + if os.path.isfile(avatar_path) and not avatar_path == self._get_default_avatar_path(): + os.remove(avatar_path) + if generate_new: + self.set_avatar(common.generate_avatar(self._tox_id[:TOX_PUBLIC_KEY_SIZE * 2])) + else: + self.load_avatar() + + def set_avatar(self, avatar): + avatar_path = self.get_contact_avatar_path() + with open(avatar_path, 'wb') as f: + f.write(avatar) + self.load_avatar() + + def get_pixmap(self): + return self._widget.avatar_label.pixmap() + + def get_avatar_path(self): + avatar_path = self.get_contact_avatar_path() + if not os.path.isfile(avatar_path) or not os.path.getsize(avatar_path): # load default image + avatar_path = self._get_default_avatar_path() + + return avatar_path + + def get_contact_avatar_path(self): + directory = util.join_path(self._profile_manager.get_dir(), 'avatars') + + return util.join_path(directory, '{}.png'.format(self._tox_id[:TOX_PUBLIC_KEY_SIZE * 2])) + + def has_avatar(self): + path = self.get_contact_avatar_path() + + return util.file_exists(path) + + def get_avatar_changed_event(self): + return self._avatar_changed_event + + avatar_changed_event = property(get_avatar_changed_event) + + # ----------------------------------------------------------------------------------------------------------------- + # Widgets + # ----------------------------------------------------------------------------------------------------------------- + + def init_widget(self): + self._widget.name.setText(self._name) + self._widget.status_message.setText(self._status_message) + self._widget.connection_status.update(self._status) + self.load_avatar() + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _get_default_avatar_path(): + return util.join_path(util.get_images_directory(), 'avatar.png') diff --git a/toxygen/contacts/common.py b/toxygen/contacts/common.py new file mode 100644 index 0000000..27750a2 --- /dev/null +++ b/toxygen/contacts/common.py @@ -0,0 +1,50 @@ +from pydenticon import Generator +import hashlib + + +# ----------------------------------------------------------------------------------------------------------------- +# Typing notifications +# ----------------------------------------------------------------------------------------------------------------- + +class BaseTypingNotificationHandler: + + DEFAULT_HANDLER = None + + def __init__(self): + pass + + def send(self, tox, is_typing): + pass + + +class FriendTypingNotificationHandler(BaseTypingNotificationHandler): + + def __init__(self, friend_number): + super().__init__() + self._friend_number = friend_number + + def send(self, tox, is_typing): + tox.self_set_typing(self._friend_number, is_typing) + + +BaseTypingNotificationHandler.DEFAULT_HANDLER = BaseTypingNotificationHandler() + + +# ----------------------------------------------------------------------------------------------------------------- +# Identicons support +# ----------------------------------------------------------------------------------------------------------------- + + +def generate_avatar(public_key): + foreground = ['rgb(45,79,255)', 'rgb(185, 66, 244)', 'rgb(185, 66, 244)', + 'rgb(254,180,44)', 'rgb(252, 2, 2)', 'rgb(109, 198, 0)', + 'rgb(226,121,234)', 'rgb(130, 135, 124)', + 'rgb(30,179,253)', 'rgb(160, 157, 0)', + 'rgb(232,77,65)', 'rgb(102, 4, 4)', + 'rgb(49,203,115)', + 'rgb(141,69,170)'] + generator = Generator(5, 5, foreground=foreground, background='rgba(42,42,42,0)') + digest = hashlib.sha256(public_key.encode('utf-8')).hexdigest() + identicon = generator.generate(digest, 220, 220, padding=(10, 10, 10, 10)) + + return identicon diff --git a/toxygen/contacts/contact.py b/toxygen/contacts/contact.py new file mode 100644 index 0000000..e88acf2 --- /dev/null +++ b/toxygen/contacts/contact.py @@ -0,0 +1,333 @@ +from history.database import * +from contacts import basecontact, common +from messenger.messages import * +from contacts.contact_menu import * +from file_transfers import file_transfers as ft +import re + + +class Contact(basecontact.BaseContact): + """ + Class encapsulating TOX contact + Properties: number, message getter, history etc. Base class for friend and gc classes + """ + + def __init__(self, profile_manager, message_getter, number, name, status_message, widget, tox_id): + """ + :param message_getter: gets messages from db + :param number: number of friend. + """ + super().__init__(profile_manager, name, status_message, widget, tox_id) + self._number = number + self._new_messages = False + self._visible = True + self._alias = False + self._message_getter = message_getter + self._corr = [] + self._unsaved_messages = 0 + self._history_loaded = self._new_actions = False + self._curr_text = self._search_string = '' + self._search_index = 0 + + def __del__(self): + self.set_visibility(False) + del self._widget + if hasattr(self, '_message_getter'): + del self._message_getter + + # ----------------------------------------------------------------------------------------------------------------- + # History support + # ----------------------------------------------------------------------------------------------------------------- + + def load_corr(self, first_time=True): + """ + :param first_time: friend became active, load first part of messages + """ + try: + if (first_time and self._history_loaded) or (not hasattr(self, '_message_getter')): + return + if self._message_getter is None: + return + data = list(self._message_getter.get(PAGE_SIZE)) + if data is not None and len(data): + data.reverse() + else: + return + data = list(map(lambda p: self._get_text_message(p), data)) + self._corr = data + self._corr + except: + pass + finally: + self._history_loaded = True + + def load_all_corr(self): + """ + Get all chat history from db for current friend + """ + if self._message_getter is None: + return + data = list(self._message_getter.get_all()) + if data is not None and len(data): + data.reverse() + data = list(map(lambda p: self._get_text_message(p), data)) + self._corr = data + self._corr + self._history_loaded = True + + def get_corr_for_saving(self): + """ + Get data to save in db + :return: list of unsaved messages or [] + """ + messages = list(filter(lambda m: m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']), self._corr)) + return messages[-self._unsaved_messages:] if self._unsaved_messages else [] + + def get_corr(self): + return self._corr[:] + + def append_message(self, message): + """ + :param message: text or file transfer message + """ + self._corr.append(message) + if message.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']): + self._unsaved_messages += 1 + + def get_last_message_text(self): + messages = list(filter(lambda m: m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']) + and m.author.type != MESSAGE_AUTHOR['FRIEND'], self._corr)) + if messages: + return messages[-1].text + else: + return '' + + def remove_messages_widgets(self): + for message in self._corr: + message.remove_widget() + + def get_message(self, _filter): + return list(filter(lambda m: _filter(m), self._corr))[0] + + @staticmethod + def _get_text_message(params): + (message, author_type, author_name, unix_time, message_type, unique_id) = params + author = MessageAuthor(author_name, author_type) + + return TextMessage(message, author, unix_time, message_type, unique_id) + + # ----------------------------------------------------------------------------------------------------------------- + # Unsent messages + # ----------------------------------------------------------------------------------------------------------------- + + def get_unsent_messages(self): + """ + :return list of unsent messages + """ + messages = filter(lambda m: m.author is not None and m.author.type == MESSAGE_AUTHOR['NOT_SENT'], self._corr) + return list(messages) + + def get_unsent_messages_for_saving(self): + """ + :return list of unsent messages for saving + """ + messages = filter(lambda m: m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']) + and m.author.type == MESSAGE_AUTHOR['NOT_SENT'], self._corr) + return list(messages) + + def mark_as_sent(self, tox_message_id): + try: + message = list(filter(lambda m: m.author is not None and m.author.type == MESSAGE_AUTHOR['NOT_SENT'] + and m.tox_message_id == tox_message_id, self._corr))[0] + message.mark_as_sent() + except Exception as ex: + util.log('Mark as sent ex: ' + str(ex)) + + # ----------------------------------------------------------------------------------------------------------------- + # Message deletion + # ----------------------------------------------------------------------------------------------------------------- + + def delete_message(self, message_id): + elem = list(filter(lambda m: m.message_id == message_id, self._corr))[0] + tmp = list(filter(lambda m: m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']), self._corr)) + if elem in tmp[-self._unsaved_messages:] and self._unsaved_messages: + self._unsaved_messages -= 1 + self._corr.remove(elem) + self._message_getter.delete_one() + self._search_index = 0 + + def delete_old_messages(self): + """ + Delete old messages (reduces RAM usage if messages saving is not enabled) + """ + def save_message(m): + if m.type == MESSAGE_TYPE['FILE_TRANSFER'] and (m.state not in ACTIVE_FILE_TRANSFERS): + return True + return m.author is not None and m.author.type == MESSAGE_AUTHOR['NOT_SENT'] + + old = filter(save_message, self._corr[:-SAVE_MESSAGES]) + self._corr = list(old) + self._corr[-SAVE_MESSAGES:] + text_messages = filter(lambda m: m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']), self._corr) + self._unsaved_messages = min(self._unsaved_messages, len(list(text_messages))) + self._search_index = 0 + + def clear_corr(self, save_unsent=False): + """ + Clear messages list + """ + if hasattr(self, '_message_getter'): + del self._message_getter + self._search_index = 0 + # don't delete data about active file transfer + if not save_unsent: + self._corr = list(filter(lambda m: m.type == MESSAGE_TYPE['FILE_TRANSFER'] and + m.state in ft.ACTIVE_FILE_TRANSFERS, self._corr)) + self._unsaved_messages = 0 + else: + self._corr = list(filter(lambda m: (m.type == MESSAGE_TYPE['FILE_TRANSFER'] + and m.state in ft.ACTIVE_FILE_TRANSFERS) + or (m.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']) + and m.author.type == MESSAGE_AUTHOR['NOT_SENT']), + self._corr)) + self._unsaved_messages = len(self.get_unsent_messages()) + + # ----------------------------------------------------------------------------------------------------------------- + # Chat history search + # ----------------------------------------------------------------------------------------------------------------- + + def search_string(self, search_string): + self._search_string, self._search_index = search_string, 0 + return self.search_prev() + + def search_prev(self): + while True: + l = len(self._corr) + for i in range(self._search_index - 1, -l - 1, -1): + if self._corr[i].type not in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']): + continue + message = self._corr[i].text + if re.search(self._search_string, message, re.IGNORECASE) is not None: + self._search_index = i + return i + self._search_index = -l + self.load_corr(False) + if len(self._corr) == l: + return None # not found + + def search_next(self): + if not self._search_index: + return None + for i in range(self._search_index + 1, 0): + if self._corr[i].type not in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']): + continue + message = self._corr[i].text + if re.search(self._search_string, message, re.IGNORECASE) is not None: + self._search_index = i + return i + return None # not found + + # ----------------------------------------------------------------------------------------------------------------- + # Current text - text from message area + # ----------------------------------------------------------------------------------------------------------------- + + def get_curr_text(self): + return self._curr_text + + def set_curr_text(self, value): + self._curr_text = value + + curr_text = property(get_curr_text, set_curr_text) + + # ----------------------------------------------------------------------------------------------------------------- + # Alias support + # ----------------------------------------------------------------------------------------------------------------- + + def set_name(self, value): + """ + Set new name or ignore if alias exists + :param value: new name + """ + if not self._alias: + super().set_name(value) + + def set_alias(self, alias): + self._alias = bool(alias) + + def has_alias(self): + return self._alias + + # ----------------------------------------------------------------------------------------------------------------- + # Visibility in friends' list + # ----------------------------------------------------------------------------------------------------------------- + + def get_visibility(self): + return self._visible + + def set_visibility(self, value): + self._visible = value + + visibility = property(get_visibility, set_visibility) + + # ----------------------------------------------------------------------------------------------------------------- + # Unread messages and other actions from friend + # ----------------------------------------------------------------------------------------------------------------- + + def get_actions(self): + return self._new_actions + + def set_actions(self, value): + self._new_actions = value + self._widget.connection_status.update(self.status, value) + + actions = property(get_actions, set_actions) # unread messages, incoming files, av calls + + def get_messages(self): + return self._new_messages + + def inc_messages(self): + self._new_messages += 1 + self._new_actions = True + self._widget.connection_status.update(self.status, True) + self._widget.messages.update(self._new_messages) + + def reset_messages(self): + self._new_actions = False + self._new_messages = 0 + self._widget.messages.update(self._new_messages) + self._widget.connection_status.update(self.status, False) + + messages = property(get_messages) + + # ----------------------------------------------------------------------------------------------------------------- + # Friend's or group's number (can be used in toxcore) + # ----------------------------------------------------------------------------------------------------------------- + + def get_number(self): + return self._number + + def set_number(self, value): + self._number = value + + number = property(get_number, set_number) + + # ----------------------------------------------------------------------------------------------------------------- + # Typing notifications + # ----------------------------------------------------------------------------------------------------------------- + + def get_typing_notification_handler(self): + return common.BaseTypingNotificationHandler.DEFAULT_HANDLER + + typing_notification_handler = property(get_typing_notification_handler) + + # ----------------------------------------------------------------------------------------------------------------- + # Context menu support + # ----------------------------------------------------------------------------------------------------------------- + + def get_context_menu_generator(self): + return BaseContactMenuGenerator(self) + + # ----------------------------------------------------------------------------------------------------------------- + # Filtration support + # ----------------------------------------------------------------------------------------------------------------- + + def set_widget(self, widget): + self._widget = widget + self.init_widget() diff --git a/toxygen/contacts/contact_menu.py b/toxygen/contacts/contact_menu.py new file mode 100644 index 0000000..8178d31 --- /dev/null +++ b/toxygen/contacts/contact_menu.py @@ -0,0 +1,229 @@ +from PyQt5 import QtWidgets +import utils.ui as util_ui + + +# ----------------------------------------------------------------------------------------------------------------- +# Builder +# ----------------------------------------------------------------------------------------------------------------- + +def _create_menu(menu_name, parent): + menu_name = menu_name or '' + + return QtWidgets.QMenu(menu_name) if parent is None else parent.addMenu(menu_name) + + +class ContactMenuBuilder: + + def __init__(self): + self._actions = {} + self._submenus = {} + self._name = None + self._index = 0 + + def with_name(self, name): + self._name = name + + return self + + def with_action(self, text, handler): + self._add_action(text, handler) + + return self + + def with_optional_action(self, text, handler, show_action): + if show_action: + self._add_action(text, handler) + + return self + + def with_actions(self, actions): + for action in actions: + (text, handler) = action + self._add_action(text, handler) + + return self + + def with_submenu(self, submenu_builder): + self._add_submenu(submenu_builder) + + return self + + def with_optional_submenu(self, submenu_builder): + if submenu_builder is not None: + self._add_submenu(submenu_builder) + + return self + + def build(self, parent=None): + menu = _create_menu(self._name, parent) + + for i in range(self._index): + if i in self._actions: + text, handler = self._actions[i] + action = menu.addAction(text) + action.triggered.connect(handler) + else: + submenu_builder = self._submenus[i] + submenu = submenu_builder.build(menu) + menu.addMenu(submenu) + + return menu + + def _add_submenu(self, submenu): + self._submenus[self._index] = submenu + self._index += 1 + + def _add_action(self, text, handler): + self._actions[self._index] = (text, handler) + self._index += 1 + +# ----------------------------------------------------------------------------------------------------------------- +# Generators +# ----------------------------------------------------------------------------------------------------------------- + + +class BaseContactMenuGenerator: + + def __init__(self, contact): + self._contact = contact + + def generate(self, plugin_loader, contacts_manager, main_screen, settings, number, groups_service, history_loader): + return ContactMenuBuilder().build() + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _generate_copy_menu_builder(self, main_screen): + copy_menu_builder = ContactMenuBuilder() + (copy_menu_builder + .with_name(util_ui.tr('Copy')) + .with_action(util_ui.tr('Name'), lambda: main_screen.copy_text(self._contact.name)) + .with_action(util_ui.tr('Status message'), lambda: main_screen.copy_text(self._contact.status_message)) + .with_action(util_ui.tr('Public key'), lambda: main_screen.copy_text(self._contact.tox_id)) + ) + + return copy_menu_builder + + def _generate_history_menu_builder(self, history_loader, main_screen): + history_menu_builder = ContactMenuBuilder() + (history_menu_builder + .with_name(util_ui.tr('Chat history')) + .with_action(util_ui.tr('Clear history'), lambda: history_loader.clear_history(self._contact) + or main_screen.messages.clear()) + .with_action(util_ui.tr('Export as text'), lambda: history_loader.export_history(self._contact)) + .with_action(util_ui.tr('Export as HTML'), lambda: history_loader.export_history(self._contact, False)) + ) + + return history_menu_builder + + +class FriendMenuGenerator(BaseContactMenuGenerator): + + def generate(self, plugin_loader, contacts_manager, main_screen, settings, number, groups_service, history_loader): + history_menu_builder = self._generate_history_menu_builder(history_loader, main_screen) + copy_menu_builder = self._generate_copy_menu_builder(main_screen) + plugins_menu_builder = self._generate_plugins_menu_builder(plugin_loader, number) + groups_menu_builder = self._generate_groups_menu(contacts_manager, groups_service) + + allowed = self._contact.tox_id in settings['auto_accept_from_friends'] + auto = util_ui.tr('Disallow auto accept') if allowed else util_ui.tr('Allow auto accept') + + builder = ContactMenuBuilder() + menu = (builder + .with_action(util_ui.tr('Set alias'), lambda: main_screen.set_alias(number)) + .with_submenu(history_menu_builder) + .with_submenu(copy_menu_builder) + .with_action(auto, lambda: main_screen.auto_accept(number, not allowed)) + .with_action(util_ui.tr('Remove friend'), lambda: main_screen.remove_friend(number)) + .with_action(util_ui.tr('Block friend'), lambda: main_screen.block_friend(number)) + .with_action(util_ui.tr('Notes'), lambda: main_screen.show_note(self._contact)) + .with_optional_submenu(plugins_menu_builder) + .with_optional_submenu(groups_menu_builder) + ).build() + + return menu + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _generate_plugins_menu_builder(plugin_loader, number): + if plugin_loader is None: + return None + plugins_actions = plugin_loader.get_menu(number) + if not len(plugins_actions): + return None + plugins_menu_builder = ContactMenuBuilder() + (plugins_menu_builder + .with_name(util_ui.tr('Plugins')) + .with_actions(plugins_actions) + ) + + return plugins_menu_builder + + def _generate_groups_menu(self, contacts_manager, groups_service): + chats = contacts_manager.get_group_chats() + if not len(chats) or self._contact.status is None: + return None + groups_menu_builder = ContactMenuBuilder() + (groups_menu_builder + .with_name(util_ui.tr('Invite to group')) + .with_actions([(g.name, lambda: groups_service.invite_friend(self._contact.number, g.number)) for g in chats]) + ) + + return groups_menu_builder + + +class GroupMenuGenerator(BaseContactMenuGenerator): + + def generate(self, plugin_loader, contacts_manager, main_screen, settings, number, groups_service, history_loader): + copy_menu_builder = self._generate_copy_menu_builder(main_screen) + history_menu_builder = self._generate_history_menu_builder(history_loader, main_screen) + + builder = ContactMenuBuilder() + menu = (builder + .with_action(util_ui.tr('Set alias'), lambda: main_screen.set_alias(number)) + .with_submenu(copy_menu_builder) + .with_submenu(history_menu_builder) + .with_optional_action(util_ui.tr('Manage group'), + lambda: groups_service.show_group_management_screen(self._contact), + self._contact.is_self_founder()) + .with_optional_action(util_ui.tr('Group settings'), + lambda: groups_service.show_group_settings_screen(self._contact), + not self._contact.is_self_founder()) + .with_optional_action(util_ui.tr('Set topic'), + lambda: groups_service.set_group_topic(self._contact), + self._contact.is_self_moderator_or_founder()) + .with_action(util_ui.tr('Bans list'), + lambda: groups_service.show_bans_list(self._contact)) + .with_action(util_ui.tr('Reconnect to group'), + lambda: groups_service.reconnect_to_group(self._contact.number)) + .with_optional_action(util_ui.tr('Disconnect from group'), + lambda: groups_service.disconnect_from_group(self._contact.number), + self._contact.status is not None) + .with_action(util_ui.tr('Leave group'), lambda: groups_service.leave_group(self._contact.number)) + .with_action(util_ui.tr('Notes'), lambda: main_screen.show_note(self._contact)) + ).build() + + return menu + + +class GroupPeerMenuGenerator(BaseContactMenuGenerator): + + def generate(self, plugin_loader, contacts_manager, main_screen, settings, number, groups_service, history_loader): + copy_menu_builder = self._generate_copy_menu_builder(main_screen) + history_menu_builder = self._generate_history_menu_builder(history_loader, main_screen) + + builder = ContactMenuBuilder() + menu = (builder + .with_action(util_ui.tr('Set alias'), lambda: main_screen.set_alias(number)) + .with_submenu(copy_menu_builder) + .with_submenu(history_menu_builder) + .with_action(util_ui.tr('Quit chat'), + lambda: contacts_manager.remove_group_peer(self._contact)) + .with_action(util_ui.tr('Notes'), lambda: main_screen.show_note(self._contact)) + ).build() + + return menu diff --git a/toxygen/contacts/contact_provider.py b/toxygen/contacts/contact_provider.py new file mode 100644 index 0000000..76e8e79 --- /dev/null +++ b/toxygen/contacts/contact_provider.py @@ -0,0 +1,107 @@ +import common.tox_save as tox_save + + +class ContactProvider(tox_save.ToxSave): + + def __init__(self, tox, friend_factory, group_factory, group_peer_factory): + super().__init__(tox) + self._friend_factory = friend_factory + self._group_factory = group_factory + self._group_peer_factory = group_peer_factory + self._cache = {} # key - contact's public key, value - contact instance + + # ----------------------------------------------------------------------------------------------------------------- + # Friends + # ----------------------------------------------------------------------------------------------------------------- + + def get_friend_by_number(self, friend_number): + public_key = self._tox.friend_get_public_key(friend_number) + + return self.get_friend_by_public_key(public_key) + + def get_friend_by_public_key(self, public_key): + friend = self._get_contact_from_cache(public_key) + if friend is not None: + return friend + friend = self._friend_factory.create_friend_by_public_key(public_key) + self._add_to_cache(public_key, friend) + + return friend + + def get_all_friends(self): + friend_numbers = self._tox.self_get_friend_list() + friends = map(lambda n: self.get_friend_by_number(n), friend_numbers) + + return list(friends) + + # ----------------------------------------------------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------------------------------------------------- + + def get_all_groups(self): + group_numbers = range(self._tox.group_get_number_groups()) + groups = map(lambda n: self.get_group_by_number(n), group_numbers) + + return list(groups) + + def get_group_by_number(self, group_number): + public_key = self._tox.group_get_chat_id(group_number) + + return self.get_group_by_public_key(public_key) + + def get_group_by_public_key(self, public_key): + group = self._get_contact_from_cache(public_key) + if group is not None: + return group + group = self._group_factory.create_group_by_public_key(public_key) + self._add_to_cache(public_key, group) + + return group + + # ----------------------------------------------------------------------------------------------------------------- + # Group peers + # ----------------------------------------------------------------------------------------------------------------- + + def get_all_group_peers(self): + return list() + + def get_group_peer_by_id(self, group, peer_id): + peer = group.get_peer_by_id(peer_id) + + return self._get_group_peer(group, peer) + + def get_group_peer_by_public_key(self, group, public_key): + peer = group.get_peer_by_public_key(public_key) + + return self._get_group_peer(group, peer) + + # ----------------------------------------------------------------------------------------------------------------- + # All contacts + # ----------------------------------------------------------------------------------------------------------------- + + def get_all(self): + return self.get_all_friends() + self.get_all_groups() + self.get_all_group_peers() + + # ----------------------------------------------------------------------------------------------------------------- + # Caching + # ----------------------------------------------------------------------------------------------------------------- + + def clear_cache(self): + self._cache.clear() + + def remove_contact_from_cache(self, contact_public_key): + if contact_public_key in self._cache: + del self._cache[contact_public_key] + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _get_contact_from_cache(self, public_key): + return self._cache[public_key] if public_key in self._cache else None + + def _add_to_cache(self, public_key, contact): + self._cache[public_key] = contact + + def _get_group_peer(self, group, peer): + return self._group_peer_factory.create_group_peer(group, peer) diff --git a/toxygen/contacts/contacts_manager.py b/toxygen/contacts/contacts_manager.py new file mode 100644 index 0000000..87a61ff --- /dev/null +++ b/toxygen/contacts/contacts_manager.py @@ -0,0 +1,575 @@ +from contacts.friend import Friend +from contacts.group_chat import GroupChat +from messenger.messages import * +from common.tox_save import ToxSave +from contacts.group_peer_contact import GroupPeerContact + + +class ContactsManager(ToxSave): + """ + Represents contacts list. + """ + + def __init__(self, tox, settings, screen, profile_manager, contact_provider, history, tox_dns, + messages_items_factory): + super().__init__(tox) + self._settings = settings + self._screen = screen + self._profile_manager = profile_manager + self._contact_provider = contact_provider + self._tox_dns = tox_dns + self._messages_items_factory = messages_items_factory + self._messages = screen.messages + self._contacts, self._active_contact = [], -1 + self._active_contact_changed = Event() + self._sorting = settings['sorting'] + self._filter_string = '' + screen.contacts_filter.setCurrentIndex(int(self._sorting)) + self._history = history + self._load_contacts() + + def get_contact(self, num): + if num < 0 or num >= len(self._contacts): + return None + return self._contacts[num] + + def get_curr_contact(self): + return self._contacts[self._active_contact] if self._active_contact + 1 else None + + def save_profile(self): + data = self._tox.get_savedata() + self._profile_manager.save_profile(data) + + def is_friend_active(self, friend_number): + if not self.is_active_a_friend(): + return False + + return self.get_curr_contact().number == friend_number + + def is_group_active(self, group_number): + if self.is_active_a_friend(): + return False + + return self.get_curr_contact().number == group_number + + def is_contact_active(self, contact): + return self._contacts[self._active_contact].tox_id == contact.tox_id + + # ----------------------------------------------------------------------------------------------------------------- + # Reconnection support + # ----------------------------------------------------------------------------------------------------------------- + + def reset_contacts_statuses(self): + for contact in self._contacts: + contact.status = None + + # ----------------------------------------------------------------------------------------------------------------- + # Work with active friend + # ----------------------------------------------------------------------------------------------------------------- + + def get_active(self): + return self._active_contact + + def set_active(self, value): + """ + Change current active friend or update info + :param value: number of new active friend in friend's list + """ + if value is None and self._active_contact == -1: # nothing to update + return + if value == -1: # all friends were deleted + self._screen.account_name.setText('') + self._screen.account_status.setText('') + self._screen.account_status.setToolTip('') + self._active_contact = -1 + self._screen.account_avatar.setHidden(True) + self._messages.clear() + self._screen.messageEdit.clear() + return + try: + self._screen.typing.setVisible(False) + current_contact = self.get_curr_contact() + if current_contact is not None: + # TODO: send when needed + current_contact.typing_notification_handler.send(self._tox, False) + current_contact.remove_messages_widgets() # TODO: if required + self._unsubscribe_from_events(current_contact) + + if self._active_contact + 1 and self._active_contact != value: + try: + current_contact.curr_text = self._screen.messageEdit.toPlainText() + except: + pass + contact = self._contacts[value] + self._subscribe_to_events(contact) + contact.remove_invalid_unsent_files() + if self._active_contact != value: + self._screen.messageEdit.setPlainText(contact.curr_text) + self._active_contact = value + contact.reset_messages() + if not self._settings['save_history']: + contact.delete_old_messages() + self._messages.clear() + contact.load_corr() + corr = contact.get_corr()[-PAGE_SIZE:] + for message in corr: + if message.type == MESSAGE_TYPE['FILE_TRANSFER']: + self._messages_items_factory.create_file_transfer_item(message) + elif message.type == MESSAGE_TYPE['INLINE']: + self._messages_items_factory.create_inline_item(message) + else: + self._messages_items_factory.create_message_item(message) + self._messages.scrollToBottom() + # if value in self._call: + # self._screen.active_call() + # elif value in self._incoming_calls: + # self._screen.incoming_call() + # else: + # self._screen.call_finished() + self._set_current_contact_data(contact) + self._active_contact_changed(contact) + except Exception as ex: # no friend found. ignore + util.log('Friend value: ' + str(value)) + util.log('Error in set active: ' + str(ex)) + raise + + active_contact = property(get_active, set_active) + + def get_active_contact_changed(self): + return self._active_contact_changed + + active_contact_changed = property(get_active_contact_changed) + + def update(self): + if self._active_contact + 1: + self.set_active(self._active_contact) + + def is_active_a_friend(self): + return type(self.get_curr_contact()) is Friend + + def is_active_a_group(self): + return type(self.get_curr_contact()) is GroupChat + + def is_active_a_group_chat_peer(self): + return type(self.get_curr_contact()) is GroupPeerContact + + # ----------------------------------------------------------------------------------------------------------------- + # Filtration + # ----------------------------------------------------------------------------------------------------------------- + + def filtration_and_sorting(self, sorting=0, filter_str=''): + """ + Filtration of friends list + :param sorting: 0 - no sorting, 1 - online only, 2 - online first, 3 - by name, + 4 - online and by name, 5 - online first and by name + :param filter_str: show contacts which name contains this substring + """ + filter_str = filter_str.lower() + current_contact = self.get_curr_contact() + + if sorting > 5 or sorting < 0: + sorting = 0 + + if sorting in (1, 2, 4, 5): # online first + self._contacts = sorted(self._contacts, key=lambda x: int(x.status is not None), reverse=True) + sort_by_name = sorting in (4, 5) + # save results of previous sorting + online_friends = filter(lambda x: x.status is not None, self._contacts) + online_friends_count = len(list(online_friends)) + part1 = self._contacts[:online_friends_count] + part2 = self._contacts[online_friends_count:] + key_lambda = lambda x: x.name.lower() if sort_by_name else x.number + part1 = sorted(part1, key=key_lambda) + part2 = sorted(part2, key=key_lambda) + self._contacts = part1 + part2 + elif sorting == 0: + contacts = sorted(self._contacts, key=lambda c: c.number) + friends = filter(lambda c: type(c) is Friend, contacts) + groups = filter(lambda c: type(c) is GroupChat, contacts) + group_peers = filter(lambda c: type(c) is GroupPeerContact, contacts) + self._contacts = list(friends) + list(groups) + list(group_peers) + else: + self._contacts = sorted(self._contacts, key=lambda x: x.name.lower()) + + # change item widgets + for index, contact in enumerate(self._contacts): + list_item = self._screen.friends_list.item(index) + item_widget = self._screen.friends_list.itemWidget(list_item) + contact.set_widget(item_widget) + + for index, friend in enumerate(self._contacts): + filtered_by_name = filter_str in friend.name.lower() + friend.visibility = (friend.status is not None or sorting not in (1, 4)) and filtered_by_name + # show friend even if it's hidden when there any unread messages/actions + friend.visibility = friend.visibility or friend.messages or friend.actions + item = self._screen.friends_list.item(index) + item_widget = self._screen.friends_list.itemWidget(item) + item.setSizeHint(QtCore.QSize(250, item_widget.height() if friend.visibility else 0)) + + # save soring results + self._sorting, self._filter_string = sorting, filter_str + self._settings['sorting'] = self._sorting + self._settings.save() + + # update active contact + if current_contact is not None: + index = self._contacts.index(current_contact) + self.set_active(index) + + def update_filtration(self): + """ + Update list of contacts when 1 of friends change connection status + """ + self.filtration_and_sorting(self._sorting, self._filter_string) + + # ----------------------------------------------------------------------------------------------------------------- + # Contact getters + # ----------------------------------------------------------------------------------------------------------------- + + def get_friend_by_number(self, number): + return list(filter(lambda c: c.number == number and type(c) is Friend, self._contacts))[0] + + def get_group_by_number(self, number): + return list(filter(lambda c: c.number == number and type(c) is GroupChat, self._contacts))[0] + + def get_or_create_group_peer_contact(self, group_number, peer_id): + group = self.get_group_by_number(group_number) + peer = group.get_peer_by_id(peer_id) + if not self.check_if_contact_exists(peer.public_key): + self.add_group_peer(group, peer) + + return self.get_contact_by_tox_id(peer.public_key) + + def check_if_contact_exists(self, tox_id): + return any(filter(lambda c: c.tox_id == tox_id, self._contacts)) + + def get_contact_by_tox_id(self, tox_id): + return list(filter(lambda c: c.tox_id == tox_id, self._contacts))[0] + + def get_active_number(self): + return self.get_curr_contact().number if self._active_contact + 1 else -1 + + def get_active_name(self): + return self.get_curr_contact().name if self._active_contact + 1 else '' + + def is_active_online(self): + return self._active_contact + 1 and self.get_curr_contact().status is not None + + # ----------------------------------------------------------------------------------------------------------------- + # Work with friends (remove, block, set alias, get public key) + # ----------------------------------------------------------------------------------------------------------------- + + def set_alias(self, num): + """ + Set new alias for friend + """ + friend = self._contacts[num] + name = friend.name + text = util_ui.tr("Enter new alias for friend {} or leave empty to use friend's name:").format(name) + title = util_ui.tr('Set alias') + text, ok = util_ui.text_dialog(text, title, name) + if not ok: + return + aliases = self._settings['friends_aliases'] + if text: + friend.name = text + try: + index = list(map(lambda x: x[0], aliases)).index(friend.tox_id) + aliases[index] = (friend.tox_id, text) + except: + aliases.append((friend.tox_id, text)) + friend.set_alias(text) + else: # use default name + friend.name = self._tox.friend_get_name(friend.number) + friend.set_alias('') + try: + index = list(map(lambda x: x[0], aliases)).index(friend.tox_id) + del aliases[index] + except: + pass + self._settings.save() + + def friend_public_key(self, num): + return self._contacts[num].tox_id + + def delete_friend(self, num): + """ + Removes friend from contact list + :param num: number of friend in list + """ + friend = self._contacts[num] + self._cleanup_contact_data(friend) + self._tox.friend_delete(friend.number) + self._delete_contact(num) + + def add_friend(self, tox_id): + """ + Adds friend to list + """ + self._tox.friend_add_norequest(tox_id) + self._add_friend(tox_id) + self.update_filtration() + + def block_user(self, tox_id): + """ + Block user with specified tox id (or public key) - delete from friends list and ignore friend requests + """ + tox_id = tox_id[:TOX_PUBLIC_KEY_SIZE * 2] + if tox_id == self._tox.self_get_address[:TOX_PUBLIC_KEY_SIZE * 2]: + return + if tox_id not in self._settings['blocked']: + self._settings['blocked'].append(tox_id) + self._settings.save() + try: + num = self._tox.friend_by_public_key(tox_id) + self.delete_friend(num) + self.save_profile() + except: # not in friend list + pass + + def unblock_user(self, tox_id, add_to_friend_list): + """ + Unblock user + :param tox_id: tox id of contact + :param add_to_friend_list: add this contact to friend list or not + """ + self._settings['blocked'].remove(tox_id) + self._settings.save() + if add_to_friend_list: + self.add_friend(tox_id) + self.save_profile() + + # ----------------------------------------------------------------------------------------------------------------- + # Groups support + # ----------------------------------------------------------------------------------------------------------------- + + def get_group_chats(self): + return list(filter(lambda c: type(c) is GroupChat, self._contacts)) + + def add_group(self, group_number): + group = self._contact_provider.get_group_by_number(group_number) + index = len(self._contacts) + self._contacts.append(group) + group.reset_avatar(self._settings['identicons']) + self._save_profile() + self.set_active(index) + self.update_filtration() + + def delete_group(self, group_number): + group = self.get_group_by_number(group_number) + self._cleanup_contact_data(group) + num = self._contacts.index(group) + self._delete_contact(num) + + # ----------------------------------------------------------------------------------------------------------------- + # Groups private messaging + # ----------------------------------------------------------------------------------------------------------------- + + def add_group_peer(self, group, peer): + contact = self._contact_provider.get_group_peer_by_id(group, peer.id) + if self.check_if_contact_exists(contact.tox_id): + return + self._contacts.append(contact) + contact.reset_avatar(self._settings['identicons']) + self._save_profile() + + def remove_group_peer_by_id(self, group, peer_id): + peer = group.get_peer_by_id(peer_id) + if not self.check_if_contact_exists(peer.public_key): + return + contact = self.get_contact_by_tox_id(peer.public_key) + self.remove_group_peer(contact) + + def remove_group_peer(self, group_peer_contact): + contact = self.get_contact_by_tox_id(group_peer_contact.tox_id) + self._cleanup_contact_data(contact) + num = self._contacts.index(contact) + self._delete_contact(num) + + def get_gc_peer_name(self, name): + group = self.get_curr_contact() + + names = sorted(group.get_peers_names()) + if name in names: # return next nick + index = names.index(name) + index = (index + 1) % len(names) + + return names[index] + + suggested_names = list(filter(lambda x: x.startswith(name), names)) + if not len(suggested_names): + return '\t' + + return suggested_names[0] + + # ----------------------------------------------------------------------------------------------------------------- + # Friend requests + # ----------------------------------------------------------------------------------------------------------------- + + def send_friend_request(self, tox_id, message): + """ + Function tries to send request to contact with specified id + :param tox_id: id of new contact or tox dns 4 value + :param message: additional message + :return: True on success else error string + """ + try: + message = message or 'Hello! Add me to your contact list please' + if '@' in tox_id: # value like groupbot@toxme.io + tox_id = self._tox_dns.lookup(tox_id) + if tox_id is None: + raise Exception('TOX DNS lookup failed') + if len(tox_id) == TOX_PUBLIC_KEY_SIZE * 2: # public key + self.add_friend(tox_id) + title = util_ui.tr('Friend added') + text = util_ui.tr('Friend added without sending friend request') + util_ui.message_box(text, title) + else: + self._tox.friend_add(tox_id, message.encode('utf-8')) + tox_id = tox_id[:TOX_PUBLIC_KEY_SIZE * 2] + self._add_friend(tox_id) + self.update_filtration() + self.save_profile() + return True + except Exception as ex: # wrong data + util.log('Friend request failed with ' + str(ex)) + return str(ex) + + def process_friend_request(self, tox_id, message): + """ + Accept or ignore friend request + :param tox_id: tox id of contact + :param message: message + """ + if tox_id in self._settings['blocked']: + return + try: + text = util_ui.tr('User {} wants to add you to contact list. Message:\n{}') + reply = util_ui.question(text.format(tox_id, message), util_ui.tr('Friend request')) + if reply: # accepted + self.add_friend(tox_id) + data = self._tox.get_savedata() + self._profile_manager.save_profile(data) + except Exception as ex: # something is wrong + util.log('Accept friend request failed! ' + str(ex)) + + def can_send_typing_notification(self): + return self._settings['typing_notifications'] and not self.is_active_a_group_chat_peer() + + # ----------------------------------------------------------------------------------------------------------------- + # Contacts numbers update + # ----------------------------------------------------------------------------------------------------------------- + + def update_friends_numbers(self): + for friend in self._contact_provider.get_all_friends(): + friend.number = self._tox.friend_by_public_key(friend.tox_id) + self.update_filtration() + + def update_groups_numbers(self): + groups = self._contact_provider.get_all_groups() + for i in range(len(groups)): + chat_id = self._tox.group_get_chat_id(i) + group = self.get_contact_by_tox_id(chat_id) + group.number = i + self.update_filtration() + + def update_groups_lists(self): + groups = self._contact_provider.get_all_groups() + for group in groups: + group.remove_all_peers_except_self() + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _load_contacts(self): + self._load_friends() + self._load_groups() + if len(self._contacts): + self.set_active(0) + for contact in filter(lambda c: not c.has_avatar(), self._contacts): + contact.reset_avatar(self._settings['identicons']) + self.update_filtration() + + def _load_friends(self): + self._contacts.extend(self._contact_provider.get_all_friends()) + + def _load_groups(self): + self._contacts.extend(self._contact_provider.get_all_groups()) + + # ----------------------------------------------------------------------------------------------------------------- + # Current contact subscriptions + # ----------------------------------------------------------------------------------------------------------------- + + def _subscribe_to_events(self, contact): + contact.name_changed_event.add_callback(self._current_contact_name_changed) + contact.status_changed_event.add_callback(self._current_contact_status_changed) + contact.status_message_changed_event.add_callback(self._current_contact_status_message_changed) + contact.avatar_changed_event.add_callback(self._current_contact_avatar_changed) + + def _unsubscribe_from_events(self, contact): + contact.name_changed_event.remove_callback(self._current_contact_name_changed) + contact.status_changed_event.remove_callback(self._current_contact_status_changed) + contact.status_message_changed_event.remove_callback(self._current_contact_status_message_changed) + contact.avatar_changed_event.remove_callback(self._current_contact_avatar_changed) + + def _current_contact_name_changed(self, name): + self._screen.account_name.setText(name) + + def _current_contact_status_changed(self, status): + pass + + def _current_contact_status_message_changed(self, status_message): + self._screen.account_status.setText(status_message) + + def _current_contact_avatar_changed(self, avatar_path): + self._set_current_contact_avatar(avatar_path) + + def _set_current_contact_data(self, contact): + self._screen.account_name.setText(contact.name) + self._screen.account_status.setText(contact.status_message) + self._set_current_contact_avatar(contact.get_avatar_path()) + + def _set_current_contact_avatar(self, avatar_path): + width = self._screen.account_avatar.width() + pixmap = QtGui.QPixmap(avatar_path) + self._screen.account_avatar.setPixmap(pixmap.scaled(width, width, + QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) + + def _add_friend(self, tox_id): + self._history.add_friend_to_db(tox_id) + friend = self._contact_provider.get_friend_by_public_key(tox_id) + index = len(self._contacts) + self._contacts.append(friend) + if not friend.has_avatar(): + friend.reset_avatar(self._settings['identicons']) + self._save_profile() + self.set_active(index) + + def _save_profile(self): + data = self._tox.get_savedata() + self._profile_manager.save_profile(data) + + def _cleanup_contact_data(self, contact): + try: + index = list(map(lambda x: x[0], self._settings['friends_aliases'])).index(contact.tox_id) + del self._settings['friends_aliases'][index] + except: + pass + if contact.tox_id in self._settings['notes']: + del self._settings['notes'][contact.tox_id] + self._settings.save() + self._history.delete_history(contact) + if contact.has_avatar(): + avatar_path = contact.get_contact_avatar_path() + remove(avatar_path) + + def _delete_contact(self, num): + self.set_active(-1 if len(self._contacts) == 1 else 0) + + self._contact_provider.remove_contact_from_cache(self._contacts[num].tox_id) + del self._contacts[num] + self._screen.friends_list.takeItem(num) + self._save_profile() + + self.update_filtration() diff --git a/toxygen/contacts/friend.py b/toxygen/contacts/friend.py new file mode 100644 index 0000000..5c8eabb --- /dev/null +++ b/toxygen/contacts/friend.py @@ -0,0 +1,74 @@ +from contacts import contact, common +from messenger.messages import * +import os +from contacts.contact_menu import * + + +class Friend(contact.Contact): + """ + Friend in list of friends. + """ + + def __init__(self, profile_manager, message_getter, number, name, status_message, widget, tox_id): + super().__init__(profile_manager, message_getter, number, name, status_message, widget, tox_id) + self._receipts = 0 + self._typing_notification_handler = common.FriendTypingNotificationHandler(number) + + # ----------------------------------------------------------------------------------------------------------------- + # File transfers support + # ----------------------------------------------------------------------------------------------------------------- + + def insert_inline(self, before_message_id, inline): + """ + Update status of active transfer and load inline if needed + """ + try: + tr = list(filter(lambda m: m.message_id == before_message_id, self._corr))[0] + i = self._corr.index(tr) + if inline: # inline was loaded + self._corr.insert(i, inline) + return i - len(self._corr) + except: + pass + + def get_unsent_files(self): + messages = filter(lambda m: type(m) is UnsentFileMessage, self._corr) + return list(messages) + + def clear_unsent_files(self): + self._corr = list(filter(lambda m: type(m) is not UnsentFileMessage, self._corr)) + + def remove_invalid_unsent_files(self): + def is_valid(message): + if type(message) is not UnsentFileMessage: + return True + if message.data is not None: + return True + return os.path.exists(message.path) + + self._corr = list(filter(is_valid, self._corr)) + + def delete_one_unsent_file(self, message_id): + self._corr = list(filter(lambda m: not (type(m) is UnsentFileMessage and m.message_id == message_id), + self._corr)) + + # ----------------------------------------------------------------------------------------------------------------- + # Full status + # ----------------------------------------------------------------------------------------------------------------- + + def get_full_status(self): + return self._status_message + + # ----------------------------------------------------------------------------------------------------------------- + # Typing notifications + # ----------------------------------------------------------------------------------------------------------------- + + def get_typing_notification_handler(self): + return self._typing_notification_handler + + # ----------------------------------------------------------------------------------------------------------------- + # Context menu support + # ----------------------------------------------------------------------------------------------------------------- + + def get_context_menu_generator(self): + return FriendMenuGenerator(self) diff --git a/toxygen/contacts/friend_factory.py b/toxygen/contacts/friend_factory.py new file mode 100644 index 0000000..8ebafd6 --- /dev/null +++ b/toxygen/contacts/friend_factory.py @@ -0,0 +1,44 @@ +from contacts.friend import Friend +from common.tox_save import ToxSave + + +class FriendFactory(ToxSave): + + def __init__(self, profile_manager, settings, tox, db, items_factory): + super().__init__(tox) + self._profile_manager = profile_manager + self._settings = settings + self._db = db + self._items_factory = items_factory + + def create_friend_by_public_key(self, public_key): + friend_number = self._tox.friend_by_public_key(public_key) + + return self.create_friend_by_number(friend_number) + + def create_friend_by_number(self, friend_number): + aliases = self._settings['friends_aliases'] + tox_id = self._tox.friend_get_public_key(friend_number) + try: + alias = list(filter(lambda x: x[0] == tox_id, aliases))[0][1] + except: + alias = '' + item = self._create_friend_item() + name = alias or self._tox.friend_get_name(friend_number) or tox_id + status_message = self._tox.friend_get_status_message(friend_number) + message_getter = self._db.messages_getter(tox_id) + friend = Friend(self._profile_manager, message_getter, friend_number, name, status_message, item, tox_id) + friend.set_alias(alias) + + return friend + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _create_friend_item(self): + """ + Method-factory + :return: new widget for friend instance + """ + return self._items_factory.create_contact_item() diff --git a/toxygen/contacts/group_chat.py b/toxygen/contacts/group_chat.py new file mode 100644 index 0000000..19ebc8e --- /dev/null +++ b/toxygen/contacts/group_chat.py @@ -0,0 +1,137 @@ +from contacts import contact +from contacts.contact_menu import GroupMenuGenerator +import utils.util as util +from groups.group_peer import GroupChatPeer +from wrapper import toxcore_enums_and_consts as constants +from common.tox_save import ToxSave +from groups.group_ban import GroupBan + + +class GroupChat(contact.Contact, ToxSave): + + def __init__(self, tox, profile_manager, message_getter, number, name, status_message, widget, tox_id, is_private): + super().__init__(profile_manager, message_getter, number, name, status_message, widget, tox_id) + ToxSave.__init__(self, tox) + + self._is_private = is_private + self._password = str() + self._peers_limit = 512 + self._peers = [] + self._add_self_to_gc() + + def remove_invalid_unsent_files(self): + pass + + def get_context_menu_generator(self): + return GroupMenuGenerator(self) + + # ----------------------------------------------------------------------------------------------------------------- + # Properties + # ----------------------------------------------------------------------------------------------------------------- + + def get_is_private(self): + return self._is_private + + def set_is_private(self, is_private): + self._is_private = is_private + + is_private = property(get_is_private, set_is_private) + + def get_password(self): + return self._password + + def set_password(self, password): + self._password = password + + password = property(get_password, set_password) + + def get_peers_limit(self): + return self._peers_limit + + def set_peers_limit(self, peers_limit): + self._peers_limit = peers_limit + + peers_limit = property(get_peers_limit, set_peers_limit) + + # ----------------------------------------------------------------------------------------------------------------- + # Peers methods + # ----------------------------------------------------------------------------------------------------------------- + + def get_self_peer(self): + return self._peers[0] + + def get_self_name(self): + return self._peers[0].name + + def get_self_role(self): + return self._peers[0].role + + def is_self_moderator_or_founder(self): + return self.get_self_role() <= constants.TOX_GROUP_ROLE['MODERATOR'] + + def is_self_founder(self): + return self.get_self_role() == constants.TOX_GROUP_ROLE['FOUNDER'] + + def add_peer(self, peer_id, is_current_user=False): + peer = GroupChatPeer(peer_id, + self._tox.group_peer_get_name(self._number, peer_id), + self._tox.group_peer_get_status(self._number, peer_id), + self._tox.group_peer_get_role(self._number, peer_id), + self._tox.group_peer_get_public_key(self._number, peer_id), + is_current_user) + self._peers.append(peer) + + def remove_peer(self, peer_id): + if peer_id == self.get_self_peer().id: # we were kicked or banned + self.remove_all_peers_except_self() + else: + peer = self.get_peer_by_id(peer_id) + self._peers.remove(peer) + + def get_peer_by_id(self, peer_id): + peers = list(filter(lambda p: p.id == peer_id, self._peers)) + + return peers[0] + + def get_peer_by_public_key(self, public_key): + peers = list(filter(lambda p: p.public_key == public_key, self._peers)) + + return peers[0] + + def remove_all_peers_except_self(self): + self._peers = self._peers[:1] + + def get_peers_names(self): + peers_names = map(lambda p: p.name, self._peers) + + return list(peers_names) + + def get_peers(self): + return self._peers[:] + + peers = property(get_peers) + + def get_bans(self): + ban_ids = self._tox.group_ban_get_list(self._number) + bans = [] + for ban_id in ban_ids: + ban = GroupBan(ban_id, + self._tox.group_ban_get_target(self._number, ban_id), + self._tox.group_ban_get_time_set(self._number, ban_id)) + bans.append(ban) + + return bans + + bans = property(get_bans) + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _get_default_avatar_path(): + return util.join_path(util.get_images_directory(), 'group.png') + + def _add_self_to_gc(self): + peer_id = self._tox.group_self_get_peer_id(self._number) + self.add_peer(peer_id, True) diff --git a/toxygen/contacts/group_factory.py b/toxygen/contacts/group_factory.py new file mode 100644 index 0000000..4083438 --- /dev/null +++ b/toxygen/contacts/group_factory.py @@ -0,0 +1,53 @@ +from contacts.group_chat import GroupChat +from common.tox_save import ToxSave +import wrapper.toxcore_enums_and_consts as constants + + +class GroupFactory(ToxSave): + + def __init__(self, profile_manager, settings, tox, db, items_factory): + super().__init__(tox) + self._profile_manager = profile_manager + self._settings = settings + self._db = db + self._items_factory = items_factory + + def create_group_by_public_key(self, public_key): + group_number = self._get_group_number_by_chat_id(public_key) + + return self.create_group_by_number(group_number) + + def create_group_by_number(self, group_number): + aliases = self._settings['friends_aliases'] + tox_id = self._tox.group_get_chat_id(group_number) + try: + alias = list(filter(lambda x: x[0] == tox_id, aliases))[0][1] + except: + alias = '' + item = self._create_group_item() + name = alias or self._tox.group_get_name(group_number) or tox_id + status_message = self._tox.group_get_topic(group_number) + message_getter = self._db.messages_getter(tox_id) + is_private = self._tox.group_get_privacy_state(group_number) == constants.TOX_GROUP_PRIVACY_STATE['PRIVATE'] + group = GroupChat(self._tox, self._profile_manager, message_getter, group_number, name, status_message, + item, tox_id, is_private) + group.set_alias(alias) + + return group + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _create_group_item(self): + """ + Method-factory + :return: new widget for group instance + """ + return self._items_factory.create_contact_item() + + def _get_group_number_by_chat_id(self, chat_id): + for i in range(self._tox.group_get_number_groups()): + if self._tox.group_get_chat_id(i) == chat_id: + return i + return -1 diff --git a/toxygen/contacts/group_peer_contact.py b/toxygen/contacts/group_peer_contact.py new file mode 100644 index 0000000..8854198 --- /dev/null +++ b/toxygen/contacts/group_peer_contact.py @@ -0,0 +1,20 @@ +import contacts.contact +from contacts.contact_menu import GroupPeerMenuGenerator + + +class GroupPeerContact(contacts.contact.Contact): + + def __init__(self, profile_manager, message_getter, peer_number, name, widget, tox_id, group_pk): + super().__init__(profile_manager, message_getter, peer_number, name, str(), widget, tox_id) + self._group_pk = group_pk + + def get_group_pk(self): + return self._group_pk + + group_pk = property(get_group_pk) + + def remove_invalid_unsent_files(self): + pass + + def get_context_menu_generator(self): + return GroupPeerMenuGenerator(self) diff --git a/toxygen/contacts/group_peer_factory.py b/toxygen/contacts/group_peer_factory.py new file mode 100644 index 0000000..38b3a20 --- /dev/null +++ b/toxygen/contacts/group_peer_factory.py @@ -0,0 +1,23 @@ +from common.tox_save import ToxSave +from contacts.group_peer_contact import GroupPeerContact + + +class GroupPeerFactory(ToxSave): + + def __init__(self, tox, profile_manager, db, items_factory): + super().__init__(tox) + self._profile_manager = profile_manager + self._db = db + self._items_factory = items_factory + + def create_group_peer(self, group, peer): + item = self._create_group_peer_item() + message_getter = self._db.messages_getter(peer.public_key) + group_peer_contact = GroupPeerContact(self._profile_manager, message_getter, peer.id, peer.name, + item, peer.public_key, group.tox_id) + group_peer_contact.status = peer.status + + return group_peer_contact + + def _create_group_peer_item(self): + return self._items_factory.create_contact_item() diff --git a/toxygen/contacts/profile.py b/toxygen/contacts/profile.py new file mode 100644 index 0000000..81220af --- /dev/null +++ b/toxygen/contacts/profile.py @@ -0,0 +1,87 @@ +from contacts import basecontact +import random +import threading +import common.tox_save as tox_save +from middleware.threads import invoke_in_main_thread + + +class Profile(basecontact.BaseContact, tox_save.ToxSave): + """ + Profile of current toxygen user. + """ + def __init__(self, profile_manager, tox, screen, contacts_provider, reset_action): + """ + :param tox: tox instance + :param screen: ref to main screen + """ + basecontact.BaseContact.__init__(self, + profile_manager, + tox.self_get_name(), + tox.self_get_status_message(), + screen, + tox.self_get_address()) + tox_save.ToxSave.__init__(self, tox) + self._screen = screen + self._messages = screen.messages + self._contacts_provider = contacts_provider + self._reset_action = reset_action + self._waiting_for_reconnection = False + self._timer = None + + # ----------------------------------------------------------------------------------------------------------------- + # Edit current user's data + # ----------------------------------------------------------------------------------------------------------------- + + def change_status(self): + """ + Changes status of user (online, away, busy) + """ + if self._status is not None: + self.set_status((self._status + 1) % 3) + + def set_status(self, status): + super().set_status(status) + if status is not None: + self._tox.self_set_status(status) + elif not self._waiting_for_reconnection: + self._waiting_for_reconnection = True + self._timer = threading.Timer(50, self._reconnect) + self._timer.start() + + def set_name(self, value): + if self.name == value: + return + super().set_name(value) + self._tox.self_set_name(self._name) + + def set_status_message(self, value): + super().set_status_message(value) + self._tox.self_set_status_message(self._status_message) + + def set_new_nospam(self): + """Sets new nospam part of tox id""" + self._tox.self_set_nospam(random.randint(0, 4294967295)) # no spam - uint32 + self._tox_id = self._tox.self_get_address() + + return self._tox_id + + # ----------------------------------------------------------------------------------------------------------------- + # Reset + # ----------------------------------------------------------------------------------------------------------------- + + def restart(self): + """ + Recreate tox instance + """ + self.status = None + invoke_in_main_thread(self._reset_action) + + def _reconnect(self): + self._waiting_for_reconnection = False + contacts = self._contacts_provider.get_all_friends() + all_friends_offline = all(list(map(lambda x: x.status is None, contacts))) + if self.status is None or (all_friends_offline and len(contacts)): + self._waiting_for_reconnection = True + self.restart() + self._timer = threading.Timer(50, self._reconnect) + self._timer.start() diff --git a/toxygen/file_transfers/__init__.py b/toxygen/file_transfers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/file_transfers/file_transfers.py b/toxygen/file_transfers/file_transfers.py new file mode 100644 index 0000000..0f04e5b --- /dev/null +++ b/toxygen/file_transfers/file_transfers.py @@ -0,0 +1,351 @@ +from wrapper.toxcore_enums_and_consts import TOX_FILE_KIND, TOX_FILE_CONTROL +from os.path import basename, getsize, exists, dirname +from os import remove, rename, chdir +from time import time +from wrapper.tox import Tox +from common.event import Event +from middleware.threads import invoke_in_main_thread + + +FILE_TRANSFER_STATE = { + 'RUNNING': 0, + 'PAUSED_BY_USER': 1, + 'CANCELLED': 2, + 'FINISHED': 3, + 'PAUSED_BY_FRIEND': 4, + 'INCOMING_NOT_STARTED': 5, + 'OUTGOING_NOT_STARTED': 6, + 'UNSENT': 7 +} + +ACTIVE_FILE_TRANSFERS = (0, 1, 4, 5, 6) + +PAUSED_FILE_TRANSFERS = (1, 4, 5, 6) + +DO_NOT_SHOW_ACCEPT_BUTTON = (2, 3, 4, 6) + +SHOW_PROGRESS_BAR = (0, 1, 4) + + +def is_inline(file_name): + allowed_inlines = ('toxygen_inline.png', 'utox-inline.png', 'sticker.png') + + return file_name in allowed_inlines or file_name.startswith('qTox_Image_') + + +class FileTransfer: + """ + Superclass for file transfers + """ + + def __init__(self, path, tox, friend_number, size, file_number=None): + self._path = path + self._tox = tox + self._friend_number = friend_number + self._state = FILE_TRANSFER_STATE['RUNNING'] + self._file_number = file_number + self._creation_time = None + self._size = float(size) + self._done = 0 + self._state_changed_event = Event() + self._finished_event = Event() + self._file_id = self._file = None + + def set_state_changed_handler(self, handler): + self._state_changed_event += lambda *args: invoke_in_main_thread(handler, *args) + + def set_transfer_finished_handler(self, handler): + self._finished_event += lambda *args: invoke_in_main_thread(handler, *args) + + def get_file_number(self): + return self._file_number + + file_number = property(get_file_number) + + def get_state(self): + return self._state + + def set_state(self, value): + self._state = value + self._signal() + + state = property(get_state, set_state) + + def get_friend_number(self): + return self._friend_number + + friend_number = property(get_friend_number) + + def get_file_id(self): + return self._file_id + + file_id = property(get_file_id) + + def get_path(self): + return self._path + + path = property(get_path) + + def get_size(self): + return self._size + + size = property(get_size) + + def cancel(self): + self.send_control(TOX_FILE_CONTROL['CANCEL']) + if self._file is not None: + self._file.close() + self._signal() + + def cancelled(self): + if self._file is not None: + self._file.close() + self.set_state(FILE_TRANSFER_STATE['CANCELLED']) + + def pause(self, by_friend): + if not by_friend: + self.send_control(TOX_FILE_CONTROL['PAUSE']) + else: + self.set_state(FILE_TRANSFER_STATE['PAUSED_BY_FRIEND']) + + def send_control(self, control): + if self._tox.file_control(self._friend_number, self._file_number, control): + self.set_state(control) + + def get_file_id(self): + return self._tox.file_get_file_id(self._friend_number, self._file_number) + + def _signal(self): + percentage = self._done / self._size if self._size else 0 + if self._creation_time is None or not percentage: + t = -1 + else: + t = ((time() - self._creation_time) / percentage) * (1 - percentage) + self._state_changed_event(self.state, percentage, int(t)) + + def _finished(self): + self._finished_event(self._friend_number, self._file_number) + +# ----------------------------------------------------------------------------------------------------------------- +# Send file +# ----------------------------------------------------------------------------------------------------------------- + + +class SendTransfer(FileTransfer): + + def __init__(self, path, tox, friend_number, kind=TOX_FILE_KIND['DATA'], file_id=None): + if path is not None: + fl = open(path, 'rb') + size = getsize(path) + else: + fl = None + size = 0 + super().__init__(path, tox, friend_number, size) + self._file = fl + self.state = FILE_TRANSFER_STATE['OUTGOING_NOT_STARTED'] + self._file_number = tox.file_send(friend_number, kind, size, file_id, + bytes(basename(path), 'utf-8') if path else b'') + self._file_id = self.get_file_id() + + def send_chunk(self, position, size): + """ + Send chunk + :param position: start position in file + :param size: chunk max size + """ + if self._creation_time is None: + self._creation_time = time() + if size: + self._file.seek(position) + data = self._file.read(size) + self._tox.file_send_chunk(self._friend_number, self._file_number, position, data) + self._done += size + self._signal() + else: + if self._file is not None: + self._file.close() + self.state = FILE_TRANSFER_STATE['FINISHED'] + self._finished() + + +class SendAvatar(SendTransfer): + """ + Send avatar to friend. Doesn't need file transfer item + """ + + def __init__(self, path, tox, friend_number): + if path is None: + avatar_hash = None + else: + with open(path, 'rb') as fl: + avatar_hash = Tox.hash(fl.read()) + super().__init__(path, tox, friend_number, TOX_FILE_KIND['AVATAR'], avatar_hash) + + +class SendFromBuffer(FileTransfer): + """ + Send inline image + """ + + def __init__(self, tox, friend_number, data, file_name): + super().__init__(None, tox, friend_number, len(data)) + self.state = FILE_TRANSFER_STATE['OUTGOING_NOT_STARTED'] + self._data = data + self._file_number = tox.file_send(friend_number, TOX_FILE_KIND['DATA'], + len(data), None, bytes(file_name, 'utf-8')) + + def get_data(self): + return self._data + + data = property(get_data) + + def send_chunk(self, position, size): + if self._creation_time is None: + self._creation_time = time() + if size: + data = self._data[position:position + size] + self._tox.file_send_chunk(self._friend_number, self._file_number, position, data) + self._done += size + else: + self.state = FILE_TRANSFER_STATE['FINISHED'] + self._finished() + self._signal() + + +class SendFromFileBuffer(SendTransfer): + + def __init__(self, *args): + super().__init__(*args) + + def send_chunk(self, position, size): + super().send_chunk(position, size) + if not size: + chdir(dirname(self._path)) + remove(self._path) + +# ----------------------------------------------------------------------------------------------------------------- +# Receive file +# ----------------------------------------------------------------------------------------------------------------- + + +class ReceiveTransfer(FileTransfer): + + def __init__(self, path, tox, friend_number, size, file_number, position=0): + super().__init__(path, tox, friend_number, size, file_number) + self._file = open(self._path, 'wb') + self._file_size = position + self._file.truncate(position) + self._missed = set() + self._file_id = self.get_file_id() + self._done = position + + def cancel(self): + super().cancel() + remove(self._path) + + def total_size(self): + self._missed.add(self._file_size) + + return min(self._missed) + + def write_chunk(self, position, data): + """ + Incoming chunk + :param position: position in file to save data + :param data: raw data (string) + """ + if self._creation_time is None: + self._creation_time = time() + if data is None: + self._file.close() + self.state = FILE_TRANSFER_STATE['FINISHED'] + self._finished() + else: + data = bytearray(data) + if self._file_size < position: + self._file.seek(0, 2) + self._file.write(b'\0' * (position - self._file_size)) + self._missed.add(self._file_size) + else: + self._missed.discard(position) + self._file.seek(position) + self._file.write(data) + l = len(data) + if position + l > self._file_size: + self._file_size = position + l + self._done += l + self._signal() + + +class ReceiveToBuffer(FileTransfer): + """ + Inline image - save in buffer not in file system + """ + + def __init__(self, tox, friend_number, size, file_number): + super().__init__(None, tox, friend_number, size, file_number) + self._data = bytes() + self._data_size = 0 + + def get_data(self): + return self._data + + data = property(get_data) + + def write_chunk(self, position, data): + if self._creation_time is None: + self._creation_time = time() + if data is None: + self.state = FILE_TRANSFER_STATE['FINISHED'] + self._finished() + else: + data = bytes(data) + l = len(data) + if self._data_size < position: + self._data += (b'\0' * (position - self._data_size)) + self._data = self._data[:position] + data + self._data[position + l:] + if position + l > self._data_size: + self._data_size = position + l + self._done += l + self._signal() + + +class ReceiveAvatar(ReceiveTransfer): + """ + Get friend's avatar. Doesn't need file transfer item + """ + MAX_AVATAR_SIZE = 512 * 1024 + + def __init__(self, path, tox, friend_number, size, file_number): + full_path = path + '.tmp' + super().__init__(full_path, tox, friend_number, size, file_number) + if size > self.MAX_AVATAR_SIZE: + self.send_control(TOX_FILE_CONTROL['CANCEL']) + self._file.close() + remove(full_path) + elif not size: + self.send_control(TOX_FILE_CONTROL['CANCEL']) + self._file.close() + remove(full_path) + elif exists(path): + hash = self.get_file_id() + with open(path, 'rb') as fl: + data = fl.read() + existing_hash = Tox.hash(data) + if hash == existing_hash: + self.send_control(TOX_FILE_CONTROL['CANCEL']) + self._file.close() + remove(full_path) + else: + self.send_control(TOX_FILE_CONTROL['RESUME']) + else: + self.send_control(TOX_FILE_CONTROL['RESUME']) + + def write_chunk(self, position, data): + if data is None: + avatar_path = self._path[:-4] + if exists(avatar_path): + chdir(dirname(avatar_path)) + remove(avatar_path) + rename(self._path, avatar_path) + super().write_chunk(position, data) diff --git a/toxygen/file_transfers/file_transfers_handler.py b/toxygen/file_transfers/file_transfers_handler.py new file mode 100644 index 0000000..114383b --- /dev/null +++ b/toxygen/file_transfers/file_transfers_handler.py @@ -0,0 +1,304 @@ +from messenger.messages import * +from ui.contact_items import * +import utils.util as util +from common.tox_save import ToxSave + + +class FileTransfersHandler(ToxSave): + + def __init__(self, tox, settings, contact_provider, file_transfers_message_service, profile): + super().__init__(tox) + self._settings = settings + self._contact_provider = contact_provider + self._file_transfers_message_service = file_transfers_message_service + self._file_transfers = {} + # key = (friend number, file number), value - transfer instance + self._paused_file_transfers = dict(settings['paused_file_transfers']) + # key - file id, value: [path, friend number, is incoming, start position] + self._insert_inline_before = {} + # key = (friend number, file number), value - message id + + profile.avatar_changed_event.add_callback(self._send_avatar_to_contacts) + + def stop(self): + self._settings['paused_file_transfers'] = self._paused_file_transfers if self._settings['resend_files'] else {} + self._settings.save() + + # ----------------------------------------------------------------------------------------------------------------- + # File transfers support + # ----------------------------------------------------------------------------------------------------------------- + + def incoming_file_transfer(self, friend_number, file_number, size, file_name): + """ + New transfer + :param friend_number: number of friend who sent file + :param file_number: file number + :param size: file size in bytes + :param file_name: file name without path + """ + friend = self._get_friend_by_number(friend_number) + auto = self._settings['allow_auto_accept'] and friend.tox_id in self._settings['auto_accept_from_friends'] + inline = is_inline(file_name) and self._settings['allow_inline'] + file_id = self._tox.file_get_file_id(friend_number, file_number) + accepted = True + if file_id in self._paused_file_transfers: + (path, ft_friend_number, is_incoming, start_position) = self._paused_file_transfers[file_id] + pos = start_position if os.path.exists(path) else 0 + if pos >= size: + self._tox.file_control(friend_number, file_number, TOX_FILE_CONTROL['CANCEL']) + return + self._tox.file_seek(friend_number, file_number, pos) + self._file_transfers_message_service.add_incoming_transfer_message( + friend, accepted, size, file_name, file_number) + self.accept_transfer(path, friend_number, file_number, size, False, pos) + elif inline and size < 1024 * 1024: + self._file_transfers_message_service.add_incoming_transfer_message( + friend, accepted, size, file_name, file_number) + self.accept_transfer('', friend_number, file_number, size, True) + elif auto: + path = self._settings['auto_accept_path'] or util.curr_directory() + self._file_transfers_message_service.add_incoming_transfer_message( + friend, accepted, size, file_name, file_number) + self.accept_transfer(path + '/' + file_name, friend_number, file_number, size) + else: + accepted = False + self._file_transfers_message_service.add_incoming_transfer_message( + friend, accepted, size, file_name, file_number) + + def cancel_transfer(self, friend_number, file_number, already_cancelled=False): + """ + Stop transfer + :param friend_number: number of friend + :param file_number: file number + :param already_cancelled: was cancelled by friend + """ + if (friend_number, file_number) in self._file_transfers: + tr = self._file_transfers[(friend_number, file_number)] + if not already_cancelled: + tr.cancel() + else: + tr.cancelled() + if (friend_number, file_number) in self._file_transfers: + del tr + del self._file_transfers[(friend_number, file_number)] + elif not already_cancelled: + self._tox.file_control(friend_number, file_number, TOX_FILE_CONTROL['CANCEL']) + + def cancel_not_started_transfer(self, friend_number, message_id): + self._get_friend_by_number(friend_number).delete_one_unsent_file(message_id) + + def pause_transfer(self, friend_number, file_number, by_friend=False): + """ + Pause transfer with specified data + """ + tr = self._file_transfers[(friend_number, file_number)] + tr.pause(by_friend) + + def resume_transfer(self, friend_number, file_number, by_friend=False): + """ + Resume transfer with specified data + """ + tr = self._file_transfers[(friend_number, file_number)] + if by_friend: + tr.state = FILE_TRANSFER_STATE['RUNNING'] + else: + tr.send_control(TOX_FILE_CONTROL['RESUME']) + + def accept_transfer(self, path, friend_number, file_number, size, inline=False, from_position=0): + """ + :param path: path for saving + :param friend_number: friend number + :param file_number: file number + :param size: file size + :param inline: is inline image + :param from_position: position for start + """ + path = self._generate_valid_path(path, from_position) + friend = self._get_friend_by_number(friend_number) + if not inline: + rt = ReceiveTransfer(path, self._tox, friend_number, size, file_number, from_position) + else: + rt = ReceiveToBuffer(self._tox, friend_number, size, file_number) + rt.set_transfer_finished_handler(self.transfer_finished) + message = friend.get_message(lambda m: m.type == MESSAGE_TYPE['FILE_TRANSFER'] + and m.state in (FILE_TRANSFER_STATE['INCOMING_NOT_STARTED'], + FILE_TRANSFER_STATE['RUNNING']) + and m.file_number == file_number) + rt.set_state_changed_handler(message.transfer_updated) + self._file_transfers[(friend_number, file_number)] = rt + rt.send_control(TOX_FILE_CONTROL['RESUME']) + if inline: + self._insert_inline_before[(friend_number, file_number)] = message.message_id + + def send_screenshot(self, data, friend_number): + """ + Send screenshot + :param data: raw data - png format + :param friend_number: friend number + """ + self.send_inline(data, 'toxygen_inline.png', friend_number) + + def send_sticker(self, path, friend_number): + with open(path, 'rb') as fl: + data = fl.read() + self.send_inline(data, 'sticker.png', friend_number) + + def send_inline(self, data, file_name, friend_number, is_resend=False): + friend = self._get_friend_by_number(friend_number) + if friend.status is None and not is_resend: + self._file_transfers_message_service.add_unsent_file_message(friend, file_name, data) + return + elif friend.status is None and is_resend: + raise RuntimeError() + st = SendFromBuffer(self._tox, friend.number, data, file_name) + self._send_file_add_set_handlers(st, friend, file_name, True) + + def send_file(self, path, friend_number, is_resend=False, file_id=None): + """ + Send file to current active friend + :param path: file path + :param friend_number: friend_number + :param is_resend: is 'offline' message + :param file_id: file id of transfer + """ + friend = self._get_friend_by_number(friend_number) + if friend.status is None and not is_resend: + self._file_transfers_message_service.add_unsent_file_message(friend, path, None) + return + elif friend.status is None and is_resend: + print('Error in sending') + return + st = SendTransfer(path, self._tox, friend_number, TOX_FILE_KIND['DATA'], file_id) + file_name = os.path.basename(path) + self._send_file_add_set_handlers(st, friend, file_name) + + def incoming_chunk(self, friend_number, file_number, position, data): + """ + Incoming chunk + """ + self._file_transfers[(friend_number, file_number)].write_chunk(position, data) + + def outgoing_chunk(self, friend_number, file_number, position, size): + """ + Outgoing chunk + """ + self._file_transfers[(friend_number, file_number)].send_chunk(position, size) + + def transfer_finished(self, friend_number, file_number): + transfer = self._file_transfers[(friend_number, file_number)] + t = type(transfer) + if t is ReceiveAvatar: + self._get_friend_by_number(friend_number).load_avatar() + elif t is ReceiveToBuffer or (t is SendFromBuffer and self._settings['allow_inline']): # inline image + print('inline') + inline = InlineImageMessage(transfer.data) + message_id = self._insert_inline_before[(friend_number, file_number)] + del self._insert_inline_before[(friend_number, file_number)] + index = self._get_friend_by_number(friend_number).insert_inline(message_id, inline) + self._file_transfers_message_service.add_inline_message(transfer, index) + del self._file_transfers[(friend_number, file_number)] + + def send_files(self, friend_number): + friend = self._get_friend_by_number(friend_number) + friend.remove_invalid_unsent_files() + files = friend.get_unsent_files() + try: + for fl in files: + data, path = fl.data, fl.path + if data is not None: + self.send_inline(data, path, friend_number, True) + else: + self.send_file(path, friend_number, True) + friend.clear_unsent_files() + for key in self._paused_file_transfers.keys(): + (path, ft_friend_number, is_incoming, start_position) = self._paused_file_transfers[key] + if not os.path.exists(path): + del self._paused_file_transfers[key] + elif ft_friend_number == friend_number and not is_incoming: + self.send_file(path, friend_number, True, key) + del self._paused_file_transfers[key] + except Exception as ex: + print('Exception in file sending: ' + str(ex)) + + def friend_exit(self, friend_number): + for friend_num, file_num in self._file_transfers.keys(): + if friend_num != friend_number: + continue + ft = self._file_transfers[(friend_num, file_num)] + if type(ft) is SendTransfer: + self._paused_file_transfers[ft.file_id] = [ft.path, friend_num, False, -1] + elif type(ft) is ReceiveTransfer and ft.state != FILE_TRANSFER_STATE['INCOMING_NOT_STARTED']: + self._paused_file_transfers[ft.file_id] = [ft.path, friend_num, True, ft.total_size()] + self.cancel_transfer(friend_num, file_num, True) + + # ----------------------------------------------------------------------------------------------------------------- + # Avatars support + # ----------------------------------------------------------------------------------------------------------------- + + def send_avatar(self, friend_number, avatar_path=None): + """ + :param friend_number: number of friend who should get new avatar + :param avatar_path: path to avatar or None if reset + """ + sa = SendAvatar(avatar_path, self._tox, friend_number) + self._file_transfers[(friend_number, sa.file_number)] = sa + + def incoming_avatar(self, friend_number, file_number, size): + """ + Friend changed avatar + :param friend_number: friend number + :param file_number: file number + :param size: size of avatar or 0 (default avatar) + """ + friend = self._get_friend_by_number(friend_number) + ra = ReceiveAvatar(friend.get_contact_avatar_path(), self._tox, friend_number, size, file_number) + if ra.state != FILE_TRANSFER_STATE['CANCELLED']: + self._file_transfers[(friend_number, file_number)] = ra + ra.set_transfer_finished_handler(self.transfer_finished) + elif not size: + friend.reset_avatar(self._settings['identicons']) + + def _send_avatar_to_contacts(self, _): + friends = self._get_all_friends() + for friend in filter(self._is_friend_online, friends): + self.send_avatar(friend.number) + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _is_friend_online(self, friend_number): + friend = self._get_friend_by_number(friend_number) + + return friend.status is not None + + def _get_friend_by_number(self, friend_number): + return self._contact_provider.get_friend_by_number(friend_number) + + def _get_all_friends(self): + return self._contact_provider.get_all_friends() + + def _send_file_add_set_handlers(self, st, friend, file_name, inline=False): + st.set_transfer_finished_handler(self.transfer_finished) + file_number = st.get_file_number() + self._file_transfers[(friend.number, file_number)] = st + tm = self._file_transfers_message_service.add_outgoing_transfer_message(friend, st.size, file_name, file_number) + st.set_state_changed_handler(tm.transfer_updated) + if inline: + self._insert_inline_before[(friend.number, file_number)] = tm.message_id + + @staticmethod + def _generate_valid_path(path, from_position): + path, file_name = os.path.split(path) + new_file_name, i = file_name, 1 + if not from_position: + while os.path.isfile(join_path(path, new_file_name)): # file with same name already exists + if '.' in file_name: # has extension + d = file_name.rindex('.') + else: # no extension + d = len(file_name) + new_file_name = file_name[:d] + ' ({})'.format(i) + file_name[d:] + i += 1 + path = join_path(path, new_file_name) + + return path diff --git a/toxygen/file_transfers/file_transfers_messages_service.py b/toxygen/file_transfers/file_transfers_messages_service.py new file mode 100644 index 0000000..4509183 --- /dev/null +++ b/toxygen/file_transfers/file_transfers_messages_service.py @@ -0,0 +1,78 @@ +from messenger.messenger import * +import utils.util as util +from file_transfers.file_transfers import * + + +class FileTransfersMessagesService: + + def __init__(self, contacts_manager, messages_items_factory, profile, main_screen): + self._contacts_manager = contacts_manager + self._messages_items_factory = messages_items_factory + self._profile = profile + self._messages = main_screen.messages + + def add_incoming_transfer_message(self, friend, accepted, size, file_name, file_number): + author = MessageAuthor(friend.name, MESSAGE_AUTHOR['FRIEND']) + status = FILE_TRANSFER_STATE['RUNNING'] if accepted else FILE_TRANSFER_STATE['INCOMING_NOT_STARTED'] + tm = TransferMessage(author, util.get_unix_time(), status, size, file_name, friend.number, file_number) + + if self._is_friend_active(friend.number): + self._create_file_transfer_item(tm) + self._messages.scrollToBottom() + else: + friend.actions = True + + friend.append_message(tm) + + return tm + + def add_outgoing_transfer_message(self, friend, size, file_name, file_number): + author = MessageAuthor(self._profile.name, MESSAGE_AUTHOR['ME']) + status = FILE_TRANSFER_STATE['OUTGOING_NOT_STARTED'] + tm = TransferMessage(author, util.get_unix_time(), status, size, file_name, friend.number, file_number) + + if self._is_friend_active(friend.number): + self._create_file_transfer_item(tm) + self._messages.scrollToBottom() + + friend.append_message(tm) + + return tm + + def add_inline_message(self, transfer, index): + if not self._is_friend_active(transfer.friend_number): + return + count = self._messages.count() + if count + index + 1 >= 0: + self._create_inline_item(transfer.data, count + index + 1) + + def add_unsent_file_message(self, friend, file_path, data): + author = MessageAuthor(self._profile.name, MESSAGE_AUTHOR['ME']) + size = os.path.getsize(file_path) if data is None else len(data) + tm = UnsentFileMessage(file_path, data, util.get_unix_time(), author, size, friend.number) + friend.append_message(tm) + + if self._is_friend_active(friend.number): + self._create_unsent_file_item(tm) + self._messages.scrollToBottom() + + return tm + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _is_friend_active(self, friend_number): + if not self._contacts_manager.is_active_a_friend(): + return False + + return friend_number == self._contacts_manager.get_active_number() + + def _create_file_transfer_item(self, tm): + return self._messages_items_factory.create_file_transfer_item(tm) + + def _create_inline_item(self, data, position): + return self._messages_items_factory.create_inline_item(data, False, position) + + def _create_unsent_file_item(self, tm): + return self._messages_items_factory.create_unsent_file_item(tm) diff --git a/toxygen/groups/__init__.py b/toxygen/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/groups/group_ban.py b/toxygen/groups/group_ban.py new file mode 100644 index 0000000..89ecc7e --- /dev/null +++ b/toxygen/groups/group_ban.py @@ -0,0 +1,23 @@ + + +class GroupBan: + + def __init__(self, ban_id, ban_target, ban_time): + self._ban_id = ban_id + self._ban_target = ban_target + self._ban_time = ban_time + + def get_ban_id(self): + return self._ban_id + + ban_id = property(get_ban_id) + + def get_ban_target(self): + return self._ban_target + + ban_target = property(get_ban_target) + + def get_ban_time(self): + return self._ban_time + + ban_time = property(get_ban_time) diff --git a/toxygen/groups/group_invite.py b/toxygen/groups/group_invite.py new file mode 100644 index 0000000..a2eed47 --- /dev/null +++ b/toxygen/groups/group_invite.py @@ -0,0 +1,23 @@ + + +class GroupInvite: + + def __init__(self, friend_public_key, chat_name, invite_data): + self._friend_public_key = friend_public_key + self._chat_name = chat_name + self._invite_data = invite_data[:] + + def get_friend_public_key(self): + return self._friend_public_key + + friend_public_key = property(get_friend_public_key) + + def get_chat_name(self): + return self._chat_name + + chat_name = property(get_chat_name) + + def get_invite_data(self): + return self._invite_data[:] + + invite_data = property(get_invite_data) diff --git a/toxygen/groups/group_peer.py b/toxygen/groups/group_peer.py new file mode 100644 index 0000000..4eaf255 --- /dev/null +++ b/toxygen/groups/group_peer.py @@ -0,0 +1,70 @@ + + +class GroupChatPeer: + """ + Represents peer in group chat. + """ + + def __init__(self, peer_id, name, status, role, public_key, is_current_user=False, is_muted=False): + self._peer_id = peer_id + self._name = name + self._status = status + self._role = role + self._public_key = public_key + self._is_current_user = is_current_user + self._is_muted = is_muted + + # ----------------------------------------------------------------------------------------------------------------- + # Readonly properties + # ----------------------------------------------------------------------------------------------------------------- + + def get_id(self): + return self._peer_id + + id = property(get_id) + + def get_public_key(self): + return self._public_key + + public_key = property(get_public_key) + + def get_is_current_user(self): + return self._is_current_user + + is_current_user = property(get_is_current_user) + + # ----------------------------------------------------------------------------------------------------------------- + # Read-write properties + # ----------------------------------------------------------------------------------------------------------------- + + def get_name(self): + return self._name + + def set_name(self, name): + self._name = name + + name = property(get_name, set_name) + + def get_status(self): + return self._status + + def set_status(self, status): + self._status = status + + status = property(get_status, set_status) + + def get_role(self): + return self._role + + def set_role(self, role): + self._role = role + + role = property(get_role, set_role) + + def get_is_muted(self): + return self._is_muted + + def set_is_muted(self, is_muted): + self._is_muted = is_muted + + is_muted = property(get_is_muted, set_is_muted) diff --git a/toxygen/groups/groups_service.py b/toxygen/groups/groups_service.py new file mode 100644 index 0000000..b8fc7cc --- /dev/null +++ b/toxygen/groups/groups_service.py @@ -0,0 +1,242 @@ +import common.tox_save as tox_save +import utils.ui as util_ui +from groups.peers_list import PeersListGenerator +from groups.group_invite import GroupInvite +import wrapper.toxcore_enums_and_consts as constants + + +class GroupsService(tox_save.ToxSave): + + def __init__(self, tox, contacts_manager, contacts_provider, main_screen, widgets_factory_provider): + super().__init__(tox) + self._contacts_manager = contacts_manager + self._contacts_provider = contacts_provider + self._main_screen = main_screen + self._peers_list_widget = main_screen.peers_list + self._widgets_factory_provider = widgets_factory_provider + self._group_invites = [] + self._screen = None + + def set_tox(self, tox): + super().set_tox(tox) + for group in self._get_all_groups(): + group.set_tox(tox) + + # ----------------------------------------------------------------------------------------------------------------- + # Groups creation + # ----------------------------------------------------------------------------------------------------------------- + + def create_new_gc(self, name, privacy_state, nick, status): + group_number = self._tox.group_new(privacy_state, name, nick, status) + if group_number == -1: + return + + self._add_new_group_by_number(group_number) + group = self._get_group_by_number(group_number) + group.status = constants.TOX_USER_STATUS['NONE'] + self._contacts_manager.update_filtration() + + def join_gc_by_id(self, chat_id, password, nick, status): + group_number = self._tox.group_join(chat_id, password, nick, status) + self._add_new_group_by_number(group_number) + + # ----------------------------------------------------------------------------------------------------------------- + # Groups reconnect and leaving + # ----------------------------------------------------------------------------------------------------------------- + + def leave_group(self, group_number): + self._tox.group_leave(group_number) + self._contacts_manager.delete_group(group_number) + + def disconnect_from_group(self, group_number): + self._tox.group_disconnect(group_number) + group = self._get_group_by_number(group_number) + group.status = None + self._clear_peers_list(group) + + def reconnect_to_group(self, group_number): + self._tox.group_reconnect(group_number) + group = self._get_group_by_number(group_number) + group.status = constants.TOX_USER_STATUS['NONE'] + self._clear_peers_list(group) + + # ----------------------------------------------------------------------------------------------------------------- + # Group invites + # ----------------------------------------------------------------------------------------------------------------- + + def invite_friend(self, friend_number, group_number): + self._tox.group_invite_friend(group_number, friend_number) + + def process_group_invite(self, friend_number, group_name, invite_data): + friend = self._get_friend_by_number(friend_number) + invite = GroupInvite(friend.tox_id, group_name, invite_data) + self._group_invites.append(invite) + self._update_invites_button_state() + + def accept_group_invite(self, invite, name, status, password): + pk = invite.friend_public_key + friend = self._get_friend_by_public_key(pk) + self._join_gc_via_invite(invite.invite_data, friend.number, name, status, password) + self._delete_group_invite(invite) + self._update_invites_button_state() + + def decline_group_invite(self, invite): + self._delete_group_invite(invite) + self._main_screen.update_gc_invites_button_state() + + def get_group_invites(self): + return self._group_invites[:] + + group_invites = property(get_group_invites) + + def get_group_invites_count(self): + return len(self._group_invites) + + group_invites_count = property(get_group_invites_count) + + # ----------------------------------------------------------------------------------------------------------------- + # Group info methods + # ----------------------------------------------------------------------------------------------------------------- + + def update_group_info(self, group): + group.name = self._tox.group_get_name(group.number) + group.status_message = self._tox.group_get_topic(group.number) + + def set_group_topic(self, group): + if not group.is_self_moderator_or_founder(): + return + text = util_ui.tr('New topic for group "{}":'.format(group.name)) + title = util_ui.tr('Set group topic') + topic, ok = util_ui.text_dialog(text, title, group.status_message) + if not ok or not topic: + return + self._tox.group_set_topic(group.number, topic) + group.status_message = topic + + def show_group_management_screen(self, group): + widgets_factory = self._get_widgets_factory() + self._screen = widgets_factory.create_group_management_screen(group) + self._screen.show() + + def show_group_settings_screen(self, group): + widgets_factory = self._get_widgets_factory() + self._screen = widgets_factory.create_group_settings_screen(group) + self._screen.show() + + def set_group_password(self, group, password): + if group.password == password: + return + self._tox.group_founder_set_password(group.number, password) + group.password = password + + def set_group_peers_limit(self, group, peers_limit): + if group.peers_limit == peers_limit: + return + self._tox.group_founder_set_peer_limit(group.number, peers_limit) + group.peers_limit = peers_limit + + def set_group_privacy_state(self, group, privacy_state): + is_private = privacy_state == constants.TOX_GROUP_PRIVACY_STATE['PRIVATE'] + if group.is_private == is_private: + return + self._tox.group_founder_set_privacy_state(group.number, privacy_state) + group.is_private = is_private + + # ----------------------------------------------------------------------------------------------------------------- + # Peers list + # ----------------------------------------------------------------------------------------------------------------- + + def generate_peers_list(self): + if not self._contacts_manager.is_active_a_group(): + return + group = self._contacts_manager.get_curr_contact() + PeersListGenerator().generate(group.peers, self, self._peers_list_widget, group.tox_id) + + def peer_selected(self, chat_id, peer_id): + widgets_factory = self._get_widgets_factory() + group = self._get_group_by_public_key(chat_id) + self_peer = group.get_self_peer() + if self_peer.id != peer_id: + self._screen = widgets_factory.create_peer_screen_window(group, peer_id) + else: + self._screen = widgets_factory.create_self_peer_screen_window(group) + self._screen.show() + + # ----------------------------------------------------------------------------------------------------------------- + # Peers actions + # ----------------------------------------------------------------------------------------------------------------- + + def set_new_peer_role(self, group, peer, role): + self._tox.group_mod_set_role(group.number, peer.id, role) + peer.role = role + self.generate_peers_list() + + def toggle_ignore_peer(self, group, peer, ignore): + self._tox.group_toggle_ignore(group.number, peer.id, ignore) + peer.is_muted = ignore + + def set_self_info(self, group, name, status): + self._tox.group_self_set_name(group.number, name) + self._tox.group_self_set_status(group.number, status) + self_peer = group.get_self_peer() + self_peer.name = name + self_peer.status = status + self.generate_peers_list() + + # ----------------------------------------------------------------------------------------------------------------- + # Bans support + # ----------------------------------------------------------------------------------------------------------------- + + def show_bans_list(self, group): + widgets_factory = self._get_widgets_factory() + self._screen = widgets_factory.create_groups_bans_screen(group) + self._screen.show() + + def ban_peer(self, group, peer_id, ban_type): + self._tox.group_mod_ban_peer(group.number, peer_id, ban_type) + + def kick_peer(self, group, peer_id): + self._tox.group_mod_remove_peer(group.number, peer_id) + + def cancel_ban(self, group_number, ban_id): + self._tox.group_mod_remove_ban(group_number, ban_id) + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _add_new_group_by_number(self, group_number): + self._contacts_manager.add_group(group_number) + + def _get_group_by_number(self, group_number): + return self._contacts_provider.get_group_by_number(group_number) + + def _get_group_by_public_key(self, public_key): + return self._contacts_provider.get_group_by_public_key(public_key) + + def _get_all_groups(self): + return self._contacts_provider.get_all_groups() + + def _get_friend_by_number(self, friend_number): + return self._contacts_provider.get_friend_by_number(friend_number) + + def _get_friend_by_public_key(self, public_key): + return self._contacts_provider.get_friend_by_public_key(public_key) + + def _clear_peers_list(self, group): + group.remove_all_peers_except_self() + self.generate_peers_list() + + def _delete_group_invite(self, invite): + if invite in self._group_invites: + self._group_invites.remove(invite) + + def _join_gc_via_invite(self, invite_data, friend_number, nick, status, password): + group_number = self._tox.group_invite_accept(invite_data, friend_number, nick, status, password) + self._add_new_group_by_number(group_number) + + def _update_invites_button_state(self): + self._main_screen.update_gc_invites_button_state() + + def _get_widgets_factory(self): + return self._widgets_factory_provider.get_item() diff --git a/toxygen/groups/peers_list.py b/toxygen/groups/peers_list.py new file mode 100644 index 0000000..17495f5 --- /dev/null +++ b/toxygen/groups/peers_list.py @@ -0,0 +1,104 @@ +from ui.group_peers_list import PeerItem, PeerTypeItem +from wrapper.toxcore_enums_and_consts import * +from ui.widgets import * + + +# ----------------------------------------------------------------------------------------------------------------- +# Builder +# ----------------------------------------------------------------------------------------------------------------- + + +class PeerListBuilder: + + def __init__(self): + self._peers = {} + self._titles = {} + self._index = 0 + self._handler = None + + def with_click_handler(self, handler): + self._handler = handler + + return self + + def with_title(self, title): + self._titles[self._index] = title + self._index += 1 + + return self + + def with_peers(self, peers): + for peer in peers: + self._add_peer(peer) + + return self + + def build(self, list_widget): + list_widget.clear() + + for i in range(self._index): + if i in self._peers: + peer = self._peers[i] + self._add_peer_item(peer, list_widget) + else: + title = self._titles[i] + self._add_peer_type_item(title, list_widget) + + def _add_peer_item(self, peer, parent): + item = PeerItem(peer, self._handler, parent.width(), parent) + self._add_item(parent, item) + + def _add_peer_type_item(self, text, parent): + item = PeerTypeItem(text, parent.width(), parent) + self._add_item(parent, item) + + @staticmethod + def _add_item(parent, item): + elem = QtWidgets.QListWidgetItem(parent) + elem.setSizeHint(QtCore.QSize(parent.width(), item.height())) + parent.addItem(elem) + parent.setItemWidget(elem, item) + + def _add_peer(self, peer): + self._peers[self._index] = peer + self._index += 1 + +# ----------------------------------------------------------------------------------------------------------------- +# Generators +# ----------------------------------------------------------------------------------------------------------------- + + +class PeersListGenerator: + + @staticmethod + def generate(peers_list, groups_service, list_widget, chat_id): + admin_title = util_ui.tr('Administrator') + moderators_title = util_ui.tr('Moderators') + users_title = util_ui.tr('Users') + observers_title = util_ui.tr('Observers') + + admins = list(filter(lambda p: p.role == TOX_GROUP_ROLE['FOUNDER'], peers_list)) + moderators = list(filter(lambda p: p.role == TOX_GROUP_ROLE['MODERATOR'], peers_list)) + users = list(filter(lambda p: p.role == TOX_GROUP_ROLE['USER'], peers_list)) + observers = list(filter(lambda p: p.role == TOX_GROUP_ROLE['OBSERVER'], peers_list)) + + builder = (PeerListBuilder() + .with_click_handler(lambda peer_id: groups_service.peer_selected(chat_id, peer_id))) + if len(admins): + (builder + .with_title(admin_title) + .with_peers(admins)) + if len(moderators): + (builder + .with_title(moderators_title) + .with_peers(moderators)) + if len(users): + (builder + .with_title(users_title) + .with_peers(users)) + if len(observers): + (builder + .with_title(observers_title) + .with_peers(observers)) + + builder.build(list_widget) diff --git a/toxygen/history/__init__.py b/toxygen/history/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/history/database.py b/toxygen/history/database.py new file mode 100644 index 0000000..751c74b --- /dev/null +++ b/toxygen/history/database.py @@ -0,0 +1,201 @@ +from sqlite3 import connect +import os.path +import utils.util as util + + +TIMEOUT = 11 + +SAVE_MESSAGES = 500 + +MESSAGE_AUTHOR = { + 'ME': 0, + 'FRIEND': 1, + 'NOT_SENT': 2, + 'GC_PEER': 3 +} + +CONTACT_TYPE = { + 'FRIEND': 0, + 'GC_PEER': 1, + 'GC_PEER_PRIVATE': 2 +} + + +class Database: + + def __init__(self, path, toxes): + self._path, self._toxes = path, toxes + self._name = os.path.basename(path) + if os.path.exists(path): + try: + with open(path, 'rb') as fin: + data = fin.read() + if toxes.is_data_encrypted(data): + data = toxes.pass_decrypt(data) + with open(path, 'wb') as fout: + fout.write(data) + except Exception as ex: + util.log('Db reading error: ' + str(ex)) + os.remove(path) + + # ----------------------------------------------------------------------------------------------------------------- + # Public methods + # ----------------------------------------------------------------------------------------------------------------- + + def save(self): + if self._toxes.has_password(): + with open(self._path, 'rb') as fin: + data = fin.read() + data = self._toxes.pass_encrypt(bytes(data)) + with open(self._path, 'wb') as fout: + fout.write(data) + + def export(self, directory): + new_path = util.join_path(directory, self._name) + with open(self._path, 'rb') as fin: + data = fin.read() + if self._toxes.has_password(): + data = self._toxes.pass_encrypt(data) + with open(new_path, 'wb') as fout: + fout.write(data) + + def add_friend_to_db(self, tox_id): + db = self._connect() + try: + cursor = db.cursor() + cursor.execute('CREATE TABLE IF NOT EXISTS id' + tox_id + '(' + ' id INTEGER PRIMARY KEY,' + ' author_name TEXT,' + ' message TEXT,' + ' author_type INTEGER,' + ' unix_time REAL,' + ' message_type INTEGER' + ')') + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def delete_friend_from_db(self, tox_id): + db = self._connect() + try: + cursor = db.cursor() + cursor.execute('DROP TABLE id' + tox_id + ';') + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def save_messages_to_db(self, tox_id, messages_iter): + db = self._connect() + try: + cursor = db.cursor() + cursor.executemany('INSERT INTO id' + tox_id + + '(message, author_name, author_type, unix_time, message_type) ' + + 'VALUES (?, ?, ?, ?, ?, ?);', messages_iter) + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def update_messages(self, tox_id, message_id): + db = self._connect() + try: + cursor = db.cursor() + cursor.execute('UPDATE id' + tox_id + ' SET author = 0 ' + 'WHERE id = ' + str(message_id) + ' AND author = 2;') + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def delete_message(self, tox_id, unique_id): + db = self._connect() + try: + cursor = db.cursor() + cursor.execute('DELETE FROM id' + tox_id + ' WHERE id = ' + str(unique_id) + ';') + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def delete_messages(self, tox_id): + db = self._connect() + try: + cursor = db.cursor() + cursor.execute('DELETE FROM id' + tox_id + ';') + db.commit() + except: + print('Database is locked!') + db.rollback() + finally: + db.close() + + def messages_getter(self, tox_id): + self.add_friend_to_db(tox_id) + + return Database.MessageGetter(self._path, tox_id) + + # ----------------------------------------------------------------------------------------------------------------- + # Messages loading + # ----------------------------------------------------------------------------------------------------------------- + + class MessageGetter: + + def __init__(self, path, tox_id): + self._count = 0 + self._path = path + self._tox_id = tox_id + self._db = self._cursor = None + + def get_one(self): + return self.get(1) + + def get_all(self): + self._connect() + data = self._cursor.fetchall() + self._disconnect() + self._count = len(data) + return data + + def get(self, count): + self._connect() + self.skip() + data = self._cursor.fetchmany(count) + self._disconnect() + self._count += len(data) + return data + + def skip(self): + if self._count: + self._cursor.fetchmany(self._count) + + def delete_one(self): + if self._count: + self._count -= 1 + + def _connect(self): + self._db = connect(self._path, timeout=TIMEOUT) + self._cursor = self._db.cursor() + self._cursor.execute('SELECT message, author_type, author_name, unix_time, message_type, id FROM id' + + self._tox_id + ' ORDER BY unix_time DESC;') + + def _disconnect(self): + self._db.close() + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _connect(self): + return connect(self._path, timeout=TIMEOUT) diff --git a/toxygen/history/history.py b/toxygen/history/history.py new file mode 100644 index 0000000..bd7e353 --- /dev/null +++ b/toxygen/history/history.py @@ -0,0 +1,138 @@ +from history.history_logs_generators import * + + +class History: + + def __init__(self, contact_provider, db, settings, main_screen, messages_items_factory): + self._contact_provider = contact_provider + self._db = db + self._settings = settings + self._messages = main_screen.messages + self._messages_items_factory = messages_items_factory + self._is_loading = False + self._contacts_manager = None + + def __del__(self): + del self._db + + def set_contacts_manager(self, contacts_manager): + self._contacts_manager = contacts_manager + + # ----------------------------------------------------------------------------------------------------------------- + # History support + # ----------------------------------------------------------------------------------------------------------------- + + def save_history(self): + """ + Save history to db + """ + if self._settings['save_db']: + for friend in self._contact_provider.get_all_friends(): + self._db.add_friend_to_db(friend.tox_id) + if not self._settings['save_unsent_only']: + messages = friend.get_corr_for_saving() + else: + messages = friend.get_unsent_messages_for_saving() + self._db.delete_messages(friend.tox_id) + messages = map(lambda m: (m.text, m.author.name, m.author.type, m.time, m.type), messages) + self._db.save_messages_to_db(friend.tox_id, messages) + + self._db.save() + + def clear_history(self, friend, save_unsent=False): + """ + Clear chat history + """ + friend.clear_corr(save_unsent) + self._db.delete_friend_from_db(friend.tox_id) + + def export_history(self, contact, as_text=True): + extension = 'txt' if as_text else 'html' + file_name, _ = util_ui.save_file_dialog(util_ui.tr('Choose file name'), extension) + + if not file_name: + return + + if not file_name.endswith('.' + extension): + file_name += '.' + extension + + history = self.generate_history(contact, as_text) + with open(file_name, 'wt') as fl: + fl.write(history) + + def delete_message(self, message): + contact = self._contacts_manager.get_curr_contact() + if message.type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']): + if message.is_saved(): + self._db.delete_message(contact.tox_id, message.id) + contact.delete_message(message.message_id) + + def load_history(self, friend): + """ + Tries to load next part of messages + """ + if self._is_loading: + return + self._is_loading = True + friend.load_corr(False) + messages = friend.get_corr() + if not messages: + self._is_loading = False + return + messages.reverse() + messages = messages[self._messages.count():self._messages.count() + PAGE_SIZE] + for message in messages: + message_type = message.get_type() + if message_type in (MESSAGE_TYPE['TEXT'], MESSAGE_TYPE['ACTION']): # text message + self._create_message_item(message) + elif message_type == MESSAGE_TYPE['FILE_TRANSFER']: # file transfer + if message.state == FILE_TRANSFER_STATE['UNSENT']: + self._create_unsent_file_item(message) + else: + self._create_file_transfer_item(message) + elif message_type == MESSAGE_TYPE['INLINE']: # inline image + self._create_inline_item(message) + else: # info message + self._create_message_item(message) + self._is_loading = False + + def get_message_getter(self, friend_public_key): + self._db.add_friend_to_db(friend_public_key) + + return self._db.messages_getter(friend_public_key) + + def delete_history(self, friend): + self._db.delete_friend_from_db(friend.tox_id) + + def add_friend_to_db(self, tox_id): + self._db.add_friend_to_db(tox_id) + + @staticmethod + def generate_history(contact, as_text=True, _range=None): + if _range is None: + contact.load_all_corr() + corr = contact.get_corr() + elif _range[1] + 1: + corr = contact.get_corr()[_range[0]:_range[1] + 1] + else: + corr = contact.get_corr()[_range[0]:] + + generator = TextHistoryGenerator(corr, contact.name) if as_text else HtmlHistoryGenerator(corr, contact.name) + + return generator.generate() + + # ----------------------------------------------------------------------------------------------------------------- + # Items creation + # ----------------------------------------------------------------------------------------------------------------- + + def _create_message_item(self, message): + return self._messages_items_factory.create_message_item(message, False) + + def _create_unsent_file_item(self, message): + return self._messages_items_factory.create_unsent_file_item(message, False) + + def _create_file_transfer_item(self, message): + return self._messages_items_factory.create_file_transfer_item(message, False) + + def _create_inline_item(self, message): + return self._messages_items_factory.create_inline_item(message, False) diff --git a/toxygen/history/history_logs_generators.py b/toxygen/history/history_logs_generators.py new file mode 100644 index 0000000..b8d0a56 --- /dev/null +++ b/toxygen/history/history_logs_generators.py @@ -0,0 +1,48 @@ +from messenger.messages import * +import utils.util as util + + +class HistoryLogsGenerator: + + def __init__(self, history, contact_name): + self._history = history + self._contact_name = contact_name + + def generate(self): + return str() + + @staticmethod + def _get_message_time(message): + return util.convert_time(message.time) if message.author.type != MESSAGE_AUTHOR['NOT_SENT'] else 'Unsent' + + +class HtmlHistoryGenerator(HistoryLogsGenerator): + + def __init__(self, history, contact_name): + super().__init__(history, contact_name) + + def generate(self): + arr = [] + for message in self._history: + if type(message) is TextMessage: + x = '[{}] {}: {}
' + arr.append(x.format(self._get_message_time(message), message.author.name, message.text)) + s = '
'.join(arr) + html = '{}{}' + + return html.format(self._contact_name, s) + + +class TextHistoryGenerator(HistoryLogsGenerator): + + def __init__(self, history, contact_name): + super().__init__(history, contact_name) + + def generate(self): + arr = [self._contact_name] + for message in self._history: + if type(message) is TextMessage: + x = '[{}] {}: {}\n' + arr.append(x.format(self._get_message_time(message), message.author.name, message.text)) + + return '\n'.join(arr) diff --git a/toxygen/messenger/__init__.py b/toxygen/messenger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/messenger/messages.py b/toxygen/messenger/messages.py new file mode 100644 index 0000000..e777c4b --- /dev/null +++ b/toxygen/messenger/messages.py @@ -0,0 +1,239 @@ +from history.database import MESSAGE_AUTHOR +import os.path +from ui.messages_widgets import * + + +MESSAGE_TYPE = { + 'TEXT': 0, + 'ACTION': 1, + 'FILE_TRANSFER': 2, + 'INLINE': 3, + 'INFO_MESSAGE': 4 +} + +PAGE_SIZE = 42 + + +class MessageAuthor: + + def __init__(self, author_name, author_type): + self._name = author_name + self._type = author_type + + def get_name(self): + return self._name + + name = property(get_name) + + def get_type(self): + return self._type + + def set_type(self, value): + self._type = value + + type = property(get_type, set_type) + + +class Message: + + MESSAGE_ID = 0 + + def __init__(self, message_type, author, time): + self._time = time + self._type = message_type + self._author = author + self._widget = None + self._message_id = self._get_id() + + def get_type(self): + return self._type + + type = property(get_type) + + def get_author(self): + return self._author + + author = property(get_author) + + def get_time(self): + return self._time + + time = property(get_time) + + def get_message_id(self): + return self._message_id + + message_id = property(get_message_id) + + def get_widget(self, *args): + self._widget = self._create_widget(*args) + + return self._widget + + widget = property(get_widget) + + def remove_widget(self): + self._widget = None + + def mark_as_sent(self): + self._author.type = MESSAGE_AUTHOR['ME'] + if self._widget is not None: + self._widget.mark_as_sent() + + def _create_widget(self, *args): + pass + + @staticmethod + def _get_id(): + Message.MESSAGE_ID += 1 + + return int(Message.MESSAGE_ID) + + +class TextMessage(Message): + """ + Plain text or action message + """ + + def __init__(self, message, owner, time, message_type, message_id=0): + super().__init__(message_type, owner, time) + self._message = message + self._id = message_id + + def get_text(self): + return self._message + + text = property(get_text) + + def get_id(self): + return self._id + + id = property(get_id) + + def is_saved(self): + return self._id > 0 + + def _create_widget(self, *args): + return MessageItem(self, *args) + + +class OutgoingTextMessage(TextMessage): + + def __init__(self, message, owner, time, message_type, tox_message_id=0): + super().__init__(message, owner, time, message_type) + self._tox_message_id = tox_message_id + + def get_tox_message_id(self): + return self._tox_message_id + + def set_tox_message_id(self, tox_message_id): + self._tox_message_id = tox_message_id + + tox_message_id = property(get_tox_message_id, set_tox_message_id) + + +class GroupChatMessage(TextMessage): + + def __init__(self, id, message, owner, time, message_type, name): + super().__init__(id, message, owner, time, message_type) + self._user_name = name + + +class TransferMessage(Message): + """ + Message with info about file transfer + """ + + def __init__(self, author, time, state, size, file_name, friend_number, file_number): + super().__init__(MESSAGE_TYPE['FILE_TRANSFER'], author, time) + self._state = state + self._size = size + self._file_name = file_name + self._friend_number, self._file_number = friend_number, file_number + + def is_active(self, file_number): + if self._file_number != file_number: + return False + + return self._state not in (FILE_TRANSFER_STATE['FINISHED'], FILE_TRANSFER_STATE['CANCELLED']) + + def get_friend_number(self): + return self._friend_number + + friend_number = property(get_friend_number) + + def get_file_number(self): + return self._file_number + + file_number = property(get_file_number) + + def get_state(self): + return self._state + + def set_state(self, value): + self._state = value + + state = property(get_state, set_state) + + def get_size(self): + return self._size + + size = property(get_size) + + def get_file_name(self): + return self._file_name + + file_name = property(get_file_name) + + def transfer_updated(self, state, percentage, time): + self._state = state + if self._widget is not None: + self._widget.update_transfer_state(state, percentage, time) + + def _create_widget(self, *args): + return FileTransferItem(self, *args) + + +class UnsentFileMessage(TransferMessage): + + def __init__(self, path, data, time, author, size, friend_number): + file_name = os.path.basename(path) + super().__init__(author, time, FILE_TRANSFER_STATE['UNSENT'], size, file_name, friend_number, -1) + self._data, self._path = data, path + + def get_data(self): + return self._data + + data = property(get_data) + + def get_path(self): + return self._path + + path = property(get_path) + + def _create_widget(self, *args): + return UnsentFileItem(self, *args) + + +class InlineImageMessage(Message): + """ + Inline image + """ + + def __init__(self, data): + super().__init__(MESSAGE_TYPE['INLINE'], None, None) + self._data = data + + def get_data(self): + return self._data + + data = property(get_data) + + def _create_widget(self, *args): + return InlineImageItem(self, *args) + + +class InfoMessage(TextMessage): + + def __init__(self, message, time): + super().__init__(message, None, time, MESSAGE_TYPE['INFO_MESSAGE']) diff --git a/toxygen/messenger/messenger.py b/toxygen/messenger/messenger.py new file mode 100644 index 0000000..e859135 --- /dev/null +++ b/toxygen/messenger/messenger.py @@ -0,0 +1,310 @@ +import common.tox_save as tox_save +from messenger.messages import * + + +class Messenger(tox_save.ToxSave): + + def __init__(self, tox, plugin_loader, screen, contacts_manager, contacts_provider, items_factory, profile, + calls_manager): + super().__init__(tox) + self._plugin_loader = plugin_loader + self._screen = screen + self._contacts_manager = contacts_manager + self._contacts_provider = contacts_provider + self._items_factory = items_factory + self._profile = profile + self._profile_name = profile.name + + profile.name_changed_event.add_callback(self._on_profile_name_changed) + calls_manager.call_started_event.add_callback(self._on_call_started) + calls_manager.call_finished_event.add_callback(self._on_call_finished) + + def get_last_message(self): + contact = self._contacts_manager.get_curr_contact() + if contact is None: + return str() + + return contact.get_last_message_text() + + # ----------------------------------------------------------------------------------------------------------------- + # Messaging - friends + # ----------------------------------------------------------------------------------------------------------------- + + def new_message(self, friend_number, message_type, message): + """ + Current user gets new message + :param friend_number: friend_num of friend who sent message + :param message_type: message type - plain text or action message (/me) + :param message: text of message + """ + t = util.get_unix_time() + friend = self._get_friend_by_number(friend_number) + text_message = TextMessage(message, MessageAuthor(friend.name, MESSAGE_AUTHOR['FRIEND']), t, message_type) + self._add_message(text_message, friend) + + def send_message(self): + text = self._screen.messageEdit.toPlainText() + + plugin_command_prefix = '/plugin ' + if text.startswith(plugin_command_prefix): + self._plugin_loader.command(text[len(plugin_command_prefix):]) + self._screen.messageEdit.clear() + return + + action_message_prefix = '/me ' + if text.startswith(action_message_prefix): + message_type = TOX_MESSAGE_TYPE['ACTION'] + text = text[len(action_message_prefix):] + else: + message_type = TOX_MESSAGE_TYPE['NORMAL'] + + if self._contacts_manager.is_active_a_friend(): + self.send_message_to_friend(text, message_type) + elif self._contacts_manager.is_active_a_group(): + self.send_message_to_group(text, message_type) + elif self._contacts_manager.is_active_a_group_chat_peer(): + self.send_message_to_group_peer(text, message_type) + + def send_message_to_friend(self, text, message_type, friend_number=None): + """ + Send message + :param text: message text + :param friend_number: number of friend + """ + if friend_number is None: + friend_number = self._contacts_manager.get_active_number() + + if not text or friend_number < 0: + return + + friend = self._get_friend_by_number(friend_number) + messages = self._split_message(text.encode('utf-8')) + t = util.get_unix_time() + for message in messages: + if friend.status is not None: + message_id = self._tox.friend_send_message(friend_number, message_type, message) + else: + message_id = 0 + message_author = MessageAuthor(self._profile.name, MESSAGE_AUTHOR['NOT_SENT']) + message = OutgoingTextMessage(text, message_author, t, message_type, message_id) + friend.append_message(message) + if not self._contacts_manager.is_friend_active(friend_number): + return + self._create_message_item(message) + self._screen.messageEdit.clear() + self._screen.messages.scrollToBottom() + + def send_messages(self, friend_number): + """ + Send 'offline' messages to friend + """ + friend = self._get_friend_by_number(friend_number) + friend.load_corr() + messages = friend.get_unsent_messages() + try: + for message in messages: + message_id = self._tox.friend_send_message(friend_number, message.type, message.text.encode('utf-8')) + message.tox_message_id = message_id + except Exception as ex: + util.log('Sending pending messages failed with ' + str(ex)) + + # ----------------------------------------------------------------------------------------------------------------- + # Messaging - groups + # ----------------------------------------------------------------------------------------------------------------- + + def send_message_to_group(self, text, message_type, group_number=None): + if group_number is None: + group_number = self._contacts_manager.get_active_number() + + if not text or group_number < 0: + return + + group = self._get_group_by_number(group_number) + messages = self._split_message(text.encode('utf-8')) + t = util.get_unix_time() + for message in messages: + self._tox.group_send_message(group_number, message_type, message) + message_author = MessageAuthor(group.get_self_name(), MESSAGE_AUTHOR['GC_PEER']) + message = OutgoingTextMessage(text, message_author, t, message_type) + group.append_message(message) + if not self._contacts_manager.is_group_active(group_number): + return + self._create_message_item(message) + self._screen.messageEdit.clear() + self._screen.messages.scrollToBottom() + + def new_group_message(self, group_number, message_type, message, peer_id): + """ + Current user gets new message + :param message_type: message type - plain text or action message (/me) + :param message: text of message + """ + t = util.get_unix_time() + group = self._get_group_by_number(group_number) + peer = group.get_peer_by_id(peer_id) + text_message = TextMessage(message, MessageAuthor(peer.name, MESSAGE_AUTHOR['GC_PEER']), t, message_type) + self._add_message(text_message, group) + + # ----------------------------------------------------------------------------------------------------------------- + # Messaging - group peers + # ----------------------------------------------------------------------------------------------------------------- + + def send_message_to_group_peer(self, text, message_type, group_number=None, peer_id=None): + if group_number is None or peer_id is None: + group_peer_contact = self._contacts_manager.get_curr_contact() + peer_id = group_peer_contact.number + group = self._get_group_by_public_key(group_peer_contact.group_pk) + group_number = group.number + + if not text or group_number < 0 or peer_id < 0: + return + + group_peer_contact = self._contacts_manager.get_or_create_group_peer_contact(group_number, peer_id) + group = self._get_group_by_number(group_number) + messages = self._split_message(text.encode('utf-8')) + t = util.get_unix_time() + for message in messages: + self._tox.group_send_private_message(group_number, peer_id, message_type, message) + message_author = MessageAuthor(group.get_self_name(), MESSAGE_AUTHOR['GC_PEER']) + message = OutgoingTextMessage(text, message_author, t, message_type) + group_peer_contact.append_message(message) + if not self._contacts_manager.is_contact_active(group_peer_contact): + return + self._create_message_item(message) + self._screen.messageEdit.clear() + self._screen.messages.scrollToBottom() + + def new_group_private_message(self, group_number, message_type, message, peer_id): + """ + Current user gets new message + :param message: text of message + """ + t = util.get_unix_time() + group = self._get_group_by_number(group_number) + peer = group.get_peer_by_id(peer_id) + text_message = TextMessage(message, MessageAuthor(peer.name, MESSAGE_AUTHOR['GC_PEER']), + t, message_type) + group_peer_contact = self._contacts_manager.get_or_create_group_peer_contact(group_number, peer_id) + self._add_message(text_message, group_peer_contact) + + # ----------------------------------------------------------------------------------------------------------------- + # Message receipts + # ----------------------------------------------------------------------------------------------------------------- + + def receipt(self, friend_number, message_id): + friend = self._get_friend_by_number(friend_number) + friend.mark_as_sent(message_id) + + # ----------------------------------------------------------------------------------------------------------------- + # Typing notifications + # ----------------------------------------------------------------------------------------------------------------- + + def send_typing(self, typing): + """ + Send typing notification to a friend + """ + if not self._contacts_manager.can_send_typing_notification(): + return + contact = self._contacts_manager.get_curr_contact() + contact.typing_notification_handler.send(self._tox, typing) + + def friend_typing(self, friend_number, typing): + """ + Display incoming typing notification + """ + if self._contacts_manager.is_friend_active(friend_number): + self._screen.typing.setVisible(typing) + + # ----------------------------------------------------------------------------------------------------------------- + # Contact info updated + # ----------------------------------------------------------------------------------------------------------------- + + def new_friend_name(self, friend, old_name, new_name): + if old_name == new_name or friend.has_alias(): + return + message = util_ui.tr('User {} is now known as {}') + message = message.format(old_name, new_name) + if not self._contacts_manager.is_friend_active(friend.number): + friend.actions = True + self._add_info_message(friend.number, message) + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _split_message(message): + messages = [] + while len(message) > TOX_MAX_MESSAGE_LENGTH: + size = TOX_MAX_MESSAGE_LENGTH * 4 // 5 + last_part = message[size:TOX_MAX_MESSAGE_LENGTH] + if b' ' in last_part: + index = last_part.index(b' ') + elif b',' in last_part: + index = last_part.index(b',') + elif b'.' in last_part: + index = last_part.index(b'.') + else: + index = TOX_MAX_MESSAGE_LENGTH - size - 1 + index += size + 1 + messages.append(message[:index]) + message = message[index:] + if message: + messages.append(message) + + return messages + + def _get_friend_by_number(self, friend_number): + return self._contacts_provider.get_friend_by_number(friend_number) + + def _get_group_by_number(self, group_number): + return self._contacts_provider.get_group_by_number(group_number) + + def _get_group_by_public_key(self, public_key): + return self._contacts_provider.get_group_by_public_key( public_key) + + def _on_profile_name_changed(self, new_name): + if self._profile_name == new_name: + return + message = util_ui.tr('User {} is now known as {}') + message = message.format(self._profile_name, new_name) + for friend in self._contacts_provider.get_all_friends(): + self._add_info_message(friend.number, message) + self._profile_name = new_name + + def _on_call_started(self, friend_number, audio, video, is_outgoing): + if is_outgoing: + text = util_ui.tr("Outgoing video call") if video else util_ui.tr("Outgoing audio call") + else: + text = util_ui.tr("Incoming video call") if video else util_ui.tr("Incoming audio call") + self._add_info_message(friend_number, text) + + def _on_call_finished(self, friend_number, is_declined): + text = util_ui.tr("Call declined") if is_declined else util_ui.tr("Call finished") + self._add_info_message(friend_number, text) + + def _add_info_message(self, friend_number, text): + friend = self._get_friend_by_number(friend_number) + message = InfoMessage(text, util.get_unix_time()) + friend.append_message(message) + if self._contacts_manager.is_friend_active(friend_number): + self._create_info_message_item(message) + + def _create_info_message_item(self, message): + self._items_factory.create_message_item(message) + self._screen.messages.scrollToBottom() + + def _add_message(self, text_message, contact): + if self._contacts_manager.is_contact_active(contact): # add message to list + self._create_message_item(text_message) + self._screen.messages.scrollToBottom() + self._contacts_manager.get_curr_contact().append_message(text_message) + else: + contact.inc_messages() + contact.append_message(text_message) + if not contact.visibility: + self._contacts_manager.update_filtration() + + def _create_message_item(self, text_message): + # pixmap = self._contacts_manager.get_curr_contact().get_pixmap() + self._items_factory.create_message_item(text_message) diff --git a/toxygen/middleware/__init__.py b/toxygen/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/middleware/callbacks.py b/toxygen/middleware/callbacks.py new file mode 100644 index 0000000..b9a4099 --- /dev/null +++ b/toxygen/middleware/callbacks.py @@ -0,0 +1,605 @@ +from PyQt5 import QtGui +from wrapper.toxcore_enums_and_consts import * +from wrapper.toxav_enums import * +from wrapper.tox import bin_to_string +import utils.ui as util_ui +import utils.util as util +import cv2 +import numpy as np +from middleware.threads import invoke_in_main_thread, execute +from notifications.tray import tray_notification +from notifications.sound import * +import threading + +# TODO: refactoring. Use contact provider instead of manager + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - current user +# ----------------------------------------------------------------------------------------------------------------- + + +def self_connection_status(tox, profile): + """ + Current user changed connection status (offline, TCP, UDP) + """ + def wrapped(tox_link, connection, user_data): + print('Connection status: ', str(connection)) + status = tox.self_get_status() if connection != TOX_CONNECTION['NONE'] else None + invoke_in_main_thread(profile.set_status, status) + + return wrapped + + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - friends +# ----------------------------------------------------------------------------------------------------------------- + + +def friend_status(contacts_manager, file_transfer_handler, profile, settings): + def wrapped(tox, friend_number, new_status, user_data): + """ + Check friend's status (none, busy, away) + """ + print("Friend's #{} status changed!".format(friend_number)) + friend = contacts_manager.get_friend_by_number(friend_number) + if friend.status is None and settings['sound_notifications'] and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['FRIEND_CONNECTION_STATUS']) + invoke_in_main_thread(friend.set_status, new_status) + + def set_timer(): + t = threading.Timer(5, lambda: file_transfer_handler.send_files(friend_number)) + t.start() + invoke_in_main_thread(set_timer) + invoke_in_main_thread(contacts_manager.update_filtration) + + return wrapped + + +def friend_connection_status(contacts_manager, profile, settings, plugin_loader, file_transfer_handler, + messenger, calls_manager): + def wrapped(tox, friend_number, new_status, user_data): + """ + Check friend's connection status (offline, udp, tcp) + """ + print("Friend #{} connection status: {}".format(friend_number, new_status)) + friend = contacts_manager.get_friend_by_number(friend_number) + if new_status == TOX_CONNECTION['NONE']: + invoke_in_main_thread(friend.set_status, None) + invoke_in_main_thread(file_transfer_handler.friend_exit, friend_number) + invoke_in_main_thread(contacts_manager.update_filtration) + invoke_in_main_thread(messenger.friend_typing, friend_number, False) + invoke_in_main_thread(calls_manager.friend_exit, friend_number) + if settings['sound_notifications'] and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['FRIEND_CONNECTION_STATUS']) + elif friend.status is None: + invoke_in_main_thread(file_transfer_handler.send_avatar, friend_number) + invoke_in_main_thread(plugin_loader.friend_online, friend_number) + + return wrapped + + +def friend_name(contacts_provider, messenger): + def wrapped(tox, friend_number, name, size, user_data): + """ + Friend changed his name + """ + print('New name friend #' + str(friend_number)) + friend = contacts_provider.get_friend_by_number(friend_number) + old_name = friend.name + new_name = str(name, 'utf-8') + invoke_in_main_thread(friend.set_name, new_name) + invoke_in_main_thread(messenger.new_friend_name, friend, old_name, new_name) + + return wrapped + + +def friend_status_message(contacts_manager, messenger): + def wrapped(tox, friend_number, status_message, size, user_data): + """ + :return: function for callback friend_status_message. It updates friend's status message + and calls window repaint + """ + friend = contacts_manager.get_friend_by_number(friend_number) + invoke_in_main_thread(friend.set_status_message, str(status_message, 'utf-8')) + print('User #{} has new status message'.format(friend_number)) + invoke_in_main_thread(messenger.send_messages, friend_number) + + return wrapped + + +def friend_message(messenger, contacts_manager, profile, settings, window, tray): + def wrapped(tox, friend_number, message_type, message, size, user_data): + """ + New message from friend + """ + message = str(message, 'utf-8') + invoke_in_main_thread(messenger.new_message, friend_number, message_type, message) + if not window.isActiveWindow(): + friend = contacts_manager.get_friend_by_number(friend_number) + if settings['notifications'] and profile.status != TOX_USER_STATUS['BUSY'] and not settings.locked: + invoke_in_main_thread(tray_notification, friend.name, message, tray, window) + if settings['sound_notifications'] and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['MESSAGE']) + icon = os.path.join(util.get_images_directory(), 'icon_new_messages.png') + invoke_in_main_thread(tray.setIcon, QtGui.QIcon(icon)) + + return wrapped + + +def friend_request(contacts_manager): + def wrapped(tox, public_key, message, message_size, user_data): + """ + Called when user get new friend request + """ + print('Friend request') + key = ''.join(chr(x) for x in public_key[:TOX_PUBLIC_KEY_SIZE]) + tox_id = bin_to_string(key, TOX_PUBLIC_KEY_SIZE) + invoke_in_main_thread(contacts_manager.process_friend_request, tox_id, str(message, 'utf-8')) + + return wrapped + + +def friend_typing(messenger): + def wrapped(tox, friend_number, typing, user_data): + invoke_in_main_thread(messenger.friend_typing, friend_number, typing) + + return wrapped + + +def friend_read_receipt(messenger): + def wrapped(tox, friend_number, message_id, user_data): + invoke_in_main_thread(messenger.receipt, friend_number, message_id) + + return wrapped + + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - file transfers +# ----------------------------------------------------------------------------------------------------------------- + + +def tox_file_recv(window, tray, profile, file_transfer_handler, contacts_manager, settings): + """ + New incoming file + """ + def wrapped(tox, friend_number, file_number, file_type, size, file_name, file_name_size, user_data): + if file_type == TOX_FILE_KIND['DATA']: + print('File') + try: + file_name = str(file_name[:file_name_size], 'utf-8') + except: + file_name = 'toxygen_file' + invoke_in_main_thread(file_transfer_handler.incoming_file_transfer, + friend_number, + file_number, + size, + file_name) + if not window.isActiveWindow(): + friend = contacts_manager.get_friend_by_number(friend_number) + if settings['notifications'] and profile.status != TOX_USER_STATUS['BUSY'] and not settings.locked: + file_from = util_ui.tr("File from") + invoke_in_main_thread(tray_notification, file_from + ' ' + friend.name, file_name, tray, window) + if settings['sound_notifications'] and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['FILE_TRANSFER']) + icon = util.join_path(util.get_images_directory(), 'icon_new_messages.png') + invoke_in_main_thread(tray.setIcon, QtGui.QIcon(icon)) + else: # avatar + print('Avatar') + invoke_in_main_thread(file_transfer_handler.incoming_avatar, + friend_number, + file_number, + size) + return wrapped + + +def file_recv_chunk(file_transfer_handler): + """ + Incoming chunk + """ + def wrapped(tox, friend_number, file_number, position, chunk, length, user_data): + chunk = chunk[:length] if length else None + execute(file_transfer_handler.incoming_chunk, friend_number, file_number, position, chunk) + + return wrapped + + +def file_chunk_request(file_transfer_handler): + """ + Outgoing chunk + """ + def wrapped(tox, friend_number, file_number, position, size, user_data): + execute(file_transfer_handler.outgoing_chunk, friend_number, file_number, position, size) + + return wrapped + + +def file_recv_control(file_transfer_handler): + """ + Friend cancelled, paused or resumed file transfer + """ + def wrapped(tox, friend_number, file_number, file_control, user_data): + if file_control == TOX_FILE_CONTROL['CANCEL']: + file_transfer_handler.cancel_transfer(friend_number, file_number, True) + elif file_control == TOX_FILE_CONTROL['PAUSE']: + file_transfer_handler.pause_transfer(friend_number, file_number, True) + elif file_control == TOX_FILE_CONTROL['RESUME']: + file_transfer_handler.resume_transfer(friend_number, file_number, True) + + return wrapped + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - custom packets +# ----------------------------------------------------------------------------------------------------------------- + + +def lossless_packet(plugin_loader): + def wrapped(tox, friend_number, data, length, user_data): + """ + Incoming lossless packet + """ + data = data[:length] + invoke_in_main_thread(plugin_loader.callback_lossless, friend_number, data) + + return wrapped + + +def lossy_packet(plugin_loader): + def wrapped(tox, friend_number, data, length, user_data): + """ + Incoming lossy packet + """ + data = data[:length] + invoke_in_main_thread(plugin_loader.callback_lossy, friend_number, data) + + return wrapped + + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - audio +# ----------------------------------------------------------------------------------------------------------------- + +def call_state(calls_manager): + def wrapped(toxav, friend_number, mask, user_data): + """ + New call state + """ + print(friend_number, mask) + if mask == TOXAV_FRIEND_CALL_STATE['FINISHED'] or mask == TOXAV_FRIEND_CALL_STATE['ERROR']: + invoke_in_main_thread(calls_manager.stop_call, friend_number, True) + else: + calls_manager.toxav_call_state_cb(friend_number, mask) + + return wrapped + + +def call(calls_manager): + def wrapped(toxav, friend_number, audio, video, user_data): + """ + Incoming call from friend + """ + print(friend_number, audio, video) + invoke_in_main_thread(calls_manager.incoming_call, audio, video, friend_number) + + return wrapped + + +def callback_audio(calls_manager): + def wrapped(toxav, friend_number, samples, audio_samples_per_channel, audio_channels_count, rate, user_data): + """ + New audio chunk + """ + calls_manager.call.audio_chunk( + bytes(samples[:audio_samples_per_channel * 2 * audio_channels_count]), + audio_channels_count, + rate) + + return wrapped + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - video +# ----------------------------------------------------------------------------------------------------------------- + + +def video_receive_frame(toxav, friend_number, width, height, y, u, v, ystride, ustride, vstride, user_data): + """ + Creates yuv frame from y, u, v and shows it using OpenCV + For yuv => bgr we need this YUV420 frame: + + width + ------------------------- + | | + | Y | height + | | + ------------------------- + | | | + | U even | U odd | height // 4 + | | | + ------------------------- + | | | + | V even | V odd | height // 4 + | | | + ------------------------- + + width // 2 width // 2 + + It can be created from initial y, u, v using slices + """ + try: + y_size = abs(max(width, abs(ystride))) + u_size = abs(max(width // 2, abs(ustride))) + v_size = abs(max(width // 2, abs(vstride))) + + y = np.asarray(y[:y_size * height], dtype=np.uint8).reshape(height, y_size) + u = np.asarray(u[:u_size * height // 2], dtype=np.uint8).reshape(height // 2, u_size) + v = np.asarray(v[:v_size * height // 2], dtype=np.uint8).reshape(height // 2, v_size) + + width -= width % 4 + height -= height % 4 + + frame = np.zeros((int(height * 1.5), width), dtype=np.uint8) + + frame[:height, :] = y[:height, :width] + frame[height:height * 5 // 4, :width // 2] = u[:height // 2:2, :width // 2] + frame[height:height * 5 // 4, width // 2:] = u[1:height // 2:2, :width // 2] + + frame[height * 5 // 4:, :width // 2] = v[:height // 2:2, :width // 2] + frame[height * 5 // 4:, width // 2:] = v[1:height // 2:2, :width // 2] + + frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + + invoke_in_main_thread(cv2.imshow, str(friend_number), frame) + except Exception as ex: + print(ex) + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - groups +# ----------------------------------------------------------------------------------------------------------------- + + +def group_message(window, tray, tox, messenger, settings, profile): + """ + New message in group chat + """ + def wrapped(tox_link, group_number, peer_id, message_type, message, length, user_data): + message = str(message[:length], 'utf-8') + invoke_in_main_thread(messenger.new_group_message, group_number, message_type, message, peer_id) + if window.isActiveWindow(): + return + bl = settings['notify_all_gc'] or profile.name in message + name = tox.group_peer_get_name(group_number, peer_id) + if settings['notifications'] and profile.status != TOX_USER_STATUS['BUSY'] and (not settings.locked) and bl: + invoke_in_main_thread(tray_notification, name, message, tray, window) + if settings['sound_notifications'] and bl and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['MESSAGE']) + icon = util.join_path(util.get_images_directory(), 'icon_new_messages.png') + invoke_in_main_thread(tray.setIcon, QtGui.QIcon(icon)) + + return wrapped + + +def group_private_message(window, tray, tox, messenger, settings, profile): + """ + New private message in group chat + """ + def wrapped(tox_link, group_number, peer_id, message_type, message, length, user_data): + message = str(message[:length], 'utf-8') + invoke_in_main_thread(messenger.new_group_private_message, group_number, message_type, message, peer_id) + if window.isActiveWindow(): + return + bl = settings['notify_all_gc'] or profile.name in message + name = tox.group_peer_get_name(group_number, peer_id) + if settings['notifications'] and profile.status != TOX_USER_STATUS['BUSY'] and (not settings.locked) and bl: + invoke_in_main_thread(tray_notification, name, message, tray, window) + if settings['sound_notifications'] and bl and profile.status != TOX_USER_STATUS['BUSY']: + sound_notification(SOUND_NOTIFICATION['MESSAGE']) + icon = util.join_path(util.get_images_directory(), 'icon_new_messages.png') + invoke_in_main_thread(tray.setIcon, QtGui.QIcon(icon)) + + return wrapped + + +def group_invite(window, settings, tray, profile, groups_service, contacts_provider): + def wrapped(tox, friend_number, invite_data, length, group_name, group_name_length, user_data): + group_name = str(bytes(group_name[:group_name_length]), 'utf-8') + invoke_in_main_thread(groups_service.process_group_invite, + friend_number, group_name, + bytes(invite_data[:length])) + if window.isActiveWindow(): + return + if settings['notifications'] and profile.status != TOX_USER_STATUS['BUSY'] and not settings.locked: + friend = contacts_provider.get_friend_by_number(friend_number) + title = util_ui.tr('New invite to group chat') + text = util_ui.tr('{} invites you to group "{}"').format(friend.name, group_name) + invoke_in_main_thread(tray_notification, title, text, tray, window) + icon = util.join_path(util.get_images_directory(), 'icon_new_messages.png') + invoke_in_main_thread(tray.setIcon, QtGui.QIcon(icon)) + + return wrapped + + +def group_self_join(contacts_provider, contacts_manager, groups_service): + def wrapped(tox, group_number, user_data): + group = contacts_provider.get_group_by_number(group_number) + invoke_in_main_thread(group.set_status, TOX_USER_STATUS['NONE']) + invoke_in_main_thread(groups_service.update_group_info, group) + invoke_in_main_thread(contacts_manager.update_filtration) + + return wrapped + + +def group_peer_join(contacts_provider, groups_service): + def wrapped(tox, group_number, peer_id, user_data): + group = contacts_provider.get_group_by_number(group_number) + group.add_peer(peer_id) + invoke_in_main_thread(groups_service.generate_peers_list) + invoke_in_main_thread(groups_service.update_group_info, group) + + return wrapped + + +def group_peer_exit(contacts_provider, groups_service, contacts_manager): + def wrapped(tox, group_number, peer_id, message, length, user_data): + group = contacts_provider.get_group_by_number(group_number) + group.remove_peer(peer_id) + invoke_in_main_thread(groups_service.generate_peers_list) + + return wrapped + + +def group_peer_name(contacts_provider, groups_service): + def wrapped(tox, group_number, peer_id, name, length, user_data): + group = contacts_provider.get_group_by_number(group_number) + peer = group.get_peer_by_id(peer_id) + peer.name = str(name[:length], 'utf-8') + invoke_in_main_thread(groups_service.generate_peers_list) + + return wrapped + + +def group_peer_status(contacts_provider, groups_service): + def wrapped(tox, group_number, peer_id, peer_status, user_data): + group = contacts_provider.get_group_by_number(group_number) + peer = group.get_peer_by_id(peer_id) + peer.status = peer_status + invoke_in_main_thread(groups_service.generate_peers_list) + + return wrapped + + +def group_topic(contacts_provider): + def wrapped(tox, group_number, peer_id, topic, length, user_data): + group = contacts_provider.get_group_by_number(group_number) + topic = str(topic[:length], 'utf-8') + invoke_in_main_thread(group.set_status_message, topic) + + return wrapped + + +def group_moderation(groups_service, contacts_provider, contacts_manager, messenger): + + def update_peer_role(group, mod_peer_id, peer_id, new_role): + peer = group.get_peer_by_id(peer_id) + peer.role = new_role + # TODO: add info message + + def remove_peer(group, mod_peer_id, peer_id, is_ban): + contacts_manager.remove_group_peer_by_id(group, peer_id) + group.remove_peer(peer_id) + # TODO: add info message + + def wrapped(tox, group_number, mod_peer_id, peer_id, event_type, user_data): + group = contacts_provider.get_group_by_number(group_number) + + if event_type == TOX_GROUP_MOD_EVENT['KICK']: + remove_peer(group, mod_peer_id, peer_id, False) + elif event_type == TOX_GROUP_MOD_EVENT['BAN']: + remove_peer(group, mod_peer_id, peer_id, True) + elif event_type == TOX_GROUP_MOD_EVENT['OBSERVER']: + update_peer_role(group, mod_peer_id, peer_id, TOX_GROUP_ROLE['OBSERVER']) + elif event_type == TOX_GROUP_MOD_EVENT['USER']: + update_peer_role(group, mod_peer_id, peer_id, TOX_GROUP_ROLE['USER']) + elif event_type == TOX_GROUP_MOD_EVENT['MODERATOR']: + update_peer_role(group, mod_peer_id, peer_id, TOX_GROUP_ROLE['MODERATOR']) + + invoke_in_main_thread(groups_service.generate_peers_list) + + return wrapped + + +def group_password(contacts_provider): + + def wrapped(tox_link, group_number, password, length, user_data): + password = str(password[:length], 'utf-8') + group = contacts_provider.get_group_by_number(group_number) + group.password = password + + return wrapped + + +def group_peer_limit(contacts_provider): + + def wrapped(tox_link, group_number, peer_limit, user_data): + group = contacts_provider.get_group_by_number(group_number) + group.peer_limit = peer_limit + + return wrapped + + +def group_privacy_state(contacts_provider): + + def wrapped(tox_link, group_number, privacy_state, user_data): + group = contacts_provider.get_group_by_number(group_number) + group.is_private = privacy_state == TOX_GROUP_PRIVACY_STATE['PRIVATE'] + + return wrapped + +# ----------------------------------------------------------------------------------------------------------------- +# Callbacks - initialization +# ----------------------------------------------------------------------------------------------------------------- + + +def init_callbacks(tox, profile, settings, plugin_loader, contacts_manager, + calls_manager, file_transfer_handler, main_window, tray, messenger, groups_service, + contacts_provider): + """ + Initialization of all callbacks. + :param tox: Tox instance + :param profile: Profile instance + :param settings: Settings instance + :param contacts_manager: ContactsManager instance + :param contacts_manager: ContactsManager instance + :param calls_manager: CallsManager instance + :param file_transfer_handler: FileTransferHandler instance + :param plugin_loader: PluginLoader instance + :param main_window: MainWindow instance + :param tray: tray (for notifications) + :param messenger: Messenger instance + :param groups_service: GroupsService instance + :param contacts_provider: ContactsProvider instance + """ + # self callbacks + tox.callback_self_connection_status(self_connection_status(tox, profile)) + + # friend callbacks + tox.callback_friend_status(friend_status(contacts_manager, file_transfer_handler, profile, settings)) + tox.callback_friend_message(friend_message(messenger, contacts_manager, profile, settings, main_window, tray)) + tox.callback_friend_connection_status(friend_connection_status(contacts_manager, profile, settings, plugin_loader, + file_transfer_handler, messenger, calls_manager)) + tox.callback_friend_name(friend_name(contacts_provider, messenger)) + tox.callback_friend_status_message(friend_status_message(contacts_manager, messenger)) + tox.callback_friend_request(friend_request(contacts_manager)) + tox.callback_friend_typing(friend_typing(messenger)) + tox.callback_friend_read_receipt(friend_read_receipt(messenger)) + + # file transfer + tox.callback_file_recv(tox_file_recv(main_window, tray, profile, file_transfer_handler, + contacts_manager, settings)) + tox.callback_file_recv_chunk(file_recv_chunk(file_transfer_handler)) + tox.callback_file_chunk_request(file_chunk_request(file_transfer_handler)) + tox.callback_file_recv_control(file_recv_control(file_transfer_handler)) + + # av + toxav = tox.AV + toxav.callback_call_state(call_state(calls_manager), 0) + toxav.callback_call(call(calls_manager), 0) + toxav.callback_audio_receive_frame(callback_audio(calls_manager), 0) + toxav.callback_video_receive_frame(video_receive_frame, 0) + + # custom packets + tox.callback_friend_lossless_packet(lossless_packet(plugin_loader)) + tox.callback_friend_lossy_packet(lossy_packet(plugin_loader)) + + # gc callbacks + tox.callback_group_message(group_message(main_window, tray, tox, messenger, settings, profile), 0) + tox.callback_group_private_message(group_private_message(main_window, tray, tox, messenger, settings, profile), 0) + tox.callback_group_invite(group_invite(main_window, settings, tray, profile, groups_service, contacts_provider), 0) + tox.callback_group_self_join(group_self_join(contacts_provider, contacts_manager, groups_service), 0) + tox.callback_group_peer_join(group_peer_join(contacts_provider, groups_service), 0) + tox.callback_group_peer_exit(group_peer_exit(contacts_provider, groups_service, contacts_manager), 0) + tox.callback_group_peer_name(group_peer_name(contacts_provider, groups_service), 0) + tox.callback_group_peer_status(group_peer_status(contacts_provider, groups_service), 0) + tox.callback_group_topic(group_topic(contacts_provider), 0) + tox.callback_group_moderation(group_moderation(groups_service, contacts_provider, contacts_manager, messenger), 0) + tox.callback_group_password(group_password(contacts_provider), 0) + tox.callback_group_peer_limit(group_peer_limit(contacts_provider), 0) + tox.callback_group_privacy_state(group_privacy_state(contacts_provider), 0) diff --git a/toxygen/middleware/threads.py b/toxygen/middleware/threads.py new file mode 100644 index 0000000..5f9404b --- /dev/null +++ b/toxygen/middleware/threads.py @@ -0,0 +1,172 @@ +from bootstrap.bootstrap import * +import threading +import queue +from utils import util +import time +from PyQt5 import QtCore + + +# ----------------------------------------------------------------------------------------------------------------- +# Base threads +# ----------------------------------------------------------------------------------------------------------------- + +class BaseThread(threading.Thread): + + def __init__(self): + super().__init__() + self._stop_thread = False + + def stop_thread(self): + self._stop_thread = True + self.join() + + +class BaseQThread(QtCore.QThread): + + def __init__(self): + super().__init__() + self._stop_thread = False + + def stop_thread(self): + self._stop_thread = True + self.wait() + + +# ----------------------------------------------------------------------------------------------------------------- +# Toxcore threads +# ----------------------------------------------------------------------------------------------------------------- + +class InitThread(BaseThread): + + def __init__(self, tox, plugin_loader, settings, is_first_start): + super().__init__() + self._tox, self._plugin_loader, self._settings = tox, plugin_loader, settings + self._is_first_start = is_first_start + + def run(self): + if self._is_first_start: + # download list of nodes if needed + download_nodes_list(self._settings) + # start plugins + self._plugin_loader.load() + + # bootstrap + try: + for data in generate_nodes(): + if self._stop_thread: + return + self._tox.bootstrap(*data) + self._tox.add_tcp_relay(*data) + except: + pass + + for _ in range(10): + if self._stop_thread: + return + time.sleep(1) + + while not self._tox.self_get_connection_status(): + try: + for data in generate_nodes(None): + if self._stop_thread: + return + self._tox.bootstrap(*data) + self._tox.add_tcp_relay(*data) + except: + pass + finally: + time.sleep(5) + + +class ToxIterateThread(BaseQThread): + + def __init__(self, tox): + super().__init__() + self._tox = tox + + def run(self): + while not self._stop_thread: + self._tox.iterate() + time.sleep(self._tox.iteration_interval() / 1000) + + +class ToxAVIterateThread(BaseQThread): + + def __init__(self, toxav): + super().__init__() + self._toxav = toxav + + def run(self): + while not self._stop_thread: + self._toxav.iterate() + time.sleep(self._toxav.iteration_interval() / 1000) + + +# ----------------------------------------------------------------------------------------------------------------- +# File transfers thread +# ----------------------------------------------------------------------------------------------------------------- + +class FileTransfersThread(BaseQThread): + + def __init__(self): + super().__init__() + self._queue = queue.Queue() + self._timeout = 0.01 + + def execute(self, func, *args, **kwargs): + self._queue.put((func, args, kwargs)) + + def run(self): + while not self._stop_thread: + try: + func, args, kwargs = self._queue.get(timeout=self._timeout) + func(*args, **kwargs) + except queue.Empty: + pass + except queue.Full: + util.log('Queue is full in _thread') + except Exception as ex: + util.log('Exception in _thread: ' + str(ex)) + + +_thread = FileTransfersThread() + + +def start_file_transfer_thread(): + _thread.start() + + +def stop_file_transfer_thread(): + _thread.stop_thread() + + +def execute(func, *args, **kwargs): + _thread.execute(func, *args, **kwargs) + + +# ----------------------------------------------------------------------------------------------------------------- +# Invoking in main thread +# ----------------------------------------------------------------------------------------------------------------- + +class InvokeEvent(QtCore.QEvent): + EVENT_TYPE = QtCore.QEvent.Type(QtCore.QEvent.registerEventType()) + + def __init__(self, fn, *args, **kwargs): + QtCore.QEvent.__init__(self, InvokeEvent.EVENT_TYPE) + self.fn = fn + self.args = args + self.kwargs = kwargs + + +class Invoker(QtCore.QObject): + + def event(self, event): + event.fn(*event.args, **event.kwargs) + return True + + +_invoker = Invoker() + + +def invoke_in_main_thread(fn, *args, **kwargs): + QtCore.QCoreApplication.postEvent(_invoker, InvokeEvent(fn, *args, **kwargs)) diff --git a/toxygen/middleware/tox_factory.py b/toxygen/middleware/tox_factory.py new file mode 100644 index 0000000..9ee5c01 --- /dev/null +++ b/toxygen/middleware/tox_factory.py @@ -0,0 +1,34 @@ +import user_data.settings +import wrapper.tox +import wrapper.toxcore_enums_and_consts as enums +import ctypes + + +def tox_factory(data=None, settings=None): + """ + :param data: user data from .tox file. None = no saved data, create new profile + :param settings: current profile settings. None = default settings will be used + :return: new tox instance + """ + if settings is None: + settings = user_data.settings.Settings.get_default_settings() + + tox_options = wrapper.tox.Tox.options_new() + tox_options.contents.udp_enabled = settings['udp_enabled'] + tox_options.contents.proxy_type = settings['proxy_type'] + tox_options.contents.proxy_host = bytes(settings['proxy_host'], 'UTF-8') + tox_options.contents.proxy_port = settings['proxy_port'] + tox_options.contents.start_port = settings['start_port'] + tox_options.contents.end_port = settings['end_port'] + tox_options.contents.tcp_port = settings['tcp_port'] + tox_options.contents.local_discovery_enabled = settings['lan_discovery'] + if data: # load existing profile + tox_options.contents.savedata_type = enums.TOX_SAVEDATA_TYPE['TOX_SAVE'] + tox_options.contents.savedata_data = ctypes.c_char_p(data) + tox_options.contents.savedata_length = len(data) + else: # create new profile + tox_options.contents.savedata_type = enums.TOX_SAVEDATA_TYPE['NONE'] + tox_options.contents.savedata_data = None + tox_options.contents.savedata_length = 0 + + return wrapper.tox.Tox(tox_options) diff --git a/toxygen/network/__init__.py b/toxygen/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/network/tox_dns.py b/toxygen/network/tox_dns.py new file mode 100644 index 0000000..02e97f5 --- /dev/null +++ b/toxygen/network/tox_dns.py @@ -0,0 +1,65 @@ +import json +import urllib.request +import utils.util as util +from PyQt5 import QtNetwork, QtCore + + +class ToxDns: + + def __init__(self, settings): + self._settings = settings + + @staticmethod + def _send_request(url, data): + req = urllib.request.Request(url) + req.add_header('Content-Type', 'application/json') + response = urllib.request.urlopen(req, bytes(json.dumps(data), 'utf-8')) + res = json.loads(str(response.read(), 'utf-8')) + if not res['c']: + return res['tox_id'] + else: + raise LookupError() + + def lookup(self, email): + """ + TOX DNS 4 + :param email: data like 'groupbot@toxme.io' + :return: tox id on success else None + """ + site = email.split('@')[1] + data = {"action": 3, "name": "{}".format(email)} + urls = ('https://{}/api'.format(site), 'http://{}/api'.format(site)) + if not self._settings['proxy_type']: # no proxy + for url in urls: + try: + return self._send_request(url, data) + except Exception as ex: + util.log('TOX DNS ERROR: ' + str(ex)) + else: # proxy + netman = QtNetwork.QNetworkAccessManager() + proxy = QtNetwork.QNetworkProxy() + if self._settings['proxy_type'] == 2: + proxy.setType(QtNetwork.QNetworkProxy.Socks5Proxy) + else: + proxy.setType(QtNetwork.QNetworkProxy.HttpProxy) + proxy.setHostName(self._settings['proxy_host']) + proxy.setPort(self._settings['proxy_port']) + netman.setProxy(proxy) + for url in urls: + try: + request = QtNetwork.QNetworkRequest() + request.setUrl(QtCore.QUrl(url)) + request.setHeader(QtNetwork.QNetworkRequest.ContentTypeHeader, "application/json") + reply = netman.post(request, bytes(json.dumps(data), 'utf-8')) + + while not reply.isFinished(): + QtCore.QThread.msleep(1) + QtCore.QCoreApplication.processEvents() + data = bytes(reply.readAll().data()) + result = json.loads(str(data, 'utf-8')) + if not result['c']: + return result['tox_id'] + except Exception as ex: + util.log('TOX DNS ERROR: ' + str(ex)) + + return None # error diff --git a/toxygen/notifications/__init__.py b/toxygen/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/notifications/sound.py b/toxygen/notifications/sound.py new file mode 100644 index 0000000..361cd05 --- /dev/null +++ b/toxygen/notifications/sound.py @@ -0,0 +1,54 @@ +import utils.util +import wave +import pyaudio +import os.path + + +SOUND_NOTIFICATION = { + 'MESSAGE': 0, + 'FRIEND_CONNECTION_STATUS': 1, + 'FILE_TRANSFER': 2 +} + + +class AudioFile: + chunk = 1024 + + def __init__(self, fl): + self.wf = wave.open(fl, 'rb') + self.p = pyaudio.PyAudio() + self.stream = self.p.open( + format=self.p.get_format_from_width(self.wf.getsampwidth()), + channels=self.wf.getnchannels(), + rate=self.wf.getframerate(), + output=True) + + def play(self): + data = self.wf.readframes(self.chunk) + while data: + self.stream.write(data) + data = self.wf.readframes(self.chunk) + + def close(self): + self.stream.close() + self.p.terminate() + + +def sound_notification(t): + """ + Plays sound notification + :param t: type of notification + """ + if t == SOUND_NOTIFICATION['MESSAGE']: + f = get_file_path('message.wav') + elif t == SOUND_NOTIFICATION['FILE_TRANSFER']: + f = get_file_path('file.wav') + else: + f = get_file_path('contact.wav') + a = AudioFile(f) + a.play() + a.close() + + +def get_file_path(file_name): + return os.path.join(utils.util.get_sounds_directory(), file_name) diff --git a/toxygen/notifications/tray.py b/toxygen/notifications/tray.py new file mode 100644 index 0000000..4232253 --- /dev/null +++ b/toxygen/notifications/tray.py @@ -0,0 +1,22 @@ +from PyQt5 import QtCore, QtWidgets + + +def tray_notification(title, text, tray, window): + """ + Show tray notification and activate window icon + NOTE: different behaviour on different OS + :param title: Name of user who sent message or file + :param text: text of message or file info + :param tray: ref to tray icon + :param window: main window + """ + if QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + if len(text) > 30: + text = text[:27] + '...' + tray.showMessage(title, text, QtWidgets.QSystemTrayIcon.NoIcon, 3000) + QtWidgets.QApplication.alert(window, 0) + + def message_clicked(): + window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + window.activateWindow() + tray.messageClicked.connect(message_clicked) diff --git a/toxygen/plugin_support/__init__.py b/toxygen/plugin_support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/plugin_support/plugin_support.py b/toxygen/plugin_support/plugin_support.py new file mode 100644 index 0000000..ed45910 --- /dev/null +++ b/toxygen/plugin_support/plugin_support.py @@ -0,0 +1,194 @@ +import utils.util as util +import os +import importlib +import inspect +import plugins.plugin_super_class as pl +import sys + + +class Plugin: + + def __init__(self, plugin, is_active): + self._instance = plugin + self._is_active = is_active + + def get_instance(self): + return self._instance + + instance = property(get_instance) + + def get_is_active(self): + return self._is_active + + def set_is_active(self, is_active): + self._is_active = is_active + + is_active = property(get_is_active, set_is_active) + + +class PluginLoader: + + def __init__(self, settings, app): + self._settings = settings + self._app = app + self._plugins = {} # dict. key - plugin unique short name, value - Plugin instance + + def set_tox(self, tox): + """ + New tox instance + """ + for plugin in self._plugins.values(): + plugin.instance.set_tox(tox) + + def load(self): + """ + Load all plugins in plugins folder + """ + path = util.get_plugins_directory() + if not os.path.exists(path): + util.log('Plugin dir not found') + return + else: + sys.path.append(path) + files = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + for fl in files: + if fl in ('plugin_super_class.py', '__init__.py') or not fl.endswith('.py'): + continue + name = fl[:-3] # module name without .py + try: + module = importlib.import_module(name) # import plugin + except ImportError: + util.log('Import error in module ' + name) + continue + except Exception as ex: + util.log('Exception in module ' + name + ' Exception: ' + str(ex)) + continue + for elem in dir(module): + obj = getattr(module, elem) + # looking for plugin class in module + if not inspect.isclass(obj) or not hasattr(obj, 'is_plugin') or not obj.is_plugin: + continue + print('Plugin', elem) + try: # create instance of plugin class + instance = obj(self._app) + is_active = instance.get_short_name() in self._settings['plugins'] + if is_active: + instance.start() + except Exception as ex: + util.log('Exception in module ' + name + ' Exception: ' + str(ex)) + continue + self._plugins[instance.get_short_name()] = Plugin(instance, is_active) + break + + def callback_lossless(self, friend_number, data): + """ + New incoming custom lossless packet (callback) + """ + l = data[0] - pl.LOSSLESS_FIRST_BYTE + name = ''.join(chr(x) for x in data[1:l + 1]) + if name in self._plugins and self._plugins[name].is_active: + self._plugins[name].instance.lossless_packet(''.join(chr(x) for x in data[l + 1:]), friend_number) + + def callback_lossy(self, friend_number, data): + """ + New incoming custom lossy packet (callback) + """ + l = data[0] - pl.LOSSY_FIRST_BYTE + name = ''.join(chr(x) for x in data[1:l + 1]) + if name in self._plugins and self._plugins[name].is_active: + self._plugins[name].instance.lossy_packet(''.join(chr(x) for x in data[l + 1:]), friend_number) + + def friend_online(self, friend_number): + """ + Friend with specified number is online + """ + for plugin in self._plugins.values(): + if plugin.is_active: + plugin.instance.friend_connected(friend_number) + + def get_plugins_list(self): + """ + Returns list of all plugins + """ + result = [] + for plugin in self._plugins.values(): + try: + result.append([plugin.instance.get_name(), # plugin full name + plugin.is_active, # is enabled + plugin.instance.get_description(), # plugin description + plugin.instance.get_short_name()]) # key - short unique name + except: + continue + + return result + + def plugin_window(self, key): + """ + Return window or None for specified plugin + """ + return self._plugins[key].instance.get_window() + + def toggle_plugin(self, key): + """ + Enable/disable plugin + :param key: plugin short name + """ + plugin = self._plugins[key] + if plugin.is_active: + plugin.instance.stop() + else: + plugin.instance.start() + plugin.is_active = not plugin.is_active + if plugin.is_active: + self._settings['plugins'].append(key) + else: + self._settings['plugins'].remove(key) + self._settings.save() + + def command(self, text): + """ + New command for plugin + """ + text = text.strip() + name = text.split()[0] + if name in self._plugins and self._plugins[name].is_active: + self._plugins[name].instance.command(text[len(name) + 1:]) + + def get_menu(self, num): + """ + Return list of items for menu + """ + result = [] + for plugin in self._plugins.values(): + if not plugin.is_active: + continue + try: + result.extend(plugin.instance.get_menu(num)) + except: + continue + return result + + def get_message_menu(self, menu, selected_text): + result = [] + for plugin in self._plugins.values(): + if not plugin.is_active: + continue + try: + result.extend(plugin.instance.get_message_menu(menu, selected_text)) + except: + pass + return result + + def stop(self): + """ + App is closing, stop all plugins + """ + for key in list(self._plugins.keys()): + if self._plugins[key].is_active: + self._plugins[key].instance.close() + del self._plugins[key] + + def reload(self): + print('Reloading plugins') + self.stop() + self.load() diff --git a/toxygen/plugins/plugin_super_class.py b/toxygen/plugins/plugin_super_class.py index c857c56..0056d36 100644 --- a/toxygen/plugins/plugin_super_class.py +++ b/toxygen/plugins/plugin_super_class.py @@ -1,5 +1,7 @@ import os from PyQt5 import QtCore, QtWidgets +import utils.ui as util_ui +import common.tox_save as tox_save MAX_SHORT_NAME_LENGTH = 5 @@ -26,25 +28,22 @@ def log(name, data): fl.write(str(data) + '\n') -class PluginSuperClass: +class PluginSuperClass(tox_save.ToxSave): """ Superclass for all plugins. Plugin is Python3 module with at least one class derived from PluginSuperClass. """ is_plugin = True - def __init__(self, name, short_name, tox=None, profile=None, settings=None, encrypt_save=None): + def __init__(self, name, short_name, app): """ - Constructor. In plugin __init__ should take only 4 last arguments + Constructor. In plugin __init__ should take only 1 last argument :param name: plugin full name :param short_name: plugin unique short name (length of short name should not exceed MAX_SHORT_NAME_LENGTH) - :param tox: tox instance - :param profile: profile instance - :param settings: profile settings - :param encrypt_save: ToxES instance. + :param app: App instance """ - self._settings = settings - self._profile = profile - self._tox = tox + tox = getattr(app, '_tox') + super().__init__(tox) + self._settings = getattr(app, '_settings') name = name.strip() short_name = short_name.strip() if not name or not short_name: @@ -52,7 +51,6 @@ class PluginSuperClass: self._name = name self._short_name = short_name[:MAX_SHORT_NAME_LENGTH] self._translator = None # translator for plugin's GUI - self._encrypt_save = encrypt_save # ----------------------------------------------------------------------------------------------------------------- # Get methods @@ -76,12 +74,11 @@ class PluginSuperClass: """ return self.__doc__ - def get_menu(self, menu, row_number): + def get_menu(self, row_number): """ This method creates items for menu which called on right click in list of friends - :param menu: menu instance :param row_number: number of selected row in list of contacts - :return list of QAction's + :return list of tuples (text, handler) """ return [] @@ -100,12 +97,6 @@ class PluginSuperClass: """ return None - def set_tox(self, tox): - """ - New tox instance - """ - self._tox = tox - # ----------------------------------------------------------------------------------------------------------------- # Plugin was stopped, started or new command received # ----------------------------------------------------------------------------------------------------------------- @@ -134,11 +125,9 @@ class PluginSuperClass: :param command: string with command """ if command == 'help': - msgbox = QtWidgets.QMessageBox() - title = QtWidgets.QApplication.translate("PluginWindow", "List of commands for plugin {}") - msgbox.setWindowTitle(title.format(self._name)) - msgbox.setText(QtWidgets.QApplication.translate("PluginWindow", "No commands available")) - msgbox.exec_() + text = util_ui.tr('No commands available') + title = util_ui.tr('List of commands for plugin {}').format(self._name) + util_ui.message_box(text, title) # ----------------------------------------------------------------------------------------------------------------- # Translations support diff --git a/toxygen/styles/dark_style.qss b/toxygen/styles/dark_style.qss index 0216f23..ece5ec3 100644 --- a/toxygen/styles/dark_style.qss +++ b/toxygen/styles/dark_style.qss @@ -1207,12 +1207,12 @@ MessageItem border: none; } -MessageEdit +MessageBrowser { border: none; } -MessageEdit::focus +MessageBrowser::focus { border: none; } @@ -1222,7 +1222,7 @@ MessageItem::focus border: none; } -MessageEdit:hover +MessageBrowser:hover { border: none; } @@ -1243,7 +1243,7 @@ QPushButton:hover background-color: #1E90FF; } -MessageEdit +MessageBrowser { background-color: transparent; } @@ -1253,7 +1253,7 @@ MessageEdit background-color: #1E90FF; } -#friends_list:item:selected +#friendsListWidget:item:selected { background-color: #333333; } @@ -1277,7 +1277,7 @@ QListWidget > QLabel color: #A9A9A9; } -#contact_name +#searchLineEdit { padding-left: 22px; } @@ -1322,3 +1322,14 @@ ClickableLabel:hover { background-color: #4A4949; } + +#warningLabel +{ + color: #BC1C1C; +} + +#groupInvitesPushButton +{ + background-color: #009c00; +} + diff --git a/toxygen/styles/style.qss b/toxygen/styles/style.qss index 26fbaf2..ff9f614 100644 --- a/toxygen/styles/style.qss +++ b/toxygen/styles/style.qss @@ -1,4 +1,4 @@ -#contact_name +#searchLineEdit { padding-left: 22px; } @@ -27,3 +27,14 @@ MessageEdit { background-color: transparent; } + +#warningLabel +{ + color: #BC1C1C; +} + +#groupInvitesPushButton +{ + background-color: #009c00; +} + diff --git a/toxygen/ui/__init__.py b/toxygen/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/ui/av_widgets.py b/toxygen/ui/av_widgets.py new file mode 100644 index 0000000..e5773a8 --- /dev/null +++ b/toxygen/ui/av_widgets.py @@ -0,0 +1,130 @@ +from PyQt5 import QtCore, QtGui, QtWidgets +from ui import widgets +import utils.util as util +import pyaudio +import wave + + +class IncomingCallWidget(widgets.CenteredWidget): + + def __init__(self, settings, calls_manager, friend_number, text, name): + super().__init__() + self._settings = settings + self._calls_manager = calls_manager + self.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowStaysOnTopHint) + self.resize(QtCore.QSize(500, 270)) + self.avatar_label = QtWidgets.QLabel(self) + self.avatar_label.setGeometry(QtCore.QRect(10, 20, 64, 64)) + self.avatar_label.setScaledContents(False) + self.name = widgets.DataLabel(self) + self.name.setGeometry(QtCore.QRect(90, 20, 300, 25)) + self._friend_number = friend_number + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPointSize(16) + font.setBold(True) + self.name.setFont(font) + self.call_type = widgets.DataLabel(self) + self.call_type.setGeometry(QtCore.QRect(90, 55, 300, 25)) + self.call_type.setFont(font) + self.accept_audio = QtWidgets.QPushButton(self) + self.accept_audio.setGeometry(QtCore.QRect(20, 100, 150, 150)) + self.accept_video = QtWidgets.QPushButton(self) + self.accept_video.setGeometry(QtCore.QRect(170, 100, 150, 150)) + self.decline = QtWidgets.QPushButton(self) + self.decline.setGeometry(QtCore.QRect(320, 100, 150, 150)) + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'accept_audio.png')) + icon = QtGui.QIcon(pixmap) + self.accept_audio.setIcon(icon) + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'accept_video.png')) + icon = QtGui.QIcon(pixmap) + self.accept_video.setIcon(icon) + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'decline_call.png')) + icon = QtGui.QIcon(pixmap) + self.decline.setIcon(icon) + self.accept_audio.setIconSize(QtCore.QSize(150, 150)) + self.accept_video.setIconSize(QtCore.QSize(140, 140)) + self.decline.setIconSize(QtCore.QSize(140, 140)) + self.accept_audio.setStyleSheet("QPushButton { border: none }") + self.accept_video.setStyleSheet("QPushButton { border: none }") + self.decline.setStyleSheet("QPushButton { border: none }") + self.setWindowTitle(text) + self.name.setText(name) + self.call_type.setText(text) + self._processing = False + self.accept_audio.clicked.connect(self.accept_call_with_audio) + self.accept_video.clicked.connect(self.accept_call_with_video) + self.decline.clicked.connect(self.decline_call) + + class SoundPlay(QtCore.QThread): + + def __init__(self): + QtCore.QThread.__init__(self) + self.a = None + + def run(self): + class AudioFile: + chunk = 1024 + + def __init__(self, fl): + self.stop = False + self.fl = fl + self.wf = wave.open(self.fl, 'rb') + self.p = pyaudio.PyAudio() + self.stream = self.p.open( + format=self.p.get_format_from_width(self.wf.getsampwidth()), + channels=self.wf.getnchannels(), + rate=self.wf.getframerate(), + output=True) + + def play(self): + while not self.stop: + data = self.wf.readframes(self.chunk) + while data and not self.stop: + self.stream.write(data) + data = self.wf.readframes(self.chunk) + self.wf = wave.open(self.fl, 'rb') + + def close(self): + self.stream.close() + self.p.terminate() + + self.a = AudioFile(util.join_path(util.get_sounds_directory(), 'call.wav')) + self.a.play() + self.a.close() + + if self._settings['calls_sound']: + self.thread = SoundPlay() + self.thread.start() + else: + self.thread = None + + def stop(self): + if self.thread is not None: + self.thread.a.stop = True + self.thread.wait() + self.close() + + def accept_call_with_audio(self): + if self._processing: + return + self._processing = True + self._calls_manager.accept_call(self._friend_number, True, False) + self.stop() + + def accept_call_with_video(self): + if self._processing: + return + self._processing = True + self._calls_manager.accept_call(self._friend_number, True, True) + self.stop() + + def decline_call(self): + if self._processing: + return + self._processing = True + self._calls_manager.stop_call(self._friend_number, False) + self.stop() + + def set_pixmap(self, pixmap): + self.avatar_label.setPixmap(pixmap) diff --git a/toxygen/ui/contact_items.py b/toxygen/ui/contact_items.py new file mode 100644 index 0000000..7a32284 --- /dev/null +++ b/toxygen/ui/contact_items.py @@ -0,0 +1,97 @@ +from wrapper.toxcore_enums_and_consts import * +from PyQt5 import QtCore, QtGui, QtWidgets +from utils.util import * +from ui.widgets import DataLabel + + +class ContactItem(QtWidgets.QWidget): + """ + Contact in friends list + """ + + def __init__(self, settings, parent=None): + QtWidgets.QWidget.__init__(self, parent) + mode = settings['compact_mode'] + self.setBaseSize(QtCore.QSize(250, 40 if mode else 70)) + self.avatar_label = QtWidgets.QLabel(self) + size = 32 if mode else 64 + self.avatar_label.setGeometry(QtCore.QRect(3, 4, size, size)) + self.avatar_label.setScaledContents(False) + self.avatar_label.setAlignment(QtCore.Qt.AlignCenter) + self.name = DataLabel(self) + self.name.setGeometry(QtCore.QRect(50 if mode else 75, 3 if mode else 10, 150, 15 if mode else 25)) + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPointSize(10 if mode else 12) + font.setBold(True) + self.name.setFont(font) + self.status_message = DataLabel(self) + self.status_message.setGeometry(QtCore.QRect(50 if mode else 75, 20 if mode else 30, 170, 15 if mode else 20)) + font.setPointSize(10) + font.setBold(False) + self.status_message.setFont(font) + self.connection_status = StatusCircle(self) + self.connection_status.setGeometry(QtCore.QRect(230, -2 if mode else 5, 32, 32)) + self.messages = UnreadMessagesCount(settings, self) + self.messages.setGeometry(QtCore.QRect(20 if mode else 52, 20 if mode else 50, 30, 20)) + + +class StatusCircle(QtWidgets.QWidget): + """ + Connection status + """ + def __init__(self, parent): + QtWidgets.QWidget.__init__(self, parent) + self.setGeometry(0, 0, 32, 32) + self.label = QtWidgets.QLabel(self) + self.label.setGeometry(QtCore.QRect(0, 0, 32, 32)) + self.unread = False + + def update(self, status, unread_messages=None): + if unread_messages is None: + unread_messages = self.unread + else: + self.unread = unread_messages + if status == TOX_USER_STATUS['NONE']: + name = 'online' + elif status == TOX_USER_STATUS['AWAY']: + name = 'idle' + elif status == TOX_USER_STATUS['BUSY']: + name = 'busy' + else: + name = 'offline' + if unread_messages: + name += '_notification' + self.label.setGeometry(QtCore.QRect(0, 0, 32, 32)) + else: + self.label.setGeometry(QtCore.QRect(2, 0, 32, 32)) + pixmap = QtGui.QPixmap(join_path(get_images_directory(), '{}.png'.format(name))) + self.label.setPixmap(pixmap) + + +class UnreadMessagesCount(QtWidgets.QWidget): + + def __init__(self, settings, parent=None): + super().__init__(parent) + self._settings = settings + self.resize(30, 20) + self.label = QtWidgets.QLabel(self) + self.label.setGeometry(QtCore.QRect(0, 0, 30, 20)) + self.label.setVisible(False) + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPointSize(12) + font.setBold(True) + self.label.setFont(font) + self.label.setAlignment(QtCore.Qt.AlignVCenter | QtCore.Qt.AlignCenter) + color = settings['unread_color'] + self.label.setStyleSheet('QLabel { color: white; background-color: ' + color + '; border-radius: 10; }') + + def update(self, messages_count): + color = self._settings['unread_color'] + self.label.setStyleSheet('QLabel { color: white; background-color: ' + color + '; border-radius: 10; }') + if messages_count: + self.label.setVisible(True) + self.label.setText(str(messages_count)) + else: + self.label.setVisible(False) diff --git a/toxygen/ui/create_profile_screen.py b/toxygen/ui/create_profile_screen.py new file mode 100644 index 0000000..512c141 --- /dev/null +++ b/toxygen/ui/create_profile_screen.py @@ -0,0 +1,52 @@ +from ui.widgets import * +from PyQt5 import uic +import utils.util as util +import utils.ui as util_ui + + +class CreateProfileScreenResult: + + def __init__(self, save_into_default_folder, password): + self._save_into_default_folder = save_into_default_folder + self._password = password + + def get_save_into_default_folder(self): + return self._save_into_default_folder + + save_into_default_folder = property(get_save_into_default_folder) + + def get_password(self): + return self._password + + password = property(get_password) + + +class CreateProfileScreen(CenteredWidget, DialogWithResult): + + def __init__(self): + CenteredWidget.__init__(self) + DialogWithResult.__init__(self) + uic.loadUi(util.get_views_path('create_profile_screen'), self) + self.center() + self.createProfile.clicked.connect(self._create_profile) + self._retranslate_ui() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('New profile settings')) + self.defaultFolder.setText(util_ui.tr('Save in default folder')) + self.programFolder.setText(util_ui.tr('Save in program folder')) + self.password.setPlaceholderText(util_ui.tr('Password')) + self.confirmPassword.setPlaceholderText(util_ui.tr('Confirm password')) + self.createProfile.setText(util_ui.tr('Create profile')) + self.passwordLabel.setText(util_ui.tr('Password (at least 8 symbols):')) + + def _create_profile(self): + password = self.password.text() + if password != self.confirmPassword.text(): + self.errorLabel.setText(util_ui.tr('Passwords do not match')) + return + if 0 < len(password) < 8: + self.errorLabel.setText(util_ui.tr('Password must be at least 8 symbols')) + return + result = CreateProfileScreenResult(self.defaultFolder.isChecked(), password) + self.close_with_result(result) diff --git a/toxygen/ui/group_bans_widgets.py b/toxygen/ui/group_bans_widgets.py new file mode 100644 index 0000000..b2758c7 --- /dev/null +++ b/toxygen/ui/group_bans_widgets.py @@ -0,0 +1,68 @@ +from ui.widgets import CenteredWidget +from PyQt5 import uic, QtWidgets, QtCore +import utils.util as util +import utils.ui as util_ui + + +class GroupBanItem(QtWidgets.QWidget): + + def __init__(self, ban, cancel_ban, can_cancel_ban, parent=None): + super().__init__(parent) + self._ban = ban + self._cancel_ban = cancel_ban + self._can_cancel_ban = can_cancel_ban + + uic.loadUi(util.get_views_path('gc_ban_item'), self) + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self.banTargetLabel.setText(self._ban.ban_target) + ban_time = self._ban.ban_time + self.banTimeLabel.setText(util.unix_time_to_long_str(ban_time)) + + self.cancelPushButton.clicked.connect(self._cancel_ban) + self.cancelPushButton.setEnabled(self._can_cancel_ban) + + def _retranslate_ui(self): + self.cancelPushButton.setText(util_ui.tr('Cancel ban')) + + def _cancel_ban(self): + self._cancel_ban(self._ban.ban_id) + + +class GroupBansScreen(CenteredWidget): + + def __init__(self, groups_service, group): + super().__init__() + self._groups_service = groups_service + self._group = group + + uic.loadUi(util.get_views_path('bans_list_screen'), self) + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self._refresh_bans_list() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Bans list for group "{}"').format(self._group.name)) + + def _refresh_bans_list(self): + self.bansListWidget.clear() + can_cancel_ban = self._group.is_self_moderator_or_founder() + for ban in self._group.bans: + self._create_ban_item(ban, can_cancel_ban) + + def _create_ban_item(self, ban, can_cancel_ban): + item = GroupBanItem(ban, self._on_ban_cancelled, can_cancel_ban, self.bansListWidget) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(item.width(), item.height())) + self.bansListWidget.addItem(elem) + self.bansListWidget.setItemWidget(elem, item) + + def _on_ban_cancelled(self, ban_id): + self._groups_service.cancel_ban(self._group.number, ban_id) + self._refresh_bans_list() diff --git a/toxygen/ui/group_invites_widgets.py b/toxygen/ui/group_invites_widgets.py new file mode 100644 index 0000000..d35aca1 --- /dev/null +++ b/toxygen/ui/group_invites_widgets.py @@ -0,0 +1,127 @@ +from PyQt5 import uic, QtWidgets +import utils.util as util +from ui.widgets import * + + +class GroupInviteItem(QtWidgets.QWidget): + + def __init__(self, parent, chat_name, avatar, friend_name): + super().__init__(parent) + uic.loadUi(util.get_views_path('gc_invite_item'), self) + + self.groupNameLabel.setText(chat_name) + self.friendNameLabel.setText(friend_name) + self.friendAvatarLabel.setPixmap(avatar) + + def is_selected(self): + return self.selectCheckBox.isChecked() + + def subscribe_checked_event(self, callback): + self.selectCheckBox.clicked.connect(callback) + + +class GroupInvitesScreen(CenteredWidget): + + def __init__(self, groups_service, profile, contacts_provider): + super().__init__() + self._groups_service = groups_service + self._profile = profile + self._contacts_provider = contacts_provider + + uic.loadUi(util.get_views_path('group_invites_screen'), self) + + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self._refresh_invites_list() + + self.nickLineEdit.setText(self._profile.name) + self.statusComboBox.setCurrentIndex(self._profile.status or 0) + + self.nickLineEdit.textChanged.connect(self._nick_changed) + self.acceptPushButton.clicked.connect(self._accept_invites) + self.declinePushButton.clicked.connect(self._decline_invites) + + self.invitesListWidget.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.invitesListWidget.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + self._update_buttons_state() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Group chat invites')) + self.noInvitesLabel.setText(util_ui.tr('No group invites found')) + self.acceptPushButton.setText(util_ui.tr('Accept')) + self.declinePushButton.setText(util_ui.tr('Decline')) + self.statusComboBox.addItem(util_ui.tr('Online')) + self.statusComboBox.addItem(util_ui.tr('Away')) + self.statusComboBox.addItem(util_ui.tr('Busy')) + self.nickLineEdit.setPlaceholderText(util_ui.tr('Your nick in chat')) + self.passwordLineEdit.setPlaceholderText(util_ui.tr('Optional password')) + + def _get_friend(self, public_key): + return self._contacts_provider.get_friend_by_public_key(public_key) + + def _accept_invites(self): + nick = self.nickLineEdit.text() + password = self.passwordLineEdit.text() + status = self.statusComboBox.currentIndex() + + selected_invites = self._get_selected_invites() + for invite in selected_invites: + self._groups_service.accept_group_invite(invite, nick, status, password) + + self._refresh_invites_list() + self._close_window_if_needed() + + def _decline_invites(self): + selected_invites = self._get_selected_invites() + for invite in selected_invites: + self._groups_service.decline_group_invite(invite) + + self._refresh_invites_list() + self._close_window_if_needed() + + def _get_selected_invites(self): + all_invites = self._groups_service.get_group_invites() + selected = [] + items_count = len(all_invites) + for index in range(items_count): + list_item = self.invitesListWidget.item(index) + item_widget = self.invitesListWidget.itemWidget(list_item) + if item_widget.is_selected(): + selected.append(all_invites[index]) + + return selected + + def _refresh_invites_list(self): + self.invitesListWidget.clear() + invites = self._groups_service.get_group_invites() + for invite in invites: + self._create_invite_item(invite) + + def _create_invite_item(self, invite): + friend = self._get_friend(invite.friend_public_key) + item = GroupInviteItem(self.invitesListWidget, invite.chat_name, friend.get_pixmap(), friend.name) + item.subscribe_checked_event(self._item_selected) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(item.width(), item.height())) + self.invitesListWidget.addItem(elem) + self.invitesListWidget.setItemWidget(elem, item) + + def _item_selected(self): + self._update_buttons_state() + + def _nick_changed(self): + self._update_buttons_state() + + def _update_buttons_state(self): + nick = self.nickLineEdit.text() + selected_items = self._get_selected_invites() + self.acceptPushButton.setEnabled(bool(nick) and len(selected_items)) + self.declinePushButton.setEnabled(len(selected_items) > 0) + + def _close_window_if_needed(self): + if self._groups_service.group_invites_count == 0: + self.close() diff --git a/toxygen/ui/group_peers_list.py b/toxygen/ui/group_peers_list.py new file mode 100644 index 0000000..9d2632d --- /dev/null +++ b/toxygen/ui/group_peers_list.py @@ -0,0 +1,33 @@ +from ui.widgets import * +from wrapper.toxcore_enums_and_consts import * + + +class PeerItem(QtWidgets.QWidget): + + def __init__(self, peer, handler, width, parent=None): + super().__init__(parent) + self.resize(QtCore.QSize(width, 34)) + self.nameLabel = DataLabel(self) + self.nameLabel.setGeometry(5, 0, width - 5, 34) + name = peer.name + if peer.is_current_user: + name += util_ui.tr(' (You)') + self.nameLabel.setText(name) + if peer.status == TOX_USER_STATUS['NONE']: + style = 'QLabel {color: green}' + elif peer.status == TOX_USER_STATUS['AWAY']: + style = 'QLabel {color: yellow}' + else: + style = 'QLabel {color: red}' + self.nameLabel.setStyleSheet(style) + self.nameLabel.mousePressEvent = lambda x: handler(peer.id) + + +class PeerTypeItem(QtWidgets.QWidget): + + def __init__(self, text, width, parent=None): + super().__init__(parent) + self.resize(QtCore.QSize(width, 34)) + self.nameLabel = DataLabel(self) + self.nameLabel.setGeometry(5, 0, width - 5, 34) + self.nameLabel.setText(text) diff --git a/toxygen/ui/group_settings_widgets.py b/toxygen/ui/group_settings_widgets.py new file mode 100644 index 0000000..c32168b --- /dev/null +++ b/toxygen/ui/group_settings_widgets.py @@ -0,0 +1,77 @@ +from ui.widgets import CenteredWidget +from PyQt5 import uic +import utils.util as util +import utils.ui as util_ui + + +class GroupManagementScreen(CenteredWidget): + + def __init__(self, groups_service, group): + super().__init__() + self._groups_service = groups_service + self._group = group + + uic.loadUi(util.get_views_path('group_management_screen'), self) + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self.passwordLineEdit.setText(self._group.password) + self.privacyStateComboBox.setCurrentIndex(1 if self._group.is_private else 0) + self.peersLimitSpinBox.setValue(self._group.peers_limit) + + self.savePushButton.clicked.connect(self._save) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Group "{}"').format(self._group.name)) + self.passwordLabel.setText(util_ui.tr('Password:')) + self.peerLimitLabel.setText(util_ui.tr('Peer limit:')) + self.privacyStateLabel.setText(util_ui.tr('Privacy state:')) + self.savePushButton.setText(util_ui.tr('Save')) + + self.privacyStateComboBox.clear() + self.privacyStateComboBox.addItem(util_ui.tr('Public')) + self.privacyStateComboBox.addItem(util_ui.tr('Private')) + + def _save(self): + password = self.passwordLineEdit.text() + privacy_state = self.privacyStateComboBox.currentIndex() + peers_limit = self.peersLimitSpinBox.value() + + self._groups_service.set_group_password(self._group, password) + self._groups_service.set_group_privacy_state(self._group, privacy_state) + self._groups_service.set_group_peers_limit(self._group, peers_limit) + + self.close() + + +class GroupSettingsScreen(CenteredWidget): + + def __init__(self, group): + super().__init__() + self._group = group + + uic.loadUi(util.get_views_path('gc_settings_screen'), self) + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self.copyPasswordPushButton.clicked.connect(self._copy_password) + self.copyPasswordPushButton.setEnabled(bool(self._group.password)) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Group "{}"').format(self._group.name)) + if self._group.password: + password_label_text = '{} {}'.format(util_ui.tr('Password:'), self._group.password) + else: + password_label_text = util_ui.tr('Password is not set') + self.passwordLabel.setText(password_label_text) + self.peerLimitLabel.setText('{} {}'.format(util_ui.tr('Peer limit:'), self._group.peers_limit)) + privacy_state = util_ui.tr('Private') if self._group.is_private else util_ui.tr('Public') + self.privacyStateLabel.setText('{} {}'.format(util_ui.tr('Privacy state:'), privacy_state)) + self.copyPasswordPushButton.setText(util_ui.tr('Copy password')) + + def _copy_password(self): + util_ui.copy_to_clipboard(self._group.password) diff --git a/toxygen/ui/groups_widgets.py b/toxygen/ui/groups_widgets.py new file mode 100644 index 0000000..ad4b703 --- /dev/null +++ b/toxygen/ui/groups_widgets.py @@ -0,0 +1,123 @@ +from PyQt5 import uic +import utils.util as util +from ui.widgets import * +from wrapper.toxcore_enums_and_consts import * + + +class BaseGroupScreen(CenteredWidget): + + def __init__(self, groups_service, profile): + super().__init__() + self._groups_service = groups_service + self._profile = profile + + def _retranslate_ui(self): + self.nickLineEdit.setPlaceholderText(util_ui.tr('Your nick in chat')) + self.nickLabel.setText(util_ui.tr('Nickname:')) + self.statusLabel.setText(util_ui.tr('Status:')) + self.statusComboBox.addItem(util_ui.tr('Online')) + self.statusComboBox.addItem(util_ui.tr('Away')) + self.statusComboBox.addItem(util_ui.tr('Busy')) + + +class CreateGroupScreen(BaseGroupScreen): + + def __init__(self, groups_service, profile): + super().__init__(groups_service, profile) + uic.loadUi(util.get_views_path('create_group_screen'), self) + self.center() + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self.statusComboBox.setCurrentIndex(self._profile.status or 0) + self.nickLineEdit.setText(self._profile.name) + + self.addGroupButton.clicked.connect(self._create_group) + self.groupNameLineEdit.textChanged.connect(self._group_name_changed) + self.nickLineEdit.textChanged.connect(self._nick_changed) + + def _retranslate_ui(self): + super()._retranslate_ui() + self.setWindowTitle(util_ui.tr('Create new group chat')) + self.groupNameLabel.setText(util_ui.tr('Group name:')) + self.groupTypeLabel.setText(util_ui.tr('Group type:')) + self.groupNameLineEdit.setPlaceholderText(util_ui.tr('Group\'s persistent name')) + self.addGroupButton.setText(util_ui.tr('Create group')) + self.groupTypeComboBox.addItem(util_ui.tr('Public')) + self.groupTypeComboBox.addItem(util_ui.tr('Private')) + self.groupTypeComboBox.setCurrentIndex(1) + + def _create_group(self): + group_name = self.groupNameLineEdit.text() + privacy_state = self.groupTypeComboBox.currentIndex() + nick = self.nickLineEdit.text() + status = self.statusComboBox.currentIndex() + self._groups_service.create_new_gc(group_name, privacy_state, nick, status) + self.close() + + def _nick_changed(self): + self._update_button_state() + + def _group_name_changed(self): + self._update_button_state() + + def _update_button_state(self): + is_nick_set = bool(self.nickLineEdit.text()) + is_group_name_set = bool(self.groupNameLineEdit.text()) + self.addGroupButton.setEnabled(is_nick_set and is_group_name_set) + + +class JoinGroupScreen(BaseGroupScreen): + + def __init__(self, groups_service, profile): + super().__init__(groups_service, profile) + uic.loadUi(util.get_views_path('join_group_screen'), self) + self.center() + self._update_ui() + + def _update_ui(self): + self._retranslate_ui() + + self.statusComboBox.setCurrentIndex(self._profile.status or 0) + self.nickLineEdit.setText(self._profile.name) + + self.chatIdLineEdit.textChanged.connect(self._chat_id_changed) + self.joinGroupButton.clicked.connect(self._join_group) + self.nickLineEdit.textChanged.connect(self._nick_changed) + + def _retranslate_ui(self): + super()._retranslate_ui() + self.setWindowTitle(util_ui.tr('Join public group chat')) + self.chatIdLabel.setText(util_ui.tr('Group ID:')) + self.passwordLabel.setText(util_ui.tr('Password:')) + self.chatIdLineEdit.setPlaceholderText(util_ui.tr('Group\'s chat ID')) + self.joinGroupButton.setText(util_ui.tr('Join group')) + self.passwordLineEdit.setPlaceholderText(util_ui.tr('Optional password')) + + def _chat_id_changed(self): + self._update_button_state() + + def _nick_changed(self): + self._update_button_state() + + def _update_button_state(self): + chat_id = self._get_chat_id() + is_nick_set = bool(self.nickLineEdit.text()) + self.joinGroupButton.setEnabled(len(chat_id) == TOX_GROUP_CHAT_ID_SIZE * 2 and is_nick_set) + + def _join_group(self): + chat_id = self._get_chat_id() + password = self.passwordLineEdit.text() + nick = self.nickLineEdit.text() + status = self.statusComboBox.currentIndex() + self._groups_service.join_gc_by_id(chat_id, password, nick, status) + self.close() + + def _get_chat_id(self): + chat_id = self.chatIdLineEdit.text().strip() + if chat_id.startswith('tox:'): + chat_id = chat_id[4:] + + return chat_id diff --git a/toxygen/ui/items_factories.py b/toxygen/ui/items_factories.py new file mode 100644 index 0000000..7346f8f --- /dev/null +++ b/toxygen/ui/items_factories.py @@ -0,0 +1,90 @@ +from ui.contact_items import * +from ui.messages_widgets import * + + +class ContactItemsFactory: + + def __init__(self, settings, main_screen): + self._settings = settings + self._friends_list = main_screen.friends_list + + def create_contact_item(self): + item = ContactItem(self._settings) + elem = QtWidgets.QListWidgetItem(self._friends_list) + elem.setSizeHint(QtCore.QSize(250, 40 if self._settings['compact_mode'] else 70)) + self._friends_list.addItem(elem) + self._friends_list.setItemWidget(elem, item) + + return item + + +class MessagesItemsFactory: + + def __init__(self, settings, plugin_loader, smiley_loader, main_screen, delete_action): + self._file_transfers_handler = None + self._settings, self._plugin_loader = settings, plugin_loader + self._smiley_loader, self._delete_action = smiley_loader, delete_action + self._messages = main_screen.messages + self._message_edit = main_screen.messageEdit + + def set_file_transfers_handler(self, file_transfers_handler): + self._file_transfers_handler = file_transfers_handler + + def create_message_item(self, message, append=True, pixmap=None): + item = message.get_widget(self._settings, self._create_message_browser, + self._delete_action, self._messages) + if pixmap is not None: + item.set_avatar(pixmap) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(self._messages.width(), item.height())) + if append: + self._messages.addItem(elem) + else: + self._messages.insertItem(0, elem) + self._messages.setItemWidget(elem, item) + + return item + + def create_inline_item(self, message, append=True, position=0): + elem = QtWidgets.QListWidgetItem() + item = InlineImageItem(message.data, self._messages.width(), elem, self._messages) + elem.setSizeHint(QtCore.QSize(self._messages.width(), item.height())) + if append: + self._messages.addItem(elem) + else: + self._messages.insertItem(position, elem) + self._messages.setItemWidget(elem, item) + + return item + + def create_unsent_file_item(self, message, append=True): + item = message.get_widget(self._file_transfers_handler, self._settings, self._messages.width(), self._messages) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(self._messages.width() - 30, 34)) + if append: + self._messages.addItem(elem) + else: + self._messages.insertItem(0, elem) + self._messages.setItemWidget(elem, item) + + return item + + def create_file_transfer_item(self, message, append=True): + item = message.get_widget(self._file_transfers_handler, self._settings, self._messages.width(), self._messages) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(self._messages.width() - 30, 34)) + if append: + self._messages.addItem(elem) + else: + self._messages.insertItem(0, elem) + self._messages.setItemWidget(elem, item) + + return item + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _create_message_browser(self, text, width, message_type, parent=None): + return MessageBrowser(self._settings, self._message_edit, self._smiley_loader, self._plugin_loader, + text, width, message_type, parent) diff --git a/toxygen/ui/login_screen.py b/toxygen/ui/login_screen.py new file mode 100644 index 0000000..35e33b5 --- /dev/null +++ b/toxygen/ui/login_screen.py @@ -0,0 +1,77 @@ +from ui.widgets import * +from PyQt5 import uic +import utils.util as util +import utils.ui as util_ui +import os.path + + +class LoginScreenResult: + + def __init__(self, profile_path, load_as_default, password=None): + self._profile_path = profile_path + self._load_as_default = load_as_default + self._password = password + + def get_profile_path(self): + return self._profile_path + + profile_path = property(get_profile_path) + + def get_load_as_default(self): + return self._load_as_default + + load_as_default = property(get_load_as_default) + + def get_password(self): + return self._password + + password = property(get_password) + + def is_new_profile(self): + return not os.path.isfile(self._profile_path) + + +class LoginScreen(CenteredWidget, DialogWithResult): + + def __init__(self): + CenteredWidget.__init__(self) + DialogWithResult.__init__(self) + uic.loadUi(util.get_views_path('login_screen'), self) + self.center() + self._profiles = [] + self._update_ui() + + def update_select(self, profiles): + profiles = sorted(profiles, key=lambda p: p[1]) + self._profiles = list(profiles) + self.profilesComboBox.addItems(list(map(lambda p: p[1], profiles))) + self.loadProfilePushButton.setEnabled(len(profiles) > 0) + + def _update_ui(self): + self.profileNameLineEdit = LineEditWithEnterSupport(self._create_profile, self) + self.profileNameLineEdit.setGeometry(QtCore.QRect(20, 100, 160, 30)) + self._retranslate_ui() + self.createProfilePushButton.clicked.connect(self._create_profile) + self.loadProfilePushButton.clicked.connect(self._load_existing_profile) + + def _create_profile(self): + path = self.profileNameLineEdit.text() + load_as_default = self.defaultProfileCheckBox.isChecked() + result = LoginScreenResult(path, load_as_default) + self.close_with_result(result) + + def _load_existing_profile(self): + index = self.profilesComboBox.currentIndex() + load_as_default = self.defaultProfileCheckBox.isChecked() + path = util.join_path(self._profiles[index][0], self._profiles[index][1] + '.tox') + result = LoginScreenResult(path, load_as_default) + self.close_with_result(result) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Log in')) + self.profileNameLineEdit.setPlaceholderText(util_ui.tr('Profile name')) + self.createProfilePushButton.setText(util_ui.tr('Create')) + self.loadProfilePushButton.setText(util_ui.tr('Load profile')) + self.defaultProfileCheckBox.setText(util_ui.tr('Use as default')) + self.existingProfileGroupBox.setTitle(util_ui.tr('Load existing profile')) + self.newProfileGroupBox.setTitle(util_ui.tr('Create new profile')) diff --git a/toxygen/ui/main_screen.py b/toxygen/ui/main_screen.py new file mode 100644 index 0000000..5a510a5 --- /dev/null +++ b/toxygen/ui/main_screen.py @@ -0,0 +1,718 @@ +from ui.contact_items import * +from ui.widgets import MultilineEdit +from ui.main_screen_widgets import * +import utils.util as util +import utils.ui as util_ui +from PyQt5 import uic + + +class MainWindow(QtWidgets.QMainWindow): + + def __init__(self, settings, tray): + super().__init__() + self._settings = settings + self._contacts_manager = None + self._tray = tray + self._widget_factory = None + self._modal_window = None + self._plugins_loader = None + self.setAcceptDrops(True) + self._saved = False + self._smiley_window = None + self._profile = self._toxes = self._messenger = None + self._file_transfer_handler = self._history_loader = self._groups_service = self._calls_manager = None + self._should_show_group_peers_list = False + self.initUI() + + def set_dependencies(self, widget_factory, tray, contacts_manager, messenger, profile, plugins_loader, + file_transfer_handler, history_loader, calls_manager, groups_service, toxes): + self._widget_factory = widget_factory + self._tray = tray + self._contacts_manager = contacts_manager + self._profile = profile + self._plugins_loader = plugins_loader + self._file_transfer_handler = file_transfer_handler + self._history_loader = history_loader + self._calls_manager = calls_manager + self._groups_service = groups_service + self._toxes = toxes + self._messenger = messenger + self._contacts_manager.active_contact_changed.add_callback(self._new_contact_selected) + self.messageEdit.set_dependencies(messenger, contacts_manager, file_transfer_handler) + + self.update_gc_invites_button_state() + + def show(self): + super().show() + self._contacts_manager.update() + if self._settings['show_welcome_screen']: + self._modal_window = self._widget_factory.create_welcome_window() + + def setup_menu(self, window): + self.menubar = QtWidgets.QMenuBar(window) + self.menubar.setObjectName("menubar") + self.menubar.setNativeMenuBar(False) + self.menubar.setMinimumSize(self.width(), 25) + self.menubar.setMaximumSize(self.width(), 25) + self.menubar.setBaseSize(self.width(), 25) + self.menuProfile = QtWidgets.QMenu(self.menubar) + + self.menuProfile = QtWidgets.QMenu(self.menubar) + self.menuProfile.setObjectName("menuProfile") + self.menuGC = QtWidgets.QMenu(self.menubar) + self.menuSettings = QtWidgets.QMenu(self.menubar) + self.menuSettings.setObjectName("menuSettings") + self.menuPlugins = QtWidgets.QMenu(self.menubar) + self.menuPlugins.setObjectName("menuPlugins") + self.menuAbout = QtWidgets.QMenu(self.menubar) + self.menuAbout.setObjectName("menuAbout") + + self.actionAdd_friend = QtWidgets.QAction(window) + self.actionAdd_friend.setObjectName("actionAdd_friend") + self.actionprofilesettings = QtWidgets.QAction(window) + self.actionprofilesettings.setObjectName("actionprofilesettings") + self.actionPrivacy_settings = QtWidgets.QAction(window) + self.actionPrivacy_settings.setObjectName("actionPrivacy_settings") + self.actionInterface_settings = QtWidgets.QAction(window) + self.actionInterface_settings.setObjectName("actionInterface_settings") + self.actionNotifications = QtWidgets.QAction(window) + self.actionNotifications.setObjectName("actionNotifications") + self.actionNetwork = QtWidgets.QAction(window) + self.actionNetwork.setObjectName("actionNetwork") + self.actionAbout_program = QtWidgets.QAction(window) + self.actionAbout_program.setObjectName("actionAbout_program") + self.updateSettings = QtWidgets.QAction(window) + self.actionSettings = QtWidgets.QAction(window) + self.actionSettings.setObjectName("actionSettings") + self.audioSettings = QtWidgets.QAction(window) + self.videoSettings = QtWidgets.QAction(window) + self.pluginData = QtWidgets.QAction(window) + self.importPlugin = QtWidgets.QAction(window) + self.reloadPlugins = QtWidgets.QAction(window) + self.lockApp = QtWidgets.QAction(window) + self.createGC = QtWidgets.QAction(window) + self.joinGC = QtWidgets.QAction(window) + self.gc_invites = QtWidgets.QAction(window) + + self.menuProfile.addAction(self.actionAdd_friend) + self.menuProfile.addAction(self.actionSettings) + self.menuProfile.addAction(self.lockApp) + self.menuGC.addAction(self.createGC) + self.menuGC.addAction(self.joinGC) + self.menuGC.addAction(self.gc_invites) + self.menuSettings.addAction(self.actionPrivacy_settings) + self.menuSettings.addAction(self.actionInterface_settings) + self.menuSettings.addAction(self.actionNotifications) + self.menuSettings.addAction(self.actionNetwork) + self.menuSettings.addAction(self.audioSettings) + self.menuSettings.addAction(self.videoSettings) + self.menuSettings.addAction(self.updateSettings) + self.menuPlugins.addAction(self.pluginData) + self.menuPlugins.addAction(self.importPlugin) + self.menuPlugins.addAction(self.reloadPlugins) + self.menuAbout.addAction(self.actionAbout_program) + + self.menubar.addAction(self.menuProfile.menuAction()) + self.menubar.addAction(self.menuGC.menuAction()) + self.menubar.addAction(self.menuSettings.menuAction()) + self.menubar.addAction(self.menuPlugins.menuAction()) + self.menubar.addAction(self.menuAbout.menuAction()) + + self.actionAbout_program.triggered.connect(self.about_program) + self.actionNetwork.triggered.connect(self.network_settings) + self.actionAdd_friend.triggered.connect(self.add_contact_triggered) + self.createGC.triggered.connect(self.create_gc) + self.joinGC.triggered.connect(self.join_gc) + self.actionSettings.triggered.connect(self.profile_settings) + self.actionPrivacy_settings.triggered.connect(self.privacy_settings) + self.actionInterface_settings.triggered.connect(self.interface_settings) + self.actionNotifications.triggered.connect(self.notification_settings) + self.audioSettings.triggered.connect(self.audio_settings) + self.videoSettings.triggered.connect(self.video_settings) + self.updateSettings.triggered.connect(self.update_settings) + self.pluginData.triggered.connect(self.plugins_menu) + self.lockApp.triggered.connect(self.lock_app) + self.importPlugin.triggered.connect(self.import_plugin) + self.reloadPlugins.triggered.connect(self.reload_plugins) + self.gc_invites.triggered.connect(self._open_gc_invites_list) + + def languageChange(self, *args, **kwargs): + self.retranslateUi() + + def event(self, event): + if event.type() == QtCore.QEvent.WindowActivate: + self._tray.setIcon(QtGui.QIcon(util.join_path(util.get_images_directory(), 'icon.png'))) + self.messages.repaint() + return super().event(event) + + def retranslateUi(self): + self.lockApp.setText(util_ui.tr("Lock")) + self.menuPlugins.setTitle(util_ui.tr("Plugins")) + self.menuGC.setTitle(util_ui.tr("Group chats")) + self.pluginData.setText(util_ui.tr("List of plugins")) + self.menuProfile.setTitle(util_ui.tr("Profile")) + self.menuSettings.setTitle(util_ui.tr("Settings")) + self.menuAbout.setTitle(util_ui.tr("About")) + self.actionAdd_friend.setText(util_ui.tr("Add contact")) + self.createGC.setText(util_ui.tr("Create group chat")) + self.joinGC.setText(util_ui.tr("Join group chat")) + self.gc_invites.setText(util_ui.tr("Group invites")) + self.actionprofilesettings.setText(util_ui.tr("Profile")) + self.actionPrivacy_settings.setText(util_ui.tr("Privacy")) + self.actionInterface_settings.setText(util_ui.tr("Interface")) + self.actionNotifications.setText(util_ui.tr("Notifications")) + self.actionNetwork.setText(util_ui.tr("Network")) + self.actionAbout_program.setText(util_ui.tr("About program")) + self.actionSettings.setText(util_ui.tr("Settings")) + self.audioSettings.setText(util_ui.tr("Audio")) + self.videoSettings.setText(util_ui.tr("Video")) + self.updateSettings.setText(util_ui.tr("Updates")) + self.importPlugin.setText(util_ui.tr("Import plugin")) + self.reloadPlugins.setText(util_ui.tr("Reload plugins")) + + self.searchLineEdit.setPlaceholderText(util_ui.tr("Search")) + self.sendMessageButton.setToolTip(util_ui.tr("Send message")) + self.callButton.setToolTip(util_ui.tr("Start audio call with friend")) + self.contactsFilterComboBox.clear() + self.contactsFilterComboBox.addItem(util_ui.tr("All")) + self.contactsFilterComboBox.addItem(util_ui.tr("Online")) + self.contactsFilterComboBox.addItem(util_ui.tr("Online first")) + self.contactsFilterComboBox.addItem(util_ui.tr("Name")) + self.contactsFilterComboBox.addItem(util_ui.tr("Online and by name")) + self.contactsFilterComboBox.addItem(util_ui.tr("Online first and by name")) + + def setup_right_bottom(self, Form): + Form.resize(650, 60) + self.messageEdit = MessageArea(Form, self) + self.messageEdit.setGeometry(QtCore.QRect(0, 3, 450, 55)) + font = QtGui.QFont() + font.setPointSize(11) + font.setFamily(self._settings['font']) + self.messageEdit.setFont(font) + + self.sendMessageButton = QtWidgets.QPushButton(Form) + self.sendMessageButton.setGeometry(QtCore.QRect(565, 3, 60, 55)) + + self.menuButton = MenuButton(Form, self.show_menu) + self.menuButton.setGeometry(QtCore.QRect(QtCore.QRect(455, 3, 55, 55))) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'send.png')) + icon = QtGui.QIcon(pixmap) + self.sendMessageButton.setIcon(icon) + self.sendMessageButton.setIconSize(QtCore.QSize(45, 60)) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'menu.png')) + icon = QtGui.QIcon(pixmap) + self.menuButton.setIcon(icon) + self.menuButton.setIconSize(QtCore.QSize(40, 40)) + + self.sendMessageButton.clicked.connect(self.send_message) + + QtCore.QMetaObject.connectSlotsByName(Form) + + def setup_left_column(self, left_column): + uic.loadUi(util.get_views_path('ms_left_column'), left_column) + + pixmap = QtGui.QPixmap() + pixmap.load(util.join_path(util.get_images_directory(), 'search.png')) + left_column.searchLabel.setPixmap(pixmap) + + self.name = DataLabel(left_column) + self.name.setGeometry(QtCore.QRect(75, 15, 150, 25)) + font = QtGui.QFont() + font.setFamily(self._settings['font']) + font.setPointSize(14) + font.setBold(True) + self.name.setFont(font) + + self.status_message = DataLabel(left_column) + self.status_message.setGeometry(QtCore.QRect(75, 35, 170, 25)) + + self.connection_status = StatusCircle(left_column) + self.connection_status.setGeometry(QtCore.QRect(230, 10, 32, 32)) + + left_column.contactsFilterComboBox.activated[int].connect(lambda x: self._filtering()) + + self.avatar_label = left_column.avatarLabel + self.searchLineEdit = left_column.searchLineEdit + self.contacts_filter = self.contactsFilterComboBox = left_column.contactsFilterComboBox + + self.groupInvitesPushButton = left_column.groupInvitesPushButton + + self.groupInvitesPushButton.clicked.connect(self._open_gc_invites_list) + self.avatar_label.mouseReleaseEvent = self.profile_settings + self.status_message.mouseReleaseEvent = self.profile_settings + self.name.mouseReleaseEvent = self.profile_settings + + self.friends_list = left_column.friendsListWidget + self.friends_list.itemSelectionChanged.connect(self._selected_contact_changed) + self.friends_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.friends_list.customContextMenuRequested.connect(self._friend_right_click) + self.friends_list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.friends_list.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + self.friends_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.friends_list.verticalScrollBar().setContextMenuPolicy(QtCore.Qt.NoContextMenu) + + def setup_right_top(self, Form): + Form.resize(650, 75) + self.account_avatar = QtWidgets.QLabel(Form) + self.account_avatar.setGeometry(QtCore.QRect(10, 5, 64, 64)) + self.account_avatar.setScaledContents(False) + self.account_name = DataLabel(Form) + self.account_name.setGeometry(QtCore.QRect(100, 0, 400, 25)) + self.account_name.setTextInteractionFlags(QtCore.Qt.LinksAccessibleByMouse) + font = QtGui.QFont() + font.setFamily(self._settings['font']) + font.setPointSize(14) + font.setBold(True) + self.account_name.setFont(font) + self.account_status = DataLabel(Form) + self.account_status.setGeometry(QtCore.QRect(100, 20, 400, 25)) + self.account_status.setTextInteractionFlags(QtCore.Qt.LinksAccessibleByMouse) + font.setPointSize(12) + font.setBold(False) + self.account_status.setFont(font) + self.account_status.setObjectName("account_status") + self.callButton = QtWidgets.QPushButton(Form) + self.callButton.setGeometry(QtCore.QRect(550, 5, 50, 50)) + self.callButton.setObjectName("callButton") + self.callButton.clicked.connect(lambda: self._calls_manager.call_click(True)) + self.videocallButton = QtWidgets.QPushButton(Form) + self.videocallButton.setGeometry(QtCore.QRect(550, 5, 50, 50)) + self.videocallButton.setObjectName("videocallButton") + self.videocallButton.clicked.connect(lambda: self._calls_manager.call_click(True, True)) + self.groupMenuButton = QtWidgets.QPushButton(Form) + self.groupMenuButton.setGeometry(QtCore.QRect(470, 10, 50, 50)) + self.groupMenuButton.clicked.connect(self._toggle_gc_peers_list) + self.groupMenuButton.setVisible(False) + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'menu.png')) + icon = QtGui.QIcon(pixmap) + self.groupMenuButton.setIcon(icon) + self.groupMenuButton.setIconSize(QtCore.QSize(45, 60)) + self.update_call_state('call') + self.typing = QtWidgets.QLabel(Form) + self.typing.setGeometry(QtCore.QRect(500, 25, 50, 30)) + pixmap = QtGui.QPixmap(QtCore.QSize(50, 30)) + pixmap.load(util.join_path(util.get_images_directory(), 'typing.png')) + self.typing.setScaledContents(False) + self.typing.setPixmap(pixmap.scaled(50, 30, QtCore.Qt.KeepAspectRatio)) + self.typing.setVisible(False) + QtCore.QMetaObject.connectSlotsByName(Form) + + def setup_right_center(self, widget): + self.messages = QtWidgets.QListWidget(widget) + self.messages.setGeometry(0, 0, 620, 310) + self.messages.setObjectName("messages") + self.messages.setSpacing(1) + self.messages.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + self.messages.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.messages.focusOutEvent = lambda event: self.messages.clearSelection() + self.messages.verticalScrollBar().setContextMenuPolicy(QtCore.Qt.NoContextMenu) + + def load(pos): + if not pos: + contact = self._contacts_manager.get_curr_contact() + self._history_loader.load_history(contact) + self.messages.verticalScrollBar().setValue(1) + self.messages.verticalScrollBar().valueChanged.connect(load) + self.messages.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.messages.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + self.peers_list = QtWidgets.QListWidget(widget) + self.peers_list.setGeometry(0, 0, 0, 0) + self.peers_list.setObjectName("peersList") + self.peers_list.setSpacing(1) + self.peers_list.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.peers_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.peers_list.verticalScrollBar().setContextMenuPolicy(QtCore.Qt.NoContextMenu) + self.peers_list.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + + def initUI(self): + self.setMinimumSize(920, 500) + s = self._settings + self.setGeometry(s['x'], s['y'], s['width'], s['height']) + self.setWindowTitle('Toxygen') + menu = QtWidgets.QWidget() + main = QtWidgets.QWidget() + grid = QtWidgets.QGridLayout() + info = QtWidgets.QWidget() + left_column = QtWidgets.QWidget() + messages = QtWidgets.QWidget() + message_buttons = QtWidgets.QWidget() + self.setup_right_center(messages) + self.setup_right_top(info) + self.setup_right_bottom(message_buttons) + self.setup_left_column(left_column) + self.setup_menu(menu) + if not s['mirror_mode']: + grid.addWidget(left_column, 1, 0, 4, 1) + grid.addWidget(messages, 2, 1, 2, 1) + grid.addWidget(info, 1, 1) + grid.addWidget(message_buttons, 4, 1) + grid.setColumnMinimumWidth(1, 500) + grid.setColumnMinimumWidth(0, 270) + else: + grid.addWidget(left_column, 1, 1, 4, 1) + grid.addWidget(messages, 2, 0, 2, 1) + grid.addWidget(info, 1, 0) + grid.addWidget(message_buttons, 4, 0) + grid.setColumnMinimumWidth(0, 500) + grid.setColumnMinimumWidth(1, 270) + + grid.addWidget(menu, 0, 0, 1, 2) + grid.setSpacing(0) + grid.setContentsMargins(0, 0, 0, 0) + grid.setRowMinimumHeight(0, 25) + grid.setRowMinimumHeight(1, 75) + grid.setRowMinimumHeight(2, 25) + grid.setRowMinimumHeight(3, 320) + grid.setRowMinimumHeight(4, 55) + grid.setColumnStretch(1, 1) + grid.setRowStretch(3, 1) + main.setLayout(grid) + self.setCentralWidget(main) + self.messageEdit.setFocus() + self.friend_info = info + self.retranslateUi() + + def closeEvent(self, event): + close_setting = self._settings['close_app'] + if close_setting == 0 or self._settings.closing: + if self._saved: + return + self._saved = True + self._settings['x'] = self.geometry().x() + self._settings['y'] = self.geometry().y() + self._settings['width'] = self.width() + self._settings['height'] = self.height() + self._settings.save() + util_ui.close_all_windows() + event.accept() + elif close_setting == 2 and QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + event.ignore() + self.hide() + else: + event.ignore() + self.showMinimized() + + def close_window(self): + self._settings.closing = True + self.close() + + def resizeEvent(self, *args, **kwargs): + width = self.width() - 270 + if not self._should_show_group_peers_list: + self.messages.setGeometry(0, 0, width, self.height() - 155) + self.peers_list.setGeometry(0, 0, 0, 0) + else: + self.messages.setGeometry(0, 0, width * 3 // 4, self.height() - 155) + self.peers_list.setGeometry(width * 3 // 4, 0, width - width * 3 // 4, self.height() - 155) + + invites_button_visible = self.groupInvitesPushButton.isVisible() + self.friends_list.setGeometry(0, 125 if invites_button_visible else 100, + 270, self.height() - 150 if invites_button_visible else self.height() - 125) + + self.videocallButton.setGeometry(QtCore.QRect(self.width() - 330, 10, 50, 50)) + self.callButton.setGeometry(QtCore.QRect(self.width() - 390, 10, 50, 50)) + self.groupMenuButton.setGeometry(QtCore.QRect(self.width() - 450, 10, 50, 50)) + self.typing.setGeometry(QtCore.QRect(self.width() - 450, 20, 50, 30)) + + self.messageEdit.setGeometry(QtCore.QRect(55, 0, self.width() - 395, 55)) + self.menuButton.setGeometry(QtCore.QRect(0, 0, 55, 55)) + self.sendMessageButton.setGeometry(QtCore.QRect(self.width() - 340, 0, 70, 55)) + + self.account_name.setGeometry(QtCore.QRect(100, 15, self.width() - 560, 25)) + self.account_status.setGeometry(QtCore.QRect(100, 35, self.width() - 560, 25)) + self.messageEdit.setFocus() + + def keyPressEvent(self, event): + key, modifiers = event.key(), event.modifiers() + if key == QtCore.Qt.Key_Escape and QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): + self.hide() + elif key == QtCore.Qt.Key_C and modifiers & QtCore.Qt.ControlModifier and self.messages.selectedIndexes(): + rows = list(map(lambda x: self.messages.row(x), self.messages.selectedItems())) + indexes = (rows[0] - self.messages.count(), rows[-1] - self.messages.count()) + s = self._history_loader.export_history(self._contacts_manager.get_curr_friend(), True, indexes) + self.copy_text(s) + elif key == QtCore.Qt.Key_Z and modifiers & QtCore.Qt.ControlModifier and self.messages.selectedIndexes(): + self.messages.clearSelection() + elif key == QtCore.Qt.Key_F and modifiers & QtCore.Qt.ControlModifier: + self.show_search_field() + else: + super().keyPressEvent(event) + + # ----------------------------------------------------------------------------------------------------------------- + # Functions which called when user click in menu + # ----------------------------------------------------------------------------------------------------------------- + + def about_program(self): + # TODO: replace with window + text = util_ui.tr('Toxygen is Tox client written on Python.\nVersion: ') + text += '' + '\nGitHub: https://github.com/toxygen-project/toxygen/' + title = util_ui.tr('About') + util_ui.message_box(text, title) + + def network_settings(self): + self._modal_window = self._widget_factory.create_network_settings_window() + self._modal_window.show() + + def plugins_menu(self): + self._modal_window = self._widget_factory.create_plugins_settings_window() + self._modal_window.show() + + def add_contact_triggered(self, _): + self.add_contact() + + def add_contact(self, link=''): + self._modal_window = self._widget_factory.create_add_contact_window(link) + self._modal_window.show() + + def create_gc(self): + self._modal_window = self._widget_factory.create_group_screen_window() + self._modal_window.show() + + def join_gc(self): + self._modal_window = self._widget_factory.create_join_group_screen_window() + self._modal_window.show() + + def profile_settings(self, _): + self._modal_window = self._widget_factory.create_profile_settings_window() + self._modal_window.show() + + def privacy_settings(self): + self._modal_window = self._widget_factory.create_privacy_settings_window() + self._modal_window.show() + + def notification_settings(self): + self._modal_window = self._widget_factory.create_notification_settings_window() + self._modal_window.show() + + def interface_settings(self): + self._modal_window = self._widget_factory.create_interface_settings_window() + self._modal_window.show() + + def audio_settings(self): + self._modal_window = self._widget_factory.create_audio_settings_window() + self._modal_window.show() + + def video_settings(self): + self._modal_window = self._widget_factory.create_video_settings_window() + self._modal_window.show() + + def update_settings(self): + self._modal_window = self._widget_factory.create_update_settings_window() + self._modal_window.show() + + def reload_plugins(self): + if self._plugin_loader is not None: + self._plugin_loader.reload() + + @staticmethod + def import_plugin(): + directory = util_ui.directory_dialog(util_ui.tr('Choose folder with plugin')) + if directory: + src = directory + '/' + dest = util.get_plugins_directory() + util.copy(src, dest) + util_ui.message_box(util_ui.tr('Plugin will be loaded after restart'), util_ui.tr("Restart Toxygen")) + + def lock_app(self): + if self._toxes.has_password(): + self._settings.locked = True + self.hide() + else: + util_ui.message_box(util_ui.tr('Error. Profile password is not set.'), util_ui.tr("Cannot lock app")) + + def show_menu(self): + if not hasattr(self, 'menu'): + self.menu = DropdownMenu(self) + self.menu.setGeometry(QtCore.QRect(0 if self._settings['mirror_mode'] else 270, + self.height() - 120, + 180, + 120)) + self.menu.show() + + # ----------------------------------------------------------------------------------------------------------------- + # Messages, calls and file transfers + # ----------------------------------------------------------------------------------------------------------------- + + def send_message(self): + self._messenger.send_message() + + def send_file(self): + self.menu.hide() + if self._contacts_manager.is_active_a_friend(): + caption = util_ui.tr('Choose file') + name = util_ui.file_dialog(caption) + if name[0]: + self._file_transfer_handler.send_file(name[0], self._contacts_manager.get_active_number()) + + def send_screenshot(self, hide=False): + self.menu.hide() + if self._contacts_manager.is_active_a_friend(): + self.sw = self._widget_factory.create_screenshot_window(self) + self.sw.show() + if hide: + self.hide() + + def send_smiley(self): + self.menu.hide() + if self._contacts_manager.get_curr_contact() is None: + return + self._smiley_window = self._widget_factory.create_smiley_window(self) + rect = QtCore.QRect(self.menu.x(), + self.menu.y() - self.menu.height(), + self._smiley_window.width(), + self._smiley_window.height()) + self._smiley_window.setGeometry(rect) + self._smiley_window.show() + + def send_sticker(self): + self.menu.hide() + if self._contacts_manager.is_active_a_friend(): + self.sticker = self._widget_factory.create_sticker_window() + self.sticker.setGeometry(QtCore.QRect(self.x() if self._settings['mirror_mode'] else 270 + self.x(), + self.y() + self.height() - 200, + self.sticker.width(), + self.sticker.height())) + self.sticker.show() + + def active_call(self): + self.update_call_state('finish_call') + + def incoming_call(self): + self.update_call_state('incoming_call') + + def call_finished(self): + self.update_call_state('call') + + def update_call_state(self, state): + pixmap = QtGui.QPixmap(os.path.join(util.get_images_directory(), '{}.png'.format(state))) + icon = QtGui.QIcon(pixmap) + self.callButton.setIcon(icon) + self.callButton.setIconSize(QtCore.QSize(50, 50)) + + pixmap = QtGui.QPixmap(os.path.join(util.get_images_directory(), '{}_video.png'.format(state))) + icon = QtGui.QIcon(pixmap) + self.videocallButton.setIcon(icon) + self.videocallButton.setIconSize(QtCore.QSize(35, 35)) + + # ----------------------------------------------------------------------------------------------------------------- + # Functions which called when user open context menu in friends list + # ----------------------------------------------------------------------------------------------------------------- + + def _friend_right_click(self, pos): + item = self.friends_list.itemAt(pos) + number = self.friends_list.indexFromItem(item).row() + contact = self._contacts_manager.get_contact(number) + if contact is None or item is None: + return + generator = contact.get_context_menu_generator() + self.listMenu = generator.generate(self._plugins_loader, self._contacts_manager, self, self._settings, number, + self._groups_service, self._history_loader) + parent_position = self.friends_list.mapToGlobal(QtCore.QPoint(0, 0)) + self.listMenu.move(parent_position + pos) + self.listMenu.show() + + def show_note(self, friend): + note = self._settings['notes'][friend.tox_id] if friend.tox_id in self._settings['notes'] else '' + user = util_ui.tr('Notes about user') + user = '{} {}'.format(user, friend.name) + + def save_note(text): + if friend.tox_id in self._settings['notes']: + del self._settings['notes'][friend.tox_id] + if text: + self._settings['notes'][friend.tox_id] = text + self._settings.save() + self.note = MultilineEdit(user, note, save_note) + self.note.show() + + def set_alias(self, num): + self._contacts_manager.set_alias(num) + + def remove_friend(self, num): + self._contacts_manager.delete_friend(num) + + def block_friend(self, num): + friend = self._contacts_manager.get_contact(num) + self._contacts_manager.block_user(friend.tox_id) + + @staticmethod + def copy_text(text): + util_ui.copy_to_clipboard(text) + + def auto_accept(self, num, value): + tox_id = self._contacts_manager.friend_public_key(num) + if value: + self._settings['auto_accept_from_friends'].append(tox_id) + else: + self._settings['auto_accept_from_friends'].remove(tox_id) + self._settings.save() + + def invite_friend_to_gc(self, friend_number, group_number): + self._contacts_manager.invite_friend(friend_number, group_number) + + def select_contact_row(self, row_index): + self.friends_list.setCurrentRow(row_index) + + # ----------------------------------------------------------------------------------------------------------------- + # Functions which called when user click somewhere else + # ----------------------------------------------------------------------------------------------------------------- + + def _selected_contact_changed(self): + num = self.friends_list.currentRow() + if self._contacts_manager.active_contact != num: + self._contacts_manager.active_contact = num + self.groupMenuButton.setVisible(self._contacts_manager.is_active_a_group()) + + def mouseReleaseEvent(self, event): + pos = self.connection_status.pos() + x, y = pos.x(), pos.y() + 25 + if (x < event.x() < x + 32) and (y < event.y() < y + 32): + self._profile.change_status() + else: + super().mouseReleaseEvent(event) + + def _filtering(self): + index = self.contactsFilterComboBox.currentIndex() + search_text = self.searchLineEdit.text() + self._contacts_manager.filtration_and_sorting(index, search_text) + + def show_search_field(self): + if hasattr(self, 'search_field') and self.search_field.isVisible(): + return + if self._contacts_manager.get_curr_friend() is None: + return + self.search_field = self._widget_factory.create_search_screen(self.messages) + x, y = self.messages.x(), self.messages.y() + self.messages.height() - 40 + self.search_field.setGeometry(x, y, self.messages.width(), 40) + self.messages.setGeometry(x, self.messages.y(), self.messages.width(), self.messages.height() - 40) + if self._should_show_group_peers_list: + self.peers_list.setFixedHeight(self.peers_list.height() - 40) + self.search_field.show() + + def _toggle_gc_peers_list(self): + self._should_show_group_peers_list = not self._should_show_group_peers_list + self.resizeEvent() + if self._should_show_group_peers_list: + self._groups_service.generate_peers_list() + + def _new_contact_selected(self, _): + if self._should_show_group_peers_list: + self._toggle_gc_peers_list() + index = self.friends_list.currentRow() + if self._contacts_manager.active_contact != index: + self.friends_list.setCurrentRow(self._contacts_manager.active_contact) + self.resizeEvent() + + def _open_gc_invites_list(self): + self._modal_window = self._widget_factory.create_group_invites_window() + self._modal_window.show() + + def update_gc_invites_button_state(self): + invites_count = self._groups_service.group_invites_count + self.groupInvitesPushButton.setVisible(invites_count > 0) + text = util_ui.tr('{} new invites to group chats').format(invites_count) + self.groupInvitesPushButton.setText(text) + self.resizeEvent() diff --git a/toxygen/ui/main_screen_widgets.py b/toxygen/ui/main_screen_widgets.py new file mode 100644 index 0000000..122561b --- /dev/null +++ b/toxygen/ui/main_screen_widgets.py @@ -0,0 +1,496 @@ +from PyQt5 import QtCore, QtGui, QtWidgets +from ui.widgets import RubberBandWindow, create_menu, QRightClickButton, CenteredWidget, LineEdit +import urllib +import re +import utils.util as util +import utils.ui as util_ui +from stickers.stickers import load_stickers + + +class MessageArea(QtWidgets.QPlainTextEdit): + """User types messages here""" + + def __init__(self, parent, form): + super().__init__(parent) + self._messenger = self._contacts_manager = self._file_transfer_handler = None + self.parent = form + self.setAcceptDrops(True) + self._timer = QtCore.QTimer(self) + self._timer.timeout.connect(lambda: self._messenger.send_typing(False)) + + def set_dependencies(self, messenger, contacts_manager, file_transfer_handler): + self._messenger = messenger + self._contacts_manager = contacts_manager + self._file_transfer_handler = file_transfer_handler + + def keyPressEvent(self, event): + if event.matches(QtGui.QKeySequence.Paste): + mimeData = QtWidgets.QApplication.clipboard().mimeData() + if mimeData.hasUrls(): + for url in mimeData.urls(): + self.pasteEvent(url.toString()) + else: + self.pasteEvent() + elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + modifiers = event.modifiers() + if modifiers & QtCore.Qt.ControlModifier or modifiers & QtCore.Qt.ShiftModifier: + self.insertPlainText('\n') + else: + if self._timer.isActive(): + self._timer.stop() + self._messenger.send_typing(False) + self._messenger.send_message() + elif event.key() == QtCore.Qt.Key_Up and not self.toPlainText(): + self.appendPlainText(self._messenger.get_last_message()) + elif event.key() == QtCore.Qt.Key_Tab and self._contacts_manager.is_active_a_group(): + text = self.toPlainText() + text_cursor = self.textCursor() + pos = text_cursor.position() + current_word = re.split("\s+", text[:pos])[-1] + start_index = text.rindex(current_word, 0, pos) + peer_name = self._contacts_manager.get_gc_peer_name(current_word) + self.setPlainText(text[:start_index] + peer_name + text[pos:]) + new_pos = start_index + len(peer_name) + text_cursor.setPosition(new_pos, QtGui.QTextCursor.MoveAnchor) + self.setTextCursor(text_cursor) + else: + self._messenger.send_typing(True) + if self._timer.isActive(): + self._timer.stop() + self._timer.start(5000) + super().keyPressEvent(event) + + def contextMenuEvent(self, event): + menu = create_menu(self.createStandardContextMenu()) + menu.exec_(event.globalPos()) + del menu + + def dragEnterEvent(self, e): + e.accept() + + def dragMoveEvent(self, e): + e.accept() + + def dropEvent(self, e): + if e.mimeData().hasFormat('text/plain') or e.mimeData().hasFormat('text/html'): + e.accept() + self.pasteEvent(e.mimeData().text()) + elif e.mimeData().hasUrls(): + for url in e.mimeData().urls(): + self.pasteEvent(url.toString()) + e.accept() + else: + e.ignore() + + def pasteEvent(self, text=None): + text = text or QtWidgets.QApplication.clipboard().text() + if text.startswith('file://'): + if not self._contacts_manager.is_active_a_friend(): + return + friend_number = self._contacts_manager.get_active_number() + file_path = self._parse_file_path(text) + self._file_transfer_handler.send_file(file_path, friend_number) + else: + self.insertPlainText(text) + + @staticmethod + def _parse_file_path(file_name): + if file_name.endswith('\r\n'): + file_name = file_name[:-2] + file_name = urllib.parse.unquote(file_name) + + return file_name[8 if util.get_platform() == 'Windows' else 7:] + + +class ScreenShotWindow(RubberBandWindow): + + def __init__(self, file_transfer_handler, contacts_manager, *args): + super().__init__(*args) + self._file_transfer_handler = file_transfer_handler + self._contacts_manager = contacts_manager + + def closeEvent(self, *args): + if self.parent.isHidden(): + self.parent.show() + + def mouseReleaseEvent(self, event): + if self.rubberband.isVisible(): + self.rubberband.hide() + rect = self.rubberband.geometry() + if rect.width() and rect.height(): + screen = QtWidgets.QApplication.primaryScreen() + p = screen.grabWindow(0, + rect.x() + 4, + rect.y() + 4, + rect.width() - 8, + rect.height() - 8) + byte_array = QtCore.QByteArray() + buffer = QtCore.QBuffer(byte_array) + buffer.open(QtCore.QIODevice.WriteOnly) + p.save(buffer, 'PNG') + friend = self._contacts_manager.get_curr_contact() + self._file_transfer_handler.send_screenshot(bytes(byte_array.data()), friend.number) + self.close() + + +class SmileyWindow(QtWidgets.QWidget): + """ + Smiley selection window + """ + + def __init__(self, parent, smiley_loader): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + self._parent = parent + self._data = smiley_loader.get_smileys() + + count = len(self._data) + if not count: + self.close() + + self._page_size = int(pow(count / 8, 0.5) + 1) * 8 # smileys per page + if count % self._page_size == 0: + self._page_count = count // self._page_size + else: + self._page_count = round(count / self._page_size + 0.5) + self._page = -1 + self._radio = [] + + for i in range(self._page_count): # pages - radio buttons + elem = QtWidgets.QRadioButton(self) + elem.setGeometry(QtCore.QRect(i * 20 + 5, 160, 20, 20)) + elem.clicked.connect(lambda c, t=i: self._checked(t)) + self._radio.append(elem) + + width = max(self._page_count * 20 + 30, (self._page_size + 5) * 8 // 10) + self.setMaximumSize(width, 200) + self.setMinimumSize(width, 200) + self._buttons = [] + + for i in range(self._page_size): # buttons with smileys + b = QtWidgets.QPushButton(self) + b.setGeometry(QtCore.QRect((i // 8) * 20 + 5, (i % 8) * 20, 20, 20)) + b.clicked.connect(lambda c, t=i: self._clicked(t)) + self._buttons.append(b) + self._checked(0) + + def leaveEvent(self, event): + self.close() + + def _checked(self, pos): # new page opened + self._radio[self._page].setChecked(False) + self._radio[pos].setChecked(True) + self._page = pos + start = self._page * self._page_size + for i in range(self._page_size): + try: + self._buttons[i].setVisible(True) + pixmap = QtGui.QPixmap(self._data[start + i][1]) + icon = QtGui.QIcon(pixmap) + self._buttons[i].setIcon(icon) + except: + self._buttons[i].setVisible(False) + + def _clicked(self, pos): # smiley selected + pos += self._page * self._page_size + smiley = self._data[pos][0] + self._parent.messageEdit.insertPlainText(smiley) + self.close() + + +class MenuButton(QtWidgets.QPushButton): + + def __init__(self, parent, enter): + super().__init__(parent) + self.enter = enter + + def enterEvent(self, event): + self.enter() + super().enterEvent(event) + + +class DropdownMenu(QtWidgets.QWidget): + + def __init__(self, parent): + super().__init__(parent) + self.installEventFilter(self) + self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + self.setMaximumSize(120, 120) + self.setMinimumSize(120, 120) + self.screenshotButton = QRightClickButton(self) + self.screenshotButton.setGeometry(QtCore.QRect(0, 60, 60, 60)) + self.screenshotButton.setObjectName("screenshotButton") + + self.fileTransferButton = QtWidgets.QPushButton(self) + self.fileTransferButton.setGeometry(QtCore.QRect(60, 60, 60, 60)) + self.fileTransferButton.setObjectName("fileTransferButton") + + self.smileyButton = QtWidgets.QPushButton(self) + self.smileyButton.setGeometry(QtCore.QRect(0, 0, 60, 60)) + + self.stickerButton = QtWidgets.QPushButton(self) + self.stickerButton.setGeometry(QtCore.QRect(60, 0, 60, 60)) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'file.png')) + icon = QtGui.QIcon(pixmap) + self.fileTransferButton.setIcon(icon) + self.fileTransferButton.setIconSize(QtCore.QSize(50, 50)) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'screenshot.png')) + icon = QtGui.QIcon(pixmap) + self.screenshotButton.setIcon(icon) + self.screenshotButton.setIconSize(QtCore.QSize(50, 60)) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'smiley.png')) + icon = QtGui.QIcon(pixmap) + self.smileyButton.setIcon(icon) + self.smileyButton.setIconSize(QtCore.QSize(50, 50)) + + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'sticker.png')) + icon = QtGui.QIcon(pixmap) + self.stickerButton.setIcon(icon) + self.stickerButton.setIconSize(QtCore.QSize(55, 55)) + + self.screenshotButton.setToolTip(util_ui.tr("Send screenshot")) + self.fileTransferButton.setToolTip(util_ui.tr("Send file")) + self.smileyButton.setToolTip(util_ui.tr("Add smiley")) + self.stickerButton.setToolTip(util_ui.tr("Send sticker")) + + self.fileTransferButton.clicked.connect(parent.send_file) + self.screenshotButton.clicked.connect(parent.send_screenshot) + self.screenshotButton.rightClicked.connect(lambda: parent.send_screenshot(True)) + self.smileyButton.clicked.connect(parent.send_smiley) + self.stickerButton.clicked.connect(parent.send_sticker) + + def leaveEvent(self, event): + self.close() + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.WindowDeactivate: + self.close() + return False + + +class StickerItem(QtWidgets.QWidget): + + def __init__(self, fl): + super().__init__() + self._image_label = QtWidgets.QLabel(self) + self.path = fl + self.pixmap = QtGui.QPixmap() + self.pixmap.load(fl) + if self.pixmap.width() > 150: + self.pixmap = self.pixmap.scaled(150, 200, QtCore.Qt.KeepAspectRatio) + self.setFixedSize(150, self.pixmap.height()) + self._image_label.setPixmap(self.pixmap) + + +class StickerWindow(QtWidgets.QWidget): + """Sticker selection window""" + + def __init__(self, file_transfer_handler, contacts_manager): + super().__init__() + self._file_transfer_handler = file_transfer_handler + self._contacts_manager = contacts_manager + self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + self.setMaximumSize(250, 200) + self.setMinimumSize(250, 200) + self.list = QtWidgets.QListWidget(self) + self.list.setGeometry(QtCore.QRect(0, 0, 250, 200)) + self._stickers = load_stickers() + for sticker in self._stickers: + item = StickerItem(sticker) + elem = QtWidgets.QListWidgetItem() + elem.setSizeHint(QtCore.QSize(250, item.height())) + self.list.addItem(elem) + self.list.setItemWidget(elem, item) + self.list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.list.setSpacing(3) + self.list.clicked.connect(self.click) + + def click(self, index): + num = index.row() + friend = self._contacts_manager.get_curr_contact() + self._file_transfer_handler.send_sticker(self._stickers[num], friend.number) + self.close() + + def leaveEvent(self, event): + self.close() + + +class WelcomeScreen(CenteredWidget): + + def __init__(self, settings): + super().__init__() + self._settings = settings + self.setMaximumSize(250, 200) + self.setMinimumSize(250, 200) + self.center() + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.text = QtWidgets.QTextBrowser(self) + self.text.setGeometry(QtCore.QRect(0, 0, 250, 170)) + self.text.setOpenExternalLinks(True) + self.checkbox = QtWidgets.QCheckBox(self) + self.checkbox.setGeometry(QtCore.QRect(5, 170, 240, 30)) + self.checkbox.setText(util_ui.tr( "Don't show again")) + self.setWindowTitle(util_ui.tr( 'Tip of the day')) + import random + num = random.randint(0, 10) + if num == 0: + text = util_ui.tr('Press Esc if you want hide app to tray.') + elif num == 1: + text = util_ui.tr('Right click on screenshot button hides app to tray during screenshot.') + elif num == 2: + text = util_ui.tr('You can use Tox over Tor. For more info read this post') + elif num == 3: + text = util_ui.tr('Use Settings -> Interface to customize interface.') + elif num == 4: + text = util_ui.tr('Set profile password via Profile -> Settings. Password allows Toxygen encrypt your history and settings.') + elif num == 5: + text = util_ui.tr('Since v0.1.3 Toxygen supports plugins. Read more') + elif num == 6: + text = util_ui.tr('Toxygen supports faux offline messages and file transfers. Send message or file to offline friend and he will get it later.') + elif num == 7: + text = util_ui.tr('New in Toxygen 0.4.1:
Downloading nodes from tox.chat
Bug fixes') + elif num == 8: + text = util_ui.tr('Delete single message in chat: make right click on spinner or message time and choose "Delete" in menu') + elif num == 9: + text = util_ui.tr( 'Use right click on inline image to save it') + else: + text = util_ui.tr('Set new NoSpam to avoid spam friend requests: Profile -> Settings -> Set new NoSpam.') + self.text.setHtml(text) + self.checkbox.stateChanged.connect(self.not_show) + QtCore.QTimer.singleShot(1000, self.show) + + def not_show(self): + self._settings['show_welcome_screen'] = False + self._settings.save() + + +class MainMenuButton(QtWidgets.QPushButton): + + def __init__(self, *args): + super().__init__(*args) + self.setObjectName("mainmenubutton") + + def setText(self, text): + metrics = QtGui.QFontMetrics(self.font()) + self.setFixedWidth(metrics.size(QtCore.Qt.TextSingleLine, text).width() + 20) + super().setText(text) + + +class ClickableLabel(QtWidgets.QLabel): + + clicked = QtCore.pyqtSignal() + + def __init__(self, *args): + super().__init__(*args) + + def mouseReleaseEvent(self, ev): + self.clicked.emit() + + +class SearchScreen(QtWidgets.QWidget): + + def __init__(self, contacts_manager, history_loader, messages, width, *args): + super().__init__(*args) + self._contacts_manager = contacts_manager + self._history_loader = history_loader + self.setMaximumSize(width, 40) + self.setMinimumSize(width, 40) + self._messages = messages + + self.search_text = LineEdit(self) + self.search_text.setGeometry(0, 0, width - 160, 40) + + self.search_button = ClickableLabel(self) + self.search_button.setGeometry(width - 160, 0, 40, 40) + pixmap = QtGui.QPixmap() + pixmap.load(util.join_path(util.get_images_directory(), 'search.png')) + self.search_button.setScaledContents(False) + self.search_button.setAlignment(QtCore.Qt.AlignCenter) + self.search_button.setPixmap(pixmap) + self.search_button.clicked.connect(self.search) + + font = QtGui.QFont() + font.setPointSize(32) + font.setBold(True) + + self.prev_button = QtWidgets.QPushButton(self) + self.prev_button.setGeometry(width - 120, 0, 40, 40) + self.prev_button.clicked.connect(self.prev) + self.prev_button.setText('\u25B2') + + self.next_button = QtWidgets.QPushButton(self) + self.next_button.setGeometry(width - 80, 0, 40, 40) + self.next_button.clicked.connect(self.next) + self.next_button.setText('\u25BC') + + self.close_button = QtWidgets.QPushButton(self) + self.close_button.setGeometry(width - 40, 0, 40, 40) + self.close_button.clicked.connect(self.close) + self.close_button.setText('×') + self.close_button.setFont(font) + + font.setPointSize(18) + self.next_button.setFont(font) + self.prev_button.setFont(font) + + self.retranslateUi() + + def retranslateUi(self): + self.search_text.setPlaceholderText(util_ui.tr('Search')) + + def show(self): + super().show() + self.search_text.setFocus() + + def search(self): + self._contacts_manager.update() + text = self.search_text.text() + contact = self._contacts_manager.get_curr_contact() + if text and contact and util.is_re_valid(text): + index = contact.search_string(text) + self.load_messages(index) + + def prev(self): + contact = self._contacts_manager.get_curr_contact() + if contact is not None: + index = contact.search_prev() + self.load_messages(index) + + def next(self): + contact = self._contacts_manager.get_curr_contact() + text = self.search_text.text() + if contact is not None: + index = contact.search_next() + if index is not None: + count = self._messages.count() + index += count + item = self._messages.item(index) + self._messages.scrollToItem(item) + self._messages.itemWidget(item).select_text(text) + else: + self.not_found(text) + + def load_messages(self, index): + text = self.search_text.text() + if index is not None: + count = self._messages.count() + while count + index < 0: + self._history_loader.load_history() + count = self._messages.count() + index += count + item = self._messages.item(index) + self._messages.scrollToItem(item) + self._messages.itemWidget(item).select_text(text) + else: + self.not_found(text) + + def closeEvent(self, *args): + self._messages.setGeometry(0, 0, self._messages.width(), self._messages.height() + 40) + super().closeEvent(*args) + + @staticmethod + def not_found(text): + util_ui.message_box(util_ui.tr('Text "{}" was not found').format(text), util_ui.tr('Not found')) diff --git a/toxygen/ui/menu.py b/toxygen/ui/menu.py new file mode 100644 index 0000000..8aec578 --- /dev/null +++ b/toxygen/ui/menu.py @@ -0,0 +1,680 @@ +from PyQt5 import QtCore, QtGui, QtWidgets, uic +from user_data.settings import * +from utils.util import * +from ui.widgets import CenteredWidget, DataLabel, LineEdit, RubberBandWindow +import pyaudio +import updater.updater as updater +import utils.ui as util_ui +import cv2 + + +class AddContact(CenteredWidget): + """Add contact form""" + + def __init__(self, settings, contacts_manager, tox_id=''): + super().__init__() + self._settings = settings + self._contacts_manager = contacts_manager + uic.loadUi(get_views_path('add_contact_screen'), self) + self._update_ui(tox_id) + self._adding = False + + def _update_ui(self, tox_id): + self.toxIdLineEdit = LineEdit(self) + self.toxIdLineEdit.setGeometry(QtCore.QRect(50, 40, 460, 30)) + self.toxIdLineEdit.setText(tox_id) + + self.messagePlainTextEdit.document().setPlainText(util_ui.tr('Hello! Please add me to your contact list.')) + self.addContactPushButton.clicked.connect(self._add_friend) + self._retranslate_ui() + + def _add_friend(self): + if self._adding: + return + self._adding = True + tox_id = self.toxIdLineEdit.text().strip() + if tox_id.startswith('tox:'): + tox_id = tox_id[4:] + message = self.messagePlainTextEdit.toPlainText() + send = self._contacts_manager.send_friend_request(tox_id, message) + self._adding = False + if send is True: + # request was successful + self.close() + else: # print error data + self.errorLabel.setText(send) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Add contact')) + self.addContactPushButton.setText(util_ui.tr('Send request')) + self.toxIdLabel.setText(util_ui.tr('TOX ID:')) + self.messageLabel.setText(util_ui.tr('Message:')) + self.toxIdLineEdit.setPlaceholderText(util_ui.tr('TOX ID or public key of contact')) + + +class NetworkSettings(CenteredWidget): + """Network settings form: UDP, Ipv6 and proxy""" + def __init__(self, settings, reset): + super().__init__() + self._settings = settings + self._reset = reset + uic.loadUi(get_views_path('network_settings_screen'), self) + self._update_ui() + + def _update_ui(self): + self.ipLineEdit = LineEdit(self) + self.ipLineEdit.setGeometry(100, 280, 270, 30) + + self.portLineEdit = LineEdit(self) + self.portLineEdit.setGeometry(100, 325, 270, 30) + + self.restartCorePushButton.clicked.connect(self._restart_core) + self.ipv6CheckBox.setChecked(self._settings['ipv6_enabled']) + self.udpCheckBox.setChecked(self._settings['udp_enabled']) + self.proxyCheckBox.setChecked(self._settings['proxy_type']) + self.ipLineEdit.setText(self._settings['proxy_host']) + self.portLineEdit.setText(str(self._settings['proxy_port'])) + self.httpProxyRadioButton.setChecked(self._settings['proxy_type'] == 1) + self.socksProxyRadioButton.setChecked(self._settings['proxy_type'] != 1) + self.downloadNodesCheckBox.setChecked(self._settings['download_nodes_list']) + self.lanCheckBox.setChecked(self._settings['lan_discovery']) + self._retranslate_ui() + self.proxyCheckBox.stateChanged.connect(lambda x: self._activate_proxy()) + self._activate_proxy() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Network settings")) + self.ipv6CheckBox.setText(util_ui.tr("IPv6")) + self.udpCheckBox.setText(util_ui.tr("UDP")) + self.lanCheckBox.setText(util_ui.tr("LAN")) + self.proxyCheckBox.setText(util_ui.tr("Proxy")) + self.ipLabel.setText(util_ui.tr("IP:")) + self.portLabel.setText(util_ui.tr("Port:")) + self.restartCorePushButton.setText(util_ui.tr("Restart TOX core")) + self.httpProxyRadioButton.setText(util_ui.tr("HTTP")) + self.socksProxyRadioButton.setText(util_ui.tr("Socks 5")) + self.downloadNodesCheckBox.setText(util_ui.tr("Download nodes list from tox.chat")) + self.warningLabel.setText(util_ui.tr("WARNING:\nusing proxy with enabled UDP\ncan produce IP leak")) + + def _activate_proxy(self): + bl = self.proxyCheckBox.isChecked() + self.ipLineEdit.setEnabled(bl) + self.portLineEdit.setEnabled(bl) + self.httpProxyRadioButton.setEnabled(bl) + self.socksProxyRadioButton.setEnabled(bl) + self.ipLabel.setEnabled(bl) + self.portLabel.setEnabled(bl) + + def _restart_core(self): + try: + self._settings['ipv6_enabled'] = self.ipv6CheckBox.isChecked() + self._settings['udp_enabled'] = self.udpCheckBox.isChecked() + proxy_enabled = self.proxyCheckBox.isChecked() + self._settings['proxy_type'] = 2 - int(self.httpProxyRadioButton.isChecked()) if proxy_enabled else 0 + self._settings['proxy_host'] = str(self.ipLineEdit.text()) + self._settings['proxy_port'] = int(self.portLineEdit.text()) + self._settings['download_nodes_list'] = self.downloadNodesCheckBox.isChecked() + self._settings['lan_discovery'] = self.lanCheckBox.isChecked() + self._settings.save() + # recreate tox instance + self._reset() + self.close() + except Exception as ex: + log('Exception in restart: ' + str(ex)) + + +class PrivacySettings(CenteredWidget): + """Privacy settings form: history, typing notifications""" + + def __init__(self, contacts_manager, settings): + """ + :type contacts_manager: ContactsManager + """ + super().__init__() + self._contacts_manager = contacts_manager + self._settings = settings + self.initUI() + self.center() + + def initUI(self): + self.setObjectName("privacySettings") + self.resize(370, 600) + self.setMinimumSize(QtCore.QSize(370, 600)) + self.setMaximumSize(QtCore.QSize(370, 600)) + self.saveHistory = QtWidgets.QCheckBox(self) + self.saveHistory.setGeometry(QtCore.QRect(10, 20, 350, 22)) + self.saveUnsentOnly = QtWidgets.QCheckBox(self) + self.saveUnsentOnly.setGeometry(QtCore.QRect(10, 60, 350, 22)) + + self.fileautoaccept = QtWidgets.QCheckBox(self) + self.fileautoaccept.setGeometry(QtCore.QRect(10, 100, 350, 22)) + + self.typingNotifications = QtWidgets.QCheckBox(self) + self.typingNotifications.setGeometry(QtCore.QRect(10, 140, 350, 30)) + self.inlines = QtWidgets.QCheckBox(self) + self.inlines.setGeometry(QtCore.QRect(10, 180, 350, 30)) + self.auto_path = QtWidgets.QLabel(self) + self.auto_path.setGeometry(QtCore.QRect(10, 230, 350, 30)) + self.path = QtWidgets.QPlainTextEdit(self) + self.path.setGeometry(QtCore.QRect(10, 265, 350, 45)) + self.change_path = QtWidgets.QPushButton(self) + self.change_path.setGeometry(QtCore.QRect(10, 320, 350, 30)) + self.typingNotifications.setChecked(self._settings['typing_notifications']) + self.fileautoaccept.setChecked(self._settings['allow_auto_accept']) + self.saveHistory.setChecked(self._settings['save_history']) + self.inlines.setChecked(self._settings['allow_inline']) + self.saveUnsentOnly.setChecked(self._settings['save_unsent_only']) + self.saveUnsentOnly.setEnabled(self._settings['save_history']) + self.saveHistory.stateChanged.connect(self.update) + self.path.setPlainText(self._settings['auto_accept_path'] or curr_directory()) + self.change_path.clicked.connect(self.new_path) + self.block_user_label = QtWidgets.QLabel(self) + self.block_user_label.setGeometry(QtCore.QRect(10, 360, 350, 30)) + self.block_id = QtWidgets.QPlainTextEdit(self) + self.block_id.setGeometry(QtCore.QRect(10, 390, 350, 30)) + self.block = QtWidgets.QPushButton(self) + self.block.setGeometry(QtCore.QRect(10, 430, 350, 30)) + self.block.clicked.connect(lambda: self._contacts_manager.block_user(self.block_id.toPlainText()) or self.close()) + self.blocked_users_label = QtWidgets.QLabel(self) + self.blocked_users_label.setGeometry(QtCore.QRect(10, 470, 350, 30)) + self.comboBox = QtWidgets.QComboBox(self) + self.comboBox.setGeometry(QtCore.QRect(10, 500, 350, 30)) + self.comboBox.addItems(self._settings['blocked']) + self.unblock = QtWidgets.QPushButton(self) + self.unblock.setGeometry(QtCore.QRect(10, 540, 350, 30)) + self.unblock.clicked.connect(lambda: self.unblock_user()) + self.retranslateUi() + QtCore.QMetaObject.connectSlotsByName(self) + + def retranslateUi(self): + self.setWindowTitle(util_ui.tr("Privacy settings")) + self.saveHistory.setText(util_ui.tr("Save chat history")) + self.fileautoaccept.setText(util_ui.tr("Allow file auto accept")) + self.typingNotifications.setText(util_ui.tr("Send typing notifications")) + self.auto_path.setText(util_ui.tr("Auto accept default path:")) + self.change_path.setText(util_ui.tr("Change")) + self.inlines.setText(util_ui.tr("Allow inlines")) + self.block_user_label.setText(util_ui.tr("Block by public key:")) + self.blocked_users_label.setText(util_ui.tr("Blocked users:")) + self.unblock.setText(util_ui.tr("Unblock")) + self.block.setText(util_ui.tr("Block user")) + self.saveUnsentOnly.setText(util_ui.tr("Save unsent messages only")) + + def update(self, new_state): + self.saveUnsentOnly.setEnabled(new_state) + if not new_state: + self.saveUnsentOnly.setChecked(False) + + def unblock_user(self): + if not self.comboBox.count(): + return + title = util_ui.tr("Add to friend list") + info = util_ui.tr("Do you want to add this user to friend list?") + reply = util_ui.question(info, title) + self._contacts_manager.unblock_user(self.comboBox.currentText(), reply) + self.close() + + def closeEvent(self, event): + self._settings['typing_notifications'] = self.typingNotifications.isChecked() + self._settings['allow_auto_accept'] = self.fileautoaccept.isChecked() + text = util_ui.tr('History will be cleaned! Continue?') + title = util_ui.tr('Chat history') + + if self._settings['save_history'] and not self.saveHistory.isChecked(): # clear history + reply = util_ui.question(text, title) + if reply: + self._history_loader.clear_history() + self._settings['save_history'] = self.saveHistory.isChecked() + else: + self._settings['save_history'] = self.saveHistory.isChecked() + if self.saveUnsentOnly.isChecked() and not self._settings['save_unsent_only']: + reply = util_ui.question(text, title) + if reply: + self._history_loader.clear_history(None, True) + self._settings['save_unsent_only'] = self.saveUnsentOnly.isChecked() + else: + self._settings['save_unsent_only'] = self.saveUnsentOnly.isChecked() + self._settings['auto_accept_path'] = self.path.toPlainText() + self._settings['allow_inline'] = self.inlines.isChecked() + self._settings.save() + + def new_path(self): + directory = util_ui.directory_dialog() + if directory: + self.path.setPlainText(directory) + + +class NotificationsSettings(CenteredWidget): + """Notifications settings form""" + + def __init__(self, setttings): + super().__init__() + self._settings = setttings + uic.loadUi(get_views_path('notifications_settings_screen'), self) + self._update_ui() + self.center() + + def closeEvent(self, *args, **kwargs): + self._settings['notifications'] = self.notificationsCheckBox.isChecked() + self._settings['sound_notifications'] = self.soundNotificationsCheckBox.isChecked() + self._settings['group_notifications'] = self.groupNotificationsCheckBox.isChecked() + self._settings['calls_sound'] = self.callsSoundCheckBox.isChecked() + self._settings.save() + + def _update_ui(self): + self.notificationsCheckBox.setChecked(self._settings['notifications']) + self.soundNotificationsCheckBox.setChecked(self._settings['sound_notifications']) + self.groupNotificationsCheckBox.setChecked(self._settings['group_notifications']) + self.callsSoundCheckBox.setChecked(self._settings['calls_sound']) + self._retranslate_ui() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Notifications settings")) + self.notificationsCheckBox.setText(util_ui.tr("Enable notifications")) + self.groupNotificationsCheckBox.setText(util_ui.tr("Notify about all messages in groups")) + self.callsSoundCheckBox.setText(util_ui.tr("Enable call\'s sound")) + self.soundNotificationsCheckBox.setText(util_ui.tr("Enable sound notifications")) + + +class InterfaceSettings(CenteredWidget): + """Interface settings form""" + + def __init__(self, settings, smiley_loader): + super().__init__() + self._settings = settings + self._smiley_loader = smiley_loader + + uic.loadUi(get_views_path('interface_settings_screen'), self) + self._update_ui() + self.center() + + def _update_ui(self): + themes = list(self._settings.built_in_themes().keys()) + self.themeComboBox.addItems(themes) + theme = self._settings['theme'] + if theme in self._settings.built_in_themes().keys(): + index = themes.index(theme) + else: + index = 0 + self.themeComboBox.setCurrentIndex(index) + + supported_languages = sorted(Settings.supported_languages().keys(), reverse=True) + for key in supported_languages: + self.languageComboBox.insertItem(0, key) + if self._settings['language'] == key: + self.languageComboBox.setCurrentIndex(0) + + smiley_packs = self._smiley_loader.get_packs_list() + self.smileysPackComboBox.addItems(smiley_packs) + try: + index = smiley_packs.index(self._settings['smiley_pack']) + except: + index = smiley_packs.index('default') + self.smileysPackComboBox.setCurrentIndex(index) + + app_closing_setting = self._settings['close_app'] + self.closeRadioButton.setChecked(app_closing_setting == 0) + self.hideRadioButton.setChecked(app_closing_setting == 1) + self.closeToTrayRadioButton.setChecked(app_closing_setting == 2) + + self.compactModeCheckBox.setChecked(self._settings['compact_mode']) + self.showAvatarsCheckBox.setChecked(self._settings['show_avatars']) + self.smileysCheckBox.setChecked(self._settings['smileys']) + + self.importSmileysPushButton.clicked.connect(self._import_smileys) + self.importStickersPushButton.clicked.connect(self._import_stickers) + + self._retranslate_ui() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Interface settings")) + self.showAvatarsCheckBox.setText(util_ui.tr("Show avatars in chat")) + self.themeLabel.setText(util_ui.tr("Theme:")) + self.languageLabel.setText(util_ui.tr("Language:")) + self.smileysGroupBox.setTitle(util_ui.tr("Smileys settings")) + self.smileysPackLabel.setText(util_ui.tr("Smiley pack:")) + self.smileysCheckBox.setText(util_ui.tr("Smileys")) + self.closeRadioButton.setText(util_ui.tr("Close app")) + self.hideRadioButton.setText(util_ui.tr("Hide app")) + self.closeToTrayRadioButton.setText(util_ui.tr("Close to tray")) + self.mirrorModeCheckBox.setText(util_ui.tr("Mirror mode")) + self.compactModeCheckBox.setText(util_ui.tr("Compact contact list")) + self.importSmileysPushButton.setText(util_ui.tr("Import smiley pack")) + self.importStickersPushButton.setText(util_ui.tr("Import sticker pack")) + self.appClosingGroupBox.setTitle(util_ui.tr("App closing settings")) + + @staticmethod + def _import_stickers(): + directory = util_ui.directory_dialog(util_ui.tr('Choose folder with sticker pack')) + if directory: + dest = join_path(get_stickers_directory(), os.path.basename(directory)) + copy(directory, dest) + + @staticmethod + def _import_smileys(): + directory = util_ui.directory_dialog(util_ui.tr('Choose folder with smiley pack')) + if not directory: + return + src = directory + '/' + dest = join_path(get_smileys_directory(), os.path.basename(directory)) + copy(src, dest) + + def closeEvent(self, event): + app = QtWidgets.QApplication.instance() + + self._settings['theme'] = str(self.themeComboBox.currentText()) + try: + theme = self._settings['theme'] + styles_path = join_path(get_styles_directory(), self._settings.built_in_themes()[theme]) + with open(styles_path) as fl: + style = fl.read() + app.setStyleSheet(style) + except IsADirectoryError: + pass + + self._settings['smileys'] = self.smileysCheckBox.isChecked() + + restart = False + if self._settings['mirror_mode'] != self.mirrorModeCheckBox.isChecked(): + self._settings['mirror_mode'] = self.mirrorModeCheckBox.isChecked() + restart = True + + if self._settings['compact_mode'] != self.compactModeCheckBox.isChecked(): + self._settings['compact_mode'] = self.compactModeCheckBox.isChecked() + restart = True + + if self._settings['show_avatars'] != self.showAvatarsCheckBox.isChecked(): + self._settings['show_avatars'] = self.showAvatarsCheckBox.isChecked() + restart = True + + self._settings['smiley_pack'] = self.smileysPackComboBox.currentText() + self._smiley_loader.load_pack() + + language = self.languageComboBox.currentText() + if self._settings['language'] != language: + self._settings['language'] = language + path = Settings.supported_languages()[language] + app.removeTranslator(app.translator) + app.translator.load(join_path(get_translations_directory(), path)) + app.installTranslator(app.translator) + + app_closing_setting = 0 + if self.hideRadioButton.isChecked(): + app_closing_setting = 1 + elif self.closeToTrayRadioButton.isChecked(): + app_closing_setting = 2 + self._settings['close_app'] = app_closing_setting + self._settings.save() + + if restart: + util_ui.message_box(util_ui.tr('Restart app to apply settings'), util_ui.tr('Restart required')) + + +class AudioSettings(CenteredWidget): + """ + Audio calls settings form + """ + + def __init__(self, settings): + super().__init__() + self._settings = settings + self._in_indexes = self._out_indexes = None + uic.loadUi(get_views_path('audio_settings_screen'), self) + self._update_ui() + self.center() + + def closeEvent(self, event): + self._settings.audio['input'] = self._in_indexes[self.inputDeviceComboBox.currentIndex()] + self._settings.audio['output'] = self._out_indexes[self.outputDeviceComboBox.currentIndex()] + self._settings.save() + + def _update_ui(self): + p = pyaudio.PyAudio() + self._in_indexes, self._out_indexes = [], [] + for i in range(p.get_device_count()): + device = p.get_device_info_by_index(i) + if device["maxInputChannels"]: + self.inputDeviceComboBox.addItem(str(device["name"])) + self._in_indexes.append(i) + if device["maxOutputChannels"]: + self.outputDeviceComboBox.addItem(str(device["name"])) + self._out_indexes.append(i) + self.inputDeviceComboBox.setCurrentIndex(self._in_indexes.index(self._settings.audio['input'])) + self.outputDeviceComboBox.setCurrentIndex(self._out_indexes.index(self._settings.audio['output'])) + self._retranslate_ui() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Audio settings")) + self.inputDeviceLabel.setText(util_ui.tr("Input device:")) + self.outputDeviceLabel.setText(util_ui.tr("Output device:")) + + +class DesktopAreaSelectionWindow(RubberBandWindow): + + def mouseReleaseEvent(self, event): + if self.rubberband.isVisible(): + self.rubberband.hide() + rect = self.rubberband.geometry() + width, height = rect.width(), rect.height() + if width >= 8 and height >= 8: + self.parent.save(rect.x(), rect.y(), width - (width % 4), height - (height % 4)) + self.close() + + +class VideoSettings(CenteredWidget): + """ + Audio calls settings form + """ + + def __init__(self, settings): + super().__init__() + self._settings = settings + uic.loadUi(get_views_path('video_settings_screen'), self) + self._devices = self._frame_max_sizes = None + self._update_ui() + self.center() + self.desktopAreaSelection = None + + def closeEvent(self, event): + if self.deviceComboBox.currentIndex() == 0: + return + try: + self._settings.video['device'] = self.devices[self.input.currentIndex()] + text = self.resolutionComboBox.currentText() + self._settings.video['width'] = int(text.split(' ')[0]) + self._settings.video['height'] = int(text.split(' ')[-1]) + self._settings.save() + except Exception as ex: + print('Saving video settings error: ' + str(ex)) + + def save(self, x, y, width, height): + self.desktopAreaSelection = None + self._settings.video['device'] = -1 + self._settings.video['width'] = width + self._settings.video['height'] = height + self._settings.video['x'] = x + self._settings.video['y'] = y + self._settings.save() + + def _update_ui(self): + self.deviceComboBox.currentIndexChanged.connect(self._device_changed) + self.selectRegionPushButton.clicked.connect(self._button_clicked) + self._devices = [-1] + screen = QtWidgets.QApplication.primaryScreen() + size = screen.size() + self._frame_max_sizes = [(size.width(), size.height())] + desktop = util_ui.tr("Desktop") + self.deviceComboBox.addItem(desktop) + for i in range(10): + v = cv2.VideoCapture(i) + if v.isOpened(): + v.set(cv2.CAP_PROP_FRAME_WIDTH, 10000) + v.set(cv2.CAP_PROP_FRAME_HEIGHT, 10000) + + width = int(v.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(v.get(cv2.CAP_PROP_FRAME_HEIGHT)) + del v + self._devices.append(i) + self._frame_max_sizes.append((width, height)) + self.deviceComboBox.addItem(util_ui.tr('Device #') + str(i)) + try: + index = self._devices.index(self._settings.video['device']) + self.deviceComboBox.setCurrentIndex(index) + except: + print('Video devices error!') + self._retranslate_ui() + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Video settings")) + self.deviceLabel.setText(util_ui.tr("Device:")) + self.selectRegionPushButton.setText(util_ui.tr("Select region")) + + def _button_clicked(self): + self.desktopAreaSelection = DesktopAreaSelectionWindow(self) + + def _device_changed(self): + index = self.deviceComboBox.currentIndex() + self.selectRegionPushButton.setVisible(index == 0) + self.resolutionComboBox.setVisible(index != 0) + width, height = self._frame_max_sizes[index] + self.resolutionComboBox.clear() + dims = [ + (320, 240), + (640, 360), + (640, 480), + (720, 480), + (1280, 720), + (1920, 1080), + (2560, 1440) + ] + for w, h in dims: + if w <= width and h <= height: + self.resolutionComboBox.addItem(str(w) + ' * ' + str(h)) + + +class PluginsSettings(CenteredWidget): + """ + Plugins settings form + """ + + def __init__(self, plugin_loader): + super().__init__() + self._plugin_loader = plugin_loader + self._window = None + self.initUI() + self.center() + self.retranslateUi() + + def initUI(self): + self.resize(400, 210) + self.setMinimumSize(QtCore.QSize(400, 210)) + self.setMaximumSize(QtCore.QSize(400, 210)) + self.comboBox = QtWidgets.QComboBox(self) + self.comboBox.setGeometry(QtCore.QRect(30, 10, 340, 30)) + self.label = QtWidgets.QLabel(self) + self.label.setGeometry(QtCore.QRect(30, 40, 340, 90)) + self.label.setWordWrap(True) + self.button = QtWidgets.QPushButton(self) + self.button.setGeometry(QtCore.QRect(30, 130, 340, 30)) + self.button.clicked.connect(self.button_click) + self.open = QtWidgets.QPushButton(self) + self.open.setGeometry(QtCore.QRect(30, 170, 340, 30)) + self.open.clicked.connect(self.open_plugin) + self.update_list() + self.comboBox.currentIndexChanged.connect(self.show_data) + self.show_data() + + def retranslateUi(self): + self.setWindowTitle(util_ui.tr("Plugins")) + self.open.setText(util_ui.tr("Open selected plugin")) + + def open_plugin(self): + ind = self.comboBox.currentIndex() + plugin = self.data[ind] + window = self.pl_loader.plugin_window(plugin[-1]) + if window is not None: + self._window = window + self._window.show() + else: + util_ui.message_box(util_ui.tr('No GUI found for this plugin'), util_ui.tr('Error')) + + def update_list(self): + self.comboBox.clear() + data = self._plugin_loader.get_plugins_list() + self.comboBox.addItems(list(map(lambda x: x[0], data))) + self.data = data + + def show_data(self): + ind = self.comboBox.currentIndex() + if len(self.data): + plugin = self.data[ind] + descr = plugin[2] or util_ui.tr("No description available") + self.label.setText(descr) + if plugin[1]: + self.button.setText(util_ui.tr("Disable plugin")) + else: + self.button.setText(util_ui.tr("Enable plugin")) + else: + self.open.setVisible(False) + self.button.setVisible(False) + self.label.setText(util_ui.tr("No plugins found")) + + def button_click(self): + ind = self.comboBox.currentIndex() + plugin = self.data[ind] + self._plugin_loader.toggle_plugin(plugin[-1]) + plugin[1] = not plugin[1] + if plugin[1]: + self.button.setText(util_ui.tr("Disable plugin")) + else: + self.button.setText(util_ui.tr("Enable plugin")) + + +class UpdateSettings(CenteredWidget): + """ + Updates settings form + """ + + def __init__(self, settings, version): + super().__init__() + self._settings = settings + self._version = version + uic.loadUi(get_views_path('update_settings_screen'), self) + self._update_ui() + self.center() + + def closeEvent(self, event): + self._settings['update'] = self.updateModeComboBox.currentIndex() + self._settings.save() + + def _update_ui(self): + self.updatePushButton.clicked.connect(self._update_client) + self.updateModeComboBox.currentIndexChanged.connect(self._update_mode_changed) + self._retranslate_ui() + self.updateModeComboBox.setCurrentIndex(self._settings['update']) + + def _update_mode_changed(self): + index = self.updateModeComboBox.currentIndex() + self.updatePushButton.setEnabled(index > 0) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Update settings")) + self.updateModeLabel.setText(util_ui.tr("Select update mode:")) + self.updatePushButton.setText(util_ui.tr("Update Toxygen")) + self.updateModeComboBox.addItem(util_ui.tr("Disabled")) + self.updateModeComboBox.addItem(util_ui.tr("Manual")) + self.updateModeComboBox.addItem(util_ui.tr("Auto")) + + def _update_client(self): + if not updater.connection_available(): + util_ui.message_box(util_ui.tr('Problems with internet connection'), util_ui.tr("Error")) + return + if not updater.updater_available(): + util_ui.message_box(util_ui.tr('Updater not found'), util_ui.tr("Error")) + return + version = updater.check_for_updates(self._version, self._settings) + if version is not None: + updater.download(version) + util_ui.close_all_windows() + else: + util_ui.message_box(util_ui.tr('Toxygen is up to date'), util_ui.tr("No updates found")) diff --git a/toxygen/ui/messages_widgets.py b/toxygen/ui/messages_widgets.py new file mode 100644 index 0000000..8a46fd0 --- /dev/null +++ b/toxygen/ui/messages_widgets.py @@ -0,0 +1,449 @@ +from wrapper.toxcore_enums_and_consts import * +import ui.widgets as widgets +import utils.util as util +import ui.menu as menu +import html as h +import re +from ui.widgets import * +from messenger.messages import MESSAGE_AUTHOR +from file_transfers.file_transfers import * + + +class MessageBrowser(QtWidgets.QTextBrowser): + + def __init__(self, settings, message_edit, smileys_loader, plugin_loader, text, width, message_type, parent=None): + super().__init__(parent) + self.urls = {} + self._message_edit = message_edit + self._smileys_loader = smileys_loader + self._plugin_loader = plugin_loader + self._add_contact = None + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setWordWrapMode(QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere) + self.document().setTextWidth(width) + self.setOpenExternalLinks(True) + self.setAcceptRichText(True) + self.setOpenLinks(False) + path = smileys_loader.get_smileys_path() + if path is not None: + self.setSearchPaths([path]) + self.document().setDefaultStyleSheet('a { color: #306EFF; }') + text = self.decoratedText(text) + if message_type != TOX_MESSAGE_TYPE['NORMAL']: + self.setHtml('

' + text + '

') + else: + self.setHtml(text) + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPixelSize(settings['message_font_size']) + font.setBold(False) + self.setFont(font) + self.resize(width, self.document().size().height()) + self.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse | QtCore.Qt.LinksAccessibleByMouse) + self.anchorClicked.connect(self.on_anchor_clicked) + + def contextMenuEvent(self, event): + menu = widgets.create_menu(self.createStandardContextMenu(event.pos())) + quote = menu.addAction(util_ui.tr('Quote selected text')) + quote.triggered.connect(self.quote_text) + text = self.textCursor().selection().toPlainText() + if not text: + quote.setEnabled(False) + else: + sub_menu = self._plugin_loader.get_message_menu(menu, text) + if len(sub_menu): + plugins_menu = menu.addMenu(util_ui.tr('Plugins')) + plugins_menu.addActions(sub_menu) + menu.popup(event.globalPos()) + menu.exec_(event.globalPos()) + del menu + + def quote_text(self): + text = self.textCursor().selection().toPlainText() + if not text: + return + text = '>' + '\n>'.join(text.split('\n')) + if self._message_edit.toPlainText(): + text = '\n' + text + self._message_edit.appendPlainText(text) + + def on_anchor_clicked(self, url): + text = str(url.toString()) + if text.startswith('tox:'): + self._add_contact = menu.AddContact(text[4:]) + self._add_contact.show() + else: + QtGui.QDesktopServices.openUrl(url) + self.clearFocus() + + def addAnimation(self, url, file_name): + movie = QtGui.QMovie(self) + movie.setFileName(file_name) + self.urls[movie] = url + movie.frameChanged[int].connect(lambda x: self.animate(movie)) + movie.start() + + def animate(self, movie): + self.document().addResource(QtGui.QTextDocument.ImageResource, + self.urls[movie], + movie.currentPixmap()) + self.setLineWrapColumnOrWidth(self.lineWrapColumnOrWidth()) + + def decoratedText(self, text): + text = h.escape(text) # replace < and > + exp = QtCore.QRegExp( + '(' + '(?:\\b)((www\\.)|(http[s]?|ftp)://)' + '\\w+\\S+)' + '|(?:\\b)(file:///)([\\S| ]*)' + '|(?:\\b)(tox:[a-zA-Z\\d]{76}$)' + '|(?:\\b)(mailto:\\S+@\\S+\\.\\S+)' + '|(?:\\b)(tox:\\S+@\\S+)') + offset = exp.indexIn(text, 0) + while offset != -1: # add links + url = exp.cap() + if exp.cap(2) == 'www.': + html = '{0}'.format(url) + else: + html = '{0}'.format(url) + text = text[:offset] + html + text[offset + len(exp.cap()):] + offset += len(html) + offset = exp.indexIn(text, offset) + arr = text.split('\n') + for i in range(len(arr)): # quotes + if arr[i].startswith('>'): + arr[i] = '' + arr[i][4:] + '' + text = '
'.join(arr) + text = self._smileys_loader.add_smileys_to_text(text, self) + return text + + +class MessageItem(QtWidgets.QWidget): + """ + Message in messages list + """ + def __init__(self, text_message, settings, message_browser_factory_method, delete_action, parent=None): + QtWidgets.QWidget.__init__(self, parent) + self._message = text_message + self._delete_action = delete_action + self.name = widgets.DataLabel(self) + self.name.setGeometry(QtCore.QRect(2, 2, 95, 23)) + self.name.setTextFormat(QtCore.Qt.PlainText) + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPointSize(11) + font.setBold(True) + if text_message.author is not None: + self.name.setFont(font) + self.name.setText(text_message.author.name) + + self.time = QtWidgets.QLabel(self) + self.time.setGeometry(QtCore.QRect(parent.width() - 60, 0, 50, 25)) + font.setPointSize(10) + font.setBold(False) + self.time.setFont(font) + self._time = text_message.time + if text_message.author and text_message.author.type == MESSAGE_AUTHOR['NOT_SENT']: + movie = QtGui.QMovie(util.join_path(util.get_images_directory(), 'spinner.gif')) + self.time.setMovie(movie) + movie.start() + self.t = True + else: + self.time.setText(util.convert_time(text_message.time)) + self.t = False + + self.message = message_browser_factory_method(text_message.text, parent.width() - 160, + text_message.type, self) + if text_message.type != TOX_MESSAGE_TYPE['NORMAL']: + self.name.setStyleSheet("QLabel { color: #5CB3FF; }") + self.message.setAlignment(QtCore.Qt.AlignCenter) + self.time.setStyleSheet("QLabel { color: #5CB3FF; }") + self.message.setGeometry(QtCore.QRect(100, 0, parent.width() - 160, self.message.height())) + self.setFixedHeight(self.message.height()) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.RightButton and event.x() > self.time.x(): + self.listMenu = QtWidgets.QMenu() + delete_item = self.listMenu.addAction(util_ui.tr('Delete message')) + delete_item.triggered.connect(self.delete) + parent_position = self.time.mapToGlobal(QtCore.QPoint(0, 0)) + self.listMenu.move(parent_position) + self.listMenu.show() + + def delete(self): + self._delete_action(self._message) + + def mark_as_sent(self): + if self.t: + self.time.setText(util.convert_time(self._time)) + self.t = False + return True + return False + + def set_avatar(self, pixmap): + self.name.setAlignment(QtCore.Qt.AlignCenter) + self.message.setAlignment(QtCore.Qt.AlignVCenter) + self.setFixedHeight(max(self.height(), 36)) + self.name.setFixedHeight(self.height()) + self.message.setFixedHeight(self.height()) + self.name.setPixmap(pixmap.scaled(30, 30, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) + + def select_text(self, text): + tmp = self.message.toHtml() + text = h.escape(text) + strings = re.findall(text, tmp, flags=re.IGNORECASE) + for s in strings: + tmp = self.replace_all(tmp, s) + self.message.setHtml(tmp) + + @staticmethod + def replace_all(text, substring): + i, l = 0, len(substring) + while i < len(text) - l + 1: + index = text[i:].find(substring) + if index == -1: + break + i += index + lgt, rgt = text[i:].find('<'), text[i:].find('>') + if rgt < lgt: + i += rgt + 1 + continue + sub = '{}'.format(substring) + text = text[:i] + sub + text[i + l:] + i += len(sub) + return text + + +class FileTransferItem(QtWidgets.QListWidget): + + def __init__(self, transfer_message, file_transfer_handler, settings, width, parent=None): + + QtWidgets.QListWidget.__init__(self, parent) + self._file_transfer_handler = file_transfer_handler + self.resize(QtCore.QSize(width, 34)) + if transfer_message.state == FILE_TRANSFER_STATE['CANCELLED']: + self.setStyleSheet('QListWidget { border: 1px solid #B40404; }') + elif transfer_message.state in PAUSED_FILE_TRANSFERS: + self.setStyleSheet('QListWidget { border: 1px solid #FF8000; }') + else: + self.setStyleSheet('QListWidget { border: 1px solid green; }') + self.state = transfer_message.state + + self.name = DataLabel(self) + self.name.setGeometry(QtCore.QRect(3, 7, 95, 25)) + self.name.setTextFormat(QtCore.Qt.PlainText) + font = QtGui.QFont() + font.setFamily(settings['font']) + font.setPointSize(11) + font.setBold(True) + self.name.setFont(font) + self.name.setText(transfer_message.author.name) + + self.time = QtWidgets.QLabel(self) + self.time.setGeometry(QtCore.QRect(width - 60, 7, 50, 25)) + font.setPointSize(10) + font.setBold(False) + self.time.setFont(font) + self.time.setText(util.convert_time(transfer_message.time)) + + self.cancel = QtWidgets.QPushButton(self) + self.cancel.setGeometry(QtCore.QRect(width - 125, 2, 30, 30)) + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), 'decline.png')) + icon = QtGui.QIcon(pixmap) + self.cancel.setIcon(icon) + self.cancel.setIconSize(QtCore.QSize(30, 30)) + self.cancel.setVisible(transfer_message.state in ACTIVE_FILE_TRANSFERS or + transfer_message.state == FILE_TRANSFER_STATE['UNSENT']) + self.cancel.clicked.connect( + lambda: self.cancel_transfer(transfer_message.friend_number, transfer_message.file_number)) + self.cancel.setStyleSheet('QPushButton:hover { border: 1px solid #3A3939; background-color: none;}') + + self.accept_or_pause = QtWidgets.QPushButton(self) + self.accept_or_pause.setGeometry(QtCore.QRect(width - 170, 2, 30, 30)) + if transfer_message.state == FILE_TRANSFER_STATE['INCOMING_NOT_STARTED']: + self.accept_or_pause.setVisible(True) + self.button_update('accept') + elif transfer_message.state in DO_NOT_SHOW_ACCEPT_BUTTON: + self.accept_or_pause.setVisible(False) + elif transfer_message.state == FILE_TRANSFER_STATE['PAUSED_BY_USER']: # setup for continue + self.accept_or_pause.setVisible(True) + self.button_update('resume') + elif transfer_message.state == FILE_TRANSFER_STATE['UNSENT']: + self.accept_or_pause.setVisible(False) + self.setStyleSheet('QListWidget { border: 1px solid #FF8000; }') + else: # pause + self.accept_or_pause.setVisible(True) + self.button_update('pause') + self.accept_or_pause.clicked.connect( + lambda: self.accept_or_pause_transfer(transfer_message.friend_number, transfer_message.file_number, + transfer_message.size)) + + self.accept_or_pause.setStyleSheet('QPushButton:hover { border: 1px solid #3A3939; background-color: none}') + + self.pb = QtWidgets.QProgressBar(self) + self.pb.setGeometry(QtCore.QRect(100, 7, 100, 20)) + self.pb.setValue(0) + self.pb.setStyleSheet('QProgressBar { background-color: #302F2F; }') + self.pb.setVisible(transfer_message.state in SHOW_PROGRESS_BAR) + + self.file_name = DataLabel(self) + self.file_name.setGeometry(QtCore.QRect(210, 7, width - 420, 20)) + font.setPointSize(12) + self.file_name.setFont(font) + file_size = transfer_message.size // 1024 + if not file_size: + file_size = '{}B'.format(transfer_message.size) + elif file_size >= 1024: + file_size = '{}MB'.format(file_size // 1024) + else: + file_size = '{}KB'.format(file_size) + file_data = '{} {}'.format(file_size, transfer_message.file_name) + self.file_name.setText(file_data) + self.file_name.setToolTip(transfer_message.file_name) + self.saved_name = transfer_message.file_name + self.time_left = QtWidgets.QLabel(self) + self.time_left.setGeometry(QtCore.QRect(width - 92, 7, 30, 20)) + font.setPointSize(10) + self.time_left.setFont(font) + self.time_left.setVisible(transfer_message.state == FILE_TRANSFER_STATE['RUNNING']) + self.setFocusPolicy(QtCore.Qt.NoFocus) + self.paused = False + + def cancel_transfer(self, friend_number, file_number): + self._file_transfer_handler.cancel_transfer(friend_number, file_number) + self.setStyleSheet('QListWidget { border: 1px solid #B40404; }') + self.cancel.setVisible(False) + self.accept_or_pause.setVisible(False) + self.pb.setVisible(False) + + def accept_or_pause_transfer(self, friend_number, file_number, size): + if self.state == FILE_TRANSFER_STATE['INCOMING_NOT_STARTED']: + directory = util_ui.directory_dialog(util_ui.tr('Choose folder')) + self.pb.setVisible(True) + if directory: + self._file_transfer_handler.accept_transfer(directory + '/' + self.saved_name, + friend_number, file_number, size) + self.button_update('pause') + elif self.state == FILE_TRANSFER_STATE['PAUSED_BY_USER']: # resume + self.paused = False + self._file_transfer_handler.resume_transfer(friend_number, file_number) + self.button_update('pause') + self.state = FILE_TRANSFER_STATE['RUNNING'] + else: # pause + self.paused = True + self.state = FILE_TRANSFER_STATE['PAUSED_BY_USER'] + self._file_transfer_handler.pause_transfer(friend_number, file_number) + self.button_update('resume') + self.accept_or_pause.clearFocus() + + def button_update(self, path): + pixmap = QtGui.QPixmap(util.join_path(util.get_images_directory(), '{}.png'.format(path))) + icon = QtGui.QIcon(pixmap) + self.accept_or_pause.setIcon(icon) + self.accept_or_pause.setIconSize(QtCore.QSize(30, 30)) + + def update_transfer_state(self, state, progress, time): + self.pb.setValue(int(progress * 100)) + if time + 1: + m, s = divmod(time, 60) + self.time_left.setText('{0:02d}:{1:02d}'.format(m, s)) + if self.state != state and self.state in ACTIVE_FILE_TRANSFERS: + if state == FILE_TRANSFER_STATE['CANCELLED']: + self.setStyleSheet('QListWidget { border: 1px solid #B40404; }') + self.cancel.setVisible(False) + self.accept_or_pause.setVisible(False) + self.pb.setVisible(False) + self.state = state + self.time_left.setVisible(False) + elif state == FILE_TRANSFER_STATE['FINISHED']: + self.accept_or_pause.setVisible(False) + self.pb.setVisible(False) + self.cancel.setVisible(False) + self.setStyleSheet('QListWidget { border: 1px solid green; }') + self.state = state + self.time_left.setVisible(False) + elif state == FILE_TRANSFER_STATE['PAUSED_BY_FRIEND']: + self.accept_or_pause.setVisible(False) + self.setStyleSheet('QListWidget { border: 1px solid #FF8000; }') + self.state = state + self.time_left.setVisible(False) + elif state == FILE_TRANSFER_STATE['PAUSED_BY_USER']: + self.button_update('resume') # setup button continue + self.setStyleSheet('QListWidget { border: 1px solid green; }') + self.state = state + self.time_left.setVisible(False) + elif state == FILE_TRANSFER_STATE['OUTGOING_NOT_STARTED']: + self.setStyleSheet('QListWidget { border: 1px solid #FF8000; }') + self.accept_or_pause.setVisible(False) + self.time_left.setVisible(False) + self.pb.setVisible(False) + elif not self.paused: # active + self.pb.setVisible(True) + self.accept_or_pause.setVisible(True) # setup to pause + self.button_update('pause') + self.setStyleSheet('QListWidget { border: 1px solid green; }') + self.state = state + self.time_left.setVisible(True) + + +class UnsentFileItem(FileTransferItem): + + def __init__(self, transfer_message, file_transfer_handler, settings, width, parent=None): + super().__init__(transfer_message, file_transfer_handler, settings, width, parent) + self._time = time + movie = QtGui.QMovie(util.join_path(util.get_images_directory(), 'spinner.gif')) + self.time.setMovie(movie) + movie.start() + self._message_id = transfer_message.message_id + self._friend_number = transfer_message.friend_number + + def cancel_transfer(self, *args): + self._file_transfer_handler.cancel_not_started_transfer(self._friend_number, self._message_id) + + +class InlineImageItem(QtWidgets.QScrollArea): + + def __init__(self, data, width, elem, parent=None): + + QtWidgets.QScrollArea.__init__(self, parent) + self.setFocusPolicy(QtCore.Qt.NoFocus) + self._elem = elem + self._image_label = QtWidgets.QLabel(self) + self._image_label.raise_() + self.setWidget(self._image_label) + self._image_label.setScaledContents(False) + self._pixmap = QtGui.QPixmap() + self._pixmap.loadFromData(data, 'PNG') + self._max_size = width - 30 + self._resize_needed = not (self._pixmap.width() <= self._max_size) + self._full_size = not self._resize_needed + if not self._resize_needed: + self._image_label.setPixmap(self._pixmap) + self.resize(QtCore.QSize(self._max_size + 5, self._pixmap.height() + 5)) + self._image_label.setGeometry(5, 0, self._pixmap.width(), self._pixmap.height()) + else: + pixmap = self._pixmap.scaled(self._max_size, self._max_size, QtCore.Qt.KeepAspectRatio) + self._image_label.setPixmap(pixmap) + self.resize(QtCore.QSize(self._max_size + 5, pixmap.height())) + self._image_label.setGeometry(5, 0, self._max_size + 5, pixmap.height()) + self._elem.setSizeHint(QtCore.QSize(self.width(), self.height())) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton and self._resize_needed: # scale inline + if self._full_size: + pixmap = self._pixmap.scaled(self._max_size, self._max_size, QtCore.Qt.KeepAspectRatio) + self._image_label.setPixmap(pixmap) + self.resize(QtCore.QSize(self._max_size, pixmap.height())) + self._image_label.setGeometry(5, 0, pixmap.width(), pixmap.height()) + else: + self._image_label.setPixmap(self._pixmap) + self.resize(QtCore.QSize(self._max_size, self._pixmap.height() + 17)) + self._image_label.setGeometry(5, 0, self._pixmap.width(), self._pixmap.height()) + self._full_size = not self._full_size + self._elem.setSizeHint(QtCore.QSize(self.width(), self.height())) + elif event.button() == QtCore.Qt.RightButton: # save inline + directory = util_ui.directory_dialog(util_ui.tr('Choose folder')) + if directory: + fl = QtCore.QFile(directory + '/toxygen_inline_' + util.curr_time().replace(':', '_') + '.png') + self._pixmap.save(fl, 'PNG') diff --git a/toxygen/ui/password_screen.py b/toxygen/ui/password_screen.py new file mode 100644 index 0000000..bbae7ff --- /dev/null +++ b/toxygen/ui/password_screen.py @@ -0,0 +1,153 @@ +from ui.widgets import CenteredWidget, LineEdit, DialogWithResult +from PyQt5 import QtCore, QtWidgets +import utils.ui as util_ui + + +class PasswordArea(LineEdit): + + def __init__(self, parent): + super().__init__(parent) + self._parent = parent + self.setEchoMode(QtWidgets.QLineEdit.Password) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Return: + self._parent.button_click() + else: + super().keyPressEvent(event) + + +class PasswordScreenBase(CenteredWidget, DialogWithResult): + + def __init__(self, encrypt): + CenteredWidget.__init__(self) + DialogWithResult.__init__(self) + self._encrypt = encrypt + self.initUI() + + def initUI(self): + self.resize(360, 170) + self.setMinimumSize(QtCore.QSize(360, 170)) + self.setMaximumSize(QtCore.QSize(360, 170)) + + self.enter_pass = QtWidgets.QLabel(self) + self.enter_pass.setGeometry(QtCore.QRect(30, 10, 300, 30)) + + self.password = PasswordArea(self) + self.password.setGeometry(QtCore.QRect(30, 50, 300, 30)) + + self.button = QtWidgets.QPushButton(self) + self.button.setGeometry(QtCore.QRect(30, 90, 300, 30)) + self.button.setText(util_ui.tr('OK')) + self.button.clicked.connect(self.button_click) + + self.warning = QtWidgets.QLabel(self) + self.warning.setGeometry(QtCore.QRect(30, 130, 300, 30)) + self.warning.setStyleSheet('QLabel { color: #F70D1A; }') + self.warning.setVisible(False) + + self.retranslateUi() + self.center() + QtCore.QMetaObject.connectSlotsByName(self) + + def button_click(self): + pass + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Enter: + self.button_click() + else: + super(PasswordScreenBase, self).keyPressEvent(event) + + def retranslateUi(self): + self.setWindowTitle(util_ui.tr('Enter password')) + self.enter_pass.setText(util_ui.tr('Password:')) + self.warning.setText(util_ui.tr('Incorrect password')) + + +class PasswordScreen(PasswordScreenBase): + + def __init__(self, encrypt, data): + super().__init__(encrypt) + self._data = data + + def button_click(self): + if self.password.text(): + try: + self._encrypt.set_password(self.password.text()) + new_data = self._encrypt.pass_decrypt(self._data) + except Exception as ex: + self.warning.setVisible(True) + print('Decryption error:', ex) + else: + self.close_with_result(new_data) + + +class UnlockAppScreen(PasswordScreenBase): + + def __init__(self, encrypt, callback): + super(UnlockAppScreen, self).__init__(encrypt) + self._callback = callback + self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + + def button_click(self): + if self.password.text(): + if self._encrypt.is_password(self.password.text()): + self._callback() + self.close() + else: + self.warning.setVisible(True) + print('Wrong password!') + + +class SetProfilePasswordScreen(CenteredWidget): + + def __init__(self, encrypt): + super(SetProfilePasswordScreen, self).__init__() + self._encrypt = encrypt + self.initUI() + self.retranslateUi() + self.center() + + def initUI(self): + self.setMinimumSize(QtCore.QSize(700, 200)) + self.setMaximumSize(QtCore.QSize(700, 200)) + self.password = LineEdit(self) + self.password.setGeometry(QtCore.QRect(40, 10, 300, 30)) + self.password.setEchoMode(QtWidgets.QLineEdit.Password) + self.confirm_password = LineEdit(self) + self.confirm_password.setGeometry(QtCore.QRect(40, 50, 300, 30)) + self.confirm_password.setEchoMode(QtWidgets.QLineEdit.Password) + self.set_password = QtWidgets.QPushButton(self) + self.set_password.setGeometry(QtCore.QRect(40, 100, 300, 30)) + self.set_password.clicked.connect(self.new_password) + self.not_match = QtWidgets.QLabel(self) + self.not_match.setGeometry(QtCore.QRect(350, 50, 300, 30)) + self.not_match.setVisible(False) + self.not_match.setStyleSheet('QLabel { color: #BC1C1C; }') + self.warning = QtWidgets.QLabel(self) + self.warning.setGeometry(QtCore.QRect(40, 160, 500, 30)) + self.warning.setStyleSheet('QLabel { color: #BC1C1C; }') + + def retranslateUi(self): + self.setWindowTitle(util_ui.tr('Profile password')) + self.password.setPlaceholderText( + util_ui.tr('Password (at least 8 symbols)')) + self.confirm_password.setPlaceholderText( + util_ui.tr('Confirm password')) + self.set_password.setText( + util_ui.tr('Set password')) + self.not_match.setText(util_ui.tr('Passwords do not match')) + self.warning.setText(util_ui.tr('There is no way to recover lost passwords')) + + def new_password(self): + if self.password.text() == self.confirm_password.text(): + if len(self.password.text()) >= 8: + self._encrypt.set_password(self.password.text()) + self.close() + else: + self.not_match.setText(util_ui.tr('Password must be at least 8 symbols')) + self.not_match.setVisible(True) + else: + self.not_match.setText(util_ui.tr('Passwords do not match')) + self.not_match.setVisible(True) diff --git a/toxygen/ui/peer_screen.py b/toxygen/ui/peer_screen.py new file mode 100644 index 0000000..8f2d5ba --- /dev/null +++ b/toxygen/ui/peer_screen.py @@ -0,0 +1,111 @@ +from ui.widgets import CenteredWidget +from PyQt5 import uic +import utils.util as util +import utils.ui as util_ui +from ui.contact_items import * +import wrapper.toxcore_enums_and_consts as consts + + +class PeerScreen(CenteredWidget): + + def __init__(self, contacts_manager, groups_service, group, peer_id): + super().__init__() + self._contacts_manager = contacts_manager + self._groups_service = groups_service + self._group = group + self._peer = group.get_peer_by_id(peer_id) + + self._roles = { + TOX_GROUP_ROLE['FOUNDER']: util_ui.tr('Administrator'), + TOX_GROUP_ROLE['MODERATOR']: util_ui.tr('Moderator'), + TOX_GROUP_ROLE['USER']: util_ui.tr('User'), + TOX_GROUP_ROLE['OBSERVER']: util_ui.tr('Observer') + } + + uic.loadUi(util.get_views_path('peer_screen'), self) + self._update_ui() + + def _update_ui(self): + self.statusCircle = StatusCircle(self) + self.statusCircle.setGeometry(50, 15, 30, 30) + + self.statusCircle.update(self._peer.status) + self.peerNameLabel.setText(self._peer.name) + self.ignorePeerCheckBox.setChecked(self._peer.is_muted) + self.ignorePeerCheckBox.clicked.connect(self._toggle_ignore) + self.sendPrivateMessagePushButton.clicked.connect(self._send_private_message) + self.copyPublicKeyPushButton.clicked.connect(self._copy_public_key) + self.roleNameLabel.setText(self._get_role_name()) + can_change_role_or_ban = self._can_change_role_or_ban() + self.rolesComboBox.setVisible(can_change_role_or_ban) + self.roleNameLabel.setVisible(not can_change_role_or_ban) + self.banGroupBox.setEnabled(can_change_role_or_ban) + self.banPushButton.clicked.connect(self._ban_peer) + self.kickPushButton.clicked.connect(self._kick_peer) + + self._retranslate_ui() + + self.rolesComboBox.currentIndexChanged.connect(self._role_set) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Peer details')) + self.ignorePeerCheckBox.setText(util_ui.tr('Ignore peer')) + self.roleLabel.setText(util_ui.tr('Role:')) + self.copyPublicKeyPushButton.setText(util_ui.tr('Copy public key')) + self.sendPrivateMessagePushButton.setText(util_ui.tr('Send private message')) + self.banPushButton.setText(util_ui.tr('Ban peer')) + self.kickPushButton.setText(util_ui.tr('Kick peer')) + self.banGroupBox.setTitle(util_ui.tr('Ban peer')) + self.ipBanRadioButton.setText(util_ui.tr('IP')) + self.nickBanRadioButton.setText(util_ui.tr('Nickname')) + self.pkBanRadioButton.setText(util_ui.tr('Public key')) + + self.rolesComboBox.clear() + index = self._group.get_self_peer().role + roles = list(self._roles.values()) + for role in roles[index + 1:]: + self.rolesComboBox.addItem(role) + self.rolesComboBox.setCurrentIndex(self._peer.role - index - 1) + + def _can_change_role_or_ban(self): + self_peer = self._group.get_self_peer() + if self_peer.role > TOX_GROUP_ROLE['MODERATOR']: + return False + + return self_peer.role < self._peer.role + + def _role_set(self): + index = self.rolesComboBox.currentIndex() + all_roles_count = len(self._roles) + diff = all_roles_count - self.rolesComboBox.count() + self._groups_service.set_new_peer_role(self._group, self._peer, index + diff) + + def _get_role_name(self): + return self._roles[self._peer.role] + + def _toggle_ignore(self): + ignore = self.ignorePeerCheckBox.isChecked() + self._groups_service.toggle_ignore_peer(self._group, self._peer, ignore) + + def _send_private_message(self): + self._contacts_manager.add_group_peer(self._group, self._peer) + self.close() + + def _copy_public_key(self): + util_ui.copy_to_clipboard(self._peer.public_key) + + def _ban_peer(self): + ban_type = self._get_ban_type() + self._groups_service.ban_peer(self._group, self._peer.id, ban_type) + self.close() + + def _kick_peer(self): + self._groups_service.kick_peer(self._group, self._peer.id) + self.close() + + def _get_ban_type(self): + if self.ipBanRadioButton.isChecked(): + return consts.TOX_GROUP_BAN_TYPE['IP_PORT'] + elif self.nickBanRadioButton.isChecked(): + return consts.TOX_GROUP_BAN_TYPE['NICK'] + return consts.TOX_GROUP_BAN_TYPE['PUBLIC_KEY'] diff --git a/toxygen/ui/profile_settings_screen.py b/toxygen/ui/profile_settings_screen.py new file mode 100644 index 0000000..2e55d3d --- /dev/null +++ b/toxygen/ui/profile_settings_screen.py @@ -0,0 +1,157 @@ +from ui.widgets import CenteredWidget +import utils.ui as util_ui +from utils.util import join_path, get_images_directory, get_views_path +from user_data.settings import Settings +from PyQt5 import QtGui, QtCore, uic + + +class ProfileSettings(CenteredWidget): + """Form with profile settings such as name, status, TOX ID""" + def __init__(self, profile, profile_manager, settings, toxes): + super().__init__() + self._profile = profile + self._profile_manager = profile_manager + self._settings = settings + self._toxes = toxes + self._auto = False + + uic.loadUi(get_views_path('profile_settings_screen'), self) + + self._init_ui() + self.center() + + def closeEvent(self, event): + self._profile.set_name(self.nameLineEdit.text()) + self._profile.set_status_message(self.statusMessageLineEdit.text()) + self._profile.set_status(self.statusComboBox.currentIndex()) + + def _init_ui(self): + self._auto = Settings.get_auto_profile() == self._profile_manager.get_path() + self.toxIdLabel.setText(self._profile.tox_id) + self.nameLineEdit.setText(self._profile.name) + self.statusMessageLineEdit.setText(self._profile.status_message) + self.defaultProfilePushButton.clicked.connect(self._toggle_auto_profile) + self.copyToxIdPushButton.clicked.connect(self._copy_tox_id) + self.copyPublicKeyPushButton.clicked.connect(self._copy_public_key) + self.changePasswordPushButton.clicked.connect(self._save_password) + self.exportProfilePushButton.clicked.connect(self._export_profile) + self.newNoSpamPushButton.clicked.connect(self._set_new_no_spam) + self.newAvatarPushButton.clicked.connect(self._set_avatar) + self.resetAvatarPushButton.clicked.connect(self._reset_avatar) + + self.invalidPasswordsLabel.setVisible(False) + + self._retranslate_ui() + + if self._profile.status is not None: + self.statusComboBox.setCurrentIndex(self._profile.status) + else: + self.statusComboBox.setVisible(False) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr("Profile settings")) + + self.exportProfilePushButton.setText(util_ui.tr("Export profile")) + self.nameLabel.setText(util_ui.tr("Name:")) + self.statusLabel.setText(util_ui.tr("Status:")) + self.toxIdTitleLabel.setText(util_ui.tr("TOX ID:")) + self.copyToxIdPushButton.setText(util_ui.tr("Copy TOX ID")) + self.newAvatarPushButton.setText(util_ui.tr("New avatar")) + self.resetAvatarPushButton.setText(util_ui.tr("Reset avatar")) + self.newNoSpamPushButton.setText(util_ui.tr("New NoSpam")) + self.profilePasswordLabel.setText(util_ui.tr("Profile password")) + self.passwordLineEdit.setPlaceholderText(util_ui.tr("Password (at least 8 symbols)")) + self.confirmPasswordLineEdit.setPlaceholderText(util_ui.tr("Confirm password")) + self.changePasswordPushButton.setText(util_ui.tr("Set password")) + self.invalidPasswordsLabel.setText(util_ui.tr("Passwords do not match")) + self.emptyPasswordLabel.setText(util_ui.tr("Leaving blank will reset current password")) + self.warningLabel.setText(util_ui.tr("There is no way to recover lost passwords")) + self.statusComboBox.addItem(util_ui.tr("Online")) + self.statusComboBox.addItem(util_ui.tr("Away")) + self.statusComboBox.addItem(util_ui.tr("Busy")) + self.copyPublicKeyPushButton.setText(util_ui.tr("Copy public key")) + + self._set_default_profile_button_text() + + def _toggle_auto_profile(self): + if self._auto: + Settings.reset_auto_profile() + else: + Settings.set_auto_profile(self._profile_manager.get_path()) + self._auto = not self._auto + self._set_default_profile_button_text() + + def _set_default_profile_button_text(self): + if self._auto: + self.defaultProfilePushButton.setText(util_ui.tr("Mark as not default profile")) + else: + self.defaultProfilePushButton.setText(util_ui.tr("Mark as default profile")) + + def _save_password(self): + password = self.passwordLineEdit.text() + confirm_password = self.confirmPasswordLineEdit.text() + if password == confirm_password: + if not len(password) or len(password) >= 8: + self._toxes.set_password(password) + self.close() + else: + self.invalidPasswordsLabel.setText( + util_ui.tr("Password must be at least 8 symbols")) + self.invalidPasswordsLabel.setVisible(True) + else: + self.invalidPasswordsLabel.setText(util_ui.tr("Passwords do not match")) + self.invalidPasswordsLabel.setVisible(True) + + def _copy_tox_id(self): + util_ui.copy_to_clipboard(self._profile.tox_id) + + icon = self._get_accept_icon() + self.copyToxIdPushButton.setIcon(icon) + self.copyToxIdPushButton.setIconSize(QtCore.QSize(10, 10)) + + def _copy_public_key(self): + util_ui.copy_to_clipboard(self._profile.tox_id[:64]) + + icon = self._get_accept_icon() + self.copyPublicKeyPushButton.setIcon(icon) + self.copyPublicKeyPushButton.setIconSize(QtCore.QSize(10, 10)) + + def _set_new_no_spam(self): + self.toxIdLabel.setText(self._profile.set_new_nospam()) + + def _reset_avatar(self): + self._profile.reset_avatar(self._settings['identicons']) + + def _set_avatar(self): + choose = util_ui.tr("Choose avatar") + name = util_ui.file_dialog(choose, 'Images (*.png)') + if not name[0]: + return + bitmap = QtGui.QPixmap(name[0]) + bitmap.scaled(QtCore.QSize(128, 128), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + + byte_array = QtCore.QByteArray() + buffer = QtCore.QBuffer(byte_array) + buffer.open(QtCore.QIODevice.WriteOnly) + bitmap.save(buffer, 'PNG') + + self._profile.set_avatar(bytes(byte_array.data())) + + def _export_profile(self): + directory = util_ui.directory_dialog() + if not directory: + return + + reply = util_ui.question(util_ui.tr('Do you want to move your profile to this location?'), + util_ui.tr('Use new path')) + + self._settings.export(directory) + self._profile.export_db(directory) + self._profile_manager.export_profile(self._settings, directory, reply) + + @staticmethod + def _get_accept_icon(): + pixmap = QtGui.QPixmap(join_path(get_images_directory(), 'accept.png')) + + return QtGui.QIcon(pixmap) + diff --git a/toxygen/ui/self_peer_screen.py b/toxygen/ui/self_peer_screen.py new file mode 100644 index 0000000..cf252d3 --- /dev/null +++ b/toxygen/ui/self_peer_screen.py @@ -0,0 +1,66 @@ +from ui.widgets import CenteredWidget, LineEdit +from PyQt5 import uic +import utils.util as util +import utils.ui as util_ui +from ui.contact_items import * + + +class SelfPeerScreen(CenteredWidget): + + def __init__(self, contacts_manager, groups_service, group): + super().__init__() + self._contacts_manager = contacts_manager + self._groups_service = groups_service + self._group = group + self._peer = group.get_self_peer() + self._roles = { + TOX_GROUP_ROLE['FOUNDER']: util_ui.tr('Administrator'), + TOX_GROUP_ROLE['MODERATOR']: util_ui.tr('Moderator'), + TOX_GROUP_ROLE['USER']: util_ui.tr('User'), + TOX_GROUP_ROLE['OBSERVER']: util_ui.tr('Observer') + } + + uic.loadUi(util.get_views_path('self_peer_screen'), self) + self._update_ui() + + def _update_ui(self): + self.lineEdit = LineEdit(self) + self.lineEdit.setGeometry(140, 40, 400, 30) + self.lineEdit.setText(self._peer.name) + self.lineEdit.textChanged.connect(self._nick_changed) + + self.savePushButton.clicked.connect(self._save) + self.copyPublicKeyPushButton.clicked.connect(self._copy_public_key) + + self._retranslate_ui() + + self.statusComboBox.setCurrentIndex(self._peer.status) + + def _retranslate_ui(self): + self.setWindowTitle(util_ui.tr('Change credentials in group')) + self.lineEdit.setPlaceholderText(util_ui.tr('Your nickname in group')) + self.nameLabel.setText(util_ui.tr('Name:')) + self.roleLabel.setText(util_ui.tr('Role:')) + self.statusLabel.setText(util_ui.tr('Status:')) + self.copyPublicKeyPushButton.setText(util_ui.tr('Copy public key')) + self.savePushButton.setText(util_ui.tr('Save')) + self.roleNameLabel.setText(self._get_role_name()) + self.statusComboBox.addItem(util_ui.tr('Online')) + self.statusComboBox.addItem(util_ui.tr('Away')) + self.statusComboBox.addItem(util_ui.tr('Busy')) + + def _get_role_name(self): + return self._roles[self._peer.role] + + def _nick_changed(self): + nick = self.lineEdit.text() + self.savePushButton.setEnabled(bool(nick)) + + def _save(self): + nick = self.lineEdit.text() + status = self.statusComboBox.currentIndex() + self._groups_service.set_self_info(self._group, nick, status) + self.close() + + def _copy_public_key(self): + util_ui.copy_to_clipboard(self._peer.public_key) diff --git a/toxygen/ui/tray.py b/toxygen/ui/tray.py new file mode 100644 index 0000000..3bfc7f3 --- /dev/null +++ b/toxygen/ui/tray.py @@ -0,0 +1,111 @@ +from PyQt5 import QtWidgets, QtGui, QtCore +from utils.ui import tr +from utils.util import * +from ui.password_screen import UnlockAppScreen +import os.path + + +class SystemTrayIcon(QtWidgets.QSystemTrayIcon): + + leftClicked = QtCore.pyqtSignal() + + def __init__(self, icon, parent=None): + super().__init__(icon, parent) + self.activated.connect(self.icon_activated) + + def icon_activated(self, reason): + if reason == QtWidgets.QSystemTrayIcon.Trigger: + self.leftClicked.emit() + + +class Menu(QtWidgets.QMenu): + + def __init__(self, settings, profile, *args): + super().__init__(*args) + self._settings = settings + self._profile = profile + + def new_status(self, status): + if not self._settings.locked: + self._profile.set_status(status) + self.about_to_show_handler() + self.hide() + + def about_to_show_handler(self): + status = self._profile.status + act = self.act + if status is None or self._settings.locked: + self.actions()[1].setVisible(False) + else: + self.actions()[1].setVisible(True) + act.actions()[0].setChecked(False) + act.actions()[1].setChecked(False) + act.actions()[2].setChecked(False) + act.actions()[status].setChecked(True) + self.actions()[2].setVisible(not self._settings.locked) + + def languageChange(self, *args, **kwargs): + self.actions()[0].setText(tr('Open Toxygen')) + self.actions()[1].setText(tr('Set status')) + self.actions()[2].setText(tr('Exit')) + self.act.actions()[0].setText(tr('Online')) + self.act.actions()[1].setText(tr('Away')) + self.act.actions()[2].setText(tr('Busy')) + + +def init_tray(profile, settings, main_screen, toxes): + icon = os.path.join(get_images_directory(), 'icon.png') + tray = SystemTrayIcon(QtGui.QIcon(icon)) + + menu = Menu(settings, profile) + show = menu.addAction(tr('Open Toxygen')) + sub = menu.addMenu(tr('Set status')) + online = sub.addAction(tr('Online')) + away = sub.addAction(tr('Away')) + busy = sub.addAction(tr('Busy')) + online.setCheckable(True) + away.setCheckable(True) + busy.setCheckable(True) + menu.act = sub + exit = menu.addAction(tr('Exit')) + + def show_window(): + def show(): + if not main_screen.isActiveWindow(): + main_screen.setWindowState( + main_screen.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + main_screen.activateWindow() + main_screen.show() + if not settings.locked: + show() + else: + def correct_pass(): + show() + settings.locked = False + settings.unlockScreen = False + if not settings.unlockScreen: + settings.unlockScreen = True + show_window.screen = UnlockAppScreen(toxes, correct_pass) + show_window.screen.show() + + def tray_activated(reason): + if reason == QtWidgets.QSystemTrayIcon.DoubleClick: + show_window() + + def close_app(): + if not settings.locked: + settings.closing = True + main_screen.close() + + show.triggered.connect(show_window) + exit.triggered.connect(close_app) + menu.aboutToShow.connect(menu.about_to_show_handler) + online.triggered.connect(lambda: menu.new_status(0)) + away.triggered.connect(lambda: menu.new_status(1)) + busy.triggered.connect(lambda: menu.new_status(2)) + + tray.setContextMenu(menu) + tray.show() + tray.activated.connect(tray_activated) + + return tray diff --git a/toxygen/ui/widgets.py b/toxygen/ui/widgets.py new file mode 100644 index 0000000..e7fe623 --- /dev/null +++ b/toxygen/ui/widgets.py @@ -0,0 +1,197 @@ +from PyQt5 import QtCore, QtGui, QtWidgets +import utils.ui as util_ui + + +class DataLabel(QtWidgets.QLabel): + """ + Label with elided text + """ + def setText(self, text): + text = ''.join('\u25AF' if len(bytes(c, 'utf-8')) >= 4 else c for c in text) + metrics = QtGui.QFontMetrics(self.font()) + text = metrics.elidedText(text, QtCore.Qt.ElideRight, self.width()) + super().setText(text) + + +class ComboBox(QtWidgets.QComboBox): + + def __init__(self, *args): + super().__init__(*args) + self.view().setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding) + + +class CenteredWidget(QtWidgets.QWidget): + + def __init__(self): + super().__init__() + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.center() + + def center(self): + qr = self.frameGeometry() + cp = QtWidgets.QDesktopWidget().availableGeometry().center() + qr.moveCenter(cp) + self.move(qr.topLeft()) + + +class DialogWithResult(QtWidgets.QWidget): + + def __init__(self, parent=None): + super().__init__(parent) + self._result = None + + def get_result(self): + return self._result + + result = property(get_result) + + def close_with_result(self, result): + self._result = result + self.close() + + +class LineEdit(QtWidgets.QLineEdit): + + def __init__(self, parent=None): + super().__init__(parent) + + def contextMenuEvent(self, event): + menu = create_menu(self.createStandardContextMenu()) + menu.exec_(event.globalPos()) + del menu + + +class QRightClickButton(QtWidgets.QPushButton): + """ + Button with right click support + """ + + rightClicked = QtCore.pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.rightClicked.emit() + else: + super().mousePressEvent(event) + + +class RubberBand(QtWidgets.QRubberBand): + + def __init__(self): + super().__init__(QtWidgets.QRubberBand.Rectangle, None) + self.setPalette(QtGui.QPalette(QtCore.Qt.transparent)) + self.pen = QtGui.QPen(QtCore.Qt.blue, 4) + self.pen.setStyle(QtCore.Qt.SolidLine) + self.painter = QtGui.QPainter() + + def paintEvent(self, event): + + self.painter.begin(self) + self.painter.setPen(self.pen) + self.painter.drawRect(event.rect()) + self.painter.end() + + +class RubberBandWindow(QtWidgets.QWidget): + + def __init__(self, parent): + super().__init__() + self.parent = parent + self.setMouseTracking(True) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint) + self.showFullScreen() + self.setWindowOpacity(0.5) + self.rubberband = RubberBand() + self.rubberband.setWindowFlags(self.rubberband.windowFlags() | QtCore.Qt.FramelessWindowHint) + self.rubberband.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + def mousePressEvent(self, event): + self.origin = event.pos() + self.rubberband.setGeometry(QtCore.QRect(self.origin, QtCore.QSize())) + self.rubberband.show() + QtWidgets.QWidget.mousePressEvent(self, event) + + def mouseMoveEvent(self, event): + if self.rubberband.isVisible(): + self.rubberband.setGeometry(QtCore.QRect(self.origin, event.pos()).normalized()) + left = QtGui.QRegion(QtCore.QRect(0, 0, self.rubberband.x(), self.height())) + right = QtGui.QRegion(QtCore.QRect(self.rubberband.x() + self.rubberband.width(), 0, self.width(), self.height())) + top = QtGui.QRegion(0, 0, self.width(), self.rubberband.y()) + bottom = QtGui.QRegion(0, self.rubberband.y() + self.rubberband.height(), self.width(), self.height()) + self.setMask(left + right + top + bottom) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Escape: + self.rubberband.setHidden(True) + self.close() + else: + super().keyPressEvent(event) + + +def create_menu(menu): + """ + :return translated menu + """ + for action in menu.actions(): + text = action.text() + if 'Link Location' in text: + text = text.replace('Copy &Link Location', + util_ui.tr("Copy link location")) + elif '&Copy' in text: + text = text.replace('&Copy', util_ui.tr("Copy")) + elif 'All' in text: + text = text.replace('Select All', util_ui.tr("Select all")) + elif 'Delete' in text: + text = text.replace('Delete', util_ui.tr("Delete")) + elif '&Paste' in text: + text = text.replace('&Paste', util_ui.tr("Paste")) + elif 'Cu&t' in text: + text = text.replace('Cu&t', util_ui.tr("Cut")) + elif '&Undo' in text: + text = text.replace('&Undo', util_ui.tr("Undo")) + elif '&Redo' in text: + text = text.replace('&Redo', util_ui.tr("Redo")) + else: + menu.removeAction(action) + continue + action.setText(text) + return menu + + +class MultilineEdit(CenteredWidget): + + def __init__(self, title, text, save): + super(MultilineEdit, self).__init__() + self.resize(350, 200) + self.setMinimumSize(QtCore.QSize(350, 200)) + self.setMaximumSize(QtCore.QSize(350, 200)) + self.setWindowTitle(title) + self.edit = QtWidgets.QTextEdit(self) + self.edit.setGeometry(QtCore.QRect(0, 0, 350, 150)) + self.edit.setText(text) + self.button = QtWidgets.QPushButton(self) + self.button.setGeometry(QtCore.QRect(0, 150, 350, 50)) + self.button.setText(util_ui.tr("Save")) + self.button.clicked.connect(self.button_click) + self.center() + self.save = save + + def button_click(self): + self.save(self.edit.toPlainText()) + self.close() + + +class LineEditWithEnterSupport(LineEdit): + + def __init__(self, enter_action, parent=None): + super().__init__(parent) + self._action = enter_action + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Return: + self._action() + else: + super().keyPressEvent(event) diff --git a/toxygen/ui/widgets_factory.py b/toxygen/ui/widgets_factory.py new file mode 100644 index 0000000..128e85e --- /dev/null +++ b/toxygen/ui/widgets_factory.py @@ -0,0 +1,97 @@ +from ui.main_screen_widgets import * +from ui.menu import * +from ui.groups_widgets import * +from ui.peer_screen import * +from ui.self_peer_screen import * +from ui.group_invites_widgets import * +from ui.group_settings_widgets import * +from ui.group_bans_widgets import * +from ui.profile_settings_screen import ProfileSettings + + +class WidgetsFactory: + + def __init__(self, settings, profile, profile_manager, contacts_manager, file_transfer_handler, smiley_loader, + plugin_loader, toxes, version, groups_service, history, contacts_provider): + self._settings = settings + self._profile = profile + self._profile_manager = profile_manager + self._contacts_manager = contacts_manager + self._file_transfer_handler = file_transfer_handler + self._smiley_loader = smiley_loader + self._plugin_loader = plugin_loader + self._toxes = toxes + self._version = version + self._groups_service = groups_service + self._history = history + self._contacts_provider = contacts_provider + + def create_screenshot_window(self, *args): + return ScreenShotWindow(self._file_transfer_handler, self._contacts_manager, *args) + + def create_welcome_window(self): + return WelcomeScreen(self._settings) + + def create_profile_settings_window(self): + return ProfileSettings(self._profile, self._profile_manager, self._settings, self._toxes) + + def create_network_settings_window(self): + return NetworkSettings(self._settings, self._profile.restart) + + def create_audio_settings_window(self): + return AudioSettings(self._settings) + + def create_video_settings_window(self): + return VideoSettings(self._settings) + + def create_update_settings_window(self): + return UpdateSettings(self._settings, self._version) + + def create_plugins_settings_window(self): + return PluginsSettings(self._plugin_loader) + + def create_add_contact_window(self, tox_id): + return AddContact(self._settings, self._contacts_manager, tox_id) + + def create_privacy_settings_window(self): + return PrivacySettings(self._contacts_manager, self._settings) + + def create_interface_settings_window(self): + return InterfaceSettings(self._settings, self._smiley_loader) + + def create_notification_settings_window(self): + return NotificationsSettings(self._settings) + + def create_smiley_window(self, parent): + return SmileyWindow(parent, self._smiley_loader) + + def create_sticker_window(self): + return StickerWindow(self._file_transfer_handler, self._contacts_manager) + + def create_group_screen_window(self): + return CreateGroupScreen(self._groups_service, self._profile) + + def create_join_group_screen_window(self): + return JoinGroupScreen(self._groups_service, self._profile) + + def create_search_screen(self, messages): + return SearchScreen(self._contacts_manager, self._history, messages, messages.parent()) + + def create_peer_screen_window(self, group, peer_id): + return PeerScreen(self._contacts_manager, self._groups_service, group, peer_id) + + def create_self_peer_screen_window(self, group): + return SelfPeerScreen(self._contacts_manager, self._groups_service, group) + + def create_group_invites_window(self): + return GroupInvitesScreen(self._groups_service, self._profile, self._contacts_provider) + + def create_group_management_screen(self, group): + return GroupManagementScreen(self._groups_service, group) + + @staticmethod + def create_group_settings_screen(group): + return GroupSettingsScreen(group) + + def create_groups_bans_screen(self, group): + return GroupBansScreen(self._groups_service, group) diff --git a/toxygen/updater/__init__.py b/toxygen/updater/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/updater/updater.py b/toxygen/updater/updater.py new file mode 100644 index 0000000..329353c --- /dev/null +++ b/toxygen/updater/updater.py @@ -0,0 +1,124 @@ +import utils.util as util +import utils.ui as util_ui +import os +import platform +import urllib +from PyQt5 import QtNetwork, QtCore +import subprocess + + +def connection_available(): + try: + urllib.request.urlopen('http://216.58.192.142', timeout=1) # google.com + return True + except: + return False + + +def updater_available(): + if is_from_sources(): + return os.path.exists(util.curr_directory() + '/toxygen_updater.py') + elif platform.system() == 'Windows': + return os.path.exists(util.curr_directory() + '/toxygen_updater.exe') + else: + return os.path.exists(util.curr_directory() + '/toxygen_updater') + + +def check_for_updates(current_version, settings): + major, minor, patch = list(map(lambda x: int(x), current_version.split('.'))) + versions = generate_versions(major, minor, patch) + for version in versions: + if send_request(version, settings): + return version + return None # no new version was found + + +def is_from_sources(): + return __file__.endswith('.py') + + +def test_url(version): + return 'https://github.com/toxygen-project/toxygen/releases/tag/v' + version + + +def get_url(version): + if is_from_sources(): + return 'https://github.com/toxygen-project/toxygen/archive/v' + version + '.zip' + else: + if platform.system() == 'Windows': + name = 'toxygen_windows.zip' + elif util.is_64_bit(): + name = 'toxygen_linux_64.tar.gz' + else: + name = 'toxygen_linux.tar.gz' + return 'https://github.com/toxygen-project/toxygen/releases/download/v{}/{}'.format(version, name) + + +def get_params(url, version): + if is_from_sources(): + if platform.system() == 'Windows': + return ['python', 'toxygen_updater.py', url, version] + else: + return ['python3', 'toxygen_updater.py', url, version] + elif platform.system() == 'Windows': + return [util.curr_directory() + '/toxygen_updater.exe', url, version] + else: + return ['./toxygen_updater', url, version] + + +def download(version): + os.chdir(util.curr_directory()) + url = get_url(version) + params = get_params(url, version) + print('Updating Toxygen') + util.log('Updating Toxygen') + try: + subprocess.Popen(params) + except Exception as ex: + util.log('Exception: running updater failed with ' + str(ex)) + + +def send_request(version, settings): + netman = QtNetwork.QNetworkAccessManager() + proxy = QtNetwork.QNetworkProxy() + if settings['proxy_type']: + proxy.setType(QtNetwork.QNetworkProxy.Socks5Proxy if settings['proxy_type'] == 2 else QtNetwork.QNetworkProxy.HttpProxy) + proxy.setHostName(settings['proxy_host']) + proxy.setPort(settings['proxy_port']) + netman.setProxy(proxy) + url = test_url(version) + try: + request = QtNetwork.QNetworkRequest() + request.setUrl(QtCore.QUrl(url)) + reply = netman.get(request) + while not reply.isFinished(): + QtCore.QThread.msleep(1) + QtCore.QCoreApplication.processEvents() + attr = reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute) + return attr is not None and 200 <= attr < 300 + except Exception as ex: + util.log('TOXYGEN UPDATER ERROR: ' + str(ex)) + return False + + +def generate_versions(major, minor, patch): + new_major = '.'.join([str(major + 1), '0', '0']) + new_minor = '.'.join([str(major), str(minor + 1), '0']) + new_patch = '.'.join([str(major), str(minor), str(patch + 1)]) + return new_major, new_minor, new_patch + + +def start_update_if_needed(version, settings): + updating = False + if settings['update'] and updater_available() and connection_available(): # auto update + version = check_for_updates(version, settings) + if version is not None: + if settings['update'] == 2: + download(version) + updating = True + else: + reply = util_ui.question(util_ui.tr('Update for Toxygen was found. Download and install it?')) + if reply: + download(version) + updating = True + return updating diff --git a/toxygen/user_data/__init__.py b/toxygen/user_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/user_data/backup_service.py b/toxygen/user_data/backup_service.py new file mode 100644 index 0000000..bb0cef9 --- /dev/null +++ b/toxygen/user_data/backup_service.py @@ -0,0 +1,40 @@ +import os.path +from utils.util import get_profile_name_from_path, join_path + + +class BackupService: + + def __init__(self, settings, profile_manager): + self._settings = settings + self._profile_name = get_profile_name_from_path(profile_manager.get_path()) + + settings.settings_saved_event.add_callback(self._settings_saved) + profile_manager.profile_saved_event.add_callback(self._profile_saved) + + def _settings_saved(self, data): + if not self._check_if_should_save_backup(): + return + + file_path = join_path(self._get_backup_directory(), self._profile_name + '.json') + + with open(file_path, 'wt') as fl: + fl.write(data) + + def _profile_saved(self, data): + if not self._check_if_should_save_backup(): + return + + file_path = join_path(self._get_backup_directory(), self._profile_name + '.tox') + + with open(file_path, 'wb') as fl: + fl.write(data) + + def _check_if_should_save_backup(self): + backup_directory = self._get_backup_directory() + if backup_directory is None: + return False + + return os.path.exists(backup_directory) and os.path.isdir(backup_directory) + + def _get_backup_directory(self): + return self._settings['backup_directory'] diff --git a/toxygen/user_data/profile_manager.py b/toxygen/user_data/profile_manager.py new file mode 100644 index 0000000..05e2f2d --- /dev/null +++ b/toxygen/user_data/profile_manager.py @@ -0,0 +1,90 @@ +import utils.util as util +import os +from user_data.settings import Settings +from common.event import Event + + +class ProfileManager: + """ + Class with methods for search, load and save profiles + """ + def __init__(self, toxes, path): + self._toxes = toxes + self._path = path + self._directory = os.path.dirname(path) + self._profile_saved_event = Event() + # create /avatars if not exists: + avatars_directory = util.join_path(self._directory, 'avatars') + if not os.path.exists(avatars_directory): + os.makedirs(avatars_directory) + + # ----------------------------------------------------------------------------------------------------------------- + # Properties + # ----------------------------------------------------------------------------------------------------------------- + + def get_profile_saved_event(self): + return self._profile_saved_event + + profile_saved_event = property(get_profile_saved_event) + + # ----------------------------------------------------------------------------------------------------------------- + # Public methods + # ----------------------------------------------------------------------------------------------------------------- + + def open_profile(self): + with open(self._path, 'rb') as fl: + data = fl.read() + if data: + return data + else: + raise IOError('Save file has zero size!') + + def get_dir(self): + return self._directory + + def get_path(self): + return self._path + + def save_profile(self, data): + if self._toxes.has_password(): + data = self._toxes.pass_encrypt(data) + with open(self._path, 'wb') as fl: + fl.write(data) + print('Profile saved successfully') + + self._profile_saved_event(data) + + def export_profile(self, settings, new_path, use_new_path): + path = new_path + os.path.basename(self._path) + with open(self._path, 'rb') as fin: + data = fin.read() + with open(path, 'wb') as fout: + fout.write(data) + print('Profile exported successfully') + util.copy(self._directory + 'avatars', new_path + 'avatars') + if use_new_path: + self._path = new_path + os.path.basename(self._path) + self._directory = new_path + settings.update_path(new_path) + + @staticmethod + def find_profiles(): + """ + Find available tox profiles + """ + path = Settings.get_default_path() + result = [] + # check default path + if not os.path.exists(path): + os.makedirs(path) + for fl in os.listdir(path): + if fl.endswith('.tox'): + name = fl[:-4] + result.append((path, name)) + path = util.get_base_directory(__file__) + # check current directory + for fl in os.listdir(path): + if fl.endswith('.tox'): + name = fl[:-4] + result.append((path + '/', name)) + return result diff --git a/toxygen/user_data/settings.py b/toxygen/user_data/settings.py new file mode 100644 index 0000000..71422c2 --- /dev/null +++ b/toxygen/user_data/settings.py @@ -0,0 +1,244 @@ +import json +from utils.util import * +import pyaudio +from common.event import Event + + +class Settings(dict): + """ + Settings of current profile + global app settings + """ + + def __init__(self, toxes, path): + self._path = path + self._profile_path = path.replace('.json', '.tox') + self._toxes = toxes + self._settings_saved_event = Event() + if os.path.isfile(path): + with open(path, 'rb') as fl: + data = fl.read() + try: + if toxes.is_data_encrypted(data): + data = toxes.pass_decrypt(data) + info = json.loads(str(data, 'utf-8')) + except Exception as ex: + info = Settings.get_default_settings() + log('Parsing settings error: ' + str(ex)) + super().__init__(info) + self._upgrade() + else: + super().__init__(Settings.get_default_settings()) + self.save() + self.locked = False + self.closing = False + self.unlockScreen = False + p = pyaudio.PyAudio() + input_devices = output_devices = 0 + for i in range(p.get_device_count()): + device = p.get_device_info_by_index(i) + if device["maxInputChannels"]: + input_devices += 1 + if device["maxOutputChannels"]: + output_devices += 1 + self.audio = {'input': p.get_default_input_device_info()['index'] if input_devices else -1, + 'output': p.get_default_output_device_info()['index'] if output_devices else -1, + 'enabled': input_devices and output_devices} + self.video = {'device': -1, 'width': 640, 'height': 480, 'x': 0, 'y': 0} + + # ----------------------------------------------------------------------------------------------------------------- + # Properties + # ----------------------------------------------------------------------------------------------------------------- + + def get_settings_saved_event(self): + return self._settings_saved_event + + settings_saved_event = property(get_settings_saved_event) + + # ----------------------------------------------------------------------------------------------------------------- + # Public methods + # ----------------------------------------------------------------------------------------------------------------- + + def save(self): + text = json.dumps(self) + if self._toxes.has_password(): + text = bytes(self._toxes.pass_encrypt(bytes(text, 'utf-8'))) + else: + text = bytes(text, 'utf-8') + with open(self._path, 'wb') as fl: + fl.write(text) + + self._settings_saved_event(text) + + def close(self): + path = self._profile_path + '.lock' + if os.path.isfile(path): + os.remove(path) + + def set_active_profile(self): + """ + Mark current profile as active + """ + path = self._profile_path + '.lock' + with open(path, 'w') as fl: + fl.write('active') + + def export(self, path): + text = json.dumps(self) + name = os.path.basename(self._path) + with open(join_path(path, str(name)), 'w') as fl: + fl.write(text) + + def update_path(self, new_path): + self._path = new_path + self.save() + + # ----------------------------------------------------------------------------------------------------------------- + # Static methods + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def get_auto_profile(): + p = Settings.get_global_settings_path() + if not os.path.isfile(p): + return None + with open(p) as fl: + data = fl.read() + try: + auto = json.loads(data) + except Exception as ex: + log(str(ex)) + auto = {} + if 'profile_path' in auto: + path = str(auto['profile_path']) + if not os.path.isabs(path): + path = join_path(path, curr_directory(__file__)) + if os.path.isfile(path): + return path + + @staticmethod + def set_auto_profile(path): + p = Settings.get_global_settings_path() + if os.path.isfile(p): + with open(p) as fl: + data = fl.read() + data = json.loads(data) + else: + data = {} + data['profile_path'] = str(path) + with open(p, 'w') as fl: + fl.write(json.dumps(data)) + + @staticmethod + def reset_auto_profile(): + p = Settings.get_global_settings_path() + if os.path.isfile(p): + with open(p) as fl: + data = fl.read() + data = json.loads(data) + else: + data = {} + if 'profile_path' in data: + del data['profile_path'] + with open(p, 'w') as fl: + fl.write(json.dumps(data)) + + @staticmethod + def is_active_profile(profile_path): + return os.path.isfile(profile_path + '.lock') + + @staticmethod + def get_default_settings(): + """ + Default profile settings + """ + return { + 'theme': 'dark', + 'ipv6_enabled': False, + 'udp_enabled': True, + 'proxy_type': 0, + 'proxy_host': '127.0.0.1', + 'proxy_port': 9050, + 'start_port': 0, + 'end_port': 0, + 'tcp_port': 0, + 'notifications': True, + 'sound_notifications': False, + 'language': 'English', + 'save_history': False, + 'allow_inline': True, + 'allow_auto_accept': True, + 'auto_accept_path': None, + 'sorting': 0, + 'auto_accept_from_friends': [], + 'paused_file_transfers': {}, + 'resend_files': True, + 'friends_aliases': [], + 'show_avatars': False, + 'typing_notifications': False, + 'calls_sound': True, + 'blocked': [], + 'plugins': [], + 'notes': {}, + 'smileys': True, + 'smiley_pack': 'default', + 'mirror_mode': False, + 'width': 920, + 'height': 500, + 'x': 400, + 'y': 400, + 'message_font_size': 14, + 'unread_color': 'red', + 'save_unsent_only': False, + 'compact_mode': False, + 'identicons': True, + 'show_welcome_screen': True, + 'close_app': 0, + 'font': 'Times New Roman', + 'update': 1, + 'group_notifications': True, + 'download_nodes_list': False, + 'notify_all_gc': False, + 'lan_discovery': True, + 'backup_directory': None + } + + @staticmethod + def supported_languages(): + return { + 'English': 'en_EN', + 'French': 'fr_FR', + 'Russian': 'ru_RU', + 'Ukrainian': 'uk_UA' + } + + @staticmethod + def built_in_themes(): + return { + 'dark': 'dark_style.qss', + 'default': 'style.qss' + } + + @staticmethod + def get_global_settings_path(): + return os.path.join(get_base_directory(), 'toxygen.json') + + @staticmethod + def get_default_path(): + system = get_platform() + if system == 'Windows': + return os.getenv('APPDATA') + '/Tox/' + elif system == 'Darwin': + return os.getenv('HOME') + '/Library/Application Support/Tox/' + else: + return os.getenv('HOME') + '/.config/tox/' + + # ----------------------------------------------------------------------------------------------------------------- + # Private methods + # ----------------------------------------------------------------------------------------------------------------- + + def _upgrade(self): + default = Settings.get_default_settings() + for key in default: + if key not in self: + print(key) + self[key] = default[key] diff --git a/toxygen/user_data/toxes.py b/toxygen/user_data/toxes.py new file mode 100644 index 0000000..982f287 --- /dev/null +++ b/toxygen/user_data/toxes.py @@ -0,0 +1,24 @@ + +class ToxES: + + def __init__(self, tox_encrypt_save): + self._tox_encrypt_save = tox_encrypt_save + self._password = None + + def set_password(self, password): + self._password = password + + def has_password(self): + return bool(self._password) + + def is_password(self, password): + return self._password == password + + def is_data_encrypted(self, data): + return len(data) > 0 and self._tox_encrypt_save.is_data_encrypted(data) + + def pass_encrypt(self, data): + return self._tox_encrypt_save.pass_encrypt(data, self._password) + + def pass_decrypt(self, data): + return self._tox_encrypt_save.pass_decrypt(data, self._password) diff --git a/toxygen/utils/__init__.py b/toxygen/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/utils/ui.py b/toxygen/utils/ui.py new file mode 100644 index 0000000..d2d7122 --- /dev/null +++ b/toxygen/utils/ui.py @@ -0,0 +1,54 @@ +from PyQt5 import QtWidgets +import utils.util as util + + +def tr(s): + return QtWidgets.QApplication.translate('Toxygen', s) + + +def question(text, title=None): + reply = QtWidgets.QMessageBox.question(None, title or 'Toxygen', text, + QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.No) + return reply == QtWidgets.QMessageBox.Yes + + +def message_box(text, title=None): + m_box = QtWidgets.QMessageBox() + m_box.setText(tr(text)) + m_box.setWindowTitle(title or 'Toxygen') + m_box.exec_() + + +def text_dialog(text, title='', default_value=''): + text, ok = QtWidgets.QInputDialog.getText(None, title, text, QtWidgets.QLineEdit.Normal, default_value) + + return text, ok + + +def directory_dialog(caption=''): + return QtWidgets.QFileDialog.getExistingDirectory(None, caption, util.curr_directory(), + QtWidgets.QFileDialog.DontUseNativeDialog) + + +def file_dialog(caption, file_filter=None): + return QtWidgets.QFileDialog.getOpenFileName(None, caption, util.curr_directory(), file_filter, + options=QtWidgets.QFileDialog.DontUseNativeDialog) + + +def save_file_dialog(caption, filter=None): + return QtWidgets.QFileDialog.getSaveFileName(None, caption, util.curr_directory(), + filter=filter, + options=QtWidgets.QFileDialog.ShowDirsOnly | QtWidgets.QFileDialog.DontUseNativeDialog) + + +def close_all_windows(): + QtWidgets.QApplication.closeAllWindows() + + +def copy_to_clipboard(text): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(text) + + +# TODO: all dialogs diff --git a/toxygen/utils/util.py b/toxygen/utils/util.py new file mode 100644 index 0000000..5bd5c3a --- /dev/null +++ b/toxygen/utils/util.py @@ -0,0 +1,170 @@ +import os +import time +import shutil +import sys +import re +import platform +import datetime + + +def cached(func): + saved_result = None + + def wrapped_func(): + nonlocal saved_result + if saved_result is None: + saved_result = func() + + return saved_result + + return wrapped_func + + +def log(data): + try: + with open(join_path(curr_directory(), 'logs.log'), 'a') as fl: + fl.write(str(data) + '\n') + except Exception as ex: + print(ex) + + +def curr_directory(current_file=None): + return os.path.dirname(os.path.realpath(current_file or __file__)) + + +def get_base_directory(current_file=None): + return os.path.dirname(curr_directory(current_file or __file__)) + + +@cached +def get_images_directory(): + return get_app_directory('images') + + +@cached +def get_styles_directory(): + return get_app_directory('styles') + + +@cached +def get_sounds_directory(): + return get_app_directory('sounds') + + +@cached +def get_stickers_directory(): + return get_app_directory('stickers') + + +@cached +def get_smileys_directory(): + return get_app_directory('smileys') + + +@cached +def get_translations_directory(): + return get_app_directory('translations') + + +@cached +def get_plugins_directory(): + return get_app_directory('plugins') + + +@cached +def get_libs_directory(): + return get_app_directory('libs') + + +def get_app_directory(directory_name): + return os.path.join(get_base_directory(), directory_name) + + +def get_profile_name_from_path(path): + return os.path.basename(path)[:-4] + + +def get_views_path(view_name): + ui_folder = os.path.join(get_base_directory(), 'ui') + views_folder = os.path.join(ui_folder, 'views') + + return os.path.join(views_folder, view_name + '.ui') + + +def curr_time(): + return time.strftime('%H:%M') + + +def get_unix_time(): + return int(time.time()) + + +def join_path(a, b): + return os.path.join(a, b) + + +def file_exists(file_path): + return os.path.exists(file_path) + + +def copy(src, dest): + if not os.path.exists(dest): + os.makedirs(dest) + src_files = os.listdir(src) + for file_name in src_files: + full_file_name = os.path.join(src, file_name) + if os.path.isfile(full_file_name): + shutil.copy(full_file_name, dest) + else: + copy(full_file_name, os.path.join(dest, file_name)) + + +def remove(folder): + if os.path.isdir(folder): + shutil.rmtree(folder) + + +def convert_time(t): + offset = time.timezone + time_offset() * 60 + sec = int(t) - offset + m, s = divmod(sec, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + return '%02d:%02d' % (h, m) + + +@cached +def time_offset(): + hours = int(time.strftime('%H')) + minutes = int(time.strftime('%M')) + sec = int(time.time()) - time.timezone + m, s = divmod(sec, 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + result = hours * 60 + minutes - h * 60 - m + return result + + +def unix_time_to_long_str(unix_time): + date_time = datetime.datetime.utcfromtimestamp(unix_time) + + return date_time.strftime('%Y-%m-%d %H:%M:%S') + + +@cached +def is_64_bit(): + return sys.maxsize > 2 ** 32 + + +def is_re_valid(regex): + try: + re.compile(regex) + except re.error: + return False + else: + return True + + +@cached +def get_platform(): + return platform.system() diff --git a/toxygen/wrapper/__init__.py b/toxygen/wrapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toxygen/wrapper/libtox.py b/toxygen/wrapper/libtox.py new file mode 100644 index 0000000..01d41f1 --- /dev/null +++ b/toxygen/wrapper/libtox.py @@ -0,0 +1,61 @@ +from ctypes import CDLL +import utils.util as util + + +class LibToxCore: + + def __init__(self): + platform = util.get_platform() + if platform == 'Windows': + self._libtoxcore = CDLL(util.join_path(util.get_libs_directory(), 'libtox.dll')) + elif platform == 'Darwin': + self._libtoxcore = CDLL('libtoxcore.dylib') + else: + # libtoxcore and libsodium must be installed in your os + try: + self._libtoxcore = CDLL('libtoxcore.so') + except: + self._libtoxcore = CDLL(util.join_path(util.get_libs_directory(), 'libtoxcore.so')) + + def __getattr__(self, item): + return self._libtoxcore.__getattr__(item) + + +class LibToxAV: + + def __init__(self): + platform = util.get_platform() + if platform == 'Windows': + # on Windows av api is in libtox.dll + self._libtoxav = CDLL(util.join_path(util.get_libs_directory(), 'libtox.dll')) + elif platform == 'Darwin': + self._libtoxav = CDLL('libtoxcore.dylib') + else: + # /usr/lib/libtoxcore.so must exists + try: + self._libtoxav = CDLL('libtoxcore.so') + except: + self._libtoxav = CDLL(util.join_path(util.get_libs_directory(), 'libtoxcore.so')) + + def __getattr__(self, item): + return self._libtoxav.__getattr__(item) + + +class LibToxEncryptSave: + + def __init__(self): + platform = util.get_platform() + if platform == 'Windows': + # on Windows profile encryption api is in libtox.dll + self._lib_tox_encrypt_save = CDLL(util.join_path(util.get_libs_directory(), 'libtox.dll')) + elif platform == 'Darwin': + self._lib_tox_encrypt_save = CDLL('libtoxcore.dylib') + else: + # /usr/lib/libtoxcore.so must exists + try: + self._lib_tox_encrypt_save = CDLL('libtoxcore.so') + except: + self._lib_tox_encrypt_save = CDLL(util.join_path(util.get_libs_directory(), 'libtoxcore.so')) + + def __getattr__(self, item): + return self._lib_tox_encrypt_save.__getattr__(item) diff --git a/toxygen/wrapper/tox.py b/toxygen/wrapper/tox.py new file mode 100644 index 0000000..21b0ebc --- /dev/null +++ b/toxygen/wrapper/tox.py @@ -0,0 +1,2532 @@ +# -*- coding: utf-8 -*- +from ctypes import * +from wrapper.toxcore_enums_and_consts import * +from wrapper.toxav import ToxAV +from wrapper.libtox import LibToxCore + + +class ToxOptions(Structure): + _fields_ = [ + ('ipv6_enabled', c_bool), + ('udp_enabled', c_bool), + ('local_discovery_enabled', c_bool), + ('proxy_type', c_int), + ('proxy_host', c_char_p), + ('proxy_port', c_uint16), + ('start_port', c_uint16), + ('end_port', c_uint16), + ('tcp_port', c_uint16), + ('hole_punching_enabled', c_bool), + ('savedata_type', c_int), + ('savedata_data', c_char_p), + ('savedata_length', c_size_t), + ('log_callback', c_void_p), + ('log_user_data', c_void_p) + ] + + +class GroupChatSelfPeerInfo(Structure): + _fields_ = [ + ('nick', c_char_p), + ('nick_length', c_uint8), + ('user_status', c_int) + ] + + +def string_to_bin(tox_id): + return c_char_p(bytes.fromhex(tox_id)) if tox_id is not None else None + + +def bin_to_string(raw_id, length): + res = ''.join('{:02x}'.format(ord(raw_id[i])) for i in range(length)) + return res.upper() + + +class Tox: + libtoxcore = LibToxCore() + + def __init__(self, tox_options=None, tox_pointer=None): + """ + Creates and initialises a new Tox instance with the options passed. + + This function will bring the instance into a valid state. Running the event loop with a new instance will + operate correctly. + + :param tox_options: An options object. If this parameter is None, the default options are used. + :param tox_pointer: Tox instance pointer. If this parameter is not None, tox_options will be ignored. + """ + if tox_pointer is not None: + self._tox_pointer = tox_pointer + else: + tox_err_new = c_int() + f = Tox.libtoxcore.tox_new + f.restype = POINTER(c_void_p) + self._tox_pointer = f(tox_options, byref(tox_err_new)) + tox_err_new = tox_err_new.value + if tox_err_new == TOX_ERR_NEW['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_new == TOX_ERR_NEW['MALLOC']: + raise MemoryError('The function was unable to allocate enough ' + 'memory to store the internal structures for the Tox object.') + elif tox_err_new == TOX_ERR_NEW['PORT_ALLOC']: + raise RuntimeError('The function was unable to bind to a port. This may mean that all ports have ' + 'already been bound, e.g. by other Tox instances, or it may mean a permission error.' + ' You may be able to gather more information from errno.') + elif tox_err_new == TOX_ERR_NEW['PROXY_BAD_TYPE']: + raise ArgumentError('proxy_type was invalid.') + elif tox_err_new == TOX_ERR_NEW['PROXY_BAD_HOST']: + raise ArgumentError('proxy_type was valid but the proxy_host passed had an invalid format or was NULL.') + elif tox_err_new == TOX_ERR_NEW['PROXY_BAD_PORT']: + raise ArgumentError('proxy_type was valid, but the proxy_port was invalid.') + elif tox_err_new == TOX_ERR_NEW['PROXY_NOT_FOUND']: + raise ArgumentError('The proxy address passed could not be resolved.') + elif tox_err_new == TOX_ERR_NEW['LOAD_ENCRYPTED']: + raise ArgumentError('The byte array to be loaded contained an encrypted save.') + elif tox_err_new == TOX_ERR_NEW['LOAD_BAD_FORMAT']: + raise ArgumentError('The data format was invalid. This can happen when loading data that was saved by' + ' an older version of Tox, or when the data has been corrupted. When loading from' + ' badly formatted data, some data may have been loaded, and the rest is discarded.' + ' Passing an invalid length parameter also causes this error.') + + self.self_connection_status_cb = None + self.friend_name_cb = None + self.friend_status_message_cb = None + self.friend_status_cb = None + self.friend_connection_status_cb = None + self.friend_request_cb = None + self.friend_read_receipt_cb = None + self.friend_typing_cb = None + self.friend_message_cb = None + self.file_recv_control_cb = None + self.file_chunk_request_cb = None + self.file_recv_cb = None + self.file_recv_chunk_cb = None + self.friend_lossy_packet_cb = None + self.friend_lossless_packet_cb = None + self.group_moderation_cb = None + self.group_join_fail_cb = None + self.group_self_join_cb = None + self.group_invite_cb = None + self.group_custom_packet_cb = None + self.group_private_message_cb = None + self.group_message_cb = None + self.group_password_cb = None + self.group_peer_limit_cb = None + self.group_privacy_state_cb = None + self.group_topic_cb = None + self.group_peer_status_cb = None + self.group_peer_name_cb = None + self.group_peer_exit_cb = None + self.group_peer_join_cb = None + + self.AV = ToxAV(self._tox_pointer) + + def kill(self): + del self.AV + Tox.libtoxcore.tox_kill(self._tox_pointer) + + # ----------------------------------------------------------------------------------------------------------------- + # Startup options + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def options_default(tox_options): + """ + Initialises a Tox_Options object with the default options. + + The result of this function is independent of the original options. All values will be overwritten, no values + will be read (so it is permissible to pass an uninitialised object). + + If options is NULL, this function has no effect. + + :param tox_options: A pointer to options object to be filled with default options. + """ + Tox.libtoxcore.tox_options_default(tox_options) + + @staticmethod + def options_new(): + """ + Allocates a new Tox_Options object and initialises it with the default options. This function can be used to + preserve long term ABI compatibility by giving the responsibility of allocation and deallocation to the Tox + library. + + Objects returned from this function must be freed using the tox_options_free function. + + :return: A pointer to new ToxOptions object with default options or raise MemoryError. + """ + tox_err_options_new = c_int() + f = Tox.libtoxcore.tox_options_new + f.restype = POINTER(ToxOptions) + result = f(byref(tox_err_options_new)) + tox_err_options_new = tox_err_options_new.value + if tox_err_options_new == TOX_ERR_OPTIONS_NEW['OK']: + return result + elif tox_err_options_new == TOX_ERR_OPTIONS_NEW['MALLOC']: + raise MemoryError('The function failed to allocate enough memory for the options struct.') + + @staticmethod + def options_free(tox_options): + """ + Releases all resources associated with an options objects. + + Passing a pointer that was not returned by tox_options_new results in undefined behaviour. + + :param tox_options: A pointer to new ToxOptions object + """ + Tox.libtoxcore.tox_options_free(tox_options) + + # ----------------------------------------------------------------------------------------------------------------- + # Creation and destruction + # ----------------------------------------------------------------------------------------------------------------- + + def get_savedata_size(self): + """ + Calculates the number of bytes required to store the tox instance with tox_get_savedata. + This function cannot fail. The result is always greater than 0. + + :return: number of bytes + """ + return Tox.libtoxcore.tox_get_savedata_size(self._tox_pointer) + + def get_savedata(self, savedata=None): + """ + Store all information associated with the tox instance to a byte array. + + :param savedata: pointer (c_char_p) to a memory region large enough to store the tox instance data. + Call tox_get_savedata_size to find the number of bytes required. If this parameter is None, this function + allocates memory for the tox instance data. + :return: pointer (c_char_p) to a memory region with the tox instance data + """ + if savedata is None: + savedata_size = self.get_savedata_size() + savedata = create_string_buffer(savedata_size) + Tox.libtoxcore.tox_get_savedata(self._tox_pointer, savedata) + return savedata[:] + + # ----------------------------------------------------------------------------------------------------------------- + # Connection lifecycle and event loop + # ----------------------------------------------------------------------------------------------------------------- + + def bootstrap(self, address, port, public_key): + """ + Sends a "get nodes" request to the given bootstrap node with IP, port, and public key to setup connections. + + This function will attempt to connect to the node using UDP. You must use this function even if + Tox_Options.udp_enabled was set to false. + + :param address: The hostname or IP address (IPv4 or IPv6) of the node. + :param port: The port on the host on which the bootstrap Tox instance is listening. + :param public_key: The long term public key of the bootstrap node (TOX_PUBLIC_KEY_SIZE bytes). + :return: True on success. + """ + address = bytes(address, 'utf-8') + tox_err_bootstrap = c_int() + result = Tox.libtoxcore.tox_bootstrap(self._tox_pointer, c_char_p(address), c_uint16(port), + string_to_bin(public_key), byref(tox_err_bootstrap)) + tox_err_bootstrap = tox_err_bootstrap.value + if tox_err_bootstrap == TOX_ERR_BOOTSTRAP['OK']: + return bool(result) + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['BAD_HOST']: + raise ArgumentError('The address could not be resolved to an IP ' + 'address, or the IP address passed was invalid.') + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['BAD_PORT']: + raise ArgumentError('The port passed was invalid. The valid port range is (1, 65535).') + + def add_tcp_relay(self, address, port, public_key): + """ + Adds additional host:port pair as TCP relay. + + This function can be used to initiate TCP connections to different ports on the same bootstrap node, or to add + TCP relays without using them as bootstrap nodes. + + :param address: The hostname or IP address (IPv4 or IPv6) of the TCP relay. + :param port: The port on the host on which the TCP relay is listening. + :param public_key: The long term public key of the TCP relay (TOX_PUBLIC_KEY_SIZE bytes). + :return: True on success. + """ + address = bytes(address, 'utf-8') + tox_err_bootstrap = c_int() + result = Tox.libtoxcore.tox_add_tcp_relay(self._tox_pointer, c_char_p(address), c_uint16(port), + string_to_bin(public_key), byref(tox_err_bootstrap)) + tox_err_bootstrap = tox_err_bootstrap.value + if tox_err_bootstrap == TOX_ERR_BOOTSTRAP['OK']: + return bool(result) + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['BAD_HOST']: + raise ArgumentError('The address could not be resolved to an IP ' + 'address, or the IP address passed was invalid.') + elif tox_err_bootstrap == TOX_ERR_BOOTSTRAP['BAD_PORT']: + raise ArgumentError('The port passed was invalid. The valid port range is (1, 65535).') + + def self_get_connection_status(self): + """ + Return whether we are connected to the DHT. The return value is equal to the last value received through the + `self_connection_status` callback. + + :return: TOX_CONNECTION + """ + return Tox.libtoxcore.tox_self_get_connection_status(self._tox_pointer) + + def callback_self_connection_status(self, callback): + """ + Set the callback for the `self_connection_status` event. Pass None to unset. + + This event is triggered whenever there is a change in the DHT connection state. When disconnected, a client may + choose to call tox_bootstrap again, to reconnect to the DHT. Note that this state may frequently change for + short amounts of time. Clients should therefore not immediately bootstrap on receiving a disconnect. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + TOX_CONNECTION (c_int), + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_int, c_void_p) + self.self_connection_status_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_self_connection_status(self._tox_pointer, + self.self_connection_status_cb) + + def iteration_interval(self): + """ + Return the time in milliseconds before tox_iterate() should be called again for optimal performance. + :return: time in milliseconds + """ + return Tox.libtoxcore.tox_iteration_interval(self._tox_pointer) + + def iterate(self, user_data=None): + """ + The main loop that needs to be run in intervals of tox_iteration_interval() milliseconds. + """ + if user_data is not None: + user_data = c_char_p(user_data) + Tox.libtoxcore.tox_iterate(self._tox_pointer, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Internal client information (Tox address/id) + # ----------------------------------------------------------------------------------------------------------------- + + def self_get_address(self, address=None): + """ + Writes the Tox friend address of the client to a byte array. The address is not in human-readable format. If a + client wants to display the address, formatting is required. + + :param address: pointer (c_char_p) to a memory region of at least TOX_ADDRESS_SIZE bytes. If this parameter is + None, this function allocates memory for address. + :return: Tox friend address + """ + if address is None: + address = create_string_buffer(TOX_ADDRESS_SIZE) + Tox.libtoxcore.tox_self_get_address(self._tox_pointer, address) + return bin_to_string(address, TOX_ADDRESS_SIZE) + + def self_set_nospam(self, nospam): + """ + Set the 4-byte nospam part of the address. + + :param nospam: Any 32 bit unsigned integer. + """ + Tox.libtoxcore.tox_self_set_nospam(self._tox_pointer, c_uint32(nospam)) + + def self_get_nospam(self): + """ + Get the 4-byte nospam part of the address. + + :return: nospam part of the address + """ + return Tox.libtoxcore.tox_self_get_nospam(self._tox_pointer) + + def self_get_public_key(self, public_key=None): + """ + Copy the Tox Public Key (long term) from the Tox object. + + :param public_key: A memory region of at least TOX_PUBLIC_KEY_SIZE bytes. If this parameter is NULL, this + function allocates memory for Tox Public Key. + :return: Tox Public Key + """ + if public_key is None: + public_key = create_string_buffer(TOX_PUBLIC_KEY_SIZE) + Tox.libtoxcore.tox_self_get_public_key(self._tox_pointer, public_key) + return bin_to_string(public_key, TOX_PUBLIC_KEY_SIZE) + + def self_get_secret_key(self, secret_key=None): + """ + Copy the Tox Secret Key from the Tox object. + + :param secret_key: pointer (c_char_p) to a memory region of at least TOX_SECRET_KEY_SIZE bytes. If this + parameter is NULL, this function allocates memory for Tox Secret Key. + :return: Tox Secret Key + """ + if secret_key is None: + secret_key = create_string_buffer(TOX_SECRET_KEY_SIZE) + Tox.libtoxcore.tox_self_get_secret_key(self._tox_pointer, secret_key) + return bin_to_string(secret_key, TOX_SECRET_KEY_SIZE) + + # ----------------------------------------------------------------------------------------------------------------- + # User-visible client information (nickname/status) + # ----------------------------------------------------------------------------------------------------------------- + + def self_set_name(self, name): + """ + Set the nickname for the Tox client. + + Nickname length cannot exceed TOX_MAX_NAME_LENGTH. If length is 0, the name parameter is ignored + (it can be None), and the nickname is set back to empty. + :param name: New nickname. + :return: True on success. + """ + tox_err_set_info = c_int() + name = bytes(name, 'utf-8') + result = Tox.libtoxcore.tox_self_set_name(self._tox_pointer, c_char_p(name), + c_size_t(len(name)), byref(tox_err_set_info)) + tox_err_set_info = tox_err_set_info.value + if tox_err_set_info == TOX_ERR_SET_INFO['OK']: + return bool(result) + elif tox_err_set_info == TOX_ERR_SET_INFO['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_set_info == TOX_ERR_SET_INFO['TOO_LONG']: + raise ArgumentError('Information length exceeded maximum permissible size.') + + def self_get_name_size(self): + """ + Return the length of the current nickname as passed to tox_self_set_name. + + If no nickname was set before calling this function, the name is empty, and this function returns 0. + + :return: length of the current nickname + """ + return Tox.libtoxcore.tox_self_get_name_size(self._tox_pointer) + + def self_get_name(self, name=None): + """ + Write the nickname set by tox_self_set_name to a byte array. + + If no nickname was set before calling this function, the name is empty, and this function has no effect. + + Call tox_self_get_name_size to find out how much memory to allocate for the result. + + :param name: pointer (c_char_p) to a memory region location large enough to hold the nickname. If this parameter + is NULL, the function allocates memory for the nickname. + :return: nickname + """ + if name is None: + name = create_string_buffer(self.self_get_name_size()) + Tox.libtoxcore.tox_self_get_name(self._tox_pointer, name) + return str(name.value, 'utf-8') + + def self_set_status_message(self, status_message): + """ + Set the client's status message. + + Status message length cannot exceed TOX_MAX_STATUS_MESSAGE_LENGTH. If length is 0, the status parameter is + ignored, and the user status is set back to empty. + + :param status_message: new status message + :return: True on success. + """ + tox_err_set_info = c_int() + status_message = bytes(status_message, 'utf-8') + result = Tox.libtoxcore.tox_self_set_status_message(self._tox_pointer, c_char_p(status_message), + c_size_t(len(status_message)), byref(tox_err_set_info)) + tox_err_set_info = tox_err_set_info.value + if tox_err_set_info == TOX_ERR_SET_INFO['OK']: + return bool(result) + elif tox_err_set_info == TOX_ERR_SET_INFO['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_set_info == TOX_ERR_SET_INFO['TOO_LONG']: + raise ArgumentError('Information length exceeded maximum permissible size.') + + def self_get_status_message_size(self): + """ + Return the length of the current status message as passed to tox_self_set_status_message. + + If no status message was set before calling this function, the status is empty, and this function returns 0. + + :return: length of the current status message + """ + return Tox.libtoxcore.tox_self_get_status_message_size(self._tox_pointer) + + def self_get_status_message(self, status_message=None): + """ + Write the status message set by tox_self_set_status_message to a byte array. + + If no status message was set before calling this function, the status is empty, and this function has no effect. + + Call tox_self_get_status_message_size to find out how much memory to allocate for the result. + + :param status_message: pointer (c_char_p) to a valid memory location large enough to hold the status message. + If this parameter is None, the function allocates memory for the status message. + :return: status message + """ + if status_message is None: + status_message = create_string_buffer(self.self_get_status_message_size()) + Tox.libtoxcore.tox_self_get_status_message(self._tox_pointer, status_message) + return str(status_message.value, 'utf-8') + + def self_set_status(self, status): + """ + Set the client's user status. + + :param status: One of the user statuses listed in the enumeration TOX_USER_STATUS. + """ + Tox.libtoxcore.tox_self_set_status(self._tox_pointer, c_int(status)) + + def self_get_status(self): + """ + Returns the client's user status. + + :return: client's user status + """ + return Tox.libtoxcore.tox_self_get_status(self._tox_pointer) + + # ----------------------------------------------------------------------------------------------------------------- + # Friend list management + # ----------------------------------------------------------------------------------------------------------------- + + def friend_add(self, address, message): + """ + Add a friend to the friend list and send a friend request. + + A friend request message must be at least 1 byte long and at most TOX_MAX_FRIEND_REQUEST_LENGTH. + + Friend numbers are unique identifiers used in all functions that operate on friends. Once added, a friend number + is stable for the lifetime of the Tox object. After saving the state and reloading it, the friend numbers may + not be the same as before. Deleting a friend creates a gap in the friend number set, which is filled by the next + adding of a friend. Any pattern in friend numbers should not be relied on. + + If more than INT32_MAX friends are added, this function causes undefined behaviour. + + :param address: The address of the friend (returned by tox_self_get_address of the friend you wish to add) it + must be TOX_ADDRESS_SIZE bytes. + :param message: The message that will be sent along with the friend request. + :return: the friend number on success, UINT32_MAX on failure. + """ + tox_err_friend_add = c_int() + result = Tox.libtoxcore.tox_friend_add(self._tox_pointer, string_to_bin(address), c_char_p(message), + c_size_t(len(message)), byref(tox_err_friend_add)) + tox_err_friend_add = tox_err_friend_add.value + if tox_err_friend_add == TOX_ERR_FRIEND_ADD['OK']: + return result + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['TOO_LONG']: + raise ArgumentError('The length of the friend request message exceeded TOX_MAX_FRIEND_REQUEST_LENGTH.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['NO_MESSAGE']: + raise ArgumentError('The friend request message was empty. This, and the TOO_LONG code will never be' + ' returned from tox_friend_add_norequest.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['OWN_KEY']: + raise ArgumentError('The friend address belongs to the sending client.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['ALREADY_SENT']: + raise ArgumentError('A friend request has already been sent, or the address belongs to a friend that is' + ' already on the friend list.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['BAD_CHECKSUM']: + raise ArgumentError('The friend address checksum failed.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['SET_NEW_NOSPAM']: + raise ArgumentError('The friend was already there, but the nospam value was different.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['MALLOC']: + raise MemoryError('A memory allocation failed when trying to increase the friend list size.') + + def friend_add_norequest(self, public_key): + """ + Add a friend without sending a friend request. + + This function is used to add a friend in response to a friend request. If the client receives a friend request, + it can be reasonably sure that the other client added this client as a friend, eliminating the need for a friend + request. + + This function is also useful in a situation where both instances are controlled by the same entity, so that this + entity can perform the mutual friend adding. In this case, there is no need for a friend request, either. + + :param public_key: A byte array of length TOX_PUBLIC_KEY_SIZE containing the Public Key (not the Address) of the + friend to add. + :return: the friend number on success, UINT32_MAX on failure. + """ + tox_err_friend_add = c_int() + result = Tox.libtoxcore.tox_friend_add_norequest(self._tox_pointer, string_to_bin(public_key), + byref(tox_err_friend_add)) + tox_err_friend_add = tox_err_friend_add.value + if tox_err_friend_add == TOX_ERR_FRIEND_ADD['OK']: + return result + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['TOO_LONG']: + raise ArgumentError('The length of the friend request message exceeded TOX_MAX_FRIEND_REQUEST_LENGTH.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['NO_MESSAGE']: + raise ArgumentError('The friend request message was empty. This, and the TOO_LONG code will never be' + ' returned from tox_friend_add_norequest.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['OWN_KEY']: + raise ArgumentError('The friend address belongs to the sending client.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['ALREADY_SENT']: + raise ArgumentError('A friend request has already been sent, or the address belongs to a friend that is' + ' already on the friend list.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['BAD_CHECKSUM']: + raise ArgumentError('The friend address checksum failed.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['SET_NEW_NOSPAM']: + raise ArgumentError('The friend was already there, but the nospam value was different.') + elif tox_err_friend_add == TOX_ERR_FRIEND_ADD['MALLOC']: + raise MemoryError('A memory allocation failed when trying to increase the friend list size.') + + def friend_delete(self, friend_number): + """ + Remove a friend from the friend list. + + This does not notify the friend of their deletion. After calling this function, this client will appear offline + to the friend and no communication can occur between the two. + + :param friend_number: Friend number for the friend to be deleted. + :return: True on success. + """ + tox_err_friend_delete = c_int() + result = Tox.libtoxcore.tox_friend_delete(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_delete)) + tox_err_friend_delete = tox_err_friend_delete.value + if tox_err_friend_delete == TOX_ERR_FRIEND_DELETE['OK']: + return bool(result) + elif tox_err_friend_delete == TOX_ERR_FRIEND_DELETE['FRIEND_NOT_FOUND']: + raise ArgumentError('There was no friend with the given friend number. No friends were deleted.') + + # ----------------------------------------------------------------------------------------------------------------- + # Friend list queries + # ----------------------------------------------------------------------------------------------------------------- + + def friend_by_public_key(self, public_key): + """ + Return the friend number associated with that Public Key. + + :param public_key: A byte array containing the Public Key. + :return: friend number + """ + tox_err_friend_by_public_key = c_int() + result = Tox.libtoxcore.tox_friend_by_public_key(self._tox_pointer, string_to_bin(public_key), + byref(tox_err_friend_by_public_key)) + tox_err_friend_by_public_key = tox_err_friend_by_public_key.value + if tox_err_friend_by_public_key == TOX_ERR_FRIEND_BY_PUBLIC_KEY['OK']: + return result + elif tox_err_friend_by_public_key == TOX_ERR_FRIEND_BY_PUBLIC_KEY['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_by_public_key == TOX_ERR_FRIEND_BY_PUBLIC_KEY['NOT_FOUND']: + raise ArgumentError('No friend with the given Public Key exists on the friend list.') + + def friend_exists(self, friend_number): + """ + Checks if a friend with the given friend number exists and returns true if it does. + """ + return bool(Tox.libtoxcore.tox_friend_exists(self._tox_pointer, c_uint32(friend_number))) + + def self_get_friend_list_size(self): + """ + Return the number of friends on the friend list. + + This function can be used to determine how much memory to allocate for tox_self_get_friend_list. + + :return: number of friends + """ + return Tox.libtoxcore.tox_self_get_friend_list_size(self._tox_pointer) + + def self_get_friend_list(self, friend_list=None): + """ + Copy a list of valid friend numbers into an array. + + Call tox_self_get_friend_list_size to determine the number of elements to allocate. + + :param friend_list: pointer (c_char_p) to a memory region with enough space to hold the friend list. If this + parameter is None, this function allocates memory for the friend list. + :return: friend list + """ + friend_list_size = self.self_get_friend_list_size() + if friend_list is None: + friend_list = create_string_buffer(sizeof(c_uint32) * friend_list_size) + friend_list = POINTER(c_uint32)(friend_list) + Tox.libtoxcore.tox_self_get_friend_list(self._tox_pointer, friend_list) + return friend_list[0:friend_list_size] + + def friend_get_public_key(self, friend_number, public_key=None): + """ + Copies the Public Key associated with a given friend number to a byte array. + + :param friend_number: The friend number you want the Public Key of. + :param public_key: pointer (c_char_p) to a memory region of at least TOX_PUBLIC_KEY_SIZE bytes. If this + parameter is None, this function allocates memory for Tox Public Key. + :return: Tox Public Key + """ + if public_key is None: + public_key = create_string_buffer(TOX_PUBLIC_KEY_SIZE) + tox_err_friend_get_public_key = c_int() + Tox.libtoxcore.tox_friend_get_public_key(self._tox_pointer, c_uint32(friend_number), public_key, + byref(tox_err_friend_get_public_key)) + tox_err_friend_get_public_key = tox_err_friend_get_public_key.value + if tox_err_friend_get_public_key == TOX_ERR_FRIEND_GET_PUBLIC_KEY['OK']: + return bin_to_string(public_key, TOX_PUBLIC_KEY_SIZE) + elif tox_err_friend_get_public_key == TOX_ERR_FRIEND_GET_PUBLIC_KEY['FRIEND_NOT_FOUND']: + raise ArgumentError('No friend with the given number exists on the friend list.') + + def friend_get_last_online(self, friend_number): + """ + Return a unix-time timestamp of the last time the friend associated with a given friend number was seen online. + This function will return UINT64_MAX on error. + + :param friend_number: The friend number you want to query. + :return: unix-time timestamp + """ + tox_err_last_online = c_int() + result = Tox.libtoxcore.tox_friend_get_last_online(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_last_online)) + tox_err_last_online = tox_err_last_online.value + if tox_err_last_online == TOX_ERR_FRIEND_GET_LAST_ONLINE['OK']: + return result + elif tox_err_last_online == TOX_ERR_FRIEND_GET_LAST_ONLINE['FRIEND_NOT_FOUND']: + raise ArgumentError('No friend with the given number exists on the friend list.') + + # ----------------------------------------------------------------------------------------------------------------- + # Friend-specific state queries (can also be received through callbacks) + # ----------------------------------------------------------------------------------------------------------------- + + def friend_get_name_size(self, friend_number): + """ + Return the length of the friend's name. If the friend number is invalid, the return value is unspecified. + + The return value is equal to the `length` argument received by the last `friend_name` callback. + """ + tox_err_friend_query = c_int() + result = Tox.libtoxcore.tox_friend_get_name_size(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return result + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def friend_get_name(self, friend_number, name=None): + """ + Write the name of the friend designated by the given friend number to a byte array. + + Call tox_friend_get_name_size to determine the allocation size for the `name` parameter. + + The data written to `name` is equal to the data received by the last `friend_name` callback. + + :param friend_number: number of friend + :param name: pointer (c_char_p) to a valid memory region large enough to store the friend's name. + :return: name of the friend + """ + if name is None: + name = create_string_buffer(self.friend_get_name_size(friend_number)) + tox_err_friend_query = c_int() + Tox.libtoxcore.tox_friend_get_name(self._tox_pointer, c_uint32(friend_number), name, + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return str(name.value, 'utf-8') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def callback_friend_name(self, callback): + """ + Set the callback for the `friend_name` event. Pass None to unset. + + This event is triggered when a friend changes their name. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend whose name changed, + A byte array (c_char_p) containing the same data as tox_friend_get_name would write to its `name` parameter, + A value (c_size_t) equal to the return value of tox_friend_get_name_size, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_char_p, c_size_t, c_void_p) + self.friend_name_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_name(self._tox_pointer, self.friend_name_cb) + + def friend_get_status_message_size(self, friend_number): + """ + Return the length of the friend's status message. If the friend number is invalid, the return value is SIZE_MAX. + + :return: length of the friend's status message + """ + tox_err_friend_query = c_int() + result = Tox.libtoxcore.tox_friend_get_status_message_size(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return result + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def friend_get_status_message(self, friend_number, status_message=None): + """ + Write the status message of the friend designated by the given friend number to a byte array. + + Call tox_friend_get_status_message_size to determine the allocation size for the `status_name` parameter. + + The data written to `status_message` is equal to the data received by the last `friend_status_message` callback. + + :param friend_number: + :param status_message: pointer (c_char_p) to a valid memory region large enough to store the friend's status + message. + :return: status message of the friend + """ + if status_message is None: + status_message = create_string_buffer(self.friend_get_status_message_size(friend_number)) + tox_err_friend_query = c_int() + Tox.libtoxcore.tox_friend_get_status_message(self._tox_pointer, c_uint32(friend_number), status_message, + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return str(status_message.value, 'utf-8') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def callback_friend_status_message(self, callback): + """ + Set the callback for the `friend_status_message` event. Pass NULL to unset. + + This event is triggered when a friend changes their status message. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend whose status message changed, + A byte array (c_char_p) containing the same data as tox_friend_get_status_message would write to its + `status_message` parameter, + A value (c_size_t) equal to the return value of tox_friend_get_status_message_size, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_char_p, c_size_t, c_void_p) + self.friend_status_message_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_status_message(self._tox_pointer, + self.friend_status_message_cb) + + def friend_get_status(self, friend_number): + """ + Return the friend's user status (away/busy/...). If the friend number is invalid, the return value is + unspecified. + + The status returned is equal to the last status received through the `friend_status` callback. + + :return: TOX_USER_STATUS + """ + tox_err_friend_query = c_int() + result = Tox.libtoxcore.tox_friend_get_status(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return result + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def callback_friend_status(self, callback): + """ + Set the callback for the `friend_status` event. Pass None to unset. + + This event is triggered when a friend changes their user status. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend whose user status changed, + The new user status (TOX_USER_STATUS), + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_int, c_void_p) + self.friend_status_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_status(self._tox_pointer, self.friend_status_cb) + + def friend_get_connection_status(self, friend_number): + """ + Check whether a friend is currently connected to this client. + + The result of this function is equal to the last value received by the `friend_connection_status` callback. + + :param friend_number: The friend number for which to query the connection status. + :return: the friend's connection status (TOX_CONNECTION) as it was received through the + `friend_connection_status` event. + """ + tox_err_friend_query = c_int() + result = Tox.libtoxcore.tox_friend_get_connection_status(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return result + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def callback_friend_connection_status(self, callback): + """ + Set the callback for the `friend_connection_status` event. Pass NULL to unset. + + This event is triggered when a friend goes offline after having been online, or when a friend goes online. + + This callback is not called when adding friends. It is assumed that when adding friends, their connection status + is initially offline. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend whose connection status changed, + The result of calling tox_friend_get_connection_status (TOX_CONNECTION) on the passed friend_number, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_int, c_void_p) + self.friend_connection_status_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_connection_status(self._tox_pointer, + self.friend_connection_status_cb) + + def friend_get_typing(self, friend_number): + """ + Check whether a friend is currently typing a message. + + :param friend_number: The friend number for which to query the typing status. + :return: true if the friend is typing. + """ + tox_err_friend_query = c_int() + result = Tox.libtoxcore.tox_friend_get_typing(self._tox_pointer, c_uint32(friend_number), + byref(tox_err_friend_query)) + tox_err_friend_query = tox_err_friend_query.value + if tox_err_friend_query == TOX_ERR_FRIEND_QUERY['OK']: + return bool(result) + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['NULL']: + raise ArgumentError('The pointer parameter for storing the query result (name, message) was NULL. Unlike' + ' the `_self_` variants of these functions, which have no effect when a parameter is' + ' NULL, these functions return an error in that case.') + elif tox_err_friend_query == TOX_ERR_FRIEND_QUERY['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number did not designate a valid friend.') + + def callback_friend_typing(self, callback): + """ + Set the callback for the `friend_typing` event. Pass NULL to unset. + + This event is triggered when a friend starts or stops typing. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who started or stopped typing, + The result of calling tox_friend_get_typing (c_bool) on the passed friend_number, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_bool, c_void_p) + self.friend_typing_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_typing(self._tox_pointer, self.friend_typing_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # Sending private messages + # ----------------------------------------------------------------------------------------------------------------- + + def self_set_typing(self, friend_number, typing): + """ + Set the client's typing status for a friend. + + The client is responsible for turning it on or off. + + :param friend_number: The friend to which the client is typing a message. + :param typing: The typing status. True means the client is typing. + :return: True on success. + """ + tox_err_set_typing = c_int() + result = Tox.libtoxcore.tox_self_set_typing(self._tox_pointer, c_uint32(friend_number), + c_bool(typing), byref(tox_err_set_typing)) + tox_err_set_typing = tox_err_set_typing.value + if tox_err_set_typing == TOX_ERR_SET_TYPING['OK']: + return bool(result) + elif tox_err_set_typing == TOX_ERR_SET_TYPING['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + + def friend_send_message(self, friend_number, message_type, message): + """ + Send a text chat message to an online friend. + + This function creates a chat message packet and pushes it into the send queue. + + The message length may not exceed TOX_MAX_MESSAGE_LENGTH. Larger messages must be split by the client and sent + as separate messages. Other clients can then reassemble the fragments. Messages may not be empty. + + The return value of this function is the message ID. If a read receipt is received, the triggered + `friend_read_receipt` event will be passed this message ID. + + Message IDs are unique per friend. The first message ID is 0. Message IDs are incremented by 1 each time a + message is sent. If UINT32_MAX messages were sent, the next message ID is 0. + + :param friend_number: The friend number of the friend to send the message to. + :param message_type: Message type (TOX_MESSAGE_TYPE). + :param message: A non-None message text. + :return: message ID + """ + tox_err_friend_send_message = c_int() + result = Tox.libtoxcore.tox_friend_send_message(self._tox_pointer, c_uint32(friend_number), + c_int(message_type), c_char_p(message), c_size_t(len(message)), + byref(tox_err_friend_send_message)) + tox_err_friend_send_message = tox_err_friend_send_message.value + if tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['OK']: + return result + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['SENDQ']: + raise MemoryError('An allocation error occurred while increasing the send queue size.') + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['TOO_LONG']: + raise ArgumentError('Message length exceeded TOX_MAX_MESSAGE_LENGTH.') + elif tox_err_friend_send_message == TOX_ERR_FRIEND_SEND_MESSAGE['EMPTY']: + raise ArgumentError('Attempted to send a zero-length message.') + + def callback_friend_read_receipt(self, callback): + """ + Set the callback for the `friend_read_receipt` event. Pass None to unset. + + This event is triggered when the friend receives the message sent with tox_friend_send_message with the + corresponding message ID. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who received the message, + The message ID (c_uint32) as returned from tox_friend_send_message corresponding to the message sent, + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_void_p) + self.friend_read_receipt_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_read_receipt(self._tox_pointer, + self.friend_read_receipt_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # Receiving private messages and friend requests + # ----------------------------------------------------------------------------------------------------------------- + + def callback_friend_request(self, callback): + """ + Set the callback for the `friend_request` event. Pass None to unset. + + This event is triggered when a friend request is received. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The Public Key (c_uint8 array) of the user who sent the friend request, + The message (c_char_p) they sent along with the request, + The size (c_size_t) of the message byte array, + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, POINTER(c_uint8), c_char_p, c_size_t, c_void_p) + self.friend_request_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_request(self._tox_pointer, self.friend_request_cb) + + def callback_friend_message(self, callback): + """ + Set the callback for the `friend_message` event. Pass None to unset. + + This event is triggered when a message from a friend is received. + + :param callback: Python function. Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who sent the message, + Message type (TOX_MESSAGE_TYPE), + The message data (c_char_p) they sent, + The size (c_size_t) of the message byte array. + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_int, c_char_p, c_size_t, c_void_p) + self.friend_message_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_friend_message(self._tox_pointer, self.friend_message_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # File transmission: common between sending and receiving + # ----------------------------------------------------------------------------------------------------------------- + + @staticmethod + def hash(data, hash=None): + """ + Generates a cryptographic hash of the given data. + + This function may be used by clients for any purpose, but is provided primarily for validating cached avatars. + This use is highly recommended to avoid unnecessary avatar updates. + + If hash is NULL or data is NULL while length is not 0 the function returns false, otherwise it returns true. + + This function is a wrapper to internal message-digest functions. + + :param hash: A valid memory location the hash data. It must be at least TOX_HASH_LENGTH bytes in size. + :param data: Data to be hashed or NULL. + :return: true if hash was not NULL. + """ + if hash is None: + hash = create_string_buffer(TOX_HASH_LENGTH) + Tox.libtoxcore.tox_hash(hash, c_char_p(data), len(data)) + return bin_to_string(hash, TOX_HASH_LENGTH) + + def file_control(self, friend_number, file_number, control): + """ + Sends a file control command to a friend for a given file transfer. + + :param friend_number: The friend number of the friend the file is being transferred to or received from. + :param file_number: The friend-specific identifier for the file transfer. + :param control: The control (TOX_FILE_CONTROL) command to send. + :return: True on success. + """ + tox_err_file_control = c_int() + result = Tox.libtoxcore.tox_file_control(self._tox_pointer, c_uint32(friend_number), c_uint32(file_number), + c_int(control), byref(tox_err_file_control)) + tox_err_file_control = tox_err_file_control.value + if tox_err_file_control == TOX_ERR_FILE_CONTROL['OK']: + return bool(result) + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['NOT_FOUND']: + raise ArgumentError('No file transfer with the given file number was found for the given friend.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['NOT_PAUSED']: + raise RuntimeError('A RESUME control was sent, but the file transfer is running normally.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['DENIED']: + raise RuntimeError('A RESUME control was sent, but the file transfer was paused by the other party. Only ' + 'the party that paused the transfer can resume it.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['ALREADY_PAUSED']: + raise RuntimeError('A PAUSE control was sent, but the file transfer was already paused.') + elif tox_err_file_control == TOX_ERR_FILE_CONTROL['SENDQ']: + raise RuntimeError('Packet queue is full.') + + def callback_file_recv_control(self, callback): + """ + Set the callback for the `file_recv_control` event. Pass NULL to unset. + + This event is triggered when a file control command is received from a friend. + + :param callback: Python function. + When receiving TOX_FILE_CONTROL_CANCEL, the client should release the resources associated with the file number + and consider the transfer failed. + + Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who is sending the file. + The friend-specific file number (c_uint32) the data received is associated with. + The file control (TOX_FILE_CONTROL) command received. + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_int, c_void_p) + self.file_recv_control_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_file_recv_control(self._tox_pointer, + self.file_recv_control_cb) + + def file_seek(self, friend_number, file_number, position): + """ + Sends a file seek control command to a friend for a given file transfer. + + This function can only be called to resume a file transfer right before TOX_FILE_CONTROL_RESUME is sent. + + :param friend_number: The friend number of the friend the file is being received from. + :param file_number: The friend-specific identifier for the file transfer. + :param position: The position that the file should be seeked to. + :return: True on success. + """ + tox_err_file_seek = c_int() + result = Tox.libtoxcore.tox_file_control(self._tox_pointer, c_uint32(friend_number), c_uint32(file_number), + c_uint64(position), byref(tox_err_file_seek)) + tox_err_file_seek = tox_err_file_seek.value + if tox_err_file_seek == TOX_ERR_FILE_SEEK['OK']: + return bool(result) + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['NOT_FOUND']: + raise ArgumentError('No file transfer with the given file number was found for the given friend.') + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['SEEK_DENIED']: + raise IOError('File was not in a state where it could be seeked.') + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['INVALID_POSITION']: + raise ArgumentError('Seek position was invalid') + elif tox_err_file_seek == TOX_ERR_FILE_SEEK['SENDQ']: + raise RuntimeError('Packet queue is full.') + + def file_get_file_id(self, friend_number, file_number, file_id=None): + """ + Copy the file id associated to the file transfer to a byte array. + + :param friend_number: The friend number of the friend the file is being transferred to or received from. + :param file_number: The friend-specific identifier for the file transfer. + :param file_id: A pointer (c_char_p) to memory region of at least TOX_FILE_ID_LENGTH bytes. If this parameter is + None, this function has no effect. + :return: file id. + """ + if file_id is None: + file_id = create_string_buffer(TOX_FILE_ID_LENGTH) + tox_err_file_get = c_int() + Tox.libtoxcore.tox_file_get_file_id(self._tox_pointer, c_uint32(friend_number), c_uint32(file_number), file_id, + byref(tox_err_file_get)) + tox_err_file_get = tox_err_file_get.value + if tox_err_file_get == TOX_ERR_FILE_GET['OK']: + return bin_to_string(file_id, TOX_FILE_ID_LENGTH) + elif tox_err_file_get == TOX_ERR_FILE_GET['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_file_get == TOX_ERR_FILE_GET['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif tox_err_file_get == TOX_ERR_FILE_GET['NOT_FOUND']: + raise ArgumentError('No file transfer with the given file number was found for the given friend.') + + # ----------------------------------------------------------------------------------------------------------------- + # File transmission: sending + # ----------------------------------------------------------------------------------------------------------------- + + def file_send(self, friend_number, kind, file_size, file_id, filename): + """ + Send a file transmission request. + + Maximum filename length is TOX_MAX_FILENAME_LENGTH bytes. The filename should generally just be a file name, not + a path with directory names. + + If a non-UINT64_MAX file size is provided, it can be used by both sides to determine the sending progress. File + size can be set to UINT64_MAX for streaming data of unknown size. + + File transmission occurs in chunks, which are requested through the `file_chunk_request` event. + + When a friend goes offline, all file transfers associated with the friend are purged from core. + + If the file contents change during a transfer, the behaviour is unspecified in general. What will actually + happen depends on the mode in which the file was modified and how the client determines the file size. + + - If the file size was increased + - and sending mode was streaming (file_size = UINT64_MAX), the behaviour will be as expected. + - and sending mode was file (file_size != UINT64_MAX), the file_chunk_request callback will receive length = + 0 when Core thinks the file transfer has finished. If the client remembers the file size as it was when + sending the request, it will terminate the transfer normally. If the client re-reads the size, it will think + the friend cancelled the transfer. + - If the file size was decreased + - and sending mode was streaming, the behaviour is as expected. + - and sending mode was file, the callback will return 0 at the new (earlier) end-of-file, signalling to the + friend that the transfer was cancelled. + - If the file contents were modified + - at a position before the current read, the two files (local and remote) will differ after the transfer + terminates. + - at a position after the current read, the file transfer will succeed as expected. + - In either case, both sides will regard the transfer as complete and successful. + + :param friend_number: The friend number of the friend the file send request should be sent to. + :param kind: The meaning of the file to be sent. + :param file_size: Size in bytes of the file the client wants to send, UINT64_MAX if unknown or streaming. + :param file_id: A file identifier of length TOX_FILE_ID_LENGTH that can be used to uniquely identify file + transfers across core restarts. If NULL, a random one will be generated by core. It can then be obtained by + using tox_file_get_file_id(). + :param filename: Name of the file. Does not need to be the actual name. This name will be sent along with the + file send request. + :return: A file number used as an identifier in subsequent callbacks. This number is per friend. File numbers + are reused after a transfer terminates. On failure, this function returns UINT32_MAX. Any pattern in file + numbers should not be relied on. + """ + tox_err_file_send = c_int() + result = self.libtoxcore.tox_file_send(self._tox_pointer, c_uint32(friend_number), c_uint32(kind), + c_uint64(file_size), + string_to_bin(file_id), + c_char_p(filename), + c_size_t(len(filename)), byref(tox_err_file_send)) + tox_err_file_send = tox_err_file_send.value + if tox_err_file_send == TOX_ERR_FILE_SEND['OK']: + return result + elif tox_err_file_send == TOX_ERR_FILE_SEND['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_file_send == TOX_ERR_FILE_SEND['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif tox_err_file_send == TOX_ERR_FILE_SEND['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_file_send == TOX_ERR_FILE_SEND['NAME_TOO_LONG']: + raise ArgumentError('Filename length exceeded TOX_MAX_FILENAME_LENGTH bytes.') + elif tox_err_file_send == TOX_ERR_FILE_SEND['TOO_MANY']: + raise RuntimeError('Too many ongoing transfers. The maximum number of concurrent file transfers is 256 per' + 'friend per direction (sending and receiving).') + + def file_send_chunk(self, friend_number, file_number, position, data): + """ + Send a chunk of file data to a friend. + + This function is called in response to the `file_chunk_request` callback. The length parameter should be equal + to the one received though the callback. If it is zero, the transfer is assumed complete. For files with known + size, Core will know that the transfer is complete after the last byte has been received, so it is not necessary + (though not harmful) to send a zero-length chunk to terminate. For streams, core will know that the transfer is + finished if a chunk with length less than the length requested in the callback is sent. + + :param friend_number: The friend number of the receiving friend for this file. + :param file_number: The file transfer identifier returned by tox_file_send. + :param position: The file or stream position from which to continue reading. + :param data: Chunk of file data + :return: true on success. + """ + tox_err_file_send_chunk = c_int() + result = self.libtoxcore.tox_file_send_chunk(self._tox_pointer, c_uint32(friend_number), c_uint32(file_number), + c_uint64(position), c_char_p(data), c_size_t(len(data)), + byref(tox_err_file_send_chunk)) + tox_err_file_send_chunk = tox_err_file_send_chunk.value + if tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['OK']: + return bool(result) + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['NULL']: + raise ArgumentError('The length parameter was non-zero, but data was NULL.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['FRIEND_NOT_FOUND']: + ArgumentError('The friend_number passed did not designate a valid friend.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['NOT_FOUND']: + raise ArgumentError('No file transfer with the given file number was found for the given friend.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['NOT_TRANSFERRING']: + raise ArgumentError('File transfer was found but isn\'t in a transferring state: (paused, done, broken, ' + 'etc...) (happens only when not called from the request chunk callback).') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['INVALID_LENGTH']: + raise ArgumentError('Attempted to send more or less data than requested. The requested data size is ' + 'adjusted according to maximum transmission unit and the expected end of the file. ' + 'Trying to send less or more than requested will return this error.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['SENDQ']: + raise RuntimeError('Packet queue is full.') + elif tox_err_file_send_chunk == TOX_ERR_FILE_SEND_CHUNK['WRONG_POSITION']: + raise ArgumentError('Position parameter was wrong.') + + def callback_file_chunk_request(self, callback): + """ + Set the callback for the `file_chunk_request` event. Pass None to unset. + + This event is triggered when Core is ready to send more file data. + + :param callback: Python function. + If the length parameter is 0, the file transfer is finished, and the client's resources associated with the file + number should be released. After a call with zero length, the file number can be reused for future file + transfers. + + If the requested position is not equal to the client's idea of the current file or stream position, it will need + to seek. In case of read-once streams, the client should keep the last read chunk so that a seek back can be + supported. A seek-back only ever needs to read from the last requested chunk. This happens when a chunk was + requested, but the send failed. A seek-back request can occur an arbitrary number of times for any given chunk. + + In response to receiving this callback, the client should call the function `tox_file_send_chunk` with the + requested chunk. If the number of bytes sent through that function is zero, the file transfer is assumed + complete. A client must send the full length of data requested with this callback. + + Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the receiving friend for this file. + The file transfer identifier (c_uint32) returned by tox_file_send. + The file or stream position (c_uint64) from which to continue reading. + The number of bytes (c_size_t) requested for the current chunk. + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_uint64, c_size_t, c_void_p) + self.file_chunk_request_cb = c_callback(callback) + self.libtoxcore.tox_callback_file_chunk_request(self._tox_pointer, self.file_chunk_request_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # File transmission: receiving + # ----------------------------------------------------------------------------------------------------------------- + + def callback_file_recv(self, callback): + """ + Set the callback for the `file_recv` event. Pass None to unset. + + This event is triggered when a file transfer request is received. + + :param callback: Python function. + The client should acquire resources to be associated with the file transfer. Incoming file transfers start in + the PAUSED state. After this callback returns, a transfer can be rejected by sending a TOX_FILE_CONTROL_CANCEL + control command before any other control commands. It can be accepted by sending TOX_FILE_CONTROL_RESUME. + + Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who is sending the file transfer request. + The friend-specific file number (c_uint32) the data received is associated with. + The meaning of the file (c_uint32) to be sent. + Size in bytes (c_uint64) of the file the client wants to send, UINT64_MAX if unknown or streaming. + Name of the file (c_char_p). Does not need to be the actual name. This name will be sent along with the file + send request. + Size in bytes (c_size_t) of the filename. + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_uint32, c_uint64, c_char_p, c_size_t, c_void_p) + self.file_recv_cb = c_callback(callback) + self.libtoxcore.tox_callback_file_recv(self._tox_pointer, self.file_recv_cb) + + def callback_file_recv_chunk(self, callback): + """ + Set the callback for the `file_recv_chunk` event. Pass NULL to unset. + + This event is first triggered when a file transfer request is received, and subsequently when a chunk of file + data for an accepted request was received. + + :param callback: Python function. + When length is 0, the transfer is finished and the client should release the resources it acquired for the + transfer. After a call with length = 0, the file number can be reused for new file transfers. + + If position is equal to file_size (received in the file_receive callback) when the transfer finishes, the file + was received completely. Otherwise, if file_size was UINT64_MAX, streaming ended successfully when length is 0. + + Should take pointer (c_void_p) to Tox object, + The friend number (c_uint32) of the friend who is sending the file. + The friend-specific file number (c_uint32) the data received is associated with. + The file position (c_uint64) of the first byte in data. + A byte array (c_char_p) containing the received chunk. + The length (c_size_t) of the received chunk. + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_uint64, POINTER(c_uint8), c_size_t, c_void_p) + self.file_recv_chunk_cb = c_callback(callback) + self.libtoxcore.tox_callback_file_recv_chunk(self._tox_pointer, self.file_recv_chunk_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # Low-level custom packet sending and receiving + # ----------------------------------------------------------------------------------------------------------------- + + def friend_send_lossy_packet(self, friend_number, data): + """ + Send a custom lossy packet to a friend. + The first byte of data must be in the range 200-254. Maximum length of a + custom packet is TOX_MAX_CUSTOM_PACKET_SIZE. + + Lossy packets behave like UDP packets, meaning they might never reach the + other side or might arrive more than once (if someone is messing with the + connection) or might arrive in the wrong order. + + Unless latency is an issue, it is recommended that you use lossless custom packets instead. + + :param friend_number: The friend number of the friend this lossy packet + :param data: python string containing the packet data + :return: True on success. + """ + tox_err_friend_custom_packet = c_int() + result = self.libtoxcore.tox_friend_send_lossy_packet(self._tox_pointer, c_uint32(friend_number), + c_char_p(data), c_size_t(len(data)), + byref(tox_err_friend_custom_packet)) + tox_err_friend_custom_packet = tox_err_friend_custom_packet.value + if tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['OK']: + return bool(result) + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['INVALID']: + raise ArgumentError('The first byte of data was not in the specified range for the packet type.' + 'This range is 200-254 for lossy, and 160-191 for lossless packets.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['EMPTY']: + raise ArgumentError('Attempted to send an empty packet.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['TOO_LONG']: + raise ArgumentError('Packet data length exceeded TOX_MAX_CUSTOM_PACKET_SIZE.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['SENDQ']: + raise RuntimeError('Packet queue is full.') + + def friend_send_lossless_packet(self, friend_number, data): + """ + Send a custom lossless packet to a friend. + The first byte of data must be in the range 160-191. Maximum length of a + custom packet is TOX_MAX_CUSTOM_PACKET_SIZE. + + Lossless packet behaviour is comparable to TCP (reliability, arrive in order) + but with packets instead of a stream. + + :param friend_number: The friend number of the friend this lossless packet + :param data: python string containing the packet data + :return: True on success. + """ + tox_err_friend_custom_packet = c_int() + result = self.libtoxcore.tox_friend_send_lossless_packet(self._tox_pointer, c_uint32(friend_number), + c_char_p(data), c_size_t(len(data)), + byref(tox_err_friend_custom_packet)) + tox_err_friend_custom_packet = tox_err_friend_custom_packet.value + if tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['OK']: + return bool(result) + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['FRIEND_NOT_CONNECTED']: + raise ArgumentError('This client is currently not connected to the friend.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['INVALID']: + raise ArgumentError('The first byte of data was not in the specified range for the packet type.' + 'This range is 200-254 for lossy, and 160-191 for lossless packets.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['EMPTY']: + raise ArgumentError('Attempted to send an empty packet.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['TOO_LONG']: + raise ArgumentError('Packet data length exceeded TOX_MAX_CUSTOM_PACKET_SIZE.') + elif tox_err_friend_custom_packet == TOX_ERR_FRIEND_CUSTOM_PACKET['SENDQ']: + raise RuntimeError('Packet queue is full.') + + def callback_friend_lossy_packet(self, callback): + """ + Set the callback for the `friend_lossy_packet` event. Pass NULL to unset. + + :param callback: Python function. + Should take pointer (c_void_p) to Tox object, + friend_number (c_uint32) - The friend number of the friend who sent a lossy packet, + A byte array (c_uint8 array) containing the received packet data, + length (c_size_t) - The length of the packet data byte array, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(c_uint8), c_size_t, c_void_p) + self.friend_lossy_packet_cb = c_callback(callback) + self.libtoxcore.tox_callback_friend_lossy_packet(self._tox_pointer, self.friend_lossy_packet_cb) + + def callback_friend_lossless_packet(self, callback): + """ + Set the callback for the `friend_lossless_packet` event. Pass NULL to unset. + + :param callback: Python function. + Should take pointer (c_void_p) to Tox object, + friend_number (c_uint32) - The friend number of the friend who sent a lossless packet, + A byte array (c_uint8 array) containing the received packet data, + length (c_size_t) - The length of the packet data byte array, + pointer (c_void_p) to user_data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(c_uint8), c_size_t, c_void_p) + self.friend_lossless_packet_cb = c_callback(callback) + self.libtoxcore.tox_callback_friend_lossless_packet(self._tox_pointer, self.friend_lossless_packet_cb) + + # ----------------------------------------------------------------------------------------------------------------- + # Low-level network information + # ----------------------------------------------------------------------------------------------------------------- + + def self_get_dht_id(self, dht_id=None): + """ + Writes the temporary DHT public key of this instance to a byte array. + + This can be used in combination with an externally accessible IP address and the bound port (from + tox_self_get_udp_port) to run a temporary bootstrap node. + + Be aware that every time a new instance is created, the DHT public key changes, meaning this cannot be used to + run a permanent bootstrap node. + + :param dht_id: pointer (c_char_p) to a memory region of at least TOX_PUBLIC_KEY_SIZE bytes. If this parameter is + None, this function allocates memory for dht_id. + :return: dht_id + """ + if dht_id is None: + dht_id = create_string_buffer(TOX_PUBLIC_KEY_SIZE) + Tox.libtoxcore.tox_self_get_dht_id(self._tox_pointer, dht_id) + return bin_to_string(dht_id, TOX_PUBLIC_KEY_SIZE) + + def self_get_udp_port(self): + """ + Return the UDP port this Tox instance is bound to. + """ + tox_err_get_port = c_int() + result = Tox.libtoxcore.tox_self_get_udp_port(self._tox_pointer, byref(tox_err_get_port)) + tox_err_get_port = tox_err_get_port.value + if tox_err_get_port == TOX_ERR_GET_PORT['OK']: + return result + elif tox_err_get_port == TOX_ERR_GET_PORT['NOT_BOUND']: + raise RuntimeError('The instance was not bound to any port.') + + def self_get_tcp_port(self): + """ + Return the TCP port this Tox instance is bound to. This is only relevant if the instance is acting as a TCP + relay. + """ + tox_err_get_port = c_int() + result = Tox.libtoxcore.tox_self_get_tcp_port(self._tox_pointer, byref(tox_err_get_port)) + tox_err_get_port = tox_err_get_port.value + if tox_err_get_port == TOX_ERR_GET_PORT['OK']: + return result + elif tox_err_get_port == TOX_ERR_GET_PORT['NOT_BOUND']: + raise RuntimeError('The instance was not bound to any port.') + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat instance management + # ----------------------------------------------------------------------------------------------------------------- + + def group_new(self, privacy_state, group_name, nick, status): + """ + Creates a new group chat. + + This function creates a new group chat object and adds it to the chats array. + + The client should initiate its peer list with self info after calling this function, as + the peer_join callback will not be triggered. + + :param privacy_state: The privacy state of the group. If this is set to TOX_GROUP_PRIVACY_STATE_PUBLIC, + the group will attempt to announce itself to the DHT and anyone with the Chat ID may join. + Otherwise a friend invite will be required to join the group. + :param group_name: The name of the group. The name must be non-NULL. + + :return group number on success, UINT32_MAX on failure. + """ + + error = c_int() + peer_info = self.group_self_peer_info_new() + nick = bytes(nick, 'utf-8') + group_name = group_name.encode('utf-8') + peer_info.contents.nick = c_char_p(nick) + peer_info.contents.nick_length = len(nick) + peer_info.contents.user_status = status + result = Tox.libtoxcore.tox_group_new(self._tox_pointer, privacy_state, group_name, + len(group_name), peer_info, byref(error)) + return result + + def group_join(self, chat_id, password, nick, status): + """ + Joins a group chat with specified Chat ID. + + This function creates a new group chat object, adds it to the chats array, and sends + a DHT announcement to find peers in the group associated with chat_id. Once a peer has been + found a join attempt will be initiated. + + :param chat_id: The Chat ID of the group you wish to join. This must be TOX_GROUP_CHAT_ID_SIZE bytes. + :param password: The password required to join the group. Set to NULL if no password is required. + + :return group_number on success, UINT32_MAX on failure. + """ + + error = c_int() + peer_info = self.group_self_peer_info_new() + nick = bytes(nick, 'utf-8') + peer_info.contents.nick = c_char_p(nick) + peer_info.contents.nick_length = len(nick) + peer_info.contents.user_status = status + result = Tox.libtoxcore.tox_group_join(self._tox_pointer, string_to_bin(chat_id), + password, + len(password) if password is not None else 0, + peer_info, + byref(error)) + return result + + def group_reconnect(self, group_number): + """ + Reconnects to a group. + + This function disconnects from all peers in the group, then attempts to reconnect with the group. + The caller's state is not changed (i.e. name, status, role, chat public key etc.) + + :param group_number: The group number of the group we wish to reconnect to. + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_reconnect(self._tox_pointer, group_number, byref(error)) + return result + + def group_is_connected(self, group_number): + error = c_int() + result = Tox.libtoxcore.tox_group_is_connected(self._tox_pointer, group_number, byref(error)) + return result + + def group_disconnect(self, group_number): + error = c_int() + result = Tox.libtoxcore.tox_group_disconnect(self._tox_pointer, group_number, byref(error)) + return result + + def group_leave(self, group_number, message=''): + """ + Leaves a group. + + This function sends a parting packet containing a custom (non-obligatory) message to all + peers in a group, and deletes the group from the chat array. All group state information is permanently + lost, including keys and role credentials. + + :param group_number: The group number of the group we wish to leave. + :param message: The parting message to be sent to all the peers. Set to NULL if we do not wish to + send a parting message. + + :return True if the group chat instance was successfully deleted. + """ + + error = c_int() + f = Tox.libtoxcore.tox_group_leave + f.restype = c_bool + result = f(self._tox_pointer, group_number, message, + len(message) if message is not None else 0, byref(error)) + return result + + # ----------------------------------------------------------------------------------------------------------------- + # Group user-visible client information (nickname/status/role/public key) + # ----------------------------------------------------------------------------------------------------------------- + + def group_self_set_name(self, group_number, name): + """ + Set the client's nickname for the group instance designated by the given group number. + + Nickname length cannot exceed TOX_MAX_NAME_LENGTH. If length is equal to zero or name is a NULL + pointer, the function call will fail. + + :param name: A byte array containing the new nickname. + + :return True on success. + """ + + error = c_int() + name = bytes(name, 'utf-8') + result = Tox.libtoxcore.tox_group_self_set_name(self._tox_pointer, group_number, name, len(name), byref(error)) + return result + + def group_self_get_name_size(self, group_number): + """ + Return the length of the client's current nickname for the group instance designated + by group_number as passed to tox_group_self_set_name. + + If no nickname was set before calling this function, the name is empty, + and this function returns 0. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_self_get_name_size(self._tox_pointer, group_number, byref(error)) + return result + + def group_self_get_name(self, group_number): + """ + Write the nickname set by tox_group_self_set_name to a byte array. + + If no nickname was set before calling this function, the name is empty, + and this function has no effect. + + Call tox_group_self_get_name_size to find out how much memory to allocate for the result. + :return nickname + """ + + error = c_int() + size = self.group_self_get_name_size(group_number) + name = create_string_buffer(size) + result = Tox.libtoxcore.tox_group_self_get_name(self._tox_pointer, group_number, name, byref(error)) + return str(name[:size], 'utf-8') + + def group_self_set_status(self, group_number, status): + + """ + Set the client's status for the group instance. Status must be a TOX_USER_STATUS. + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_self_set_status(self._tox_pointer, group_number, status, byref(error)) + return result + + def group_self_get_status(self, group_number): + """ + returns the client's status for the group instance on success. + return value is unspecified on failure. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_self_get_status(self._tox_pointer, group_number, byref(error)) + return result + + def group_self_get_role(self, group_number): + """ + returns the client's role for the group instance on success. + return value is unspecified on failure. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_self_get_role(self._tox_pointer, group_number, byref(error)) + return result + + def group_self_get_peer_id(self, group_number): + """ + returns the client's peer id for the group instance on success. + return value is unspecified on failure. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_self_get_peer_id(self._tox_pointer, group_number, byref(error)) + return result + + def group_self_get_public_key(self, group_number): + """ + Write the client's group public key designated by the given group number to a byte array. + + This key will be permanently tied to the client's identity for this particular group until + the client explicitly leaves the group or gets kicked/banned. This key is the only way for + other peers to reliably identify the client across client restarts. + + `public_key` should have room for at least TOX_GROUP_PEER_PUBLIC_KEY_SIZE bytes. + + :return public key + """ + + error = c_int() + key = create_string_buffer(TOX_GROUP_PEER_PUBLIC_KEY_SIZE) + result = Tox.libtoxcore.tox_group_self_get_public_key(self._tox_pointer, group_number, + key, byref(error)) + return bin_to_string(key, TOX_GROUP_PEER_PUBLIC_KEY_SIZE) + + # ----------------------------------------------------------------------------------------------------------------- + # Peer-specific group state queries. + # ----------------------------------------------------------------------------------------------------------------- + + def group_peer_get_name_size(self, group_number, peer_id): + """ + Return the length of the peer's name. If the group number or ID is invalid, the + return value is unspecified. + + The return value is equal to the `length` argument received by the last + `group_peer_name` callback. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_peer_get_name_size(self._tox_pointer, group_number, peer_id, byref(error)) + return result + + def group_peer_get_name(self, group_number, peer_id): + """ + Write the name of the peer designated by the given ID to a byte + array. + + Call tox_group_peer_get_name_size to determine the allocation size for the `name` parameter. + + The data written to `name` is equal to the data received by the last + `group_peer_name` callback. + + :param group_number: The group number of the group we wish to query. + :param peer_id: The ID of the peer whose name we want to retrieve. + + :return name. + """ + error = c_int() + size = self.group_peer_get_name_size(group_number, peer_id) + name = create_string_buffer(size) + result = Tox.libtoxcore.tox_group_peer_get_name(self._tox_pointer, group_number, peer_id, name, byref(error)) + return str(name[:], 'utf-8') + + def group_peer_get_status(self, group_number, peer_id): + """ + Return the peer's user status (away/busy/...). If the ID or group number is + invalid, the return value is unspecified. + + The status returned is equal to the last status received through the + `group_peer_status` callback. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_peer_get_status(self._tox_pointer, group_number, peer_id, byref(error)) + return result + + def group_peer_get_role(self, group_number, peer_id): + """ + Return the peer's role (user/moderator/founder...). If the ID or group number is + invalid, the return value is unspecified. + + The role returned is equal to the last role received through the + `group_moderation` callback. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_peer_get_role(self._tox_pointer, group_number, peer_id, byref(error)) + return result + + def group_peer_get_public_key(self, group_number, peer_id): + """ + Write the group public key with the designated peer_id for the designated group number to public_key. + + This key will be permanently tied to a particular peer until they explicitly leave the group or + get kicked/banned, and is the only way to reliably identify the same peer across client restarts. + + `public_key` should have room for at least TOX_GROUP_PEER_PUBLIC_KEY_SIZE bytes. + + :return public key + """ + + error = c_int() + key = create_string_buffer(TOX_GROUP_PEER_PUBLIC_KEY_SIZE) + result = Tox.libtoxcore.tox_group_peer_get_public_key(self._tox_pointer, group_number, peer_id, + key, byref(error)) + return bin_to_string(key, TOX_GROUP_PEER_PUBLIC_KEY_SIZE) + + def callback_group_peer_name(self, callback, user_data): + """ + Set the callback for the `group_peer_name` event. Pass NULL to unset. + This event is triggered when a peer changes their nickname. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_void_p) + self.group_peer_name_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_peer_name(self._tox_pointer, self.group_peer_name_cb, user_data) + + def callback_group_peer_status(self, callback, user_data): + """ + Set the callback for the `group_peer_status` event. Pass NULL to unset. + This event is triggered when a peer changes their status. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_int, c_void_p) + self.group_peer_status_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_peer_status(self._tox_pointer, self.group_peer_status_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat state queries and events. + # ----------------------------------------------------------------------------------------------------------------- + + def group_set_topic(self, group_number, topic): + """ + Set the group topic and broadcast it to the rest of the group. + + topic length cannot be longer than TOX_GROUP_MAX_TOPIC_LENGTH. If length is equal to zero or + topic is set to NULL, the topic will be unset. + + :return True on success. + """ + + error = c_int() + topic = bytes(topic, 'utf-8') + result = Tox.libtoxcore.tox_group_set_topic(self._tox_pointer, group_number, topic, len(topic), byref(error)) + return result + + def group_get_topic_size(self, group_number): + """ + Return the length of the group topic. If the group number is invalid, the + return value is unspecified. + + The return value is equal to the `length` argument received by the last + `group_topic` callback. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_get_topic_size(self._tox_pointer, group_number, byref(error)) + return result + + def group_get_topic(self, group_number): + """ + Write the topic designated by the given group number to a byte array. + Call tox_group_get_topic_size to determine the allocation size for the `topic` parameter. + The data written to `topic` is equal to the data received by the last + `group_topic` callback. + + :return topic + """ + + error = c_int() + size = self.group_get_topic_size(group_number) + topic = create_string_buffer(size) + result = Tox.libtoxcore.tox_group_get_topic(self._tox_pointer, group_number, topic, byref(error)) + return str(topic[:size], 'utf-8') + + def group_get_name_size(self, group_number): + """ + Return the length of the group name. If the group number is invalid, the + return value is unspecified. + """ + error = c_int() + result = Tox.libtoxcore.tox_group_get_name_size(self._tox_pointer, group_number, byref(error)) + return int(result) + + def group_get_name(self, group_number): + """ + Write the name of the group designated by the given group number to a byte array. + Call tox_group_get_name_size to determine the allocation size for the `name` parameter. + :return true on success. + """ + + error = c_int() + size = self.group_get_name_size(group_number) + name = create_string_buffer(size) + result = Tox.libtoxcore.tox_group_get_name(self._tox_pointer, group_number, + name, byref(error)) + return str(name[:size], 'utf-8') + + def group_get_chat_id(self, group_number): + """ + Write the Chat ID designated by the given group number to a byte array. + `chat_id` should have room for at least TOX_GROUP_CHAT_ID_SIZE bytes. + :return chat id. + """ + + error = c_int() + buff = create_string_buffer(TOX_GROUP_CHAT_ID_SIZE) + result = Tox.libtoxcore.tox_group_get_chat_id(self._tox_pointer, group_number, + buff, byref(error)) + return bin_to_string(buff, TOX_GROUP_CHAT_ID_SIZE) + + def group_get_number_groups(self): + """ + Return the number of groups in the Tox chats array. + """ + + result = Tox.libtoxcore.tox_group_get_number_groups(self._tox_pointer) + return result + + def groups_get_list(self): + groups_list_size = self.group_get_number_groups() + groups_list = create_string_buffer(sizeof(c_uint32) * groups_list_size) + groups_list = POINTER(c_uint32)(groups_list) + Tox.libtoxcore.tox_groups_get_list(self._tox_pointer, groups_list) + return groups_list[0:groups_list_size] + + def group_get_privacy_state(self, group_number): + """ + Return the privacy state of the group designated by the given group number. If group number + is invalid, the return value is unspecified. + + The value returned is equal to the data received by the last + `group_privacy_state` callback. + + see the `Group chat founder controls` section for the respective set function. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_get_privacy_state(self._tox_pointer, group_number, byref(error)) + return result + + def group_get_peer_limit(self, group_number): + """ + Return the maximum number of peers allowed for the group designated by the given group number. + If the group number is invalid, the return value is unspecified. + + The value returned is equal to the data received by the last + `group_peer_limit` callback. + + see the `Group chat founder controls` section for the respective set function. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_get_peer_limit(self._tox_pointer, group_number, byref(error)) + return result + + def group_get_password_size(self, group_number): + """ + Return the length of the group password. If the group number is invalid, the + return value is unspecified. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_get_password_size(self._tox_pointer, group_number, byref(error)) + return result + + def group_get_password(self, group_number): + """ + Write the password for the group designated by the given group number to a byte array. + + Call tox_group_get_password_size to determine the allocation size for the `password` parameter. + + The data received is equal to the data received by the last + `group_password` callback. + + see the `Group chat founder controls` section for the respective set function. + + :return password + """ + + error = c_int() + size = self.group_get_password_size(group_number) + password = create_string_buffer(size) + result = Tox.libtoxcore.tox_group_get_password(self._tox_pointer, group_number, + password, byref(error)) + return str(password[:size], 'utf-8') + + def callback_group_topic(self, callback, user_data): + """ + Set the callback for the `group_topic` event. Pass NULL to unset. + This event is triggered when a peer changes the group topic. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_void_p) + self.group_topic_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_topic(self._tox_pointer, self.group_topic_cb, user_data) + + def callback_group_privacy_state(self, callback, user_data): + """ + Set the callback for the `group_privacy_state` event. Pass NULL to unset. + This event is triggered when the group founder changes the privacy state. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_int, c_void_p) + self.group_privacy_state_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_privacy_state(self._tox_pointer, self.group_privacy_state_cb, user_data) + + def callback_group_peer_limit(self, callback, user_data): + """ + Set the callback for the `group_peer_limit` event. Pass NULL to unset. + This event is triggered when the group founder changes the maximum peer limit. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_void_p) + self.group_peer_limit_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_peer_limit(self._tox_pointer, self.group_peer_limit_cb, user_data) + + def callback_group_password(self, callback, user_data): + """ + Set the callback for the `group_password` event. Pass NULL to unset. + This event is triggered when the group founder changes the group password. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_char_p, c_size_t, c_void_p) + self.group_password_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_password(self._tox_pointer, self.group_password_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Group message sending + # ----------------------------------------------------------------------------------------------------------------- + + def group_send_custom_packet(self, group_number, lossless, data): + """ + Send a custom packet to the group. + + If lossless is true the packet will be lossless. Lossless packet behaviour is comparable + to TCP (reliability, arrive in order) but with packets instead of a stream. + + If lossless is false, the packet will be lossy. Lossy packets behave like UDP packets, + meaning they might never reach the other side or might arrive more than once (if someone + is messing with the connection) or might arrive in the wrong order. + + Unless latency is an issue or message reliability is not important, it is recommended that you use + lossless custom packets. + + :param group_number: The group number of the group the message is intended for. + :param lossless: True if the packet should be lossless. + :param data A byte array containing the packet data. + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_send_custom_packet(self._tox_pointer, group_number, lossless, data, + len(data), byref(error)) + return result + + def group_send_private_message(self, group_number, peer_id, message_type, message): + """ + Send a text chat message to the specified peer in the specified group. + + This function creates a group private message packet and pushes it into the send + queue. + + The message length may not exceed TOX_MAX_MESSAGE_LENGTH. Larger messages + must be split by the client and sent as separate messages. Other clients can + then reassemble the fragments. Messages may not be empty. + + :param group_number: The group number of the group the message is intended for. + :param peer_id: The ID of the peer the message is intended for. + :param message: A non-NULL pointer to the first element of a byte array containing the message text. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_send_private_message(self._tox_pointer, group_number, peer_id, + message_type, message, + len(message), byref(error)) + return result + + def group_send_message(self, group_number, type, message): + """ + Send a text chat message to the group. + + This function creates a group message packet and pushes it into the send + queue. + + The message length may not exceed TOX_MAX_MESSAGE_LENGTH. Larger messages + must be split by the client and sent as separate messages. Other clients can + then reassemble the fragments. Messages may not be empty. + + :param group_number: The group number of the group the message is intended for. + :param type: Message type (normal, action, ...). + :param message: A non-NULL pointer to the first element of a byte array containing the message text. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_send_message(self._tox_pointer, group_number, type, message, len(message), + byref(error)) + return result + + # ----------------------------------------------------------------------------------------------------------------- + # Group message receiving + # ----------------------------------------------------------------------------------------------------------------- + + def callback_group_message(self, callback, user_data): + """ + Set the callback for the `group_message` event. Pass NULL to unset. + This event is triggered when the client receives a group message. + + Callback: python function with params: + tox Tox* instance + group_number The group number of the group the message is intended for. + peer_id The ID of the peer who sent the message. + type The type of message (normal, action, ...). + message The message data. + length The length of the message. + user_data - user data + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_int, c_char_p, c_size_t, c_void_p) + self.group_message_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_message(self._tox_pointer, self.group_message_cb, user_data) + + def callback_group_private_message(self, callback, user_data): + """ + Set the callback for the `group_private_message` event. Pass NULL to unset. + This event is triggered when the client receives a private message. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_uint8, c_char_p, c_size_t, c_void_p) + self.group_private_message_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_private_message(self._tox_pointer, self.group_private_message_cb, user_data) + + def callback_group_custom_packet(self, callback, user_data): + """ + Set the callback for the `group_custom_packet` event. Pass NULL to unset. + + This event is triggered when the client receives a custom packet. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, POINTER(c_uint8), c_void_p) + self.group_custom_packet_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_custom_packet(self._tox_pointer, self.group_custom_packet_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat inviting and join/part events + # ----------------------------------------------------------------------------------------------------------------- + + def group_invite_friend(self, group_number, friend_number): + """ + Invite a friend to a group. + + This function creates an invite request packet and pushes it to the send queue. + + :param group_number: The group number of the group the message is intended for. + :param friend_number: The friend number of the friend the invite is intended for. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_invite_friend(self._tox_pointer, group_number, friend_number, byref(error)) + return result + + @staticmethod + def group_self_peer_info_new(): + error = c_int() + f = Tox.libtoxcore.tox_group_self_peer_info_new + f.restype = POINTER(GroupChatSelfPeerInfo) + result = f(byref(error)) + + return result + + def group_invite_accept(self, invite_data, friend_number, nick, status, password=None): + """ + Accept an invite to a group chat that the client previously received from a friend. The invite + is only valid while the inviter is present in the group. + + :param invite_data: The invite data received from the `group_invite` event. + :param password: The password required to join the group. Set to NULL if no password is required. + :return the group_number on success, UINT32_MAX on failure. + """ + + error = c_int() + f = Tox.libtoxcore.tox_group_invite_accept + f.restype = c_uint32 + peer_info = self.group_self_peer_info_new() + nick = bytes(nick, 'utf-8') + peer_info.contents.nick = c_char_p(nick) + peer_info.contents.nick_length = len(nick) + peer_info.contents.user_status = status + result = f(self._tox_pointer, friend_number, invite_data, len(invite_data), password, + len(password) if password is not None else 0, peer_info, byref(error)) + print('Invite accept. Result:', result, 'Error:', error.value) + return result + + def callback_group_invite(self, callback, user_data): + """ + Set the callback for the `group_invite` event. Pass NULL to unset. + + This event is triggered when the client receives a group invite from a friend. The client must store + invite_data which is used to join the group via tox_group_invite_accept. + + Callback: python function with params: + tox - Tox* + friend_number The friend number of the contact who sent the invite. + invite_data The invite data. + length The length of invite_data. + user_data - user data + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(c_uint8), c_size_t, + POINTER(c_uint8), c_size_t, c_void_p) + self.group_invite_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_invite(self._tox_pointer, self.group_invite_cb, user_data) + + def callback_group_peer_join(self, callback, user_data): + """ + Set the callback for the `group_peer_join` event. Pass NULL to unset. + + This event is triggered when a peer other than self joins the group. + Callback: python function with params: + tox - Tox* + group_number - group number + peer_id - peer id + user_data - user data + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_void_p) + self.group_peer_join_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_peer_join(self._tox_pointer, self.group_peer_join_cb, user_data) + + def callback_group_peer_exit(self, callback, user_data): + """ + Set the callback for the `group_peer_exit` event. Pass NULL to unset. + + This event is triggered when a peer other than self exits the group. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_char_p, c_size_t, c_void_p) + self.group_peer_exit_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_peer_exit(self._tox_pointer, self.group_peer_exit_cb, user_data) + + def callback_group_self_join(self, callback, user_data): + """ + Set the callback for the `group_self_join` event. Pass NULL to unset. + + This event is triggered when the client has successfully joined a group. Use this to initialize + any group information the client may need. + Callback: python fucntion with params: + tox - *Tox + group_number - group number + user_data - user data + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_void_p) + self.group_self_join_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_self_join(self._tox_pointer, self.group_self_join_cb, user_data) + + def callback_group_join_fail(self, callback, user_data): + """ + Set the callback for the `group_join_fail` event. Pass NULL to unset. + + This event is triggered when the client fails to join a group. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_int, c_void_p) + self.group_join_fail_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_join_fail(self._tox_pointer, self.group_join_fail_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat founder controls (these only work for the group founder) + # ----------------------------------------------------------------------------------------------------------------- + + def group_founder_set_password(self, group_number, password): + """ + Set or unset the group password. + + This function sets the groups password, creates a new group shared state including the change, + and distributes it to the rest of the group. + + :param group_number: The group number of the group for which we wish to set the password. + :param password: The password we want to set. Set password to NULL to unset the password. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_founder_set_password(self._tox_pointer, group_number, password, + len(password), byref(error)) + return result + + def group_founder_set_privacy_state(self, group_number, privacy_state): + """ + Set the group privacy state. + + This function sets the group's privacy state, creates a new group shared state + including the change, and distributes it to the rest of the group. + + If an attempt is made to set the privacy state to the same state that the group is already + in, the function call will be successful and no action will be taken. + + :param group_number: The group number of the group for which we wish to change the privacy state. + :param privacy_state: The privacy state we wish to set the group to. + + :return true on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_founder_set_privacy_state(self._tox_pointer, group_number, privacy_state, + byref(error)) + return result + + def group_founder_set_peer_limit(self, group_number, max_peers): + """ + Set the group peer limit. + + This function sets a limit for the number of peers who may be in the group, creates a new + group shared state including the change, and distributes it to the rest of the group. + + :param group_number: The group number of the group for which we wish to set the peer limit. + :param max_peers: The maximum number of peers to allow in the group. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_founder_set_peer_limit(self._tox_pointer, group_number, + max_peers, byref(error)) + return result + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat moderation + # ----------------------------------------------------------------------------------------------------------------- + + def group_toggle_ignore(self, group_number, peer_id, ignore): + """ + Ignore or unignore a peer. + + :param group_number: The group number of the group the in which you wish to ignore a peer. + :param peer_id: The ID of the peer who shall be ignored or unignored. + :param ignore: True to ignore the peer, false to unignore the peer. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_toggle_ignore(self._tox_pointer, group_number, peer_id, ignore, byref(error)) + return result + + def group_mod_set_role(self, group_number, peer_id, role): + """ + Set a peer's role. + + This function will first remove the peer's previous role and then assign them a new role. + It will also send a packet to the rest of the group, requesting that they perform + the role reassignment. Note: peers cannot be set to the founder role. + + :param group_number: The group number of the group the in which you wish set the peer's role. + :param peer_id: The ID of the peer whose role you wish to set. + :param role: The role you wish to set the peer to. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_mod_set_role(self._tox_pointer, group_number, peer_id, role, byref(error)) + return result + + def group_mod_remove_peer(self, group_number, peer_id): + """ + Kick/ban a peer. + + This function will remove a peer from the caller's peer list and optionally add their IP address + to the ban list. It will also send a packet to all group members requesting them + to do the same. + + :param group_number: The group number of the group the ban is intended for. + :param peer_id: The ID of the peer who will be kicked and/or added to the ban list. + + :return True on success. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_mod_remove_peer(self._tox_pointer, group_number, peer_id, + byref(error)) + return result + + def group_mod_ban_peer(self, group_number, peer_id, ban_type): + + error = c_int() + result = Tox.libtoxcore.tox_group_mod_ban_peer(self._tox_pointer, group_number, peer_id, + ban_type, byref(error)) + return result + + def group_mod_remove_ban(self, group_number, ban_id): + """ + Removes a ban. + + This function removes a ban entry from the ban list, and sends a packet to the rest of + the group requesting that they do the same. + + :param group_number: The group number of the group in which the ban is to be removed. + :param ban_id: The ID of the ban entry that shall be removed. + + :return True on success + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_mod_remove_ban(self._tox_pointer, group_number, ban_id, byref(error)) + return result + + def callback_group_moderation(self, callback, user_data): + """ + Set the callback for the `group_moderation` event. Pass NULL to unset. + + This event is triggered when a moderator or founder executes a moderation event. + """ + + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_uint32, c_int, c_void_p) + self.group_moderation_cb = c_callback(callback) + Tox.libtoxcore.tox_callback_group_moderation(self._tox_pointer, self.group_moderation_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Group chat ban list queries + # ----------------------------------------------------------------------------------------------------------------- + + def group_ban_get_list_size(self, group_number): + """ + Return the number of entries in the ban list for the group designated by + the given group number. If the group number is invalid, the return value is unspecified. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_ban_get_list_size(self._tox_pointer, group_number, byref(error)) + return result + + def group_ban_get_list(self, group_number): + """ + Copy a list of valid ban list ID's into an array. + + Call tox_group_ban_get_list_size to determine the number of elements to allocate. + return true on success. + """ + + error = c_int() + bans_list_size = self.group_ban_get_list_size(group_number) + bans_list = create_string_buffer(sizeof(c_uint32) * bans_list_size) + bans_list = POINTER(c_uint32)(bans_list) + result = Tox.libtoxcore.tox_group_ban_get_list(self._tox_pointer, group_number, bans_list, byref(error)) + return bans_list[:bans_list_size] + + def group_ban_get_type(self, group_number, ban_id): + """ + Return the type for the ban list entry designated by ban_id, in the + group designated by the given group number. If either group_number or ban_id is invalid, + the return value is unspecified. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_ban_get_type(self._tox_pointer, group_number, ban_id, byref(error)) + return result + + def group_ban_get_target_size(self, group_number, ban_id): + """ + Return the length of the name for the ban list entry designated by ban_id, in the + group designated by the given group number. If either group_number or ban_id is invalid, + the return value is unspecified. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_ban_get_target_size(self._tox_pointer, group_number, ban_id, byref(error)) + return result + + def group_ban_get_target(self, group_number, ban_id): + """ + Write the name of the ban entry designated by ban_id in the group designated by the + given group number to a byte array. + + Call tox_group_ban_get_name_size to find out how much memory to allocate for the result. + + :return name + """ + + error = c_int() + size = self.group_ban_get_target_size(group_number, ban_id) + target = create_string_buffer(size) + target_type = self.group_ban_get_type(group_number, ban_id) + + result = Tox.libtoxcore.tox_group_ban_get_target(self._tox_pointer, group_number, ban_id, + target, byref(error)) + if target_type == TOX_GROUP_BAN_TYPE['PUBLIC_KEY']: + return bin_to_string(target, size) + return str(target[:size], 'utf-8') + + def group_ban_get_time_set(self, group_number, ban_id): + """ + Return a time stamp indicating the time the ban was set, for the ban list entry + designated by ban_id, in the group designated by the given group number. + If either group_number or ban_id is invalid, the return value is unspecified. + """ + + error = c_int() + result = Tox.libtoxcore.tox_group_ban_get_time_set(self._tox_pointer, group_number, ban_id, byref(error)) + return result diff --git a/toxygen/wrapper/toxav.py b/toxygen/wrapper/toxav.py new file mode 100644 index 0000000..98e1c73 --- /dev/null +++ b/toxygen/wrapper/toxav.py @@ -0,0 +1,363 @@ +from ctypes import c_int, POINTER, c_void_p, byref, ArgumentError, c_uint32, CFUNCTYPE, c_size_t, c_uint8, c_uint16 +from ctypes import c_char_p, c_int32, c_bool, cast +from wrapper.libtox import LibToxAV +from wrapper.toxav_enums import * + + +class ToxAV: + """ + The ToxAV instance type. Each ToxAV instance can be bound to only one Tox instance, and Tox instance can have only + one ToxAV instance. One must make sure to close ToxAV instance prior closing Tox instance otherwise undefined + behaviour occurs. Upon closing of ToxAV instance, all active calls will be forcibly terminated without notifying + peers. + """ + + # ----------------------------------------------------------------------------------------------------------------- + # Creation and destruction + # ----------------------------------------------------------------------------------------------------------------- + + def __init__(self, tox_pointer): + """ + Start new A/V session. There can only be only one session per Tox instance. + + :param tox_pointer: pointer to Tox instance + """ + self.libtoxav = LibToxAV() + toxav_err_new = c_int() + f = self.libtoxav.toxav_new + f.restype = POINTER(c_void_p) + self._toxav_pointer = f(tox_pointer, byref(toxav_err_new)) + toxav_err_new = toxav_err_new.value + if toxav_err_new == TOXAV_ERR_NEW['NULL']: + raise ArgumentError('One of the arguments to the function was NULL when it was not expected.') + elif toxav_err_new == TOXAV_ERR_NEW['MALLOC']: + raise MemoryError('Memory allocation failure while trying to allocate structures required for the A/V ' + 'session.') + elif toxav_err_new == TOXAV_ERR_NEW['MULTIPLE']: + raise RuntimeError('Attempted to create a second session for the same Tox instance.') + + self.call_state_cb = None + self.audio_receive_frame_cb = None + self.video_receive_frame_cb = None + self.call_cb = None + + def kill(self): + """ + Releases all resources associated with the A/V session. + + If any calls were ongoing, these will be forcibly terminated without notifying peers. After calling this + function, no other functions may be called and the av pointer becomes invalid. + """ + self.libtoxav.toxav_kill(self._toxav_pointer) + + def get_tox_pointer(self): + """ + Returns the Tox instance the A/V object was created for. + + :return: pointer to the Tox instance + """ + self.libtoxav.toxav_get_tox.restype = POINTER(c_void_p) + return self.libtoxav.toxav_get_tox(self._toxav_pointer) + + # ----------------------------------------------------------------------------------------------------------------- + # A/V event loop + # ----------------------------------------------------------------------------------------------------------------- + + def iteration_interval(self): + """ + Returns the interval in milliseconds when the next toxav_iterate call should be. If no call is active at the + moment, this function returns 200. + + :return: interval in milliseconds + """ + return self.libtoxav.toxav_iteration_interval(self._toxav_pointer) + + def iterate(self): + """ + Main loop for the session. This function needs to be called in intervals of toxav_iteration_interval() + milliseconds. It is best called in the separate thread from tox_iterate. + """ + self.libtoxav.toxav_iterate(self._toxav_pointer) + + # ----------------------------------------------------------------------------------------------------------------- + # Call setup + # ----------------------------------------------------------------------------------------------------------------- + + def call(self, friend_number, audio_bit_rate, video_bit_rate): + """ + Call a friend. This will start ringing the friend. + + It is the client's responsibility to stop ringing after a certain timeout, if such behaviour is desired. If the + client does not stop ringing, the library will not stop until the friend is disconnected. Audio and video + receiving are both enabled by default. + + :param friend_number: The friend number of the friend that should be called. + :param audio_bit_rate: Audio bit rate in Kb/sec. Set this to 0 to disable audio sending. + :param video_bit_rate: Video bit rate in Kb/sec. Set this to 0 to disable video sending. + :return: True on success. + """ + toxav_err_call = c_int() + result = self.libtoxav.toxav_call(self._toxav_pointer, c_uint32(friend_number), c_uint32(audio_bit_rate), + c_uint32(video_bit_rate), byref(toxav_err_call)) + toxav_err_call = toxav_err_call.value + if toxav_err_call == TOXAV_ERR_CALL['OK']: + return bool(result) + elif toxav_err_call == TOXAV_ERR_CALL['MALLOC']: + raise MemoryError('A resource allocation error occurred while trying to create the structures required for ' + 'the call.') + elif toxav_err_call == TOXAV_ERR_CALL['SYNC']: + raise RuntimeError('Synchronization error occurred.') + elif toxav_err_call == TOXAV_ERR_CALL['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + elif toxav_err_call == TOXAV_ERR_CALL['FRIEND_NOT_CONNECTED']: + raise ArgumentError('The friend was valid, but not currently connected.') + elif toxav_err_call == TOXAV_ERR_CALL['FRIEND_ALREADY_IN_CALL']: + raise ArgumentError('Attempted to call a friend while already in an audio or video call with them.') + elif toxav_err_call == TOXAV_ERR_CALL['INVALID_BIT_RATE']: + raise ArgumentError('Audio or video bit rate is invalid.') + + def callback_call(self, callback, user_data): + """ + Set the callback for the `call` event. Pass None to unset. + + :param callback: The function for the call callback. + + Should take pointer (c_void_p) to ToxAV object, + The friend number (c_uint32) from which the call is incoming. + True (c_bool) if friend is sending audio. + True (c_bool) if friend is sending video. + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_bool, c_bool, c_void_p) + self.call_cb = c_callback(callback) + self.libtoxav.toxav_callback_call(self._toxav_pointer, self.call_cb, user_data) + + def answer(self, friend_number, audio_bit_rate, video_bit_rate): + """ + Accept an incoming call. + + If answering fails for any reason, the call will still be pending and it is possible to try and answer it later. + Audio and video receiving are both enabled by default. + + :param friend_number: The friend number of the friend that is calling. + :param audio_bit_rate: Audio bit rate in Kb/sec. Set this to 0 to disable audio sending. + :param video_bit_rate: Video bit rate in Kb/sec. Set this to 0 to disable video sending. + :return: True on success. + """ + toxav_err_answer = c_int() + result = self.libtoxav.toxav_answer(self._toxav_pointer, c_uint32(friend_number), c_uint32(audio_bit_rate), + c_uint32(video_bit_rate), byref(toxav_err_answer)) + toxav_err_answer = toxav_err_answer.value + if toxav_err_answer == TOXAV_ERR_ANSWER['OK']: + return bool(result) + elif toxav_err_answer == TOXAV_ERR_ANSWER['SYNC']: + raise RuntimeError('Synchronization error occurred.') + elif toxav_err_answer == TOXAV_ERR_ANSWER['CODEC_INITIALIZATION']: + raise RuntimeError('Failed to initialize codecs for call session. Note that codec initiation will fail if ' + 'there is no receive callback registered for either audio or video.') + elif toxav_err_answer == TOXAV_ERR_ANSWER['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend number did not designate a valid friend.') + elif toxav_err_answer == TOXAV_ERR_ANSWER['FRIEND_NOT_CALLING']: + raise ArgumentError('The friend was valid, but they are not currently trying to initiate a call. This is ' + 'also returned if this client is already in a call with the friend.') + elif toxav_err_answer == TOXAV_ERR_ANSWER['INVALID_BIT_RATE']: + raise ArgumentError('Audio or video bit rate is invalid.') + + # ----------------------------------------------------------------------------------------------------------------- + # Call state graph + # ----------------------------------------------------------------------------------------------------------------- + + def callback_call_state(self, callback, user_data): + """ + Set the callback for the `call_state` event. Pass None to unset. + + :param callback: Python function. + The function for the call_state callback. + + Should take pointer (c_void_p) to ToxAV object, + The friend number (c_uint32) for which the call state changed. + The bitmask of the new call state which is guaranteed to be different than the previous state. The state is set + to 0 when the call is paused. The bitmask represents all the activities currently performed by the friend. + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint32, c_void_p) + self.call_state_cb = c_callback(callback) + self.libtoxav.toxav_callback_call_state(self._toxav_pointer, self.call_state_cb, user_data) + + # ----------------------------------------------------------------------------------------------------------------- + # Call control + # ----------------------------------------------------------------------------------------------------------------- + + def call_control(self, friend_number, control): + """ + Sends a call control command to a friend. + + :param friend_number: The friend number of the friend this client is in a call with. + :param control: The control command to send. + :return: True on success. + """ + toxav_err_call_control = c_int() + result = self.libtoxav.toxav_call_control(self._toxav_pointer, c_uint32(friend_number), c_int(control), + byref(toxav_err_call_control)) + toxav_err_call_control = toxav_err_call_control.value + if toxav_err_call_control == TOXAV_ERR_CALL_CONTROL['OK']: + return bool(result) + elif toxav_err_call_control == TOXAV_ERR_CALL_CONTROL['SYNC']: + raise RuntimeError('Synchronization error occurred.') + elif toxav_err_call_control == TOXAV_ERR_CALL_CONTROL['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif toxav_err_call_control == TOXAV_ERR_CALL_CONTROL['FRIEND_NOT_IN_CALL']: + raise RuntimeError('This client is currently not in a call with the friend. Before the call is answered, ' + 'only CANCEL is a valid control.') + elif toxav_err_call_control == TOXAV_ERR_CALL_CONTROL['INVALID_TRANSITION']: + raise RuntimeError('Happens if user tried to pause an already paused call or if trying to resume a call ' + 'that is not paused.') + + # ----------------------------------------------------------------------------------------------------------------- + # TODO Controlling bit rates + # ----------------------------------------------------------------------------------------------------------------- + + # ----------------------------------------------------------------------------------------------------------------- + # A/V sending + # ----------------------------------------------------------------------------------------------------------------- + + def audio_send_frame(self, friend_number, pcm, sample_count, channels, sampling_rate): + """ + Send an audio frame to a friend. + + The expected format of the PCM data is: [s1c1][s1c2][...][s2c1][s2c2][...]... + Meaning: sample 1 for channel 1, sample 1 for channel 2, ... + For mono audio, this has no meaning, every sample is subsequent. For stereo, this means the expected format is + LRLRLR... with samples for left and right alternating. + + :param friend_number: The friend number of the friend to which to send an audio frame. + :param pcm: An array of audio samples. The size of this array must be sample_count * channels. + :param sample_count: Number of samples in this frame. Valid numbers here are + ((sample rate) * (audio length) / 1000), where audio length can be 2.5, 5, 10, 20, 40 or 60 milliseconds. + :param channels: Number of audio channels. Sulpported values are 1 and 2. + :param sampling_rate: Audio sampling rate used in this frame. Valid sampling rates are 8000, 12000, 16000, + 24000, or 48000. + """ + toxav_err_send_frame = c_int() + result = self.libtoxav.toxav_audio_send_frame(self._toxav_pointer, c_uint32(friend_number), + cast(pcm, c_void_p), + c_size_t(sample_count), c_uint8(channels), + c_uint32(sampling_rate), byref(toxav_err_send_frame)) + toxav_err_send_frame = toxav_err_send_frame.value + if toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['OK']: + return bool(result) + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['NULL']: + raise ArgumentError('The samples data pointer was NULL.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['FRIEND_NOT_IN_CALL']: + raise RuntimeError('This client is currently not in a call with the friend.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['SYNC']: + raise RuntimeError('Synchronization error occurred.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['INVALID']: + raise ArgumentError('One of the frame parameters was invalid. E.g. the resolution may be too small or too ' + 'large, or the audio sampling rate may be unsupported.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['PAYLOAD_TYPE_DISABLED']: + raise RuntimeError('Either friend turned off audio or video receiving or we turned off sending for the said' + 'payload.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['RTP_FAILED']: + RuntimeError('Failed to push frame through rtp interface.') + + def video_send_frame(self, friend_number, width, height, y, u, v): + """ + Send a video frame to a friend. + + Y - plane should be of size: height * width + U - plane should be of size: (height/2) * (width/2) + V - plane should be of size: (height/2) * (width/2) + + :param friend_number: The friend number of the friend to which to send a video frame. + :param width: Width of the frame in pixels. + :param height: Height of the frame in pixels. + :param y: Y (Luminance) plane data. + :param u: U (Chroma) plane data. + :param v: V (Chroma) plane data. + """ + toxav_err_send_frame = c_int() + result = self.libtoxav.toxav_video_send_frame(self._toxav_pointer, c_uint32(friend_number), c_uint16(width), + c_uint16(height), c_char_p(y), c_char_p(u), c_char_p(v), + byref(toxav_err_send_frame)) + toxav_err_send_frame = toxav_err_send_frame.value + if toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['OK']: + return bool(result) + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['NULL']: + raise ArgumentError('One of Y, U, or V was NULL.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['FRIEND_NOT_FOUND']: + raise ArgumentError('The friend_number passed did not designate a valid friend.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['FRIEND_NOT_IN_CALL']: + raise RuntimeError('This client is currently not in a call with the friend.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['SYNC']: + raise RuntimeError('Synchronization error occurred.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['INVALID']: + raise ArgumentError('One of the frame parameters was invalid. E.g. the resolution may be too small or too ' + 'large, or the audio sampling rate may be unsupported.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['PAYLOAD_TYPE_DISABLED']: + raise RuntimeError('Either friend turned off audio or video receiving or we turned off sending for the said' + 'payload.') + elif toxav_err_send_frame == TOXAV_ERR_SEND_FRAME['RTP_FAILED']: + RuntimeError('Failed to push frame through rtp interface.') + + # ----------------------------------------------------------------------------------------------------------------- + # A/V receiving + # ----------------------------------------------------------------------------------------------------------------- + + def callback_audio_receive_frame(self, callback, user_data): + """ + Set the callback for the `audio_receive_frame` event. Pass None to unset. + + :param callback: Python function. + Function for the audio_receive_frame callback. The callback can be called multiple times per single + iteration depending on the amount of queued frames in the buffer. The received format is the same as in send + function. + + Should take pointer (c_void_p) to ToxAV object, + The friend number (c_uint32) of the friend who sent an audio frame. + An array (c_uint8) of audio samples (sample_count * channels elements). + The number (c_size_t) of audio samples per channel in the PCM array. + Number (c_uint8) of audio channels. + Sampling rate (c_uint32) used in this frame. + pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(c_uint8), c_size_t, c_uint8, c_uint32, c_void_p) + self.audio_receive_frame_cb = c_callback(callback) + self.libtoxav.toxav_callback_audio_receive_frame(self._toxav_pointer, self.audio_receive_frame_cb, user_data) + + def callback_video_receive_frame(self, callback, user_data): + """ + Set the callback for the `video_receive_frame` event. Pass None to unset. + + :param callback: Python function. + The function type for the video_receive_frame callback. + + Should take + toxAV pointer (c_void_p) to ToxAV object, + friend_number The friend number (c_uint32) of the friend who sent a video frame. + width Width (c_uint16) of the frame in pixels. + height Height (c_uint16) of the frame in pixels. + y + u + v Plane data (POINTER(c_uint8)). + The size of plane data is derived from width and height where + Y = MAX(width, abs(ystride)) * height, + U = MAX(width/2, abs(ustride)) * (height/2) and + V = MAX(width/2, abs(vstride)) * (height/2). + ystride + ustride + vstride Strides data (c_int32). Strides represent padding for each plane that may or may not be present. You must + handle strides in your image processing code. Strides are negative if the image is bottom-up + hence why you MUST abs() it when calculating plane buffer size. + user_data pointer (c_void_p) to user_data + :param user_data: pointer (c_void_p) to user data + """ + c_callback = CFUNCTYPE(None, c_void_p, c_uint32, c_uint16, c_uint16, POINTER(c_uint8), POINTER(c_uint8), + POINTER(c_uint8), c_int32, c_int32, c_int32, c_void_p) + self.video_receive_frame_cb = c_callback(callback) + self.libtoxav.toxav_callback_video_receive_frame(self._toxav_pointer, self.video_receive_frame_cb, user_data) diff --git a/toxygen/wrapper/toxav_enums.py b/toxygen/wrapper/toxav_enums.py new file mode 100644 index 0000000..3f3977a --- /dev/null +++ b/toxygen/wrapper/toxav_enums.py @@ -0,0 +1,131 @@ +TOXAV_ERR_NEW = { + # The function returned successfully. + 'OK': 0, + # One of the arguments to the function was NULL when it was not expected. + 'NULL': 1, + # Memory allocation failure while trying to allocate structures required for the A/V session. + 'MALLOC': 2, + # Attempted to create a second session for the same Tox instance. + 'MULTIPLE': 3, +} + +TOXAV_ERR_CALL = { + # The function returned successfully. + 'OK': 0, + # A resource allocation error occurred while trying to create the structures required for the call. + 'MALLOC': 1, + # Synchronization error occurred. + 'SYNC': 2, + # The friend number did not designate a valid friend. + 'FRIEND_NOT_FOUND': 3, + # The friend was valid, but not currently connected. + 'FRIEND_NOT_CONNECTED': 4, + # Attempted to call a friend while already in an audio or video call with them. + 'FRIEND_ALREADY_IN_CALL': 5, + # Audio or video bit rate is invalid. + 'INVALID_BIT_RATE': 6, +} + +TOXAV_ERR_ANSWER = { + # The function returned successfully. + 'OK': 0, + # Synchronization error occurred. + 'SYNC': 1, + # Failed to initialize codecs for call session. Note that codec initiation will fail if there is no receive callback + # registered for either audio or video. + 'CODEC_INITIALIZATION': 2, + # The friend number did not designate a valid friend. + 'FRIEND_NOT_FOUND': 3, + # The friend was valid, but they are not currently trying to initiate a call. This is also returned if this client + # is already in a call with the friend. + 'FRIEND_NOT_CALLING': 4, + # Audio or video bit rate is invalid. + 'INVALID_BIT_RATE': 5, +} + +TOXAV_FRIEND_CALL_STATE = { + # Set by the AV core if an error occurred on the remote end or if friend timed out. This is the final state after + # which no more state transitions can occur for the call. This call state will never be triggered in combination + # with other call states. + 'ERROR': 1, + # The call has finished. This is the final state after which no more state transitions can occur for the call. This + # call state will never be triggered in combination with other call states. + 'FINISHED': 2, + # The flag that marks that friend is sending audio. + 'SENDING_A': 4, + # The flag that marks that friend is sending video. + 'SENDING_V': 8, + # The flag that marks that friend is receiving audio. + 'ACCEPTING_A': 16, + # The flag that marks that friend is receiving video. + 'ACCEPTING_V': 32, +} + +TOXAV_CALL_CONTROL = { + # Resume a previously paused call. Only valid if the pause was caused by this client, if not, this control is + # ignored. Not valid before the call is accepted. + 'RESUME': 0, + # Put a call on hold. Not valid before the call is accepted. + 'PAUSE': 1, + # Reject a call if it was not answered, yet. Cancel a call after it was answered. + 'CANCEL': 2, + # Request that the friend stops sending audio. Regardless of the friend's compliance, this will cause the + # audio_receive_frame event to stop being triggered on receiving an audio frame from the friend. + 'MUTE_AUDIO': 3, + # Calling this control will notify client to start sending audio again. + 'UNMUTE_AUDIO': 4, + # Request that the friend stops sending video. Regardless of the friend's compliance, this will cause the + # video_receive_frame event to stop being triggered on receiving a video frame from the friend. + 'HIDE_VIDEO': 5, + # Calling this control will notify client to start sending video again. + 'SHOW_VIDEO': 6, +} + +TOXAV_ERR_CALL_CONTROL = { + # The function returned successfully. + 'OK': 0, + # Synchronization error occurred. + 'SYNC': 1, + # The friend_number passed did not designate a valid friend. + 'FRIEND_NOT_FOUND': 2, + # This client is currently not in a call with the friend. Before the call is answered, only CANCEL is a valid + # control. + 'FRIEND_NOT_IN_CALL': 3, + # Happens if user tried to pause an already paused call or if trying to resume a call that is not paused. + 'INVALID_TRANSITION': 4, +} + +TOXAV_ERR_BIT_RATE_SET = { + # The function returned successfully. + 'OK': 0, + # Synchronization error occurred. + 'SYNC': 1, + # The audio bit rate passed was not one of the supported values. + 'INVALID_AUDIO_BIT_RATE': 2, + # The video bit rate passed was not one of the supported values. + 'INVALID_VIDEO_BIT_RATE': 3, + # The friend_number passed did not designate a valid friend. + 'FRIEND_NOT_FOUND': 4, + # This client is currently not in a call with the friend. + 'FRIEND_NOT_IN_CALL': 5, +} + +TOXAV_ERR_SEND_FRAME = { + # The function returned successfully. + 'OK': 0, + # In case of video, one of Y, U, or V was NULL. In case of audio, the samples data pointer was NULL. + 'NULL': 1, + # The friend_number passed did not designate a valid friend. + 'FRIEND_NOT_FOUND': 2, + # This client is currently not in a call with the friend. + 'FRIEND_NOT_IN_CALL': 3, + # Synchronization error occurred. + 'SYNC': 4, + # One of the frame parameters was invalid. E.g. the resolution may be too small or too large, or the audio sampling + # rate may be unsupported. + 'INVALID': 5, + # Either friend turned off audio or video receiving or we turned off sending for the said payload. + 'PAYLOAD_TYPE_DISABLED': 6, + # Failed to push frame through rtp interface. + 'RTP_FAILED': 7, +} diff --git a/toxygen/wrapper/toxcore_enums_and_consts.py b/toxygen/wrapper/toxcore_enums_and_consts.py new file mode 100644 index 0000000..b34e272 --- /dev/null +++ b/toxygen/wrapper/toxcore_enums_and_consts.py @@ -0,0 +1,954 @@ +TOX_USER_STATUS = { + 'NONE': 0, + 'AWAY': 1, + 'BUSY': 2, +} + +TOX_MESSAGE_TYPE = { + 'NORMAL': 0, + 'ACTION': 1, +} + +TOX_PROXY_TYPE = { + 'NONE': 0, + 'HTTP': 1, + 'SOCKS5': 2, +} + +TOX_SAVEDATA_TYPE = { + 'NONE': 0, + 'TOX_SAVE': 1, + 'SECRET_KEY': 2, +} + +TOX_ERR_OPTIONS_NEW = { + 'OK': 0, + 'MALLOC': 1, +} + +TOX_ERR_NEW = { + 'OK': 0, + 'NULL': 1, + 'MALLOC': 2, + 'PORT_ALLOC': 3, + 'PROXY_BAD_TYPE': 4, + 'PROXY_BAD_HOST': 5, + 'PROXY_BAD_PORT': 6, + 'PROXY_NOT_FOUND': 7, + 'LOAD_ENCRYPTED': 8, + 'LOAD_BAD_FORMAT': 9, +} + +TOX_ERR_BOOTSTRAP = { + 'OK': 0, + 'NULL': 1, + 'BAD_HOST': 2, + 'BAD_PORT': 3, +} + +TOX_CONNECTION = { + 'NONE': 0, + 'TCP': 1, + 'UDP': 2, +} + +TOX_ERR_SET_INFO = { + 'OK': 0, + 'NULL': 1, + 'TOO_LONG': 2, +} + +TOX_ERR_FRIEND_ADD = { + 'OK': 0, + 'NULL': 1, + 'TOO_LONG': 2, + 'NO_MESSAGE': 3, + 'OWN_KEY': 4, + 'ALREADY_SENT': 5, + 'BAD_CHECKSUM': 6, + 'SET_NEW_NOSPAM': 7, + 'MALLOC': 8, +} + +TOX_ERR_FRIEND_DELETE = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, +} + +TOX_ERR_FRIEND_BY_PUBLIC_KEY = { + 'OK': 0, + 'NULL': 1, + 'NOT_FOUND': 2, +} + +TOX_ERR_FRIEND_GET_PUBLIC_KEY = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, +} + +TOX_ERR_FRIEND_GET_LAST_ONLINE = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, +} + +TOX_ERR_FRIEND_QUERY = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, +} + +TOX_ERR_SET_TYPING = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, +} + +TOX_ERR_FRIEND_SEND_MESSAGE = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, + 'FRIEND_NOT_CONNECTED': 3, + 'SENDQ': 4, + 'TOO_LONG': 5, + 'EMPTY': 6, +} + +TOX_FILE_KIND = { + 'DATA': 0, + 'AVATAR': 1, +} + +TOX_FILE_CONTROL = { + 'RESUME': 0, + 'PAUSE': 1, + 'CANCEL': 2, +} + +TOX_ERR_FILE_CONTROL = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, + 'FRIEND_NOT_CONNECTED': 2, + 'NOT_FOUND': 3, + 'NOT_PAUSED': 4, + 'DENIED': 5, + 'ALREADY_PAUSED': 6, + 'SENDQ': 7, +} + +TOX_ERR_FILE_SEEK = { + 'OK': 0, + 'FRIEND_NOT_FOUND': 1, + 'FRIEND_NOT_CONNECTED': 2, + 'NOT_FOUND': 3, + 'DENIED': 4, + 'INVALID_POSITION': 5, + 'SENDQ': 6, +} + +TOX_ERR_FILE_GET = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, + 'NOT_FOUND': 3, +} + +TOX_ERR_FILE_SEND = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, + 'FRIEND_NOT_CONNECTED': 3, + 'NAME_TOO_LONG': 4, + 'TOO_MANY': 5, +} + +TOX_ERR_FILE_SEND_CHUNK = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, + 'FRIEND_NOT_CONNECTED': 3, + 'NOT_FOUND': 4, + 'NOT_TRANSFERRING': 5, + 'INVALID_LENGTH': 6, + 'SENDQ': 7, + 'WRONG_POSITION': 8, +} + +TOX_ERR_FRIEND_CUSTOM_PACKET = { + 'OK': 0, + 'NULL': 1, + 'FRIEND_NOT_FOUND': 2, + 'FRIEND_NOT_CONNECTED': 3, + 'INVALID': 4, + 'EMPTY': 5, + 'TOO_LONG': 6, + 'SENDQ': 7, +} + +TOX_ERR_GET_PORT = { + 'OK': 0, + 'NOT_BOUND': 1, +} + +TOX_GROUP_PRIVACY_STATE = { + + # + # The group is considered to be public. Anyone may join the group using the Chat ID. + # + # If the group is in this state, even if the Chat ID is never explicitly shared + # with someone outside of the group, information including the Chat ID, IP addresses, + # and peer ID's (but not Tox ID's) is visible to anyone with access to a node + # storing a DHT entry for the given group. + # + 'PUBLIC': 0, + + # + # The group is considered to be private. The only way to join the group is by having + # someone in your contact list send you an invite. + # + # If the group is in this state, no group information (mentioned above) is present in the DHT; + # the DHT is not used for any purpose at all. If a public group is set to private, + # all DHT information related to the group will expire shortly. + # + 'PRIVATE': 1 +} + +TOX_GROUP_ROLE = { + + # + # May kick and ban all other peers as well as set their role to anything (except founder). + # Founders may also set the group password, toggle the privacy state, and set the peer limit. + # + 'FOUNDER': 0, + + # + # May kick, ban and set the user and observer roles for peers below this role. + # May also set the group topic. + # + 'MODERATOR': 1, + + # + # May communicate with other peers normally. + # + 'USER': 2, + + # + # May observe the group and ignore peers; may not communicate with other peers or with the group. + # + 'OBSERVER': 3 +} + +TOX_ERR_GROUP_NEW = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_NEW_OK': 0, + + # + # The group name exceeded TOX_GROUP_MAX_GROUP_NAME_LENGTH. + # + 'TOX_ERR_GROUP_NEW_TOO_LONG': 1, + + # + # group_name is NULL or length is zero. + # + 'TOX_ERR_GROUP_NEW_EMPTY': 2, + + # + # TOX_GROUP_PRIVACY_STATE is an invalid type. + # + 'TOX_ERR_GROUP_NEW_PRIVACY': 3, + + # + # The group instance failed to initialize. + # + 'TOX_ERR_GROUP_NEW_INIT': 4, + + # + # The group state failed to initialize. This usually indicates that something went wrong + # related to cryptographic signing. + # + 'TOX_ERR_GROUP_NEW_STATE': 5, + + # + # The group failed to announce to the DHT. This indicates a network related error. + # + 'TOX_ERR_GROUP_NEW_ANNOUNCE': 6, +} + +TOX_ERR_GROUP_JOIN = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_JOIN_OK': 0, + + # + # The group instance failed to initialize. + # + 'TOX_ERR_GROUP_JOIN_INIT': 1, + + # + # The chat_id pointer is set to NULL or a group with chat_id already exists. This usually + # happens if the client attempts to create multiple sessions for the same group. + # + 'TOX_ERR_GROUP_JOIN_BAD_CHAT_ID': 2, + + # + # Password length exceeded TOX_GROUP_MAX_PASSWORD_SIZE. + # + 'TOX_ERR_GROUP_JOIN_TOO_LONG': 3, +} + +TOX_ERR_GROUP_RECONNECT = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_RECONNECT_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_RECONNECT_GROUP_NOT_FOUND': 1, +} + +TOX_ERR_GROUP_LEAVE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_LEAVE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_LEAVE_GROUP_NOT_FOUND': 1, + + # + # Message length exceeded 'TOX_GROUP_MAX_PART_LENGTH. + # + 'TOX_ERR_GROUP_LEAVE_TOO_LONG': 2, + + # + # The parting packet failed to send. + # + 'TOX_ERR_GROUP_LEAVE_FAIL_SEND': 3, + + # + # The group chat instance failed to be deleted. This may occur due to memory related errors. + # + 'TOX_ERR_GROUP_LEAVE_DELETE_FAIL': 4, +} + +TOX_ERR_GROUP_SELF_QUERY = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SELF_QUERY_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SELF_QUERY_GROUP_NOT_FOUND': 1, +} + + +TOX_ERR_GROUP_SELF_NAME_SET = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_GROUP_NOT_FOUND': 1, + + # + # Name length exceeded 'TOX_MAX_NAME_LENGTH. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_TOO_LONG': 2, + + # + # The length given to the set function is zero or name is a NULL pointer. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_INVALID': 3, + + # + # The name is already taken by another peer in the group. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_TAKEN': 4, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_SELF_NAME_SET_FAIL_SEND': 5 +} + +TOX_ERR_GROUP_SELF_STATUS_SET = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SELF_STATUS_SET_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SELF_STATUS_SET_GROUP_NOT_FOUND': 1, + + # + # An invalid type was passed to the set function. + # + 'TOX_ERR_GROUP_SELF_STATUS_SET_INVALID': 2, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_SELF_STATUS_SET_FAIL_SEND': 3 +} + +TOX_ERR_GROUP_PEER_QUERY = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_PEER_QUERY_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_PEER_QUERY_GROUP_NOT_FOUND': 1, + + # + # The ID passed did not designate a valid peer. + # + 'TOX_ERR_GROUP_PEER_QUERY_PEER_NOT_FOUND': 2 +} + +TOX_ERR_GROUP_STATE_QUERIES = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_STATE_QUERIES_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_STATE_QUERIES_GROUP_NOT_FOUND': 1 +} + + +TOX_ERR_GROUP_TOPIC_SET = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_TOPIC_SET_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_TOPIC_SET_GROUP_NOT_FOUND': 1, + + # + # Topic length exceeded 'TOX_GROUP_MAX_TOPIC_LENGTH. + # + 'TOX_ERR_GROUP_TOPIC_SET_TOO_LONG': 2, + + # + # The caller does not have the required permissions to set the topic. + # + 'TOX_ERR_GROUP_TOPIC_SET_PERMISSIONS': 3, + + # + # The packet could not be created. This error is usually related to cryptographic signing. + # + 'TOX_ERR_GROUP_TOPIC_SET_FAIL_CREATE': 4, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_TOPIC_SET_FAIL_SEND': 5 +} + +TOX_ERR_GROUP_SEND_MESSAGE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_GROUP_NOT_FOUND': 1, + + # + # Message length exceeded 'TOX_MAX_MESSAGE_LENGTH. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_TOO_LONG': 2, + + # + # The message pointer is null or length is zero. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_EMPTY': 3, + + # + # The message type is invalid. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_BAD_TYPE': 4, + + # + # The caller does not have the required permissions to send group messages. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_PERMISSIONS': 5, + + # + # Packet failed to send. + # + 'TOX_ERR_GROUP_SEND_MESSAGE_FAIL_SEND': 6 +} + +TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_GROUP_NOT_FOUND': 1, + + # + # The ID passed did not designate a valid peer. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_PEER_NOT_FOUND': 2, + + # + # Message length exceeded 'TOX_MAX_MESSAGE_LENGTH. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_TOO_LONG': 3, + + # + # The message pointer is null or length is zero. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_EMPTY': 4, + + # + # The caller does not have the required permissions to send group messages. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_PERMISSIONS': 5, + + # + # Packet failed to send. + # + 'TOX_ERR_GROUP_SEND_PRIVATE_MESSAGE_FAIL_SEND': 6 +} + +TOX_ERR_GROUP_SEND_CUSTOM_PACKET = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_SEND_CUSTOM_PACKET_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_SEND_CUSTOM_PACKET_GROUP_NOT_FOUND': 1, + + # + # Message length exceeded 'TOX_MAX_MESSAGE_LENGTH. + # + 'TOX_ERR_GROUP_SEND_CUSTOM_PACKET_TOO_LONG': 2, + + # + # The message pointer is null or length is zero. + # + 'TOX_ERR_GROUP_SEND_CUSTOM_PACKET_EMPTY': 3, + + # + # The caller does not have the required permissions to send group messages. + # + 'TOX_ERR_GROUP_SEND_CUSTOM_PACKET_PERMISSIONS': 4 +} + +TOX_ERR_GROUP_INVITE_FRIEND = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_INVITE_FRIEND_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_INVITE_FRIEND_GROUP_NOT_FOUND': 1, + + # + # The friend number passed did not designate a valid friend. + # + 'TOX_ERR_GROUP_INVITE_FRIEND_FRIEND_NOT_FOUND': 2, + + # + # Creation of the invite packet failed. This indicates a network related error. + # + 'TOX_ERR_GROUP_INVITE_FRIEND_INVITE_FAIL': 3, + + # + # Packet failed to send. + # + 'TOX_ERR_GROUP_INVITE_FRIEND_FAIL_SEND': 4 +} + +TOX_ERR_GROUP_INVITE_ACCEPT = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_INVITE_ACCEPT_OK': 0, + + # + # The invite data is not in the expected format. + # + 'TOX_ERR_GROUP_INVITE_ACCEPT_BAD_INVITE': 1, + + # + # The group instance failed to initialize. + # + 'TOX_ERR_GROUP_INVITE_ACCEPT_INIT_FAILED': 2, + + # + # Password length exceeded 'TOX_GROUP_MAX_PASSWORD_SIZE. + # + 'TOX_ERR_GROUP_INVITE_ACCEPT_TOO_LONG': 3 +} + +TOX_GROUP_JOIN_FAIL = { + + # + # You are using the same nickname as someone who is already in the group. + # + 'TOX_GROUP_JOIN_FAIL_NAME_TAKEN': 0, + + # + # The group peer limit has been reached. + # + 'TOX_GROUP_JOIN_FAIL_PEER_LIMIT': 1, + + # + # You have supplied an invalid password. + # + 'TOX_GROUP_JOIN_FAIL_INVALID_PASSWORD': 2, + + # + # The join attempt failed due to an unspecified error. This often occurs when the group is + # not found in the DHT. + # + 'TOX_GROUP_JOIN_FAIL_UNKNOWN': 3 +} + +TOX_ERR_GROUP_FOUNDER_SET_PASSWORD = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PASSWORD_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PASSWORD_GROUP_NOT_FOUND': 1, + + # + # The caller does not have the required permissions to set the password. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PASSWORD_PERMISSIONS': 2, + + # + # Password length exceeded 'TOX_GROUP_MAX_PASSWORD_SIZE. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PASSWORD_TOO_LONG': 3, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PASSWORD_FAIL_SEND': 4 +} + +TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_GROUP_NOT_FOUND': 1, + + # + # 'TOX_GROUP_PRIVACY_STATE is an invalid type. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_INVALID': 2, + + # + # The caller does not have the required permissions to set the privacy state. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_PERMISSIONS': 3, + + # + # The privacy state could not be set. This may occur due to an error related to + # cryptographic signing of the new shared state. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_FAIL_SET': 4, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PRIVACY_STATE_FAIL_SEND': 5 +} + +TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT_GROUP_NOT_FOUND': 1, + + # + # The caller does not have the required permissions to set the peer limit. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT_PERMISSIONS': 2, + + # + # The peer limit could not be set. This may occur due to an error related to + # cryptographic signing of the new shared state. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT_FAIL_SET': 3, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_FOUNDER_SET_PEER_LIMIT_FAIL_SEND': 4 +} + +TOX_ERR_GROUP_TOGGLE_IGNORE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_TOGGLE_IGNORE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_TOGGLE_IGNORE_GROUP_NOT_FOUND': 1, + + # + # The ID passed did not designate a valid peer. + # + 'TOX_ERR_GROUP_TOGGLE_IGNORE_PEER_NOT_FOUND': 2 +} + +TOX_ERR_GROUP_MOD_SET_ROLE = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_GROUP_NOT_FOUND': 1, + + # + # The ID passed did not designate a valid peer. Note: you cannot set your own role. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_PEER_NOT_FOUND': 2, + + # + # The caller does not have the required permissions for this action. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_PERMISSIONS': 3, + + # + # The role assignment is invalid. This will occur if you try to set a peer's role to + # the role they already have. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_ASSIGNMENT': 4, + + # + # The role was not successfully set. This may occur if something goes wrong with role setting': , + # or if the packet fails to send. + # + 'TOX_ERR_GROUP_MOD_SET_ROLE_FAIL_ACTION': 5 +} + +TOX_ERR_GROUP_MOD_REMOVE_PEER = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_GROUP_NOT_FOUND': 1, + + # + # The ID passed did not designate a valid peer. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_PEER_NOT_FOUND': 2, + + # + # The caller does not have the required permissions for this action. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_PERMISSIONS': 3, + + # + # The peer could not be removed from the group. + # + # If a ban was set': , this error indicates that the ban entry could not be created. + # This is usually due to the peer's IP address already occurring in the ban list. It may also + # be due to the entry containing invalid peer information': , or a failure to cryptographically + # authenticate the entry. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_FAIL_ACTION': 4, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_MOD_REMOVE_PEER_FAIL_SEND': 5 +} + +TOX_ERR_GROUP_MOD_REMOVE_BAN = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_MOD_REMOVE_BAN_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_MOD_REMOVE_BAN_GROUP_NOT_FOUND': 1, + + # + # The caller does not have the required permissions for this action. + # + 'TOX_ERR_GROUP_MOD_REMOVE_BAN_PERMISSIONS': 2, + + # + # The ban entry could not be removed. This may occur if ban_id does not designate + # a valid ban entry. + # + 'TOX_ERR_GROUP_MOD_REMOVE_BAN_FAIL_ACTION': 3, + + # + # The packet failed to send. + # + 'TOX_ERR_GROUP_MOD_REMOVE_BAN_FAIL_SEND': 4 +} + +TOX_GROUP_MOD_EVENT = { + + # + # A peer has been kicked from the group. + # + 'KICK': 0, + + # + # A peer has been banned from the group. + # + 'BAN': 1, + + # + # A peer as been given the observer role. + # + 'OBSERVER': 2, + + # + # A peer has been given the user role. + # + 'USER': 3, + + # + # A peer has been given the moderator role. + # + 'MODERATOR': 4, +} + +TOX_ERR_GROUP_BAN_QUERY = { + + # + # The function returned successfully. + # + 'TOX_ERR_GROUP_BAN_QUERY_OK': 0, + + # + # The group number passed did not designate a valid group. + # + 'TOX_ERR_GROUP_BAN_QUERY_GROUP_NOT_FOUND': 1, + + # + # The ban_id does not designate a valid ban list entry. + # + 'TOX_ERR_GROUP_BAN_QUERY_BAD_ID': 2, +} + + +TOX_GROUP_BAN_TYPE = { + + 'IP_PORT': 0, + + 'PUBLIC_KEY': 1, + + 'NICK': 2 +} + +TOX_PUBLIC_KEY_SIZE = 32 + +TOX_ADDRESS_SIZE = TOX_PUBLIC_KEY_SIZE + 6 + +TOX_MAX_FRIEND_REQUEST_LENGTH = 1016 + +TOX_MAX_MESSAGE_LENGTH = 1372 + +TOX_GROUP_MAX_TOPIC_LENGTH = 512 + +TOX_GROUP_MAX_PART_LENGTH = 128 + +TOX_GROUP_MAX_GROUP_NAME_LENGTH = 48 + +TOX_GROUP_MAX_PASSWORD_SIZE = 32 + +TOX_GROUP_CHAT_ID_SIZE = 32 + +TOX_GROUP_PEER_PUBLIC_KEY_SIZE = 32 + +TOX_MAX_NAME_LENGTH = 128 + +TOX_MAX_STATUS_MESSAGE_LENGTH = 1007 + +TOX_SECRET_KEY_SIZE = 32 + +TOX_FILE_ID_LENGTH = 32 + +TOX_HASH_LENGTH = 32 + +TOX_MAX_CUSTOM_PACKET_SIZE = 1373 diff --git a/toxygen/wrapper/toxencryptsave.py b/toxygen/wrapper/toxencryptsave.py new file mode 100644 index 0000000..31de085 --- /dev/null +++ b/toxygen/wrapper/toxencryptsave.py @@ -0,0 +1,74 @@ +from wrapper import libtox +from ctypes import c_size_t, create_string_buffer, byref, c_int, ArgumentError, c_char_p, c_bool +from wrapper.toxencryptsave_enums_and_consts import * + + +class ToxEncryptSave: + + def __init__(self): + self.libtoxencryptsave = libtox.LibToxEncryptSave() + + def is_data_encrypted(self, data): + """ + Checks if given data is encrypted + """ + func = self.libtoxencryptsave.tox_is_data_encrypted + func.restype = c_bool + result = func(c_char_p(bytes(data))) + return result + + def pass_encrypt(self, data, password): + """ + Encrypts the given data with the given password. + + :return: output array + """ + out = create_string_buffer(len(data) + TOX_PASS_ENCRYPTION_EXTRA_LENGTH) + tox_err_encryption = c_int() + self.libtoxencryptsave.tox_pass_encrypt(c_char_p(data), + c_size_t(len(data)), + c_char_p(bytes(password, 'utf-8')), + c_size_t(len(password)), + out, + byref(tox_err_encryption)) + tox_err_encryption = tox_err_encryption.value + if tox_err_encryption == TOX_ERR_ENCRYPTION['OK']: + return out[:] + elif tox_err_encryption == TOX_ERR_ENCRYPTION['NULL']: + raise ArgumentError('Some input data, or maybe the output pointer, was null.') + elif tox_err_encryption == TOX_ERR_ENCRYPTION['KEY_DERIVATION_FAILED']: + raise RuntimeError('The crypto lib was unable to derive a key from the given passphrase, which is usually a' + ' lack of memory issue. The functions accepting keys do not produce this error.') + elif tox_err_encryption == TOX_ERR_ENCRYPTION['FAILED']: + raise RuntimeError('The encryption itself failed.') + + def pass_decrypt(self, data, password): + """ + Decrypts the given data with the given password. + + :return: output array + """ + out = create_string_buffer(len(data) - TOX_PASS_ENCRYPTION_EXTRA_LENGTH) + tox_err_decryption = c_int() + self.libtoxencryptsave.tox_pass_decrypt(c_char_p(bytes(data)), + c_size_t(len(data)), + c_char_p(bytes(password, 'utf-8')), + c_size_t(len(password)), + out, + byref(tox_err_decryption)) + tox_err_decryption = tox_err_decryption.value + if tox_err_decryption == TOX_ERR_DECRYPTION['OK']: + return out[:] + elif tox_err_decryption == TOX_ERR_DECRYPTION['NULL']: + raise ArgumentError('Some input data, or maybe the output pointer, was null.') + elif tox_err_decryption == TOX_ERR_DECRYPTION['INVALID_LENGTH']: + raise ArgumentError('The input data was shorter than TOX_PASS_ENCRYPTION_EXTRA_LENGTH bytes') + elif tox_err_decryption == TOX_ERR_DECRYPTION['BAD_FORMAT']: + raise ArgumentError('The input data is missing the magic number (i.e. wasn\'t created by this module, or is' + ' corrupted)') + elif tox_err_decryption == TOX_ERR_DECRYPTION['KEY_DERIVATION_FAILED']: + raise RuntimeError('The crypto lib was unable to derive a key from the given passphrase, which is usually a' + ' lack of memory issue. The functions accepting keys do not produce this error.') + elif tox_err_decryption == TOX_ERR_DECRYPTION['FAILED']: + raise RuntimeError('The encrypted byte array could not be decrypted. Either the data was corrupt or the ' + 'password/key was incorrect.') diff --git a/toxygen/wrapper/toxencryptsave_enums_and_consts.py b/toxygen/wrapper/toxencryptsave_enums_and_consts.py new file mode 100644 index 0000000..cf795f8 --- /dev/null +++ b/toxygen/wrapper/toxencryptsave_enums_and_consts.py @@ -0,0 +1,29 @@ +TOX_ERR_ENCRYPTION = { + # The function returned successfully. + 'OK': 0, + # Some input data, or maybe the output pointer, was null. + 'NULL': 1, + # The crypto lib was unable to derive a key from the given passphrase, which is usually a lack of memory issue. The + # functions accepting keys do not produce this error. + 'KEY_DERIVATION_FAILED': 2, + # The encryption itself failed. + 'FAILED': 3 +} + +TOX_ERR_DECRYPTION = { + # The function returned successfully. + 'OK': 0, + # Some input data, or maybe the output pointer, was null. + 'NULL': 1, + # The input data was shorter than TOX_PASS_ENCRYPTION_EXTRA_LENGTH bytes + 'INVALID_LENGTH': 2, + # The input data is missing the magic number (i.e. wasn't created by this module, or is corrupted) + 'BAD_FORMAT': 3, + # The crypto lib was unable to derive a key from the given passphrase, which is usually a lack of memory issue. The + # functions accepting keys do not produce this error. + 'KEY_DERIVATION_FAILED': 4, + # The encrypted byte array could not be decrypted. Either the data was corrupt or the password/key was incorrect. + 'FAILED': 5, +} + +TOX_PASS_ENCRYPTION_EXTRA_LENGTH = 80