Commit 5b468948 authored by Jim Nelson's avatar Jim Nelson

IDLE support: Closes #3885

IDLE support is now baked into ClientConnection and ClientSession.
ClientConnection can be configured to go into an IDLE state when
quiet (no commands pending), and ClientSession will turn this on
when IDLE is detected from the CAPABILITY server data response.
parent cdbf3d76
......@@ -280,8 +280,8 @@ public class MessageViewer : WebKit.WebView {
if (Geary.String.is_empty(_value))
return;
string title = Geary.String.escape_markup(_title);
string value = escape_value ? Geary.String.escape_markup(_value) : _value;
string title = Geary.HTML.escape_markup(_title);
string value = escape_value ? Geary.HTML.escape_markup(_value) : _value;
header_text += "<tr><td class='header_title'>%s</td><td class='header_text'>%s</td></tr>"
.printf(title, value);
......
......@@ -415,9 +415,9 @@ public class Sidebar.Tree : Gtk.TreeView {
assert(!entry_map.has_key(entry));
entry_map.set(entry, wrapper);
store.set(assoc_iter, Columns.NAME, Geary.String.escape_markup(entry.get_sidebar_name()));
store.set(assoc_iter, Columns.NAME, Geary.HTML.escape_markup(entry.get_sidebar_name()));
store.set(assoc_iter, Columns.TOOLTIP, entry.get_sidebar_tooltip() != null ?
Geary.String.escape_markup(entry.get_sidebar_tooltip()) : null);
Geary.HTML.escape_markup(entry.get_sidebar_tooltip()) : null);
store.set(assoc_iter, Columns.WRAPPER, wrapper);
load_entry_icons(assoc_iter);
......@@ -444,8 +444,8 @@ public class Sidebar.Tree : Gtk.TreeView {
EntryWrapper new_wrapper = new EntryWrapper(store, entry, store.get_path(new_iter));
entry_map.set(entry, new_wrapper);
store.set(new_iter, Columns.NAME, Geary.String.escape_markup(entry.get_sidebar_name()));
store.set(new_iter, Columns.TOOLTIP, Geary.String.escape_markup(entry.get_sidebar_tooltip()));
store.set(new_iter, Columns.NAME, Geary.HTML.escape_markup(entry.get_sidebar_name()));
store.set(new_iter, Columns.TOOLTIP, Geary.HTML.escape_markup(entry.get_sidebar_tooltip()));
store.set(new_iter, Columns.WRAPPER, new_wrapper);
load_entry_icons(new_iter);
......@@ -658,7 +658,7 @@ public class Sidebar.Tree : Gtk.TreeView {
assert(wrapper != null);
store.set(wrapper.get_iter(), Columns.TOOLTIP, tooltip != null ?
Geary.String.escape_markup(tooltip) : null);
Geary.HTML.escape_markup(tooltip) : null);
}
private void on_sidebar_icon_changed(Sidebar.Entry entry, Icon? icon) {
......@@ -681,7 +681,7 @@ public class Sidebar.Tree : Gtk.TreeView {
EntryWrapper? wrapper = get_wrapper(entry);
assert(wrapper != null);
store.set(wrapper.get_iter(), Columns.NAME, Geary.String.escape_markup(name));
store.set(wrapper.get_iter(), Columns.NAME, Geary.HTML.escape_markup(name));
}
private Gdk.Pixbuf? fetch_icon_pixbuf(GLib.Icon? gicon) {
......
......@@ -133,3 +133,11 @@ public class Geary.Imap.ExpungeCommand : Command {
}
}
public class Geary.Imap.IdleCommand : Command {
public const string NAME = "idle";
public IdleCommand() {
base (NAME);
}
}
......@@ -6,10 +6,12 @@
public class Geary.Imap.Tag : StringParameter, Hashable, Equalable {
public const string UNTAGGED_VALUE = "*";
public const string CONTINUATION_VALUE = "+";
public const string UNASSIGNED_VALUE = "----";
private static Tag? untagged = null;
private static Tag? unassigned = null;
private static Tag? continuation = null;
public Tag(string value) {
base (value);
......@@ -26,6 +28,13 @@ public class Geary.Imap.Tag : StringParameter, Hashable, Equalable {
return untagged;
}
public static Tag get_continuation() {
if (continuation == null)
continuation = new Tag(CONTINUATION_VALUE);
return continuation;
}
public static Tag get_unassigned() {
if (unassigned == null)
unassigned = new Tag(UNASSIGNED_VALUE);
......@@ -34,11 +43,15 @@ public class Geary.Imap.Tag : StringParameter, Hashable, Equalable {
}
public bool is_tagged() {
return value != UNTAGGED_VALUE;
return (value != UNTAGGED_VALUE) && (value != CONTINUATION_VALUE);
}
public bool is_continuation() {
return value == CONTINUATION_VALUE;
}
public bool is_assigned() {
return value != UNASSIGNED_VALUE;
return (value != UNASSIGNED_VALUE) && (value != CONTINUATION_VALUE);
}
public uint to_hash() {
......
/* Copyright 2011-2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class Geary.Imap.ContinuationResponse : ServerResponse {
public ContinuationResponse(Tag tag) {
base (tag);
}
public ContinuationResponse.reconstitute(RootParameters root) throws ImapError {
base.reconstitute(root);
}
}
......@@ -5,6 +5,12 @@
*/
public abstract class Geary.Imap.ServerResponse : RootParameters {
public enum Type {
STATUS_RESPONSE,
SERVER_DATA,
CONTINUATION_RESPONSE
}
public Tag tag { get; private set; }
public ServerResponse(Tag tag) {
......@@ -19,29 +25,28 @@ public abstract class Geary.Imap.ServerResponse : RootParameters {
// Returns true if the RootParameters represents a StatusResponse, otherwise they should be
// treated as ServerData.
public static ServerResponse from_server(RootParameters root, out bool is_status_response)
public static ServerResponse from_server(RootParameters root, out Type response_type)
throws ImapError {
// must be at least two parameters: a tag and a status or a value
if (root.get_count() < 2) {
throw new ImapError.TYPE_ERROR("Too few parameters (%d) for server response",
root.get_count());
}
Tag tag = new Tag.from_parameter((StringParameter) root.get_as(0, typeof(StringParameter)));
Tag tag = new Tag.from_parameter(root.get_as_string(0));
if (tag.is_tagged()) {
// Attempt to decode second parameter for predefined status codes (piggyback on
// Status.decode's exception if this is invalid)
StringParameter? statusparam = root.get(1) as StringParameter;
StringParameter? statusparam = root.get_if_string(1);
if (statusparam != null)
Status.decode(statusparam.value);
// tagged and has proper status, so it's a status response
is_status_response = true;
response_type = Type.STATUS_RESPONSE;
return new StatusResponse.reconstitute(root);
} else if (tag.is_continuation()) {
// nothing to decode; everything after the tag is human-readable stuff
response_type = Type.CONTINUATION_RESPONSE;
return new ContinuationResponse.reconstitute(root);
}
is_status_response = false;
response_type = Type.SERVER_DATA;
return new ServerData.reconstitute(root);
}
......
......@@ -112,6 +112,22 @@ public class Geary.Imap.UnsolicitedServerData : Object {
return null;
}
}
public string to_string() {
if (exists >= 0)
return "EXISTS %d".printf(exists);
if (recent >= 0)
return "RECENT %d".printf(recent);
if (expunge != null)
return "EXPUNGE %s".printf(expunge.to_string());
if (flags != null)
return "FLAGS %s".printf(flags.to_string());
return "(invalid unsolicited data)";
}
}
......@@ -15,6 +15,7 @@ public class Geary.Imap.ClientSessionManager {
private Gee.HashSet<SelectedContext> selected_contexts = new Gee.HashSet<SelectedContext>();
private int unselected_keepalive_sec = ClientSession.DEFAULT_UNSELECTED_KEEPALIVE_SEC;
private int selected_keepalive_sec = ClientSession.DEFAULT_SELECTED_KEEPALIVE_SEC;
private int selected_with_idle_keepalive_sec = ClientSession.DEFAULT_SELECTED_WITH_IDLE_KEEPALIVE_SEC;
public signal void login_failed();
......@@ -59,6 +60,16 @@ public class Geary.Imap.ClientSessionManager {
this.selected_keepalive_sec = selected_keepalive_sec;
}
/**
* Set to zero or negative value if keepalives should be disabled when a mailbox is selected
* or examined and IDLE is supported. (This is not recommended.)
*
* This only affects newly selected/examined sessions.
*/
public void set_selected_with_idle_keepalive(int selected_with_idle_keepalive_sec) {
this.selected_with_idle_keepalive_sec = selected_with_idle_keepalive_sec;
}
public async Gee.Collection<Geary.Imap.MailboxInformation> list_roots(
Cancellable? cancellable = null) throws Error {
ClientSession session = yield get_authorized_session(cancellable);
......@@ -183,7 +194,8 @@ public class Geary.Imap.ClientSessionManager {
yield new_session.login_async(credentials, cancellable);
// do this after logging in
new_session.enable_keepalives(selected_keepalive_sec, unselected_keepalive_sec);
new_session.enable_keepalives(selected_keepalive_sec, unselected_keepalive_sec,
selected_with_idle_keepalive_sec);
// since "disconnected" is used to remove the ClientSession from the sessions list, want
// to only connect to the signal once the object has been added to the list; otherwise it's
......
......@@ -10,8 +10,9 @@ public class Geary.Imap.ClientSession {
// NOOP is only sent after this amount of time has passed since the last received
// message on the connection dependant on connection state (selected/examined vs. authorized)
public const int DEFAULT_SELECTED_KEEPALIVE_SEC = 45;
public const int DEFAULT_SELECTED_KEEPALIVE_SEC = 60;
public const int DEFAULT_UNSELECTED_KEEPALIVE_SEC = MIN_KEEPALIVE_SEC;
public const int DEFAULT_SELECTED_WITH_IDLE_KEEPALIVE_SEC = MIN_KEEPALIVE_SEC;
public enum Context {
UNCONNECTED,
......@@ -174,10 +175,14 @@ public class Geary.Imap.ClientSession {
Hashable.hash_func, Equalable.equal_func);
private Gee.HashMap<Tag, CommandResponse> tag_response = new Gee.HashMap<Tag, CommandResponse>(
Hashable.hash_func, Equalable.equal_func);
private Gee.HashSet<string> current_capabilities = new Gee.HashSet<string>(String.stri_hash,
String.stri_equal);
private CommandResponse current_cmd_response = new CommandResponse();
private uint keepalive_id = 0;
private int selected_keepalive_secs = 0;
private int unselected_keepalive_secs = 0;
private int selected_with_idle_keepalive_secs = 0;
private bool allow_idle = true;
// state used only during connect and disconnect
private bool awaiting_connect_response = false;
......@@ -346,6 +351,11 @@ public class Geary.Imap.ClientSession {
fsm.set_logging(false);
}
~ClientSession() {
if (keepalive_id != 0)
Source.remove(keepalive_id);
}
public string? get_current_mailbox() {
return current_mailbox;
}
......@@ -416,11 +426,15 @@ public class Geary.Imap.ClientSession {
cx.flush_failure.connect(on_network_flush_error);
cx.received_status_response.connect(on_received_status_response);
cx.received_server_data.connect(on_received_server_data);
cx.received_unsolicited_server_data.connect(on_received_unsolicited_server_data);
cx.received_bad_response.connect(on_received_bad_response);
cx.recv_closed.connect(on_received_closed);
cx.receive_failure.connect(on_network_receive_failure);
cx.deserialize_failure.connect(on_network_receive_failure);
// only use IDLE when in SELECTED or EXAMINED state
cx.set_idle_when_quiet(false);
cx.connect_async.begin(connect_params.cancellable, on_connect_completed);
connect_params.do_yield = true;
......@@ -520,6 +534,7 @@ public class Geary.Imap.ClientSession {
private uint on_login_failed(uint state, uint event, void *user) {
login_failed();
return State.NOAUTH;
}
......@@ -534,9 +549,10 @@ public class Geary.Imap.ClientSession {
* Although keepalives can be enabled at any time, if they're enabled and trigger sending
* a command prior to connection, error signals may be fired.
*/
public void enable_keepalives(int seconds_while_selected = DEFAULT_SELECTED_KEEPALIVE_SEC,
int seconds_while_unselected = DEFAULT_UNSELECTED_KEEPALIVE_SEC) {
public void enable_keepalives(int seconds_while_selected,
int seconds_while_unselected, int seconds_while_selected_with_idle) {
selected_keepalive_secs = seconds_while_selected;
selected_with_idle_keepalive_secs = seconds_while_selected_with_idle;
unselected_keepalive_secs = seconds_while_unselected;
// schedule one now, although will be rescheduled if traffic is received before it fires
......@@ -556,10 +572,24 @@ public class Geary.Imap.ClientSession {
return true;
}
/**
* If enabled, an IDLE command will be used for notification of unsolicited server data whenever
* a mailbox is selected or examined. IDLE will only be used if ClientSession has seen a
* CAPABILITY server data response with IDLE listed as a supported extension.
*
* This will *not* break a connection out of IDLE mode; a command must be sent as well to force
* the connection back to de-idled state.
*/
public void allow_idle_when_selected(bool allow_idle) {
this.allow_idle = allow_idle;
}
private void schedule_keepalive() {
// if old one was scheduled, unschedule and schedule anew
if (keepalive_id != 0)
if (keepalive_id != 0) {
Source.remove(keepalive_id);
keepalive_id = 0;
}
int seconds;
switch (get_context(null)) {
......@@ -569,14 +599,15 @@ public class Geary.Imap.ClientSession {
case Context.IN_PROGRESS:
case Context.EXAMINED:
case Context.SELECTED:
seconds = selected_keepalive_secs;
break;
seconds = (allow_idle && supports_idle()) ? selected_with_idle_keepalive_secs
: selected_keepalive_secs;
break;
case Context.UNAUTHORIZED:
case Context.AUTHORIZED:
default:
seconds = unselected_keepalive_secs;
break;
break;
}
// Possible to not have keepalives in one state but in another, or for neither
......@@ -587,6 +618,7 @@ public class Geary.Imap.ClientSession {
}
private bool on_keepalive() {
debug("Sending keepalive...");
send_command_async.begin(new NoopCommand(), null, on_keepalive_completed);
// Reschedule to reflect current connection state, although will be rescheduled again if
......@@ -611,6 +643,29 @@ public class Geary.Imap.ClientSession {
debug("Keepalive failed: %s", response.status_response.to_string());
}
/**
* ClientSession tracks server extensions reported via the CAPABILITY server data response.
* This comes automatically when logging in and can be fetched by the CAPABILITY command.
* ClientSession stores the last seen list as a service for users and uses it internally
* (specifically for IDLE support). However, ClientSession will not automatically fetch
* capabilities, only watch for them as they're reported. Thus, it's recommended that users
* of ClientSession issue a CapabilityCommand (if needed) before login.
*
* has_capability returns true if the extension was reported. Some extensions (COMPRESS)
* report values as well; accessing these will be added in the future.
*/
public bool has_capability(string name) {
return current_capabilities.contains(name);
}
public Gee.Set<string> get_current_capabilities() {
return current_capabilities.read_only_view;
}
public bool supports_idle() {
return has_capability("idle");
}
//
// send commands
//
......@@ -648,33 +703,12 @@ public class Geary.Imap.ClientSession {
Gee.ArrayList<ServerData>? to_remove = null;
foreach (ServerData data in params.cmd_response.server_data) {
UnsolicitedServerData? unsolicited = UnsolicitedServerData.from_server_data(data);
if (unsolicited == null)
continue;
if (unsolicited.exists >= 0) {
debug("UNSOLICITED EXISTS %d", unsolicited.exists);
unsolicited_exists(unsolicited.exists);
}
if (unsolicited.recent >= 0) {
debug("UNSOLICITED RECENT %d", unsolicited.recent);
unsolicited_recent(unsolicited.recent);
}
if (unsolicited.expunge != null) {
debug("UNSOLICITED EXPUNGE %s", unsolicited.expunge.to_string());
unsolicited_expunged(unsolicited.expunge);
}
if (unsolicited.flags != null) {
debug("UNSOLICITED FLAGS %s", unsolicited.flags.to_string());
unsolicited_flags(unsolicited.flags);
if (unsolicited != null && report_unsolicited_server_data(unsolicited)) {
if (to_remove == null)
to_remove = new Gee.ArrayList<ServerData>();
to_remove.add(data);
}
if (to_remove == null)
to_remove = new Gee.ArrayList<ServerData>();
to_remove.add(data);
}
if (to_remove != null) {
......@@ -775,6 +809,8 @@ public class Geary.Imap.ClientSession {
current_mailbox = params.mailbox;
current_mailbox_readonly = !params.is_select;
cx.set_idle_when_quiet(allow_idle && supports_idle());
return State.SELECTED;
}
......@@ -819,6 +855,8 @@ public class Geary.Imap.ClientSession {
private uint on_close_mailbox(uint state, uint event, void *user, Object? object) {
assert(object != null);
cx.set_idle_when_quiet(false);
AsyncParams params = (AsyncParams) object;
issue_command_async.begin(new CloseCommand(), params, params.cancellable,
......@@ -1092,6 +1130,41 @@ public class Geary.Imap.ClientSession {
return success;
}
private bool report_unsolicited_server_data(UnsolicitedServerData unsolicited) {
bool reported = false;
if (unsolicited.exists >= 0) {
debug("UNSOLICITED EXISTS %d", unsolicited.exists);
unsolicited_exists(unsolicited.exists);
reported = true;
}
if (unsolicited.recent >= 0) {
debug("UNSOLICITED RECENT %d", unsolicited.recent);
unsolicited_recent(unsolicited.recent);
reported = true;
}
if (unsolicited.expunge != null) {
debug("UNSOLICITED EXPUNGE %s", unsolicited.expunge.to_string());
unsolicited_expunged(unsolicited.expunge);
reported = true;
}
if (unsolicited.flags != null) {
debug("UNSOLICITED FLAGS %s", unsolicited.flags.to_string());
unsolicited_flags(unsolicited.flags);
reported = true;
}
return reported;
}
//
// network connection event handlers
//
......@@ -1147,6 +1220,17 @@ public class Geary.Imap.ClientSession {
// reschedule keepalive, now that traffic has been seen
schedule_keepalive();
// Watch for CAPABILITY and store all reported extensions
StringParameter? name = server_data.get_if_string(1);
if (name != null && name.equals_ci(CapabilityCommand.NAME)) {
current_capabilities.clear();
for (int ctr = 2; ctr < server_data.get_count(); ctr++) {
StringParameter? param = server_data.get_if_string(ctr);
if (param != null)
current_capabilities.add(param.value.down());
}
}
// The first response from the server is an untagged status response, which is considered
// ServerData in our model. This captures that and treats it as such.
if (awaiting_connect_response) {
......@@ -1161,6 +1245,10 @@ public class Geary.Imap.ClientSession {
current_cmd_response.add_server_data(server_data);
}
private void on_received_unsolicited_server_data(UnsolicitedServerData unsolicited) {
report_unsolicited_server_data(unsolicited);
}
private void on_received_bad_response(RootParameters root, ImapError err) {
// reschedule keepalive, now that traffic has been seen
schedule_keepalive();
......
......@@ -405,7 +405,8 @@ public class Geary.Imap.Deserializer {
// Atom specials includes space and close-parens, but those are handled in particular ways
// while in the ATOM state, so they're excluded here. Like atom specials, the space is
// treated in a particular way for tags, but unlike atom, the close-parens character is not.
if (state == State.TAG && DataFormat.is_tag_special(ch, " "))
// The + symbol indicates a continuation and needs to be excepted when searching for a tag.
if (state == State.TAG && DataFormat.is_tag_special(ch, " +"))
return state;
else if (state == State.ATOM && DataFormat.is_atom_special(ch, (string) atom_specials_exceptions))
return state;
......
......@@ -62,6 +62,10 @@ private abstract class Geary.SendReplayOperation {
debug("Unable to compelte send replay queue operation [%s] error: %s", name, e.message);
}
}
public string to_string() {
return name;
}
}
private class Geary.SendReplayQueue {
......@@ -171,7 +175,7 @@ private class Geary.SendReplayQueue {
try {
completed = yield op.replay_remote();
} catch (Error e) {
debug("Error: could not replay remote");
debug("Error: could not replay remote %s: %s", op.to_string(), e.message);
remote_error = e;
}
......
......@@ -12,6 +12,10 @@ public class Geary.State.Machine {
private bool locked = false;
private bool abort_on_no_transition = true;
private bool logging = false;
private unowned PostTransition? post_transition = null;
private void *post_user = null;
private Object? post_object = null;
private Error? post_err = null;
public Machine(MachineDescriptor descriptor, Mapping[] mappings, Transition? default_transition) {
this.descriptor = descriptor;
......@@ -86,14 +90,53 @@ public class Geary.State.Machine {
assert(locked);
locked = false;
if (is_logging()) {
message("%s: %s@%s -> %s", to_string(), descriptor.get_event_string(event),
descriptor.get_state_string(old_state), descriptor.get_state_string(state));
if (is_logging())
message("%s: %s", to_string(), get_transition_string(old_state, event, state));
// Perform post-transition if registered
if (post_transition != null) {
// clear post-transition before calling, in case this method is re-entered
unowned PostTransition? perform = post_transition;
void* perform_user = post_user;
Object? perform_object = post_object;
Error? perform_err = post_err;
post_transition = null;
post_user = null;
post_object = null;
post_err = null;
perform(perform_user, perform_object, perform_err);
}
return state;
}
// Must be used carefully: this allows for a delegate (callback) to be called after the
// *current* transition has occurred; thus, this method can *only* be called from within
// a TransitionHandler.
//
// Note that if a second call to this method is made inside the same transition, the earlier
// PostTransition is silently dropped. Only one PostTransition call may be registered.
//
// Returns false if unable to register the PostTransition delegate for the reasons specified
// above.
public bool do_post_transition(PostTransition post_transition, void *user = null,
Object? object = null, Error? err = null) {
if (!locked) {
warning("%s: Attempt to register post-transition while machine is unlocked", to_string());
return false;
}
this.post_transition = post_transition;
post_user = user;
post_object = object;
post_err = err;
return true;
}
public string get_state_string(uint state) {
return descriptor.get_state_string(state);
}
......@@ -102,6 +145,15 @@ public class Geary.State.Machine {
return descriptor.get_event_string(event);
}
public string get_event_issued_string(uint state, uint event) {
return "%s@%s".printf(descriptor.get_state_string(state), descriptor.get_event_string(event));
}
public string get_transition_string(uint old_state, uint event, uint new_state) {
return "%s@%s -> %s".printf(descriptor.get_state_string(old_state), descriptor.get_event_string(event),
descriptor.get_state_string(new_state));
}
public string to_string() {
return "Machine %s [%s]".printf(descriptor.name, descriptor.get_state_string(state));
}
......
......@@ -7,6 +7,9 @@
public delegate uint Geary.State.Transition(uint state, uint event, void *user = null,
Object? object = null, Error? err = null);
public delegate void Geary.State.PostTransition(void *user = null, Object? object = null,
Error? err = null);
public class Geary.State.Mapping {
public uint state;
public uint event;
......
......@@ -6,6 +6,10 @@
namespace Geary.HTML {
public inline string escape_markup(string? plain) {
return (!String.is_empty(plain) && plain.validate()) ? Markup.escape_text(plain) : "";
}
// Removes any text between < and >. Additionally, if input terminates in the middle of a tag,
// the tag will be removed.
// If the HTML is invalid, the original string will be returned.
......
......@@ -38,8 +38,12 @@ public inline bool ascii_equali(string a, string b) {
return ascii_cmpi(a, b) == 0;
}
public inline string escape_markup(string? plain) {
return (!is_empty(plain) && plain.validate()) ? Markup.escape_text(plain) : "";
public uint stri_hash(void *str) {
return str_hash(((string *) str)->down());
}
public bool stri_equal(void *a, void *b) {
return str_equal(((string *) a)->down(), ((string *) b)->down());
}
/**
......
......@@ -65,6 +65,7 @@ def build(bld):
'../engine/imap/message/imap-message-set.vala',
'../engine/imap/message/imap-parameter.vala',
'../engine/imap/message/imap-tag.vala',
'../engine/imap/response/imap-continuation-response.vala',
'../engine/imap/response/imap-response-code-type.vala',
'../engine/imap/response/imap-response-code.vala',
'../engine/imap/response/imap-server-data-type.vala',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment