bd7914d50cc650449d5cf7f0498136ffe9a7fd55
[gc-dialer] / src / session.py
1 from __future__ import with_statement
2
3 import os
4 import time
5 import datetime
6 import contextlib
7 import logging
8
9 try:
10         import cPickle
11         pickle = cPickle
12 except ImportError:
13         import pickle
14
15 from PyQt4 import QtCore
16
17 from util import qore_utils
18 from util import qui_utils
19 from util import concurrent
20 from util import misc as misc_utils
21
22 import constants
23
24
25 _moduleLogger = logging.getLogger(__name__)
26
27
28 class _DraftContact(object):
29
30         def __init__(self, title, description, numbersWithDescriptions):
31                 self.title = title
32                 self.description = description
33                 self.numbers = numbersWithDescriptions
34                 self.selectedNumber = numbersWithDescriptions[0][0]
35
36
37 class Draft(QtCore.QObject):
38
39         sendingMessage = QtCore.pyqtSignal()
40         sentMessage = QtCore.pyqtSignal()
41         calling = QtCore.pyqtSignal()
42         called = QtCore.pyqtSignal()
43         cancelling = QtCore.pyqtSignal()
44         cancelled = QtCore.pyqtSignal()
45         error = QtCore.pyqtSignal(str)
46
47         recipientsChanged = QtCore.pyqtSignal()
48
49         def __init__(self, pool, backend, errorLog):
50                 QtCore.QObject.__init__(self)
51                 self._errorLog = errorLog
52                 self._contacts = {}
53                 self._pool = pool
54                 self._backend = backend
55                 self._busyReason = None
56                 self._message = ""
57
58         def send(self):
59                 assert 0 < len(self._contacts), "No contacts selected"
60                 assert 0 < len(self._message), "No message to send"
61                 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
62                 le = concurrent.AsyncLinearExecution(self._pool, self._send)
63                 le.start(numbers, self._message)
64
65         def call(self):
66                 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
67                 assert len(self._message) == 0, "Cannot send message with call"
68                 (contact, ) = self._contacts.itervalues()
69                 number = misc_utils.make_ugly(contact.selectedNumber)
70                 le = concurrent.AsyncLinearExecution(self._pool, self._call)
71                 le.start(number)
72
73         def cancel(self):
74                 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
75                 le.start()
76
77         def _get_message(self):
78                 return self._message
79
80         def _set_message(self, message):
81                 self._message = message
82
83         message = property(_get_message, _set_message)
84
85         def add_contact(self, contactId, title, description, numbersWithDescriptions):
86                 if self._busyReason is not None:
87                         raise RuntimeError("Please wait for %r" % self._busyReason)
88                 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
89                 contactDetails = _DraftContact(title, description, numbersWithDescriptions)
90                 self._contacts[contactId] = contactDetails
91                 self.recipientsChanged.emit()
92
93         def remove_contact(self, contactId):
94                 if self._busyReason is not None:
95                         raise RuntimeError("Please wait for %r" % self._busyReason)
96                 assert contactId in self._contacts, "Contact missing"
97                 del self._contacts[contactId]
98                 self.recipientsChanged.emit()
99
100         def get_contacts(self):
101                 return self._contacts.iterkeys()
102
103         def get_num_contacts(self):
104                 return len(self._contacts)
105
106         def get_title(self, cid):
107                 return self._contacts[cid].title
108
109         def get_description(self, cid):
110                 return self._contacts[cid].description
111
112         def get_numbers(self, cid):
113                 return self._contacts[cid].numbers
114
115         def get_selected_number(self, cid):
116                 return self._contacts[cid].selectedNumber
117
118         def set_selected_number(self, cid, number):
119                 # @note I'm lazy, this isn't firing any kind of signal since only one
120                 # controller right now and that is the viewer
121                 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
122                 self._contacts[cid].selectedNumber = number
123
124         def clear(self):
125                 if self._busyReason is not None:
126                         raise RuntimeError("Please wait for %r" % self._busyReason)
127                 self._clear()
128
129         def _clear(self):
130                 oldContacts = self._contacts
131                 self._contacts = {}
132                 self._message = ""
133                 if oldContacts:
134                         self.recipientsChanged.emit()
135
136         @contextlib.contextmanager
137         def _busy(self, message):
138                 if self._busyReason is not None:
139                         raise RuntimeError("Already busy doing %r" % self._busyReason)
140                 try:
141                         self._busyReason = message
142                         yield
143                 finally:
144                         self._busyReason = None
145
146         def _send(self, numbers, text):
147                 self.sendingMessage.emit()
148                 try:
149                         with self._busy("Sending Text"):
150                                 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
151                                         yield (
152                                                 self._backend[0].send_sms,
153                                                 (numbers, text),
154                                                 {},
155                                         )
156                                 self.sentMessage.emit()
157                                 self._clear()
158                 except Exception, e:
159                         _moduleLogger.exception("Reporting error to user")
160                         self.error.emit(str(e))
161
162         def _call(self, number):
163                 self.calling.emit()
164                 try:
165                         with self._busy("Calling"):
166                                 with qui_utils.notify_busy(self._errorLog, "Calling"):
167                                         yield (
168                                                 self._backend[0].call,
169                                                 (number, ),
170                                                 {},
171                                         )
172                                 self.called.emit()
173                                 self._clear()
174                 except Exception, e:
175                         _moduleLogger.exception("Reporting error to user")
176                         self.error.emit(str(e))
177
178         def _cancel(self):
179                 self.cancelling.emit()
180                 try:
181                         with qui_utils.notify_busy(self._errorLog, "Cancelling"):
182                                 yield (
183                                         self._backend[0].cancel,
184                                         (),
185                                         {},
186                                 )
187                         self.cancelled.emit()
188                 except Exception, e:
189                         _moduleLogger.exception("Reporting error to user")
190                         self.error.emit(str(e))
191
192
193 class Session(QtCore.QObject):
194
195         # @todo Somehow add support for csv contacts
196
197         stateChange = QtCore.pyqtSignal(str)
198         loggedOut = QtCore.pyqtSignal()
199         loggedIn = QtCore.pyqtSignal()
200         callbackNumberChanged = QtCore.pyqtSignal(str)
201
202         contactsUpdated = QtCore.pyqtSignal()
203         messagesUpdated = QtCore.pyqtSignal()
204         newMessages = QtCore.pyqtSignal()
205         historyUpdated = QtCore.pyqtSignal()
206         dndStateChange = QtCore.pyqtSignal(bool)
207
208         error = QtCore.pyqtSignal(str)
209
210         LOGGEDOUT_STATE = "logged out"
211         LOGGINGIN_STATE = "logging in"
212         LOGGEDIN_STATE = "logged in"
213
214         _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.1.90")
215
216         _LOGGEDOUT_TIME = -1
217         _LOGGINGIN_TIME = 0
218
219         def __init__(self, errorLog, cachePath = None):
220                 QtCore.QObject.__init__(self)
221                 self._errorLog = errorLog
222                 self._pool = qore_utils.AsyncPool()
223                 self._backend = []
224                 self._loggedInTime = self._LOGGEDOUT_TIME
225                 self._loginOps = []
226                 self._cachePath = cachePath
227                 self._username = None
228                 self._draft = Draft(self._pool, self._backend, self._errorLog)
229
230                 self._contacts = {}
231                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
232                 self._messages = []
233                 self._cleanMessages = []
234                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
235                 self._history = []
236                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
237                 self._dnd = False
238                 self._callback = ""
239
240         @property
241         def state(self):
242                 return {
243                         self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
244                         self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
245                 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
246
247         @property
248         def draft(self):
249                 return self._draft
250
251         def login(self, username, password):
252                 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
253                 assert username != "", "No username specified"
254                 if self._cachePath is not None:
255                         cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
256                 else:
257                         cookiePath = None
258
259                 if self._username != username or not self._backend:
260                         from backends import gv_backend
261                         del self._backend[:]
262                         self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
263
264                 self._pool.start()
265                 le = concurrent.AsyncLinearExecution(self._pool, self._login)
266                 le.start(username, password)
267
268         def logout(self):
269                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
270                 _moduleLogger.info("Logging out")
271                 self._pool.stop()
272                 self._loggedInTime = self._LOGGEDOUT_TIME
273                 self._backend[0].persist()
274                 self._save_to_cache()
275                 self.stateChange.emit(self.LOGGEDOUT_STATE)
276                 self.loggedOut.emit()
277
278         def clear(self):
279                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
280                 self._backend[0].logout()
281                 del self._backend[0]
282                 self._clear_cache()
283                 self._draft.clear()
284
285         def logout_and_clear(self):
286                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
287                 _moduleLogger.info("Logging out and clearing the account")
288                 self._pool.stop()
289                 self._loggedInTime = self._LOGGEDOUT_TIME
290                 self.clear()
291                 self.stateChange.emit(self.LOGGEDOUT_STATE)
292                 self.loggedOut.emit()
293
294         def update_contacts(self, force = True):
295                 if not force and self._contacts:
296                         return
297                 le = concurrent.AsyncLinearExecution(self._pool, self._update_contacts)
298                 self._perform_op_while_loggedin(le)
299
300         def get_contacts(self):
301                 return self._contacts
302
303         def get_when_contacts_updated(self):
304                 return self._contactUpdateTime
305
306         def update_messages(self, force = True):
307                 if not force and self._messages:
308                         return
309                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages)
310                 self._perform_op_while_loggedin(le)
311
312         def get_messages(self):
313                 return self._messages
314
315         def get_when_messages_updated(self):
316                 return self._messageUpdateTime
317
318         def update_history(self, force = True):
319                 if not force and self._history:
320                         return
321                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history)
322                 self._perform_op_while_loggedin(le)
323
324         def get_history(self):
325                 return self._history
326
327         def get_when_history_updated(self):
328                 return self._historyUpdateTime
329
330         def update_dnd(self):
331                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd)
332                 self._perform_op_while_loggedin(le)
333
334         def set_dnd(self, dnd):
335                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
336                 le.start(dnd)
337
338         def _set_dnd(self, dnd):
339                 oldDnd = self._dnd
340                 try:
341                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
342                         with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
343                                 yield (
344                                         self._backend[0].set_dnd,
345                                         (dnd, ),
346                                         {},
347                                 )
348                 except Exception, e:
349                         _moduleLogger.exception("Reporting error to user")
350                         self.error.emit(str(e))
351                         return
352                 self._dnd = dnd
353                 if oldDnd != self._dnd:
354                         self.dndStateChange.emit(self._dnd)
355
356         def get_dnd(self):
357                 return self._dnd
358
359         def get_account_number(self):
360                 if self.state != self.LOGGEDIN_STATE:
361                         return ""
362                 return self._backend[0].get_account_number()
363
364         def get_callback_numbers(self):
365                 if self.state != self.LOGGEDIN_STATE:
366                         return {}
367                 return self._backend[0].get_callback_numbers()
368
369         def get_callback_number(self):
370                 return self._callback
371
372         def set_callback_number(self, callback):
373                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
374                 le.start(callback)
375
376         def _set_callback_number(self, callback):
377                 oldCallback = self._callback
378                 try:
379                         assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
380                         yield (
381                                 self._backend[0].set_callback_number,
382                                 (callback, ),
383                                 {},
384                         )
385                 except Exception, e:
386                         _moduleLogger.exception("Reporting error to user")
387                         self.error.emit(str(e))
388                         return
389                 self._callback = callback
390                 if oldCallback != self._callback:
391                         self.callbackNumberChanged.emit(self._callback)
392
393         def _login(self, username, password):
394                 with qui_utils.notify_busy(self._errorLog, "Logging In"):
395                         self._loggedInTime = self._LOGGINGIN_TIME
396                         self.stateChange.emit(self.LOGGINGIN_STATE)
397                         finalState = self.LOGGEDOUT_STATE
398                         accountData = None
399                         try:
400                                 if accountData is None and self._backend[0].is_quick_login_possible():
401                                         accountData = yield (
402                                                 self._backend[0].refresh_account_info,
403                                                 (),
404                                                 {},
405                                         )
406                                         if accountData is not None:
407                                                 _moduleLogger.info("Logged in through cookies")
408                                         else:
409                                                 # Force a clearing of the cookies
410                                                 yield (
411                                                         self._backend[0].logout,
412                                                         (),
413                                                         {},
414                                                 )
415
416                                 if accountData is None:
417                                         accountData = yield (
418                                                 self._backend[0].login,
419                                                 (username, password),
420                                                 {},
421                                         )
422                                         if accountData is not None:
423                                                 _moduleLogger.info("Logged in through credentials")
424
425                                 if accountData is not None:
426                                         self._loggedInTime = int(time.time())
427                                         oldUsername = self._username
428                                         self._username = username
429                                         finalState = self.LOGGEDIN_STATE
430                                         if oldUsername != self._username:
431                                                 needOps = not self._load()
432                                         else:
433                                                 needOps = True
434
435                                         self.loggedIn.emit()
436                                         self.stateChange.emit(finalState)
437                                         finalState = None # Mark it as already set
438
439                                         if needOps:
440                                                 loginOps = self._loginOps[:]
441                                         else:
442                                                 loginOps = []
443                                         del self._loginOps[:]
444                                         for asyncOp in loginOps:
445                                                 asyncOp.start()
446                                 else:
447                                         self._loggedInTime = self._LOGGEDOUT_TIME
448                                         self.error.emit("Error logging in")
449                         except Exception, e:
450                                 _moduleLogger.exception("Booh")
451                                 self._loggedInTime = self._LOGGEDOUT_TIME
452                                 _moduleLogger.exception("Reporting error to user")
453                                 self.error.emit(str(e))
454                         finally:
455                                 if finalState is not None:
456                                         self.stateChange.emit(finalState)
457                         if accountData is not None and self._callback:
458                                 self.set_callback_number(self._callback)
459
460         def _load(self):
461                 updateContacts = len(self._contacts) != 0
462                 updateMessages = len(self._messages) != 0
463                 updateHistory = len(self._history) != 0
464                 oldDnd = self._dnd
465                 oldCallback = self._callback
466
467                 self._contacts = {}
468                 self._messages = []
469                 self._cleanMessages = []
470                 self._history = []
471                 self._dnd = False
472                 self._callback = ""
473
474                 loadedFromCache = self._load_from_cache()
475                 if loadedFromCache:
476                         updateContacts = True
477                         updateMessages = True
478                         updateHistory = True
479
480                 if updateContacts:
481                         self.contactsUpdated.emit()
482                 if updateMessages:
483                         self.messagesUpdated.emit()
484                 if updateHistory:
485                         self.historyUpdated.emit()
486                 if oldDnd != self._dnd:
487                         self.dndStateChange.emit(self._dnd)
488                 if oldCallback != self._callback:
489                         self.callbackNumberChanged.emit(self._callback)
490
491                 return loadedFromCache
492
493         def _load_from_cache(self):
494                 if self._cachePath is None:
495                         return False
496                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
497
498                 try:
499                         with open(cachePath, "rb") as f:
500                                 dumpedData = pickle.load(f)
501                 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
502                         _moduleLogger.exception("Pickle fun loading")
503                         return False
504                 except:
505                         _moduleLogger.exception("Weirdness loading")
506                         return False
507
508                 try:
509                         (
510                                 version, build,
511                                 contacts, contactUpdateTime,
512                                 messages, messageUpdateTime,
513                                 history, historyUpdateTime,
514                                 dnd, callback
515                         ) = dumpedData
516                 except ValueError:
517                         _moduleLogger.exception("Upgrade/downgrade fun")
518                         return False
519                 except:
520                         _moduleLogger.exception("Weirdlings")
521
522                 if misc_utils.compare_versions(
523                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
524                         misc_utils.parse_version(version),
525                 ) <= 0:
526                         _moduleLogger.info("Loaded cache")
527                         self._contacts = contacts
528                         self._contactUpdateTime = contactUpdateTime
529                         self._messages = messages
530                         self._alert_on_messages(self._messages)
531                         self._messageUpdateTime = messageUpdateTime
532                         self._history = history
533                         self._historyUpdateTime = historyUpdateTime
534                         self._dnd = dnd
535                         self._callback = callback
536                         return True
537                 else:
538                         _moduleLogger.debug(
539                                 "Skipping cache due to version mismatch (%s-%s)" % (
540                                         version, build
541                                 )
542                         )
543                         return False
544
545         def _save_to_cache(self):
546                 _moduleLogger.info("Saving cache")
547                 if self._cachePath is None:
548                         return
549                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
550
551                 try:
552                         dataToDump = (
553                                 constants.__version__, constants.__build__,
554                                 self._contacts, self._contactUpdateTime,
555                                 self._messages, self._messageUpdateTime,
556                                 self._history, self._historyUpdateTime,
557                                 self._dnd, self._callback
558                         )
559                         with open(cachePath, "wb") as f:
560                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
561                         _moduleLogger.info("Cache saved")
562                 except (pickle.PickleError, IOError):
563                         _moduleLogger.exception("While saving")
564
565         def _clear_cache(self):
566                 updateContacts = len(self._contacts) != 0
567                 updateMessages = len(self._messages) != 0
568                 updateHistory = len(self._history) != 0
569                 oldDnd = self._dnd
570                 oldCallback = self._callback
571
572                 self._contacts = {}
573                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
574                 self._messages = []
575                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
576                 self._history = []
577                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
578                 self._dnd = False
579                 self._callback = ""
580
581                 if updateContacts:
582                         self.contactsUpdated.emit()
583                 if updateMessages:
584                         self.messagesUpdated.emit()
585                 if updateHistory:
586                         self.historyUpdated.emit()
587                 if oldDnd != self._dnd:
588                         self.dndStateChange.emit(self._dnd)
589                 if oldCallback != self._callback:
590                         self.callbackNumberChanged.emit(self._callback)
591
592                 self._save_to_cache()
593
594         def _update_contacts(self):
595                 try:
596                         assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s" % self.state
597                         with qui_utils.notify_busy(self._errorLog, "Updating Contacts"):
598                                 self._contacts = yield (
599                                         self._backend[0].get_contacts,
600                                         (),
601                                         {},
602                                 )
603                 except Exception, e:
604                         _moduleLogger.exception("Reporting error to user")
605                         self.error.emit(str(e))
606                         return
607                 self._contactUpdateTime = datetime.datetime.now()
608                 self.contactsUpdated.emit()
609
610         def _update_messages(self):
611                 try:
612                         assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
613                         with qui_utils.notify_busy(self._errorLog, "Updating Messages"):
614                                 self._messages = yield (
615                                         self._backend[0].get_messages,
616                                         (),
617                                         {},
618                                 )
619                 except Exception, e:
620                         _moduleLogger.exception("Reporting error to user")
621                         self.error.emit(str(e))
622                         return
623                 self._messageUpdateTime = datetime.datetime.now()
624                 self.messagesUpdated.emit()
625                 self._alert_on_messages(self._messages)
626
627         def _update_history(self):
628                 try:
629                         assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
630                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
631                                 self._history = yield (
632                                         self._backend[0].get_recent,
633                                         (),
634                                         {},
635                                 )
636                 except Exception, e:
637                         _moduleLogger.exception("Reporting error to user")
638                         self.error.emit(str(e))
639                         return
640                 self._historyUpdateTime = datetime.datetime.now()
641                 self.historyUpdated.emit()
642
643         def _update_dnd(self):
644                 oldDnd = self._dnd
645                 try:
646                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
647                         self._dnd = yield (
648                                 self._backend[0].is_dnd,
649                                 (),
650                                 {},
651                         )
652                 except Exception, e:
653                         _moduleLogger.exception("Reporting error to user")
654                         self.error.emit(str(e))
655                         return
656                 if oldDnd != self._dnd:
657                         self.dndStateChange(self._dnd)
658
659         def _perform_op_while_loggedin(self, op):
660                 if self.state == self.LOGGEDIN_STATE:
661                         op.start()
662                 else:
663                         self._push_login_op(op)
664
665         def _push_login_op(self, asyncOp):
666                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
667                 if asyncOp in self._loginOps:
668                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
669                         return
670                 self._loginOps.append(asyncOp)
671
672         def _alert_on_messages(self, messages):
673                 cleanNewMessages = list(self._clean_messages(messages))
674                 cleanNewMessages.sort(key=lambda m: m["contactId"])
675                 if self._cleanMessages:
676                         if self._cleanMessages != cleanNewMessages:
677                                 self.newMessages.emit()
678                 self._cleanMessages = cleanNewMessages
679
680         def _clean_messages(self, messages):
681                 for message in messages:
682                         cleaned = dict(
683                                 kv
684                                 for kv in message.iteritems()
685                                 if kv[0] not in
686                                 [
687                                         "relTime",
688                                         "time",
689                                         "isArchived",
690                                         "isRead",
691                                         "isSpam",
692                                         "isTrash",
693                                 ]
694                         )
695
696                         # Don't let outbound messages cause alerts, especially if the package has only outbound
697                         cleaned["messageParts"] = [
698                                 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
699                         ]
700                         if not cleaned["messageParts"]:
701                                 continue
702
703                         yield cleaned