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