BROKEN: Moved everything
authorEd Page <eopage@byu.net>
Wed, 10 Aug 2011 00:10:18 +0000 (19:10 -0500)
committerEd Page <eopage@byu.net>
Wed, 10 Aug 2011 00:10:18 +0000 (19:10 -0500)
115 files changed:
DialCentral [new file with mode: 0755]
README [deleted file]
data/LICENSE [deleted file]
data/app/LICENSE [new file with mode: 0644]
data/app/bell.flac [new file with mode: 0644]
data/app/bell.wav [new file with mode: 0644]
data/app/contacts.png [new file with mode: 0644]
data/app/dialpad.png [new file with mode: 0644]
data/app/history.png [new file with mode: 0644]
data/app/messages.png [new file with mode: 0644]
data/app/missed.png [new file with mode: 0644]
data/app/placed.png [new file with mode: 0644]
data/app/received.png [new file with mode: 0644]
data/bell.flac [deleted file]
data/bell.wav [deleted file]
data/contacts.png [deleted file]
data/dialcentral-base.svg [new file with mode: 0644]
data/dialcentral.colors [new file with mode: 0644]
data/dialcentral.png [new file with mode: 0644]
data/dialcentral.svg [new file with mode: 0644]
data/dialpad.png [deleted file]
data/history.png [deleted file]
data/messages.png [deleted file]
data/missed.png [deleted file]
data/placed.png [deleted file]
data/received.png [deleted file]
data/template.desktop [new file with mode: 0644]
dialcentral/__init__.py [new file with mode: 0644]
dialcentral/alarm_handler.py [new file with mode: 0644]
dialcentral/alarm_notify.py [new file with mode: 0755]
dialcentral/backends/__init__.py [new file with mode: 0644]
dialcentral/backends/file_backend.py [new file with mode: 0644]
dialcentral/backends/gv_backend.py [new file with mode: 0644]
dialcentral/backends/gvoice/__init__.py [new file with mode: 0644]
dialcentral/backends/gvoice/browser_emu.py [new file with mode: 0644]
dialcentral/backends/gvoice/gvoice.py [new file with mode: 0755]
dialcentral/backends/null_backend.py [new file with mode: 0644]
dialcentral/backends/qt_backend.py [new file with mode: 0644]
dialcentral/call_handler.py [new file with mode: 0644]
dialcentral/constants.py [new file with mode: 0644]
dialcentral/dialcentral_qt.py [new file with mode: 0755]
dialcentral/dialogs.py [new file with mode: 0644]
dialcentral/examples/log_notifier.py [new file with mode: 0644]
dialcentral/examples/sound_notifier.py [new file with mode: 0644]
dialcentral/gv_views.py [new file with mode: 0644]
dialcentral/led_handler.py [new file with mode: 0755]
dialcentral/session.py [new file with mode: 0644]
dialcentral/stream_gst.py [new file with mode: 0644]
dialcentral/stream_handler.py [new file with mode: 0644]
dialcentral/stream_null.py [new file with mode: 0644]
dialcentral/stream_osso.py [new file with mode: 0644]
dialcentral/util/__init__.py [new file with mode: 0644]
dialcentral/util/algorithms.py [new file with mode: 0644]
dialcentral/util/concurrent.py [new file with mode: 0644]
dialcentral/util/coroutines.py [new file with mode: 0755]
dialcentral/util/go_utils.py [new file with mode: 0644]
dialcentral/util/io.py [new file with mode: 0644]
dialcentral/util/linux.py [new file with mode: 0644]
dialcentral/util/misc.py [new file with mode: 0644]
dialcentral/util/overloading.py [new file with mode: 0644]
dialcentral/util/qore_utils.py [new file with mode: 0644]
dialcentral/util/qt_compat.py [new file with mode: 0644]
dialcentral/util/qtpie.py [new file with mode: 0755]
dialcentral/util/qtpieboard.py [new file with mode: 0755]
dialcentral/util/qui_utils.py [new file with mode: 0644]
dialcentral/util/qwrappers.py [new file with mode: 0644]
dialcentral/util/time_utils.py [new file with mode: 0644]
dialcentral/util/tp_utils.py [new file with mode: 0644]
src [new symlink]
src/__init__.py [deleted file]
src/alarm_handler.py [deleted file]
src/alarm_notify.py [deleted file]
src/backends/__init__.py [deleted file]
src/backends/file_backend.py [deleted file]
src/backends/gv_backend.py [deleted file]
src/backends/gvoice/__init__.py [deleted file]
src/backends/gvoice/browser_emu.py [deleted file]
src/backends/gvoice/gvoice.py [deleted file]
src/backends/null_backend.py [deleted file]
src/backends/qt_backend.py [deleted file]
src/call_handler.py [deleted file]
src/constants.py [deleted file]
src/dialcentral.py [deleted file]
src/dialcentral_qt.py [deleted file]
src/dialogs.py [deleted file]
src/examples/log_notifier.py [deleted file]
src/examples/sound_notifier.py [deleted file]
src/gv_views.py [deleted file]
src/led_handler.py [deleted file]
src/session.py [deleted file]
src/stream_gst.py [deleted file]
src/stream_handler.py [deleted file]
src/stream_null.py [deleted file]
src/stream_osso.py [deleted file]
src/util/__init__.py [deleted file]
src/util/algorithms.py [deleted file]
src/util/concurrent.py [deleted file]
src/util/coroutines.py [deleted file]
src/util/go_utils.py [deleted file]
src/util/io.py [deleted file]
src/util/linux.py [deleted file]
src/util/misc.py [deleted file]
src/util/overloading.py [deleted file]
src/util/qore_utils.py [deleted file]
src/util/qt_compat.py [deleted file]
src/util/qtpie.py [deleted file]
src/util/qtpieboard.py [deleted file]
src/util/qui_utils.py [deleted file]
src/util/qwrappers.py [deleted file]
src/util/time_utils.py [deleted file]
src/util/tp_utils.py [deleted file]
support/dialcentral.desktop [deleted file]
support/icons/hicolor/26x26/hildon/dialcentral.png [deleted file]
support/icons/hicolor/64x64/hildon/dialcentral.png [deleted file]
support/icons/hicolor/scalable/hildon/dialcentral.png [deleted file]

