Commit 66d357d6 authored by Saul Ibarra's avatar Saul Ibarra

Add file transfer support

parent 3bc1b5e1
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
__all__ = ['BlinkSettings', 'SIPSimpleSettingsExtension'] __all__ = ['BlinkSettings', 'SIPSimpleSettingsExtension']
import os
import platform import platform
import sys import sys
...@@ -42,8 +43,7 @@ class ChatSettingsExtension(ChatSettings): ...@@ -42,8 +43,7 @@ class ChatSettingsExtension(ChatSettings):
class FileTransferSettingsExtension(FileTransferSettings): class FileTransferSettingsExtension(FileTransferSettings):
auto_accept = Setting(type=bool, default=False) directory = Setting(type=Path, default=Path(os.path.expanduser('~/Downloads')))
directory = Setting(type=Path, default=None, nillable=True)
class GoogleContactsSettings(SettingsGroup): class GoogleContactsSettings(SettingsGroup):
......
...@@ -13,7 +13,7 @@ from PyQt4 import uic ...@@ -13,7 +13,7 @@ from PyQt4 import uic
from PyQt4.QtCore import Qt, QAbstractListModel, QAbstractTableModel, QByteArray, QEasingCurve, QEvent, QMimeData, QModelIndex, QPointF, QPropertyAnimation, QRectF, QRect, QSize, pyqtSignal from PyQt4.QtCore import Qt, QAbstractListModel, QAbstractTableModel, QByteArray, QEasingCurve, QEvent, QMimeData, QModelIndex, QPointF, QPropertyAnimation, QRectF, QRect, QSize, pyqtSignal
from PyQt4.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPainterPath, QPalette, QPen, QPixmap, QPolygonF, QStyle from PyQt4.QtGui import QBrush, QColor, QIcon, QLinearGradient, QPainter, QPainterPath, QPalette, QPen, QPixmap, QPolygonF, QStyle
from PyQt4.QtGui import QAction, QMenu, QKeyEvent, QMouseEvent, QSortFilterProxyModel, QItemDelegate, QStyledItemDelegate from PyQt4.QtGui import QAction, QMenu, QKeyEvent, QMouseEvent, QSortFilterProxyModel, QItemDelegate, QStyledItemDelegate
from PyQt4.QtGui import QApplication, QButtonGroup, QComboBox, QHBoxLayout, QListView, QRadioButton, QTableView, QWidget from PyQt4.QtGui import QApplication, QButtonGroup, QComboBox, QFileDialog, QHBoxLayout, QListView, QRadioButton, QTableView, QWidget
from application import log from application import log
from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy
...@@ -2352,8 +2352,24 @@ class ContactModel(QAbstractListModel): ...@@ -2352,8 +2352,24 @@ class ContactModel(QAbstractListModel):
return True return True
def _DH_TextUriList(self, mime_data, action, index): def _DH_TextUriList(self, mime_data, action, index):
if not index.isValid():
return False
item = self.items[index.row()]
if not isinstance(item, Contact):
return False
# TODO: support directories? -Saul
files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
if not files:
return False return False
contact = item
session_manager = SessionManager()
for filename in files:
session_manager.send_file(contact, contact.uri, filename)
return True
@run_in_gui_thread @run_in_gui_thread
def handle_notification(self, notification): def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null) handler = getattr(self, '_NH_%s' % notification.name, Null)
...@@ -2752,8 +2768,21 @@ class ContactSearchModel(QSortFilterProxyModel): ...@@ -2752,8 +2768,21 @@ class ContactSearchModel(QSortFilterProxyModel):
return False return False
def _DH_TextUriList(self, mime_data, action, index): def _DH_TextUriList(self, mime_data, action, index):
if not index.isValid():
return False
# TODO: support directories? -Saul
files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
if not files:
return False return False
contact = index.data(Qt.UserRole)
session_manager = SessionManager()
for filename in files:
session_manager.send_file(contact, contact.uri, filename)
return True
class ContactDetailModel(QAbstractListModel): class ContactDetailModel(QAbstractListModel):
implements(IObserver) implements(IObserver)
...@@ -2865,8 +2894,27 @@ class ContactDetailModel(QAbstractListModel): ...@@ -2865,8 +2894,27 @@ class ContactDetailModel(QAbstractListModel):
return False return False
def _DH_TextUriList(self, mime_data, action, index): def _DH_TextUriList(self, mime_data, action, index):
if not index.isValid():
contact_uri = self.contact_detail.uri
else:
item = self.items[index.row()]
if isinstance(item, ContactURI):
contact_uri = item.uri
else:
contact_uri = self.contact_detail.uri
# TODO: support directories? -Saul
files = [url.toLocalFile() for url in mime_data.urls() if url.isLocalFile() and os.path.isfile(url.toLocalFile())]
if not files:
return False return False
contact = self.contact_detail
session_manager = SessionManager()
for filename in files:
session_manager.send_file(contact, contact_uri, filename)
return True
@run_in_gui_thread @run_in_gui_thread
def handle_notification(self, notification): def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null) handler = getattr(self, '_NH_%s' % notification.name, Null)
...@@ -3026,7 +3074,7 @@ class ContactListView(QListView): ...@@ -3026,7 +3074,7 @@ class ContactListView(QListView):
self.actions.start_audio_call.setEnabled(default_account is not None) self.actions.start_audio_call.setEnabled(default_account is not None)
self.actions.start_chat_session.setEnabled(default_account is not None) self.actions.start_chat_session.setEnabled(default_account is not None)
self.actions.send_sms.setEnabled(False) self.actions.send_sms.setEnabled(False)
self.actions.send_files.setEnabled(False) self.actions.send_files.setEnabled(True)
self.actions.request_screen.setEnabled(False) self.actions.request_screen.setEnabled(False)
self.actions.share_my_screen.setEnabled(False) self.actions.share_my_screen.setEnabled(False)
self.actions.edit_item.setEnabled(contact.editable) self.actions.edit_item.setEnabled(contact.editable)
...@@ -3251,7 +3299,12 @@ class ContactListView(QListView): ...@@ -3251,7 +3299,12 @@ class ContactListView(QListView):
pass pass
def _AH_SendFiles(self): def _AH_SendFiles(self):
pass session_manager = SessionManager()
files = QFileDialog.getOpenFileNames(self, u'Select File(s)', session_manager.send_file_directory, u'Any file (*.*)')
if files:
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
for filename in files:
session_manager.send_file(contact, contact.uri, filename)
def _AH_RequestScreen(self): def _AH_RequestScreen(self):
pass pass
...@@ -3418,7 +3471,7 @@ class ContactSearchListView(QListView): ...@@ -3418,7 +3471,7 @@ class ContactSearchListView(QListView):
self.actions.start_audio_call.setEnabled(default_account is not None) self.actions.start_audio_call.setEnabled(default_account is not None)
self.actions.start_chat_session.setEnabled(default_account is not None) self.actions.start_chat_session.setEnabled(default_account is not None)
self.actions.send_sms.setEnabled(False) self.actions.send_sms.setEnabled(False)
self.actions.send_files.setEnabled(False) self.actions.send_files.setEnabled(True)
self.actions.request_screen.setEnabled(False) self.actions.request_screen.setEnabled(False)
self.actions.share_my_screen.setEnabled(False) self.actions.share_my_screen.setEnabled(False)
self.actions.edit_item.setEnabled(contact.editable) self.actions.edit_item.setEnabled(contact.editable)
...@@ -3579,7 +3632,12 @@ class ContactSearchListView(QListView): ...@@ -3579,7 +3632,12 @@ class ContactSearchListView(QListView):
pass pass
def _AH_SendFiles(self): def _AH_SendFiles(self):
pass session_manager = SessionManager()
files = QFileDialog.getOpenFileNames(self, u'Select File(s)', session_manager.send_file_directory, u'Any file (*.*)')
if files:
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
for filename in files:
session_manager.send_file(contact, contact.uri, filename)
def _AH_RequestScreen(self): def _AH_RequestScreen(self):
pass pass
...@@ -3634,6 +3692,7 @@ class ContactDetailView(QListView): ...@@ -3634,6 +3692,7 @@ class ContactDetailView(QListView):
self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles) self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
self.actions.request_screen = QAction("Request Screen", self, triggered=self._AH_RequestScreen) self.actions.request_screen = QAction("Request Screen", self, triggered=self._AH_RequestScreen)
self.actions.share_my_screen = QAction("Share My Screen", self, triggered=self._AH_ShareMyScreen) self.actions.share_my_screen = QAction("Share My Screen", self, triggered=self._AH_ShareMyScreen)
self.drop_indicator_index = QModelIndex()
self.doubleClicked.connect(self._SH_DoubleClicked) # activated is emitted on single click self.doubleClicked.connect(self._SH_DoubleClicked) # activated is emitted on single click
contact_list.installEventFilter(self) contact_list.installEventFilter(self)
...@@ -3688,7 +3747,7 @@ class ContactDetailView(QListView): ...@@ -3688,7 +3747,7 @@ class ContactDetailView(QListView):
self.actions.start_audio_call.setEnabled(account_manager.default_account is not None and contact_has_uris) self.actions.start_audio_call.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.start_chat_session.setEnabled(account_manager.default_account is not None and contact_has_uris) self.actions.start_chat_session.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.send_sms.setEnabled(False) self.actions.send_sms.setEnabled(False)
self.actions.send_files.setEnabled(False) self.actions.send_files.setEnabled(True)
self.actions.request_screen.setEnabled(False) self.actions.request_screen.setEnabled(False)
self.actions.share_my_screen.setEnabled(False) self.actions.share_my_screen.setEnabled(False)
self.actions.edit_contact.setEnabled(model.contact_detail.editable) self.actions.edit_contact.setEnabled(model.contact_detail.editable)
...@@ -3707,6 +3766,17 @@ class ContactDetailView(QListView): ...@@ -3707,6 +3766,17 @@ class ContactDetailView(QListView):
else: else:
super(ContactDetailView, self).keyPressEvent(event) super(ContactDetailView, self).keyPressEvent(event)
def paintEvent(self, event):
super(ContactDetailView, self).paintEvent(event)
if self.drop_indicator_index.isValid():
rect = self.visualRect(self.drop_indicator_index)
painter = QPainter(self.viewport())
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setBrush(Qt.NoBrush)
painter.setPen(QPen(QBrush(QColor('#dc3169')), 2.0))
painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3, 3)
painter.end()
def startDrag(self, supported_actions): def startDrag(self, supported_actions):
super(ContactDetailView, self).startDrag(supported_actions) super(ContactDetailView, self).startDrag(supported_actions)
main_window = QApplication.instance().main_window main_window = QApplication.instance().main_window
...@@ -3724,18 +3794,23 @@ class ContactDetailView(QListView): ...@@ -3724,18 +3794,23 @@ class ContactDetailView(QListView):
def dragLeaveEvent(self, event): def dragLeaveEvent(self, event):
super(ContactDetailView, self).dragLeaveEvent(event) super(ContactDetailView, self).dragLeaveEvent(event)
self.viewport().update(self.visualRect(self.drop_indicator_index))
self.drop_indicator_index = QModelIndex()
def dragMoveEvent(self, event): def dragMoveEvent(self, event):
super(ContactDetailView, self).dragMoveEvent(event) super(ContactDetailView, self).dragMoveEvent(event)
model = self.model() model = self.model()
for mime_type in model.accepted_mime_types: for mime_type in model.accepted_mime_types:
if event.provides(mime_type): if event.provides(mime_type):
self.viewport().update(self.visualRect(self.drop_indicator_index))
self.drop_indicator_index = QModelIndex()
index = self.indexAt(event.pos()) index = self.indexAt(event.pos())
rect = self.visualRect(index) rect = self.visualRect(index)
item = index.data(Qt.UserRole) item = index.data(Qt.UserRole)
name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '') name = mime_type.replace('/', ' ').replace('-', ' ').title().replace(' ', '')
handler = getattr(self, '_DH_%s' % name) handler = getattr(self, '_DH_%s' % name)
handler(event, index, rect, item) handler(event, index, rect, item)
self.viewport().update(self.visualRect(self.drop_indicator_index))
break break
else: else:
event.ignore() event.ignore()
...@@ -3745,6 +3820,8 @@ class ContactDetailView(QListView): ...@@ -3745,6 +3820,8 @@ class ContactDetailView(QListView):
if model.handleDroppedData(event.mimeData(), event.dropAction(), self.indexAt(event.pos())): if model.handleDroppedData(event.mimeData(), event.dropAction(), self.indexAt(event.pos())):
event.accept() event.accept()
super(ContactDetailView, self).dropEvent(event) super(ContactDetailView, self).dropEvent(event)
self.viewport().update(self.visualRect(self.drop_indicator_index))
self.drop_indicator_index = QModelIndex()
def _AH_DeleteContact(self): def _AH_DeleteContact(self):
self.contact_list._AH_DeleteSelection() self.contact_list._AH_DeleteSelection()
...@@ -3784,7 +3861,18 @@ class ContactDetailView(QListView): ...@@ -3784,7 +3861,18 @@ class ContactDetailView(QListView):
pass pass
def _AH_SendFiles(self): def _AH_SendFiles(self):
pass session_manager = SessionManager()
files = QFileDialog.getOpenFileNames(self, u'Select File(s)', session_manager.send_file_directory, u'Any file (*.*)')
if files:
contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
selected_indexes = self.selectionModel().selectedIndexes()
item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
if isinstance(item, ContactURI):
selected_uri = item.uri
else:
selected_uri = contact.uri
for filename in files:
session_manager.send_file(contact, selected_uri, filename)
def _AH_RequestScreen(self): def _AH_RequestScreen(self):
pass pass
...@@ -3796,7 +3884,14 @@ class ContactDetailView(QListView): ...@@ -3796,7 +3884,14 @@ class ContactDetailView(QListView):
event.ignore(rect) event.ignore(rect)
def _DH_TextUriList(self, event, index, rect, item): def _DH_TextUriList(self, event, index, rect, item):
event.ignore(rect) if index.isValid():
event.accept(rect)
self.drop_indicator_index = index
else:
model = self.model()
rect = self.viewport().rect()
rect.setTop(self.visualRect(model.index(model.rowCount()-1, 0)).bottom())
event.accept(rect)
def _SH_AnimationFinished(self): def _SH_AnimationFinished(self):
if self.animation.direction() == QPropertyAnimation.Forward: if self.animation.direction() == QPropertyAnimation.Forward:
......
# Copyright (C) 2014 AG Projects. See LICENSE for details.
#
__all__ = ['FileTransferWindow']
import os
from PyQt4 import uic
from PyQt4.QtCore import Qt, QUrl
from PyQt4.QtGui import QAction, QDesktopServices, QMenu
from application.notification import IObserver, NotificationCenter
from application.python import Null
from application.system import makedirs
from zope.interface import implements
from sipsimple.configuration.settings import SIPSimpleSettings
from blink.resources import Resources
from blink.sessions import FileTransferDelegate, FileTransferModel
from blink.widgets.util import ContextMenuActions
ui_class, base_class = uic.loadUiType(Resources.get('filetransfer_window.ui'))
class FileTransferWindow(base_class, ui_class):
implements(IObserver)
def __init__(self, parent=None):
super(FileTransferWindow, self).__init__(parent)
with Resources.directory:
self.setupUi(self)
self.model = FileTransferModel(self)
self.listview.setModel(self.model)
self.listview.setItemDelegate(FileTransferDelegate(self.listview))
self.listview.customContextMenuRequested.connect(self._SH_ContextMenuRequested)
self.context_menu = QMenu(self.listview)
self.actions = ContextMenuActions()
self.actions.open_file = QAction("Open", self, triggered=self._AH_OpenFile)
self.actions.open_folder = QAction("Open Containing Folder", self, triggered=self._AH_OpenContainingFolder)
self.actions.cancel_transfer = QAction("Cancel", self, triggered=self._AH_CancelTransfer)
self.actions.remove_entry = QAction("Remove From List", self, triggered=self._AH_RemoveEntry)
self.actions.open_downloads_folder = QAction("Open Downloads Folder", self, triggered=self._AH_OpenDownloadsFolder)
self.actions.clear_list = QAction("Clear List", self, triggered=self._AH_ClearList)
self.model.itemAdded.connect(self.update_status)
self.model.itemRemoved.connect(self.update_status)
notification_center = NotificationCenter()
notification_center.add_observer(self, name='FileTransferDidEnd')
def show(self, activate=True):
settings = SIPSimpleSettings()
directory = settings.file_transfer.directory.normalized
makedirs(directory)
self.setAttribute(Qt.WA_ShowWithoutActivating, not activate)
super(FileTransferWindow, self).show()
self.raise_()
if activate:
self.activateWindow()
def update_status(self):
total = len(self.model.items)
active = len([item for item in self.model.items if not item.ended])
text = u'%d %s' % (total, 'transfer' if total==1 else 'transfers')
if active > 0:
text += u' (%d active)' % active
self.status_label.setText(text)
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_FileTransferDidEnd(self, notification):
self.update_status()
def _SH_ContextMenuRequested(self, pos):
menu = self.context_menu
menu.clear()
index = self.listview.indexAt(pos)
if index.isValid():
item = index.data(Qt.UserRole)
if item.ended:
if item.direction == 'incoming' and item.ended and not item.failed:
menu.addAction(self.actions.open_file)
menu.addAction(self.actions.open_folder)
menu.addAction(self.actions.remove_entry)
else:
menu.addAction(self.actions.cancel_transfer)
menu.addSeparator()
menu.addAction(self.actions.open_downloads_folder)
menu.addAction(self.actions.clear_list)
elif self.model.rowCount() > 0:
menu.addAction(self.actions.open_downloads_folder)
menu.addAction(self.actions.clear_list)
else:
menu.addAction(self.actions.open_downloads_folder)
menu.exec_(self.mapToGlobal(pos))
def _AH_OpenFile(self):
item = self.listview.selectedIndexes()[0].data(Qt.UserRole)
QDesktopServices.openUrl(QUrl.fromLocalFile(item.filename))
def _AH_OpenContainingFolder(self):
item = self.listview.selectedIndexes()[0].data(Qt.UserRole)
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.basename(item.filename)))
def _AH_CancelTransfer(self):
item = self.listview.selectedIndexes()[0].data(Qt.UserRole)
item.end()
def _AH_RemoveEntry(self):
item = self.listview.selectedIndexes()[0].data(Qt.UserRole)
self.model.removeItem(item)
def _AH_OpenDownloadsFolder(self):
settings = SIPSimpleSettings()
directory = settings.file_transfer.directory.normalized
QDesktopServices.openUrl(QUrl.fromLocalFile(directory))
def _AH_ClearList(self):
self.model.clear_ended()
del ui_class, base_class
...@@ -25,6 +25,7 @@ from sipsimple.configuration.settings import SIPSimpleSettings ...@@ -25,6 +25,7 @@ from sipsimple.configuration.settings import SIPSimpleSettings
from blink.aboutpanel import AboutPanel from blink.aboutpanel import AboutPanel
from blink.accounts import AccountModel, ActiveAccountModel, ServerToolsAccountModel, ServerToolsWindow from blink.accounts import AccountModel, ActiveAccountModel, ServerToolsAccountModel, ServerToolsWindow
from blink.contacts import Contact, ContactEditorDialog, ContactModel, ContactSearchModel, GoogleContactsDialog, URIUtils from blink.contacts import Contact, ContactEditorDialog, ContactModel, ContactSearchModel, GoogleContactsDialog, URIUtils
from blink.filetransferwindow import FileTransferWindow
from blink.history import HistoryManager from blink.history import HistoryManager
from blink.preferences import PreferencesWindow from blink.preferences import PreferencesWindow
from blink.sessions import ConferenceDialog, SessionManager, AudioSessionModel, StreamDescription from blink.sessions import ConferenceDialog, SessionManager, AudioSessionModel, StreamDescription
...@@ -52,6 +53,8 @@ class MainWindow(base_class, ui_class): ...@@ -52,6 +53,8 @@ class MainWindow(base_class, ui_class):
notification_center.add_observer(self, name='SIPAccountGotPendingWatcher') notification_center.add_observer(self, name='SIPAccountGotPendingWatcher')
notification_center.add_observer(self, name='BlinkSessionNewOutgoing') notification_center.add_observer(self, name='BlinkSessionNewOutgoing')
notification_center.add_observer(self, name='BlinkSessionDidReinitializeForOutgoing') notification_center.add_observer(self, name='BlinkSessionDidReinitializeForOutgoing')
notification_center.add_observer(self, name='FileTransferNewIncoming')
notification_center.add_observer(self, name='FileTransferNewOutgoing')
notification_center.add_observer(self, sender=AccountManager()) notification_center.add_observer(self, sender=AccountManager())
icon_manager = IconManager() icon_manager = IconManager()
...@@ -122,6 +125,7 @@ class MainWindow(base_class, ui_class): ...@@ -122,6 +125,7 @@ class MainWindow(base_class, ui_class):
self.conference_dialog = ConferenceDialog(self) self.conference_dialog = ConferenceDialog(self)
self.contact_editor_dialog = ContactEditorDialog(self) self.contact_editor_dialog = ContactEditorDialog(self)
self.google_contacts_dialog = GoogleContactsDialog(self) self.google_contacts_dialog = GoogleContactsDialog(self)
self.filetransfer_window = FileTransferWindow()
self.preferences_window = PreferencesWindow(self.account_model, None) self.preferences_window = PreferencesWindow(self.account_model, None)
self.server_tools_window = ServerToolsWindow(self.server_tools_account_model, None) self.server_tools_window = ServerToolsWindow(self.server_tools_account_model, None)
...@@ -172,7 +176,6 @@ class MainWindow(base_class, ui_class): ...@@ -172,7 +176,6 @@ class MainWindow(base_class, ui_class):
self.help_action.triggered.connect(partial(QDesktopServices.openUrl, QUrl(u'http://icanblink.com/help-qt.phtml'))) self.help_action.triggered.connect(partial(QDesktopServices.openUrl, QUrl(u'http://icanblink.com/help-qt.phtml')))
self.preferences_action.triggered.connect(self.preferences_window.show) self.preferences_action.triggered.connect(self.preferences_window.show)
self.auto_accept_chat_action.triggered.connect(self._AH_AutoAcceptChatTriggered) self.auto_accept_chat_action.triggered.connect(self._AH_AutoAcceptChatTriggered)
self.auto_accept_files_action.triggered.connect(self._AH_AutoAcceptFilesTriggered)
self.release_notes_action.triggered.connect(partial(QDesktopServices.openUrl, QUrl(u'http://icanblink.com/changelog-qt.phtml'))) self.release_notes_action.triggered.connect(partial(QDesktopServices.openUrl, QUrl(u'http://icanblink.com/changelog-qt.phtml')))
self.quit_action.triggered.connect(self._AH_QuitActionTriggered) self.quit_action.triggered.connect(self._AH_QuitActionTriggered)
...@@ -202,6 +205,7 @@ class MainWindow(base_class, ui_class): ...@@ -202,6 +205,7 @@ class MainWindow(base_class, ui_class):
self.search_for_people_action.triggered.connect(self._AH_SearchForPeople) self.search_for_people_action.triggered.connect(self._AH_SearchForPeople)
self.history_on_server_action.triggered.connect(self._AH_HistoryOnServer) self.history_on_server_action.triggered.connect(self._AH_HistoryOnServer)
self.buy_pstn_access_action.triggered.connect(self._AH_PurchasePstnAccess) self.buy_pstn_access_action.triggered.connect(self._AH_PurchasePstnAccess)
self.file_transfers_action.triggered.connect(self._AH_FileTransfersActionTriggered)
self.logs_action.triggered.connect(self._AH_LogsActionTriggered) self.logs_action.triggered.connect(self._AH_LogsActionTriggered)
def setupUi(self): def setupUi(self):
...@@ -235,6 +239,7 @@ class MainWindow(base_class, ui_class): ...@@ -235,6 +239,7 @@ class MainWindow(base_class, ui_class):
self.conference_dialog.close() self.conference_dialog.close()
self.contact_editor_dialog.close() self.contact_editor_dialog.close()
self.google_contacts_dialog.close() self.google_contacts_dialog.close()
self.filetransfer_window.close()
self.preferences_window.close() self.preferences_window.close()
self.server_tools_window.close() self.server_tools_window.close()
for dialog in self.pending_watcher_dialogs[:]: for dialog in self.pending_watcher_dialogs[:]:
...@@ -324,11 +329,6 @@ class MainWindow(base_class, ui_class): ...@@ -324,11 +329,6 @@ class MainWindow(base_class, ui_class):
settings.chat.auto_accept = checked settings.chat.auto_accept = checked
settings.save() settings.save()
def _AH_AutoAcceptFilesTriggered(self, checked):
settings = SIPSimpleSettings()
settings.file_transfer.auto_accept = checked
settings.save()
def _AH_EnableAnsweringMachineTriggered(self, checked): def _AH_EnableAnsweringMachineTriggered(self, checked):
settings = SIPSimpleSettings() settings = SIPSimpleSettings()
settings.answering_machine.enabled = checked settings.answering_machine.enabled = checked
...@@ -369,6 +369,9 @@ class MainWindow(base_class, ui_class): ...@@ -369,6 +369,9 @@ class MainWindow(base_class, ui_class):
account = account if account is not BonjourAccount() and account.server.settings_url else None account = account if account is not BonjourAccount() and account.server.settings_url else None
self.server_tools_window.open_buy_pstn_access_page(account) self.server_tools_window.open_buy_pstn_access_page(account)
def _AH_FileTransfersActionTriggered(self, checked):
self.filetransfer_window.show()
def _AH_LogsActionTriggered(self, checked): def _AH_LogsActionTriggered(self, checked):
directory = ApplicationData.get('logs') directory = ApplicationData.get('logs')
makedirs(directory) makedirs(directory)
...@@ -662,7 +665,6 @@ class MainWindow(base_class, ui_class): ...@@ -662,7 +665,6 @@ class MainWindow(base_class, ui_class):
self.silent_button.setChecked(settings.audio.silent) self.silent_button.setChecked(settings.audio.silent)
self.answering_machine_action.setChecked(settings.answering_machine.enabled) self.answering_machine_action.setChecked(settings.answering_machine.enabled)
self.auto_accept_chat_action.setChecked(settings.chat.auto_accept) self.auto_accept_chat_action.setChecked(settings.chat.auto_accept)
self.auto_accept_files_action.setChecked(settings.file_transfer.auto_accept)
if settings.google_contacts.authorization_token is None: if settings.google_contacts.authorization_token is None:
self.google_contacts_action.setText(u'Enable Google Contacts') self.google_contacts_action.setText(u'Enable Google Contacts')
else: else:
...@@ -728,8 +730,6 @@ class MainWindow(base_class, ui_class): ...@@ -728,8 +730,6 @@ class MainWindow(base_class, ui_class):
self.answering_machine_action.setChecked(settings.answering_machine.enabled) self.answering_machine_action.setChecked(settings.answering_machine.enabled)
if 'chat.auto_accept' in notification.data.modified: if 'chat.auto_accept' in notification.data.modified:
self.auto_accept_chat_action.setChecked(settings.chat.auto_accept) self.auto_accept_chat_action.setChecked(settings.chat.auto_accept)
if 'file_transfer.auto_accept' in notification.data.modified:
self.auto_accept_files_action.setChecked(settings.file_transfer.auto_accept)
if 'google_contacts.authorization_token' in notification.data.modified: if 'google_contacts.authorization_token' in notification.data.modified:
authorization_token = notification.sender.google_contacts.authorization_token authorization_token = notification.sender.google_contacts.authorization_token
if authorization_token is None: if authorization_token is None:
...@@ -816,6 +816,11 @@ class MainWindow(base_class, ui_class): ...@@ -816,6 +816,11 @@ class MainWindow(base_class, ui_class):
def _NH_BlinkSessionDidReinitializeForOutgoing(self, notification): def _NH_BlinkSessionDidReinitializeForOutgoing(self, notification):
self.search_box.clear() self.search_box.clear()
def _NH_FileTransferNewIncoming(self, notification):
self.filetransfer_window.show(activate=QApplication.activeWindow() is not None)
def _NH_FileTransferNewOutgoing(self, notification):
self.filetransfer_window.show(activate=QApplication.activeWindow() is not None)
del ui_class, base_class del ui_class, base_class
......
...@@ -240,7 +240,6 @@ class PreferencesWindow(base_class, ui_class): ...@@ -240,7 +240,6 @@ class PreferencesWindow(base_class, ui_class):
self.sms_replication_button.clicked.connect(self._SH_SMSReplicationButtonClicked) self.sms_replication_button.clicked.connect(self._SH_SMSReplicationButtonClicked)
# File transfer # File transfer
self.auto_accept_files_button.clicked.connect(self._SH_AutoAcceptFilesButtonClicked)
self.download_directory_editor.locationCleared.connect(self._SH_DownloadDirectoryEditorLocationCleared) self.download_directory_editor.locationCleared.connect(self._SH_DownloadDirectoryEditorLocationCleared)
self.download_directory_browse_button.clicked.connect(self._SH_DownloadDirectoryBrowseButtonClicked) self.download_directory_browse_button.clicked.connect(self._SH_DownloadDirectoryBrowseButtonClicked)
...@@ -501,7 +500,6 @@ class PreferencesWindow(base_class, ui_class): ...@@ -501,7 +500,6 @@ class PreferencesWindow(base_class, ui_class):
self.sms_replication_button.setChecked(settings.chat.sms_replication) self.sms_replication_button.setChecked(settings.chat.sms_replication)
# File transfer settings # File transfer settings
self.auto_accept_files_button.setChecked(settings.file_transfer.auto_accept)
self.download_directory_editor.setText(settings.file_transfer.directory or u'') self.download_directory_editor.setText(settings.file_transfer.directory or u'')
# Alert settings # Alert settings
...@@ -1091,11 +1089,6 @@ class PreferencesWindow(base_class, ui_class): ...@@ -1091,11 +1089,6 @@ class PreferencesWindow(base_class, ui_class):
settings.save() settings.save()
# File transfer signal handlers # File transfer signal handlers
def _SH_AutoAcceptFilesButtonClicked(self, checked):
settings = SIPSimpleSettings()
settings.file_transfer.auto_accept = checked
settings.save()
def _SH_DownloadDirectoryEditorLocationCleared(self): def _SH_DownloadDirectoryEditorLocationCleared(self):
settings = SIPSimpleSettings() settings = SIPSimpleSettings()
settings.file_transfer.directory = None settings.file_transfer.directory = None
...@@ -1299,8 +1292,6 @@ class PreferencesWindow(base_class, ui_class): ...@@ -1299,8 +1292,6 @@ class PreferencesWindow(base_class, ui_class):
self.enable_answering_machine_button.setChecked(settings.answering_machine.enabled) self.enable_answering_machine_button.setChecked(settings.answering_machine.enabled)
if 'chat.auto_accept' in notification.data.modified: if 'chat.auto_accept' in notification.data.modified:
self.auto_accept_chat_button.setChecked(settings.chat.auto_accept) self.auto_accept_chat_button.setChecked(settings.chat.auto_accept)
if 'file_transfer.auto_accept' in notification.data.modified:
self.auto_accept_files_button.setChecked(settings.file_transfer.auto_accept)
elif isinstance(notification.sender, (Account, BonjourAccount)) and notification.sender is self.selected_account: elif isinstance(notification.sender, (Account, BonjourAccount)) and notification.sender is self.selected_account:
account = notification.sender account = notification.sender
if 'enabled' in notification.data.modified: if 'enabled' in notification.data.modified:
......
...@@ -5,25 +5,29 @@ __all__ = ['ClientConference', 'ConferenceDialog', 'AudioSessionModel', 'AudioSe ...@@ -5,25 +5,29 @@ __all__ = ['ClientConference', 'ConferenceDialog', 'AudioSessionModel', 'AudioSe
import bisect import bisect
import cPickle as pickle import cPickle as pickle
import hashlib
import os import os
import re import re
import string import string
import sys
from collections import defaultdict, deque from collections import defaultdict, deque
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial from functools import partial
from itertools import chain, izip, repeat from itertools import chain, count, izip, repeat
from operator import attrgetter from operator import attrgetter
from threading import Event
from PyQt4 import uic from PyQt4 import uic
from PyQt4.QtCore import Qt, QAbstractListModel, QByteArray, QEasingCurve, QEvent, QMimeData, QModelIndex, QObject, QPointF, QPropertyAnimation, QRect, QSize, QTimer, pyqtSignal from PyQt4.QtCore import Qt, QAbstractListModel, QByteArray, QEasingCurve, QEvent, QMimeData, QModelIndex, QObject, QPointF, QPropertyAnimation, QRect, QRectF, QSize, QTimer, QUrl, pyqtSignal
from PyQt4.QtGui import QApplication, QBrush, QColor, QDrag, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPalette, QPen, QPixmap, QPolygonF, QShortcut from PyQt4.QtGui import QApplication, QBrush, QColor, QDesktopServices, QDrag, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPainterPath, QPalette, QPen, QPixmap, QPolygonF, QShortcut
from PyQt4.QtGui import QStyle, QStyledItemDelegate, QStyleOption from PyQt4.QtGui import QStyle, QStyledItemDelegate, QStyleOption
from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy
from application.python import Null, limit from application.python import Null, limit
from application.python.types import MarkerType, Singleton from application.python.types import MarkerType, Singleton
from application.python.weakref import weakobjectmap from application.python.weakref import weakobjectmap
from application.system import makedirs, unlink
from zope.interface import implements from zope.interface import implements
from sipsimple.account import Account, AccountManager, BonjourAccount from sipsimple.account import Account, AccountManager, BonjourAccount
...@@ -34,12 +38,14 @@ from sipsimple.core import SIPCoreError, SIPURI, ToHeader ...@@ -34,12 +38,14 @@ from sipsimple.core import SIPCoreError, SIPURI, ToHeader
from sipsimple.lookup import DNSLookup from sipsimple.lookup import DNSLookup
from sipsimple.session import Session from sipsimple.session import Session
from sipsimple.streams import MediaStreamRegistry from sipsimple.streams import MediaStreamRegistry
from sipsimple.streams.msrp import FileSelector
from sipsimple.threading import run_in_thread
from blink.resources import Resources from blink.resources import Resources
from blink.util import call_later, run_in_gui_thread from blink.util import call_later, call_in_gui_thread, run_in_gui_thread
from blink.widgets.buttons import LeftSegment, MiddleSegment, RightSegment from blink.widgets.buttons import LeftSegment, MiddleSegment, RightSegment
from blink.widgets.labels import Status from blink.widgets.labels import Status
from blink.widgets.color import ColorHelperMixin from blink.widgets.color import ColorHelperMixin, ColorUtils, cache_result, background_color_key
from blink.widgets.util import ContextMenuActions, QtDynamicProperty from blink.widgets.util import ContextMenuActions, QtDynamicProperty
...@@ -310,12 +316,12 @@ class StreamListDescriptor(object): ...@@ -310,12 +316,12 @@ class StreamListDescriptor(object):
raise AttributeError("Attribute cannot be deleted") raise AttributeError("Attribute cannot be deleted")
class BlinkSessionState(str): class SessionState(str):
state = property(lambda self: str(self.partition('/')[0]) or None) state = property(lambda self: str(self.partition('/')[0]) or None)
substate = property(lambda self: str(self.partition('/')[2]) or None) substate = property(lambda self: str(self.partition('/')[2]) or None)
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, BlinkSessionState): if isinstance(other, SessionState):
return self.state == other.state and self.substate == other.substate return self.state == other.state and self.substate == other.substate
elif isinstance(other, basestring): elif isinstance(other, basestring):
state = other.partition('/')[0] or None state = other.partition('/')[0] or None
...@@ -403,8 +409,8 @@ class BlinkSession(QObject): ...@@ -403,8 +409,8 @@ class BlinkSession(QObject):
return self.__dict__['state'] return self.__dict__['state']
def _set_state(self, value): def _set_state(self, value):
if value is not None and not isinstance(value, BlinkSessionState): if value is not None and not isinstance(value, SessionState):
value = BlinkSessionState(value) value = SessionState(value)
old_state = self.__dict__.get('state', None) old_state = self.__dict__.get('state', None)
new_state = self.__dict__['state'] = value new_state = self.__dict__['state'] = value
if new_state != old_state: if new_state != old_state:
...@@ -732,7 +738,7 @@ class BlinkSession(QObject): ...@@ -732,7 +738,7 @@ class BlinkSession(QObject):
self.remote_hold = False self.remote_hold = False
self.recording = False self.recording = False
state = BlinkSessionState('ended') state = SessionState('ended')
state.reason = reason state.reason = reason
state.error = error state.error = error
...@@ -3166,6 +3172,844 @@ class ChatSessionListView(QListView): ...@@ -3166,6 +3172,844 @@ class ChatSessionListView(QListView):
self.ignore_selection_changes = False self.ignore_selection_changes = False
# File transfers
#
class FileSizeFormatter(object):
boundaries = [( 1024, '%d bytes', 1),
( 10*1024, '%.2f KB', 1024.0), ( 1024*1024, '%.1f KB', 1024.0),
( 10*1024*1024, '%.2f MB', 1024*1024.0), ( 1024*1024*1024, '%.1f MB', 1024*1024.0),
(10*1024*1024*1024, '%.2f GB', 1024*1024*1024.0), (float('infinity'), '%.1f GB', 1024*1024*1024.0)]
@classmethod
def format(cls, size):
for boundary, format, divisor in cls.boundaries:
if size < boundary:
return format % (size/divisor,)
else:
return "%d bytes" % size
class UniqueFilenameGenerator(object):
@classmethod
def generate(cls, name):
yield name
prefix, extension = os.path.splitext(name)
for x in count(1):
yield "%s-%d%s" % (prefix, x, extension)
class FileTransfer(object):
implements(IObserver)
tmp_file_suffix = '.download'
def __init__(self):
self.direction = None
self.state = None
self.account = None
self.contact = None
self.contact_uri = None
self.sip_session = None
self.stream = None
self.filename = None
self._file_selector = None
self._error = False
self._finished = False
self._reason = None
def init_incoming(self, contact, contact_uri, session, stream):
assert self.state is None
self.direction = 'incoming'
self.account = session.account
self.contact = contact
self.contact_uri = contact_uri
self.sip_session = session
self.stream = stream
self._file_selector = stream.file_selector
self._local_hash = hashlib.sha1()
settings = SIPSimpleSettings()
directory = settings.file_transfer.directory.normalized
makedirs(directory)
filename = os.path.basename(self._file_selector.name)
for name in UniqueFilenameGenerator.generate(os.path.join(directory, filename)):
if not os.path.exists(name) and not os.path.exists(name + self.tmp_file_suffix):
self.filename = name
break
self._file_selector.fd = open(self.filename+self.tmp_file_suffix, 'w+')
self.state = 'connecting'
notification_center = NotificationCenter()
notification_center.post_notification('FileTransferNewIncoming', sender=self)
self.sip_session.accept([self.stream])
def init_outgoing(self, account, contact, contact_uri, filename):
assert self.state is None
self.direction = 'outgoing'
self.account = account
self.contact = contact
self.contact_uri = contact_uri
self._stop_event = Event()
self._uri = self._normalize_uri(contact_uri.uri)
self._file_selector = FileSelector.for_file(filename.encode(sys.getfilesystemencoding()), hash=None)
self.filename = filename
self.state = 'initialized'
notification_center = NotificationCenter()
notification_center.post_notification('FileTransferNewOutgoing', self)
def connect(self):
assert self.direction == 'outgoing' and self.state in ('initialized', 'ended')
if self.state == 'ended':
# Reinitialize to retry
self._error = False
self._finished = False
self._reason = None
self._stop_event.clear()
self._file_selector = FileSelector.for_file(self.filename.encode(sys.getfilesystemencoding()), hash=None)
self.state = 'initialized'
notification_center = NotificationCenter()
notification_center.post_notification('FileTransferWillRetry', self)
# TODO: use a pool of threads -Saul
self._calculate_hash()
def end(self):
assert self.state is not None
if self.state in ('ending', 'ended'):
return
self.state = 'ending'
if self.sip_session is not None:
self.sip_session.end()
else:
assert self.direction == 'outgoing'
self._error = True
self._reason = 'Cancelled'
self._stop_event.set()
def _get_state(self):
return self.__dict__['state']
def _set_state(self, value):
if value is not None and not isinstance(value, SessionState):
value = SessionState(value)
old_state = self.__dict__.get('state', None)
new_state = self.__dict__['state'] = value
if new_state != old_state:
NotificationCenter().post_notification('FileTransferDidChangeState', sender=self, data=NotificationData(old_state=old_state, new_state=new_state))
state = property(_get_state, _set_state)
del _get_state, _set_state
def _get_sip_session(self):
return self.__dict__['sip_session']
def _set_sip_session(self, value):
old_session = self.__dict__.get('sip_session', None)
new_session = self.__dict__['sip_session'] = value
if new_session != old_session:
notification_center = NotificationCenter()
if old_session is not None:
notification_center.remove_observer(self, sender=old_session)
if new_session is not None:
notification_center.add_observer(self, sender=new_session)
sip_session = property(_get_sip_session, _set_sip_session)
del _get_sip_session, _set_sip_session
def _get_stream(self):
return self.__dict__['stream']
def _set_stream(self, value):
old_session = self.__dict__.get('stream', None)
new_session = self.__dict__['stream'] = value
if new_session != old_session:
notification_center = NotificationCenter()
if old_session is not None:
notification_center.remove_observer(self, sender=old_session)
if new_session is not None:
notification_center.add_observer(self, sender=new_session)
stream = property(_get_stream, _set_stream)
del _get_stream, _set_stream
@run_in_thread('file-transfer')
def _process_received_chunk(self, data):
if data is not None:
try:
self._file_selector.fd.write(data)
except EnvironmentError, e:
self._error = True
self._reason = str(e)
call_in_gui_thread(self.end)
else:
self._local_hash.update(data)
else:
if not self._finished and not self._error:
self._error = True
self._reason = 'Cancelled'
self._terminate()
@run_in_thread('file-hash')
def _calculate_hash(self):
hash = hashlib.sha1()
pos = 0
progress = 0
size = self._file_selector.size
if size == 0:
self._error = True
self._reason = 'Empty file'
self._terminate()
return
chunk_size = limit(size/100, min=65536, max=1048576)
self.state = 'connecting/hashing'
notification_center = NotificationCenter()
notification_center.post_notification('FileTransferHashProgress', sender=self, data=NotificationData(progress=0))
while not self._stop_event.is_set():
try:
content = self._file_selector.fd.read(chunk_size)
except EnvironmentError, e:
self._error = True
self._reason = str(e)
self._terminate()
return
if not content:
break
hash.update(content)
pos += len(content)
progress = int(pos * 100 / size)
notification_center.post_notification('FileTransferHashProgress', sender=self, data=NotificationData(progress=progress))
else:
self._terminate()
return
self._file_selector.fd.seek(0)
self._file_selector.hash = hash
self._start_outgoing_session()
@run_in_gui_thread
def _start_outgoing_session(self):
if self._stop_event.is_set():
self._terminate()
return
settings = SIPSimpleSettings()
if isinstance(self.account, Account):
if self.account.sip.outbound_proxy is not None:
uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport})
elif self.account.sip.always_use_my_proxy:
uri = SIPURI(host=self.account.id.domain)
else:
uri = self._uri
else:
uri = self._uri
lookup = DNSLookup()
notification_center = NotificationCenter()
notification_center.add_observer(self, sender=lookup)
lookup.lookup_sip_proxy(uri, settings.sip.transport_list)
self.state = 'connecting/dns_lookup'
def _normalize_uri(self, uri):
if '@' not in uri:
uri += '@' + self.account.id.domain
if not uri.startswith(('sip:', 'sips:')):
uri = 'sip:' + uri
return SIPURI.parse(str(uri).translate(None, ' \t'))
@run_in_gui_thread
def _terminate(self):
if self.state != 'ending':
self.state = 'ending'
reason, error = self._reason, self._error
if self._file_selector is not None and self._file_selector.fd is not None:
self._file_selector.fd.close()
self._file_selector.fd = None
if self.direction == 'incoming':
filename = self.filename+self.tmp_file_suffix
if error:
unlink(filename)
else:
local_hash = 'sha1:' + ':'.join(re.findall(r'..', self._local_hash.hexdigest()))
remote_hash = self._file_selector.hash.lower()
if local_hash == remote_hash:
tmp_name = filename
os.rename(tmp_name, self.filename)
reason = 'Completed (%s)' % FileSizeFormatter.format(self._file_selector.size)
else:
error = True
reason = 'File hash mismatch'
unlink(filename)
self.sip_session = None
self.stream = None
state = SessionState('ended')
state.reason = reason
state.error = error
self.state = state
notification_center = NotificationCenter()
notification_center.post_notification('FileTransferDidEnd', sender=self, data=NotificationData(reason=reason, error=error))
@run_in_gui_thread
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_DNSLookupDidSucceed(self, notification):
notification.center.remove_observer(self, sender=notification.sender)
if self._stop_event.is_set():
self._terminate()
return
routes = notification.data.result
if not routes:
self._error = True
self._reason = 'Destination not found'
self._terminate()
return
self.sip_session = Session(self.account)
registry = MediaStreamRegistry()
cls = registry.get('file-transfer')
self.stream = cls(self._file_selector, 'sendonly')
self.sip_session.connect(ToHeader(self._uri), routes, [self.stream])
def _NH_DNSLookupDidFail(self, notification):
notification.center.remove_observer(self, sender=notification.sender)
if self._stop_event.is_set():
self._terminate()
return
self._error = True
self._reason = 'DNS Lookup failed'
self._terminate()
def _NH_SIPSessionNewOutgoing(self, notification):
self.state = 'connecting'
def _NH_SIPSessionGotProvisionalResponse(self, notification):
if notification.data.code == 180:
self.state = 'connecting/ringing'
def _NH_SIPSessionWillStart(self, notification):
self.state = 'connecting/starting'
def _NH_SIPSessionDidStart(self, notification):
self.state = 'connected'
def _NH_SIPSessionDidFail(self, notification):
if notification.data.failure_reason == 'user request':
if notification.data.code == 487:
reason = 'Cancelled'
else:
reason = notification.data.reason
else:
reason = notification.data.failure_reason
if self.state != 'ended':
self._error = True
self._reason = reason
self._terminate()
def _NH_MediaStreamDidFail(self, notification):
if self.state == 'connected':
self.end()
def _NH_MediaStreamDidEnd(self, notification):
if self.direction == 'incoming':
# Mark end of write operations
self._process_received_chunk(None)
elif self.state != 'ended':
# In case of SIPSessionDidFail, _terminate() was already called -Saul
if self._finished:
self._error = False
self._reason = 'Completed (%s)' % FileSizeFormatter.format(self._file_selector.size)
else:
self._error = True
self._reason = 'Cancelled'
self._terminate()
def _NH_FileTransferStreamGotChunk(self, notification):
if not self._error:
self._file_selector.size = notification.data.file_size
self._process_received_chunk(notification.data.content)
notification.center.post_notification('FileTransferProgress', sender=self, data=NotificationData(bytes=notification.data.transferred_bytes,
total_bytes=notification.data.file_size))
def _NH_FileTransferStreamDidDeliverChunk(self, notification):
notification.center.post_notification('FileTransferProgress', sender=self, data=NotificationData(bytes=notification.data.transferred_bytes,
total_bytes=notification.data.file_size))
def _NH_FileTransferStreamDidNotDeliverChunk(self, notification):
if notification.data.chunk.size > 0:
self._error = True
self._reason = notification.data.reason
self.end()
def _NH_FileTransferStreamDidFinish(self, notification):
self._finished = True
if self.direction == 'incoming':
call_later(3, self.end)
else:
self.end()
class TransferStateLabel(QLabel, ColorHelperMixin):
class ProgressDisplayMode: __metaclass__ = MarkerType
class InactiveDisplayMode: __metaclass__ = MarkerType
def __init__(self, parent=None):
super(TransferStateLabel, self).__init__(parent)
self.display_mode = self.InactiveDisplayMode
self.show_cancel_button = False
self.show_retry_button = False
self.progress = 0
def _get_display_mode(self):
return self.__dict__['display_mode']
def _set_display_mode(self, value):
if value not in (self.ProgressDisplayMode, self.InactiveDisplayMode):
raise ValueError("invalid display_mode: %r" % value)
old_value = self.__dict__.get('display_mode', self.InactiveDisplayMode)
new_value = self.__dict__['display_mode'] = value
if new_value != old_value:
self.update()
display_mode = property(_get_display_mode, _set_display_mode)
del _get_display_mode, _set_display_mode
def _get_show_cancel_button(self):
return self.__dict__['show_cancel_button']
def _set_show_cancel_button(self, value):
old_value = self.__dict__.get('show_cancel_button', False)
new_value = self.__dict__['show_cancel_button'] = bool(value)
if new_value != old_value:
self.update()
show_cancel_button = property(_get_show_cancel_button, _set_show_cancel_button)
del _get_show_cancel_button, _set_show_cancel_button
def _get_show_retry_button(self):
return self.__dict__['show_retry_button']
def _set_show_retry_button(self, value):
old_value = self.__dict__.get('show_retry_button', False)
new_value = self.__dict__['show_retry_button'] = bool(value)
if new_value != old_value:
self.update()
show_retry_button = property(_get_show_retry_button, _set_show_retry_button)
del _get_show_retry_button, _set_show_retry_button
def _get_progress(self):
return self.__dict__['progress']
def _set_progress(self, value):
self.__dict__['progress'] = value
self.update()
progress = property(_get_progress, _set_progress)
del _get_progress, _set_progress
def paintEvent(self, event):
margin = self.margin()
contents_rect = QRectF(self.contentsRect().adjusted(margin, margin, -margin, -margin))
size = min(contents_rect.width(), contents_rect.height())
rect = QRectF(0, 0, size, size)
rect.moveCenter(contents_rect.center())
palette = self.palette()
if self.display_mode is self.ProgressDisplayMode:
is_selected = self.foregroundRole() == QPalette.HighlightedText
inner_pen_color = self.color_with_alpha(palette.color(self.foregroundRole()), 70)
outer_pen_color = palette.color(QPalette.HighlightedText if is_selected else QPalette.Highlight)
inner_pen_width = 1.4 * size/20
outer_pen_width = 2.3 * size/20
inner_rect_adjust = outer_pen_width - inner_pen_width / 2
outer_rect_adjust = outer_pen_width / 2
inner_rect = rect.adjusted(inner_rect_adjust, inner_rect_adjust, -inner_rect_adjust, -inner_rect_adjust)
outer_rect = rect.adjusted(outer_rect_adjust, outer_rect_adjust, -outer_rect_adjust, -outer_rect_adjust)
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(QPen(inner_pen_color, inner_pen_width, style=Qt.SolidLine, cap=Qt.FlatCap, join=Qt.RoundJoin))
painter.drawEllipse(inner_rect)
painter.setPen(QPen(outer_pen_color, outer_pen_width, style=Qt.SolidLine, cap=Qt.FlatCap, join=Qt.RoundJoin))
painter.drawArc(outer_rect, 90*16, -self.progress*3.6*16)
if self.show_cancel_button:
foreground_color = palette.color(self.foregroundRole())
background_color = palette.color(self.backgroundRole())
cross_pen_color = self.strong_deco_color(background_color, foreground_color)
cross_pen_width = 2.0 * size/20
cross_rect = QRectF(0, 0, 6.0*size/20, 6.0*size/20)
cross_rect.moveCenter(QPointF(0, 0))
painter.save()
painter.translate(rect.center())
painter.setPen(QPen(cross_pen_color, cross_pen_width, style=Qt.SolidLine, cap=Qt.RoundCap, join=Qt.RoundJoin))
painter.drawLine(cross_rect.topLeft(), cross_rect.bottomRight())
painter.drawLine(cross_rect.topRight(), cross_rect.bottomLeft())
painter.restore()
painter.end()
elif self.show_retry_button:
foreground_color = palette.color(self.foregroundRole())
background_color = palette.color(self.backgroundRole())
retry_pen_color = self.strong_deco_color(background_color, foreground_color)
retry_pen_width = 1.8 * size/20
retry_margin = 1.8 * size/20
retry_rect_adjust = retry_pen_width / 2 + retry_margin
retry_rect = rect.adjusted(retry_rect_adjust, retry_rect_adjust, -retry_rect_adjust, -retry_rect_adjust)
path = QPainterPath()
path.moveTo(retry_rect.width(), retry_rect.height()/2)
path.arcMoveTo(retry_rect, -30)
path.arcTo(retry_rect, -30, -300)
arrow_size = path.currentPosition().y() - retry_rect.top() + min(retry_pen_width, retry_margin) / 2
polygon = QPolygonF([QPointF(-arrow_size, 0), QPointF(0, 0), QPointF(0, -arrow_size)])
polygon.translate(path.currentPosition())
path.addPolygon(polygon)
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setPen(QPen(retry_pen_color, retry_pen_width, style=Qt.SolidLine, cap=Qt.RoundCap, join=Qt.MiterJoin))
painter.drawPath(path)
painter.end()
@cache_result(background_color_key)
def strong_deco_color(self, background, color):
return ColorUtils.mix(background, color, 0.55 + 0.8*self._contrast)
def minimumSizeHint(self):
margin = self.margin()
return QSize(16+2*margin, 16+2*margin)
def sizeHint(self):
margin = self.margin()
return QSize(20+2*margin, 20+2*margin)
ui_class, base_class = uic.loadUiType(Resources.get('filetransfer_item.ui'))
class FileTransferItemWidget(base_class, ui_class):
class StandardDisplayMode: __metaclass__ = MarkerType
class AlternateDisplayMode: __metaclass__ = MarkerType
class SelectedDisplayMode: __metaclass__ = MarkerType
def __init__(self, parent=None):
super(FileTransferItemWidget, self).__init__(parent)
with Resources.directory:
self.setupUi(self)
self.palettes = Palettes()
self.palettes.standard = self.palette()
self.palettes.alternate = self.palette()
self.palettes.selected = self.palette()
self.palettes.standard.setColor(QPalette.Window, self.palettes.standard.color(QPalette.Base)) # We modify the palettes because only the Oxygen theme honors the BackgroundRole if set
self.palettes.alternate.setColor(QPalette.Window, self.palettes.standard.color(QPalette.AlternateBase)) # AlternateBase set to #f0f4ff or #e0e9ff by designer
self.palettes.selected.setColor(QPalette.Window, self.palettes.standard.color(QPalette.Highlight)) # #0066cc #0066d5 #0066dd #0066aa (0, 102, 170) '#256182' (37, 97, 130), #2960a8 (41, 96, 168), '#2d6bbc' (45, 107, 188), '#245897' (36, 88, 151) #0044aa #0055d4
self.pixmaps = PixmapContainer()
self.pixmaps.incoming_transfer = QPixmap(Resources.get('icons/folder-downloads.png'))
self.pixmaps.outgoing_transfer = QPixmap(Resources.get('icons/folder-uploads.png'))
self.pixmaps.failed_transfer = QPixmap(Resources.get('icons/file-broken.png'))
self.display_mode = self.StandardDisplayMode
self.widget_layout.invalidate()
self.widget_layout.activate()
def _get_display_mode(self):
return self.__dict__['display_mode']
def _set_display_mode(self, value):
if value not in (self.StandardDisplayMode, self.AlternateDisplayMode, self.SelectedDisplayMode):
raise ValueError("invalid display_mode: %r" % value)
old_mode = self.__dict__.get('display_mode', None)
new_mode = self.__dict__['display_mode'] = value
if new_mode == old_mode:
return
if new_mode is self.StandardDisplayMode:
self.setPalette(self.palettes.standard)
self.state_indicator.setForegroundRole(QPalette.WindowText)
self.filename_label.setForegroundRole(QPalette.WindowText)
self.name_label.setForegroundRole(QPalette.Dark)
self.status_label.setForegroundRole(QPalette.Dark)
elif new_mode is self.AlternateDisplayMode:
self.setPalette(self.palettes.alternate)
self.state_indicator.setForegroundRole(QPalette.WindowText)
self.filename_label.setForegroundRole(QPalette.WindowText)
self.name_label.setForegroundRole(QPalette.Dark)
self.status_label.setForegroundRole(QPalette.Dark)
elif new_mode is self.SelectedDisplayMode:
self.setPalette(self.palettes.selected)
self.state_indicator.setForegroundRole(QPalette.HighlightedText)
self.filename_label.setForegroundRole(QPalette.HighlightedText)
self.name_label.setForegroundRole(QPalette.HighlightedText)
self.status_label.setForegroundRole(QPalette.HighlightedText)
display_mode = property(_get_display_mode, _set_display_mode)
del _get_display_mode, _set_display_mode
def update_content(self, item, initial=False):
if initial:
self.filename_label.setText(os.path.basename(item.filename))
if item.direction == 'outgoing':
self.name_label.setText(u'To: ' + item.name)
self.icon_label.setPixmap(self.pixmaps.outgoing_transfer)
else:
self.name_label.setText(u'From: ' + item.name)
self.icon_label.setPixmap(self.pixmaps.incoming_transfer)
self.status_label.setText(item.status)
if item.ended:
self.state_indicator.display_mode = self.state_indicator.InactiveDisplayMode
self.state_indicator.show_retry_button = False
if item.failed:
if item.direction == 'outgoing':
self.state_indicator.show_retry_button = True
self.icon_label.setPixmap(self.pixmaps.failed_transfer)
else:
self.state_indicator.display_mode = self.state_indicator.ProgressDisplayMode
self.state_indicator.progress = item.progress or 0
del ui_class, base_class
class FileTransferItem(object):
implements(IObserver)
def __init__(self, transfer):
self.transfer = transfer
self.status = None
self.progress = None
self.bytes = 0
self.total_bytes = 0
self.widget = FileTransferItemWidget()
self.widget.update_content(self, initial=True)
notification_center = NotificationCenter()
notification_center.add_observer(self, sender=transfer)
def __repr__(self):
return '%s(%r)' % (self.__class__.__name__, self.transfer)
def retry(self):
assert self.direction == 'outgoing' and self.ended and self.failed
notification_center = NotificationCenter()
notification_center.add_observer(self, sender=self.transfer)
self.transfer.connect()
def end(self):
self.transfer.end()
@property
def direction(self):
return self.transfer.direction
@property
def filename(self):
return self.transfer.filename or u''
@property
def name(self):
return self.transfer.contact.name or self.transfer.contact_uri.uri
@property
def ended(self):
return self.transfer.state == 'ended'
@property
def failed(self):
return self.transfer.state == 'ended' and self.transfer.state.error
@run_in_gui_thread
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_FileTransferDidChangeState(self, notification):
state = notification.data.new_state
if state == 'connecting/dns_lookup':
self.status = u'Looking up destination...'
elif state == 'connecting/hashing':
self.status = u'Computing hash...'
elif state == 'connecting':
self.progress = None
self.status = u'Connecting...'
elif state == 'connecting/ringing':
self.status = u'Ringing...'
elif state == 'connecting/starting':
self.status = u'Starting...'
elif state == 'connected':
self.status = u'Connected'
elif state == 'ending':
self.status = u'Ending...'
else:
self.status = None
self.widget.update_content(self)
notification.center.post_notification('FileTransferItemDidChange', sender=self)
def _NH_FileTransferHashProgress(self, notification):
progress = notification.data.progress
if self.progress is None or progress > self.progress:
self.progress = progress
self.status = u'Computing hash (%s%%)' % notification.data.progress
self.widget.update_content(self)
notification.center.post_notification('FileTransferItemDidChange', sender=self)
def _NH_FileTransferProgress(self, notification):
self.bytes = notification.data.bytes
self.total_bytes = notification.data.total_bytes
progress = int(self.bytes * 100 / self.total_bytes)
status = u'Transferring: %s/%s (%s%%)' % (FileSizeFormatter.format(self.bytes), FileSizeFormatter.format(self.total_bytes), progress)
if self.progress is None or progress > self.progress or status != self.status:
self.progress = progress
self.status = status
self.widget.update_content(self)
notification.center.post_notification('FileTransferItemDidChange', sender=self)
def _NH_FileTransferWillRetry(self, notification):
self.status = None
self.progress = None
self.bytes = 0
self.total_bytes = 0
self.widget.update_content(self, initial=True)
def _NH_FileTransferDidEnd(self, notification):
self.status = notification.data.reason
self.widget.update_content(self)
notification.center.post_notification('FileTransferItemDidChange', sender=self)
notification.center.remove_observer(self, sender=self.transfer)
class FileTransferDelegate(QStyledItemDelegate):
def __init__(self, parent=None):
super(FileTransferDelegate, self).__init__(parent)
def editorEvent(self, event, model, option, index):
if event.type()==QEvent.MouseButtonDblClick and event.button()==Qt.LeftButton and event.modifiers()==Qt.NoModifier:
item = index.data(Qt.UserRole)
if item.direction == 'incoming' and item.ended and not item.failed and os.path.isfile(item.filename):
QDesktopServices.openUrl(QUrl.fromLocalFile(item.filename))
return True
elif event.type()==QEvent.MouseButtonRelease and event.button()==Qt.LeftButton and event.modifiers()==Qt.NoModifier:
item = index.data(Qt.UserRole)
indicator = item.widget.state_indicator
margin = indicator.margin()
indicator_rect = indicator.contentsRect().adjusted(margin, margin, -margin, -margin)
size = min(indicator_rect.width(), indicator_rect.height())
rect = QRect(0, 0, size, size)
rect.moveCenter(indicator.geometry().center())
rect.translate(option.rect.topLeft())
if rect.contains(event.pos()):
if indicator.display_mode is indicator.InactiveDisplayMode and indicator.show_retry_button:
item.retry()
return True
elif indicator.display_mode is indicator.ProgressDisplayMode and indicator.show_cancel_button:
item.end()
return True
return super(FileTransferDelegate, self).editorEvent(event, model, option, index)
def paint(self, painter, option, index):
item = index.data(Qt.UserRole)
if option.state & QStyle.State_Selected:
item.widget.display_mode = item.widget.SelectedDisplayMode
elif index.row() % 2 == 0:
item.widget.display_mode = item.widget.StandardDisplayMode
else:
item.widget.display_mode = item.widget.AlternateDisplayMode
if not item.ended and (option.state & QStyle.State_MouseOver):
item.widget.state_indicator.show_cancel_button = True
else:
item.widget.state_indicator.show_cancel_button = False
item.widget.setFixedSize(option.rect.size())
painter.drawPixmap(option.rect, QPixmap.grabWidget(item.widget))
def sizeHint(self, option, index):
return index.data(Qt.SizeHintRole)
class FileTransferModel(QAbstractListModel):
implements(IObserver)
itemAboutToBeAdded = pyqtSignal(FileTransferItem)
itemAboutToBeRemoved = pyqtSignal(FileTransferItem)
itemAdded = pyqtSignal(FileTransferItem)
itemRemoved = pyqtSignal(FileTransferItem)
def __init__(self, parent=None):
super(FileTransferModel, self).__init__(parent)
self.items = []
notification_center = NotificationCenter()
notification_center.add_observer(self, name='FileTransferNewIncoming')
notification_center.add_observer(self, name='FileTransferNewOutgoing')
notification_center.add_observer(self, name='FileTransferItemDidChange')
def clear_ended(self):
for item in (item for item in self.items[:] if item.ended):
self.removeItem(item)
def rowCount(self, parent=QModelIndex()):
return len(self.items)
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
item = self.items[index.row()]
if role == Qt.UserRole:
return item
elif role == Qt.SizeHintRole:
return item.widget.sizeHint()
elif role == Qt.DisplayRole:
return unicode(item)
return None
def addItem(self, item):
if item in self.items:
return
self.itemAboutToBeAdded.emit(item)
self.beginInsertRows(QModelIndex(), 0, 0)
self.items.insert(0, item)
self.endInsertRows()
self.itemAdded.emit(item)
def removeItem(self, item):
if item not in self.items:
return
self.itemAboutToBeRemoved.emit(item)
position = self.items.index(item)
self.beginRemoveRows(QModelIndex(), position, position)
del self.items[position]
self.endRemoveRows()
self.itemRemoved.emit(item)
@run_in_gui_thread
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_FileTransferNewIncoming(self, notification):
self.addItem(FileTransferItem(notification.sender))
def _NH_FileTransferNewOutgoing(self, notification):
self.addItem(FileTransferItem(notification.sender))
def _NH_FileTransferItemDidChange(self, notification):
index = self.index(self.items.index(notification.sender))
self.dataChanged.emit(index, index)
# Conference participants # Conference participants
# #
...@@ -3791,7 +4635,11 @@ class IncomingRequest(QObject): ...@@ -3791,7 +4635,11 @@ class IncomingRequest(QObject):
elif self.chat_stream: elif self.chat_stream:
return 3 return 3
else: else:
return 4 return sys.maxint
@property
def stream_types(self):
return {stream.type for stream in (self.audio_stream, self.video_stream, self.screensharing_stream, self.chat_stream) if stream is not None}
def _SH_DialogAccepted(self): def _SH_DialogAccepted(self):
self.accepted.emit(self) self.accepted.emit(self)
...@@ -3800,6 +4648,112 @@ class IncomingRequest(QObject): ...@@ -3800,6 +4648,112 @@ class IncomingRequest(QObject):
self.rejected.emit(self, self.dialog.reject_mode) self.rejected.emit(self, self.dialog.reject_mode)
ui_class, base_class = uic.loadUiType(Resources.get('incoming_filetransfer_dialog.ui'))
class IncomingFileTransferDialog(base_class, ui_class):
def __init__(self, parent=None):
super(IncomingFileTransferDialog, self).__init__(parent)
self.setWindowFlags(Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_DeleteOnClose)
with Resources.directory:
self.setupUi(self)
font = self.username_label.font()
font.setPointSizeF(self.uri_label.fontInfo().pointSizeF() + 3)
font.setFamily("Sans Serif")
self.username_label.setFont(font)
font = self.file_label.font()
font.setPointSizeF(self.uri_label.fontInfo().pointSizeF() - 1)
self.file_label.setFont(font)
self.position = None
def show(self, activate=True, position=1):
blink = QApplication.instance()
screen_geometry = blink.desktop().screenGeometry(self)
available_geometry = blink.desktop().availableGeometry(self)
main_window_geometry = blink.main_window.geometry()
main_window_framegeometry = blink.main_window.frameGeometry()
horizontal_decorations = main_window_framegeometry.width() - main_window_geometry.width()
vertical_decorations = main_window_framegeometry.height() - main_window_geometry.height()
width = limit(self.sizeHint().width(), min=self.minimumSize().width(), max=min(self.maximumSize().width(), available_geometry.width()-horizontal_decorations))
height = limit(self.sizeHint().height(), min=self.minimumSize().height(), max=min(self.maximumSize().height(), available_geometry.height()-vertical_decorations))
total_width = width + horizontal_decorations
total_height = height + vertical_decorations
x = limit(screen_geometry.center().x() - total_width/2, min=available_geometry.left(), max=available_geometry.right()-total_width)
if position is None:
y = -1
elif position % 2 == 0:
y = screen_geometry.center().y() + (position-1)*total_height/2
else:
y = screen_geometry.center().y() - position*total_height/2
if available_geometry.top() <= y <= available_geometry.bottom() - total_height:
self.setGeometry(x, y, width, height)
else:
self.resize(width, height)
self.position = position
self.setAttribute(Qt.WA_ShowWithoutActivating, not activate)
super(IncomingFileTransferDialog, self).show()
del ui_class, base_class
class IncomingFileTransferRequest(QObject):
accepted = pyqtSignal(object)
rejected = pyqtSignal(object)
priority = 4
stream_types = {'file-transfer'}
def __init__(self, dialog, contact, contact_uri, session, stream):
super(IncomingFileTransferRequest, self).__init__()
self.dialog = dialog
self.contact = contact
self.contact_uri = contact_uri
self.session = session
self.stream = stream
self.dialog.setWindowTitle(u'Incoming File Transfer')
self.dialog.setWindowIconText(u'Incoming File Transfer')
self.dialog.uri_label.setText(contact_uri.uri)
self.dialog.username_label.setText(contact.name)
if contact.pixmap:
self.dialog.user_icon.setPixmap(contact.pixmap)
filename = os.path.basename(self.stream.file_selector.name)
size = self.stream.file_selector.size
if size:
self.dialog.file_label.setText(u'File: %s (%s)' % (filename, FileSizeFormatter.format(size)))
else:
self.dialog.file_label.setText(u'File: %s' % filename)
self.dialog.accepted.connect(self._SH_DialogAccepted)
self.dialog.rejected.connect(self._SH_DialogRejected)
def __eq__(self, other):
return self is other
def __ne__(self, other):
return self is not other
def __lt__(self, other):
return self.priority < other.priority
def __le__(self, other):
return self.priority <= other.priority
def __gt__(self, other):
return self.priority > other.priority
def __ge__(self, other):
return self.priority >= other.priority
def _SH_DialogAccepted(self):
self.accepted.emit(self)
def _SH_DialogRejected(self):
self.rejected.emit(self)
ui_class, base_class = uic.loadUiType(Resources.get('conference_dialog.ui')) ui_class, base_class = uic.loadUiType(Resources.get('conference_dialog.ui'))
class ConferenceDialog(base_class, ui_class): class ConferenceDialog(base_class, ui_class):
...@@ -3886,7 +4840,9 @@ class SessionManager(object): ...@@ -3886,7 +4840,9 @@ class SessionManager(object):
self.sessions = [] self.sessions = []
self.incoming_requests = [] self.incoming_requests = []
self.dialog_positions = range(1, 100) self.dialog_positions = range(1, 100)
self.file_transfers = []
self.last_dialed_uri = None self.last_dialed_uri = None
self.send_file_directory = os.path.expanduser('~')
self.active_session = None self.active_session = None
self.inbound_ringtone = Null self.inbound_ringtone = Null
...@@ -3897,6 +4853,10 @@ class SessionManager(object): ...@@ -3897,6 +4853,10 @@ class SessionManager(object):
self._hangup_tone_timer.setInterval(1000) self._hangup_tone_timer.setInterval(1000)
self._hangup_tone_timer.setSingleShot(True) self._hangup_tone_timer.setSingleShot(True)
self._filetransfer_tone_timer = QTimer()
self._filetransfer_tone_timer.setInterval(1500)
self._filetransfer_tone_timer.setSingleShot(True)
notification_center = NotificationCenter() notification_center = NotificationCenter()
notification_center.add_observer(self, name='SIPSessionNewIncoming') notification_center.add_observer(self, name='SIPSessionNewIncoming')
notification_center.add_observer(self, name='SIPSessionDidEnd') notification_center.add_observer(self, name='SIPSessionDidEnd')
...@@ -3904,6 +4864,10 @@ class SessionManager(object): ...@@ -3904,6 +4864,10 @@ class SessionManager(object):
notification_center.add_observer(self, name='SIPSessionProposalRejected') notification_center.add_observer(self, name='SIPSessionProposalRejected')
notification_center.add_observer(self, name='SIPSessionHadProposalFailure') notification_center.add_observer(self, name='SIPSessionHadProposalFailure')
notification_center.add_observer(self, name='FileTransferDidChangeState')
notification_center.add_observer(self, name='FileTransferDidEnd')
notification_center.add_observer(self, name='FileTransferWillRetry')
notification_center.add_observer(self, name='BlinkSessionNewIncoming') notification_center.add_observer(self, name='BlinkSessionNewIncoming')
notification_center.add_observer(self, name='BlinkSessionDidReinitializeForIncoming') notification_center.add_observer(self, name='BlinkSessionDidReinitializeForIncoming')
notification_center.add_observer(self, name='BlinkSessionDidEnd') notification_center.add_observer(self, name='BlinkSessionDidEnd')
...@@ -3921,8 +4885,6 @@ class SessionManager(object): ...@@ -3921,8 +4885,6 @@ class SessionManager(object):
else: else:
account = AccountManager().default_account account = AccountManager().default_account
assert account is not None
try: try:
session = next(session for session in self.sessions if session.reusable and session.contact.settings is contact.settings) session = next(session for session in self.sessions if session.reusable and session.contact.settings is contact.settings)
reinitialize = True reinitialize = True
...@@ -3938,10 +4900,26 @@ class SessionManager(object): ...@@ -3938,10 +4900,26 @@ class SessionManager(object):
return session return session
def send_file(self, contact, contact_uri, filename, account=None):
if account is None:
if contact.type == 'bonjour':
account = BonjourAccount()
else:
account = AccountManager().default_account
self.send_file_directory = os.path.dirname(filename)
transfer = FileTransfer()
self.file_transfers.append(transfer)
transfer.init_outgoing(account, contact, contact_uri, filename)
transfer.connect()
return transfer
def update_ringtone(self): def update_ringtone(self):
# Outgoing ringtone # Outgoing ringtone
outgoing_sessions_or_proposals = [session for session in self.sessions if session.state=='connecting/ringing' and session.direction=='outgoing' or session.state=='connected/sent_proposal'] outgoing_sessions_or_proposals = [session for session in self.sessions if session.state=='connecting/ringing' and session.direction=='outgoing' or session.state=='connected/sent_proposal']
if any(not session.on_hold for session in outgoing_sessions_or_proposals): outgoing_file_transfers = [transfer for transfer in self.file_transfers if transfer.state=='connecting/ringing']
if any(not session.on_hold for session in outgoing_sessions_or_proposals) or outgoing_file_transfers:
settings = SIPSimpleSettings() settings = SIPSimpleSettings()
outbound_ringtone = settings.sounds.outbound_ringtone outbound_ringtone = settings.sounds.outbound_ringtone
if outbound_ringtone: if outbound_ringtone:
...@@ -3968,7 +4946,7 @@ class SessionManager(object): ...@@ -3968,7 +4946,7 @@ class SessionManager(object):
# Incoming ringtone # Incoming ringtone
if self.incoming_requests: if self.incoming_requests:
try: try:
request = next(req for req in self.incoming_requests if req.audio_stream or req.video_stream) request = next(req for req in self.incoming_requests if req.stream_types.intersection({'audio', 'video'}))
ringtone_type = self.PrimaryRingtone ringtone_type = self.PrimaryRingtone
except StopIteration: except StopIteration:
request = self.incoming_requests[0] request = self.incoming_requests[0]
...@@ -4081,6 +5059,22 @@ class SessionManager(object): ...@@ -4081,6 +5059,22 @@ class SessionManager(object):
elif mode == 'reject': elif mode == 'reject':
incoming_request.session.reject(603) incoming_request.session.reject(603)
def _SH_IncomingFileTransferRequestAccepted(self, incoming_request):
if incoming_request.dialog.position is not None:
bisect.insort_left(self.dialog_positions, incoming_request.dialog.position)
self.incoming_requests.remove(incoming_request)
self.update_ringtone()
transfer = FileTransfer()
self.file_transfers.append(transfer)
transfer.init_incoming(incoming_request.contact, incoming_request.contact_uri, incoming_request.session, incoming_request.stream)
def _SH_IncomingFileTransferRequestRejected(self, incoming_request):
if incoming_request.dialog.position is not None:
bisect.insort_left(self.dialog_positions, incoming_request.dialog.position)
self.incoming_requests.remove(incoming_request)
self.update_ringtone()
incoming_request.session.reject(603)
@run_in_gui_thread @run_in_gui_thread
def handle_notification(self, notification): def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null) handler = getattr(self, '_NH_%s' % notification.name, Null)
...@@ -4099,20 +5093,21 @@ class SessionManager(object): ...@@ -4099,20 +5093,21 @@ class SessionManager(object):
video_streams = stream_map['video'] video_streams = stream_map['video']
chat_streams = stream_map['chat'] chat_streams = stream_map['chat']
screensharing_streams = stream_map['screen-sharing'] screensharing_streams = stream_map['screen-sharing']
filetransfer_streams = stream_map['file-transfer'] filetransfer_streams = [stream for stream in stream_map['file-transfer'] if stream.direction == 'recvonly'] # Only accept receiving files
if not audio_streams and not video_streams and not chat_streams and not screensharing_streams and not filetransfer_streams: if not audio_streams and not video_streams and not chat_streams and not screensharing_streams and not filetransfer_streams:
session.reject(488) session.reject(488)
return return
if filetransfer_streams and not (audio_streams or video_streams or chat_streams or screensharing_streams):
# TODO: add support for this with different type of session -Saul
session.reject(488)
return
session.send_ring_indication() session.send_ring_indication()
contact, contact_uri = URIUtils.find_contact(session.remote_identity.uri, display_name=session.remote_identity.display_name, exact=False) contact, contact_uri = URIUtils.find_contact(session.remote_identity.uri, display_name=session.remote_identity.display_name, exact=False)
if filetransfer_streams and not (audio_streams or video_streams or chat_streams or screensharing_streams):
dialog = IncomingFileTransferDialog() # The dialog is constructed without the main window as parent so that on Linux it is displayed on the current workspace rather than the one where the main window is.
incoming_request = IncomingFileTransferRequest(dialog, contact, contact_uri, session, filetransfer_streams[0])
incoming_request.accepted.connect(self._SH_IncomingFileTransferRequestAccepted)
incoming_request.rejected.connect(self._SH_IncomingFileTransferRequestRejected)
else:
audio_stream = audio_streams[0] if audio_streams else None audio_stream = audio_streams[0] if audio_streams else None
video_stream = video_streams[0] if video_streams else None video_stream = video_streams[0] if video_streams else None
chat_stream = chat_streams[0] if chat_streams else None chat_stream = chat_streams[0] if chat_streams else None
...@@ -4120,9 +5115,10 @@ class SessionManager(object): ...@@ -4120,9 +5115,10 @@ class SessionManager(object):
dialog = IncomingDialog() # The dialog is constructed without the main window as parent so that on Linux it is displayed on the current workspace rather than the one where the main window is. dialog = IncomingDialog() # The dialog is constructed without the main window as parent so that on Linux it is displayed on the current workspace rather than the one where the main window is.
incoming_request = IncomingRequest(dialog, session, contact, contact_uri, proposal=False, audio_stream=audio_stream, video_stream=video_stream, chat_stream=chat_stream, screensharing_stream=screensharing_stream) incoming_request = IncomingRequest(dialog, session, contact, contact_uri, proposal=False, audio_stream=audio_stream, video_stream=video_stream, chat_stream=chat_stream, screensharing_stream=screensharing_stream)
bisect.insort_right(self.incoming_requests, incoming_request)
incoming_request.accepted.connect(self._SH_IncomingRequestAccepted) incoming_request.accepted.connect(self._SH_IncomingRequestAccepted)
incoming_request.rejected.connect(self._SH_IncomingRequestRejected) incoming_request.rejected.connect(self._SH_IncomingRequestRejected)
bisect.insort_right(self.incoming_requests, incoming_request)
try: try:
position = self.dialog_positions.pop(0) position = self.dialog_positions.pop(0)
except IndexError: except IndexError:
...@@ -4243,4 +5239,21 @@ class SessionManager(object): ...@@ -4243,4 +5239,21 @@ class SessionManager(object):
self.active_session = selected_session self.active_session = selected_session
notification.center.post_notification('BlinkActiveSessionDidChange', sender=self, data=NotificationData(previous_active_session=old_active_session or None, active_session=selected_session)) notification.center.post_notification('BlinkActiveSessionDidChange', sender=self, data=NotificationData(previous_active_session=old_active_session or None, active_session=selected_session))
def _NH_FileTransferDidChangeState(self, notification):
new_state = notification.data.new_state
if new_state in ('connecting/ringing', 'connected'):
self.update_ringtone()
def _NH_FileTransferWillRetry(self, notification):
self.file_transfers.append(notification.sender)
self.update_ringtone()
def _NH_FileTransferDidEnd(self, notification):
self.file_transfers.remove(notification.sender)
self.update_ringtone()
if not notification.data.error and not self._filetransfer_tone_timer.isActive():
self._filetransfer_tone_timer.start()
player = WavePlayer(SIPApplication.voice_audio_bridge.mixer, Resources.get('sounds/file_transfer.wav'), volume=30)
SIPApplication.voice_audio_bridge.add(player)
player.start()
...@@ -948,7 +948,6 @@ padding: 2px;</string> ...@@ -948,7 +948,6 @@ padding: 2px;</string>
<normaloff>icons/quick-settings.png</normaloff>icons/quick-settings.png</iconset> <normaloff>icons/quick-settings.png</normaloff>icons/quick-settings.png</iconset>
</property> </property>
<addaction name="auto_accept_chat_action"/> <addaction name="auto_accept_chat_action"/>
<addaction name="auto_accept_files_action"/>
</widget> </widget>
<addaction name="about_action"/> <addaction name="about_action"/>
<addaction name="check_for_updates_action"/> <addaction name="check_for_updates_action"/>
...@@ -1139,9 +1138,6 @@ padding: 2px;</string> ...@@ -1139,9 +1138,6 @@ padding: 2px;</string>
</property> </property>
</action> </action>
<action name="file_transfers_action"> <action name="file_transfers_action">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text"> <property name="text">
<string>&amp;File transfers</string> <string>&amp;File transfers</string>
</property> </property>
...@@ -1217,14 +1213,6 @@ padding: 2px;</string> ...@@ -1217,14 +1213,6 @@ padding: 2px;</string>
<string>Auto-accept &amp;chat from my contacts</string> <string>Auto-accept &amp;chat from my contacts</string>
</property> </property>
</action> </action>
<action name="auto_accept_files_action">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Auto-accept &amp;files from my contacts</string>
</property>
</action>
<action name="add_account_action"> <action name="add_account_action">
<property name="text"> <property name="text">
<string>&amp;Add account...</string> <string>&amp;Add account...</string>
......
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>filetransfer_item</class>
<widget class="QWidget" name="filetransfer_item">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>300</width>
<height>55</height>
</rect>
</property>
<property name="palette">
<palette>
<active>
<colorrole role="Window">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>255</red>
<green>255</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>0</red>
<green>102</green>
<blue>204</blue>
</color>
</brush>
</colorrole>
<colorrole role="AlternateBase">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>240</red>
<green>244</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
</active>
<inactive>
<colorrole role="Window">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>255</red>
<green>255</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>0</red>
<green>102</green>
<blue>204</blue>
</color>
</brush>
</colorrole>
<colorrole role="AlternateBase">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>240</red>
<green>244</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
</inactive>
<disabled>
<colorrole role="Window">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>255</red>
<green>255</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>218</red>
<green>216</green>
<blue>213</blue>
</color>
</brush>
</colorrole>
<colorrole role="AlternateBase">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>240</red>
<green>244</green>
<blue>255</blue>
</color>
</brush>
</colorrole>
</disabled>
</palette>
</property>
<property name="windowTitle">
<string>FileTransfer Item</string>
</property>
<layout class="QHBoxLayout" name="widget_layout">
<property name="spacing">
<number>3</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>6</number>
</property>
<property name="bottomMargin">
<number>1</number>
</property>
<item>
<widget class="QLabel" name="icon_label">
<property name="minimumSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
<property name="pixmap">
<pixmap>icons/folder-downloads.png</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="file_info_layout">
<property name="spacing">
<number>0</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="ElidedLabel" name="filename_label">
<property name="text">
<string>test_file.py</string>
</property>
<property name="margin">
<number>1</number>
</property>
</widget>
</item>
<item>
<widget class="ElidedLabel" name="name_label">
<property name="text">
<string>test@example.com</string>
</property>
<property name="margin">
<number>1</number>
</property>
</widget>
</item>
<item>
<widget class="ElidedLabel" name="status_label">
<property name="text">
<string>Initializing...</string>
</property>
<property name="margin">
<number>1</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="TransferStateLabel" name="state_indicator">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>16</width>
<height>0</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ElidedLabel</class>
<extends>QLabel</extends>
<header>blink.widgets.labels</header>
</customwidget>
<customwidget>
<class>TransferStateLabel</class>
<extends>QLabel</extends>
<header>blink.sessions</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>filetransfer_window</class>
<widget class="QWidget" name="filetransfer_window">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>350</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>400</width>
<height>280</height>
</size>
</property>
<property name="windowTitle">
<string>File Transfers</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>icons/blink48.png</normaloff>icons/blink48.png</iconset>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QListView" name="listview">
<property name="mouseTracking">
<bool>true</bool>
</property>
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
<property name="styleSheet">
<string notr="true">QListView { border: 1px solid palette(dark); border-style: inset; border-radius: 3px; }</string>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="status_label">
<property name="text">
<string>0 transfers</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="margin">
<number>4</number>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>480</width>
<height>165</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>480</width>
<height>165</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>165</height>
</size>
</property>
<property name="windowTitle">
<string>Incoming File Transfer</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>icons/blink48.png</normaloff>icons/blink48.png</iconset>
</property>
<layout class="QVBoxLayout" name="dialog_layout">
<property name="spacing">
<number>34</number>
</property>
<property name="margin">
<number>8</number>
</property>
<item>
<widget class="QFrame" name="frame">
<property name="styleSheet">
<string>QFrame#frame {
background-color: #f8f8f8;
border-color: #545454;
border-radius: 4px;
border-width: 2px;
border-style: solid;
}
</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Sunken</enum>
</property>
<layout class="QGridLayout" name="frame_layout">
<property name="topMargin">
<number>7</number>
</property>
<property name="bottomMargin">
<number>7</number>
</property>
<property name="verticalSpacing">
<number>0</number>
</property>
<item row="1" column="1">
<widget class="QLabel" name="uri_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="palette">
<palette>
<active>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
</active>
<inactive>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
</inactive>
<disabled>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>118</red>
<green>118</green>
<blue>117</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>118</red>
<green>118</green>
<blue>117</blue>
</color>
</brush>
</colorrole>
</disabled>
</palette>
</property>
<property name="text">
<string>Caller URI</string>
</property>
<property name="indent">
<number>1</number>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="username_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<family>Sans Serif</family>
<pointsize>12</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Caller name</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="0" rowspan="2">
<widget class="QLabel" name="user_icon">
<property name="minimumSize">
<size>
<width>36</width>
<height>36</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>36</width>
<height>36</height>
</size>
</property>
<property name="pixmap">
<pixmap>icons/default-avatar.png</pixmap>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<spacer name="frame_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="file_label">
<property name="palette">
<palette>
<active>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
</active>
<inactive>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>68</red>
<green>68</green>
<blue>68</blue>
</color>
</brush>
</colorrole>
</inactive>
<disabled>
<colorrole role="WindowText">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>118</red>
<green>118</green>
<blue>117</blue>
</color>
</brush>
</colorrole>
<colorrole role="Text">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>118</red>
<green>118</green>
<blue>117</blue>
</color>
</brush>
</colorrole>
</disabled>
</palette>
</property>
<property name="text">
<string extracomment="is offering to share his screen">File: test.py (100 KB)</string>
</property>
<property name="indent">
<number>3</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="button_layout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QPushButton" name="reject_button">
<property name="minimumSize">
<size>
<width>85</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>24</height>
</size>
</property>
<property name="toolTip">
<string>Refuse the call and stop ringing on any other device</string>
</property>
<property name="text">
<string>Reject</string>
</property>
</widget>
</item>
<item>
<spacer name="button_spacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="accept_button">
<property name="minimumSize">
<size>
<width>85</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>24</height>
</size>
</property>
<property name="text">
<string>Accept</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<tabstops>
<tabstop>accept_button</tabstop>
<tabstop>reject_button</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>accept_button</sender>
<signal>clicked()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>345</x>
<y>117</y>
</hint>
<hint type="destinationlabel">
<x>196</x>
<y>67</y>
</hint>
</hints>
</connection>
<connection>
<sender>reject_button</sender>
<signal>clicked()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>48</x>
<y>117</y>
</hint>
<hint type="destinationlabel">
<x>196</x>
<y>67</y>
</hint>
</hints>
</connection>
</connections>
</ui>
...@@ -1948,14 +1948,20 @@ ...@@ -1948,14 +1948,20 @@
<property name="horizontalSpacing"> <property name="horizontalSpacing">
<number>3</number> <number>3</number>
</property> </property>
<item row="0" column="1" colspan="2"> <item row="1" column="0" colspan="3">
<widget class="QCheckBox" name="auto_accept_files_button"> <spacer name="file_transfer_spacer">
<property name="text"> <property name="orientation">
<string>Automatically accept file transfer requests from known contacts</string> <enum>Qt::Vertical</enum>
</property> </property>
</widget> <property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item> </item>
<item row="1" column="0"> <item row="0" column="0">
<widget class="QLabel" name="download_directory_label"> <widget class="QLabel" name="download_directory_label">
<property name="text"> <property name="text">
<string>Download Directory:</string> <string>Download Directory:</string>
...@@ -1965,33 +1971,20 @@ ...@@ -1965,33 +1971,20 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="1"> <item row="0" column="1">
<widget class="LocationBar" name="download_directory_editor"> <widget class="LocationBar" name="download_directory_editor">
<property name="readOnly"> <property name="readOnly">
<bool>true</bool> <bool>true</bool>
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="2"> <item row="0" column="2">
<widget class="QPushButton" name="download_directory_browse_button"> <widget class="QPushButton" name="download_directory_browse_button">
<property name="text"> <property name="text">
<string>Browse</string> <string>Browse</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0" colspan="3">
<spacer name="file_transfer_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout> </layout>
</widget> </widget>
</item> </item>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment