diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bfb58fb9..80a715a0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,7 @@ engine/api/geary-logging.vala engine/api/geary-named-flag.vala engine/api/geary-named-flags.vala engine/api/geary-progress-monitor.vala +engine/api/geary-revokable.vala engine/api/geary-search-folder.vala engine/api/geary-search-query.vala engine/api/geary-service.vala @@ -191,6 +192,8 @@ engine/imap-engine/imap-engine-generic-folder.vala engine/imap-engine/imap-engine-minimal-folder.vala engine/imap-engine/imap-engine-replay-operation.vala engine/imap-engine/imap-engine-replay-queue.vala +engine/imap-engine/imap-engine-revokable-move.vala +engine/imap-engine/imap-engine-revokable-committed-move.vala engine/imap-engine/imap-engine-send-replay-operation.vala engine/imap-engine/gmail/imap-engine-gmail-account.vala engine/imap-engine/gmail/imap-engine-gmail-all-mail-folder.vala @@ -211,12 +214,15 @@ engine/imap-engine/replay-ops/imap-engine-fetch-email.vala engine/imap-engine/replay-ops/imap-engine-list-email-by-id.vala engine/imap-engine/replay-ops/imap-engine-list-email-by-sparse-id.vala engine/imap-engine/replay-ops/imap-engine-mark-email.vala -engine/imap-engine/replay-ops/imap-engine-move-email.vala +engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala +engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala +engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala engine/imap-engine/replay-ops/imap-engine-remove-email.vala engine/imap-engine/replay-ops/imap-engine-replay-append.vala engine/imap-engine/replay-ops/imap-engine-replay-disconnect.vala engine/imap-engine/replay-ops/imap-engine-replay-removal.vala engine/imap-engine/replay-ops/imap-engine-server-search-email.vala +engine/imap-engine/replay-ops/imap-engine-user-close.vala engine/imap-engine/yahoo/imap-engine-yahoo-account.vala engine/imap-engine/yahoo/imap-engine-yahoo-folder.vala diff --git a/src/client/application/geary-application.vala b/src/client/application/geary-application.vala index f5332547..5000ae66 100644 --- a/src/client/application/geary-application.vala +++ b/src/client/application/geary-application.vala @@ -59,7 +59,6 @@ public class GearyApplication : Gtk.Application { * an exit, a callback should return true. */ public virtual signal bool exiting(bool panicked) { - controller.close(); Date.terminate(); return true; @@ -89,9 +88,9 @@ public class GearyApplication : Gtk.Application { private string bin; private File exec_dir; - private bool exiting_fired = false; private int exitcode = 0; + private bool is_destroyed = false; public GearyApplication() { Object(application_id: APP_ID); @@ -199,6 +198,17 @@ public class GearyApplication : Gtk.Application { release(); } + private async void destroy_async() { + // see create_async() for reasoning hold/release is used + hold(); + + yield controller.close_async(); + + release(); + + is_destroyed = true; + } + public bool compose(string mailto) { if (controller == null) return false; @@ -327,6 +337,11 @@ public class GearyApplication : Gtk.Application { return; } + // Give asynchronous destroy_async() a chance to complete + destroy_async.begin(); + while (!is_destroyed || Gtk.events_pending()) + Gtk.main_iteration(); + if (Gtk.main_level() > 0) Gtk.main_quit(); else diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala index 7a055f4e..a8d1fb77 100644 --- a/src/client/application/geary-controller.vala +++ b/src/client/application/geary-controller.vala @@ -33,6 +33,7 @@ public class GearyController : Geary.BaseObject { public const string ACTION_EMPTY_MENU = "GearyEmptyMenu"; public const string ACTION_EMPTY_SPAM = "GearyEmptySpam"; public const string ACTION_EMPTY_TRASH = "GearyEmptyTrash"; + public const string ACTION_UNDO = "GearyUndo"; public const string ACTION_FIND_IN_CONVERSATION = "GearyFindInConversation"; public const string ACTION_FIND_NEXT_IN_CONVERSATION = "GearyFindNextInConversation"; public const string ACTION_FIND_PREVIOUS_IN_CONVERSATION = "GearyFindPreviousInConversation"; @@ -125,6 +126,7 @@ public class GearyController : Geary.BaseObject { private Gee.List pending_mailtos = new Gee.ArrayList(); private Geary.Nonblocking.Mutex untrusted_host_prompt_mutex = new Geary.Nonblocking.Mutex(); private Gee.HashSet validating_endpoints = new Gee.HashSet(); + private Geary.Revokable? revokable = null; // List of windows we're waiting to close before Geary closes. private Gee.List waiting_to_close = new Gee.ArrayList(); @@ -238,6 +240,9 @@ public class GearyController : Geary.BaseObject { // instantiate here to ensure that Config is initialized and ready autostart_manager = new AutostartManager(); + // initialize revokable + save_revokable(null, null); + // Start Geary. try { yield Geary.Engine.instance.open_async(GearyApplication.instance.get_user_data_directory(), @@ -251,13 +256,59 @@ public class GearyController : Geary.BaseObject { } /** - * Stops the controller and shuts down Geary. + * At the moment, this is non-reversible, i.e. once closed a GearyController cannot be + * re-opened. */ - public void close() { + public async void close_async() { + // hide window while shutting down, as this can take a few seconds under certain conditions + main_window.hide(); + + // drop the Revokable, which will commit it if necessary + save_revokable(null, null); + + // close the ConversationMonitor + try { + if (current_conversations != null) { + yield current_conversations.stop_monitoring_async(null); + + // If not an Inbox, wait for it to close so all pending operations are flushed + if (!inboxes.values.contains(current_conversations.folder)) + yield current_conversations.folder.wait_for_close_async(null); + } + } catch (Error err) { + message("Error closing conversation at shutdown: %s", err.message); + } finally { + current_conversations = null; + } + + // close all Inboxes + foreach (Geary.Folder inbox in inboxes.values) { + try { + // close and wait for all pending operations to be flushed + yield inbox.close_async(null); + yield inbox.wait_for_close_async(null); + } catch (Error err) { + message("Error closing Inbox %s at shutdown: %s", inbox.to_string(), err.message); + } + } + + // close all Accounts + foreach (Geary.Account account in email_stores.keys) { + try { + yield account.close_async(null); + } catch (Error err) { + message("Error closing account %s at shutdown: %s", account.to_string(), err.message); + } + } + main_window.destroy(); - main_window = null; - current_account = null; - account_selected(null); + + // Turn off the lights and lock the door behind you + try { + yield Geary.Engine.instance.close_async(null); + } catch (Error err) { + message("Error closing Geary Engine instance: %s", err.message); + } } private void add_accelerator(string accelerator, string action) { @@ -404,6 +455,9 @@ public class GearyController : Geary.BaseObject { empty_trash.label = _("Empty _Trash…"); entries += empty_trash; + Gtk.ActionEntry undo = { ACTION_UNDO, "edit-undo-symbolic", null, "Z", null, on_revoke }; + entries += undo; + Gtk.ActionEntry zoom_in = { ACTION_ZOOM_IN, null, null, "equal", null, on_zoom_in }; entries += zoom_in; @@ -1273,6 +1327,9 @@ public class GearyController : Geary.BaseObject { Cancellable? conversation_cancellable = (current_is_inbox ? inbox_cancellables.get(folder.account) : cancellable_folder); + // clear Revokable, as Undo is only available while a folder is selected + save_revokable(null, null); + // stop monitoring for conversations and close the folder if (current_conversations != null) { yield current_conversations.stop_monitoring_async(null); @@ -1863,10 +1920,19 @@ public class GearyController : Geary.BaseObject { return; Geary.FolderSupport.Move? supports_move = current_folder as Geary.FolderSupport.Move; - if (supports_move == null) - return; - - supports_move.move_email_async.begin(ids, destination.path, cancellable_folder); + if (supports_move != null) + move_conversation_async.begin(supports_move, ids, destination.path, cancellable_folder); + } + + private async void move_conversation_async(Geary.FolderSupport.Move source_folder, + Gee.List ids, Geary.FolderPath destination, Cancellable? cancellable) { + try { + save_revokable(yield source_folder.move_email_async(ids, destination, cancellable), + _("Undo move (Ctrl+Z)")); + } catch (Error err) { + debug("%s: Unable to move %d emails: %s", source_folder.to_string(), ids.size, + err.message); + } } private void on_open_attachment(Geary.Attachment attachment) { @@ -2404,10 +2470,13 @@ public class GearyController : Geary.BaseObject { debug("Archiving selected messages"); Geary.FolderSupport.Archive? supports_archive = current_folder as Geary.FolderSupport.Archive; - if (supports_archive == null) + if (supports_archive == null) { debug("Folder %s doesn't support archive", current_folder.to_string()); - else - yield supports_archive.archive_email_async(ids, cancellable); + } else { + save_revokable(yield supports_archive.archive_email_async(ids, cancellable), + _("Undo archive (Ctrl+Z)")); + } + return; } @@ -2419,7 +2488,9 @@ public class GearyController : Geary.BaseObject { Geary.SpecialFolderType.TRASH, cancellable)).path; Geary.FolderSupport.Move? supports_move = current_folder as Geary.FolderSupport.Move; if (supports_move != null) { - yield supports_move.move_email_async(ids, trash_path, cancellable); + save_revokable(yield supports_move.move_email_async(ids, trash_path, cancellable), + _("Undo trash (Ctrl+Z)")); + return; } } @@ -2450,6 +2521,71 @@ public class GearyController : Geary.BaseObject { } } + private void save_revokable(Geary.Revokable? new_revokable, string? description) { + // disconnect old revokable & blindly commit it + if (revokable != null) { + revokable.notify[Geary.Revokable.PROP_VALID].disconnect(on_revokable_valid_changed); + revokable.notify[Geary.Revokable.PROP_IN_PROCESS].disconnect(update_revokable_action); + revokable.committed.disconnect(on_revokable_committed); + + revokable.commit_async.begin(); + } + + // store new revokable + revokable = new_revokable; + + // connect to new revokable + if (revokable != null) { + revokable.notify[Geary.Revokable.PROP_VALID].connect(on_revokable_valid_changed); + revokable.notify[Geary.Revokable.PROP_IN_PROCESS].connect(update_revokable_action); + revokable.committed.connect(on_revokable_committed); + } + + Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO); + undo_action.tooltip = (revokable != null && description != null) ? description : _("Undo (Ctrl+Z)"); + + update_revokable_action(); + } + + private void update_revokable_action() { + Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO); + undo_action.sensitive = revokable != null && revokable.valid && !revokable.in_process; + } + + private void on_revokable_valid_changed() { + // remove revokable if it goes invalid + if (revokable != null && !revokable.valid) + save_revokable(null, null); + } + + private void on_revokable_committed(Geary.Revokable? committed_revokable) { + if (committed_revokable == null) + return; + + // use existing description + Gtk.Action undo_action = GearyApplication.instance.get_action(ACTION_UNDO); + save_revokable(committed_revokable, undo_action.tooltip); + } + + private void on_revoke() { + if (revokable != null && revokable.valid) + revokable.revoke_async.begin(null, on_revoke_completed); + } + + private void on_revoke_completed(Object? object, AsyncResult result) { + // Don't use the "revokable" instance because it might have gone null before this callback + // was reached + Geary.Revokable? origin = object as Geary.Revokable; + if (origin == null) + return; + + try { + origin.revoke_async.end(result); + } catch (Error err) { + debug("Unable to revoke operation: %s", err.message); + } + } + private void on_zoom_in() { main_window.conversation_viewer.web_view.zoom_in(); } diff --git a/src/client/components/main-toolbar.vala b/src/client/components/main-toolbar.vala index 79b35559..2250d3bb 100644 --- a/src/client/components/main-toolbar.vala +++ b/src/client/components/main-toolbar.vala @@ -72,6 +72,10 @@ public class MainToolbar : PillHeaderbar { insert.add(trash_delete_button = create_toolbar_button(null, GearyController.ACTION_TRASH_MESSAGE, false)); Gtk.Box archive_trash_delete = create_pill_buttons(insert); + insert.clear(); + insert.add(create_toolbar_button(null, GearyController.ACTION_UNDO, false)); + Gtk.Box undo = create_pill_buttons(insert); + // Search bar. search_entry.width_chars = 28; search_entry.tooltip_text = _("Search all mail in account for keywords (Ctrl+S)"); @@ -88,6 +92,7 @@ public class MainToolbar : PillHeaderbar { // pack_end() ordering is reversed in GtkHeaderBar in 3.12 and above #if !GTK_3_12 add_end(archive_trash_delete); + add_end(undo); add_end(search_upgrade_progress_bar); add_end(search_entry); #endif @@ -103,6 +108,7 @@ public class MainToolbar : PillHeaderbar { #if GTK_3_12 add_end(search_entry); add_end(search_upgrade_progress_bar); + add_end(undo); add_end(archive_trash_delete); #endif diff --git a/src/engine/api/geary-abstract-local-folder.vala b/src/engine/api/geary-abstract-local-folder.vala index f78dcab8..3b32a924 100644 --- a/src/engine/api/geary-abstract-local-folder.vala +++ b/src/engine/api/geary-abstract-local-folder.vala @@ -8,11 +8,12 @@ * Handles open/close for local folders. */ public abstract class Geary.AbstractLocalFolder : Geary.Folder { - private int open_count = 0; - private ProgressMonitor _opening_monitor = new Geary.ReentrantProgressMonitor(Geary.ProgressType.ACTIVITY); public override Geary.ProgressMonitor opening_monitor { get { return _opening_monitor; } } + private int open_count = 0; + private Nonblocking.Semaphore closed_semaphore = new Nonblocking.Semaphore(); + protected AbstractLocalFolder() { } @@ -39,6 +40,8 @@ public abstract class Geary.AbstractLocalFolder : Geary.Folder { if (open_count++ > 0) return false; + closed_semaphore.reset(); + notify_opened(Geary.Folder.OpenState.LOCAL, properties.email_total); return true; @@ -48,8 +51,14 @@ public abstract class Geary.AbstractLocalFolder : Geary.Folder { if (open_count == 0 || --open_count > 0) return; + closed_semaphore.blind_notify(); + notify_closed(Geary.Folder.CloseReason.LOCAL_CLOSE); notify_closed(Geary.Folder.CloseReason.FOLDER_CLOSED); } + + public override async void wait_for_close_async(Cancellable? cancellable = null) throws Error { + yield closed_semaphore.wait_async(cancellable); + } } diff --git a/src/engine/api/geary-engine.vala b/src/engine/api/geary-engine.vala index ae807e92..3d2c8b87 100644 --- a/src/engine/api/geary-engine.vala +++ b/src/engine/api/geary-engine.vala @@ -136,8 +136,7 @@ public class Geary.Engine : BaseObject { * when necessary. */ public async void open_async(File user_data_dir, File resource_dir, - Geary.CredentialsMediator? authentication_mediator, - Cancellable? cancellable = null) throws Error { + Geary.CredentialsMediator? authentication_mediator, Cancellable? cancellable = null) throws Error { // initialize *before* opening the Engine ... all initialize code should assume the Engine // is closed initialize_library(); @@ -202,16 +201,19 @@ public class Geary.Engine : BaseObject { public async void close_async(Cancellable? cancellable = null) throws Error { if (!is_open) return; - - foreach(AccountInformation account in accounts.values) + + Gee.Collection unavailable_accounts = accounts.values; + accounts.clear(); + + foreach(AccountInformation account in unavailable_accounts) account_unavailable(account); - + user_data_dir = null; resource_dir = null; authentication_mediator = null; accounts = null; account_instances = null; - + is_open = false; closed(); } diff --git a/src/engine/api/geary-folder-supports-archive.vala b/src/engine/api/geary-folder-supports-archive.vala index 02bcde80..a57d8d14 100644 --- a/src/engine/api/geary-folder-supports-archive.vala +++ b/src/engine/api/geary-folder-supports-archive.vala @@ -19,8 +19,10 @@ public interface Geary.FolderSupport.Archive : Geary.Folder { * Archives the specified emails from the folder. * * The {@link Geary.Folder} must be opened prior to attempting this operation. + * + * @returns A {@link Geary.Revokable} that may be used to revoke (undo) this operation later. */ - public abstract async void archive_email_async(Gee.List email_ids, + public abstract async Geary.Revokable? archive_email_async(Gee.List email_ids, Cancellable? cancellable = null) throws Error; } diff --git a/src/engine/api/geary-folder-supports-move.vala b/src/engine/api/geary-folder-supports-move.vala index e4f45c3b..2655c7d2 100644 --- a/src/engine/api/geary-folder-supports-move.vala +++ b/src/engine/api/geary-folder-supports-move.vala @@ -20,8 +20,10 @@ public interface Geary.FolderSupport.Move : Geary.Folder { * way but will return success. * * The {@link Geary.Folder} must be opened prior to attempting this operation. + * + * @returns A {@link Geary.Revokable} that may be used to revoke (undo) this operation later. */ - public abstract async void move_email_async(Gee.List to_move, + public abstract async Geary.Revokable? move_email_async(Gee.List to_move, Geary.FolderPath destination, Cancellable? cancellable = null) throws Error; } diff --git a/src/engine/api/geary-folder.vala b/src/engine/api/geary-folder.vala index 8f76fb38..cba29ded 100644 --- a/src/engine/api/geary-folder.vala +++ b/src/engine/api/geary-folder.vala @@ -456,6 +456,14 @@ public abstract class Geary.Folder : BaseObject { */ public abstract async void close_async(Cancellable? cancellable = null) throws Error; + /** + * Wait for the Folder to fully close. + * + * Unlike {@link wait_for_open_async}, this will ''always'' block until a {@link Folder} is + * closed, even if it's not open. + */ + public abstract async void wait_for_close_async(Cancellable? cancellable = null) throws Error; + /** * Find the lowest- and highest-ordered {@link EmailIdentifier}s in the * folder, among the given set of EmailIdentifiers that may or may not be diff --git a/src/engine/api/geary-revokable.vala b/src/engine/api/geary-revokable.vala new file mode 100644 index 00000000..ccee2db4 --- /dev/null +++ b/src/engine/api/geary-revokable.vala @@ -0,0 +1,183 @@ +/* Copyright 2014 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. + */ + +/** + * A representation of an operation with the Geary Engine that may be revoked (undone) at a later + * time. + * + * The Revokable will do everything it can to commit the operation (if necessary) when its final + * ref is dropped. However, since the final ref can be dropped at an indeterminate time, it's + * advised that callers force the matter by scheduling it with {@link commit_async}. + */ + +public abstract class Geary.Revokable : BaseObject { + public const string PROP_VALID = "valid"; + public const string PROP_IN_PROCESS = "in-process"; + + /** + * Indicates if {@link revoke_async} or {@link commit_async} are valid operations for this + * {@link Revokable}. + * + * Due to later operations or notifications, it's possible for the Revokable to go invalid + * after being issued to the caller. + */ + public bool valid { get; protected set; default = true; } + + /** + * Indicates a {@link revoke_async} or {@link commit_async} operation is underway. + * + * Only one operation can occur at a time, and when complete the {@link Revokable} will be + * invalid. + * + * @see valid + */ + public bool in_process { get; protected set; default = false; } + + private uint commit_timeout_id = 0; + + /** + * Fired when the {@link Revokable} has been revoked. + * + * {@link valid} will stil be true when this is fired. + */ + public signal void revoked(); + + /** + * Fired when the {@link Revokable} has been committed. + * + * Some Revokables will offer a new Revokable to allow revoking the committed state. + * + * {@link valid} will stil be true when this is fired. + */ + public signal void committed(Geary.Revokable? commit_revokable); + + /** + * Create a {@link Revokable} with optional parameters. + * + * If commit_timeout_sec is nonzero, Revokable will automatically call {@link commit_async} + * after the timeout expires if it is still {@link valid}. + */ + protected Revokable(int commit_timeout_sec = 0) { + if (commit_timeout_sec == 0) + return; + + // This holds a reference to the Revokable, meaning cancelling the timeout in the dtor is + // largely symbolic, but so be it + commit_timeout_id = Timeout.add_seconds(commit_timeout_sec, on_timed_commit); + + // various events that cancel the need for a timed commit; this is important to drop the + // ref to this object within the event loop + revoked.connect(cancel_timed_commit); + committed.connect(cancel_timed_commit); + notify[PROP_VALID].connect(cancel_timed_commit); + } + + ~Revokable() { + cancel_timed_commit(); + } + + protected virtual void notify_revoked() { + revoked(); + } + + protected virtual void notify_committed(Geary.Revokable? commit_revokable) { + committed(commit_revokable); + } + + /** + * Revoke (undo) the operation. + * + * If the call throws an Error that does not necessarily mean the {@link Revokable} is + * invalid. Check {@link valid}. + * + * @throws EngineError.ALREADY_OPEN if {@link in_process} is true. EngineError.ALREADY_CLOSED + * if {@link valid} is false. + */ + public virtual async void revoke_async(Cancellable? cancellable = null) throws Error { + if (in_process) + throw new EngineError.ALREADY_OPEN("Already revoking or committing operation"); + + if (!valid) + throw new EngineError.ALREADY_CLOSED("Revokable not valid"); + + in_process = true; + try { + yield internal_revoke_async(cancellable); + } finally { + in_process = false; + } + } + + /** + * The child class's implementation of {@link revoke_async}. + * + * The default implementation of {@link revoke_async} deals with state issues + * ({@link in_process}, throwing the appropriate Error, etc.) Child classes can override this + * method and only worry about the revoke operation itself. + * + * This call *must* set {@link valid} before exiting. It must also call {@link notify_revoked} + * if successful. + */ + protected abstract async void internal_revoke_async(Cancellable? cancellable) throws Error; + + /** + * Commits (completes) the operation immediately. + * + * Some {@link Revokable} operations work by delaying the operation until time has passed or + * some situation occurs which requires the operation to complete. This call forces the + * operation to complete immediately rather than delay it for later. + * + * Even if the operation "actually" commits and is not delayed, calling commit_async() will + * make this Revokable invalid. + * + * @throws EngineError.ALREADY_OPEN if {@link in_process} is true. EngineError.ALREADY_CLOSED + * if {@link valid} is false. + */ + public virtual async void commit_async(Cancellable? cancellable = null) throws Error { + if (in_process) + throw new EngineError.ALREADY_OPEN("Already revoking or committing operation"); + + if (!valid) + throw new EngineError.ALREADY_CLOSED("Revokable not valid"); + + in_process = true; + try { + yield internal_commit_async(cancellable); + } finally { + in_process = false; + } + } + + /** + * The child class's implementation of {@link commit_async}. + * + * The default implementation of {@link commit_async} deals with state issues + * ({@link in_process}, throwing the appropriate Error, etc.) Child classes can override this + * method and only worry about the revoke operation itself. + * + * This call *must* set {@link valid} before exiting. It must also call {@link notify_committed} + * if successful. + */ + protected abstract async void internal_commit_async(Cancellable? cancellable) throws Error; + + private bool on_timed_commit() { + commit_timeout_id = 0; + + if (valid && !in_process) + commit_async.begin(); + + return false; + } + + private void cancel_timed_commit() { + if (commit_timeout_id == 0) + return; + + Source.remove(commit_timeout_id); + commit_timeout_id = 0; + } +} + diff --git a/src/engine/app/app-conversation-monitor.vala b/src/engine/app/app-conversation-monitor.vala index db4373a0..48943c18 100644 --- a/src/engine/app/app-conversation-monitor.vala +++ b/src/engine/app/app-conversation-monitor.vala @@ -631,7 +631,7 @@ public class Geary.App.ConversationMonitor : BaseObject { } internal async void remove_emails_async(Gee.Collection removed_ids) { - debug("%d messages(s) removed to %s, trimming/removing conversations...", removed_ids.size, + debug("%d messages(s) removed from %s, trimming/removing conversations...", removed_ids.size, folder.to_string()); Gee.Collection removed; @@ -732,7 +732,10 @@ public class Geary.App.ConversationMonitor : BaseObject { * Attempts to load enough conversations to fill min_window_count. */ internal async void fill_window_async(bool is_insert) { - if (!is_monitoring || min_window_count <= conversations.size) + if (!is_monitoring) + return; + + if (!is_insert && min_window_count <= conversations.size) return; int initial_message_count = conversations.get_email_count(); diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala index b09685e1..06cec3a9 100644 --- a/src/engine/imap-db/imap-db-account.vala +++ b/src/engine/imap-db/imap-db-account.vala @@ -250,12 +250,17 @@ private class Geary.ImapDB.Account : BaseObject { * update_uid_info is true. */ public async void update_folder_status_async(Geary.Imap.Folder imap_folder, bool update_uid_info, - Cancellable? cancellable) throws Error { + bool respect_marked_for_remove, Cancellable? cancellable) throws Error { check_open(); Geary.Imap.FolderProperties properties = imap_folder.properties; Geary.FolderPath path = imap_folder.path; + // adjust for marked remove, but don't write these adjustments to the database -- they're + // only reflected in memory via the properties + int adjust_unseen = 0; + int adjust_total = 0; + yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => { int64 parent_id; if (!do_fetch_parent_id(cx, path, true, out parent_id, cancellable)) { @@ -264,6 +269,36 @@ private class Geary.ImapDB.Account : BaseObject { return Db.TransactionOutcome.ROLLBACK; } + int64 folder_id; + if (!do_fetch_folder_id(cx, path, false, out folder_id, cancellable)) + folder_id = Db.INVALID_ROWID; + + if (respect_marked_for_remove && folder_id != Db.INVALID_ROWID) { + Db.Statement stmt = cx.prepare(""" + SELECT flags + FROM MessageTable + WHERE id IN ( + SELECT message_id + FROM MessageLocationTable + WHERE folder_id = ? AND remove_marker = ? + ) + """); + stmt.bind_rowid(0, folder_id); + stmt.bind_bool(1, true); + + Db.Result results = stmt.exec(cancellable); + while (!results.finished) { + adjust_total++; + + Imap.EmailFlags flags = new Imap.EmailFlags(Imap.MessageFlags.deserialize( + results.string_at(0))); + if (flags.contains(EmailFlags.UNREAD)) + adjust_unseen++; + + results.next(cancellable); + } + } + Db.Statement stmt; if (parent_id != Db.INVALID_ROWID) { stmt = cx.prepare( @@ -298,7 +333,7 @@ private class Geary.ImapDB.Account : BaseObject { if (db_folder != null) { Imap.FolderProperties local_properties = db_folder.get_properties(); - local_properties.set_status_unseen(properties.unseen); + local_properties.set_status_unseen(Numeric.int_floor(properties.unseen - adjust_unseen, 0)); local_properties.recent = properties.recent; local_properties.attrs = properties.attrs; @@ -307,8 +342,12 @@ private class Geary.ImapDB.Account : BaseObject { local_properties.uid_next = properties.uid_next; } - if (properties.status_messages >= 0) - local_properties.set_status_message_count(properties.status_messages, false); + // only update STATUS MESSAGES count if previously set, but use this count as the + // "authoritative" value until another SELECT/EXAMINE or MESSAGES response + if (properties.status_messages >= 0) { + local_properties.set_status_message_count( + Numeric.int_floor(properties.status_messages - adjust_total, 0), true); + } } } diff --git a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala index 16e55293..567c9a0b 100644 --- a/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala +++ b/src/engine/imap-engine/gmail/imap-engine-gmail-folder.vala @@ -17,9 +17,21 @@ private class Geary.ImapEngine.GmailFolder : MinimalFolder, FolderSupport.Archiv return yield base.create_email_async(rfc822, flags, date_received, id, cancellable); } - public async void archive_email_async(Gee.List email_ids, + public async Geary.Revokable? archive_email_async(Gee.List email_ids, Cancellable? cancellable = null) throws Error { + // Use move_email_async("All Mail") here; Gmail will do the right thing and report + // it was copied with the pre-existing All Mail UID (in other words, no actual copy is + // performed). This allows for undoing an archive with the same code path as a move. + Geary.Folder? all_mail = account.get_special_folder(Geary.SpecialFolderType.ALL_MAIL); + if (all_mail != null) + return yield move_email_async(email_ids, all_mail.path, cancellable); + + // although this shouldn't happen, fall back on our traditional archive, which is simply + // to remove the message from this label + message("%s: Unable to perform revokable archive: All Mail not found", to_string()); yield expunge_email_async(email_ids, cancellable); + + return null; } public async void remove_email_async(Gee.List email_ids, diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala b/src/engine/imap-engine/imap-engine-generic-account.vala index 9c4361c0..8d04ccff 100644 --- a/src/engine/imap-engine/imap-engine-generic-account.vala +++ b/src/engine/imap-engine/imap-engine-generic-account.vala @@ -319,6 +319,9 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { if (in_refresh_unseen.contains(folder)) return true; + // add here, remove in completed callback + in_refresh_unseen.add(folder); + refresh_unseen_async.begin(folder, null, on_refresh_unseen_completed); refresh_unseen_timeout_ids.unset(folder.path); @@ -334,21 +337,29 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { } private async void refresh_unseen_async(Geary.Folder folder, Cancellable? cancellable) throws Error { - in_refresh_unseen.add(folder); - debug("Refreshing unseen counts for %s", folder.to_string()); - bool folder_created; - Imap.Folder remote_folder = yield remote.fetch_folder_async(folder.path, - out folder_created, null, cancellable); - - if (!folder_created) { - int unseen_count = yield remote.fetch_unseen_count_async(folder.path, cancellable); - remote_folder.properties.set_status_unseen(unseen_count); - yield local.update_folder_status_async(remote_folder, false, cancellable); + try { + bool folder_created; + Imap.Folder remote_folder = yield remote.fetch_folder_async(folder.path, + out folder_created, null, cancellable); + + // if created, don't need to fetch count because it was fetched when it was created + int unseen, total; + if (!folder_created) { + yield remote.fetch_counts_async(folder.path, out unseen, out total, cancellable); + remote_folder.properties.set_status_unseen(unseen); + remote_folder.properties.set_status_message_count(total, false); + } else { + unseen = remote_folder.properties.unseen; + total = remote_folder.properties.email_total; + } + + yield local.update_folder_status_async(remote_folder, false, true, cancellable); + } finally { + // added when call scheduled (above) + in_refresh_unseen.remove(folder); } - - in_refresh_unseen.remove(folder); } private void reschedule_folder_refresh(bool immediate) { @@ -535,6 +546,9 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { * not be reflected in the local database unless there's a separate connection to the server * that is notified or detects these changes. * + * The returned Folder must be opened prior to use and closed once completed. ''Leaving a + * Folder open will cause a connection leak.'' + * * It is not recommended this object be held open long-term, or that its status or notifications * be directly written to the database unless you know exactly what you're doing. ''Caveat * implementor.'' @@ -704,7 +718,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { // always update, openable or not; have the folder update the UID info the next time // it's opened try { - yield local.update_folder_status_async(remote_folder, false, cancellable); + yield local.update_folder_status_async(remote_folder, false, false, cancellable); } catch (Error update_error) { debug("Unable to update local folder %s with remote properties: %s", remote_folder.to_string(), update_error.message); diff --git a/src/engine/imap-engine/imap-engine-minimal-folder.vala b/src/engine/imap-engine/imap-engine-minimal-folder.vala index 4d784f24..c117fc83 100644 --- a/src/engine/imap-engine/imap-engine-minimal-folder.vala +++ b/src/engine/imap-engine/imap-engine-minimal-folder.vala @@ -45,6 +45,7 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport private bool remote_opened = false; private Nonblocking.ReportingSemaphore remote_semaphore = new Nonblocking.ReportingSemaphore(false); + private Nonblocking.Semaphore closed_semaphore = new Nonblocking.Semaphore(); private ReplayQueue replay_queue; private int remote_count = -1; private uint open_remote_timer_id = 0; @@ -52,6 +53,28 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport private Nonblocking.Mutex open_mutex = new Nonblocking.Mutex(); private Nonblocking.Mutex close_mutex = new Nonblocking.Mutex(); + /** + * Called when the folder is closing (and not reestablishing a connection) and will be flushing + * the replay queue. Subscribers may add ReplayOperations to the list, which will be enqueued + * before the queue is flushed. + * + * Note that this is ''not'' fired if the queue is not being flushed. + */ + public signal void closing(Gee.List final_ops); + + /** + * Fired when an {@link EmailIdentifier} that was marked for removal is actually reported as + * removed (expunged) from the server. + * + * Marked messages are reported as removed when marked in the database, to make the operation + * appear speedy to the caller. When the message is finally acknowledged as removed by the + * server, "email-removed" is not fired to avoid double-reporting. + * + * Some internal code (especially Revokables) mark messages for removal but delay the network + * operation. They need to know if the message is removed by another client, however. + */ + public signal void marked_email_removed(Gee.Collection removed); + public MinimalFolder(GenericAccount account, Imap.Account remote, ImapDB.Account local, ImapDB.Folder local_folder, SpecialFolderType special_folder_type) { _account = account; @@ -79,6 +102,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport local_folder.email_complete.disconnect(on_email_complete); } + protected virtual void notify_closing(Gee.List final_ops) { + closing(final_ops); + } + /* * These signal notifiers are marked public (note this is a private class) so the various * ReplayOperations can directly fire the associated signals while within the queue. @@ -524,6 +551,9 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport // reset to force waiting in wait_for_open_async() remote_semaphore.reset(); + // reset to force waiting in wait_for_close_async() + closed_semaphore.reset(); + // Unless NO_DELAY is set, do NOT open the remote side here; wait for the ReplayQueue to // require a remote connection or wait_for_open_async() to be called ... this allows for // fast local-only operations to occur, local-only either because (a) the folder has all @@ -745,6 +775,22 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport } public override async void close_async(Cancellable? cancellable = null) throws Error { + // Check open_count but only decrement inside of replay queue + if (open_count <= 0) + return; + + UserClose user_close = new UserClose(this, cancellable); + replay_queue.schedule(user_close); + + yield user_close.wait_for_ready_async(cancellable); + } + + public override async void wait_for_close_async(Cancellable? cancellable = null) throws Error { + yield closed_semaphore.wait_async(cancellable); + } + + internal async void user_close_async(Cancellable? cancellable) { + // decrement open_count and, if zero, continue closing Folder if (open_count == 0 || --open_count > 0) return; @@ -754,11 +800,9 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport // block anyone from wait_until_open_async(), as this is no longer open remote_semaphore.reset(); - ReplayDisconnect disconnect_op = new ReplayDisconnect(this, - Imap.ClientSession.DisconnectReason.REMOTE_CLOSE, true, cancellable); - replay_queue.schedule(disconnect_op); - - yield disconnect_op.wait_for_ready_async(cancellable); + // don't yield here, close_internal_async() needs to be called outside of the replay queue + // the open_count protects against this path scheduling it more than once + close_internal_async.begin(CloseReason.LOCAL_CLOSE, CloseReason.REMOTE_CLOSE, true, cancellable); } // Close the remote connection and, if open_count is zero, the Folder itself. A Mutex is used @@ -814,6 +858,16 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport // That said, only flush, close, and destroy the ReplayQueue if fully closing and not // preparing for a connection reestablishment if (open_count <= 0) { + // if closing and flushing the queue, give Revokables a chance to schedule their + // commit operations before going down + if (flush_pending) { + Gee.List final_ops = new Gee.ArrayList(); + notify_closing(final_ops); + + foreach (ReplayOperation op in final_ops) + replay_queue.schedule(op); + } + // Close the replay queues; if a "clean" close, flush pending operations so everything // gets a chance to run; if forced close, drop everything outstanding try { @@ -878,6 +932,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport notify_closed(CloseReason.FOLDER_CLOSED); + // If not closing in the background, do it here + if (closing_remote_folder == null) + closed_semaphore.blind_notify(); + debug("Folder %s closed", to_string()); } @@ -926,6 +984,10 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport if (folder.open_count <= 0) { debug("Not reestablishing connection to %s: closed", folder.to_string()); + // need to do it here if not done in close_internal_locked_async() + if (remote_folder != null) + folder.closed_semaphore.blind_notify(); + return; } @@ -1190,9 +1252,15 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport err.message); } - // notify of change - if (!marked && owned_id != null) - notify_email_removed(Geary.iterate(owned_id).to_array_list()); + // notify of change ... use "marked-email-removed" for marked email to allow internal code + // to be notified when a removed email is "really" removed + if (owned_id != null) { + Gee.List removed = Geary.iterate(owned_id).to_array_list(); + if (!marked) + notify_email_removed(removed); + else + marked_email_removed(removed); + } if (!marked) notify_email_count_changed(reported_remote_count, CountChangeReason.REMOVED); @@ -1372,19 +1440,36 @@ private class Geary.ImapEngine.MinimalFolder : Geary.Folder, Geary.FolderSupport return copy.destination_uids.size > 0 ? copy.destination_uids : null; } - public virtual async void move_email_async(Gee.List to_move, + public virtual async Geary.Revokable? move_email_async(Gee.List to_move, Geary.FolderPath destination, Cancellable? cancellable = null) throws Error { check_open("move_email_async"); check_ids("move_email_async", to_move); // watch for moving to this folder, which is treated as a no-op if (destination.equal_to(path)) - return; + return null; - MoveEmail move = new MoveEmail(this, (Gee.List) to_move, destination); - replay_queue.schedule(move); + MoveEmailPrepare prepare = new MoveEmailPrepare(this, (Gee.List) to_move, + cancellable); + replay_queue.schedule(prepare); - yield move.wait_for_ready_async(cancellable); + yield prepare.wait_for_ready_async(cancellable); + + if (prepare.prepared_for_move == null || prepare.prepared_for_move.size == 0) + return null; + + return new RevokableMove(_account, this, destination, prepare.prepared_for_move); + } + + public void schedule_op(ReplayOperation op) throws Error { + check_open("schedule_op"); + + replay_queue.schedule(op); + } + + public async void exec_op_async(ReplayOperation op, Cancellable? cancellable) throws Error { + schedule_op(op); + yield op.wait_for_ready_async(cancellable); } private void on_email_flags_changed(Gee.Map changed) { diff --git a/src/engine/imap-engine/imap-engine-replay-queue.vala b/src/engine/imap-engine/imap-engine-replay-queue.vala index af43bad4..a2e3b2bc 100644 --- a/src/engine/imap-engine/imap-engine-replay-queue.vala +++ b/src/engine/imap-engine/imap-engine-replay-queue.vala @@ -9,6 +9,12 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { // see as high as 250ms private const int NOTIFICATION_QUEUE_WAIT_MSEC = 1000; + private enum State { + OPEN, + CLOSING, + CLOSED + } + private class CloseReplayQueue : ReplayOperation { public CloseReplayQueue() { // LOCAL_AND_REMOTE to make sure this operation is flushed all the way down the pipe @@ -57,8 +63,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { private Gee.ArrayList notification_queue = new Gee.ArrayList(); private Scheduler.Scheduled? notification_timer = null; private int64 next_submission_number = 0; - - private bool is_closed = false; + private State state = State.OPEN; public virtual signal void scheduled(ReplayOperation op) { Logging.debug(Logging.Flag.REPLAY, "[%s] ReplayQueue::scheduled: %s %s", to_string(), @@ -143,7 +148,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { */ public bool schedule(ReplayOperation op) { // ReplayClose is allowed past the velvet ropes even as the hoi palloi is turned away - if (is_closed && !(op is CloseReplayQueue)) { + if (state != State.OPEN && !(op is CloseReplayQueue)) { debug("Unable to schedule replay operation %s on %s: replay queue closed", op.to_string(), to_string()); @@ -188,7 +193,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { * Returns false if the operation was not schedule (queue already closed). */ public bool schedule_server_notification(ReplayOperation op) { - if (is_closed) { + if (state != State.OPEN) { debug("Unable to schedule notification operation %s on %s: replay queue closed", op.to_string(), to_string()); @@ -293,7 +298,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { * A ReplayQueue cannot be re-opened. */ public async void close_async(bool flush_pending, Cancellable? cancellable = null) throws Error { - if (is_closed) + if (state != State.OPEN) return; // cancel notification queue timeout @@ -306,8 +311,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { // mark as closed now to prevent further scheduling ... ReplayClose gets special // consideration in schedule() - is_closed = true; - + state = State.CLOSING; closing(); // if not flushing pending, clear out all waiting operations, backing out any that need to @@ -322,6 +326,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { yield close_op.wait_for_ready_async(cancellable); + state = State.CLOSED; closed(); } @@ -472,7 +477,7 @@ private class Geary.ImapEngine.ReplayQueue : Geary.BaseObject { // wait until the remote folder is opened (or throws an exception, in which case closed) try { - if (!is_close_op && folder_opened) + if (!is_close_op && folder_opened && state == State.OPEN) yield owner.wait_for_open_async(); } catch (Error remote_err) { debug("Folder %s closed or failed to open, remote replay queue closing: %s", diff --git a/src/engine/imap-engine/imap-engine-revokable-committed-move.vala b/src/engine/imap-engine/imap-engine-revokable-committed-move.vala new file mode 100644 index 00000000..864672f9 --- /dev/null +++ b/src/engine/imap-engine/imap-engine-revokable-committed-move.vala @@ -0,0 +1,64 @@ +/* Copyright 2014 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. + */ + +/** + * A {@link Geary.Revokable} for moving email back to its source after committed with + * {@link RevokableMove}. + */ + +private class Geary.ImapEngine.RevokableCommittedMove : Revokable { + private GenericAccount account; + private FolderPath source; + private FolderPath destination; + private Gee.Set destination_uids; + + public RevokableCommittedMove(GenericAccount account, FolderPath source, FolderPath destination, + Gee.Set destination_uids) { + this.account = account; + this.source = source; + this.destination = destination; + this.destination_uids = destination_uids; + } + + protected override async void internal_revoke_async(Cancellable? cancellable) throws Error { + Imap.Folder? detached_destination = null; + try { + // use a detached folder to quickly open, issue command, and leave, without full + // normalization that MinimalFolder requires + detached_destination = yield account.fetch_detached_folder_async(destination, cancellable); + + yield detached_destination.open_async(cancellable); + + foreach (Imap.MessageSet msg_set in Imap.MessageSet.uid_sparse(destination_uids)) { + // don't use Cancellable to try to make operations atomic + yield detached_destination.copy_email_async(msg_set, source, null); + yield detached_destination.remove_email_async(msg_set.to_list(), null); + + if (cancellable != null && cancellable.is_cancelled()) + throw new IOError.CANCELLED("Revoke cancelled"); + } + + notify_revoked(); + } finally { + if (detached_destination != null) { + try { + yield detached_destination.close_async(cancellable); + } catch (Error err) { + // ignored + } + } + + valid = false; + } + } + + protected override async void internal_commit_async(Cancellable? cancellable) throws Error { + // pretty simple: already committed, so done + notify_committed(null); + valid = false; + } +} + diff --git a/src/engine/imap-engine/imap-engine-revokable-move.vala b/src/engine/imap-engine/imap-engine-revokable-move.vala new file mode 100644 index 00000000..cb7f9f88 --- /dev/null +++ b/src/engine/imap-engine/imap-engine-revokable-move.vala @@ -0,0 +1,115 @@ +/* Copyright 2014 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. + */ + +/** + * A @{link Geary.Revokable} for {@link MinimalFolder} move operations. + * + * This will delay executing the move until (a) the source Folder is closed or (b) a timeout passes. + * Even then, it will fire its "committed" signal with a {@link RevokableCommittedMove} to allow + * the user to undo the operation, albeit taking more time to connect, open the destination folder, + * and move the mail back. + */ + +private class Geary.ImapEngine.RevokableMove : Revokable { + private const int COMMIT_TIMEOUT_SEC = 60; + + private GenericAccount account; + private ImapEngine.MinimalFolder source; + private FolderPath destination; + private Gee.Set move_ids; + + public RevokableMove(GenericAccount account, ImapEngine.MinimalFolder source, FolderPath destination, + Gee.Set move_ids) { + base (COMMIT_TIMEOUT_SEC); + + this.account = account; + this.source = source; + this.destination = destination; + this.move_ids = move_ids; + + account.folders_available_unavailable.connect(on_folders_available_unavailable); + source.email_removed.connect(on_source_email_removed); + source.marked_email_removed.connect(on_source_email_removed); + source.closing.connect(on_source_closing); + } + + ~RevokableMove() { + account.folders_available_unavailable.disconnect(on_folders_available_unavailable); + source.email_removed.disconnect(on_source_email_removed); + source.marked_email_removed.disconnect(on_source_email_removed); + source.closing.disconnect(on_source_closing); + + // if still valid, schedule operation so its executed + if (valid && source.get_open_state() != Folder.OpenState.CLOSED) { + debug("Freeing revokable, scheduling move %d emails from %s to %s", move_ids.size, + source.path.to_string(), destination.to_string()); + + try { + source.schedule_op(new MoveEmailCommit(source, move_ids, destination, null)); + } catch (Error err) { + debug("Move from %s to %s failed: %s", source.path.to_string(), destination.to_string(), + err.message); + } + } + } + + protected override async void internal_revoke_async(Cancellable? cancellable) throws Error { + try { + yield source.exec_op_async(new MoveEmailRevoke(source, move_ids, cancellable), + cancellable); + + // valid must still be true before firing + notify_revoked(); + } finally { + valid = false; + } + } + + protected override async void internal_commit_async(Cancellable? cancellable) throws Error { + try { + MoveEmailCommit op = new MoveEmailCommit(source, move_ids, destination, cancellable); + yield source.exec_op_async(op, cancellable); + + // valid must still be true before firing + notify_committed(new RevokableCommittedMove(account, source.path, destination, op.destination_uids)); + } finally { + valid = false; + } + } + + private void on_folders_available_unavailable(Gee.List? available, Gee.List? unavailable) { + // look for either of the folders going away + if (unavailable != null) { + foreach (Folder folder in unavailable) { + if (folder.path.equal_to(source.path) || folder.path.equal_to(destination)) { + valid = false; + + break; + } + } + } + } + + private void on_source_email_removed(Gee.Collection ids) { + // one-way switch + if (!valid) + return; + + foreach (EmailIdentifier id in ids) + move_ids.remove((ImapDB.EmailIdentifier) id); + + valid = move_ids.size > 0; + } + + private void on_source_closing(Gee.List final_ops) { + if (!valid) + return; + + final_ops.add(new MoveEmailCommit(source, move_ids, destination, null)); + valid = false; + } +} + diff --git a/src/engine/imap-engine/imap-engine-send-replay-operation.vala b/src/engine/imap-engine/imap-engine-send-replay-operation.vala index 5ae0dceb..50449cef 100644 --- a/src/engine/imap-engine/imap-engine-send-replay-operation.vala +++ b/src/engine/imap-engine/imap-engine-send-replay-operation.vala @@ -5,11 +5,15 @@ */ private abstract class Geary.ImapEngine.SendReplayOperation : Geary.ImapEngine.ReplayOperation { - public SendReplayOperation(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) { + protected SendReplayOperation(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) { base (name, ReplayOperation.Scope.LOCAL_AND_REMOTE, on_remote_error); } - public SendReplayOperation.only_remote(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) { + protected SendReplayOperation.only_local(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) { + base (name, ReplayOperation.Scope.LOCAL_ONLY, on_remote_error); + } + + protected SendReplayOperation.only_remote(string name, ReplayOperation.OnError on_remote_error = OnError.THROW) { base (name, ReplayOperation.Scope.REMOTE_ONLY, on_remote_error); } diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala b/src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala similarity index 57% rename from src/engine/imap-engine/replay-ops/imap-engine-move-email.vala rename to src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala index d0ae775d..d0de4d5f 100644 --- a/src/engine/imap-engine/replay-ops/imap-engine-move-email.vala +++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-commit.vala @@ -4,68 +4,49 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ -private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation { +/** + * Stage two of a {@link RevokableMove}: move messages from folder to destination. + */ + +private class Geary.ImapEngine.MoveEmailCommit : Geary.ImapEngine.SendReplayOperation { + public Gee.Set destination_uids = new Gee.HashSet(); + private MinimalFolder engine; private Gee.List to_move = new Gee.ArrayList(); private Geary.FolderPath destination; private Cancellable? cancellable; - private Gee.Set? moved_ids = null; - private int original_count = 0; private Gee.List? remaining_msg_sets = null; - - public MoveEmail(MinimalFolder engine, Gee.List to_move, - Geary.FolderPath destination, Cancellable? cancellable = null) { - base("MoveEmail", OnError.RETRY); - + + public MoveEmailCommit(MinimalFolder engine, Gee.Collection to_move, + Geary.FolderPath destination, Cancellable? cancellable) { + base.only_remote("MoveEmailCommit", OnError.RETRY); + this.engine = engine; - + this.to_move.add_all(to_move); this.destination = destination; this.cancellable = cancellable; } public override void notify_remote_removed_ids(Gee.Collection ids) { - // don't bother updating on server or backing out locally - if (moved_ids != null) - moved_ids.remove_all(ids); + to_move.remove_all(ids); } public override async ReplayOperation.Status replay_local_async() throws Error { - if (to_move.size <= 0) - return ReplayOperation.Status.COMPLETED; - - int remote_count; - int last_seen_remote_count; - original_count = engine.get_remote_counts(out remote_count, out last_seen_remote_count); - - // as this value is only used for reporting, offer best-possible service - if (original_count < 0) - original_count = to_move.size; - - moved_ids = yield engine.local_folder.mark_removed_async(to_move, true, cancellable); - if (moved_ids == null || moved_ids.size == 0) - return ReplayOperation.Status.COMPLETED; - - engine.replay_notify_email_removed(moved_ids); - - engine.replay_notify_email_count_changed(Numeric.int_floor(original_count - to_move.size, 0), - Geary.Folder.CountChangeReason.REMOVED); - return ReplayOperation.Status.CONTINUE; } public override void get_ids_to_be_remote_removed(Gee.Collection ids) { - if (moved_ids != null) - ids.add_all(moved_ids); + ids.add_all(to_move); } public override async ReplayOperation.Status replay_remote_async() throws Error { - if (moved_ids.size == 0) + if (to_move.size == 0) return ReplayOperation.Status.COMPLETED; // Remaining MessageSets are persisted in case of network retries if (remaining_msg_sets == null) - remaining_msg_sets = Imap.MessageSet.uid_sparse(ImapDB.EmailIdentifier.to_uids(moved_ids)); + remaining_msg_sets = Imap.MessageSet.uid_sparse(ImapDB.EmailIdentifier.to_uids(to_move)); if (remaining_msg_sets == null || remaining_msg_sets.size == 0) return ReplayOperation.Status.COMPLETED; @@ -78,7 +59,12 @@ private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation throw new IOError.CANCELLED("Move email to %s cancelled", engine.remote_folder.to_string()); Imap.MessageSet msg_set = iter.get(); - yield engine.remote_folder.copy_email_async(msg_set, destination, null); + + Gee.Map? map = yield engine.remote_folder.copy_email_async(msg_set, + destination, null); + if (map != null) + destination_uids.add_all(map.values); + yield engine.remote_folder.remove_email_async(msg_set.to_list(), null); // completed successfully, remove from list in case of retry @@ -87,14 +73,19 @@ private class Geary.ImapEngine.MoveEmail : Geary.ImapEngine.SendReplayOperation return ReplayOperation.Status.COMPLETED; } - + public override async void backout_local_async() throws Error { - yield engine.local_folder.mark_removed_async(moved_ids, false, cancellable); + if (to_move.size == 0) + return; - engine.replay_notify_email_inserted(moved_ids); - engine.replay_notify_email_count_changed(original_count, Geary.Folder.CountChangeReason.INSERTED); + yield engine.local_folder.mark_removed_async(to_move, false, cancellable); + + int count = engine.get_remote_counts(null, null); + + engine.replay_notify_email_inserted(to_move); + engine.replay_notify_email_count_changed(count + to_move.size, Folder.CountChangeReason.INSERTED); } - + public override string describe_state() { return "%d email IDs to %s".printf(to_move.size, destination.to_string()); } diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala b/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala new file mode 100644 index 00000000..58b4e4ae --- /dev/null +++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-prepare.vala @@ -0,0 +1,70 @@ +/* Copyright 2012-2014 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. + */ + +/** + * Stage one of a {@link RevokableMove}: collect valid {@link ImapDB.EmailIdentifiers}, mark + * messages as removed, and update counts. + */ + +private class Geary.ImapEngine.MoveEmailPrepare : Geary.ImapEngine.SendReplayOperation { + public Gee.Set? prepared_for_move = null; + + private MinimalFolder engine; + private Cancellable? cancellable; + private Gee.List to_move = new Gee.ArrayList(); + + public MoveEmailPrepare(MinimalFolder engine, Gee.Collection to_move, + Cancellable? cancellable) { + base.only_local("MoveEmailPrepare", OnError.RETRY); + + this.engine = engine; + this.to_move.add_all(to_move); + this.cancellable = cancellable; + } + + public override void notify_remote_removed_ids(Gee.Collection ids) { + if (prepared_for_move != null) + prepared_for_move.remove_all(ids); + } + + public override async ReplayOperation.Status replay_local_async() throws Error { + if (to_move.size <= 0) + return ReplayOperation.Status.COMPLETED; + + int count = engine.get_remote_counts(null, null); + + // as this value is only used for reporting, offer best-possible service + if (count < 0) + count = to_move.size; + + prepared_for_move = yield engine.local_folder.mark_removed_async(to_move, true, cancellable); + if (prepared_for_move == null || prepared_for_move.size == 0) + return ReplayOperation.Status.COMPLETED; + + engine.replay_notify_email_removed(prepared_for_move); + + engine.replay_notify_email_count_changed( + Numeric.int_floor(count - prepared_for_move.size, 0), + Folder.CountChangeReason.REMOVED); + + return ReplayOperation.Status.COMPLETED; + } + + public override void get_ids_to_be_remote_removed(Gee.Collection ids) { + } + + public override async ReplayOperation.Status replay_remote_async() throws Error { + return ReplayOperation.Status.COMPLETED; + } + + public override async void backout_local_async() throws Error { + } + + public override string describe_state() { + return "%d email IDs".printf(prepared_for_move != null ? prepared_for_move.size : 0); + } +} + diff --git a/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala b/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala new file mode 100644 index 00000000..15de1899 --- /dev/null +++ b/src/engine/imap-engine/replay-ops/imap-engine-move-email-revoke.vala @@ -0,0 +1,62 @@ +/* Copyright 2012-2014 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. + */ + +/** + * Revoked {@link RevokableMove}: Unmark emails as removed and update counts. + */ + +private class Geary.ImapEngine.MoveEmailRevoke : Geary.ImapEngine.SendReplayOperation { + private MinimalFolder engine; + private Gee.List to_revoke = new Gee.ArrayList(); + private Cancellable? cancellable; + + public MoveEmailRevoke(MinimalFolder engine, Gee.Collection to_revoke, + Cancellable? cancellable) { + base.only_local("MoveEmailRevoke", OnError.RETRY); + + this.engine = engine; + + this.to_revoke.add_all(to_revoke); + this.cancellable = cancellable; + } + + public override void notify_remote_removed_ids(Gee.Collection ids) { + to_revoke.remove_all(ids); + } + + public override async ReplayOperation.Status replay_local_async() throws Error { + if (to_revoke.size == 0) + return ReplayOperation.Status.COMPLETED; + + Gee.Set? revoked = yield engine.local_folder.mark_removed_async( + to_revoke, false, cancellable); + if (revoked == null || revoked.size == 0) + return ReplayOperation.Status.COMPLETED; + + int count = engine.get_remote_counts(null, null); + + engine.replay_notify_email_inserted(revoked); + engine.replay_notify_email_count_changed(count + revoked.size, + Geary.Folder.CountChangeReason.INSERTED); + + return ReplayOperation.Status.COMPLETED; + } + + public override void get_ids_to_be_remote_removed(Gee.Collection ids) { + } + + public override async ReplayOperation.Status replay_remote_async() throws Error { + return ReplayOperation.Status.COMPLETED; + } + + public override async void backout_local_async() throws Error { + } + + public override string describe_state() { + return "%d email IDs".printf(to_revoke.size); + } +} + diff --git a/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala b/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala new file mode 100644 index 00000000..14e279fd --- /dev/null +++ b/src/engine/imap-engine/replay-ops/imap-engine-user-close.vala @@ -0,0 +1,45 @@ +/* Copyright 2012-2014 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. + */ + +private class Geary.ImapEngine.UserClose : Geary.ImapEngine.ReplayOperation { + private MinimalFolder owner; + private Cancellable? cancellable; + + public UserClose(MinimalFolder owner, Cancellable? cancellable) { + base ("UserClose", Scope.LOCAL_ONLY); + + this.owner = owner; + this.cancellable = cancellable; + } + + public override void notify_remote_removed_position(Imap.SequenceNumber removed) { + } + + public override void notify_remote_removed_ids(Gee.Collection ids) { + } + + public override void get_ids_to_be_remote_removed(Gee.Collection ids) { + } + + public override async ReplayOperation.Status replay_local_async() throws Error { + yield owner.user_close_async(cancellable); + + return ReplayOperation.Status.COMPLETED; + } + + public override async void backout_local_async() throws Error { + } + + public override async ReplayOperation.Status replay_remote_async() throws Error { + // should not be called + return ReplayOperation.Status.COMPLETED; + } + + public override string describe_state() { + return ""; + } +} + diff --git a/src/engine/imap/api/imap-account.vala b/src/engine/imap/api/imap-account.vala index 42bbf717..3a0a7f6e 100644 --- a/src/engine/imap/api/imap-account.vala +++ b/src/engine/imap/api/imap-account.vala @@ -308,8 +308,8 @@ private class Geary.Imap.Account : BaseObject { } } - public async int fetch_unseen_count_async(FolderPath path, Cancellable? cancellable) - throws Error { + public async void fetch_counts_async(FolderPath path, out int unseen, out int total, + Cancellable? cancellable) throws Error { check_open(); MailboxInformation? mailbox_info = path_to_mailbox.get(path); @@ -320,8 +320,10 @@ private class Geary.Imap.Account : BaseObject { path.to_string()); } - StatusData data = yield fetch_status_async(path, { StatusDataType.UNSEEN }, cancellable); - return data.unseen; + StatusData data = yield fetch_status_async(path, { StatusDataType.UNSEEN, StatusDataType.MESSAGES }, + cancellable); + unseen = data.unseen; + total = data.messages; } private async StatusData fetch_status_async(FolderPath path, StatusDataType[] status_types, diff --git a/src/engine/imap/transport/imap-client-session-manager.vala b/src/engine/imap/transport/imap-client-session-manager.vala index c84ccce6..e11ac2ee 100644 --- a/src/engine/imap/transport/imap-client-session-manager.vala +++ b/src/engine/imap/transport/imap-client-session-manager.vala @@ -137,10 +137,15 @@ public class Geary.Imap.ClientSessionManager : BaseObject { // TODO: This isn't the best (deterministic) way to deal with this, but it's easy and works // for now + int attempts = 0; while (sessions.size > 0) { debug("Waiting for ClientSessions to disconnect from ClientSessionManager..."); Timeout.add(250, close_async.callback); yield; + + // give up after three seconds + if (++attempts > 12) + break; } } @@ -341,7 +346,8 @@ public class Geary.Imap.ClientSessionManager : BaseObject { public async void release_session_async(ClientSession session, Cancellable? cancellable) throws Error { - check_open(); + // Don't check_open(), it's valid for this to be called when is_open is false, that happens + // during mop-up MailboxSpecifier? mailbox; ClientSession.Context context = session.get_context(out mailbox); @@ -383,9 +389,15 @@ public class Geary.Imap.ClientSessionManager : BaseObject { assert_not_reached(); } - if (unreserve) { + if (!unreserve) + return; + + // if not open, disconnect, which will remove from the reserved pool anyway + if (!is_open) { + yield force_disconnect_async(session, true); + } else { try { - // don't respect Cancellable because this *must* happen; don't want this lingering + // don't respect Cancellable because this *must* happen; don't want this lingering // on the reserved list forever int token = yield sessions_mutex.claim_async();