diff --git a/DialCentral b/DialCentral
new file mode 100755 (executable)
index 0000000..a20d4fe
--- /dev/null
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+
+import sys
+
+
+sys.path.append("/opt/dialcentral/lib")
+
+
+import dialcentral_qt
+
+
+if __name__ == "__main__":
+       dialcentral_qt.run()
diff --git a/README b/README
deleted file mode 100644 (file)
index b87ce9c..0000000
--- a/README
+++ /dev/null
@@ -1,37 +0,0 @@
-Building a package
-===================
-Run
-       make PLATFORM=... package
-which will create a "./pkg-.../..." heirarchy.  Move this structure to somewhere on the tablet, then run pypackager. 
-
-Supported PLATFORMs include
-       desktop
-       os2007
-       os2008
-
-SDK Enviroment
-===================
-
-Native
-
-Follow install instructions
-       Ubuntu: http://www.linuxuk.org/node/38
-Install Nokia stuff (for each target)
-       fakeroot apt-get install maemo-explicit
-
-Userful commands
-Login
-       /scratchbox/login
-Change targets
-       sb-conf select DIABLO_ARMEL
-       sb-conf select DIABLO_X86
-Fixing it
-       fakeroot apt-get -f install
-
-Starting scratchbox
-       Xephyr :2 -host-cursor -screen 800x480x16 -dpi 96 -ac -extension Composite
-       scratchbox
-       export DISPLAY=:2
-       af-sb-init.sh start
-Then running a command in the "Maemo" terminal will launch it in the Xephyr session
-       Tip: run with "run-standalone.sh" for niceness?
diff --git a/data/LICENSE b/data/LICENSE
deleted file mode 100644 (file)
index fb44a62..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-http://www.gentleface.com/free_icon_set.html
-The Creative Commons Attribution-NonCommercial -- FREE
-http://creativecommons.org/licenses/by-nc-nd/3.0/
-
-Sound:
-http://www.freesound.org/samplesViewSingle.php?id=2166
-http://creativecommons.org/licenses/sampling+/1.0/
-
-placed.png, received.png, placed.png
-Free for commercial use
-http://www.iconeden.com/icon/free/get/bright-free-stock-iconset
diff --git a/data/app/LICENSE b/data/app/LICENSE
new file mode 100644 (file)
index 0000000..fb44a62
--- /dev/null
@@ -0,0 +1,11 @@
+http://www.gentleface.com/free_icon_set.html
+The Creative Commons Attribution-NonCommercial -- FREE
+http://creativecommons.org/licenses/by-nc-nd/3.0/
+
+Sound:
+http://www.freesound.org/samplesViewSingle.php?id=2166
+http://creativecommons.org/licenses/sampling+/1.0/
+
+placed.png, received.png, placed.png
+Free for commercial use
+http://www.iconeden.com/icon/free/get/bright-free-stock-iconset
diff --git a/data/app/bell.flac b/data/app/bell.flac
new file mode 100644 (file)
index 0000000..419420e
Binary files /dev/null and b/data/app/bell.flac differ
diff --git a/data/app/bell.wav b/data/app/bell.wav
new file mode 100644 (file)
index 0000000..6b7fc1b
Binary files /dev/null and b/data/app/bell.wav differ
diff --git a/data/app/contacts.png b/data/app/contacts.png
new file mode 100644 (file)
index 0000000..aa1a7ce
Binary files /dev/null and b/data/app/contacts.png differ
diff --git a/data/app/dialpad.png b/data/app/dialpad.png
new file mode 100644 (file)
index 0000000..b54013b
Binary files /dev/null and b/data/app/dialpad.png differ
diff --git a/data/app/history.png b/data/app/history.png
new file mode 100644 (file)
index 0000000..887989a
Binary files /dev/null and b/data/app/history.png differ
diff --git a/data/app/messages.png b/data/app/messages.png
new file mode 100644 (file)
index 0000000..e117918
Binary files /dev/null and b/data/app/messages.png differ
diff --git a/data/app/missed.png b/data/app/missed.png
new file mode 100644 (file)
index 0000000..34f71c4
Binary files /dev/null and b/data/app/missed.png differ
diff --git a/data/app/placed.png b/data/app/placed.png
new file mode 100644 (file)
index 0000000..329771d
Binary files /dev/null and b/data/app/placed.png differ
diff --git a/data/app/received.png b/data/app/received.png
new file mode 100644 (file)
index 0000000..2b45263
Binary files /dev/null and b/data/app/received.png differ
diff --git a/data/bell.flac b/data/bell.flac
deleted file mode 100644 (file)
index 419420e..0000000
Binary files a/data/bell.flac and /dev/null differ
diff --git a/data/bell.wav b/data/bell.wav
deleted file mode 100644 (file)
index 6b7fc1b..0000000
Binary files a/data/bell.wav and /dev/null differ
diff --git a/data/contacts.png b/data/contacts.png
deleted file mode 100644 (file)
index aa1a7ce..0000000
Binary files a/data/contacts.png and /dev/null differ
diff --git a/data/dialcentral-base.svg b/data/dialcentral-base.svg
new file mode 100644 (file)
index 0000000..aa35390
--- /dev/null
@@ -0,0 +1,148 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="128pt"
+   height="128pt"
+   viewBox="0 0 128 128"
+   version="1.1"
+   id="svg2"
+   inkscape:version="0.48.1 r9760"
+   sodipodi:docname="dialcentral-base.svg">
+  <metadata
+     id="metadata26">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs24">
+    <linearGradient
+       id="linearGradient3805">
+      <stop
+         style="stop-color:#acdd6e;stop-opacity:1;"
+         offset="0"
+         id="stop3807" />
+      <stop
+         style="stop-color:#2f720c;stop-opacity:1;"
+         offset="1"
+         id="stop3809" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3787">
+      <stop
+         style="stop-color:#fbfbfb;stop-opacity:1;"
+         offset="0"
+         id="stop3789" />
+      <stop
+         style="stop-color:#fbfbfb;stop-opacity:0;"
+         offset="1"
+         id="stop3791" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3779">
+      <stop
+         style="stop-color:#fbfbfb;stop-opacity:1;"
+         offset="0"
+         id="stop3781" />
+      <stop
+         style="stop-color:#f6f6f6;stop-opacity:1;"
+         offset="1"
+         id="stop3783" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient3771">
+      <stop
+         style="stop-color:#ffffff;stop-opacity:1;"
+         offset="0"
+         id="stop3773" />
+      <stop
+         style="stop-color:#f1f1f1;stop-opacity:1;"
+         offset="1"
+         id="stop3775" />
+    </linearGradient>
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3771"
+       id="linearGradient3802"
+       gradientUnits="userSpaceOnUse"
+       gradientTransform="scale(1.25,1.25)"
+       x1="10.26"
+       y1="30.534513"
+       x2="118.7"
+       y2="30.534513" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3805"
+       id="linearGradient3811"
+       x1="57.842336"
+       y1="48.798807"
+       x2="72.206342"
+       y2="48.798807"
+       gradientUnits="userSpaceOnUse" />
+    <linearGradient
+       inkscape:collect="always"
+       xlink:href="#linearGradient3805"
+       id="linearGradient3819"
+       x1="58.040001"
+       y1="83.000084"
+       x2="71.960167"
+       y2="83.000084"
+       gradientUnits="userSpaceOnUse" />
+  </defs>
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1176"
+     id="namedview22"
+     showgrid="false"
+     inkscape:zoom="3.6"
+     inkscape:cx="60.941186"
+     inkscape:cy="98.385892"
+     inkscape:window-x="0"
+     inkscape:window-y="24"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg2" />
+  <path
+     fill="#c6c6c6"
+     d=" M 27.64 22.78 C 43.74 11.36 65.26 8.46 84.02 14.31 C 98.03 18.53 111.02 27.95 117.18 41.51 C 126.19 60.23 118.29 84.06 101.48 95.55 C 97.45 98.54 92.91 100.73 88.35 102.77 C 88.14 107.99 88.76 113.45 86.50 118.33 C 80.46 115.90 76.13 110.76 70.41 107.83 C 62.51 106.56 54.28 107.18 46.58 104.67 C 34.37 101.14 22.66 94.09 15.66 83.24 C 8.87 73.38 6.41 60.68 9.17 49.02 C 11.84 38.40 18.67 28.99 27.64 22.78 Z"
+     id="path6"
+     style="fill:#606060;fill-opacity:1" />
+  <path
+     style="fill:url(#linearGradient3802);fill-opacity:1"
+     d="M 80.9375,15.03125 C 66.292503,14.983928 51.629297,18.840625 39.09375,26.5 26.44375,34.125 16.1625,46.35 12.8125,60.9375 l -0.40625,0 c -1.05,6.325 -1.54375,12.8125 -0.21875,19.125 1.8875,8.7 5.95,16.73125 11.5,23.65625 8.65,9.9125 20.18125,17.14375 32.78125,20.90625 9.9625,3.3125 20.53125,3.6375 30.90625,4.125 7.7375,4.175 14.38125,10.225 21.65625,15.1875 0.2375,-6.725 -0.56875,-13.4625 -0.21875,-20.1875 2.3125,-1.5375 4.91875,-2.5625 7.40625,-3.75 8.175,-3.7625 15.25,-9.55 21.25,-16.1875 5.575,-6.9625 9.69375,-15.04375 11.59375,-23.78125 1.275,-6.35 0.80625,-12.81875 -0.21875,-19.15625 l 0.0174,-0.03472 C 144.54861,41.752778 128.0875,27.4125 110.3125,20.625 100.98398,16.933203 90.957761,15.063628 80.9375,15.03125 z m 0.15625,16.65625 c 4.188721,-0.13501 8.539062,2.65625 9.03125,7.03125 0.6875,7.45 -1.4875,14.79375 -2.3125,22.15625 -0.9375,6.3875 -1.8875,12.75625 -2.75,19.15625 -1.15,3.3625 0.2875,10.9125 -4.875,10.25 -2.025,-2.9875 -1.975,-6.825 -2.625,-10.25 -0.825,-6.2625 -1.775,-12.46875 -2.6875,-18.71875 -0.8625,-7.075 -2.8125,-14.10625 -2.5625,-21.28125 -0.0125,-3.9625 3.05,-7.56875 7,-8.09375 0.579688,-0.142187 1.182861,-0.230713 1.78125,-0.25 z m -0.71875,63.25 c 0.297791,-0.01522 0.589844,-0.025 0.90625,0 5.0375,-0.3375 7.68125,4.6 8.65625,8.8125 -0.975,4.225 -3.64375,9.1875 -8.71875,8.8125 -5.0375,0.3125 -7.81875,-4.575 -8.65625,-8.875 0.9375,-3.949219 3.345642,-8.521713 7.8125,-8.75 z"
+     transform="scale(0.8,0.8)"
+     id="path8"
+     inkscape:connector-curvature="0"
+     sodipodi:nodetypes="scccccccccccccccssccccccccssccccs" />
+  <path
+     fill="#636363"
+     d=" M 63.44 25.54 C 67.15 24.63 71.65 26.98 72.10 30.98 C 72.65 36.94 70.92 42.80 70.26 48.69 C 69.51 53.80 68.73 58.90 68.04 64.02 C 67.12 66.71 68.28 72.75 64.15 72.22 C 62.53 69.83 62.57 66.77 62.05 64.03 C 61.39 59.02 60.63 54.04 59.90 49.04 C 59.21 43.38 57.66 37.77 57.86 32.03 C 57.85 28.86 60.28 25.96 63.44 25.54 Z"
+     id="path10"
+     style="fill:url(#linearGradient3811);fill-opacity:1" />
+  <path
+     d="m 58.04,82.96 c 0.05,-3.453333 2.94,-6.978889 6.99,-7.02 4.113333,0.0078 6.927778,3.672222 6.93,7.07 0.02556,3.546667 -2.836667,7.016667 -6.98,7.05 -4.057778,0.02778 -6.936667,-3.493333 -6.94,-7.1 z"
+     id="path18"
+     inkscape:connector-curvature="0"
+     style="fill:url(#linearGradient3819);fill-opacity:1.0"
+     sodipodi:nodetypes="ccccc" />
+</svg>
diff --git a/data/dialcentral.colors b/data/dialcentral.colors
new file mode 100644 (file)
index 0000000..c6b9fa9
--- /dev/null
@@ -0,0 +1,3 @@
+Your icon's dominant color is #72ab40
+A suggested disabled color is #d1ffa9
+A suggested pressed color is #57743e
diff --git a/data/dialcentral.png b/data/dialcentral.png
new file mode 100644 (file)
index 0000000..5f8b811
Binary files /dev/null and b/data/dialcentral.png differ
diff --git a/data/dialcentral.svg b/data/dialcentral.svg
new file mode 100644 (file)
index 0000000..e0d45a8
--- /dev/null
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="80" width="80">
+ <defs>
+  <linearGradient id="hicg_overlay_grad" gradientUnits="userSpaceOnUse" x1="39.9995" y1="5.1816" x2="39.9995" y2="58.8019">
+   <stop offset="0" style="stop-color:#FFFFFF"/>
+   <stop offset="1" style="stop-color:#000000"/>
+  </linearGradient>
+  <filter id="hicg_drop_shadow">
+    <feOffset in="SourceAlpha" dx="0" dy="4"/>
+    <feGaussianBlur stdDeviation="4"/>
+       <feColorMatrix type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 0.5 0" result="shadow"/>
+    <feBlend in="SourceGraphic" in2="shadow" mode="normal"/>
+  </filter>
+ </defs>
+ <g>
+  <path id="hicg_background" fill="#72ab40" d="M79,40c0,28.893-10.105,39-39,39S1,68.893,1,40C1,11.106,11.105,1,40,1S79,11.106,79,40z"/>
+  <path id="hicg_highlight" fill="#fff" opacity="0.25" d="M39.999,1C11.105,1,1,11.106,1,40c0,28.893,10.105,39,38.999,39   C68.896,79,79,68.893,79,40C79,11.106,68.896,1,39.999,1z M39.999,78.025C11.57,78.025,1.976,68.43,1.976,40   c0-28.429,9.595-38.024,38.023-38.024c28.43,0,38.024,9.596,38.024,38.024C78.023,68.43,68.429,78.025,39.999,78.025z"/>
+  <path id="hicg_overlay" opacity="0.4" fill="url(#hicg_overlay_grad)" d="M78.977,40c0,28.893-10.1,39-38.977,39S1.023,68.893,1.023,40c0-28.894,10.1-39,38.977-39S78.977,11.106,78.977,40z"/>
+ </g>
+<g xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" transform="translate(12, 12) scale(0.4375)" filter="url(#hicg_drop_shadow)">
+  <metadata xmlns="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" id="metadata26">
+    <rdf:RDF>
+      <cc:Work rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+        <dc:title/>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:xlink="http://www.w3.org/1999/xlink" id="defs24">
+    <linearGradient id="linearGradient3805">
+      <stop style="stop-color:#acdd6e;stop-opacity:1;" offset="0" id="stop3807"/>
+      <stop style="stop-color:#2f720c;stop-opacity:1;" offset="1" id="stop3809"/>
+    </linearGradient>
+    <linearGradient id="linearGradient3787">
+      <stop style="stop-color:#fbfbfb;stop-opacity:1;" offset="0" id="stop3789"/>
+      <stop style="stop-color:#fbfbfb;stop-opacity:0;" offset="1" id="stop3791"/>
+    </linearGradient>
+    <linearGradient id="linearGradient3779">
+      <stop style="stop-color:#fbfbfb;stop-opacity:1;" offset="0" id="stop3781"/>
+      <stop style="stop-color:#f6f6f6;stop-opacity:1;" offset="1" id="stop3783"/>
+    </linearGradient>
+    <linearGradient id="linearGradient3771">
+      <stop style="stop-color:#ffffff;stop-opacity:1;" offset="0" id="stop3773"/>
+      <stop style="stop-color:#f1f1f1;stop-opacity:1;" offset="1" id="stop3775"/>
+    </linearGradient>
+    <linearGradient inkscape:collect="always" xlink:href="#linearGradient3771" id="linearGradient3802" gradientUnits="userSpaceOnUse" gradientTransform="scale(1.25,1.25)" x1="10.26" y1="30.534513" x2="118.7" y2="30.534513"/>
+    <linearGradient inkscape:collect="always" xlink:href="#linearGradient3805" id="linearGradient3811" x1="57.842336" y1="48.798807" x2="72.206342" y2="48.798807" gradientUnits="userSpaceOnUse"/>
+    <linearGradient inkscape:collect="always" xlink:href="#linearGradient3805" id="linearGradient3819" x1="58.040001" y1="83.000084" x2="71.960167" y2="83.000084" gradientUnits="userSpaceOnUse"/>
+  </defs>
+  <sodipodi:namedview xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1920" inkscape:window-height="1176" id="namedview22" showgrid="false" inkscape:zoom="3.6" inkscape:cx="60.941186" inkscape:cy="98.385892" inkscape:window-x="0" inkscape:window-y="24" inkscape:window-maximized="1" inkscape:current-layer="svg2"/>
+  <path xmlns="http://www.w3.org/2000/svg" fill="#c6c6c6" d=" M 27.64 22.78 C 43.74 11.36 65.26 8.46 84.02 14.31 C 98.03 18.53 111.02 27.95 117.18 41.51 C 126.19 60.23 118.29 84.06 101.48 95.55 C 97.45 98.54 92.91 100.73 88.35 102.77 C 88.14 107.99 88.76 113.45 86.50 118.33 C 80.46 115.90 76.13 110.76 70.41 107.83 C 62.51 106.56 54.28 107.18 46.58 104.67 C 34.37 101.14 22.66 94.09 15.66 83.24 C 8.87 73.38 6.41 60.68 9.17 49.02 C 11.84 38.40 18.67 28.99 27.64 22.78 Z" id="path6" style="fill:#606060;fill-opacity:1"/>
+  <path xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" style="fill:url(#linearGradient3802);fill-opacity:1" d="M 80.9375,15.03125 C 66.292503,14.983928 51.629297,18.840625 39.09375,26.5 26.44375,34.125 16.1625,46.35 12.8125,60.9375 l -0.40625,0 c -1.05,6.325 -1.54375,12.8125 -0.21875,19.125 1.8875,8.7 5.95,16.73125 11.5,23.65625 8.65,9.9125 20.18125,17.14375 32.78125,20.90625 9.9625,3.3125 20.53125,3.6375 30.90625,4.125 7.7375,4.175 14.38125,10.225 21.65625,15.1875 0.2375,-6.725 -0.56875,-13.4625 -0.21875,-20.1875 2.3125,-1.5375 4.91875,-2.5625 7.40625,-3.75 8.175,-3.7625 15.25,-9.55 21.25,-16.1875 5.575,-6.9625 9.69375,-15.04375 11.59375,-23.78125 1.275,-6.35 0.80625,-12.81875 -0.21875,-19.15625 l 0.0174,-0.03472 C 144.54861,41.752778 128.0875,27.4125 110.3125,20.625 100.98398,16.933203 90.957761,15.063628 80.9375,15.03125 z m 0.15625,16.65625 c 4.188721,-0.13501 8.539062,2.65625 9.03125,7.03125 0.6875,7.45 -1.4875,14.79375 -2.3125,22.15625 -0.9375,6.3875 -1.8875,12.75625 -2.75,19.15625 -1.15,3.3625 0.2875,10.9125 -4.875,10.25 -2.025,-2.9875 -1.975,-6.825 -2.625,-10.25 -0.825,-6.2625 -1.775,-12.46875 -2.6875,-18.71875 -0.8625,-7.075 -2.8125,-14.10625 -2.5625,-21.28125 -0.0125,-3.9625 3.05,-7.56875 7,-8.09375 0.579688,-0.142187 1.182861,-0.230713 1.78125,-0.25 z m -0.71875,63.25 c 0.297791,-0.01522 0.589844,-0.025 0.90625,0 5.0375,-0.3375 7.68125,4.6 8.65625,8.8125 -0.975,4.225 -3.64375,9.1875 -8.71875,8.8125 -5.0375,0.3125 -7.81875,-4.575 -8.65625,-8.875 0.9375,-3.949219 3.345642,-8.521713 7.8125,-8.75 z" transform="scale(0.8,0.8)" id="path8" inkscape:connector-curvature="0" sodipodi:nodetypes="scccccccccccccccssccccccccssccccs"/>
+  <path xmlns="http://www.w3.org/2000/svg" fill="#636363" d=" M 63.44 25.54 C 67.15 24.63 71.65 26.98 72.10 30.98 C 72.65 36.94 70.92 42.80 70.26 48.69 C 69.51 53.80 68.73 58.90 68.04 64.02 C 67.12 66.71 68.28 72.75 64.15 72.22 C 62.53 69.83 62.57 66.77 62.05 64.03 C 61.39 59.02 60.63 54.04 59.90 49.04 C 59.21 43.38 57.66 37.77 57.86 32.03 C 57.85 28.86 60.28 25.96 63.44 25.54 Z" id="path10" style="fill:url(#linearGradient3811);fill-opacity:1"/>
+  <path xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" d="m 58.04,82.96 c 0.05,-3.453333 2.94,-6.978889 6.99,-7.02 4.113333,0.0078 6.927778,3.672222 6.93,7.07 0.02556,3.546667 -2.836667,7.016667 -6.98,7.05 -4.057778,0.02778 -6.936667,-3.493333 -6.94,-7.1 z" id="path18" inkscape:connector-curvature="0" style="fill:url(#linearGradient3819);fill-opacity:1.0" sodipodi:nodetypes="ccccc"/>
+</g></svg>
diff --git a/data/dialpad.png b/data/dialpad.png
deleted file mode 100644 (file)
index b54013b..0000000
Binary files a/data/dialpad.png and /dev/null differ
diff --git a/data/history.png b/data/history.png
deleted file mode 100644 (file)
index 887989a..0000000
Binary files a/data/history.png and /dev/null differ
diff --git a/data/messages.png b/data/messages.png
deleted file mode 100644 (file)
index e117918..0000000
Binary files a/data/messages.png and /dev/null differ
diff --git a/data/missed.png b/data/missed.png
deleted file mode 100644 (file)
index 34f71c4..0000000
Binary files a/data/missed.png and /dev/null differ
diff --git a/data/placed.png b/data/placed.png
deleted file mode 100644 (file)
index 329771d..0000000
Binary files a/data/placed.png and /dev/null differ
diff --git a/data/received.png b/data/received.png
deleted file mode 100644 (file)
index 2b45263..0000000
Binary files a/data/received.png and /dev/null differ
diff --git a/data/template.desktop b/data/template.desktop
new file mode 100644 (file)
index 0000000..3b446d7
--- /dev/null
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Encoding=UTF-8
+Version=1.0
+Type=Application
+Name=DialCentral
+Exec=/usr/bin/run-standalone.sh /opt/dialcentral/bin/dialcentral.py
+Icon=dialcentral
+Categories=Network;InstantMessaging;Qt;
diff --git a/dialcentral/__init__.py b/dialcentral/__init__.py
new file mode 100644 (file)
index 0000000..4265cc3
--- /dev/null
@@ -0,0 +1 @@
+#!/usr/bin/env python
diff --git a/dialcentral/alarm_handler.py b/dialcentral/alarm_handler.py
new file mode 100644 (file)
index 0000000..a79f992
--- /dev/null
@@ -0,0 +1,460 @@
+#!/usr/bin/env python
+
+import os
+import time
+import datetime
+import ConfigParser
+import logging
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+import dbus
+
+
+_FREMANTLE_ALARM = "Fremantle"
+_DIABLO_ALARM = "Diablo"
+_NO_ALARM = "None"
+
+
+try:
+       import alarm
+       ALARM_TYPE = _FREMANTLE_ALARM
+except (ImportError, OSError):
+       try:
+               import osso.alarmd as alarmd
+               ALARM_TYPE = _DIABLO_ALARM
+       except (ImportError, OSError):
+               ALARM_TYPE = _NO_ALARM
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+def _get_start_time(recurrence):
+       now = datetime.datetime.now()
+       startTimeMinute = now.minute + max(recurrence, 5) # being safe
+       startTimeHour = now.hour + int(startTimeMinute / 60)
+       startTimeMinute = startTimeMinute % 59
+       now.replace(minute=startTimeMinute)
+       timestamp = int(time.mktime(now.timetuple()))
+       return timestamp
+
+
+def _create_recurrence_mask(recurrence, base):
+       """
+       >>> bin(_create_recurrence_mask(60, 60))
+       '0b1'
+       >>> bin(_create_recurrence_mask(30, 60))
+       '0b1000000000000000000000000000001'
+       >>> bin(_create_recurrence_mask(2, 60))
+       '0b10101010101010101010101010101010101010101010101010101010101'
+       >>> bin(_create_recurrence_mask(1, 60))
+       '0b111111111111111111111111111111111111111111111111111111111111'
+       """
+       mask = 0
+       for i in xrange(base / recurrence):
+               mask |= 1 << (recurrence * i)
+       return mask
+
+
+def _unpack_minutes(recurrence):
+       """
+       >>> _unpack_minutes(0)
+       (0, 0, 0)
+       >>> _unpack_minutes(1)
+       (0, 0, 1)
+       >>> _unpack_minutes(59)
+       (0, 0, 59)
+       >>> _unpack_minutes(60)
+       (0, 1, 0)
+       >>> _unpack_minutes(129)
+       (0, 2, 9)
+       >>> _unpack_minutes(5 * 60 * 24 + 3 * 60 + 2)
+       (5, 3, 2)
+       >>> _unpack_minutes(12 * 60 * 24 + 3 * 60 + 2)
+       (5, 3, 2)
+       """
+       minutesInAnHour = 60
+       minutesInDay = 24 * minutesInAnHour
+       minutesInAWeek = minutesInDay * 7
+
+       days = recurrence / minutesInDay
+       daysOfWeek = days % 7
+       recurrence -= days * minutesInDay
+       hours = recurrence / minutesInAnHour
+       recurrence -= hours * minutesInAnHour
+       mins = recurrence % minutesInAnHour
+       recurrence -= mins
+       assert recurrence == 0, "Recurrence %d" % recurrence
+       return daysOfWeek, hours, mins
+
+
+class _FremantleAlarmHandler(object):
+
+       _INVALID_COOKIE = -1
+       _REPEAT_FOREVER = -1
+       _TITLE = "Dialcentral Notifications"
+       _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py"))
+
+       def __init__(self):
+               self._recurrence = 5
+
+               self._alarmCookie = self._INVALID_COOKIE
+               self._launcher = self._LAUNCHER
+
+       def load_settings(self, config, sectionName):
+               try:
+                       self._recurrence = config.getint(sectionName, "recurrence")
+                       self._alarmCookie = config.getint(sectionName, "alarmCookie")
+                       launcher = config.get(sectionName, "notifier")
+                       if launcher:
+                               self._launcher = launcher
+               except ConfigParser.NoOptionError:
+                       pass
+               except ConfigParser.NoSectionError:
+                       pass
+
+       def save_settings(self, config, sectionName):
+               try:
+                       config.set(sectionName, "recurrence", str(self._recurrence))
+                       config.set(sectionName, "alarmCookie", str(self._alarmCookie))
+                       launcher = self._launcher if self._launcher != self._LAUNCHER else ""
+                       config.set(sectionName, "notifier", launcher)
+               except ConfigParser.NoOptionError:
+                       pass
+               except ConfigParser.NoSectionError:
+                       pass
+
+       def apply_settings(self, enabled, recurrence):
+               if recurrence != self._recurrence or enabled != self.isEnabled:
+                       if self.isEnabled:
+                               self._clear_alarm()
+                       if enabled:
+                               self._set_alarm(recurrence)
+               self._recurrence = int(recurrence)
+
+       @property
+       def recurrence(self):
+               return self._recurrence
+
+       @property
+       def isEnabled(self):
+               return self._alarmCookie != self._INVALID_COOKIE
+
+       def _set_alarm(self, recurrenceMins):
+               assert 1 <= recurrenceMins, "Notifications set to occur too frequently: %d" % recurrenceMins
+               alarmTime = _get_start_time(recurrenceMins)
+
+               event = alarm.Event()
+               event.appid = self._TITLE
+               event.alarm_time = alarmTime
+               event.recurrences_left = self._REPEAT_FOREVER
+
+               action = event.add_actions(1)[0]
+               action.flags |= alarm.ACTION_TYPE_EXEC | alarm.ACTION_WHEN_TRIGGERED
+               action.command = self._launcher
+
+               recurrence = event.add_recurrences(1)[0]
+               recurrence.mask_min |= _create_recurrence_mask(recurrenceMins, 60)
+               recurrence.mask_hour |= alarm.RECUR_HOUR_DONTCARE
+               recurrence.mask_mday |= alarm.RECUR_MDAY_DONTCARE
+               recurrence.mask_wday |= alarm.RECUR_WDAY_DONTCARE
+               recurrence.mask_mon |= alarm.RECUR_MON_DONTCARE
+               recurrence.special |= alarm.RECUR_SPECIAL_NONE
+
+               assert event.is_sane()
+               self._alarmCookie = alarm.add_event(event)
+
+       def _clear_alarm(self):
+               if self._alarmCookie == self._INVALID_COOKIE:
+                       return
+               alarm.delete_event(self._alarmCookie)
+               self._alarmCookie = self._INVALID_COOKIE
+
+
+class _DiabloAlarmHandler(object):
+
+       _INVALID_COOKIE = -1
+       _TITLE = "Dialcentral Notifications"
+       _LAUNCHER = os.path.abspath(os.path.join(os.path.dirname(__file__), "alarm_notify.py"))
+       _REPEAT_FOREVER = -1
+
+       def __init__(self):
+               self._recurrence = 5
+
+               bus = dbus.SystemBus()
+               self._alarmdDBus = bus.get_object("com.nokia.alarmd", "/com/nokia/alarmd");
+               self._alarmCookie = self._INVALID_COOKIE
+               self._launcher = self._LAUNCHER
+
+       def load_settings(self, config, sectionName):
+               try:
+                       self._recurrence = config.getint(sectionName, "recurrence")
+                       self._alarmCookie = config.getint(sectionName, "alarmCookie")
+                       launcher = config.get(sectionName, "notifier")
+                       if launcher:
+                               self._launcher = launcher
+               except ConfigParser.NoOptionError:
+                       pass
+               except ConfigParser.NoSectionError:
+                       pass
+
+       def save_settings(self, config, sectionName):
+               config.set(sectionName, "recurrence", str(self._recurrence))
+               config.set(sectionName, "alarmCookie", str(self._alarmCookie))
+               launcher = self._launcher if self._launcher != self._LAUNCHER else ""
+               config.set(sectionName, "notifier", launcher)
+
+       def apply_settings(self, enabled, recurrence):
+               if recurrence != self._recurrence or enabled != self.isEnabled:
+                       if self.isEnabled:
+                               self._clear_alarm()
+                       if enabled:
+                               self._set_alarm(recurrence)
+               self._recurrence = int(recurrence)
+
+       @property
+       def recurrence(self):
+               return self._recurrence
+
+       @property
+       def isEnabled(self):
+               return self._alarmCookie != self._INVALID_COOKIE
+
+       def _set_alarm(self, recurrence):
+               assert 1 <= recurrence, "Notifications set to occur too frequently: %d" % recurrence
+               alarmTime = _get_start_time(recurrence)
+
+               #Setup the alarm arguments so that they can be passed to the D-Bus add_event method
+               _DEFAULT_FLAGS = (
+                       alarmd.ALARM_EVENT_NO_DIALOG |
+                       alarmd.ALARM_EVENT_NO_SNOOZE |
+                       alarmd.ALARM_EVENT_CONNECTED
+               )
+               action = []
+               action.extend(['flags', _DEFAULT_FLAGS])
+               action.extend(['title', self._TITLE])
+               action.extend(['path', self._launcher])
+               action.extend([
+                       'arguments',
+                       dbus.Array(
+                               [alarmTime, int(27)],
+                               signature=dbus.Signature('v')
+                       )
+               ])  #int(27) used in place of alarm_index
+
+               event = []
+               event.extend([dbus.ObjectPath('/AlarmdEventRecurring'), dbus.UInt32(4)])
+               event.extend(['action', dbus.ObjectPath('/AlarmdActionExec')])  #use AlarmdActionExec instead of AlarmdActionDbus
+               event.append(dbus.UInt32(len(action) / 2))
+               event.extend(action)
+               event.extend(['time', dbus.Int64(alarmTime)])
+               event.extend(['recurr_interval', dbus.UInt32(recurrence)])
+               event.extend(['recurr_count', dbus.Int32(self._REPEAT_FOREVER)])
+
+               self._alarmCookie = self._alarmdDBus.add_event(*event);
+
+       def _clear_alarm(self):
+               if self._alarmCookie == self._INVALID_COOKIE:
+                       return
+               deleteResult = self._alarmdDBus.del_event(dbus.Int32(self._alarmCookie))
+               self._alarmCookie = self._INVALID_COOKIE
+               assert deleteResult != -1, "Deleting of alarm event failed"
+
+
+class _ApplicationAlarmHandler(object):
+
+       _REPEAT_FOREVER = -1
+       _MIN_TO_MS_FACTORY = 1000 * 60
+
+       def __init__(self):
+               self._timer = QtCore.QTimer()
+               self._timer.setSingleShot(False)
+               self._timer.setInterval(5 * self._MIN_TO_MS_FACTORY)
+
+       def load_settings(self, config, sectionName):
+               try:
+                       self._timer.setInterval(config.getint(sectionName, "recurrence") * self._MIN_TO_MS_FACTORY)
+               except ConfigParser.NoOptionError:
+                       pass
+               except ConfigParser.NoSectionError:
+                       pass
+               self._timer.start()
+
+       def save_settings(self, config, sectionName):
+               config.set(sectionName, "recurrence", str(self.recurrence))
+
+       def apply_settings(self, enabled, recurrence):
+               self._timer.setInterval(recurrence * self._MIN_TO_MS_FACTORY)
+               if enabled:
+                       self._timer.start()
+               else:
+                       self._timer.stop()
+
+       @property
+       def notifySignal(self):
+               return self._timer.timeout
+
+       @property
+       def recurrence(self):
+               return int(self._timer.interval() / self._MIN_TO_MS_FACTORY)
+
+       @property
+       def isEnabled(self):
+               return self._timer.isActive()
+
+
+class _NoneAlarmHandler(object):
+
+       def __init__(self):
+               self._enabled = False
+               self._recurrence = 5
+
+       def load_settings(self, config, sectionName):
+               try:
+                       self._recurrence = config.getint(sectionName, "recurrence")
+                       self._enabled = True
+               except ConfigParser.NoOptionError:
+                       pass
+               except ConfigParser.NoSectionError:
+                       pass
+
+       def save_settings(self, config, sectionName):
+               config.set(sectionName, "recurrence", str(self.recurrence))
+
+       def apply_settings(self, enabled, recurrence):
+               self._enabled = enabled
+
+       @property
+       def recurrence(self):
+               return self._recurrence
+
+       @property
+       def isEnabled(self):
+               return self._enabled
+
+
+_BACKGROUND_ALARM_FACTORY = {
+       _FREMANTLE_ALARM: _FremantleAlarmHandler,
+       _DIABLO_ALARM: _DiabloAlarmHandler,
+       _NO_ALARM: None,
+}[ALARM_TYPE]
+
+
+class AlarmHandler(object):
+
+       ALARM_NONE = "No Alert"
+       ALARM_BACKGROUND = "Background Alert"
+       ALARM_APPLICATION = "Application Alert"
+       ALARM_TYPES = [ALARM_NONE, ALARM_BACKGROUND, ALARM_APPLICATION]
+
+       ALARM_FACTORY = {
+               ALARM_NONE: _NoneAlarmHandler,
+               ALARM_BACKGROUND: _BACKGROUND_ALARM_FACTORY,
+               ALARM_APPLICATION: _ApplicationAlarmHandler,
+       }
+
+       def __init__(self):
+               self._alarms = {self.ALARM_NONE: _NoneAlarmHandler()}
+               self._currentAlarmType = self.ALARM_NONE
+
+       def load_settings(self, config, sectionName):
+               try:
+                       self._currentAlarmType = config.get(sectionName, "alarm")
+               except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
+                       _moduleLogger.exception("Falling back to old style")
+                       self._currentAlarmType = self.ALARM_BACKGROUND
+               if self._currentAlarmType not in self.ALARM_TYPES:
+                       self._currentAlarmType = self.ALARM_NONE
+
+               self._init_alarm(self._currentAlarmType)
+               if self._currentAlarmType in self._alarms:
+                       self._alarms[self._currentAlarmType].load_settings(config, sectionName)
+                       if not self._alarms[self._currentAlarmType].isEnabled:
+                               _moduleLogger.info("Config file lied, not actually enabled")
+                               self._currentAlarmType = self.ALARM_NONE
+               else:
+                       _moduleLogger.info("Background alerts not supported")
+                       self._currentAlarmType = self.ALARM_NONE
+
+       def save_settings(self, config, sectionName):
+               config.set(sectionName, "alarm", self._currentAlarmType)
+               self._alarms[self._currentAlarmType].save_settings(config, sectionName)
+
+       def apply_settings(self, t, recurrence):
+               self._init_alarm(t)
+               newHandler = self._alarms[t]
+               oldHandler = self._alarms[self._currentAlarmType]
+               if newHandler != oldHandler:
+                       oldHandler.apply_settings(False, 0)
+               newHandler.apply_settings(True, recurrence)
+               self._currentAlarmType = t
+
+       @property
+       def alarmType(self):
+               return self._currentAlarmType
+
+       @property
+       def backgroundNotificationsSupported(self):
+               return self.ALARM_FACTORY[self.ALARM_BACKGROUND] is not None
+
+       @property
+       def applicationNotifySignal(self):
+               self._init_alarm(self.ALARM_APPLICATION)
+               return self._alarms[self.ALARM_APPLICATION].notifySignal
+
+       @property
+       def recurrence(self):
+               return self._alarms[self._currentAlarmType].recurrence
+
+       @property
+       def isEnabled(self):
+               return self._currentAlarmType != self.ALARM_NONE
+
+       def _init_alarm(self, t):
+               if t not in self._alarms and self.ALARM_FACTORY[t] is not None:
+                       self._alarms[t] = self.ALARM_FACTORY[t]()
+
+
+def main():
+       logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s'
+       logging.basicConfig(level=logging.DEBUG, format=logFormat)
+       import constants
+       try:
+               import optparse
+       except ImportError:
+               return
+
+       parser = optparse.OptionParser()
+       parser.add_option("-x", "--display", action="store_true", dest="display", help="Display data")
+       parser.add_option("-e", "--enable", action="store_true", dest="enabled", help="Whether the alarm should be enabled or not", default=False)
+       parser.add_option("-d", "--disable", action="store_false", dest="enabled", help="Whether the alarm should be enabled or not", default=False)
+       parser.add_option("-r", "--recurrence", action="store", type="int", dest="recurrence", help="How often the alarm occurs", default=5)
+       (commandOptions, commandArgs) = parser.parse_args()
+
+       alarmHandler = AlarmHandler()
+       config = ConfigParser.SafeConfigParser()
+       config.read(constants._user_settings_)
+       alarmHandler.load_settings(config, "alarm")
+
+       if commandOptions.display:
+               print "Alarm (%s) is %s for every %d minutes" % (
+                       alarmHandler._alarmCookie,
+                       "enabled" if alarmHandler.isEnabled else "disabled",
+                       alarmHandler.recurrence,
+               )
+       else:
+               isEnabled = commandOptions.enabled
+               recurrence = commandOptions.recurrence
+               alarmHandler.apply_settings(isEnabled, recurrence)
+
+               alarmHandler.save_settings(config, "alarm")
+               configFile = open(constants._user_settings_, "wb")
+               try:
+                       config.write(configFile)
+               finally:
+                       configFile.close()
+
+
+if __name__ == "__main__":
+       main()
diff --git a/dialcentral/alarm_notify.py b/dialcentral/alarm_notify.py
new file mode 100755 (executable)
index 0000000..bc6240e
--- /dev/null
@@ -0,0 +1,182 @@
+#!/usr/bin/env python
+
+import os
+import filecmp
+import ConfigParser
+import pprint
+import logging
+import logging.handlers
+
+import constants
+from backends.gvoice import gvoice
+
+
+def get_missed(backend):
+       missedPage = backend._browser.download(backend._XML_MISSED_URL)
+       missedJson = backend._grab_json(missedPage)
+       return missedJson
+
+
+def get_voicemail(backend):
+       voicemailPage = backend._browser.download(backend._XML_VOICEMAIL_URL)
+       voicemailJson = backend._grab_json(voicemailPage)
+       return voicemailJson
+
+
+def get_sms(backend):
+       smsPage = backend._browser.download(backend._XML_SMS_URL)
+       smsJson = backend._grab_json(smsPage)
+       return smsJson
+
+
+def remove_reltime(data):
+       for messageData in data["messages"].itervalues():
+               for badPart in [
+                       "relTime",
+                       "relativeStartTime",
+                       "time",
+                       "star",
+                       "isArchived",
+                       "isRead",
+                       "isSpam",
+                       "isTrash",
+                       "labels",
+               ]:
+                       if badPart in messageData:
+                               del messageData[badPart]
+       for globalBad in ["unreadCounts", "totalSize", "resultsPerPage"]:
+               if globalBad in data:
+                       del data[globalBad]
+
+
+def is_type_changed(backend, type, get_material):
+       jsonMaterial = get_material(backend)
+       unreadCount = jsonMaterial["unreadCounts"][type]
+
+       previousSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.old.json" % type)
+       currentSnapshotPath = os.path.join(constants._data_path_, "snapshot_%s.json" % type)
+
+       try:
+               os.remove(previousSnapshotPath)
+       except OSError, e:
+               # check if failed purely because the old file didn't exist, which is fine
+               if e.errno != 2:
+                       raise
+       try:
+               os.rename(currentSnapshotPath, previousSnapshotPath)
+               previousExists = True
+       except OSError, e:
+               # check if failed purely because the new old file didn't exist, which is fine
+               if e.errno != 2:
+                       raise
+               previousExists = False
+
+       remove_reltime(jsonMaterial)
+       textMaterial = pprint.pformat(jsonMaterial)
+       currentSnapshot = file(currentSnapshotPath, "w")
+       try:
+               currentSnapshot.write(textMaterial)
+       finally:
+               currentSnapshot.close()
+
+       if unreadCount == 0 or not previousExists:
+               return False
+
+       seemEqual = filecmp.cmp(previousSnapshotPath, currentSnapshotPath)
+       return not seemEqual
+
+
+def create_backend(config):
+       gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
+       backend = gvoice.GVoiceBackend(gvCookiePath)
+
+       loggedIn = False
+
+       if not loggedIn:
+               loggedIn = backend.refresh_account_info() is not None
+
+       if not loggedIn:
+               import base64
+               try:
+                       blobs = (
+                               config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
+                               for i in xrange(2)
+                       )
+                       creds = (
+                               base64.b64decode(blob)
+                               for blob in blobs
+                       )
+                       username, password = tuple(creds)
+                       loggedIn = backend.login(username, password) is not None
+               except ConfigParser.NoOptionError, e:
+                       pass
+               except ConfigParser.NoSectionError, e:
+                       pass
+
+       assert loggedIn
+       return backend
+
+
+def is_changed(config, backend):
+       try:
+               notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed")
+               notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail")
+               notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms")
+       except ConfigParser.NoOptionError, e:
+               notifyOnMissed = False
+               notifyOnVoicemail = False
+               notifyOnSms = False
+       except ConfigParser.NoSectionError, e:
+               notifyOnMissed = False
+               notifyOnVoicemail = False
+               notifyOnSms = False
+       logging.debug(
+               "Missed: %s, Voicemail: %s, SMS: %s" % (notifyOnMissed, notifyOnVoicemail, notifyOnSms)
+       )
+
+       notifySources = []
+       if notifyOnMissed:
+               notifySources.append(("missed", get_missed))
+       if notifyOnVoicemail:
+               notifySources.append(("voicemail", get_voicemail))
+       if notifyOnSms:
+               notifySources.append(("sms", get_sms))
+
+       notifyUser = False
+       for type, get_material in notifySources:
+               if is_type_changed(backend, type, get_material):
+                       notifyUser = True
+       return notifyUser
+
+
+def notify_on_change():
+       config = ConfigParser.SafeConfigParser()
+       config.read(constants._user_settings_)
+       backend = create_backend(config)
+       notifyUser = is_changed(config, backend)
+
+       if notifyUser:
+               logging.info("Changed")
+               import led_handler
+               led = led_handler.LedHandler()
+               led.on()
+       else:
+               logging.info("No Change")
+
+
+if __name__ == "__main__":
+       logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s'
+       logging.basicConfig(level=logging.DEBUG, format=logFormat)
+       rotating = logging.handlers.RotatingFileHandler(constants._notifier_logpath_, maxBytes=512*1024, backupCount=1)
+       rotating.setFormatter(logging.Formatter(logFormat))
+       root = logging.getLogger()
+       root.addHandler(rotating)
+       logging.info("Notifier %s-%s" % (constants.__version__, constants.__build__))
+       logging.info("OS: %s" % (os.uname()[0], ))
+       logging.info("Kernel: %s (%s) for %s" % os.uname()[2:])
+       logging.info("Hostname: %s" % os.uname()[1])
+       try:
+               notify_on_change()
+       except:
+               logging.exception("Error")
+               raise
diff --git a/dialcentral/backends/__init__.py b/dialcentral/backends/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/dialcentral/backends/file_backend.py b/dialcentral/backends/file_backend.py
new file mode 100644 (file)
index 0000000..9f8927a
--- /dev/null
@@ -0,0 +1,176 @@
+#!/usr/bin/python
+
+"""
+DialCentral - Front end for Google's Grand Central service.
+Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Filesystem backend for contact support
+"""
+
+from __future__ import with_statement
+
+import os
+import csv
+
+
+def try_unicode(s):
+       try:
+               return s.decode("UTF-8")
+       except UnicodeDecodeError:
+               return s
+
+
+class CsvAddressBook(object):
+       """
+       Currently supported file format
+       @li Has the first line as a header
+       @li Escapes with quotes
+       @li Comma as delimiter
+       @li Column 0 is name, column 1 is number
+       """
+
+       def __init__(self, name, csvPath):
+               self._name = name
+               self._csvPath = csvPath
+               self._contacts = {}
+
+       @property
+       def name(self):
+               return self._name
+
+       def update_account(self, force = True):
+               if not force or not self._contacts:
+                       return
+               self._contacts = dict(
+                       self._read_csv(self._csvPath)
+               )
+
+       def get_contacts(self):
+               """
+               @returns Iterable of (contact id, contact name)
+               """
+               if not self._contacts:
+                       self._contacts = dict(
+                               self._read_csv(self._csvPath)
+                       )
+               return self._contacts
+
+       def _read_csv(self, csvPath):
+               try:
+                       f = open(csvPath, "rU")
+                       csvReader = iter(csv.reader(f))
+               except IOError, e:
+                       if e.errno == 2:
+                               return
+                       raise
+
+               header = csvReader.next()
+               nameColumns, nameFallbacks, phoneColumns = self._guess_columns(header)
+
+               yieldCount = 0
+               for row in csvReader:
+                       contactDetails = []
+                       for (phoneType, phoneColumn) in phoneColumns:
+                               try:
+                                       if len(row[phoneColumn]) == 0:
+                                               continue
+                                       contactDetails.append({
+                                               "phoneType": try_unicode(phoneType),
+                                               "phoneNumber": row[phoneColumn],
+                                       })
+                               except IndexError:
+                                       pass
+                       if 0 < len(contactDetails):
+                               nameParts = (row[i].strip() for i in nameColumns)
+                               nameParts = (part for part in nameParts if part)
+                               fullName = " ".join(nameParts).strip()
+                               if not fullName:
+                                       for fallbackColumn in nameFallbacks:
+                                               if row[fallbackColumn].strip():
+                                                       fullName = row[fallbackColumn].strip()
+                                                       break
+                                       else:
+                                               fullName = "Unknown"
+                               fullName = try_unicode(fullName)
+                               yield str(yieldCount), {
+                                       "contactId": "%s-%d" % (self._name, yieldCount),
+                                       "name": fullName,
+                                       "numbers": contactDetails,
+                               }
+                               yieldCount += 1
+
+       @classmethod
+       def _guess_columns(cls, row):
+               firstMiddleLast = [-1, -1, -1]
+               names = []
+               nameFallbacks = []
+               phones = []
+               for i, item in enumerate(row):
+                       lowerItem = item.lower()
+                       if 0 <= lowerItem.find("name"):
+                               names.append((item, i))
+
+                               if 0 <= lowerItem.find("couple"):
+                                       names.insert(0, (item, i))
+
+                               if 0 <= lowerItem.find("first") or 0 <= lowerItem.find("given"):
+                                       firstMiddleLast[0] = i
+                               elif 0 <= lowerItem.find("middle"):
+                                       firstMiddleLast[1] = i
+                               elif 0 <= lowerItem.find("last") or 0 <= lowerItem.find("family"):
+                                       firstMiddleLast[2] = i
+                       elif 0 <= lowerItem.find("phone"):
+                               phones.append((item, i))
+                       elif 0 <= lowerItem.find("mobile"):
+                               phones.append((item, i))
+                       elif 0 <= lowerItem.find("email") or 0 <= lowerItem.find("e-mail"):
+                               nameFallbacks.append(i)
+               if len(names) == 0:
+                       names.append(("Name", 0))
+               if len(phones) == 0:
+                       phones.append(("Phone", 1))
+
+               nameColumns = [i for i in firstMiddleLast if 0 <= i]
+               if len(nameColumns) < 2:
+                       del nameColumns[:]
+                       nameColumns.append(names[0][1])
+
+               return nameColumns, nameFallbacks, phones
+
+
+class FilesystemAddressBookFactory(object):
+
+       FILETYPE_SUPPORT = {
+               "csv": CsvAddressBook,
+       }
+
+       def __init__(self, path):
+               self._path = path
+
+       def get_addressbooks(self):
+               for root, dirs, filenames in os.walk(self._path):
+                       for filename in filenames:
+                               try:
+                                       name, ext = filename.rsplit(".", 1)
+                               except ValueError:
+                                       continue
+
+                               try:
+                                       cls = self.FILETYPE_SUPPORT[ext]
+                               except KeyError:
+                                       continue
+                               yield cls(name, os.path.join(root, filename))
diff --git a/dialcentral/backends/gv_backend.py b/dialcentral/backends/gv_backend.py
new file mode 100644 (file)
index 0000000..17bbc90
--- /dev/null
@@ -0,0 +1,321 @@
+#!/usr/bin/python
+
+"""
+DialCentral - Front end for Google's GoogleVoice service.
+Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Google Voice backend code
+
+Resources
+       http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
+       http://posttopic.com/topic/google-voice-add-on-development
+"""
+
+from __future__ import with_statement
+
+import itertools
+import logging
+
+from gvoice import gvoice
+
+from util import io as io_utils
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class GVDialer(object):
+
+       MESSAGE_TEXTS = "Text"
+       MESSAGE_VOICEMAILS = "Voicemail"
+       MESSAGE_ALL = "All"
+
+       HISTORY_RECEIVED = "Received"
+       HISTORY_MISSED = "Missed"
+       HISTORY_PLACED = "Placed"
+       HISTORY_ALL = "All"
+
+       def __init__(self, cookieFile = None):
+               self._gvoice = gvoice.GVoiceBackend(cookieFile)
+               self._texts = []
+               self._voicemails = []
+               self._received = []
+               self._missed = []
+               self._placed = []
+
+       def is_quick_login_possible(self):
+               """
+               @returns True then refresh_account_info might be enough to login, else full login is required
+               """
+               return self._gvoice.is_quick_login_possible()
+
+       def refresh_account_info(self):
+               return self._gvoice.refresh_account_info()
+
+       def login(self, username, password):
+               """
+               Attempt to login to GoogleVoice
+               @returns Whether login was successful or not
+               """
+               return self._gvoice.login(username, password)
+
+       def logout(self):
+               self._texts = []
+               self._voicemails = []
+               self._received = []
+               self._missed = []
+               self._placed = []
+               return self._gvoice.logout()
+
+       def persist(self):
+               return self._gvoice.persist()
+
+       def is_dnd(self):
+               return self._gvoice.is_dnd()
+
+       def set_dnd(self, doNotDisturb):
+               return self._gvoice.set_dnd(doNotDisturb)
+
+       def call(self, outgoingNumber):
+               """
+               This is the main function responsible for initating the callback
+               """
+               return self._gvoice.call(outgoingNumber)
+
+       def cancel(self, outgoingNumber=None):
+               """
+               Cancels a call matching outgoing and forwarding numbers (if given). 
+               Will raise an error if no matching call is being placed
+               """
+               return self._gvoice.cancel(outgoingNumber)
+
+       def send_sms(self, phoneNumbers, message):
+               self._gvoice.send_sms(phoneNumbers, message)
+
+       def search(self, query):
+               """
+               Search your Google Voice Account history for calls, voicemails, and sms
+               Returns ``Folder`` instance containting matching messages
+               """
+               return self._gvoice.search(query)
+
+       def get_feed(self, feed):
+               return self._gvoice.get_feed(feed)
+
+       def download(self, messageId, targetPath):
+               """
+               Download a voicemail or recorded call MP3 matching the given ``msg``
+               which can either be a ``Message`` instance, or a SHA1 identifier. 
+               Message hashes can be found in ``self.voicemail().messages`` for example. 
+               Returns location of saved file.
+               """
+               self._gvoice.download(messageId, targetPath)
+
+       def is_valid_syntax(self, number):
+               """
+               @returns If This number be called ( syntax validation only )
+               """
+               return self._gvoice.is_valid_syntax(number)
+
+       def get_account_number(self):
+               """
+               @returns The GoogleVoice phone number
+               """
+               return self._gvoice.get_account_number()
+
+       def get_callback_numbers(self):
+               """
+               @returns a dictionary mapping call back numbers to descriptions
+               @note These results are cached for 30 minutes.
+               """
+               return self._gvoice.get_callback_numbers()
+
+       def set_callback_number(self, callbacknumber):
+               """
+               Set the number that GoogleVoice calls
+               @param callbacknumber should be a proper 10 digit number
+               """
+               return self._gvoice.set_callback_number(callbacknumber)
+
+       def get_callback_number(self):
+               """
+               @returns Current callback number or None
+               """
+               return self._gvoice.get_callback_number()
+
+       def get_call_history(self, historyType):
+               """
+               @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
+               """
+               history = list(self._get_call_history(historyType))
+               history.sort(key=lambda item: item["time"])
+               return history
+
+       def _get_call_history(self, historyType):
+               """
+               @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
+               """
+               if historyType in [self.HISTORY_RECEIVED, self.HISTORY_ALL] or not self._received:
+                       self._received = list(self._gvoice.get_received_calls())
+                       for item in self._received:
+                               item["action"] = self.HISTORY_RECEIVED
+               if historyType in [self.HISTORY_MISSED, self.HISTORY_ALL] or not self._missed:
+                       self._missed = list(self._gvoice.get_missed_calls())
+                       for item in self._missed:
+                               item["action"] = self.HISTORY_MISSED
+               if historyType in [self.HISTORY_PLACED, self.HISTORY_ALL] or not self._placed:
+                       self._placed = list(self._gvoice.get_placed_calls())
+                       for item in self._placed:
+                               item["action"] = self.HISTORY_PLACED
+               received = self._received
+               missed = self._missed
+               placed = self._placed
+               for item in received:
+                       yield item
+               for item in missed:
+                       yield item
+               for item in placed:
+                       yield item
+
+       def get_messages(self, messageType):
+               messages = list(self._get_messages(messageType))
+               messages.sort(key=lambda message: message["time"])
+               return messages
+
+       def _get_messages(self, messageType):
+               if messageType in [self.MESSAGE_VOICEMAILS, self.MESSAGE_ALL] or not self._voicemails:
+                       self._voicemails = list(self._gvoice.get_voicemails())
+               if messageType in [self.MESSAGE_TEXTS, self.MESSAGE_ALL] or not self._texts:
+                       self._texts = list(self._gvoice.get_texts())
+               voicemails = self._voicemails
+               smss = self._texts
+
+               conversations = itertools.chain(voicemails, smss)
+               for conversation in conversations:
+                       messages = conversation.messages
+                       messageParts = [
+                               (message.whoFrom, self._format_message(message), message.when)
+                               for message in messages
+                       ]
+
+                       messageDetails = {
+                               "id": conversation.id,
+                               "contactId": conversation.contactId,
+                               "name": conversation.name,
+                               "time": conversation.time,
+                               "relTime": conversation.relTime,
+                               "prettyNumber": conversation.prettyNumber,
+                               "number": conversation.number,
+                               "location": conversation.location,
+                               "messageParts": messageParts,
+                               "type": conversation.type,
+                               "isRead": conversation.isRead,
+                               "isTrash": conversation.isTrash,
+                               "isSpam": conversation.isSpam,
+                               "isArchived": conversation.isArchived,
+                       }
+                       yield messageDetails
+
+       def clear_caches(self):
+               pass
+
+       def get_addressbooks(self):
+               """
+               @returns Iterable of (Address Book Factory, Book Id, Book Name)
+               """
+               yield self, "", ""
+
+       def open_addressbook(self, bookId):
+               return self
+
+       @staticmethod
+       def contact_source_short_name(contactId):
+               return "GV"
+
+       @staticmethod
+       def factory_name():
+               return "Google Voice"
+
+       def _format_message(self, message):
+               messagePartFormat = {
+                       "med1": "<i>%s</i>",
+                       "med2": "%s",
+                       "high": "<b>%s</b>",
+               }
+               return " ".join(
+                       messagePartFormat[text.accuracy] % io_utils.escape(text.text)
+                       for text in message.body
+               )
+
+
+def sort_messages(allMessages):
+       sortableAllMessages = [
+               (message["time"], message)
+               for message in allMessages
+       ]
+       sortableAllMessages.sort(reverse=True)
+       return (
+               message
+               for (exactTime, message) in sortableAllMessages
+       )
+
+
+def decorate_recent(recentCallData):
+       """
+       @returns (personsName, phoneNumber, date, action)
+       """
+       contactId = recentCallData["contactId"]
+       if recentCallData["name"]:
+               header = recentCallData["name"]
+       elif recentCallData["prettyNumber"]:
+               header = recentCallData["prettyNumber"]
+       elif recentCallData["location"]:
+               header = recentCallData["location"]
+       else:
+               header = "Unknown"
+
+       number = recentCallData["number"]
+       relTime = recentCallData["relTime"]
+       action = recentCallData["action"]
+       return contactId, header, number, relTime, action
+
+
+def decorate_message(messageData):
+       contactId = messageData["contactId"]
+       exactTime = messageData["time"]
+       if messageData["name"]:
+               header = messageData["name"]
+       elif messageData["prettyNumber"]:
+               header = messageData["prettyNumber"]
+       else:
+               header = "Unknown"
+       number = messageData["number"]
+       relativeTime = messageData["relTime"]
+
+       messageParts = list(messageData["messageParts"])
+       if len(messageParts) == 0:
+               messages = ("No Transcription", )
+       elif len(messageParts) == 1:
+               messages = (messageParts[0][1], )
+       else:
+               messages = [
+                       "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
+                       for messagePart in messageParts
+               ]
+
+       decoratedResults = contactId, header, number, relativeTime, messages
+       return decoratedResults
diff --git a/dialcentral/backends/gvoice/__init__.py b/dialcentral/backends/gvoice/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/dialcentral/backends/gvoice/browser_emu.py b/dialcentral/backends/gvoice/browser_emu.py
new file mode 100644 (file)
index 0000000..4fef6e8
--- /dev/null
@@ -0,0 +1,210 @@
+"""
+@author:         Laszlo Nagy
+@copyright:   (c) 2005 by Szoftver Messias Bt.
+@licence:       BSD style
+
+Objects of the MozillaEmulator class can emulate a browser that is capable of:
+
+       - cookie management
+       - configurable user agent string
+       - GET and POST
+       - multipart POST (send files)
+       - receive content into file
+
+I have seen many requests on the python mailing list about how to emulate a browser. I'm using this class for years now, without any problems. This is how you can use it:
+
+       1. Use firefox
+       2. Install and open the livehttpheaders plugin
+       3. Use the website manually with firefox
+       4. Check the GET and POST requests in the livehttpheaders capture window
+       5. Create an instance of the above class and send the same GET and POST requests to the server.
+
+Optional steps:
+
+       - You can change user agent string in the build_opened method
+       - The "encode_multipart_formdata" function can be used alone to create POST data from a list of field values and files
+"""
+
+import urllib2
+import cookielib
+import logging
+
+import socket
+
+
+_moduleLogger = logging.getLogger(__name__)
+socket.setdefaulttimeout(25)
+
+
+def add_proxy(protocol, url, port):
+       proxyInfo = "%s:%s" % (url, port)
+       proxy = urllib2.ProxyHandler(
+               {protocol: proxyInfo}
+       )
+       opener = urllib2.build_opener(proxy)
+       urllib2.install_opener(opener)
+
+
+class MozillaEmulator(object):
+
+       USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.4) Gecko/20091016 Firefox/3.5.4 (.NET CLR 3.5.30729)'
+       #USER_AGENT = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 3_0 like Mac OS X; en-us) AppleWebKit/528.18 (KHTML, like Gecko) Version/4.0 Mobile/7A341 Safari/528.16"
+
+       def __init__(self, trycount = 1):
+               """Create a new MozillaEmulator object.
+
+               @param trycount: The download() method will retry the operation if it
+               fails. You can specify -1 for infinite retrying.  A value of 0 means no
+               retrying. A value of 1 means one retry. etc."""
+               self.debug = False
+               self.trycount = trycount
+               self._cookies = cookielib.LWPCookieJar()
+               self._loadedFromCookies = False
+               self._storeCookies = False
+
+       def load_cookies(self, path):
+               assert not self._loadedFromCookies, "Load cookies only once"
+               if path is None:
+                       return
+
+               self._cookies.filename = path
+               try:
+                       self._cookies.load()
+               except cookielib.LoadError:
+                       _moduleLogger.exception("Bad cookie file")
+               except IOError:
+                       _moduleLogger.exception("No cookie file")
+               except Exception, e:
+                       _moduleLogger.exception("Unknown error with cookies")
+               else:
+                       self._loadedFromCookies = True
+               self._storeCookies = True
+
+               return self._loadedFromCookies
+
+       def save_cookies(self):
+               if self._storeCookies:
+                       self._cookies.save()
+
+       def clear_cookies(self):
+               if self._storeCookies:
+                       self._cookies.clear()
+
+       def download(self, url,
+                       postdata = None, extraheaders = None, forbidRedirect = False,
+                       trycount = None, only_head = False,
+               ):
+               """Download an URL with GET or POST methods.
+
+               @param postdata: It can be a string that will be POST-ed to the URL.
+                       When None is given, the method will be GET instead.
+               @param extraheaders: You can add/modify HTTP headers with a dict here.
+               @param forbidRedirect: Set this flag if you do not want to handle
+                       HTTP 301 and 302 redirects.
+               @param trycount: Specify the maximum number of retries here.
+                       0 means no retry on error. Using -1 means infinite retring.
+                       None means the default value (that is self.trycount).
+               @param only_head: Create the openerdirector and return it. In other
+                       words, this will not retrieve any content except HTTP headers.
+
+               @return: The raw HTML page data
+               """
+               _moduleLogger.debug("Performing download of %s" % url)
+
+               if extraheaders is None:
+                       extraheaders = {}
+               if trycount is None:
+                       trycount = self.trycount
+               cnt = 0
+
+               while True:
+                       try:
+                               req, u = self._build_opener(url, postdata, extraheaders, forbidRedirect)
+                               openerdirector = u.open(req)
+                               if self.debug:
+                                       _moduleLogger.info("%r - %r" % (req.get_method(), url))
+                                       _moduleLogger.info("%r - %r" % (openerdirector.code, openerdirector.msg))
+                                       _moduleLogger.info("%r" % (openerdirector.headers))
+                               self._cookies.extract_cookies(openerdirector, req)
+                               if only_head:
+                                       return openerdirector
+
+                               return self._read(openerdirector, trycount)
+                       except urllib2.URLError, e:
+                               _moduleLogger.debug("%s: %s" % (e, url))
+                               cnt += 1
+                               if (-1 < trycount) and (trycount < cnt):
+                                       raise
+
+                       # Retry :-)
+                       _moduleLogger.debug("MozillaEmulator: urllib2.URLError, retrying %d" % cnt)
+
+       def _build_opener(self, url, postdata = None, extraheaders = None, forbidRedirect = False):
+               if extraheaders is None:
+                       extraheaders = {}
+
+               txheaders = {
+                       'Accept': 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png',
+                       'Accept-Language': 'en,en-us;q=0.5',
+                       'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
+                       'User-Agent': self.USER_AGENT,
+               }
+               for key, value in extraheaders.iteritems():
+                       txheaders[key] = value
+               req = urllib2.Request(url, postdata, txheaders)
+               self._cookies.add_cookie_header(req)
+               if forbidRedirect:
+                       redirector = HTTPNoRedirector()
+                       #_moduleLogger.info("Redirection disabled")
+               else:
+                       redirector = urllib2.HTTPRedirectHandler()
+                       #_moduleLogger.info("Redirection enabled")
+
+               http_handler = urllib2.HTTPHandler(debuglevel=self.debug)
+               https_handler = urllib2.HTTPSHandler(debuglevel=self.debug)
+
+               u = urllib2.build_opener(
+                       http_handler,
+                       https_handler,
+                       urllib2.HTTPCookieProcessor(self._cookies),
+                       redirector
+               )
+               if not postdata is None:
+                       req.add_data(postdata)
+               return (req, u)
+
+       def _read(self, openerdirector, trycount):
+               chunks = []
+
+               chunk = openerdirector.read()
+               chunks.append(chunk)
+               #while chunk and cnt < trycount:
+               #       time.sleep(1)
+               #       cnt += 1
+               #       chunk = openerdirector.read()
+               #       chunks.append(chunk)
+
+               data = "".join(chunks)
+
+               if "Content-Length" in openerdirector.info():
+                       assert len(data) == int(openerdirector.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % (
+                               openerdirector.info()["Content-Length"],
+                               len(data),
+                       )
+
+               return data
+
+
+class HTTPNoRedirector(urllib2.HTTPRedirectHandler):
+       """This is a custom http redirect handler that FORBIDS redirection."""
+
+       def http_error_302(self, req, fp, code, msg, headers):
+               e = urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
+               if e.code in (301, 302):
+                       if 'location' in headers:
+                               newurl = headers.getheaders('location')[0]
+                       elif 'uri' in headers:
+                               newurl = headers.getheaders('uri')[0]
+                       e.newurl = newurl
+               _moduleLogger.info("New url: %s" % e.newurl)
+               raise e
diff --git a/dialcentral/backends/gvoice/gvoice.py b/dialcentral/backends/gvoice/gvoice.py
new file mode 100755 (executable)
index 0000000..b0825ef
--- /dev/null
@@ -0,0 +1,1050 @@
+#!/usr/bin/python
+
+"""
+DialCentral - Front end for Google's GoogleVoice service.
+Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Google Voice backend code
+
+Resources
+       http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
+       http://posttopic.com/topic/google-voice-add-on-development
+"""
+
+from __future__ import with_statement
+
+import os
+import re
+import urllib
+import urllib2
+import time
+import datetime
+import itertools
+import logging
+import inspect
+
+from xml.sax import saxutils
+from xml.etree import ElementTree
+
+try:
+       import simplejson as _simplejson
+       simplejson = _simplejson
+except ImportError:
+       simplejson = None
+
+import browser_emu
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class NetworkError(RuntimeError):
+       pass
+
+
+class MessageText(object):
+
+       ACCURACY_LOW = "med1"
+       ACCURACY_MEDIUM = "med2"
+       ACCURACY_HIGH = "high"
+
+       def __init__(self):
+               self.accuracy = None
+               self.text = None
+
+       def __str__(self):
+               return self.text
+
+       def to_dict(self):
+               return to_dict(self)
+
+       def __eq__(self, other):
+               return self.accuracy == other.accuracy and self.text == other.text
+
+
+class Message(object):
+
+       def __init__(self):
+               self.whoFrom = None
+               self.body = None
+               self.when = None
+
+       def __str__(self):
+               return "%s (%s): %s" % (
+                       self.whoFrom,
+                       self.when,
+                       "".join(unicode(part) for part in self.body)
+               )
+
+       def to_dict(self):
+               selfDict = to_dict(self)
+               selfDict["body"] = [text.to_dict() for text in self.body] if self.body is not None else None
+               return selfDict
+
+       def __eq__(self, other):
+               return self.whoFrom == other.whoFrom and self.when == other.when and self.body == other.body
+
+
+class Conversation(object):
+
+       TYPE_VOICEMAIL = "Voicemail"
+       TYPE_SMS = "SMS"
+
+       def __init__(self):
+               self.type = None
+               self.id = None
+               self.contactId = None
+               self.name = None
+               self.location = None
+               self.prettyNumber = None
+               self.number = None
+
+               self.time = None
+               self.relTime = None
+               self.messages = None
+               self.isRead = None
+               self.isSpam = None
+               self.isTrash = None
+               self.isArchived = None
+
+       def __cmp__(self, other):
+               cmpValue = cmp(self.contactId, other.contactId)
+               if cmpValue != 0:
+                       return cmpValue
+
+               cmpValue = cmp(self.time, other.time)
+               if cmpValue != 0:
+                       return cmpValue
+
+               cmpValue = cmp(self.id, other.id)
+               if cmpValue != 0:
+                       return cmpValue
+
+       def to_dict(self):
+               selfDict = to_dict(self)
+               selfDict["messages"] = [message.to_dict() for message in self.messages] if self.messages is not None else None
+               return selfDict
+
+
+class GVoiceBackend(object):
+       """
+       This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
+       the functions include login, setting up a callback number, and initalting a callback
+       """
+
+       PHONE_TYPE_HOME = 1
+       PHONE_TYPE_MOBILE = 2
+       PHONE_TYPE_WORK = 3
+       PHONE_TYPE_GIZMO = 7
+
+       def __init__(self, cookieFile = None):
+               # Important items in this function are the setup of the browser emulation and cookie file
+               self._browser = browser_emu.MozillaEmulator(1)
+               self._loadedFromCookies = self._browser.load_cookies(cookieFile)
+
+               self._token = ""
+               self._accountNum = ""
+               self._lastAuthed = 0.0
+               self._callbackNumber = ""
+               self._callbackNumbers = {}
+
+               # Suprisingly, moving all of these from class to self sped up startup time
+
+               self._validateRe = re.compile("^\+?[0-9]{10,}$")
+
+               self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
+
+               SECURE_URL_BASE = "https://www.google.com/voice/"
+               SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/"
+               self._tokenURL = SECURE_URL_BASE + "m"
+               self._callUrl = SECURE_URL_BASE + "call/connect"
+               self._callCancelURL = SECURE_URL_BASE + "call/cancel"
+               self._sendSmsURL = SECURE_URL_BASE + "sms/send"
+
+               self._isDndURL = "https://www.google.com/voice/m/donotdisturb"
+               self._isDndRe = re.compile(r"""<input.*?id="doNotDisturb".*?checked="(.*?)"\s*/>""")
+               self._setDndURL = "https://www.google.com/voice/m/savednd"
+
+               self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/"
+               self._markAsReadURL = SECURE_URL_BASE + "m/mark"
+               self._archiveMessageURL = SECURE_URL_BASE + "m/archive"
+
+               self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/"
+               self._XML_ACCOUNT_URL = SECURE_URL_BASE + "contacts/"
+               # HACK really this redirects to the main pge and we are grabbing some javascript
+               self._XML_CONTACTS_URL = "http://www.google.com/voice/inbox/search/contact"
+               self._CSV_CONTACTS_URL = "http://mail.google.com/mail/contacts/data/export"
+               self._JSON_CONTACTS_URL = SECURE_URL_BASE + "b/0/request/user"
+               self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/"
+
+               self.XML_FEEDS = (
+                       'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms',
+                       'recorded', 'placed', 'received', 'missed'
+               )
+               self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox"
+               self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred"
+               self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all"
+               self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam"
+               self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash"
+               self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/"
+               self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/"
+               self._JSON_SMS_URL = SECURE_URL_BASE + "b/0/request/messages/"
+               self._JSON_SMS_COUNT_URL = SECURE_URL_BASE + "b/0/request/unread"
+               self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/"
+               self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/"
+               self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/"
+               self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/"
+
+               self._galxRe = re.compile(r"""<input.*?name="GALX".*?value="(.*?)".*?/>""", re.MULTILINE | re.DOTALL)
+
+               self._seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
+               self._exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
+               self._relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
+               self._voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
+               self._voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
+               self._prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
+               self._voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
+               self._messagesContactIDRegex = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
+               self._voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
+               self._smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
+               self._smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
+               self._smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
+
+       def is_quick_login_possible(self):
+               """
+               @returns True then refresh_account_info might be enough to login, else full login is required
+               """
+               return self._loadedFromCookies or 0.0 < self._lastAuthed
+
+       def refresh_account_info(self):
+               try:
+                       page = self._get_page(self._JSON_CONTACTS_URL)
+                       accountData = self._grab_account_info(page)
+               except Exception, e:
+                       _moduleLogger.exception(str(e))
+                       return None
+
+               self._browser.save_cookies()
+               self._lastAuthed = time.time()
+               return accountData
+
+       def _get_token(self):
+               tokenPage = self._get_page(self._tokenURL)
+
+               galxTokens = self._galxRe.search(tokenPage)
+               if galxTokens is not None:
+                       galxToken = galxTokens.group(1)
+               else:
+                       galxToken = ""
+                       _moduleLogger.debug("Could not grab GALX token")
+               return galxToken
+
+       def _login(self, username, password, token):
+               loginData = {
+                       'Email' : username,
+                       'Passwd' : password,
+                       'service': "grandcentral",
+                       "ltmpl": "mobile",
+                       "btmpl": "mobile",
+                       "PersistentCookie": "yes",
+                       "GALX": token,
+                       "continue": self._JSON_CONTACTS_URL,
+               }
+
+               loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData)
+               return loginSuccessOrFailurePage
+
+       def login(self, username, password):
+               """
+               Attempt to login to GoogleVoice
+               @returns Whether login was successful or not
+               @blocks
+               """
+               self.logout()
+               galxToken = self._get_token()
+               loginSuccessOrFailurePage = self._login(username, password, galxToken)
+
+               try:
+                       accountData = self._grab_account_info(loginSuccessOrFailurePage)
+               except Exception, e:
+                       # Retry in case the redirect failed
+                       # luckily refresh_account_info does everything we need for a retry
+                       accountData = self.refresh_account_info()
+                       if accountData is None:
+                               _moduleLogger.exception(str(e))
+                               return None
+                       _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this")
+
+               self._browser.save_cookies()
+               self._lastAuthed = time.time()
+               return accountData
+
+       def persist(self):
+               self._browser.save_cookies()
+
+       def shutdown(self):
+               self._browser.save_cookies()
+               self._token = None
+               self._lastAuthed = 0.0
+
+       def logout(self):
+               self._browser.clear_cookies()
+               self._browser.save_cookies()
+               self._token = None
+               self._lastAuthed = 0.0
+               self._callbackNumbers = {}
+
+       def is_dnd(self):
+               """
+               @blocks
+               """
+               isDndPage = self._get_page(self._isDndURL)
+
+               dndGroup = self._isDndRe.search(isDndPage)
+               if dndGroup is None:
+                       return False
+               dndStatus = dndGroup.group(1)
+               isDnd = True if dndStatus.strip().lower() == "true" else False
+               return isDnd
+
+       def set_dnd(self, doNotDisturb):
+               """
+               @blocks
+               """
+               dndPostData = {
+                       "doNotDisturb": 1 if doNotDisturb else 0,
+               }
+
+               dndPage = self._get_page_with_token(self._setDndURL, dndPostData)
+
+       def call(self, outgoingNumber):
+               """
+               This is the main function responsible for initating the callback
+               @blocks
+               """
+               outgoingNumber = self._send_validation(outgoingNumber)
+               subscriberNumber = None
+               phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack
+
+               callData = {
+                               'outgoingNumber': outgoingNumber,
+                               'forwardingNumber': self._callbackNumber,
+                               'subscriberNumber': subscriberNumber or 'undefined',
+                               'phoneType': str(phoneType),
+                               'remember': '1',
+               }
+               _moduleLogger.info("%r" % callData)
+
+               page = self._get_page_with_token(
+                       self._callUrl,
+                       callData,
+               )
+               self._parse_with_validation(page)
+               return True
+
+       def cancel(self, outgoingNumber=None):
+               """
+               Cancels a call matching outgoing and forwarding numbers (if given). 
+               Will raise an error if no matching call is being placed
+               @blocks
+               """
+               page = self._get_page_with_token(
+                       self._callCancelURL,
+                       {
+                       'outgoingNumber': outgoingNumber or 'undefined',
+                       'forwardingNumber': self._callbackNumber or 'undefined',
+                       'cancelType': 'C2C',
+                       },
+               )
+               self._parse_with_validation(page)
+
+       def send_sms(self, phoneNumbers, message):
+               """
+               @blocks
+               """
+               validatedPhoneNumbers = [
+                       self._send_validation(phoneNumber)
+                       for phoneNumber in phoneNumbers
+               ]
+               flattenedPhoneNumbers = ",".join(validatedPhoneNumbers)
+               page = self._get_page_with_token(
+                       self._sendSmsURL,
+                       {
+                               'phoneNumber': flattenedPhoneNumbers,
+                               'text': unicode(message).encode("utf-8"),
+                       },
+               )
+               self._parse_with_validation(page)
+
+       def search(self, query):
+               """
+               Search your Google Voice Account history for calls, voicemails, and sms
+               Returns ``Folder`` instance containting matching messages
+               @blocks
+               """
+               page = self._get_page(
+                       self._XML_SEARCH_URL,
+                       {"q": query},
+               )
+               json, html = extract_payload(page)
+               return json
+
+       def get_feed(self, feed):
+               """
+               @blocks
+               """
+               actualFeed = "_XML_%s_URL" % feed.upper()
+               feedUrl = getattr(self, actualFeed)
+
+               page = self._get_page(feedUrl)
+               json, html = extract_payload(page)
+
+               return json
+
+       def recording_url(self, messageId):
+               url = self._downloadVoicemailURL+messageId
+               return url
+
+       def download(self, messageId, targetPath):
+               """
+               Download a voicemail or recorded call MP3 matching the given ``msg``
+               which can either be a ``Message`` instance, or a SHA1 identifier. 
+               Message hashes can be found in ``self.voicemail().messages`` for example. 
+               @returns location of saved file.
+               @blocks
+               """
+               page = self._get_page(self.recording_url(messageId))
+               with open(targetPath, 'wb') as fo:
+                       fo.write(page)
+
+       def is_valid_syntax(self, number):
+               """
+               @returns If This number be called ( syntax validation only )
+               """
+               return self._validateRe.match(number) is not None
+
+       def get_account_number(self):
+               """
+               @returns The GoogleVoice phone number
+               """
+               return self._accountNum
+
+       def get_callback_numbers(self):
+               """
+               @returns a dictionary mapping call back numbers to descriptions
+               @note These results are cached for 30 minutes.
+               """
+               return self._callbackNumbers
+
+       def set_callback_number(self, callbacknumber):
+               """
+               Set the number that GoogleVoice calls
+               @param callbacknumber should be a proper 10 digit number
+               """
+               self._callbackNumber = callbacknumber
+               _moduleLogger.info("Callback number changed: %r" % self._callbackNumber)
+               return True
+
+       def get_callback_number(self):
+               """
+               @returns Current callback number or None
+               """
+               return self._callbackNumber
+
+       def get_received_calls(self):
+               """
+               @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
+               @blocks
+               """
+               return self._parse_recent(self._get_page(self._XML_RECEIVED_URL))
+
+       def get_missed_calls(self):
+               """
+               @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
+               @blocks
+               """
+               return self._parse_recent(self._get_page(self._XML_MISSED_URL))
+
+       def get_placed_calls(self):
+               """
+               @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
+               @blocks
+               """
+               return self._parse_recent(self._get_page(self._XML_PLACED_URL))
+
+       def get_csv_contacts(self):
+               data = {
+                       "groupToExport": "mine",
+                       "exportType": "ALL",
+                       "out": "OUTLOOK_CSV",
+               }
+               encodedData = urllib.urlencode(data)
+               contacts = self._get_page(self._CSV_CONTACTS_URL+"?"+encodedData)
+               return contacts
+
+       def get_voicemails(self):
+               """
+               @blocks
+               """
+               voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
+               voicemailHtml = self._grab_html(voicemailPage)
+               voicemailJson = self._grab_json(voicemailPage)
+               if voicemailJson is None:
+                       return ()
+               parsedVoicemail = self._parse_voicemail(voicemailHtml)
+               voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson)
+               return voicemails
+
+       def get_texts(self):
+               """
+               @blocks
+               """
+               smsPage = self._get_page(self._XML_SMS_URL)
+               smsHtml = self._grab_html(smsPage)
+               smsJson = self._grab_json(smsPage)
+               if smsJson is None:
+                       return ()
+               parsedSms = self._parse_sms(smsHtml)
+               smss = self._merge_conversation_sources(parsedSms, smsJson)
+               return smss
+
+       def get_unread_counts(self):
+               countPage = self._get_page(self._JSON_SMS_COUNT_URL)
+               counts = parse_json(countPage)
+               counts = counts["unreadCounts"]
+               return counts
+
+       def mark_message(self, messageId, asRead):
+               """
+               @blocks
+               """
+               postData = {
+                       "read": 1 if asRead else 0,
+                       "id": messageId,
+               }
+
+               markPage = self._get_page(self._markAsReadURL, postData)
+
+       def archive_message(self, messageId):
+               """
+               @blocks
+               """
+               postData = {
+                       "id": messageId,
+               }
+
+               markPage = self._get_page(self._archiveMessageURL, postData)
+
+       def _grab_json(self, flatXml):
+               xmlTree = ElementTree.fromstring(flatXml)
+               jsonElement = xmlTree.getchildren()[0]
+               flatJson = jsonElement.text
+               jsonTree = parse_json(flatJson)
+               return jsonTree
+
+       def _grab_html(self, flatXml):
+               xmlTree = ElementTree.fromstring(flatXml)
+               htmlElement = xmlTree.getchildren()[1]
+               flatHtml = htmlElement.text
+               return flatHtml
+
+       def _grab_account_info(self, page):
+               accountData = parse_json(page)
+               self._token = accountData["r"]
+               self._accountNum = accountData["number"]["raw"]
+               for callback in accountData["phones"].itervalues():
+                       self._callbackNumbers[callback["phoneNumber"]] = callback["name"]
+               if len(self._callbackNumbers) == 0:
+                       _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
+               return accountData
+
+       def _send_validation(self, number):
+               if not self.is_valid_syntax(number):
+                       raise ValueError('Number is not valid: "%s"' % number)
+               return number
+
+       def _parse_recent(self, recentPage):
+               allRecentHtml = self._grab_html(recentPage)
+               allRecentData = self._parse_history(allRecentHtml)
+               for recentCallData in allRecentData:
+                       yield recentCallData
+
+       def _parse_history(self, historyHtml):
+               splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml)
+               for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
+                       exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
+                       exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
+                       exactTime = google_strptime(exactTime)
+                       relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
+                       relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
+                       locationGroup = self._voicemailLocationRegex.search(messageHtml)
+                       location = locationGroup.group(1).strip() if locationGroup else ""
+
+                       nameGroup = self._voicemailNameRegex.search(messageHtml)
+                       name = nameGroup.group(1).strip() if nameGroup else ""
+                       numberGroup = self._voicemailNumberRegex.search(messageHtml)
+                       number = numberGroup.group(1).strip() if numberGroup else ""
+                       prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
+                       prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
+                       contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
+                       contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
+
+                       yield {
+                               "id": messageId.strip(),
+                               "contactId": contactId,
+                               "name": unescape(name),
+                               "time": exactTime,
+                               "relTime": relativeTime,
+                               "prettyNumber": prettyNumber,
+                               "number": number,
+                               "location": unescape(location),
+                       }
+
+       @staticmethod
+       def _interpret_voicemail_regex(group):
+               quality, content, number = group.group(2), group.group(3), group.group(4)
+               text = MessageText()
+               if quality is not None and content is not None:
+                       text.accuracy = quality
+                       text.text = unescape(content)
+                       return text
+               elif number is not None:
+                       text.accuracy = MessageText.ACCURACY_HIGH
+                       text.text = number
+                       return text
+
+       def _parse_voicemail(self, voicemailHtml):
+               splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
+               for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
+                       conv = Conversation()
+                       conv.type = Conversation.TYPE_VOICEMAIL
+                       conv.id = messageId.strip()
+
+                       exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
+                       exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
+                       conv.time = google_strptime(exactTimeText)
+                       relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
+                       conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
+                       locationGroup = self._voicemailLocationRegex.search(messageHtml)
+                       conv.location = unescape(locationGroup.group(1).strip() if locationGroup else "")
+
+                       nameGroup = self._voicemailNameRegex.search(messageHtml)
+                       conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "")
+                       numberGroup = self._voicemailNumberRegex.search(messageHtml)
+                       conv.number = numberGroup.group(1).strip() if numberGroup else ""
+                       prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
+                       conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
+                       contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
+                       conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
+
+                       messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
+                       messageParts = [
+                               self._interpret_voicemail_regex(group)
+                               for group in messageGroups
+                       ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), )
+                       message = Message()
+                       message.body = messageParts
+                       message.whoFrom = conv.name
+                       try:
+                               message.when = conv.time.strftime("%I:%M %p")
+                       except ValueError:
+                               _moduleLogger.exception("Confusing time provided: %r" % conv.time)
+                               message.when = "Unknown"
+                       conv.messages = (message, )
+
+                       yield conv
+
+       @staticmethod
+       def _interpret_sms_message_parts(fromPart, textPart, timePart):
+               text = MessageText()
+               text.accuracy = MessageText.ACCURACY_MEDIUM
+               text.text = unescape(textPart)
+
+               message = Message()
+               message.body = (text, )
+               message.whoFrom = fromPart
+               message.when = timePart
+
+               return message
+
+       def _parse_sms(self, smsHtml):
+               splitSms = self._seperateVoicemailsRegex.split(smsHtml)
+               for messageId, messageHtml in itergroup(splitSms[1:], 2):
+                       conv = Conversation()
+                       conv.type = Conversation.TYPE_SMS
+                       conv.id = messageId.strip()
+
+                       exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
+                       exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
+                       conv.time = google_strptime(exactTimeText)
+                       relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
+                       conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
+                       conv.location = ""
+
+                       nameGroup = self._voicemailNameRegex.search(messageHtml)
+                       conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "")
+                       numberGroup = self._voicemailNumberRegex.search(messageHtml)
+                       conv.number = numberGroup.group(1).strip() if numberGroup else ""
+                       prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
+                       conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
+                       contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
+                       conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
+
+                       fromGroups = self._smsFromRegex.finditer(messageHtml)
+                       fromParts = (group.group(1).strip() for group in fromGroups)
+                       textGroups = self._smsTextRegex.finditer(messageHtml)
+                       textParts = (group.group(1).strip() for group in textGroups)
+                       timeGroups = self._smsTimeRegex.finditer(messageHtml)
+                       timeParts = (group.group(1).strip() for group in timeGroups)
+
+                       messageParts = itertools.izip(fromParts, textParts, timeParts)
+                       messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts]
+                       conv.messages = messages
+
+                       yield conv
+
+       @staticmethod
+       def _merge_conversation_sources(parsedMessages, json):
+               for message in parsedMessages:
+                       jsonItem = json["messages"][message.id]
+                       message.isRead = jsonItem["isRead"]
+                       message.isSpam = jsonItem["isSpam"]
+                       message.isTrash = jsonItem["isTrash"]
+                       message.isArchived = "inbox" not in jsonItem["labels"]
+                       yield message
+
+       def _get_page(self, url, data = None, refererUrl = None):
+               headers = {}
+               if refererUrl is not None:
+                       headers["Referer"] = refererUrl
+
+               encodedData = urllib.urlencode(data) if data is not None else None
+
+               try:
+                       page = self._browser.download(url, encodedData, None, headers)
+               except urllib2.URLError, e:
+                       _moduleLogger.error("Translating error: %s" % str(e))
+                       raise NetworkError("%s is not accesible" % url)
+
+               return page
+
+       def _get_page_with_token(self, url, data = None, refererUrl = None):
+               if data is None:
+                       data = {}
+               data['_rnr_se'] = self._token
+
+               page = self._get_page(url, data, refererUrl)
+
+               return page
+
+       def _parse_with_validation(self, page):
+               json = parse_json(page)
+               self._validate_response(json)
+               return json
+
+       def _validate_response(self, response):
+               """
+               Validates that the JSON response is A-OK
+               """
+               try:
+                       assert response is not None, "Response not provided"
+                       assert 'ok' in response, "Response lacks status"
+                       assert response['ok'], "Response not good"
+               except AssertionError:
+                       try:
+                               if response["data"]["code"] == 20:
+                                       raise RuntimeError(
+"""Ambiguous error 20 returned by Google Voice.
+Please verify you have configured your callback number (currently "%s").  If it is configured some other suspected causes are: non-verified callback numbers, and Gizmo5 callback numbers.""" % self._callbackNumber)
+                       except KeyError:
+                               pass
+                       raise RuntimeError('There was a problem with GV: %s' % response)
+
+
+_UNESCAPE_ENTITIES = {
+ "&quot;": '"',
+ "&nbsp;": " ",
+ "&#39;": "'",
+}
+
+
+def unescape(text):
+       plain = saxutils.unescape(text, _UNESCAPE_ENTITIES)
+       return plain
+
+
+def google_strptime(time):
+       """
+       Hack: Google always returns the time in the same locale.  Sadly if the
+       local system's locale is different, there isn't a way to perfectly handle
+       the time.  So instead we handle implement some time formatting
+       """
+       abbrevTime = time[:-3]
+       parsedTime = datetime.datetime.strptime(abbrevTime, "%m/%d/%y %I:%M")
+       if time.endswith("PM"):
+               parsedTime += datetime.timedelta(hours=12)
+       return parsedTime
+
+
+def itergroup(iterator, count, padValue = None):
+       """
+       Iterate in groups of 'count' values. If there
+       aren't enough values, the last result is padded with
+       None.
+
+       >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
+       ...     print tuple(val)
+       (1, 2, 3)
+       (4, 5, 6)
+       >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
+       ...     print list(val)
+       [1, 2, 3]
+       [4, 5, 6]
+       >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
+       ...     print tuple(val)
+       (1, 2, 3)
+       (4, 5, 6)
+       (7, None, None)
+       >>> for val in itergroup("123456", 3):
+       ...     print tuple(val)
+       ('1', '2', '3')
+       ('4', '5', '6')
+       >>> for val in itergroup("123456", 3):
+       ...     print repr("".join(val))
+       '123'
+       '456'
+       """
+       paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
+       nIterators = (paddedIterator, ) * count
+       return itertools.izip(*nIterators)
+
+
+def safe_eval(s):
+       _TRUE_REGEX = re.compile("true")
+       _FALSE_REGEX = re.compile("false")
+       _COMMENT_REGEX = re.compile("^\s+//.*$", re.M)
+       s = _TRUE_REGEX.sub("True", s)
+       s = _FALSE_REGEX.sub("False", s)
+       s = _COMMENT_REGEX.sub("#", s)
+       try:
+               results = eval(s, {}, {})
+       except SyntaxError:
+               _moduleLogger.exception("Oops")
+               results = None
+       return results
+
+
+def _fake_parse_json(flattened):
+       return safe_eval(flattened)
+
+
+def _actual_parse_json(flattened):
+       return simplejson.loads(flattened)
+
+
+if simplejson is None:
+       parse_json = _fake_parse_json
+else:
+       parse_json = _actual_parse_json
+
+
+def extract_payload(flatXml):
+       xmlTree = ElementTree.fromstring(flatXml)
+
+       jsonElement = xmlTree.getchildren()[0]
+       flatJson = jsonElement.text
+       jsonTree = parse_json(flatJson)
+
+       htmlElement = xmlTree.getchildren()[1]
+       flatHtml = htmlElement.text
+
+       return jsonTree, flatHtml
+
+
+def guess_phone_type(number):
+       if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"):
+               return GVoiceBackend.PHONE_TYPE_GIZMO
+       else:
+               return GVoiceBackend.PHONE_TYPE_MOBILE
+
+
+def get_sane_callback(backend):
+       """
+       Try to set a sane default callback number on these preferences
+       1) 1747 numbers ( Gizmo )
+       2) anything with gizmo in the name
+       3) anything with computer in the name
+       4) the first value
+       """
+       numbers = backend.get_callback_numbers()
+
+       priorityOrderedCriteria = [
+               ("\+1747", None),
+               ("1747", None),
+               ("747", None),
+               (None, "gizmo"),
+               (None, "computer"),
+               (None, "sip"),
+               (None, None),
+       ]
+
+       for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
+               numberMatcher = None
+               descriptionMatcher = None
+               if numberCriteria is not None:
+                       numberMatcher = re.compile(numberCriteria)
+               elif descriptionCriteria is not None:
+                       descriptionMatcher = re.compile(descriptionCriteria, re.I)
+
+               for number, description in numbers.iteritems():
+                       if numberMatcher is not None and numberMatcher.match(number) is None:
+                               continue
+                       if descriptionMatcher is not None and descriptionMatcher.match(description) is None:
+                               continue
+                       return number
+
+
+def set_sane_callback(backend):
+       """
+       Try to set a sane default callback number on these preferences
+       1) 1747 numbers ( Gizmo )
+       2) anything with gizmo in the name
+       3) anything with computer in the name
+       4) the first value
+       """
+       number = get_sane_callback(backend)
+       backend.set_callback_number(number)
+
+
+def _is_not_special(name):
+       return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name
+
+
+def to_dict(obj):
+       members = inspect.getmembers(obj)
+       return dict((name, value) for (name, value) in members if _is_not_special(name))
+
+
+def grab_debug_info(username, password):
+       cookieFile = os.path.join(".", "raw_cookies.txt")
+       try:
+               os.remove(cookieFile)
+       except OSError:
+               pass
+
+       backend = GVoiceBackend(cookieFile)
+       browser = backend._browser
+
+       _TEST_WEBPAGES = [
+               ("token", backend._tokenURL),
+               ("login", backend._loginURL),
+               ("isdnd", backend._isDndURL),
+               ("account", backend._XML_ACCOUNT_URL),
+               ("html_contacts", backend._XML_CONTACTS_URL),
+               ("contacts", backend._JSON_CONTACTS_URL),
+               ("csv", backend._CSV_CONTACTS_URL),
+
+               ("voicemail", backend._XML_VOICEMAIL_URL),
+               ("html_sms", backend._XML_SMS_URL),
+               ("sms", backend._JSON_SMS_URL),
+               ("count", backend._JSON_SMS_COUNT_URL),
+
+               ("recent", backend._XML_RECENT_URL),
+               ("placed", backend._XML_PLACED_URL),
+               ("recieved", backend._XML_RECEIVED_URL),
+               ("missed", backend._XML_MISSED_URL),
+       ]
+
+       # Get Pages
+       print "Grabbing pre-login pages"
+       for name, url in _TEST_WEBPAGES:
+               try:
+                       page = browser.download(url)
+               except StandardError, e:
+                       print e.message
+                       continue
+               print "\tWriting to file"
+               with open("not_loggedin_%s.txt" % name, "w") as f:
+                       f.write(page)
+
+       # Login
+       print "Attempting login"
+       galxToken = backend._get_token()
+       loginSuccessOrFailurePage = backend._login(username, password, galxToken)
+       with open("loggingin.txt", "w") as f:
+               print "\tWriting to file"
+               f.write(loginSuccessOrFailurePage)
+       try:
+               backend._grab_account_info(loginSuccessOrFailurePage)
+       except Exception:
+               # Retry in case the redirect failed
+               # luckily refresh_account_info does everything we need for a retry
+               loggedIn = backend.refresh_account_info() is not None
+               if not loggedIn:
+                       raise
+
+       # Get Pages
+       print "Grabbing post-login pages"
+       for name, url in _TEST_WEBPAGES:
+               try:
+                       page = browser.download(url)
+               except StandardError, e:
+                       print str(e)
+                       continue
+               print "\tWriting to file"
+               with open("loggedin_%s.txt" % name, "w") as f:
+                       f.write(page)
+
+       # Cookies
+       browser.save_cookies()
+       print "\tWriting cookies to file"
+       with open("cookies.txt", "w") as f:
+               f.writelines(
+                       "%s: %s\n" % (c.name, c.value)
+                       for c in browser._cookies
+               )
+
+
+def grab_voicemails(username, password):
+       cookieFile = os.path.join(".", "raw_cookies.txt")
+       try:
+               os.remove(cookieFile)
+       except OSError:
+               pass
+
+       backend = GVoiceBackend(cookieFile)
+       backend.login(username, password)
+       voicemails = list(backend.get_voicemails())
+       for voicemail in voicemails:
+               print voicemail.id
+               backend.download(voicemail.id, ".")
+
+
+def main():
+       import sys
+       logging.basicConfig(level=logging.DEBUG)
+       args = sys.argv
+       if 3 <= len(args):
+               username = args[1]
+               password = args[2]
+
+       grab_debug_info(username, password)
+       grab_voicemails(username, password)
+
+
+if __name__ == "__main__":
+       main()
diff --git a/dialcentral/backends/null_backend.py b/dialcentral/backends/null_backend.py
new file mode 100644 (file)
index 0000000..ebaa932
--- /dev/null
@@ -0,0 +1,39 @@
+#!/usr/bin/python
+
+"""
+DialCentral - Front end for Google's Grand Central service.
+Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+"""
+
+
+class NullAddressBook(object):
+
+       @property
+       def name(self):
+               return "None"
+
+       def update_account(self, force = True):
+               pass
+
+       def get_contacts(self):
+               return {}
+
+
+class NullAddressBookFactory(object):
+
+       def get_addressbooks(self):
+               yield NullAddressBook()
diff --git a/dialcentral/backends/qt_backend.py b/dialcentral/backends/qt_backend.py
new file mode 100644 (file)
index 0000000..88e52fa
--- /dev/null
@@ -0,0 +1,106 @@
+#!/usr/bin/env python
+
+from __future__ import with_statement
+from __future__ import division
+
+import logging
+
+import util.qt_compat as qt_compat
+if qt_compat.USES_PYSIDE:
+       try:
+               import QtMobility.Contacts as _QtContacts
+               QtContacts = _QtContacts
+       except ImportError:
+               QtContacts = None
+else:
+       QtContacts = None
+
+import null_backend
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class QtContactsAddressBook(object):
+
+       def __init__(self, name, uri):
+               self._name = name
+               self._uri = uri
+               self._manager = QtContacts.QContactManager.fromUri(uri)
+               self._contacts = None
+
+       @property
+       def name(self):
+               return self._name
+
+       @property
+       def error(self):
+               return self._manager.error()
+
+       def update_account(self, force = True):
+               if not force and self._contacts is not None:
+                       return
+               self._contacts = dict(self._get_contacts())
+
+       def get_contacts(self):
+               if self._contacts is None:
+                       self._contacts = dict(self._get_contacts())
+               return self._contacts
+
+       def _get_contacts(self):
+               contacts = self._manager.contacts()
+               for contact in contacts:
+                       contactId = contact.localId()
+                       contactName = contact.displayLabel()
+                       phoneDetails = contact.details(QtContacts.QContactPhoneNumber().DefinitionName)
+                       phones = [{"phoneType": "Phone", "phoneNumber": phone.value(QtContacts.QContactPhoneNumber().FieldNumber)} for phone in phoneDetails]
+                       contactDetails = phones
+                       if 0 < len(contactDetails):
+                               yield str(contactId), {
+                                       "contactId": str(contactId),
+                                       "name": contactName,
+                                       "numbers": contactDetails,
+                               }
+
+
+class _QtContactsAddressBookFactory(object):
+
+       def __init__(self):
+               self._availableManagers = {}
+
+               availableMgrs = QtContacts.QContactManager.availableManagers()
+               availableMgrs.remove("invalid")
+               for managerName in availableMgrs:
+                       params = {}
+                       managerUri = QtContacts.QContactManager.buildUri(managerName, params)
+                       self._availableManagers[managerName] =  managerUri
+
+       def get_addressbooks(self):
+               for name, uri in self._availableManagers.iteritems():
+                       book = QtContactsAddressBook(name, uri)
+                       if book.error:
+                               _moduleLogger.info("Could not load %r due to %r" % (name, book.error))
+                       else:
+                               yield book
+
+
+class _EmptyAddressBookFactory(object):
+
+       def get_addressbooks(self):
+               if False:
+                       yield None
+
+
+if QtContacts is not None:
+       QtContactsAddressBookFactory = _QtContactsAddressBookFactory
+else:
+       QtContactsAddressBookFactory = _EmptyAddressBookFactory
+       _moduleLogger.info("QtContacts support not available")
+
+
+if __name__ == "__main__":
+       factory = QtContactsAddressBookFactory()
+       books = factory.get_addressbooks()
+       for book in books:
+               print book.name
+               print book.get_contacts()
diff --git a/dialcentral/call_handler.py b/dialcentral/call_handler.py
new file mode 100644 (file)
index 0000000..9b9c47d
--- /dev/null
@@ -0,0 +1,144 @@
+#!/usr/bin/env python
+
+from __future__ import with_statement
+from __future__ import division
+
+import logging
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+import dbus
+try:
+       import telepathy as _telepathy
+       import util.tp_utils as telepathy_utils
+       telepathy = _telepathy
+except ImportError:
+       telepathy = None
+
+import util.misc as misc_utils
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class _FakeSignaller(object):
+
+       def start(self):
+               pass
+
+       def stop(self):
+               pass
+
+
+class _MissedCallWatcher(QtCore.QObject):
+
+       callMissed = qt_compat.Signal()
+
+       def __init__(self):
+               QtCore.QObject.__init__(self)
+               self._isStarted = False
+               self._isSupported = True
+
+               self._newChannelSignaller = telepathy_utils.NewChannelSignaller(self._on_new_channel)
+               self._outstandingRequests = []
+
+       @property
+       def isSupported(self):
+               return self._isSupported
+
+       @property
+       def isStarted(self):
+               return self._isStarted
+
+       def start(self):
+               if self._isStarted:
+                       _moduleLogger.info("voicemail monitor already started")
+                       return
+               try:
+                       self._newChannelSignaller.start()
+               except RuntimeError:
+                       _moduleLogger.exception("Missed call detection not supported")
+                       self._newChannelSignaller = _FakeSignaller()
+                       self._isSupported = False
+               self._isStarted = True
+
+       def stop(self):
+               if not self._isStarted:
+                       _moduleLogger.info("voicemail monitor stopped without starting")
+                       return
+               _moduleLogger.info("Stopping voicemail refresh")
+               self._newChannelSignaller.stop()
+
+               # I don't want to trust whether the cancel happens within the current
+               # callback or not which could be the deciding factor between invalid
+               # iterators or infinite loops
+               localRequests = [r for r in self._outstandingRequests]
+               for request in localRequests:
+                       localRequests.cancel()
+
+               self._isStarted = False
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_new_channel(self, bus, serviceName, connObjectPath, channelObjectPath, channelType):
+               if channelType != telepathy.interfaces.CHANNEL_TYPE_STREAMED_MEDIA:
+                       return
+
+               conn = telepathy.client.Connection(serviceName, connObjectPath)
+               try:
+                       chan = telepathy.client.Channel(serviceName, channelObjectPath)
+               except dbus.exceptions.UnknownMethodException:
+                       _moduleLogger.exception("Client might not have implemented a deprecated method")
+                       return
+               missDetection = telepathy_utils.WasMissedCall(
+                       bus, conn, chan, self._on_missed_call, self._on_error_for_missed
+               )
+               self._outstandingRequests.append(missDetection)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_missed_call(self, missDetection):
+               _moduleLogger.info("Missed a call")
+               self.callMissed.emit()
+               self._outstandingRequests.remove(missDetection)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_error_for_missed(self, missDetection, reason):
+               _moduleLogger.debug("Error: %r claims %r" % (missDetection, reason))
+               self._outstandingRequests.remove(missDetection)
+
+
+class _DummyMissedCallWatcher(QtCore.QObject):
+
+       callMissed = qt_compat.Signal()
+
+       def __init__(self):
+               QtCore.QObject.__init__(self)
+               self._isStarted = False
+
+       @property
+       def isSupported(self):
+               return False
+
+       @property
+       def isStarted(self):
+               return self._isStarted
+
+       def start(self):
+               self._isStarted = True
+
+       def stop(self):
+               if not self._isStarted:
+                       _moduleLogger.info("voicemail monitor stopped without starting")
+                       return
+               _moduleLogger.info("Stopping voicemail refresh")
+               self._isStarted = False
+
+
+if telepathy is not None:
+       MissedCallWatcher = _MissedCallWatcher
+else:
+       MissedCallWatcher = _DummyMissedCallWatcher
+
+
+if __name__ == "__main__":
+       pass
+
diff --git a/dialcentral/constants.py b/dialcentral/constants.py
new file mode 100644 (file)
index 0000000..b9d3c79
--- /dev/null
@@ -0,0 +1,13 @@
+import os
+
+__pretty_app_name__ = "DialCentral"
+__app_name__ = "dialcentral"
+__version__ = "1.3.6"
+__build__ = 0
+__app_magic__ = 0xdeadbeef
+_data_path_ = os.path.join(os.path.expanduser("~"), ".%s" % __app_name__)
+_user_settings_ = "%s/settings.ini" % _data_path_
+_custom_notifier_settings_ = "%s/notifier.ini" % _data_path_
+_user_logpath_ = "%s/%s.log" % (_data_path_, __app_name__)
+_notifier_logpath_ = "%s/notifier.log" % _data_path_
+IS_MAEMO = True
diff --git a/dialcentral/dialcentral_qt.py b/dialcentral/dialcentral_qt.py
new file mode 100755 (executable)
index 0000000..a464ad6
--- /dev/null
@@ -0,0 +1,812 @@
+#!/usr/bin/env python
+# -*- coding: UTF8 -*-
+
+from __future__ import with_statement
+
+import os
+import base64
+import ConfigParser
+import functools
+import logging
+import logging.handlers
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+QtGui = qt_compat.import_module("QtGui")
+
+import constants
+import alarm_handler
+from util import qtpie
+from util import qwrappers
+from util import qui_utils
+from util import misc as misc_utils
+
+import session
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class Dialcentral(qwrappers.ApplicationWrapper):
+
+       _DATA_PATHS = [
+               os.path.join(os.path.dirname(__file__), "../share"),
+               os.path.join(os.path.dirname(__file__), "../data"),
+       ]
+
+       def __init__(self, app):
+               self._dataPath = None
+               self._aboutDialog = None
+               self.notifyOnMissed = False
+               self.notifyOnVoicemail = False
+               self.notifyOnSms = False
+
+               self._streamHandler = None
+               self._ledHandler = None
+               self._alarmHandler = alarm_handler.AlarmHandler()
+
+               qwrappers.ApplicationWrapper.__init__(self, app, constants)
+
+       def load_settings(self):
+               try:
+                       config = ConfigParser.SafeConfigParser()
+                       config.read(constants._user_settings_)
+               except IOError, e:
+                       _moduleLogger.info("No settings")
+                       return
+               except ValueError:
+                       _moduleLogger.info("Settings were corrupt")
+                       return
+               except ConfigParser.MissingSectionHeaderError:
+                       _moduleLogger.info("Settings were corrupt")
+                       return
+               except Exception:
+                       _moduleLogger.exception("Unknown loading error")
+
+               self._mainWindow.load_settings(config)
+
+       def save_settings(self):
+               _moduleLogger.info("Saving settings")
+               config = ConfigParser.SafeConfigParser()
+
+               self._mainWindow.save_settings(config)
+
+               with open(constants._user_settings_, "wb") as configFile:
+                       config.write(configFile)
+
+       def get_icon(self, name):
+               if self._dataPath is None:
+                       for path in self._DATA_PATHS:
+                               if os.path.exists(os.path.join(path, name)):
+                                       self._dataPath = path
+                                       break
+               if self._dataPath is not None:
+                       icon = QtGui.QIcon(os.path.join(self._dataPath, name))
+                       return icon
+               else:
+                       return None
+
+       def get_resource(self, name):
+               if self._dataPath is None:
+                       for path in self._DATA_PATHS:
+                               if os.path.exists(os.path.join(path, name)):
+                                       self._dataPath = path
+                                       break
+               if self._dataPath is not None:
+                       return os.path.join(self._dataPath, name)
+               else:
+                       return None
+
+       def _close_windows(self):
+               qwrappers.ApplicationWrapper._close_windows(self)
+               if self._aboutDialog  is not None:
+                       self._aboutDialog.close()
+
+       @property
+       def fsContactsPath(self):
+               return os.path.join(constants._data_path_, "contacts")
+
+       @property
+       def streamHandler(self):
+               if self._streamHandler is None:
+                       import stream_handler
+                       self._streamHandler = stream_handler.StreamHandler()
+               return self._streamHandler
+
+       @property
+       def alarmHandler(self):
+               return self._alarmHandler
+
+       @property
+       def ledHandler(self):
+               if self._ledHandler is None:
+                       import led_handler
+                       self._ledHandler = led_handler.LedHandler()
+               return self._ledHandler
+
+       def _new_main_window(self):
+               return MainWindow(None, self)
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_about(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       if self._aboutDialog is None:
+                               import dialogs
+                               self._aboutDialog = dialogs.AboutDialog(self)
+                       response = self._aboutDialog.run(self._mainWindow.window)
+
+
+class DelayedWidget(object):
+
+       def __init__(self, app, settingsNames):
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.setContentsMargins(0, 0, 0, 0)
+               self._widget = QtGui.QWidget()
+               self._widget.setContentsMargins(0, 0, 0, 0)
+               self._widget.setLayout(self._layout)
+               self._settings = dict((name, "") for name in settingsNames)
+
+               self._child = None
+               self._isEnabled = True
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def has_child(self):
+               return self._child is not None
+
+       def set_child(self, child):
+               if self._child is not None:
+                       self._layout.removeWidget(self._child.toplevel)
+               self._child = child
+               if self._child is not None:
+                       self._layout.addWidget(self._child.toplevel)
+
+               self._child.set_settings(self._settings)
+
+               if self._isEnabled:
+                       self._child.enable()
+               else:
+                       self._child.disable()
+
+       @property
+       def child(self):
+               return self._child
+
+       def enable(self):
+               self._isEnabled = True
+               if self._child is not None:
+                       self._child.enable()
+
+       def disable(self):
+               self._isEnabled = False
+               if self._child is not None:
+                       self._child.disable()
+
+       def clear(self):
+               if self._child is not None:
+                       self._child.clear()
+
+       def refresh(self, force=True):
+               if self._child is not None:
+                       self._child.refresh(force)
+
+       def get_settings(self):
+               if self._child is not None:
+                       return self._child.get_settings()
+               else:
+                       return self._settings
+
+       def set_settings(self, settings):
+               if self._child is not None:
+                       self._child.set_settings(settings)
+               else:
+                       self._settings = settings
+
+
+def _tab_factory(tab, app, session, errorLog):
+       import gv_views
+       return gv_views.__dict__[tab](app, session, errorLog)
+
+
+class MainWindow(qwrappers.WindowWrapper):
+
+       KEYPAD_TAB = 0
+       RECENT_TAB = 1
+       MESSAGES_TAB = 2
+       CONTACTS_TAB = 3
+       MAX_TABS = 4
+
+       _TAB_TITLES = [
+               "Dialpad",
+               "History",
+               "Messages",
+               "Contacts",
+       ]
+       assert len(_TAB_TITLES) == MAX_TABS
+
+       _TAB_ICONS = [
+               "dialpad.png",
+               "history.png",
+               "messages.png",
+               "contacts.png",
+       ]
+       assert len(_TAB_ICONS) == MAX_TABS
+
+       _TAB_CLASS = [
+               functools.partial(_tab_factory, "Dialpad"),
+               functools.partial(_tab_factory, "History"),
+               functools.partial(_tab_factory, "Messages"),
+               functools.partial(_tab_factory, "Contacts"),
+       ]
+       assert len(_TAB_CLASS) == MAX_TABS
+
+       # Hack to allow delay importing/loading of tabs
+       _TAB_SETTINGS_NAMES = [
+               (),
+               ("filter", ),
+               ("status", "type"),
+               ("selectedAddressbook", ),
+       ]
+       assert len(_TAB_SETTINGS_NAMES) == MAX_TABS
+
+       def __init__(self, parent, app):
+               qwrappers.WindowWrapper.__init__(self, parent, app)
+               self._window.setWindowTitle("%s" % constants.__pretty_app_name__)
+               self._window.resized.connect(self._on_window_resized)
+               self._errorLog = self._app.errorLog
+
+               self._session = session.Session(self._errorLog, constants._data_path_)
+               self._session.error.connect(self._on_session_error)
+               self._session.loggedIn.connect(self._on_login)
+               self._session.loggedOut.connect(self._on_logout)
+               self._session.draft.recipientsChanged.connect(self._on_recipients_changed)
+               self._session.newMessages.connect(self._on_new_message_alert)
+               self._app.alarmHandler.applicationNotifySignal.connect(self._on_app_alert)
+               self._voicemailRefreshDelay = QtCore.QTimer()
+               self._voicemailRefreshDelay.setInterval(30 * 1000)
+               self._voicemailRefreshDelay.timeout.connect(self._on_call_missed)
+               self._voicemailRefreshDelay.setSingleShot(True)
+               self._callHandler = None
+               self._updateVoicemailOnMissedCall = False
+
+               self._defaultCredentials = "", ""
+               self._curentCredentials = "", ""
+               self._currentTab = 0
+
+               self._credentialsDialog = None
+               self._smsEntryDialog = None
+               self._accountDialog = None
+
+               self._tabsContents = [
+                       DelayedWidget(self._app, self._TAB_SETTINGS_NAMES[i])
+                       for i in xrange(self.MAX_TABS)
+               ]
+               for tab in self._tabsContents:
+                       tab.disable()
+
+               self._tabWidget = QtGui.QTabWidget()
+               if qui_utils.screen_orientation() == QtCore.Qt.Vertical:
+                       self._tabWidget.setTabPosition(QtGui.QTabWidget.South)
+               else:
+                       self._tabWidget.setTabPosition(QtGui.QTabWidget.West)
+               defaultTabIconSize = self._tabWidget.iconSize()
+               defaultTabIconWidth, defaultTabIconHeight = defaultTabIconSize.width(), defaultTabIconSize.height()
+               for tabIndex, (tabTitle, tabIcon) in enumerate(
+                       zip(self._TAB_TITLES, self._TAB_ICONS)
+               ):
+                       icon = self._app.get_icon(tabIcon)
+                       if constants.IS_MAEMO and icon is not None:
+                               tabTitle = ""
+
+                       if icon is None:
+                               self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, tabTitle)
+                       else:
+                               iconSize = icon.availableSizes()[0]
+                               defaultTabIconWidth = max(defaultTabIconWidth, iconSize.width())
+                               defaultTabIconHeight = max(defaultTabIconHeight, iconSize.height())
+                               self._tabWidget.addTab(self._tabsContents[tabIndex].toplevel, icon, tabTitle)
+               defaultTabIconWidth = max(defaultTabIconWidth, 32)
+               defaultTabIconHeight = max(defaultTabIconHeight, 32)
+               self._tabWidget.setIconSize(QtCore.QSize(defaultTabIconWidth, defaultTabIconHeight))
+               self._tabWidget.currentChanged.connect(self._on_tab_changed)
+               self._tabWidget.setContentsMargins(0, 0, 0, 0)
+
+               self._layout.addWidget(self._tabWidget)
+
+               self._loginAction = QtGui.QAction(None)
+               self._loginAction.setText("Login")
+               self._loginAction.triggered.connect(self._on_login_requested)
+
+               self._importAction = QtGui.QAction(None)
+               self._importAction.setText("Import")
+               self._importAction.triggered.connect(self._on_import)
+
+               self._accountAction = QtGui.QAction(None)
+               self._accountAction.setText("Account")
+               self._accountAction.triggered.connect(self._on_account)
+
+               self._refreshConnectionAction = QtGui.QAction(None)
+               self._refreshConnectionAction.setText("Refresh Connection")
+               self._refreshConnectionAction.setShortcut(QtGui.QKeySequence("CTRL+a"))
+               self._refreshConnectionAction.triggered.connect(self._on_refresh_connection)
+
+               self._refreshTabAction = QtGui.QAction(None)
+               self._refreshTabAction.setText("Refresh Tab")
+               self._refreshTabAction.setShortcut(QtGui.QKeySequence("CTRL+r"))
+               self._refreshTabAction.triggered.connect(self._on_refresh)
+
+               fileMenu = self._window.menuBar().addMenu("&File")
+               fileMenu.addAction(self._loginAction)
+               fileMenu.addAction(self._refreshTabAction)
+               fileMenu.addAction(self._refreshConnectionAction)
+
+               toolsMenu = self._window.menuBar().addMenu("&Tools")
+               toolsMenu.addAction(self._accountAction)
+               toolsMenu.addAction(self._importAction)
+               toolsMenu.addAction(self._app.aboutAction)
+
+               self._initialize_tab(self._tabWidget.currentIndex())
+               self.set_fullscreen(self._app.fullscreenAction.isChecked())
+               self.update_orientation(self._app.orientation)
+
+       def _init_call_handler(self):
+               if self._callHandler is not None:
+                       return
+               import call_handler
+               self._callHandler = call_handler.MissedCallWatcher()
+               self._callHandler.callMissed.connect(self._voicemailRefreshDelay.start)
+
+       def set_default_credentials(self, username, password):
+               self._defaultCredentials = username, password
+
+       def get_default_credentials(self):
+               return self._defaultCredentials
+
+       def walk_children(self):
+               if self._smsEntryDialog is not None:
+                       return (self._smsEntryDialog, )
+               else:
+                       return ()
+
+       def start(self):
+               qwrappers.WindowWrapper.start(self)
+               assert self._session.state == self._session.LOGGEDOUT_STATE, "Initialization messed up"
+               if self._defaultCredentials != ("", ""):
+                       username, password = self._defaultCredentials[0], self._defaultCredentials[1]
+                       self._curentCredentials = username, password
+                       self._session.login(username, password)
+               else:
+                       self._prompt_for_login()
+
+       def close(self):
+               for diag in (
+                       self._credentialsDialog,
+                       self._accountDialog,
+               ):
+                       if diag is not None:
+                               diag.close()
+               for child in self.walk_children():
+                       child.window.destroyed.disconnect(self._on_child_close)
+                       child.window.closed.disconnect(self._on_child_close)
+                       child.close()
+               self._window.close()
+
+       def destroy(self):
+               qwrappers.WindowWrapper.destroy(self)
+               if self._session.state != self._session.LOGGEDOUT_STATE:
+                       self._session.logout()
+
+       def get_current_tab(self):
+               return self._currentTab
+
+       def set_current_tab(self, tabIndex):
+               self._tabWidget.setCurrentIndex(tabIndex)
+
+       def load_settings(self, config):
+               blobs = "", ""
+               isFullscreen = False
+               orientation = self._app.orientation
+               tabIndex = 0
+               try:
+                       blobs = [
+                               config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
+                               for i in xrange(len(self.get_default_credentials()))
+                       ]
+                       isFullscreen = config.getboolean(constants.__pretty_app_name__, "fullscreen")
+                       tabIndex = config.getint(constants.__pretty_app_name__, "tab")
+                       orientation = config.get(constants.__pretty_app_name__, "orientation")
+               except ConfigParser.NoOptionError, e:
+                       _moduleLogger.info(
+                               "Settings file %s is missing option %s" % (
+                                       constants._user_settings_,
+                                       e.option,
+                               ),
+                       )
+               except ConfigParser.NoSectionError, e:
+                       _moduleLogger.info(
+                               "Settings file %s is missing section %s" % (
+                                       constants._user_settings_,
+                                       e.section,
+                               ),
+                       )
+               except Exception:
+                       _moduleLogger.exception("Unknown loading error")
+
+               try:
+                       self._app.alarmHandler.load_settings(config, "alarm")
+                       self._app.notifyOnMissed = config.getboolean("2 - Account Info", "notifyOnMissed")
+                       self._app.notifyOnVoicemail = config.getboolean("2 - Account Info", "notifyOnVoicemail")
+                       self._app.notifyOnSms = config.getboolean("2 - Account Info", "notifyOnSms")
+                       self._updateVoicemailOnMissedCall = config.getboolean("2 - Account Info", "updateVoicemailOnMissedCall")
+               except ConfigParser.NoOptionError, e:
+                       _moduleLogger.info(
+                               "Settings file %s is missing option %s" % (
+                                       constants._user_settings_,
+                                       e.option,
+                               ),
+                       )
+               except ConfigParser.NoSectionError, e:
+                       _moduleLogger.info(
+                               "Settings file %s is missing section %s" % (
+                                       constants._user_settings_,
+                                       e.section,
+                               ),
+                       )
+               except Exception:
+                       _moduleLogger.exception("Unknown loading error")
+
+               creds = (
+                       base64.b64decode(blob)
+                       for blob in blobs
+               )
+               self.set_default_credentials(*creds)
+               self._app.fullscreenAction.setChecked(isFullscreen)
+               self._app.set_orientation(orientation)
+               self.set_current_tab(tabIndex)
+
+               backendId = 2 # For backwards compatibility
+               for tabIndex, tabTitle in enumerate(self._TAB_TITLES):
+                       sectionName = "%s - %s" % (backendId, tabTitle)
+                       settings = self._tabsContents[tabIndex].get_settings()
+                       for settingName in settings.iterkeys():
+                               try:
+                                       settingValue = config.get(sectionName, settingName)
+                               except ConfigParser.NoOptionError, e:
+                                       _moduleLogger.info(
+                                               "Settings file %s is missing section %s" % (
+                                                       constants._user_settings_,
+                                                       e.section,
+                                               ),
+                                       )
+                                       return
+                               except ConfigParser.NoSectionError, e:
+                                       _moduleLogger.info(
+                                               "Settings file %s is missing section %s" % (
+                                                       constants._user_settings_,
+                                                       e.section,
+                                               ),
+                                       )
+                                       return
+                               except Exception:
+                                       _moduleLogger.exception("Unknown loading error")
+                                       return
+                               settings[settingName] = settingValue
+                       self._tabsContents[tabIndex].set_settings(settings)
+
+       def save_settings(self, config):
+               config.add_section(constants.__pretty_app_name__)
+               config.set(constants.__pretty_app_name__, "tab", str(self.get_current_tab()))
+               config.set(constants.__pretty_app_name__, "fullscreen", str(self._app.fullscreenAction.isChecked()))
+               config.set(constants.__pretty_app_name__, "orientation", str(self._app.orientation))
+               for i, value in enumerate(self.get_default_credentials()):
+                       blob = base64.b64encode(value)
+                       config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
+
+               config.add_section("alarm")
+               self._app.alarmHandler.save_settings(config, "alarm")
+               config.add_section("2 - Account Info")
+               config.set("2 - Account Info", "notifyOnMissed", repr(self._app.notifyOnMissed))
+               config.set("2 - Account Info", "notifyOnVoicemail", repr(self._app.notifyOnVoicemail))
+               config.set("2 - Account Info", "notifyOnSms", repr(self._app.notifyOnSms))
+               config.set("2 - Account Info", "updateVoicemailOnMissedCall", repr(self._updateVoicemailOnMissedCall))
+
+               backendId = 2 # For backwards compatibility
+               for tabIndex, tabTitle in enumerate(self._TAB_TITLES):
+                       sectionName = "%s - %s" % (backendId, tabTitle)
+                       config.add_section(sectionName)
+                       tabSettings = self._tabsContents[tabIndex].get_settings()
+                       for settingName, settingValue in tabSettings.iteritems():
+                               config.set(sectionName, settingName, settingValue)
+
+       def update_orientation(self, orientation):
+               qwrappers.WindowWrapper.update_orientation(self, orientation)
+               windowOrientation = self.idealWindowOrientation
+               if windowOrientation == QtCore.Qt.Horizontal:
+                       self._tabWidget.setTabPosition(QtGui.QTabWidget.West)
+               else:
+                       self._tabWidget.setTabPosition(QtGui.QTabWidget.South)
+
+       def _initialize_tab(self, index):
+               assert index < self.MAX_TABS, "Invalid tab"
+               if not self._tabsContents[index].has_child():
+                       tab = self._TAB_CLASS[index](self._app, self._session, self._errorLog)
+                       self._tabsContents[index].set_child(tab)
+               self._tabsContents[index].refresh(force=False)
+
+       def _prompt_for_login(self):
+               if self._credentialsDialog is None:
+                       import dialogs
+                       self._credentialsDialog = dialogs.CredentialsDialog(self._app)
+               credentials = self._credentialsDialog.run(
+                       self._defaultCredentials[0], self._defaultCredentials[1], self.window
+               )
+               if credentials is None:
+                       return
+               username, password = credentials
+               self._curentCredentials = username, password
+               self._session.login(username, password)
+
+       def _show_account_dialog(self):
+               if self._accountDialog is None:
+                       import dialogs
+                       self._accountDialog = dialogs.AccountDialog(self._window, self._app, self._app.errorLog)
+                       self._accountDialog.setIfNotificationsSupported(self._app.alarmHandler.backgroundNotificationsSupported)
+                       self._accountDialog.settingsApproved.connect(self._on_settings_approved)
+
+               if self._callHandler is not None and not self._callHandler.isSupported:
+                       self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED
+               elif self._updateVoicemailOnMissedCall:
+                       self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_ENABLED
+               else:
+                       self._accountDialog.updateVMOnMissedCall = self._accountDialog.VOICEMAIL_CHECK_DISABLED
+               self._accountDialog.notifications = self._app.alarmHandler.alarmType
+               self._accountDialog.notificationTime = self._app.alarmHandler.recurrence
+               self._accountDialog.notifyOnMissed = self._app.notifyOnMissed
+               self._accountDialog.notifyOnVoicemail = self._app.notifyOnVoicemail
+               self._accountDialog.notifyOnSms = self._app.notifyOnSms
+               self._accountDialog.set_callbacks(
+                       self._session.get_callback_numbers(), self._session.get_callback_number()
+               )
+               accountNumberToDisplay = self._session.get_account_number()
+               if not accountNumberToDisplay:
+                       accountNumberToDisplay = "Not Available (%s)" % self._session.state
+               self._accountDialog.set_account_number(accountNumberToDisplay)
+               self._accountDialog.orientation = self._app.orientation
+
+               self._accountDialog.run()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_settings_approved(self):
+               if self._accountDialog.doClear:
+                       self._session.logout_and_clear()
+                       self._defaultCredentials = "", ""
+                       self._curentCredentials = "", ""
+                       for tab in self._tabsContents:
+                               tab.disable()
+               else:
+                       callbackNumber = self._accountDialog.selectedCallback
+                       self._session.set_callback_number(callbackNumber)
+
+               if self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_NOT_SUPPORTED:
+                       pass
+               elif self._accountDialog.updateVMOnMissedCall == self._accountDialog.VOICEMAIL_CHECK_ENABLED:
+                       self._updateVoicemailOnMissedCall = True
+                       self._init_call_handler()
+                       self._callHandler.start()
+               else:
+                       self._updateVoicemailOnMissedCall = False
+                       if self._callHandler is not None:
+                               self._callHandler.stop()
+               if (
+                       self._accountDialog.notifyOnMissed or
+                       self._accountDialog.notifyOnVoicemail or
+                       self._accountDialog.notifyOnSms
+               ):
+                       notifications = self._accountDialog.notifications
+               else:
+                       notifications = self._accountDialog.ALARM_NONE
+               self._app.alarmHandler.apply_settings(notifications, self._accountDialog.notificationTime)
+
+               self._app.notifyOnMissed = self._accountDialog.notifyOnMissed
+               self._app.notifyOnVoicemail = self._accountDialog.notifyOnVoicemail
+               self._app.notifyOnSms = self._accountDialog.notifyOnSms
+               self._app.set_orientation(self._accountDialog.orientation)
+               self._app.save_settings()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_window_resized(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       windowOrientation = self.idealWindowOrientation
+                       if windowOrientation == QtCore.Qt.Horizontal:
+                               self._tabWidget.setTabPosition(QtGui.QTabWidget.West)
+                       else:
+                               self._tabWidget.setTabPosition(QtGui.QTabWidget.South)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_new_message_alert(self):
+               with qui_utils.notify_error(self._errorLog):
+                       if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION:
+                               if self._currentTab == self.MESSAGES_TAB or not self._app.ledHandler.isReal:
+                                       self._errorLog.push_message("New messages")
+                               else:
+                                       self._app.ledHandler.on()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_call_missed(self):
+               with qui_utils.notify_error(self._errorLog):
+                       self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force=True)
+
+       @qt_compat.Slot(str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_session_error(self, message):
+               with qui_utils.notify_error(self._errorLog):
+                       self._errorLog.push_error(message)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_login(self):
+               with qui_utils.notify_error(self._errorLog):
+                       changedAccounts = self._defaultCredentials != self._curentCredentials
+                       noCallback = not self._session.get_callback_number()
+                       if changedAccounts or noCallback:
+                               self._show_account_dialog()
+
+                       self._defaultCredentials = self._curentCredentials
+
+                       for tab in self._tabsContents:
+                               tab.enable()
+                       self._initialize_tab(self._currentTab)
+                       if self._updateVoicemailOnMissedCall:
+                               self._init_call_handler()
+                               self._callHandler.start()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_logout(self):
+               with qui_utils.notify_error(self._errorLog):
+                       for tab in self._tabsContents:
+                               tab.disable()
+                       if self._callHandler is not None:
+                               self._callHandler.stop()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_app_alert(self):
+               with qui_utils.notify_error(self._errorLog):
+                       if self._session.state == self._session.LOGGEDIN_STATE:
+                               messageType = {
+                                       (True, True): self._session.MESSAGE_ALL,
+                                       (True, False): self._session.MESSAGE_TEXTS,
+                                       (False, True): self._session.MESSAGE_VOICEMAILS,
+                               }[(self._app.notifyOnSms, self._app.notifyOnVoicemail)]
+                               self._session.update_messages(messageType, force=True)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_recipients_changed(self):
+               with qui_utils.notify_error(self._errorLog):
+                       if self._session.draft.get_num_contacts() == 0:
+                               return
+
+                       if self._smsEntryDialog is None:
+                               import dialogs
+                               self._smsEntryDialog = dialogs.SMSEntryWindow(self.window, self._app, self._session, self._errorLog)
+                               self._smsEntryDialog.window.destroyed.connect(self._on_child_close)
+                               self._smsEntryDialog.window.closed.connect(self._on_child_close)
+                               self._smsEntryDialog.window.show()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_child_close(self, obj = None):
+               self._smsEntryDialog = None
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_login_requested(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       self._prompt_for_login()
+
+       @qt_compat.Slot(int)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_tab_changed(self, index):
+               with qui_utils.notify_error(self._errorLog):
+                       self._currentTab = index
+                       self._initialize_tab(index)
+                       if self._app.alarmHandler.alarmType == self._app.alarmHandler.ALARM_APPLICATION:
+                               self._app.ledHandler.off()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       self._tabsContents[self._currentTab].refresh(force=True)
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh_connection(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       self._session.refresh_connection()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_import(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       csvName = QtGui.QFileDialog.getOpenFileName(self._window, caption="Import", filter="CSV Files (*.csv)")
+                       csvName = unicode(csvName)
+                       if not csvName:
+                               return
+                       import shutil
+                       shutil.copy2(csvName, self._app.fsContactsPath)
+                       if self._tabsContents[self.CONTACTS_TAB].has_child:
+                               self._tabsContents[self.CONTACTS_TAB].child.update_addressbooks()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_account(self, checked = True):
+               with qui_utils.notify_error(self._errorLog):
+                       assert self._session.state == self._session.LOGGEDIN_STATE, "Must be logged in for settings"
+                       self._show_account_dialog()
+
+
+def run():
+       try:
+               os.makedirs(constants._data_path_)
+       except OSError, e:
+               if e.errno != 17:
+                       raise
+
+       logFormat = '(%(relativeCreated)5d) %(levelname)-5s %(threadName)s.%(name)s.%(funcName)s: %(message)s'
+       logging.basicConfig(level=logging.DEBUG, format=logFormat)
+       rotating = logging.handlers.RotatingFileHandler(constants._user_logpath_, maxBytes=512*1024, backupCount=1)
+       rotating.setFormatter(logging.Formatter(logFormat))
+       root = logging.getLogger()
+       root.addHandler(rotating)
+       _moduleLogger.info("%s %s-%s" % (constants.__app_name__, constants.__version__, constants.__build__))
+       _moduleLogger.info("OS: %s" % (os.uname()[0], ))
+       _moduleLogger.info("Kernel: %s (%s) for %s" % os.uname()[2:])
+       _moduleLogger.info("Hostname: %s" % os.uname()[1])
+
+       try:
+               import gobject
+               gobject.threads_init()
+       except ImportError:
+               _moduleLogger.info("GObject support not available")
+       try:
+               import dbus
+               try:
+                       from dbus.mainloop.qt import DBusQtMainLoop
+                       DBusQtMainLoop(set_as_default=True)
+                       _moduleLogger.info("Using Qt mainloop")
+               except ImportError:
+                       try:
+                               from dbus.mainloop.glib import DBusGMainLoop
+                               DBusGMainLoop(set_as_default=True)
+                               _moduleLogger.info("Using GObject mainloop")
+                       except ImportError:
+                               _moduleLogger.info("Mainloop not available")
+       except ImportError:
+               _moduleLogger.info("DBus support not available")
+
+       app = QtGui.QApplication([])
+       handle = Dialcentral(app)
+       qtpie.init_pies()
+       return app.exec_()
+
+
+if __name__ == "__main__":
+       import sys
+
+       val = run()
+       sys.exit(val)
diff --git a/dialcentral/dialogs.py b/dialcentral/dialogs.py
new file mode 100644 (file)
index 0000000..8fbf328
--- /dev/null
@@ -0,0 +1,1192 @@
+#!/usr/bin/env python
+
+from __future__ import with_statement
+from __future__ import division
+
+import functools
+import copy
+import logging
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+QtGui = qt_compat.import_module("QtGui")
+
+import constants
+from util import qwrappers
+from util import qui_utils
+from util import misc as misc_utils
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class CredentialsDialog(object):
+
+       def __init__(self, app):
+               self._app = app
+               self._usernameField = QtGui.QLineEdit()
+               self._passwordField = QtGui.QLineEdit()
+               self._passwordField.setEchoMode(QtGui.QLineEdit.Password)
+
+               self._credLayout = QtGui.QGridLayout()
+               self._credLayout.addWidget(QtGui.QLabel("Username"), 0, 0)
+               self._credLayout.addWidget(self._usernameField, 0, 1)
+               self._credLayout.addWidget(QtGui.QLabel("Password"), 1, 0)
+               self._credLayout.addWidget(self._passwordField, 1, 1)
+
+               self._loginButton = QtGui.QPushButton("&Login")
+               self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel)
+               self._buttonLayout.addButton(self._loginButton, QtGui.QDialogButtonBox.AcceptRole)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addLayout(self._credLayout)
+               self._layout.addWidget(self._buttonLayout)
+
+               self._dialog = QtGui.QDialog()
+               self._dialog.setWindowTitle("Login")
+               self._dialog.setLayout(self._layout)
+               self._dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
+               self._buttonLayout.accepted.connect(self._dialog.accept)
+               self._buttonLayout.rejected.connect(self._dialog.reject)
+
+               self._closeWindowAction = QtGui.QAction(None)
+               self._closeWindowAction.setText("Close")
+               self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w"))
+               self._closeWindowAction.triggered.connect(self._on_close_window)
+
+               self._dialog.addAction(self._closeWindowAction)
+               self._dialog.addAction(app.quitAction)
+               self._dialog.addAction(app.fullscreenAction)
+
+       def run(self, defaultUsername, defaultPassword, parent=None):
+               self._dialog.setParent(parent, QtCore.Qt.Dialog)
+               try:
+                       self._usernameField.setText(defaultUsername)
+                       self._passwordField.setText(defaultPassword)
+
+                       response = self._dialog.exec_()
+                       if response == QtGui.QDialog.Accepted:
+                               return str(self._usernameField.text()), str(self._passwordField.text())
+                       elif response == QtGui.QDialog.Rejected:
+                               return None
+                       else:
+                               _moduleLogger.error("Unknown response")
+                               return None
+               finally:
+                       self._dialog.setParent(None, QtCore.Qt.Dialog)
+
+       def close(self):
+               try:
+                       self._dialog.reject()
+               except RuntimeError:
+                       _moduleLogger.exception("Oh well")
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_close_window(self, checked = True):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._dialog.reject()
+
+
+class AboutDialog(object):
+
+       def __init__(self, app):
+               self._app = app
+               self._title = QtGui.QLabel(
+                       "<h1>%s</h1><h3>Version: %s</h3>" % (
+                               constants.__pretty_app_name__, constants.__version__
+                       )
+               )
+               self._title.setTextFormat(QtCore.Qt.RichText)
+               self._title.setAlignment(QtCore.Qt.AlignCenter)
+               self._copyright = QtGui.QLabel("<h6>Developed by Ed Page<h6><h6>Icons: See website</h6>")
+               self._copyright.setTextFormat(QtCore.Qt.RichText)
+               self._copyright.setAlignment(QtCore.Qt.AlignCenter)
+               self._link = QtGui.QLabel('<a href="http://gc-dialer.garage.maemo.org">DialCentral Website</a>')
+               self._link.setTextFormat(QtCore.Qt.RichText)
+               self._link.setAlignment(QtCore.Qt.AlignCenter)
+               self._link.setOpenExternalLinks(True)
+
+               self._buttonLayout = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addWidget(self._title)
+               self._layout.addWidget(self._copyright)
+               self._layout.addWidget(self._link)
+               self._layout.addWidget(self._buttonLayout)
+
+               self._dialog = QtGui.QDialog()
+               self._dialog.setWindowTitle("About")
+               self._dialog.setLayout(self._layout)
+               self._buttonLayout.rejected.connect(self._dialog.reject)
+
+               self._closeWindowAction = QtGui.QAction(None)
+               self._closeWindowAction.setText("Close")
+               self._closeWindowAction.setShortcut(QtGui.QKeySequence("CTRL+w"))
+               self._closeWindowAction.triggered.connect(self._on_close_window)
+
+               self._dialog.addAction(self._closeWindowAction)
+               self._dialog.addAction(app.quitAction)
+               self._dialog.addAction(app.fullscreenAction)
+
+       def run(self, parent=None):
+               self._dialog.setParent(parent, QtCore.Qt.Dialog)
+
+               response = self._dialog.exec_()
+               return response
+
+       def close(self):
+               try:
+                       self._dialog.reject()
+               except RuntimeError:
+                       _moduleLogger.exception("Oh well")
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_close_window(self, checked = True):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._dialog.reject()
+
+
+class AccountDialog(QtCore.QObject, qwrappers.WindowWrapper):
+
+       # @bug Can't enter custom callback numbers
+
+       _RECURRENCE_CHOICES = [
+               (1, "1 minute"),
+               (2, "2 minutes"),
+               (3, "3 minutes"),
+               (5, "5 minutes"),
+               (8, "8 minutes"),
+               (10, "10 minutes"),
+               (15, "15 minutes"),
+               (30, "30 minutes"),
+               (45, "45 minutes"),
+               (60, "1 hour"),
+               (3*60, "3 hours"),
+               (6*60, "6 hours"),
+               (12*60, "12 hours"),
+       ]
+
+       ALARM_NONE = "No Alert"
+       ALARM_BACKGROUND = "Background Alert"
+       ALARM_APPLICATION = "Application Alert"
+
+       VOICEMAIL_CHECK_NOT_SUPPORTED = "Not Supported"
+       VOICEMAIL_CHECK_DISABLED = "Disabled"
+       VOICEMAIL_CHECK_ENABLED = "Enabled"
+
+       settingsApproved = qt_compat.Signal()
+
+       def __init__(self, parent, app, errorLog):
+               QtCore.QObject.__init__(self)
+               qwrappers.WindowWrapper.__init__(self, parent, app)
+               self._app = app
+               self._doClear = False
+
+               self._accountNumberLabel = QtGui.QLabel("NUMBER NOT SET")
+               self._notificationSelecter = QtGui.QComboBox()
+               self._notificationSelecter.currentIndexChanged.connect(self._on_notification_change)
+               self._notificationTimeSelector = QtGui.QComboBox()
+               #self._notificationTimeSelector.setEditable(True)
+               self._notificationTimeSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop)
+               for _, label in self._RECURRENCE_CHOICES:
+                       self._notificationTimeSelector.addItem(label)
+               self._missedCallsNotificationButton = QtGui.QCheckBox("Missed Calls")
+               self._voicemailNotificationButton = QtGui.QCheckBox("Voicemail")
+               self._smsNotificationButton = QtGui.QCheckBox("SMS")
+               self._voicemailOnMissedButton = QtGui.QCheckBox("Voicemail Update on Missed Calls")
+               self._clearButton = QtGui.QPushButton("Clear Account")
+               self._clearButton.clicked.connect(self._on_clear)
+               self._callbackSelector = QtGui.QComboBox()
+               #self._callbackSelector.setEditable(True)
+               self._callbackSelector.setInsertPolicy(QtGui.QComboBox.InsertAtTop)
+               self._orientationSelector = QtGui.QComboBox()
+               for orientationMode in [
+                       self._app.DEFAULT_ORIENTATION,
+                       self._app.AUTO_ORIENTATION,
+                       self._app.LANDSCAPE_ORIENTATION,
+                       self._app.PORTRAIT_ORIENTATION,
+               ]:
+                       self._orientationSelector.addItem(orientationMode)
+
+               self._update_notification_state()
+
+               self._credLayout = QtGui.QGridLayout()
+               self._credLayout.addWidget(QtGui.QLabel("Account"), 0, 0)
+               self._credLayout.addWidget(self._accountNumberLabel, 0, 1)
+               self._credLayout.addWidget(QtGui.QLabel("Callback"), 1, 0)
+               self._credLayout.addWidget(self._callbackSelector, 1, 1)
+               self._credLayout.addWidget(self._notificationSelecter, 2, 0)
+               self._credLayout.addWidget(self._notificationTimeSelector, 2, 1)
+               self._credLayout.addWidget(QtGui.QLabel(""), 3, 0)
+               self._credLayout.addWidget(self._missedCallsNotificationButton, 3, 1)
+               self._credLayout.addWidget(QtGui.QLabel(""), 4, 0)
+               self._credLayout.addWidget(self._voicemailNotificationButton, 4, 1)
+               self._credLayout.addWidget(QtGui.QLabel(""), 5, 0)
+               self._credLayout.addWidget(self._smsNotificationButton, 5, 1)
+               self._credLayout.addWidget(QtGui.QLabel("Other"), 6, 0)
+               self._credLayout.addWidget(self._voicemailOnMissedButton, 6, 1)
+               self._credLayout.addWidget(QtGui.QLabel("Orientation"), 7, 0)
+               self._credLayout.addWidget(self._orientationSelector, 7, 1)
+               self._credLayout.addWidget(QtGui.QLabel(""), 8, 0)
+               self._credLayout.addWidget(QtGui.QLabel(""), 9, 0)
+               self._credLayout.addWidget(self._clearButton, 9, 1)
+
+               self._credWidget = QtGui.QWidget()
+               self._credWidget.setLayout(self._credLayout)
+               self._credWidget.setContentsMargins(0, 0, 0, 0)
+               self._scrollSettings = QtGui.QScrollArea()
+               self._scrollSettings.setWidget(self._credWidget)
+               self._scrollSettings.setWidgetResizable(True)
+               self._scrollSettings.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
+               self._scrollSettings.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+
+               self._applyButton = QtGui.QPushButton("&Apply")
+               self._applyButton.clicked.connect(self._on_settings_apply)
+               self._cancelButton = QtGui.QPushButton("&Cancel")
+               self._cancelButton.clicked.connect(self._on_settings_cancel)
+               self._buttonLayout = QtGui.QHBoxLayout()
+               self._buttonLayout.addStretch()
+               self._buttonLayout.addWidget(self._cancelButton)
+               self._buttonLayout.addStretch()
+               self._buttonLayout.addWidget(self._applyButton)
+               self._buttonLayout.addStretch()
+
+               self._layout.addWidget(self._scrollSettings)
+               self._layout.addLayout(self._buttonLayout)
+               self._layout.setDirection(QtGui.QBoxLayout.TopToBottom)
+
+               self._window.setWindowTitle("Account")
+               self._window.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
+
+       @property
+       def doClear(self):
+               return self._doClear
+
+       def setIfNotificationsSupported(self, isSupported):
+               if isSupported:
+                       self._notificationSelecter.clear()
+                       self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION, self.ALARM_BACKGROUND])
+                       self._notificationTimeSelector.setEnabled(False)
+                       self._missedCallsNotificationButton.setEnabled(False)
+                       self._voicemailNotificationButton.setEnabled(False)
+                       self._smsNotificationButton.setEnabled(False)
+               else:
+                       self._notificationSelecter.clear()
+                       self._notificationSelecter.addItems([self.ALARM_NONE, self.ALARM_APPLICATION])
+                       self._notificationTimeSelector.setEnabled(False)
+                       self._missedCallsNotificationButton.setEnabled(False)
+                       self._voicemailNotificationButton.setEnabled(False)
+                       self._smsNotificationButton.setEnabled(False)
+
+       def set_account_number(self, num):
+               self._accountNumberLabel.setText(num)
+
+       orientation = property(
+               lambda self: str(self._orientationSelector.currentText()),
+               lambda self, mode: qui_utils.set_current_index(self._orientationSelector, mode),
+       )
+
+       def _set_voicemail_on_missed(self, status):
+               if status == self.VOICEMAIL_CHECK_NOT_SUPPORTED:
+                       self._voicemailOnMissedButton.setChecked(False)
+                       self._voicemailOnMissedButton.hide()
+               elif status == self.VOICEMAIL_CHECK_DISABLED:
+                       self._voicemailOnMissedButton.setChecked(False)
+                       self._voicemailOnMissedButton.show()
+               elif status == self.VOICEMAIL_CHECK_ENABLED:
+                       self._voicemailOnMissedButton.setChecked(True)
+                       self._voicemailOnMissedButton.show()
+               else:
+                       raise RuntimeError("Unsupported option for updating voicemail on missed calls %r" % status)
+
+       def _get_voicemail_on_missed(self):
+               if not self._voicemailOnMissedButton.isVisible():
+                       return self.VOICEMAIL_CHECK_NOT_SUPPORTED
+               elif self._voicemailOnMissedButton.isChecked():
+                       return self.VOICEMAIL_CHECK_ENABLED
+               else:
+                       return self.VOICEMAIL_CHECK_DISABLED
+
+       updateVMOnMissedCall = property(_get_voicemail_on_missed, _set_voicemail_on_missed)
+
+       notifications = property(
+               lambda self: str(self._notificationSelecter.currentText()),
+               lambda self, enabled: qui_utils.set_current_index(self._notificationSelecter, enabled),
+       )
+
+       notifyOnMissed = property(
+               lambda self: self._missedCallsNotificationButton.isChecked(),
+               lambda self, enabled: self._missedCallsNotificationButton.setChecked(enabled),
+       )
+
+       notifyOnVoicemail = property(
+               lambda self: self._voicemailNotificationButton.isChecked(),
+               lambda self, enabled: self._voicemailNotificationButton.setChecked(enabled),
+       )
+
+       notifyOnSms = property(
+               lambda self: self._smsNotificationButton.isChecked(),
+               lambda self, enabled: self._smsNotificationButton.setChecked(enabled),
+       )
+
+       def _get_notification_time(self):
+               index = self._notificationTimeSelector.currentIndex()
+               minutes = self._RECURRENCE_CHOICES[index][0]
+               return minutes
+
+       def _set_notification_time(self, minutes):
+               for i, (time, _) in enumerate(self._RECURRENCE_CHOICES):
+                       if time == minutes:
+                               self._notificationTimeSelector.setCurrentIndex(i)
+                               break
+               else:
+                               self._notificationTimeSelector.setCurrentIndex(0)
+
+       notificationTime = property(_get_notification_time, _set_notification_time)
+
+       @property
+       def selectedCallback(self):
+               index = self._callbackSelector.currentIndex()
+               data = str(self._callbackSelector.itemData(index))
+               return data
+
+       def set_callbacks(self, choices, default):
+               self._callbackSelector.clear()
+
+               self._callbackSelector.addItem("Not Set", "")
+
+               uglyDefault = misc_utils.make_ugly(default)
+               if not uglyDefault:
+                       uglyDefault = default
+               for number, description in choices.iteritems():
+                       prettyNumber = misc_utils.make_pretty(number)
+                       uglyNumber = misc_utils.make_ugly(number)
+                       if not uglyNumber:
+                               prettyNumber = number
+                               uglyNumber = number
+
+                       self._callbackSelector.addItem("%s - %s" % (prettyNumber, description), uglyNumber)
+                       if uglyNumber == uglyDefault:
+                               self._callbackSelector.setCurrentIndex(self._callbackSelector.count() - 1)
+
+       def run(self):
+               self._doClear = False
+               self._window.show()
+
+       def close(self):
+               try:
+                       self._window.hide()
+               except RuntimeError:
+                       _moduleLogger.exception("Oh well")
+
+       def _update_notification_state(self):
+               currentText = str(self._notificationSelecter.currentText())
+               if currentText == self.ALARM_BACKGROUND:
+                       self._notificationTimeSelector.setEnabled(True)
+
+                       self._missedCallsNotificationButton.setEnabled(True)
+                       self._voicemailNotificationButton.setEnabled(True)
+                       self._smsNotificationButton.setEnabled(True)
+               elif currentText == self.ALARM_APPLICATION:
+                       self._notificationTimeSelector.setEnabled(True)
+
+                       self._missedCallsNotificationButton.setEnabled(False)
+                       self._voicemailNotificationButton.setEnabled(True)
+                       self._smsNotificationButton.setEnabled(True)
+
+                       self._missedCallsNotificationButton.setChecked(False)
+               else:
+                       self._notificationTimeSelector.setEnabled(False)
+
+                       self._missedCallsNotificationButton.setEnabled(False)
+                       self._voicemailNotificationButton.setEnabled(False)
+                       self._smsNotificationButton.setEnabled(False)
+
+                       self._missedCallsNotificationButton.setChecked(False)
+                       self._voicemailNotificationButton.setChecked(False)
+                       self._smsNotificationButton.setChecked(False)
+
+       @qt_compat.Slot(int)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_notification_change(self, index):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._update_notification_state()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_settings_cancel(self, checked = False):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self.hide()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       def _on_settings_apply(self, checked = False):
+               self.__on_settings_apply(checked)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def __on_settings_apply(self, checked = False):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self.settingsApproved.emit()
+                       self.hide()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_clear(self, checked = False):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._doClear = True
+                       self.settingsApproved.emit()
+                       self.hide()
+
+
+class ContactList(object):
+
+       _SENTINEL_ICON = QtGui.QIcon()
+
+       def __init__(self, app, session):
+               self._app = app
+               self._session = session
+               self._targetLayout = QtGui.QVBoxLayout()
+               self._targetList = QtGui.QWidget()
+               self._targetList.setLayout(self._targetLayout)
+               self._uiItems = []
+               self._closeIcon = qui_utils.get_theme_icon(("window-close", "general_close", "gtk-close"), self._SENTINEL_ICON)
+
+       @property
+       def toplevel(self):
+               return self._targetList
+
+       def setVisible(self, isVisible):
+               self._targetList.setVisible(isVisible)
+
+       def update(self):
+               cids = list(self._session.draft.get_contacts())
+               amountCommon = min(len(cids), len(self._uiItems))
+
+               # Run through everything in common
+               for i in xrange(0, amountCommon):
+                       cid = cids[i]
+                       uiItem = self._uiItems[i]
+                       title = self._session.draft.get_title(cid)
+                       description = self._session.draft.get_description(cid)
+                       numbers = self._session.draft.get_numbers(cid)
+                       uiItem["cid"] = cid
+                       uiItem["title"] = title
+                       uiItem["description"] = description
+                       uiItem["numbers"] = numbers
+                       uiItem["label"].setText(title)
+                       self._populate_number_selector(uiItem["selector"], cid, i, numbers)
+                       uiItem["rowWidget"].setVisible(True)
+
+               # More contacts than ui items
+               for i in xrange(amountCommon, len(cids)):
+                       cid = cids[i]
+                       title = self._session.draft.get_title(cid)
+                       description = self._session.draft.get_description(cid)
+                       numbers = self._session.draft.get_numbers(cid)
+
+                       titleLabel = QtGui.QLabel(title)
+                       titleLabel.setWordWrap(True)
+                       numberSelector = QtGui.QComboBox()
+                       self._populate_number_selector(numberSelector, cid, i, numbers)
+
+                       callback = functools.partial(
+                               self._on_change_number,
+                               i
+                       )
+                       callback.__name__ = "thanks partials for not having names and pyqt for requiring them"
+                       numberSelector.activated.connect(
+                               qt_compat.Slot(int)(callback)
+                       )
+
+                       if self._closeIcon is self._SENTINEL_ICON:
+                               deleteButton = QtGui.QPushButton("Delete")
+                       else:
+                               deleteButton = QtGui.QPushButton(self._closeIcon, "")
+                       deleteButton.setSizePolicy(QtGui.QSizePolicy(
+                               QtGui.QSizePolicy.Minimum,
+                               QtGui.QSizePolicy.Minimum,
+                               QtGui.QSizePolicy.PushButton,
+                       ))
+                       callback = functools.partial(
+                               self._on_remove_contact,
+                               i
+                       )
+                       callback.__name__ = "thanks partials for not having names and pyqt for requiring them"
+                       deleteButton.clicked.connect(callback)
+
+                       rowLayout = QtGui.QHBoxLayout()
+                       rowLayout.addWidget(titleLabel, 1000)
+                       rowLayout.addWidget(numberSelector, 0)
+                       rowLayout.addWidget(deleteButton, 0)
+                       rowWidget = QtGui.QWidget()
+                       rowWidget.setLayout(rowLayout)
+                       self._targetLayout.addWidget(rowWidget)
+
+                       uiItem = {}
+                       uiItem["cid"] = cid
+                       uiItem["title"] = title
+                       uiItem["description"] = description
+                       uiItem["numbers"] = numbers
+                       uiItem["label"] = titleLabel
+                       uiItem["selector"] = numberSelector
+                       uiItem["rowWidget"] = rowWidget
+                       self._uiItems.append(uiItem)
+                       amountCommon = i+1
+
+               # More UI items than contacts
+               for i in xrange(amountCommon, len(self._uiItems)):
+                       uiItem = self._uiItems[i]
+                       uiItem["rowWidget"].setVisible(False)
+                       amountCommon = i+1
+
+       def _populate_number_selector(self, selector, cid, cidIndex, numbers):
+               selector.clear()
+
+               selectedNumber = self._session.draft.get_selected_number(cid)
+               if len(numbers) == 1:
+                       # If no alt numbers available, check the address book
+                       numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1])
+               else:
+                       defaultIndex = _index_number(numbers, selectedNumber)
+
+               for number, description in numbers:
+                       if description:
+                               label = "%s - %s" % (number, description)
+                       else:
+                               label = number
+                       selector.addItem(label)
+               selector.setVisible(True)
+               if 1 < len(numbers):
+                       selector.setEnabled(True)
+                       selector.setCurrentIndex(defaultIndex)
+               else:
+                       selector.setEnabled(False)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_change_number(self, cidIndex, index):
+               with qui_utils.notify_error(self._app.errorLog):
+                       # Exception thrown when the first item is removed
+                       try:
+                               cid = self._uiItems[cidIndex]["cid"]
+                               numbers = self._session.draft.get_numbers(cid)
+                       except IndexError:
+                               _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index))
+                               return
+                       except KeyError:
+                               _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index))
+                               return
+                       number = numbers[index][0]
+                       self._session.draft.set_selected_number(cid, number)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_remove_contact(self, index, toggled):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._session.draft.remove_contact(self._uiItems[index]["cid"])
+
+
+class VoicemailPlayer(object):
+
+       def __init__(self, app, session, errorLog):
+               self._app = app
+               self._session = session
+               self._errorLog = errorLog
+               self._token = None
+               self._session.voicemailAvailable.connect(self._on_voicemail_downloaded)
+               self._session.draft.recipientsChanged.connect(self._on_recipients_changed)
+
+               self._playButton = QtGui.QPushButton("Play")
+               self._playButton.clicked.connect(self._on_voicemail_play)
+               self._pauseButton = QtGui.QPushButton("Pause")
+               self._pauseButton.clicked.connect(self._on_voicemail_pause)
+               self._pauseButton.hide()
+               self._resumeButton = QtGui.QPushButton("Resume")
+               self._resumeButton.clicked.connect(self._on_voicemail_resume)
+               self._resumeButton.hide()
+               self._stopButton = QtGui.QPushButton("Stop")
+               self._stopButton.clicked.connect(self._on_voicemail_stop)
+               self._stopButton.hide()
+
+               self._downloadButton = QtGui.QPushButton("Download Voicemail")
+               self._downloadButton.clicked.connect(self._on_voicemail_download)
+               self._downloadLayout = QtGui.QHBoxLayout()
+               self._downloadLayout.addWidget(self._downloadButton)
+               self._downloadWidget = QtGui.QWidget()
+               self._downloadWidget.setLayout(self._downloadLayout)
+
+               self._playLabel = QtGui.QLabel("Voicemail")
+               self._saveButton = QtGui.QPushButton("Save")
+               self._saveButton.clicked.connect(self._on_voicemail_save)
+               self._playerLayout = QtGui.QHBoxLayout()
+               self._playerLayout.addWidget(self._playLabel)
+               self._playerLayout.addWidget(self._playButton)
+               self._playerLayout.addWidget(self._pauseButton)
+               self._playerLayout.addWidget(self._resumeButton)
+               self._playerLayout.addWidget(self._stopButton)
+               self._playerLayout.addWidget(self._saveButton)
+               self._playerWidget = QtGui.QWidget()
+               self._playerWidget.setLayout(self._playerLayout)
+
+               self._visibleWidget = None
+               self._layout = QtGui.QHBoxLayout()
+               self._layout.setContentsMargins(0, 0, 0, 0)
+               self._widget = QtGui.QWidget()
+               self._widget.setLayout(self._layout)
+               self._update_state()
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def destroy(self):
+               self._session.voicemailAvailable.disconnect(self._on_voicemail_downloaded)
+               self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed)
+               self._invalidate_token()
+
+       def _invalidate_token(self):
+               if self._token is not None:
+                       self._token.invalidate()
+                       self._token.error.disconnect(self._on_play_error)
+                       self._token.stateChange.connect(self._on_play_state)
+                       self._token.invalidated.connect(self._on_play_invalidated)
+
+       def _show_download(self, messageId):
+               if self._visibleWidget is self._downloadWidget:
+                       return
+               self._hide()
+               self._layout.addWidget(self._downloadWidget)
+               self._visibleWidget = self._downloadWidget
+               self._visibleWidget.show()
+
+       def _show_player(self, messageId):
+               if self._visibleWidget is self._playerWidget:
+                       return
+               self._hide()
+               self._layout.addWidget(self._playerWidget)
+               self._visibleWidget = self._playerWidget
+               self._visibleWidget.show()
+
+       def _hide(self):
+               if self._visibleWidget is None:
+                       return
+               self._visibleWidget.hide()
+               self._layout.removeWidget(self._visibleWidget)
+               self._visibleWidget = None
+
+       def _update_play_state(self):
+               if self._token is not None and self._token.isValid:
+                       self._playButton.setText("Stop")
+               else:
+                       self._playButton.setText("Play")
+
+       def _update_state(self):
+               if self._session.draft.get_num_contacts() != 1:
+                       self._hide()
+                       return
+
+               (cid, ) = self._session.draft.get_contacts()
+               messageId = self._session.draft.get_message_id(cid)
+               if messageId is None:
+                       self._hide()
+                       return
+
+               if self._session.is_available(messageId):
+                       self._show_player(messageId)
+               else:
+                       self._show_download(messageId)
+               if self._token is not None:
+                       self._token.invalidate()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_save(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       targetPath = QtGui.QFileDialog.getSaveFileName(None, caption="Save Voicemail", filter="Audio File (*.mp3)")
+                       targetPath = unicode(targetPath)
+                       if not targetPath:
+                               return
+
+                       (cid, ) = self._session.draft.get_contacts()
+                       messageId = self._session.draft.get_message_id(cid)
+                       sourcePath = self._session.voicemail_path(messageId)
+                       import shutil
+                       shutil.copy2(sourcePath, targetPath)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_play_error(self, error):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._app.errorLog.push_error(error)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_play_invalidated(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._playButton.show()
+                       self._pauseButton.hide()
+                       self._resumeButton.hide()
+                       self._stopButton.hide()
+                       self._invalidate_token()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_play_state(self, state):
+               with qui_utils.notify_error(self._app.errorLog):
+                       if state == self._token.STATE_PLAY:
+                               self._playButton.hide()
+                               self._pauseButton.show()
+                               self._resumeButton.hide()
+                               self._stopButton.show()
+                       elif state == self._token.STATE_PAUSE:
+                               self._playButton.hide()
+                               self._pauseButton.hide()
+                               self._resumeButton.show()
+                               self._stopButton.show()
+                       elif state == self._token.STATE_STOP:
+                               self._playButton.show()
+                               self._pauseButton.hide()
+                               self._resumeButton.hide()
+                               self._stopButton.hide()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_play(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       (cid, ) = self._session.draft.get_contacts()
+                       messageId = self._session.draft.get_message_id(cid)
+                       sourcePath = self._session.voicemail_path(messageId)
+
+                       self._invalidate_token()
+                       uri = "file://%s" % sourcePath
+                       self._token = self._app.streamHandler.set_file(uri)
+                       self._token.stateChange.connect(self._on_play_state)
+                       self._token.invalidated.connect(self._on_play_invalidated)
+                       self._token.error.connect(self._on_play_error)
+                       self._token.play()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_pause(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._token.pause()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_resume(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._token.play()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_stop(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._token.stop()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_download(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       (cid, ) = self._session.draft.get_contacts()
+                       messageId = self._session.draft.get_message_id(cid)
+                       self._session.download_voicemail(messageId)
+                       self._hide()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_recipients_changed(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._update_state()
+
+       @qt_compat.Slot(str, str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_voicemail_downloaded(self, messageId, filepath):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._update_state()
+
+
+class SMSEntryWindow(qwrappers.WindowWrapper):
+
+       MAX_CHAR = 160
+       # @bug Somehow a window is being destroyed on object creation which causes glitches on Maemo 5
+
+       def __init__(self, parent, app, session, errorLog):
+               qwrappers.WindowWrapper.__init__(self, parent, app)
+               self._session = session
+               self._session.messagesUpdated.connect(self._on_refresh_history)
+               self._session.historyUpdated.connect(self._on_refresh_history)
+               self._session.draft.recipientsChanged.connect(self._on_recipients_changed)
+
+               self._session.draft.sendingMessage.connect(self._on_op_started)
+               self._session.draft.calling.connect(self._on_op_started)
+               self._session.draft.calling.connect(self._on_calling_started)
+               self._session.draft.cancelling.connect(self._on_op_started)
+
+               self._session.draft.sentMessage.connect(self._on_op_finished)
+               self._session.draft.called.connect(self._on_op_finished)
+               self._session.draft.cancelled.connect(self._on_op_finished)
+               self._session.draft.error.connect(self._on_op_error)
+
+               self._errorLog = errorLog
+
+               self._targetList = ContactList(self._app, self._session)
+               self._history = QtGui.QLabel()
+               self._history.setTextFormat(QtCore.Qt.RichText)
+               self._history.setWordWrap(True)
+               self._voicemailPlayer = VoicemailPlayer(self._app, self._session, self._errorLog)
+               self._smsEntry = QtGui.QTextEdit()
+               self._smsEntry.textChanged.connect(self._on_letter_count_changed)
+
+               self._entryLayout = QtGui.QVBoxLayout()
+               self._entryLayout.addWidget(self._targetList.toplevel)
+               self._entryLayout.addWidget(self._history)
+               self._entryLayout.addWidget(self._voicemailPlayer.toplevel, 0)
+               self._entryLayout.addWidget(self._smsEntry)
+               self._entryLayout.setContentsMargins(0, 0, 0, 0)
+               self._entryWidget = QtGui.QWidget()
+               self._entryWidget.setLayout(self._entryLayout)
+               self._entryWidget.setContentsMargins(0, 0, 0, 0)
+               self._scrollEntry = QtGui.QScrollArea()
+               self._scrollEntry.setWidget(self._entryWidget)
+               self._scrollEntry.setWidgetResizable(True)
+               self._scrollEntry.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignBottom)
+               self._scrollEntry.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
+               self._scrollEntry.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+
+               self._characterCountLabel = QtGui.QLabel("")
+               self._singleNumberSelector = QtGui.QComboBox()
+               self._cids = []
+               self._singleNumberSelector.activated.connect(self._on_single_change_number)
+               self._smsButton = QtGui.QPushButton("SMS")
+               self._smsButton.clicked.connect(self._on_sms_clicked)
+               self._smsButton.setEnabled(False)
+               self._dialButton = QtGui.QPushButton("Dial")
+               self._dialButton.clicked.connect(self._on_call_clicked)
+               self._cancelButton = QtGui.QPushButton("Cancel Call")
+               self._cancelButton.clicked.connect(self._on_cancel_clicked)
+               self._cancelButton.setVisible(False)
+
+               self._buttonLayout = QtGui.QHBoxLayout()
+               self._buttonLayout.addWidget(self._characterCountLabel)
+               self._buttonLayout.addStretch()
+               self._buttonLayout.addWidget(self._singleNumberSelector)
+               self._buttonLayout.addStretch()
+               self._buttonLayout.addWidget(self._smsButton)
+               self._buttonLayout.addWidget(self._dialButton)
+               self._buttonLayout.addWidget(self._cancelButton)
+
+               self._layout.addWidget(self._errorDisplay.toplevel)
+               self._layout.addWidget(self._scrollEntry)
+               self._layout.addLayout(self._buttonLayout)
+               self._layout.setDirection(QtGui.QBoxLayout.TopToBottom)
+
+               self._window.setWindowTitle("Contact")
+               self._window.closed.connect(self._on_close_window)
+               self._window.hidden.connect(self._on_close_window)
+               self._window.resized.connect(self._on_window_resized)
+
+               self._scrollTimer = QtCore.QTimer()
+               self._scrollTimer.setInterval(100)
+               self._scrollTimer.setSingleShot(True)
+               self._scrollTimer.timeout.connect(self._on_delayed_scroll_to_bottom)
+
+               self._smsEntry.setPlainText(self._session.draft.message)
+               self._update_letter_count()
+               self._update_target_fields()
+               self.set_fullscreen(self._app.fullscreenAction.isChecked())
+               self.update_orientation(self._app.orientation)
+
+       def close(self):
+               if self._window is None:
+                       # Already closed
+                       return
+               window = self._window
+               try:
+                       message = unicode(self._smsEntry.toPlainText())
+                       self._session.draft.message = message
+                       self.hide()
+               except AttributeError:
+                       _moduleLogger.exception("Oh well")
+               except RuntimeError:
+                       _moduleLogger.exception("Oh well")
+
+       def destroy(self):
+               self._session.messagesUpdated.disconnect(self._on_refresh_history)
+               self._session.historyUpdated.disconnect(self._on_refresh_history)
+               self._session.draft.recipientsChanged.disconnect(self._on_recipients_changed)
+               self._session.draft.sendingMessage.disconnect(self._on_op_started)
+               self._session.draft.calling.disconnect(self._on_op_started)
+               self._session.draft.calling.disconnect(self._on_calling_started)
+               self._session.draft.cancelling.disconnect(self._on_op_started)
+               self._session.draft.sentMessage.disconnect(self._on_op_finished)
+               self._session.draft.called.disconnect(self._on_op_finished)
+               self._session.draft.cancelled.disconnect(self._on_op_finished)
+               self._session.draft.error.disconnect(self._on_op_error)
+               self._voicemailPlayer.destroy()
+               window = self._window
+               self._window = None
+               try:
+                       window.close()
+                       window.destroy()
+               except AttributeError:
+                       _moduleLogger.exception("Oh well")
+               except RuntimeError:
+                       _moduleLogger.exception("Oh well")
+
+       def update_orientation(self, orientation):
+               qwrappers.WindowWrapper.update_orientation(self, orientation)
+               self._scroll_to_bottom()
+
+       def _update_letter_count(self):
+               count = len(self._smsEntry.toPlainText())
+               numTexts, numCharInText = divmod(count, self.MAX_CHAR)
+               numTexts += 1
+               numCharsLeftInText = self.MAX_CHAR - numCharInText
+               self._characterCountLabel.setText("%d (%d)" % (numCharsLeftInText, numTexts))
+
+       def _update_button_state(self):
+               self._cancelButton.setEnabled(True)
+               if self._session.draft.get_num_contacts() == 0:
+                       self._dialButton.setEnabled(False)
+                       self._smsButton.setEnabled(False)
+               elif self._session.draft.get_num_contacts() == 1:
+                       count = len(self._smsEntry.toPlainText())
+                       if count == 0:
+                               self._dialButton.setEnabled(True)
+                               self._smsButton.setEnabled(False)
+                       else:
+                               self._dialButton.setEnabled(False)
+                               self._smsButton.setEnabled(True)
+               else:
+                       self._dialButton.setEnabled(False)
+                       count = len(self._smsEntry.toPlainText())
+                       if count == 0:
+                               self._smsButton.setEnabled(False)
+                       else:
+                               self._smsButton.setEnabled(True)
+
+       def _update_history(self, cid):
+               draftContactsCount = self._session.draft.get_num_contacts()
+               if draftContactsCount != 1:
+                       self._history.setVisible(False)
+               else:
+                       description = self._session.draft.get_description(cid)
+
+                       self._targetList.setVisible(False)
+                       if description:
+                               self._history.setText(description)
+                               self._history.setVisible(True)
+                       else:
+                               self._history.setText("")
+                               self._history.setVisible(False)
+
+       def _update_target_fields(self):
+               draftContactsCount = self._session.draft.get_num_contacts()
+               if draftContactsCount == 0:
+                       self.hide()
+                       del self._cids[:]
+               elif draftContactsCount == 1:
+                       (cid, ) = self._session.draft.get_contacts()
+                       title = self._session.draft.get_title(cid)
+                       numbers = self._session.draft.get_numbers(cid)
+
+                       self._targetList.setVisible(False)
+                       self._update_history(cid)
+                       self._populate_number_selector(self._singleNumberSelector, cid, 0, numbers)
+                       self._cids = [cid]
+
+                       self._scroll_to_bottom()
+                       self._window.setWindowTitle(title)
+                       self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason)
+                       self.show()
+                       self._window.raise_()
+               else:
+                       self._targetList.setVisible(True)
+                       self._targetList.update()
+                       self._history.setText("")
+                       self._history.setVisible(False)
+                       self._singleNumberSelector.setVisible(False)
+
+                       self._scroll_to_bottom()
+                       self._window.setWindowTitle("Contacts")
+                       self._smsEntry.setFocus(QtCore.Qt.OtherFocusReason)
+                       self.show()
+                       self._window.raise_()
+
+       def _populate_number_selector(self, selector, cid, cidIndex, numbers):
+               selector.clear()
+
+               selectedNumber = self._session.draft.get_selected_number(cid)
+               if len(numbers) == 1:
+                       # If no alt numbers available, check the address book
+                       numbers, defaultIndex = _get_contact_numbers(self._session, cid, selectedNumber, numbers[0][1])
+               else:
+                       defaultIndex = _index_number(numbers, selectedNumber)
+
+               for number, description in numbers:
+                       if description:
+                               label = "%s - %s" % (number, description)
+                       else:
+                               label = number
+                       selector.addItem(label)
+               selector.setVisible(True)
+               if 1 < len(numbers):
+                       selector.setEnabled(True)
+                       selector.setCurrentIndex(defaultIndex)
+               else:
+                       selector.setEnabled(False)
+
+       def _scroll_to_bottom(self):
+               self._scrollTimer.start()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_delayed_scroll_to_bottom(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._scrollEntry.ensureWidgetVisible(self._smsEntry)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_sms_clicked(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       message = unicode(self._smsEntry.toPlainText())
+                       self._session.draft.message = message
+                       self._session.draft.send()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_call_clicked(self, arg):
+               with qui_utils.notify_error(self._app.errorLog):
+                       message = unicode(self._smsEntry.toPlainText())
+                       self._session.draft.message = message
+                       self._session.draft.call()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_cancel_clicked(self, message):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._session.draft.cancel()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_single_change_number(self, index):
+               with qui_utils.notify_error(self._app.errorLog):
+                       # Exception thrown when the first item is removed
+                       cid = self._cids[0]
+                       try:
+                               numbers = self._session.draft.get_numbers(cid)
+                       except KeyError:
+                               _moduleLogger.error("Contact no longer available (or bizarre error): %r (%r)" % (cid, index))
+                               return
+                       number = numbers[index][0]
+                       self._session.draft.set_selected_number(cid, number)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh_history(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       draftContactsCount = self._session.draft.get_num_contacts()
+                       if draftContactsCount != 1:
+                               # Changing contact count will automatically refresh it
+                               return
+                       (cid, ) = self._session.draft.get_contacts()
+                       self._update_history(cid)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_recipients_changed(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._update_target_fields()
+                       self._update_button_state()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_op_started(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._smsEntry.setReadOnly(True)
+                       self._smsButton.setVisible(False)
+                       self._dialButton.setVisible(False)
+                       self.show()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_calling_started(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._cancelButton.setVisible(True)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_op_finished(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._smsEntry.setPlainText("")
+                       self._smsEntry.setReadOnly(False)
+                       self._cancelButton.setVisible(False)
+                       self._smsButton.setVisible(True)
+                       self._dialButton.setVisible(True)
+                       self.close()
+                       self.destroy()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_op_error(self, message):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._smsEntry.setReadOnly(False)
+                       self._cancelButton.setVisible(False)
+                       self._smsButton.setVisible(True)
+                       self._dialButton.setVisible(True)
+
+                       self._errorLog.push_error(message)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_letter_count_changed(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._update_letter_count()
+                       self._update_button_state()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_window_resized(self):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self._scroll_to_bottom()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_close_window(self, checked = True):
+               with qui_utils.notify_error(self._app.errorLog):
+                       self.close()
+
+
+def _index_number(numbers, default):
+       uglyDefault = misc_utils.make_ugly(default)
+       uglyContactNumbers = list(
+               misc_utils.make_ugly(contactNumber)
+               for (contactNumber, _) in numbers
+       )
+       defaultMatches = [
+               misc_utils.similar_ugly_numbers(uglyDefault, contactNumber)
+               for contactNumber in uglyContactNumbers
+       ]
+       try:
+               defaultIndex = defaultMatches.index(True)
+       except ValueError:
+               defaultIndex = -1
+               _moduleLogger.warn(
+                       "Could not find contact number %s among %r" % (
+                               default, numbers
+                       )
+               )
+       return defaultIndex
+
+
+def _get_contact_numbers(session, contactId, number, description):
+       contactPhoneNumbers = []
+       if contactId and contactId != "0":
+               try:
+                       contactDetails = copy.deepcopy(session.get_contacts()[contactId])
+                       contactPhoneNumbers = contactDetails["numbers"]
+               except KeyError:
+                       contactPhoneNumbers = []
+               contactPhoneNumbers = [
+                       (contactPhoneNumber["phoneNumber"], contactPhoneNumber.get("phoneType", "Unknown"))
+                       for contactPhoneNumber in contactPhoneNumbers
+               ]
+               defaultIndex = _index_number(contactPhoneNumbers, number)
+
+       if not contactPhoneNumbers or defaultIndex == -1:
+               contactPhoneNumbers += [(number, description)]
+               defaultIndex = 0
+
+       return contactPhoneNumbers, defaultIndex
diff --git a/dialcentral/examples/log_notifier.py b/dialcentral/examples/log_notifier.py
new file mode 100644 (file)
index 0000000..541ac18
--- /dev/null
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+
+from __future__ import with_statement
+
+import sys
+import datetime
+import ConfigParser
+
+
+sys.path.insert(0,"/usr/lib/dialcentral/")
+
+
+import constants
+import alarm_notify
+
+
+def notify_on_change():
+       with open(constants._notifier_logpath_, "a") as file:
+               file.write("Notification: %r\n" % (datetime.datetime.now(), ))
+
+               config = ConfigParser.SafeConfigParser()
+               config.read(constants._user_settings_)
+               backend = alarm_notify.create_backend(config)
+               notifyUser = alarm_notify.is_changed(config, backend)
+
+               if notifyUser:
+                       file.write("\tChange occurred\n")
+
+
+if __name__ == "__main__":
+       notify_on_change()
diff --git a/dialcentral/examples/sound_notifier.py b/dialcentral/examples/sound_notifier.py
new file mode 100644 (file)
index 0000000..c31e413
--- /dev/null
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+
+import os
+import sys
+import ConfigParser
+import logging
+
+
+sys.path.insert(0,"/usr/lib/dialcentral/")
+
+
+import constants
+import alarm_notify
+
+
+def notify_on_change():
+       config = ConfigParser.SafeConfigParser()
+       config.read(constants._user_settings_)
+       backend = alarm_notify.create_backend(config)
+       notifyUser = alarm_notify.is_changed(config, backend)
+
+       config = ConfigParser.SafeConfigParser()
+       config.read(constants._custom_notifier_settings_)
+       soundFile = config.get("Sound Notifier", "soundfile")
+       soundFile = "/usr/lib/gv-notifier/alert.mp3"
+
+       if notifyUser:
+               import subprocess
+               import led_handler
+               logging.info("Changed, playing %s" % soundFile)
+               led = led_handler.LedHandler()
+               led.on()
+               soundOn = subprocess.call("/usr/bin/dbus-send --dest=com.nokia.osso_media_server --print-reply /com/nokia/osso_media_server com.nokia.osso_media_server.music.play_media string:file://%s",shell=True)
+       else:
+               logging.info("No Change")
+
+
+if __name__ == "__main__":
+       logging.basicConfig(level=logging.WARNING, filename=constants._notifier_logpath_)
+       logging.info("Sound Notifier %s-%s" % (constants.__version__, constants.__build__))
+       logging.info("OS: %s" % (os.uname()[0], ))
+       logging.info("Kernel: %s (%s) for %s" % os.uname()[2:])
+       logging.info("Hostname: %s" % os.uname()[1])
+       try:
+               notify_on_change()
+       except:
+               logging.exception("Error")
+               raise
diff --git a/dialcentral/gv_views.py b/dialcentral/gv_views.py
new file mode 100644 (file)
index 0000000..2bd0663
--- /dev/null
@@ -0,0 +1,977 @@
+#!/usr/bin/env python
+
+from __future__ import with_statement
+from __future__ import division
+
+import datetime
+import string
+import itertools
+import logging
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+QtGui = qt_compat.import_module("QtGui")
+
+from util import qtpie
+from util import qui_utils
+from util import misc as misc_utils
+
+import backends.null_backend as null_backend
+import backends.file_backend as file_backend
+import backends.qt_backend as qt_backend
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+_SENTINEL_ICON = QtGui.QIcon()
+
+
+class Dialpad(object):
+
+       def __init__(self, app, session, errorLog):
+               self._app = app
+               self._session = session
+               self._errorLog = errorLog
+
+               self._plus = QtGui.QPushButton("+")
+               self._plus.clicked.connect(lambda: self._on_keypress("+"))
+               self._entry = QtGui.QLineEdit()
+
+               backAction = QtGui.QAction(None)
+               backAction.setText("Back")
+               backAction.triggered.connect(self._on_backspace)
+               backPieItem = qtpie.QActionPieItem(backAction)
+               clearAction = QtGui.QAction(None)
+               clearAction.setText("Clear")
+               clearAction.triggered.connect(self._on_clear_text)
+               clearPieItem = qtpie.QActionPieItem(clearAction)
+               backSlices = [
+                       qtpie.PieFiling.NULL_CENTER,
+                       clearPieItem,
+                       qtpie.PieFiling.NULL_CENTER,
+                       qtpie.PieFiling.NULL_CENTER,
+               ]
+               self._back = qtpie.QPieButton(backPieItem)
+               self._back.set_center(backPieItem)
+               for slice in backSlices:
+                       self._back.insertItem(slice)
+
+               self._entryLayout = QtGui.QHBoxLayout()
+               self._entryLayout.addWidget(self._plus, 1, QtCore.Qt.AlignCenter)
+               self._entryLayout.addWidget(self._entry, 1000)
+               self._entryLayout.addWidget(self._back, 1, QtCore.Qt.AlignCenter)
+
+               smsIcon = self._app.get_icon("messages.png")
+               self._smsButton = QtGui.QPushButton(smsIcon, "SMS")
+               self._smsButton.clicked.connect(self._on_sms_clicked)
+               self._smsButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+               callIcon = self._app.get_icon("dialpad.png")
+               self._callButton = QtGui.QPushButton(callIcon, "Call")
+               self._callButton.clicked.connect(self._on_call_clicked)
+               self._callButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+
+               self._padLayout = QtGui.QGridLayout()
+               rows = [0, 0, 0, 1, 1, 1, 2, 2, 2]
+               columns = [0, 1, 2] * 3
+               keys = [
+                       ("1", ""),
+                       ("2", "ABC"),
+                       ("3", "DEF"),
+                       ("4", "GHI"),
+                       ("5", "JKL"),
+                       ("6", "MNO"),
+                       ("7", "PQRS"),
+                       ("8", "TUV"),
+                       ("9", "WXYZ"),
+               ]
+               for (num, letters), (row, column) in zip(keys, zip(rows, columns)):
+                       self._padLayout.addWidget(self._generate_key_button(num, letters), row, column)
+               self._zerothButton = QtGui.QPushButton("0")
+               self._zerothButton.clicked.connect(lambda: self._on_keypress("0"))
+               self._zerothButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+               self._padLayout.addWidget(self._smsButton, 3, 0)
+               self._padLayout.addWidget(self._zerothButton)
+               self._padLayout.addWidget(self._callButton, 3, 2)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addLayout(self._entryLayout, 0)
+               self._layout.addLayout(self._padLayout, 1000000)
+               self._widget = QtGui.QWidget()
+               self._widget.setLayout(self._layout)
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def enable(self):
+               self._smsButton.setEnabled(True)
+               self._callButton.setEnabled(True)
+
+       def disable(self):
+               self._smsButton.setEnabled(False)
+               self._callButton.setEnabled(False)
+
+       def get_settings(self):
+               return {}
+
+       def set_settings(self, settings):
+               pass
+
+       def clear(self):
+               pass
+
+       def refresh(self, force = True):
+               pass
+
+       def _generate_key_button(self, center, letters):
+               button = QtGui.QPushButton("%s\n%s" % (center, letters))
+               button.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.MinimumExpanding,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+               button.clicked.connect(lambda: self._on_keypress(center))
+               return button
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_keypress(self, key):
+               with qui_utils.notify_error(self._errorLog):
+                       self._entry.insert(key)
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_backspace(self, toggled = False):
+               with qui_utils.notify_error(self._errorLog):
+                       self._entry.backspace()
+
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_clear_text(self, toggled = False):
+               with qui_utils.notify_error(self._errorLog):
+                       self._entry.clear()
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_sms_clicked(self, checked = False):
+               with qui_utils.notify_error(self._errorLog):
+                       number = misc_utils.make_ugly(str(self._entry.text()))
+                       self._entry.clear()
+
+                       contactId = number
+                       title = misc_utils.make_pretty(number)
+                       description = misc_utils.make_pretty(number)
+                       numbersWithDescriptions = [(number, "")]
+                       self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions)
+
+       @qt_compat.Slot()
+       @qt_compat.Slot(bool)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_call_clicked(self, checked = False):
+               with qui_utils.notify_error(self._errorLog):
+                       number = misc_utils.make_ugly(str(self._entry.text()))
+                       self._entry.clear()
+
+                       contactId = number
+                       title = misc_utils.make_pretty(number)
+                       description = misc_utils.make_pretty(number)
+                       numbersWithDescriptions = [(number, "")]
+                       self._session.draft.clear()
+                       self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions)
+                       self._session.draft.call()
+
+
+class TimeCategories(object):
+
+       _NOW_SECTION = 0
+       _TODAY_SECTION = 1
+       _WEEK_SECTION = 2
+       _MONTH_SECTION = 3
+       _REST_SECTION = 4
+       _MAX_SECTIONS = 5
+
+       _NO_ELAPSED = datetime.timedelta(hours=1)
+       _WEEK_ELAPSED = datetime.timedelta(weeks=1)
+       _MONTH_ELAPSED = datetime.timedelta(days=30)
+
+       def __init__(self, parentItem):
+               self._timeItems = [
+                       QtGui.QStandardItem(description)
+                       for (i, description) in zip(
+                               xrange(self._MAX_SECTIONS),
+                               ["Now", "Today", "Week", "Month", "Past"],
+                       )
+               ]
+               for item in self._timeItems:
+                       item.setEditable(False)
+                       item.setCheckable(False)
+                       row = (item, )
+                       parentItem.appendRow(row)
+
+               self._today = datetime.datetime(1900, 1, 1)
+
+               self.prepare_for_update(self._today)
+
+       def prepare_for_update(self, newToday):
+               self._today = newToday
+               for item in self._timeItems:
+                       item.removeRows(0, item.rowCount())
+               try:
+                       hour = self._today.strftime("%X")
+                       day = self._today.strftime("%x")
+               except ValueError:
+                       _moduleLogger.exception("Can't format times")
+                       hour = "Now"
+                       day = "Today"
+               self._timeItems[self._NOW_SECTION].setText(hour)
+               self._timeItems[self._TODAY_SECTION].setText(day)
+
+       def add_row(self, rowDate, row):
+               elapsedTime = self._today - rowDate
+               todayTuple = self._today.timetuple()
+               rowTuple = rowDate.timetuple()
+               if elapsedTime < self._NO_ELAPSED:
+                       section = self._NOW_SECTION
+               elif todayTuple[0:3] == rowTuple[0:3]:
+                       section = self._TODAY_SECTION
+               elif elapsedTime < self._WEEK_ELAPSED:
+                       section = self._WEEK_SECTION
+               elif elapsedTime < self._MONTH_ELAPSED:
+                       section = self._MONTH_SECTION
+               else:
+                       section = self._REST_SECTION
+               self._timeItems[section].appendRow(row)
+
+       def get_item(self, timeIndex, rowIndex, column):
+               timeItem = self._timeItems[timeIndex]
+               item = timeItem.child(rowIndex, column)
+               return item
+
+
+class History(object):
+
+       DETAILS_IDX = 0
+       FROM_IDX = 1
+       MAX_IDX = 2
+
+       HISTORY_RECEIVED = "Received"
+       HISTORY_MISSED = "Missed"
+       HISTORY_PLACED = "Placed"
+       HISTORY_ALL = "All"
+
+       HISTORY_ITEM_TYPES = [HISTORY_RECEIVED, HISTORY_MISSED, HISTORY_PLACED, HISTORY_ALL]
+       HISTORY_COLUMNS = ["", "From"]
+       assert len(HISTORY_COLUMNS) == MAX_IDX
+
+       def __init__(self, app, session, errorLog):
+               self._selectedFilter = self.HISTORY_ITEM_TYPES[-1]
+               self._app = app
+               self._session = session
+               self._session.historyUpdated.connect(self._on_history_updated)
+               self._errorLog = errorLog
+
+               self._typeSelection = QtGui.QComboBox()
+               self._typeSelection.addItems(self.HISTORY_ITEM_TYPES)
+               self._typeSelection.setCurrentIndex(
+                       self.HISTORY_ITEM_TYPES.index(self._selectedFilter)
+               )
+               self._typeSelection.currentIndexChanged[str].connect(self._on_filter_changed)
+               refreshIcon = qui_utils.get_theme_icon(
+                       ("view-refresh", "general_refresh", "gtk-refresh", ),
+                       _SENTINEL_ICON
+               )
+               if refreshIcon is not _SENTINEL_ICON:
+                       self._refreshButton = QtGui.QPushButton(refreshIcon, "")
+               else:
+                       self._refreshButton = QtGui.QPushButton("Refresh")
+               self._refreshButton.clicked.connect(self._on_refresh_clicked)
+               self._refreshButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+               self._managerLayout = QtGui.QHBoxLayout()
+               self._managerLayout.addWidget(self._typeSelection, 1000)
+               self._managerLayout.addWidget(self._refreshButton, 0)
+
+               self._itemStore = QtGui.QStandardItemModel()
+               self._itemStore.setHorizontalHeaderLabels(self.HISTORY_COLUMNS)
+               self._categoryManager = TimeCategories(self._itemStore)
+
+               self._itemView = QtGui.QTreeView()
+               self._itemView.setModel(self._itemStore)
+               self._itemView.setUniformRowHeights(True)
+               self._itemView.setRootIsDecorated(False)
+               self._itemView.setIndentation(0)
+               self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+               self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
+               self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
+               self._itemView.setHeaderHidden(True)
+               self._itemView.setItemsExpandable(False)
+               self._itemView.header().setResizeMode(QtGui.QHeaderView.ResizeToContents)
+               self._itemView.activated.connect(self._on_row_activated)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addLayout(self._managerLayout)
+               self._layout.addWidget(self._itemView)
+               self._widget = QtGui.QWidget()
+               self._widget.setLayout(self._layout)
+
+               self._actionIcon = {
+                       "Placed": self._app.get_icon("placed.png"),
+                       "Missed": self._app.get_icon("missed.png"),
+                       "Received": self._app.get_icon("received.png"),
+               }
+
+               self._populate_items()
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def enable(self):
+               self._itemView.setEnabled(True)
+
+       def disable(self):
+               self._itemView.setEnabled(False)
+
+       def get_settings(self):
+               return {
+                       "filter": self._selectedFilter,
+               }
+
+       def set_settings(self, settings):
+               selectedFilter = settings.get("filter", self.HISTORY_ITEM_TYPES[-1])
+               if selectedFilter in self.HISTORY_ITEM_TYPES:
+                       self._selectedFilter = selectedFilter
+                       self._typeSelection.setCurrentIndex(
+                               self.HISTORY_ITEM_TYPES.index(selectedFilter)
+                       )
+
+       def clear(self):
+               self._itemView.clear()
+
+       def refresh(self, force=True):
+               self._itemView.setFocus(QtCore.Qt.OtherFocusReason)
+
+               if self._selectedFilter == self.HISTORY_RECEIVED:
+                       self._session.update_history(self._session.HISTORY_RECEIVED, force)
+               elif self._selectedFilter == self.HISTORY_MISSED:
+                       self._session.update_history(self._session.HISTORY_MISSED, force)
+               elif self._selectedFilter == self.HISTORY_PLACED:
+                       self._session.update_history(self._session.HISTORY_PLACED, force)
+               elif self._selectedFilter == self.HISTORY_ALL:
+                       self._session.update_history(self._session.HISTORY_ALL, force)
+               else:
+                       assert False, "How did we get here?"
+
+               if self._app.notifyOnMissed and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE:
+                       self._app.ledHandler.off()
+
+       def _populate_items(self):
+               self._categoryManager.prepare_for_update(self._session.get_when_history_updated())
+
+               history = self._session.get_history()
+               history.sort(key=lambda item: item["time"], reverse=True)
+               for event in history:
+                       if self._selectedFilter not in [self.HISTORY_ITEM_TYPES[-1], event["action"]]:
+                               continue
+
+                       relTime = event["relTime"]
+                       action = event["action"]
+                       number = event["number"]
+                       prettyNumber = misc_utils.make_pretty(number)
+                       if prettyNumber.startswith("+1 "):
+                               prettyNumber = prettyNumber[len("+1 "):]
+                       name = event["name"]
+                       if not name or name == number:
+                               name = event["location"]
+                       if not name:
+                               name = "Unknown"
+
+                       detailsItem = QtGui.QStandardItem(self._actionIcon[action], "%s\n%s" % (prettyNumber, relTime))
+                       detailsFont = detailsItem.font()
+                       detailsFont.setPointSize(max(detailsFont.pointSize() - 6, 5))
+                       detailsItem.setFont(detailsFont)
+                       nameItem = QtGui.QStandardItem(name)
+                       nameFont = nameItem.font()
+                       nameFont.setPointSize(nameFont.pointSize() + 4)
+                       nameItem.setFont(nameFont)
+                       row = detailsItem, nameItem
+                       for item in row:
+                               item.setEditable(False)
+                               item.setCheckable(False)
+                       row[self.DETAILS_IDX].setData(event)
+                       self._categoryManager.add_row(event["time"], row)
+               self._itemView.expandAll()
+
+       @qt_compat.Slot(str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_filter_changed(self, newItem):
+               with qui_utils.notify_error(self._errorLog):
+                       self._selectedFilter = str(newItem)
+                       self._populate_items()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_history_updated(self):
+               with qui_utils.notify_error(self._errorLog):
+                       self._populate_items()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh_clicked(self, arg = None):
+               with qui_utils.notify_error(self._errorLog):
+                       self.refresh(force=True)
+
+       @qt_compat.Slot(QtCore.QModelIndex)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_row_activated(self, index):
+               with qui_utils.notify_error(self._errorLog):
+                       timeIndex = index.parent()
+                       if not timeIndex.isValid():
+                               return
+                       timeRow = timeIndex.row()
+                       row = index.row()
+                       detailsItem = self._categoryManager.get_item(timeRow, row, self.DETAILS_IDX)
+                       fromItem = self._categoryManager.get_item(timeRow, row, self.FROM_IDX)
+                       contactDetails = detailsItem.data()
+
+                       title = unicode(fromItem.text())
+                       number = str(contactDetails["number"])
+                       contactId = number # ids don't seem too unique so using numbers
+
+                       descriptionRows = []
+                       for t in xrange(self._itemStore.rowCount()):
+                               randomTimeItem = self._itemStore.item(t, 0)
+                               for i in xrange(randomTimeItem.rowCount()):
+                                       iItem = randomTimeItem.child(i, 0)
+                                       iContactDetails = iItem.data()
+                                       iNumber = str(iContactDetails["number"])
+                                       if number != iNumber:
+                                               continue
+                                       relTime = misc_utils.abbrev_relative_date(iContactDetails["relTime"])
+                                       action = str(iContactDetails["action"])
+                                       number = str(iContactDetails["number"])
+                                       prettyNumber = misc_utils.make_pretty(number)
+                                       rowItems = relTime, action, prettyNumber
+                                       descriptionRows.append("<tr><td>%s</td></tr>" % "</td><td>".join(rowItems))
+                       description = "<table>%s</table>" % "".join(descriptionRows)
+                       numbersWithDescriptions = [(str(contactDetails["number"]), "")]
+                       self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions)
+
+
+class Messages(object):
+
+       NO_MESSAGES = "None"
+       VOICEMAIL_MESSAGES = "Voicemail"
+       TEXT_MESSAGES = "SMS"
+       ALL_TYPES = "All Messages"
+       MESSAGE_TYPES = [NO_MESSAGES, VOICEMAIL_MESSAGES, TEXT_MESSAGES, ALL_TYPES]
+
+       UNREAD_STATUS = "Unread"
+       UNARCHIVED_STATUS = "Inbox"
+       ALL_STATUS = "Any"
+       MESSAGE_STATUSES = [UNREAD_STATUS, UNARCHIVED_STATUS, ALL_STATUS]
+
+       _MIN_MESSAGES_SHOWN = 1
+
+       def __init__(self, app, session, errorLog):
+               self._selectedTypeFilter = self.ALL_TYPES
+               self._selectedStatusFilter = self.ALL_STATUS
+               self._app = app
+               self._session = session
+               self._session.messagesUpdated.connect(self._on_messages_updated)
+               self._errorLog = errorLog
+
+               self._typeSelection = QtGui.QComboBox()
+               self._typeSelection.addItems(self.MESSAGE_TYPES)
+               self._typeSelection.setCurrentIndex(
+                       self.MESSAGE_TYPES.index(self._selectedTypeFilter)
+               )
+               self._typeSelection.currentIndexChanged[str].connect(self._on_type_filter_changed)
+
+               self._statusSelection = QtGui.QComboBox()
+               self._statusSelection.addItems(self.MESSAGE_STATUSES)
+               self._statusSelection.setCurrentIndex(
+                       self.MESSAGE_STATUSES.index(self._selectedStatusFilter)
+               )
+               self._statusSelection.currentIndexChanged[str].connect(self._on_status_filter_changed)
+
+               refreshIcon = qui_utils.get_theme_icon(
+                       ("view-refresh", "general_refresh", "gtk-refresh", ),
+                       _SENTINEL_ICON
+               )
+               if refreshIcon is not _SENTINEL_ICON:
+                       self._refreshButton = QtGui.QPushButton(refreshIcon, "")
+               else:
+                       self._refreshButton = QtGui.QPushButton("Refresh")
+               self._refreshButton.clicked.connect(self._on_refresh_clicked)
+               self._refreshButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+
+               self._selectionLayout = QtGui.QHBoxLayout()
+               self._selectionLayout.addWidget(self._typeSelection, 1000)
+               self._selectionLayout.addWidget(self._statusSelection, 1000)
+               self._selectionLayout.addWidget(self._refreshButton, 0)
+
+               self._itemStore = QtGui.QStandardItemModel()
+               self._itemStore.setHorizontalHeaderLabels(["Messages"])
+               self._categoryManager = TimeCategories(self._itemStore)
+
+               self._htmlDelegate = qui_utils.QHtmlDelegate()
+               self._itemView = QtGui.QTreeView()
+               self._itemView.setModel(self._itemStore)
+               self._itemView.setUniformRowHeights(False)
+               self._itemView.setRootIsDecorated(False)
+               self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+               self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
+               self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
+               self._itemView.setHeaderHidden(True)
+               self._itemView.setItemsExpandable(False)
+               self._itemView.setItemDelegate(self._htmlDelegate)
+               self._itemView.activated.connect(self._on_row_activated)
+               self._itemView.header().sectionResized.connect(self._on_column_resized)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addLayout(self._selectionLayout)
+               self._layout.addWidget(self._itemView)
+               self._widget = QtGui.QWidget()
+               self._widget.setLayout(self._layout)
+
+               self._populate_items()
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def enable(self):
+               self._itemView.setEnabled(True)
+
+       def disable(self):
+               self._itemView.setEnabled(False)
+
+       def get_settings(self):
+               return {
+                       "type": self._selectedTypeFilter,
+                       "status": self._selectedStatusFilter,
+               }
+
+       def set_settings(self, settings):
+               selectedType = settings.get("type", self.ALL_TYPES)
+               if selectedType in self.MESSAGE_TYPES:
+                       self._selectedTypeFilter = selectedType
+                       self._typeSelection.setCurrentIndex(
+                               self.MESSAGE_TYPES.index(self._selectedTypeFilter)
+                       )
+
+               selectedStatus = settings.get("status", self.ALL_STATUS)
+               if selectedStatus in self.MESSAGE_STATUSES:
+                       self._selectedStatusFilter = selectedStatus
+                       self._statusSelection.setCurrentIndex(
+                               self.MESSAGE_STATUSES.index(self._selectedStatusFilter)
+                       )
+
+       def clear(self):
+               self._itemView.clear()
+
+       def refresh(self, force=True):
+               self._itemView.setFocus(QtCore.Qt.OtherFocusReason)
+
+               if self._selectedTypeFilter == self.NO_MESSAGES:
+                       pass
+               elif self._selectedTypeFilter == self.TEXT_MESSAGES:
+                       self._session.update_messages(self._session.MESSAGE_TEXTS, force)
+               elif self._selectedTypeFilter == self.VOICEMAIL_MESSAGES:
+                       self._session.update_messages(self._session.MESSAGE_VOICEMAILS, force)
+               elif self._selectedTypeFilter == self.ALL_TYPES:
+                       self._session.update_messages(self._session.MESSAGE_ALL, force)
+               else:
+                       assert False, "How did we get here?"
+
+               if (self._app.notifyOnSms or self._app.notifyOnVoicemail) and self._app.alarmHandler.alarmType != self._app.alarmHandler.ALARM_NONE:
+                       self._app.ledHandler.off()
+
+       def _populate_items(self):
+               self._categoryManager.prepare_for_update(self._session.get_when_messages_updated())
+
+               rawMessages = self._session.get_messages()
+               rawMessages.sort(key=lambda item: item["time"], reverse=True)
+               for item in rawMessages:
+                       isUnarchived = not item["isArchived"]
+                       isUnread = not item["isRead"]
+                       visibleStatus = {
+                               self.UNREAD_STATUS: isUnarchived and isUnread,
+                               self.UNARCHIVED_STATUS: isUnarchived,
+                               self.ALL_STATUS: True,
+                       }[self._selectedStatusFilter]
+                       visibleType = self._selectedTypeFilter in [item["type"], self.ALL_TYPES]
+                       if not (visibleType and visibleStatus):
+                               continue
+
+                       relTime = misc_utils.abbrev_relative_date(item["relTime"])
+                       number = item["number"]
+                       prettyNumber = misc_utils.make_pretty(number)
+                       name = item["name"]
+                       if not name or name == number:
+                               name = item["location"]
+                       if not name:
+                               name = "Unknown"
+
+                       messageParts = list(item["messageParts"])
+                       if len(messageParts) == 0:
+                               messages = ("No Transcription", )
+                       elif len(messageParts) == 1:
+                               if messageParts[0][1]:
+                                       messages = (messageParts[0][1], )
+                               else:
+                                       messages = ("No Transcription", )
+                       else:
+                               messages = [
+                                       "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
+                                       for messagePart in messageParts
+                               ]
+
+                       firstMessage = "<b>%s<br/>%s</b> <i>(%s)</i>" % (name, prettyNumber, relTime)
+
+                       expandedMessages = [firstMessage]
+                       expandedMessages.extend(messages)
+                       if self._MIN_MESSAGES_SHOWN < len(messages):
+                               secondMessage = "<i>%d Messages Hidden...</i>" % (len(messages) - self._MIN_MESSAGES_SHOWN, )
+                               collapsedMessages = [firstMessage, secondMessage]
+                               collapsedMessages.extend(messages[-(self._MIN_MESSAGES_SHOWN+0):])
+                       else:
+                               collapsedMessages = expandedMessages
+
+                       item = dict(item.iteritems())
+                       item["collapsedMessages"] = "<br/>\n".join(collapsedMessages)
+                       item["expandedMessages"] = "<br/>\n".join(expandedMessages)
+
+                       messageItem = QtGui.QStandardItem(item["collapsedMessages"])
+                       messageItem.setData(item)
+                       messageItem.setEditable(False)
+                       messageItem.setCheckable(False)
+                       row = (messageItem, )
+                       self._categoryManager.add_row(item["time"], row)
+               self._itemView.expandAll()
+
+       @qt_compat.Slot(str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_type_filter_changed(self, newItem):
+               with qui_utils.notify_error(self._errorLog):
+                       self._selectedTypeFilter = str(newItem)
+                       self._populate_items()
+
+       @qt_compat.Slot(str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_status_filter_changed(self, newItem):
+               with qui_utils.notify_error(self._errorLog):
+                       self._selectedStatusFilter = str(newItem)
+                       self._populate_items()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh_clicked(self, arg = None):
+               with qui_utils.notify_error(self._errorLog):
+                       self.refresh(force=True)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_messages_updated(self):
+               with qui_utils.notify_error(self._errorLog):
+                       self._populate_items()
+
+       @qt_compat.Slot(QtCore.QModelIndex)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_row_activated(self, index):
+               with qui_utils.notify_error(self._errorLog):
+                       timeIndex = index.parent()
+                       if not timeIndex.isValid():
+                               return
+                       timeRow = timeIndex.row()
+                       row = index.row()
+                       item = self._categoryManager.get_item(timeRow, row, 0)
+                       contactDetails = item.data()
+
+                       name = unicode(contactDetails["name"])
+                       number = str(contactDetails["number"])
+                       if not name or name == number:
+                               name = unicode(contactDetails["location"])
+                       if not name:
+                               name = "Unknown"
+
+                       if str(contactDetails["type"]) == "Voicemail":
+                               messageId = str(contactDetails["id"])
+                       else:
+                               messageId = None
+                       contactId = str(contactDetails["contactId"])
+                       title = name
+                       description = unicode(contactDetails["expandedMessages"])
+                       numbersWithDescriptions = [(number, "")]
+                       self._session.draft.add_contact(contactId, messageId, title, description, numbersWithDescriptions)
+
+       @qt_compat.Slot(QtCore.QModelIndex)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_column_resized(self, index, oldSize, newSize):
+               self._htmlDelegate.setWidth(newSize, self._itemStore)
+
+
+class Contacts(object):
+
+       # @todo Provide some sort of letter jump
+
+       def __init__(self, app, session, errorLog):
+               self._app = app
+               self._session = session
+               self._session.accountUpdated.connect(self._on_contacts_updated)
+               self._errorLog = errorLog
+               self._addressBookFactories = [
+                       null_backend.NullAddressBookFactory(),
+                       file_backend.FilesystemAddressBookFactory(app.fsContactsPath),
+                       qt_backend.QtContactsAddressBookFactory(),
+               ]
+               self._addressBooks = []
+
+               self._listSelection = QtGui.QComboBox()
+               self._listSelection.addItems([])
+               self._listSelection.currentIndexChanged[str].connect(self._on_filter_changed)
+               self._activeList = "None"
+               refreshIcon = qui_utils.get_theme_icon(
+                       ("view-refresh", "general_refresh", "gtk-refresh", ),
+                       _SENTINEL_ICON
+               )
+               if refreshIcon is not _SENTINEL_ICON:
+                       self._refreshButton = QtGui.QPushButton(refreshIcon, "")
+               else:
+                       self._refreshButton = QtGui.QPushButton("Refresh")
+               self._refreshButton.clicked.connect(self._on_refresh_clicked)
+               self._refreshButton.setSizePolicy(QtGui.QSizePolicy(
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.Minimum,
+                       QtGui.QSizePolicy.PushButton,
+               ))
+               self._managerLayout = QtGui.QHBoxLayout()
+               self._managerLayout.addWidget(self._listSelection, 1000)
+               self._managerLayout.addWidget(self._refreshButton, 0)
+
+               self._itemStore = QtGui.QStandardItemModel()
+               self._itemStore.setHorizontalHeaderLabels(["Contacts"])
+               self._alphaItem = {}
+
+               self._itemView = QtGui.QTreeView()
+               self._itemView.setModel(self._itemStore)
+               self._itemView.setUniformRowHeights(True)
+               self._itemView.setRootIsDecorated(False)
+               self._itemView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+               self._itemView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
+               self._itemView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
+               self._itemView.setHeaderHidden(True)
+               self._itemView.setItemsExpandable(False)
+               self._itemView.activated.connect(self._on_row_activated)
+
+               self._layout = QtGui.QVBoxLayout()
+               self._layout.addLayout(self._managerLayout)
+               self._layout.addWidget(self._itemView)
+               self._widget = QtGui.QWidget()
+               self._widget.setLayout(self._layout)
+
+               self.update_addressbooks()
+               self._populate_items()
+
+       @property
+       def toplevel(self):
+               return self._widget
+
+       def enable(self):
+               self._itemView.setEnabled(True)
+
+       def disable(self):
+               self._itemView.setEnabled(False)
+
+       def get_settings(self):
+               return {
+                       "selectedAddressbook": self._activeList,
+               }
+
+       def set_settings(self, settings):
+               currentItem = settings.get("selectedAddressbook", "None")
+               bookNames = [book["name"] for book in self._addressBooks]
+               try:
+                       newIndex = bookNames.index(currentItem)
+               except ValueError:
+                       # Switch over to None for the user
+                       newIndex = 0
+               self._listSelection.setCurrentIndex(newIndex)
+               self._activeList = currentItem
+
+       def clear(self):
+               self._itemView.clear()
+
+       def refresh(self, force=True):
+               self._itemView.setFocus(QtCore.Qt.OtherFocusReason)
+               self._backend.update_account(force)
+
+       @property
+       def _backend(self):
+               return self._addressBooks[self._listSelection.currentIndex()]["book"]
+
+       def update_addressbooks(self):
+               self._addressBooks = [
+                       {"book": book, "name": book.name}
+                       for factory in self._addressBookFactories
+                       for book in factory.get_addressbooks()
+               ]
+               self._addressBooks.append(
+                       {
+                               "book": self._session,
+                               "name": "Google Voice",
+                       }
+               )
+
+               currentItem = str(self._listSelection.currentText())
+               self._activeList = currentItem
+               if currentItem == "":
+                       # Not loaded yet
+                       currentItem = "None"
+               self._listSelection.clear()
+               bookNames = [book["name"] for book in self._addressBooks]
+               try:
+                       newIndex = bookNames.index(currentItem)
+               except ValueError:
+                       # Switch over to None for the user
+                       newIndex = 0
+                       self._itemStore.clear()
+                       _moduleLogger.info("Addressbook %r doesn't exist anymore, switching to None" % currentItem)
+               self._listSelection.addItems(bookNames)
+               self._listSelection.setCurrentIndex(newIndex)
+
+       def _populate_items(self):
+               self._itemStore.clear()
+               self._alphaItem = dict(
+                       (letter, QtGui.QStandardItem(letter))
+                       for letter in self._prefixes()
+               )
+               for letter in self._prefixes():
+                       item = self._alphaItem[letter]
+                       item.setEditable(False)
+                       item.setCheckable(False)
+                       row = (item, )
+                       self._itemStore.appendRow(row)
+
+               for item in self._get_contacts():
+                       name = item["name"]
+                       if not name:
+                               name = "Unknown"
+                       numbers = item["numbers"]
+
+                       nameItem = QtGui.QStandardItem(name)
+                       nameItem.setEditable(False)
+                       nameItem.setCheckable(False)
+                       nameItem.setData(item)
+                       nameItemFont = nameItem.font()
+                       nameItemFont.setPointSize(max(nameItemFont.pointSize() + 4, 5))
+                       nameItem.setFont(nameItemFont)
+
+                       row = (nameItem, )
+                       rowKey = name[0].upper()
+                       rowKey = rowKey if rowKey in self._alphaItem else "#"
+                       self._alphaItem[rowKey].appendRow(row)
+               self._itemView.expandAll()
+
+       def _prefixes(self):
+               return itertools.chain(string.ascii_uppercase, ("#", ))
+
+       def _jump_to_prefix(self, letter):
+               i = list(self._prefixes()).index(letter)
+               rootIndex = self._itemView.rootIndex()
+               currentIndex = self._itemView.model().index(i, 0, rootIndex)
+               self._itemView.scrollTo(currentIndex)
+               self._itemView.setItemSelected(self._itemView.topLevelItem(i), True)
+
+       def _get_contacts(self):
+               contacts = list(self._backend.get_contacts().itervalues())
+               contacts.sort(key=lambda contact: contact["name"].lower())
+               return contacts
+
+       @qt_compat.Slot(str)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_filter_changed(self, newItem):
+               with qui_utils.notify_error(self._errorLog):
+                       self._activeList = str(newItem)
+                       self.refresh(force=False)
+                       self._populate_items()
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_refresh_clicked(self, arg = None):
+               with qui_utils.notify_error(self._errorLog):
+                       self.refresh(force=True)
+
+       @qt_compat.Slot()
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_contacts_updated(self):
+               with qui_utils.notify_error(self._errorLog):
+                       self._populate_items()
+
+       @qt_compat.Slot(QtCore.QModelIndex)
+       @misc_utils.log_exception(_moduleLogger)
+       def _on_row_activated(self, index):
+               with qui_utils.notify_error(self._errorLog):
+                       letterIndex = index.parent()
+                       if not letterIndex.isValid():
+                               return
+                       letterRow = letterIndex.row()
+                       letter = list(self._prefixes())[letterRow]
+                       letterItem = self._alphaItem[letter]
+                       rowIndex = index.row()
+                       item = letterItem.child(rowIndex, 0)
+                       contactDetails = item.data()
+
+                       name = unicode(contactDetails["name"])
+                       if not name:
+                               name = unicode(contactDetails["location"])
+                       if not name:
+                               name = "Unknown"
+
+                       contactId = str(contactDetails["contactId"])
+                       numbers = contactDetails["numbers"]
+                       numbers = [
+                               dict(
+                                       (str(k), str(v))
+                                       for (k, v) in number.iteritems()
+                               )
+                               for number in numbers
+                       ]
+                       numbersWithDescriptions = [
+                               (
+                                       number["phoneNumber"],
+                                       self._choose_phonetype(number),
+                               )
+                               for number in numbers
+                       ]
+                       title = name
+                       description = name
+                       self._session.draft.add_contact(contactId, None, title, description, numbersWithDescriptions)
+
+       @staticmethod
+       def _choose_phonetype(numberDetails):
+               if "phoneTypeName" in numberDetails:
+                       return numberDetails["phoneTypeName"]
+               elif "phoneType" in numberDetails:
+                       return numberDetails["phoneType"]
+               else:
+                       return ""
diff --git a/dialcentral/led_handler.py b/dialcentral/led_handler.py
new file mode 100755 (executable)
index 0000000..0914105
--- /dev/null
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+
+import dbus
+
+
+class _NokiaLedHandler(object):
+
+       def __init__(self):
+               self._bus = dbus.SystemBus()
+               self._rawMceRequest = self._bus.get_object("com.nokia.mce", "/com/nokia/mce/request")
+               self._mceRequest = dbus.Interface(self._rawMceRequest, dbus_interface="com.nokia.mce.request")
+
+               self._ledPattern = "PatternCommunicationChat"
+
+       def on(self):
+               self._mceRequest.req_led_pattern_activate(self._ledPattern)
+
+       def off(self):
+               self._mceRequest.req_led_pattern_deactivate(self._ledPattern)
+
+
+class _NoLedHandler(object):
+
+       def __init__(self):
+               pass
+
+       def on(self):
+               pass
+
+       def off(self):
+               pass
+
+
+class LedHandler(object):
+
+       def __init__(self):
+               self._actual = None
+               self._isReal = False
+
+       def on(self):
+               self._lazy_init()
+               self._actual.on()
+
+       def off(self):
+               self._lazy_init()
+               self._actual.off()
+
+       @property
+       def isReal(self):
+               self._lazy_init()
+               self._isReal
+
+       def _lazy_init(self):
+               if self._actual is not None:
+                       return
+               try:
+                       self._actual = _NokiaLedHandler()
+                       self._isReal = True
+               except dbus.DBusException:
+                       self._actual = _NoLedHandler()
+                       self._isReal = False
+
+
+if __name__ == "__main__":
+       leds = _NokiaLedHandler()
+       leds.off()
diff --git a/dialcentral/session.py b/dialcentral/session.py
new file mode 100644 (file)
index 0000000..dbdc3e4
--- /dev/null
@@ -0,0 +1,830 @@
+from __future__ import with_statement
+
+import os
+import time
+import datetime
+import contextlib
+import logging
+
+try:
+       import cPickle
+       pickle = cPickle
+except ImportError:
+       import pickle
+
+import util.qt_compat as qt_compat
+QtCore = qt_compat.QtCore
+
+from util import qore_utils
+from util import qui_utils
+from util import concurrent
+from util import misc as misc_utils
+
+import constants
+
+
+_moduleLogger = logging.getLogger(__name__)
+
+
+class _DraftContact(object):
+
+       def __init__(self, messageId, title, description, numbersWithDescriptions):
+               self.messageId = messageId
+               self.title = title
+               self.description = description
+               self.numbers = numbersWithDescriptions
+               self.selectedNumber = numbersWithDescriptions[0][0]
+
+
+class Draft(QtCore.QObject):
+
+       sendingMessage = qt_compat.Signal()
+       sentMessage = qt_compat.Signal()
+       calling = qt_compat.Signal()
+       called = qt_compat.Signal()
+       cancelling = qt_compat.Signal()
+       cancelled = qt_compat.Signal()
+       error = qt_compat.Signal(str)
+
+       recipientsChanged = qt_compat.Signal()
+
+       def __init__(self, asyncQueue, backend, errorLog):
+               QtCore.QObject.__init__(self)
+               self._errorLog = errorLog
+               self._contacts = {}
+               self._asyncQueue = asyncQueue
+               self._backend = backend
+               self._busyReason = None
+               self._message = ""
+
+       def send(self):
+               assert 0 < len(self._contacts), "No contacts selected"
+               assert 0 < len(self._message), "No message to send"
+               numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
+               le = self._asyncQueue.add_async(self._send)
+               le.start(numbers, self._message)
+
+       def call(self):
+               assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
+               assert len(self._message) == 0, "Cannot send message with call"
+               (contact, ) = self._contacts.itervalues()
+               number = misc_utils.make_ugly(contact.selectedNumber)
+               le = self._asyncQueue.add_async(self._call)
+               le.start(number)
+
+       def cancel(self):
+               le = self._asyncQueue.add_async(self._cancel)
+               le.start()
+
+       def _get_message(self):
+               return self._message
+
+       def _set_message(self, message):
+               self._message = message
+
+       message = property(_get_message, _set_message)
+
+       def add_contact(self, contactId, messageId, title, description, numbersWithDescriptions):
+               if self._busyReason is not None:
+                       raise RuntimeError("Please wait for %r" % self._busyReason)
+               # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
+               contactDetails = _DraftContact(messageId, title, description, numbersWithDescriptions)
+               self._contacts[contactId] = contactDetails
+               self.recipientsChanged.emit()
+
+       def remove_contact(self, contactId):
+               if self._busyReason is not None:
+                       raise RuntimeError("Please wait for %r" % self._busyReason)
+               assert contactId in self._contacts, "Contact missing"
+               del self._contacts[contactId]
+               self.recipientsChanged.emit()
+
+       def get_contacts(self):
+               return self._contacts.iterkeys()
+
+       def get_num_contacts(self):
+               return len(self._contacts)
+
+       def get_message_id(self, cid):
+               return self._contacts[cid].messageId
+
+       def get_title(self, cid):
+               return self._contacts[cid].title
+
+       def get_description(self, cid):
+               return self._contacts[cid].description
+
+       def get_numbers(self, cid):
+               return self._contacts[cid].numbers
+
+       def get_selected_number(self, cid):
+               return self._contacts[cid].selectedNumber
+
+       def set_selected_number(self, cid, number):
+               # @note I'm lazy, this isn't firing any kind of signal since only one
+               # controller right now and that is the viewer
+               assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
+               self._contacts[cid].selectedNumber = number
+
+       def clear(self):
+               if self._busyReason is not None:
+                       raise RuntimeError("Please wait for %r" % self._busyReason)
+               self._clear()
+
+       def _clear(self):
+               oldContacts = self._contacts
+               self._contacts = {}
+               self._message = ""
+               if oldContacts:
+                       self.recipientsChanged.emit()
+
+       @contextlib.contextmanager
+       def _busy(self, message):
+               if self._busyReason is not None:
+                       raise RuntimeError("Already busy doing %r" % self._busyReason)
+               try:
+                       self._busyReason = message
+                       yield
+               finally:
+                       self._busyReason = None
+
+       def _send(self, numbers, text):
+               self.sendingMessage.emit()
+               try:
+                       with self._busy("Sending Text"):
+                               with qui_utils.notify_busy(self._errorLog, "Sending Text"):
+                                       yield (
+                                               self._backend[0].send_sms,
+                                               (numbers, text),
+                                               {},
+                                       )
+                               self.sentMessage.emit()
+                               self._clear()
+               except Exception, e:
+                       _moduleLogger.exception("Reporting error to user")
+                       self.error.emit(str(e))
+
+       def _call(self, number):
+               self.calling.emit()
+               try:
+                       with self._busy("Calling"):
+                               with qui_utils.notify_busy(self._errorLog, "Calling"):
+                                       yield (
+                                               self._backend[0].call,
+                                               (number, ),
+                                               {},
+                                       )
+                               self.called.emit()
+                               self._clear()
+               except Exception, e:
+                       _moduleLogger.exception("Reporting error to user")
+                       self.error.emit(str(e))
+
+       def _cancel(self):
+               self.cancelling.emit()
+               try:
+                       with qui_utils.notify_busy(self._errorLog, "Cancelling"):
+                               yield (
+                                       self._backend[0].cancel,
+                                       (),
+                                       {},
+                               )
+                       self.cancelled.emit()
+               except Exception, e:
+                       _moduleLogger.exception("Reporting error to user")
+                       self.error.emit(str(e))
+
+
+class Session(QtCore.QObject):
+
+       # @todo Somehow add support for csv contacts
+       # @BUG When loading without caches, downloads messages twice
+
+       stateChange = qt_compat.Signal(str)
+       loggedOut = qt_compat.Signal()
+       loggedIn = qt_compat.Signal()
+       callbackNumberChanged = qt_compat.Signal(str)
+
+       accountUpdated = qt_compat.Signal()
+       messagesUpdated = qt_compat.Signal()
+       newMessages = qt_compat.Signal()
+       historyUpdated = qt_compat.Signal()
+       dndStateChange = qt_compat.Signal(bool)
+       voicemailAvailable = qt_compat.Signal(str, str)
+
+       error = qt_compat.Signal(str)
+
+       LOGGEDOUT_STATE = "logged out"
+       LOGGINGIN_STATE = "logging in"
+       LOGGEDIN_STATE = "logged in"
+
+       MESSAGE_TEXTS = "Text"
+       MESSAGE_VOICEMAILS = "Voicemail"
+       MESSAGE_ALL = "All"
+
+       HISTORY_RECEIVED = "Received"
+       HISTORY_MISSED = "Missed"
+       HISTORY_PLACED = "Placed"
+       HISTORY_ALL = "All"
+
+       _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
+
+       _LOGGEDOUT_TIME = -1
+       _LOGGINGIN_TIME = 0
+
+       def __init__(self, errorLog, cachePath):
+               QtCore.QObject.__init__(self)
+               self._errorLog = errorLog
+               self._pool = qore_utils.FutureThread()
+               self._asyncQueue = concurrent.AsyncTaskQueue(self._pool)
+               self._backend = []
+               self._loggedInTime = self._LOGGEDOUT_TIME
+               self._loginOps = []
+               self._cachePath = cachePath
+               self._voicemailCachePath = None
+               self._username = None
+               self._password = None
+               self._draft = Draft(self._asyncQueue, self._backend, self._errorLog)
+               self._delayedRelogin = QtCore.QTimer()
+               self._delayedRelogin.setInterval(0)
+               self._delayedRelogin.setSingleShot(True)
+               self._delayedRelogin.timeout.connect(self._on_delayed_relogin)
+
+               self._contacts = {}
+               self._accountUpdateTime = datetime.datetime(1971, 1, 1)
+               self._messages = []
+               self._cleanMessages = []
+               self._messageUpdateTime = datetime.datetime(1971, 1, 1)
+               self._history = []
+               self._historyUpdateTime = datetime.datetime(1971, 1, 1)
+               self._dnd = False
+               self._callback = ""
+
+       @property
+       def state(self):
+               return {
+                       self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
+                       self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
+               }.get(self._loggedInTime, self.LOGGEDIN_STATE)
+
+       @property
+       def draft(self):
+               return self._draft
+
+       def login(self, username, password):
+               assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
+               assert username != "", "No username specified"
+               if self._cachePath is not None:
+                       cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
+               else:
+                       cookiePath = None
+
+               if self._username != username or not self._backend:
+                       from backends import gv_backend
+                       del self._backend[:]
+                       self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
+
+               self._pool.start()
+               le = self._asyncQueue.add_async(self._login)
+               le.start(username, password)
+
+       def logout(self):
+               assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
+               _moduleLogger.info("Logging out")
+               self._pool.stop()
+               self._loggedInTime = self._LOGGEDOUT_TIME
+               self._backend[0].persist()
+               self._save_to_cache()
+               self._clear_voicemail_cache()
+               self.stateChange.emit(self.LOGGEDOUT_STATE)
+               self.loggedOut.emit()
+
+       def clear(self):
+               assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
+               self._backend[0].logout()
+               del self._backend[0]
+               self._clear_cache()
+               self._draft.clear()
+
+       def logout_and_clear(self):
+               assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
+               _moduleLogger.info("Logging out and clearing the account")
+               self._pool.stop()
+               self._loggedInTime = self._LOGGEDOUT_TIME
+               self.clear()
+               self.stateChange.emit(self.LOGGEDOUT_STATE)
+               self.loggedOut.emit()
+
+       def update_account(self, force = True):
+               if not force and self._contacts:
+                       return
+               le = self._asyncQueue.add_async(self._update_account), (), {}
+               self._perform_op_while_loggedin(le)
+
+       def refresh_connection(self):
+               le = self._asyncQueue.add_async(self._refresh_authentication)
+               le.start()
+
+       def get_contacts(self):
+               return self._contacts
+
+       def get_when_contacts_updated(self):
+               return self._accountUpdateTime
+
+       def update_messages(self, messageType, force = True):
+               if not force and self._messages:
+                       return
+               le = self._asyncQueue.add_async(self._update_messages), (messageType, ), {}
+               self._perform_op_while_loggedin(le)
+
+       def get_messages(self):
+               return self._messages
+
+       def get_when_messages_updated(self):
+               return self._messageUpdateTime
+
+       def update_history(self, historyType, force = True):
+               if not force and self._history:
+                       return
+               le = self._asyncQueue.add_async(self._update_history), (historyType, ), {}
+               self._perform_op_while_loggedin(le)
+
+       def get_history(self):
+               return self._history
+
+       def get_when_history_updated(self):
+               return self._historyUpdateTime
+
+       def update_dnd(self):
+               le = self._asyncQueue.add_async(self._update_dnd), (), {}
+               self._perform_op_while_loggedin(le)
+
+       def set_dnd(self, dnd):
+               le = self._asyncQueue.add_async(self._set_dnd)
+               le.start(dnd)
+
+       def is_available(self, messageId):
+               actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
+               return os.path.exists(actualPath)
+
+       def voicemail_path(self, messageId):
+               actualPath = os.path.join(self._voicemailCachePath, "%s.mp3" % messageId)
+               if not os.path.exists(actualPath):
+                       raise RuntimeError("Voicemail not available")
+