1 from __future__ import with_statement
15 import util.qt_compat as qt_compat
16 QtCore = qt_compat.QtCore
18 from util import qore_utils
19 from util import qui_utils
20 from util import concurrent
21 from util import misc as misc_utils
26 _moduleLogger = logging.getLogger(__name__)
29 class _DraftContact(object):
31 def __init__(self, messageId, title, description, numbersWithDescriptions):
32 self.messageId = messageId
34 self.description = description
35 self.numbers = numbersWithDescriptions
36 self.selectedNumber = numbersWithDescriptions[0][0]
39 class Draft(QtCore.QObject):
41 sendingMessage = qt_compat.Signal()
42 sentMessage = qt_compat.Signal()
43 calling = qt_compat.Signal()
44 called = qt_compat.Signal()
45 cancelling = qt_compat.Signal()
46 cancelled = qt_compat.Signal()
47 error = qt_compat.Signal(str)
49 recipientsChanged = qt_compat.Signal()
51 def __init__(self, asyncQueue, backend, errorLog):
52 QtCore.QObject.__init__(self)
53 self._errorLog = errorLog
55 self._asyncQueue = asyncQueue
56 self._backend = backend
57 self._busyReason = None
61 assert 0 < len(self._contacts), "No contacts selected"
62 assert 0 < len(self._message), "No message to send"
63 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
64 le = self._asyncQueue.add_async(self._send)
65 le.start(numbers, self._message)
68 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
69 assert len(self._message) == 0, "Cannot send message with call"
70 (contact, ) = self._contacts.itervalues()
71 number = misc_utils.make_ugly(contact.selectedNumber)
72 le = self._asyncQueue.add_async(self._call)
76 le = self._asyncQueue.add_async(self._cancel)
79 def _get_message(self):
82 def _set_message(self, message):
83 self._message = message
85 message = property(_get_message, _set_message)
87 def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions):
88 if self._busyReason is not None:
89 raise RuntimeError("Please wait for %r" % self._busyReason)
90 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
91 contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions)
92 self._contacts[contactId] = contactDetails
93 self.recipientsChanged.emit()
95 def remove_contact(self, contactId):
96 if self._busyReason is not None:
97 raise RuntimeError("Please wait for %r" % self._busyReason)
98 assert contactId in self._contacts, "Contact missing"
99 del self._contacts[contactId]
100 self.recipientsChanged.emit()
102 def get_contacts(self):
103 return self._contacts.iterkeys()
105 def get_num_contacts(self):
106 return len(self._contacts)
108 def get_message_id(self, cid):
109 return self._contacts[cid].messageId
111 def get_title(self, cid):
112 return self._contacts[cid].title
114 def get_description(self, cid):
115 return self._contacts[cid].description
117 def get_numbers(self, cid):
118 return self._contacts[cid].numbers
120 def get_selected_number(self, cid):
121 return self._contacts[cid].selectedNumber
123 def set_selected_number(self, cid, number):
124 # @note I'm lazy, this isn't firing any kind of signal since only one
125 # controller right now and that is the viewer
126 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
127 self._contacts[cid].selectedNumber = number
130 if self._busyReason is not None:
131 raise RuntimeError("Please wait for %r" % self._busyReason)
135 oldContacts = self._contacts
139 self.recipientsChanged.emit()
141 @contextlib.contextmanager
142 def _busy(self, message):
143 if self._busyReason is not None:
144 raise RuntimeError("Already busy doing %r" % self._busyReason)
146 self._busyReason = message
149 self._busyReason = None
151 def _send(self, numbers, text):
152 self.sendingMessage.emit()
154 with self._busy("Sending Text"):
155 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
157 self._backend[0].send_sms,
161 self.sentMessage.emit()
164 _moduleLogger.exception("Reporting error to user")
165 self.error.emit(str(e))
167 def _call(self, number):
170 with self._busy("Calling"):
171 with qui_utils.notify_busy(self._errorLog, "Calling"):
173 self._backend[0].call,
180 _moduleLogger.exception("Reporting error to user")
181 self.error.emit(str(e))
184 self.cancelling.emit()
186 with qui_utils.notify_busy(self._errorLog, "Cancelling"):
188 self._backend[0].cancel,
192 self.cancelled.emit()
194 _moduleLogger.exception("Reporting error to user")
195 self.error.emit(str(e))
198 class Session(QtCore.QObject):
200 # @todo Somehow add support for csv contacts
201 # @BUG When loading without caches, downloads messages twice
203 stateChange = qt_compat.Signal(str)
204 loggedOut = qt_compat.Signal()
205 loggedIn = qt_compat.Signal()
206 callbackNumberChanged = qt_compat.Signal(str)
208 accountUpdated = qt_compat.Signal()
209 messagesUpdated = qt_compat.Signal()
210 newMessages = qt_compat.Signal()
211 historyUpdated = qt_compat.Signal()
212 dndStateChange = qt_compat.Signal(bool)
213 voicemailAvailable = qt_compat.Signal(str, str)
215 error = qt_compat.Signal(str)
217 LOGGEDOUT_STATE = "logged out"
218 LOGGINGIN_STATE = "logging in"
219 LOGGEDIN_STATE = "logged in"
221 MESSAGE_TEXTS = "Text"
222 MESSAGE_VOICEMAILS = "Voicemail"
225 HISTORY_RECEIVED = "Received"
226 HISTORY_MISSED = "Missed"
227 HISTORY_PLACED = "Placed"
230 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
235 def __init__(self, errorLog, cachePath):
236 QtCore.QObject.__init__(self)
237 self._errorLog = errorLog
238 self._pool = qore_utils.FutureThread()
239 self._asyncQueue = concurrent.AsyncTaskQueue(self._pool)
241 self._loggedInTime = self._LOGGEDOUT_TIME
243 self._cachePath = cachePath
244 self._voicemailCachePath = None
245 self._username = None
246 self._password = None
247 self._draft = Draft(self._asyncQueue, self._backend, self._errorLog)
248 self._delayedRelogin = QtCore.QTimer()
249 self._delayedRelogin.setInterval(0)
250 self._delayedRelogin.setSingleShot(True)
251 self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
254 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
256 self._cleanMessages = []
257 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
259 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
266 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
267 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
268 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
274 def login(self, username, password):
275 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
276 assert username != "", "No username specified"
277 if self._cachePath is not None:
278 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
282 if self._username != username or not self._backend:
283 from backends import gv_backend
285 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
288 le = self._asyncQueue.add_async(self._login)
289 le.start(username, password)
292 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
293 _moduleLogger.info("Logging out")
295 self._loggedInTime = self._LOGGEDOUT_TIME
296 self._backend[0].persist()
297 self._save_to_cache()
298 self._clear_voicemail_cache()
299 self.stateChange.emit(self.LOGGEDOUT_STATE)
300 self.loggedOut.emit()
303 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
304 self._backend[0].logout()
309 def logout_and_clear(self):
310 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
311 _moduleLogger.info("Logging out and clearing the account")
313 self._loggedInTime = self._LOGGEDOUT_TIME
315 self.stateChange.emit(self.LOGGEDOUT_STATE)
316 self.loggedOut.emit()
318 def update_account(self, force = True):
319 if not force and self._contacts:
321 le = self._asyncQueue.add_async(self._update_account), (), {}
322 self._perform_op_while_loggedin(le)
324 def refresh_connection(self):
325 le = self._asyncQueue.add_async(self._refresh_authentication)
328 def get_contacts(self):
329 return self._contacts
331 def get_when_contacts_updated(self):
332 return self._accountUpdateTime
334 def update_messages(self, messageType, force = True):
335 if not force and self._messages:
337 le = self._asyncQueue.add_async(self._update_messages), (messageType, ), {}
338 self._perform_op_while_loggedin(le)
340 def get_messages(self):
341 return self._messages
343 def get_when_messages_updated(self):
344 return self._messageUpdateTime
346 def update_history(self, historyType, force = True):
347 if not force and self._history:
349 le = self._asyncQueue.add_async(self._update_history), (historyType, ), {}
350 self._perform_op_while_loggedin(le)
352 def get_history(self):
355 def get_when_history_updated(self):
356 return self._historyUpdateTime
358 def update_dnd(self):
359 le = self._asyncQueue.add_async(self._update_dnd), (), {}
360 self._perform_op_while_loggedin(le)
362 def set_dnd(self, dnd):
363 le = self._asyncQueue.add_async(self._set_dnd)
366 def is_available(self, messageId):
367 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
368 return os.path.exists(actualPath)
370 def voicemail_path(self, messageId):
371 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
372 if not os.path.exists(actualPath):
373 raise RuntimeError("Voicemail not available")
376 def download_voicemail(self, messageId):
377 le = self._asyncQueue.add_async(self._download_voicemail)
380 def _set_dnd(self, dnd):
383 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
384 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
386 self._backend[0].set_dnd,
391 _moduleLogger.exception("Reporting error to user")
392 self.error.emit(str(e))
395 if oldDnd != self._dnd:
396 self.dndStateChange.emit(self._dnd)
401 def get_account_number(self):
402 if self.state != self.LOGGEDIN_STATE:
404 return self._backend[0].get_account_number()
406 def get_callback_numbers(self):
407 if self.state != self.LOGGEDIN_STATE:
409 return self._backend[0].get_callback_numbers()
411 def get_callback_number(self):
412 return self._callback
414 def set_callback_number(self, callback):
415 le = self._asyncQueue.add_async(self._set_callback_number)
418 def _set_callback_number(self, callback):
419 oldCallback = self._callback
421 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
423 self._backend[0].set_callback_number,
428 _moduleLogger.exception("Reporting error to user")
429 self.error.emit(str(e))
431 self._callback = callback
432 if oldCallback != self._callback:
433 self.callbackNumberChanged.emit(self._callback)
435 def _login(self, username, password):
436 with qui_utils.notify_busy(self._errorLog, "Logging In"):
437 self._loggedInTime = self._LOGGINGIN_TIME
438 self.stateChange.emit(self.LOGGINGIN_STATE)
439 finalState = self.LOGGEDOUT_STATE
442 if accountData is None and self._backend[0].is_quick_login_possible():
443 accountData = yield (
444 self._backend[0].refresh_account_info,
448 if accountData is not None:
449 _moduleLogger.info("Logged in through cookies")
451 # Force a clearing of the cookies
453 self._backend[0].logout,
458 if accountData is None:
459 accountData = yield (
460 self._backend[0].login,
461 (username, password),
464 if accountData is not None:
465 _moduleLogger.info("Logged in through credentials")
467 if accountData is not None:
468 self._loggedInTime = int(time.time())
469 oldUsername = self._username
470 self._username = username
471 self._password = password
472 finalState = self.LOGGEDIN_STATE
473 if oldUsername != self._username:
474 needOps = not self._load()
478 self._voicemailCachePath = os.path.join(self._cachePath, "%s.voicemail.cache" % self._username)
480 os.makedirs(self._voicemailCachePath)
486 self.stateChange.emit(finalState)
487 finalState = None # Mark it as already set
488 self._process_account_data(accountData)
491 loginOps = self._loginOps[:]
494 del self._loginOps[:]
495 for asyncOp, args, kwds in loginOps:
496 asyncOp.start(*args, **kwds)
498 self._loggedInTime = self._LOGGEDOUT_TIME
499 self.error.emit("Error logging in")
501 _moduleLogger.exception("Booh")
502 self._loggedInTime = self._LOGGEDOUT_TIME
503 _moduleLogger.exception("Reporting error to user")
504 self.error.emit(str(e))
506 if finalState is not None:
507 self.stateChange.emit(finalState)
508 if accountData is not None and self._callback:
509 self.set_callback_number(self._callback)
511 def _update_account(self):
513 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
514 accountData = yield (
515 self._backend[0].refresh_account_info,
520 _moduleLogger.exception("Reporting error to user")
521 self.error.emit(str(e))
523 self._loggedInTime = int(time.time())
524 self._process_account_data(accountData)
526 def _refresh_authentication(self):
528 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
529 accountData = yield (
530 self._backend[0].refresh_account_info,
536 _moduleLogger.exception("Passing to user")
537 self.error.emit(str(e))
538 # refresh_account_info does not normally throw, so it is fine if we
539 # just quit early because something seriously wrong is going on
542 if accountData is not None:
543 self._loggedInTime = int(time.time())
544 self._process_account_data(accountData)
546 self._delayedRelogin.start()
549 updateMessages = len(self._messages) != 0
550 updateHistory = len(self._history) != 0
552 oldCallback = self._callback
555 self._cleanMessages = []
560 loadedFromCache = self._load_from_cache()
562 updateMessages = True
566 self.messagesUpdated.emit()
568 self.historyUpdated.emit()
569 if oldDnd != self._dnd:
570 self.dndStateChange.emit(self._dnd)
571 if oldCallback != self._callback:
572 self.callbackNumberChanged.emit(self._callback)
574 return loadedFromCache
576 def _load_from_cache(self):
577 if self._cachePath is None:
579 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
582 with open(cachePath, "rb") as f:
583 dumpedData = pickle.load(f)
584 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
585 _moduleLogger.exception("Pickle fun loading")
588 _moduleLogger.exception("Weirdness loading")
592 version, build = dumpedData[0:2]
594 _moduleLogger.exception("Upgrade/downgrade fun")
597 _moduleLogger.exception("Weirdlings")
600 if misc_utils.compare_versions(
601 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
602 misc_utils.parse_version(version),
607 messages, messageUpdateTime,
608 history, historyUpdateTime,
612 _moduleLogger.exception("Upgrade/downgrade fun")
615 _moduleLogger.exception("Weirdlings")
618 _moduleLogger.info("Loaded cache")
619 self._messages = messages
620 self._alert_on_messages(self._messages)
621 self._messageUpdateTime = messageUpdateTime
622 self._history = history
623 self._historyUpdateTime = historyUpdateTime
625 self._callback = callback
629 "Skipping cache due to version mismatch (%s-%s)" % (
635 def _save_to_cache(self):
636 _moduleLogger.info("Saving cache")
637 if self._cachePath is None:
639 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
643 constants.__version__, constants.__build__,
644 self._messages, self._messageUpdateTime,
645 self._history, self._historyUpdateTime,
646 self._dnd, self._callback
648 with open(cachePath, "wb") as f:
649 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
650 _moduleLogger.info("Cache saved")
651 except (pickle.PickleError, IOError):
652 _moduleLogger.exception("While saving")
654 def _clear_cache(self):
655 updateMessages = len(self._messages) != 0
656 updateHistory = len(self._history) != 0
658 oldCallback = self._callback
661 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
663 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
668 self.messagesUpdated.emit()
670 self.historyUpdated.emit()
671 if oldDnd != self._dnd:
672 self.dndStateChange.emit(self._dnd)
673 if oldCallback != self._callback:
674 self.callbackNumberChanged.emit(self._callback)
676 self._save_to_cache()
677 self._clear_voicemail_cache()
679 def _clear_voicemail_cache(self):
681 shutil.rmtree(self._voicemailCachePath, True)
683 def _update_messages(self, messageType):
685 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
686 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
687 self._messages = yield (
688 self._backend[0].get_messages,
693 _moduleLogger.exception("Reporting error to user")
694 self.error.emit(str(e))
696 self._messageUpdateTime = datetime.datetime.now()
697 self.messagesUpdated.emit()
698 self._alert_on_messages(self._messages)
700 def _update_history(self, historyType):
702 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
703 with qui_utils.notify_busy(self._errorLog, "Updating '%s' History" % historyType):
704 self._history = yield (
705 self._backend[0].get_call_history,
710 _moduleLogger.exception("Reporting error to user")
711 self.error.emit(str(e))
713 self._historyUpdateTime = datetime.datetime.now()
714 self.historyUpdated.emit()
716 def _update_dnd(self):
717 with qui_utils.notify_busy(self._errorLog, "Updating Do-Not-Disturb Status"):
720 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
722 self._backend[0].is_dnd,
727 _moduleLogger.exception("Reporting error to user")
728 self.error.emit(str(e))
730 if oldDnd != self._dnd:
731 self.dndStateChange(self._dnd)
733 def _download_voicemail(self, messageId):
734 actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
735 targetPath = "%s.%s.part" % (actualPath, time.time())
736 if os.path.exists(actualPath):
737 self.voicemailAvailable.emit(messageId, actualPath)
739 with qui_utils.notify_busy(self._errorLog, "Downloading Voicemail"):
742 self._backend[0].download,
743 (messageId, targetPath),
747 _moduleLogger.exception("Passing to user")
748 self.error.emit(str(e))
751 if os.path.exists(actualPath):
753 os.remove(targetPath)
755 _moduleLogger.exception("Ignoring file problems with cache")
756 self.voicemailAvailable.emit(messageId, actualPath)
759 os.rename(targetPath, actualPath)
760 self.voicemailAvailable.emit(messageId, actualPath)
762 def _perform_op_while_loggedin(self, op):
763 if self.state == self.LOGGEDIN_STATE:
765 op.start(*args, **kwds)
767 self._push_login_op(op)
769 def _push_login_op(self, asyncOp):
770 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
771 if asyncOp in self._loginOps:
772 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
774 self._loginOps.append(asyncOp)
776 def _process_account_data(self, accountData):
777 self._contacts = dict(
778 (contactId, contactDetails)
779 for contactId, contactDetails in accountData["contacts"].iteritems()
780 # A zero contact id is the catch all for unknown contacts
784 self._accountUpdateTime = datetime.datetime.now()
785 self.accountUpdated.emit()
787 def _alert_on_messages(self, messages):
788 cleanNewMessages = list(self._clean_messages(messages))
789 cleanNewMessages.sort(key=lambda m: m["contactId"])
790 if self._cleanMessages:
791 if self._cleanMessages != cleanNewMessages:
792 self.newMessages.emit()
793 self._cleanMessages = cleanNewMessages
795 def _clean_messages(self, messages):
796 for message in messages:
799 for kv in message.iteritems()
811 # Don't let outbound messages cause alerts, especially if the package has only outbound
812 cleaned["messageParts"] = [
813 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
815 if not cleaned["messageParts"]:
820 @misc_utils.log_exception(_moduleLogger)
821 def _on_delayed_relogin(self):
823 username = self._username
824 password = self._password
826 self.login(username, password)
828 _moduleLogger.exception("Passing to user")
829 self.error.emit(str(e))