diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index caca32b2..e23f80a2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -181,6 +181,7 @@ engine/state/state-mapping.vala engine/util/util-collection.vala engine/util/util-converter.vala +engine/util/util-files.vala engine/util/util-generic-capabilities.vala engine/util/util-html.vala engine/util/util-inet.vala @@ -206,6 +207,7 @@ client/main.vala client/accounts/account-dialog.vala client/accounts/account-dialog-account-list-pane.vala client/accounts/account-dialog-add-edit-pane.vala +client/accounts/account-dialog-remove-confirm-pane.vala client/accounts/account-spinner-page.vala client/accounts/add-edit-page.vala client/accounts/login-dialog.vala diff --git a/src/client/accounts/account-dialog-account-list-pane.vala b/src/client/accounts/account-dialog-account-list-pane.vala index 02c4f01d..6c380b24 100644 --- a/src/client/accounts/account-dialog-account-list-pane.vala +++ b/src/client/accounts/account-dialog-account-list-pane.vala @@ -14,11 +14,14 @@ public class AccountDialogAccountListPane : Gtk.Box { private Gtk.TreeView list_view; private Gtk.ListStore list_model = new Gtk.ListStore(2, typeof (string), typeof (string)); private Gtk.Action edit_action; + private Gtk.Action delete_action; public signal void add_account(); public signal void edit_account(string email_address); + public signal void delete_account(string email_address); + public signal void close(); public AccountDialogAccountListPane() { @@ -28,6 +31,7 @@ public class AccountDialogAccountListPane : Gtk.Box { pack_end((Gtk.Box) builder.get_object("container")); Gtk.ActionGroup actions = (Gtk.ActionGroup) builder.get_object("account list actions"); edit_action = actions.get_action("edit_account"); + delete_action = actions.get_action("delete_account"); // Set up list. list_view = (Gtk.TreeView) builder.get_object("account_list"); @@ -43,7 +47,8 @@ public class AccountDialogAccountListPane : Gtk.Box { actions.get_action("close").activate.connect(() => { close(); }); actions.get_action("add_account").activate.connect(() => { add_account(); }); edit_action.activate.connect(notify_edit_account); - list_view.get_selection().changed.connect(on_selection_changed); + delete_action.activate.connect(notify_delete_account); + list_view.get_selection().changed.connect(update_buttons); list_view.button_press_event.connect(on_button_press); // Theme hint: "join" the toolbar to the scrolled window above it. @@ -75,6 +80,12 @@ public class AccountDialogAccountListPane : Gtk.Box { edit_account(account); } + private void notify_delete_account() { + string? account = get_selected_account(); + if (account != null) + delete_account(account); + } + private bool on_button_press(Gdk.EventButton event) { if (event.type != Gdk.EventType.2BUTTON_PRESS) return false; @@ -108,8 +119,19 @@ public class AccountDialogAccountListPane : Gtk.Box { return account; } - private void on_selection_changed() { + private void update_buttons() { edit_action.sensitive = get_selected_account() != null; + delete_action.sensitive = edit_action.sensitive && get_num_accounts() > 1; + } + + private int get_num_accounts() { + try { + return Geary.Engine.instance.get_accounts().size; + } catch (Error e) { + debug("Error getting number of accounts: %s", e.message); + } + + return 0; // on error } private void on_account_added(Geary.AccountInformation account) { @@ -119,11 +141,13 @@ public class AccountDialogAccountListPane : Gtk.Box { add_account_to_list(account.nickname, account.email); account.notify.connect(on_account_changed); + update_buttons(); } private void on_account_removed(Geary.AccountInformation account) { remove_account_from_list(account.email); account.notify.disconnect(on_account_changed); + update_buttons(); } // Adds an account to the list. diff --git a/src/client/accounts/account-dialog-remove-confirm-pane.vala b/src/client/accounts/account-dialog-remove-confirm-pane.vala new file mode 100644 index 00000000..3354322a --- /dev/null +++ b/src/client/accounts/account-dialog-remove-confirm-pane.vala @@ -0,0 +1,37 @@ +/* Copyright 2013 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. + */ + +// Confirmation of the deletion of an account +public class AccountDialogRemoveConfirmPane : Gtk.Box { + private Geary.AccountInformation? account = null; + private Gtk.Label account_nickname_label; + private Gtk.Label email_address_label; + + public signal void ok(Geary.AccountInformation? account); + + public signal void cancel(); + + public AccountDialogRemoveConfirmPane() { + Object(orientation: Gtk.Orientation.VERTICAL, spacing: 6); + + Gtk.Builder builder = GearyApplication.instance.create_builder("remove_confirm.glade"); + pack_end((Gtk.Box) builder.get_object("container")); + Gtk.ActionGroup actions = (Gtk.ActionGroup) builder.get_object("actions"); + account_nickname_label = (Gtk.Label) builder.get_object("account_nickname_label"); + email_address_label = (Gtk.Label) builder.get_object("email_address_label"); + + // Hook up signals. + actions.get_action("cancel_action").activate.connect(() => { cancel(); }); + actions.get_action("remove_action").activate.connect(() => { ok(account); }); + } + + public void set_account(Geary.AccountInformation a) { + account = a; + account_nickname_label.label = account.nickname; + email_address_label.label = account.email; + } +} + diff --git a/src/client/accounts/account-dialog.vala b/src/client/accounts/account-dialog.vala index 42bd4136..ba67316a 100644 --- a/src/client/accounts/account-dialog.vala +++ b/src/client/accounts/account-dialog.vala @@ -11,9 +11,11 @@ public class AccountDialog : Gtk.Dialog { private AccountDialogAccountListPane account_list_pane = new AccountDialogAccountListPane(); private AccountDialogAddEditPane add_edit_pane = new AccountDialogAddEditPane(); private AccountSpinnerPage spinner_pane = new AccountSpinnerPage(); + private AccountDialogRemoveConfirmPane remove_confirm_pane = new AccountDialogRemoveConfirmPane(); private int add_edit_page_number; private int account_list_page_number; private int spinner_page_number; + private int remove_confirm_page_number; public AccountDialog() { set_size_request(450, -1); // Sets min size. @@ -26,14 +28,18 @@ public class AccountDialog : Gtk.Dialog { account_list_page_number = notebook.append_page(account_list_pane, null); add_edit_page_number = notebook.append_page(add_edit_pane, null); spinner_page_number = notebook.append_page(spinner_pane, null); + remove_confirm_page_number = notebook.append_page(remove_confirm_pane, null); // Connect signals from pages. account_list_pane.close.connect(on_close); account_list_pane.add_account.connect(on_add_account); account_list_pane.edit_account.connect(on_edit_account); + account_list_pane.delete_account.connect(on_delete_account); add_edit_pane.ok.connect(on_save_add_or_edit); - add_edit_pane.cancel.connect(on_cancel_add_edit); + add_edit_pane.cancel.connect(on_cancel_back_to_list); add_edit_pane.size_changed.connect(() => { resize(1, 1); }); + remove_confirm_pane.ok.connect(on_delete_account_confirmed); + remove_confirm_pane.cancel.connect(on_cancel_back_to_list); // Set default page. notebook.set_current_page(account_list_page_number); @@ -55,28 +61,55 @@ public class AccountDialog : Gtk.Dialog { notebook.set_current_page(add_edit_page_number); } - private void on_edit_account(string email_address) { - // Grab the account info. While the addresses passed into this method should *always* be - // available in Geary, we double-check to be defensive. - Gee.Map accounts; + // Grab the account info. While the addresses passed into this method should *always* be + // available in Geary, we double-check to be defensive. + private Geary.AccountInformation? get_account_info_for_email(string email_address) { + Gee.Map accounts; try { accounts = Geary.Engine.instance.get_accounts(); } catch (Error e) { debug("Error getting account info: %s", e.message); - return; + return null; } if (!accounts.has_key(email_address)) { debug("Unable to get account info for: %s", email_address); - return; + + return null; } + return accounts.get(email_address); + } + + private void on_edit_account(string email_address) { + Geary.AccountInformation? account = get_account_info_for_email(email_address); + if (account == null) + return; + add_edit_pane.set_mode(AddEditPage.PageMode.EDIT); - add_edit_pane.set_account_information(accounts.get(email_address)); + add_edit_pane.set_account_information(account); notebook.set_current_page(add_edit_page_number); } + private void on_delete_account(string email_address) { + Geary.AccountInformation? account = get_account_info_for_email(email_address); + if (account == null) + return; + + // Send user to confirmation screen. + remove_confirm_pane.set_account(account); + notebook.set_current_page(remove_confirm_page_number); + } + + private void on_delete_account_confirmed(Geary.AccountInformation? account) { + assert(account != null); // Should not be able to happen since we checked earlier. + + // Remove account, then set the page back to the account list. + GearyApplication.instance.remove_account_async.begin(account, null, () => { + notebook.set_current_page(account_list_page_number); }); + } + private void on_save_add_or_edit(Geary.AccountInformation info) { // Show the busy spinner. notebook.set_current_page(spinner_page_number); @@ -94,7 +127,7 @@ public class AccountDialog : Gtk.Dialog { notebook.set_current_page(add_edit_page_number); } - private void on_cancel_add_edit() { + private void on_cancel_back_to_list() { notebook.set_current_page(account_list_page_number); } } diff --git a/src/client/geary-application.vala b/src/client/geary-application.vala index f0a7da5d..cae03d20 100644 --- a/src/client/geary-application.vala +++ b/src/client/geary-application.vala @@ -308,6 +308,17 @@ along with Geary; if not, write to the Free Software Foundation, Inc., } } + // Removes an existing account. + public async void remove_account_async(Geary.AccountInformation account, + Cancellable? cancellable = null) { + try { + yield GearyApplication.instance.get_account_instance(account).close_async(cancellable); + yield Geary.Engine.instance.remove_account_async(account, cancellable); + } catch (Error e) { + message("Error removing account: %s", e.message); + } + } + public File get_user_data_directory() { return File.new_for_path(Environment.get_user_data_dir()).get_child("geary"); } diff --git a/src/client/geary-controller.vala b/src/client/geary-controller.vala index 9428f129..86e8774c 100644 --- a/src/client/geary-controller.vala +++ b/src/client/geary-controller.vala @@ -322,12 +322,16 @@ public class GearyController { } private void on_folder_selected(Geary.Folder? folder) { + debug("Folder %s selected", folder != null ? folder.to_string() : "(null)"); + + // If the folder is being unset, clear the message list and exit here. if (folder == null) { - debug("no folder selected"); + main_window.conversation_list_store.clear(); + main_window.conversation_viewer.clear(null, null); + return; } - debug("Folder %s selected", folder.to_string()); set_busy(true); do_select_folder.begin(folder, on_select_folder_completed); } @@ -364,13 +368,6 @@ public class GearyController { main_window.main_toolbar.move_folder_menu.add_folder(f); } - // The current folder may be null if the user rapidly switches between folders. If they have - // done that then this folder selection is invalid anyways, so just return. - if (current_folder == null) { - warning("Can not open folder: %s", folder.to_string()); - return; - } - update_ui(); if (!inboxes.values.contains(current_folder)) { diff --git a/src/client/models/folder-list.vala b/src/client/models/folder-list.vala index d34db832..c63ef1f3 100644 --- a/src/client/models/folder-list.vala +++ b/src/client/models/folder-list.vala @@ -222,12 +222,25 @@ public class FolderList : Sidebar.Tree { assert(account_branch != null); assert(has_branch(account_branch)); + // If this is the current folder, unselect it. + Sidebar.Entry? entry = account_branch.folder_entries.get(folder.get_path()); + if (entry != null && is_selected(entry)) + folder_selected(null); + account_branch.remove_folder(folder); } public void remove_account(Geary.Account account) { AccountBranch? account_branch = account_branches.get(account); if (account_branch != null) { + // If a folder on this account is selected, unselect it. + foreach (FolderEntry entry in account_branch.folder_entries.values) { + if (is_selected(entry)) { + folder_selected(null); + break; + } + } + if (has_branch(account_branch)) prune(account_branch); account_branches.unset(account); diff --git a/src/engine/abstract/geary-abstract-account.vala b/src/engine/abstract/geary-abstract-account.vala index 1ac8b962..d728e1d5 100644 --- a/src/engine/abstract/geary-abstract-account.vala +++ b/src/engine/abstract/geary-abstract-account.vala @@ -44,6 +44,8 @@ public abstract class Geary.AbstractAccount : Object, Geary.Account { public abstract async void close_async(Cancellable? cancellable = null) throws Error; + public abstract bool is_open(); + public abstract Gee.Collection list_matching_folders( Geary.FolderPath? parent) throws Error; diff --git a/src/engine/api/geary-account-information.vala b/src/engine/api/geary-account-information.vala index 65c6e463..642c6396 100644 --- a/src/engine/api/geary-account-information.vala +++ b/src/engine/api/geary-account-information.vala @@ -375,4 +375,48 @@ public class Geary.AccountInformation : Object { debug("Error writing to account info file: %s", err.message); } } + + public async void clear_stored_passwords_async( + CredentialsMediator.ServiceFlag services) throws Error { + Error? return_error = null; + check_mediator_instance(); + CredentialsMediator mediator = Geary.Engine.instance.authentication_mediator; + + try { + if (services.has_imap()) { + yield mediator.clear_password_async( + CredentialsMediator.Service.IMAP, imap_credentials.user); + } + } catch (Error e) { + return_error = e; + } + + try { + if (services.has_smtp()) { + yield mediator.clear_password_async( + CredentialsMediator.Service.SMTP, smtp_credentials.user); + } + } catch (Error e) { + return_error = e; + } + + if (return_error != null) + throw return_error; + } + + /** + * Deletes an account from disk. This is used by Geary.Engine and should not + * normally be invoked directly. + */ + internal async void remove_async(Cancellable? cancellable = null) { + try { + yield clear_stored_passwords_async(CredentialsMediator.ServiceFlag.IMAP + | CredentialsMediator.ServiceFlag.SMTP); + } catch (Error e) { + debug("Error clearing SMTP password: %s", e.message); + } + + // Delete files. + yield Files.recursive_delete_async(settings_dir, cancellable); + } } diff --git a/src/engine/api/geary-account.vala b/src/engine/api/geary-account.vala index e2ffa2ec..8ba9f4e5 100644 --- a/src/engine/api/geary-account.vala +++ b/src/engine/api/geary-account.vala @@ -80,6 +80,11 @@ public interface Geary.Account : Object { */ public abstract async void close_async(Cancellable? cancellable = null) throws Error; + /** + * Returns true if this account is open, else false. + */ + public abstract bool is_open(); + /** * Lists all the currently-available folders found under the parent path * unless it's null, in which case it lists all the root folders. If the diff --git a/src/engine/api/geary-engine-error.vala b/src/engine/api/geary-engine-error.vala index 75f6652b..013f513e 100644 --- a/src/engine/api/geary-engine-error.vala +++ b/src/engine/api/geary-engine-error.vala @@ -14,6 +14,7 @@ public errordomain Geary.EngineError { BAD_RESPONSE, INCOMPLETE_MESSAGE, SERVER_UNAVAILABLE, - ALREADY_CLOSED + ALREADY_CLOSED, + CLOSE_REQUIRED } diff --git a/src/engine/api/geary-engine.vala b/src/engine/api/geary-engine.vala index 1476bcc1..2e5ee407 100644 --- a/src/engine/api/geary-engine.vala +++ b/src/engine/api/geary-engine.vala @@ -293,13 +293,25 @@ public class Geary.Engine { public async void remove_account_async(AccountInformation account, Cancellable? cancellable = null) throws Error { check_opened(); - + + // Ensure account is closed. + if (account_instances.has_key(account.email) && account_instances.get(account.email).is_open()) { + throw new EngineError.CLOSE_REQUIRED("Account %s must be closed before removal", + account.email); + } + if (accounts.unset(account.email)) { + // Removal *MUST* be done in the following order: + // 1. Send the account-unavailable signal. account_unavailable(account); - - // TODO: delete the account from disk. + + // 2. Delete the corresponding files. + yield account.remove_async(cancellable); + + // 3. Send the account-removed signal. account_removed(account); + // 4. Remove the account data from the engine. account_instances.unset(account.email); } } diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala b/src/engine/imap-engine/imap-engine-generic-account.vala index 02fc8bb6..95a58083 100644 --- a/src/engine/imap-engine/imap-engine-generic-account.vala +++ b/src/engine/imap-engine/imap-engine-generic-account.vala @@ -126,6 +126,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.AbstractAccount { properties_map.clear(); existing_folders.clear(); local_only.clear(); + open = false; if (local_err != null) throw local_err; @@ -134,6 +135,10 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.AbstractAccount { throw remote_err; } + public override bool is_open() { + return open; + } + // Subclasses should implement this to return their flavor of a GenericFolder with the // appropriate interfaces attached. The returned folder should have its SpecialFolderType // set using either the properties from the local folder or its path. diff --git a/src/engine/util/util-files.vala b/src/engine/util/util-files.vala new file mode 100644 index 00000000..4d737179 --- /dev/null +++ b/src/engine/util/util-files.vala @@ -0,0 +1,57 @@ +/* Copyright 2013 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. + */ + +namespace Geary.Files { + +// Number of files to delete in each step. +public const int RECURSIVE_DELETE_BATCH_SIZE = 50; + +/** + * Recursively deletes a folder and its children. + * This method is designed to keep chugging along even if an error occurs. + * If this method is called with a file, it will simply be deleted. + */ +public async void recursive_delete_async(File folder, Cancellable? cancellable = null) { + // If this is a folder, recurse children. + if (folder.query_file_type(FileQueryInfoFlags.NONE) == FileType.DIRECTORY) { + FileEnumerator? enumerator = null; + try { + enumerator = yield folder.enumerate_children_async(FileAttribute.STANDARD_NAME, + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, Priority.DEFAULT, cancellable); + } catch (Error e) { + debug("Error enumerating files for deletion: %s", e.message); + } + + // Iterate the enumerated files in batches. + try { + while (true) { + List? info_list = null; + + info_list = yield enumerator.next_files_async(RECURSIVE_DELETE_BATCH_SIZE, + Priority.DEFAULT, cancellable); + + if (info_list == null) + break; // Stop condition. + + // Recursive step. + foreach (FileInfo info in info_list) + yield recursive_delete_async(folder.get_child(info.get_name()), cancellable); + } + } catch (Error e) { + debug("Error enumerating batch of files: %s", e.message); + } + } + + // Children have been deleted, it's now safe to delete this file/folder. + try { + yield folder.delete_async(Priority.DEFAULT, cancellable); + } catch (Error e) { + debug("Error removing file: %s", e.message); + } +} + +} + diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 48363481..221a01d4 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -9,6 +9,7 @@ install(FILES login.glade DESTINATION ${UI_DEST}) install(FILES message.glade DESTINATION ${UI_DEST}) install(FILES password-dialog.glade DESTINATION ${UI_DEST}) install(FILES preferences.glade DESTINATION ${UI_DEST}) +install(FILES remove_confirm.glade DESTINATION ${UI_DEST}) install(FILES toolbar.glade DESTINATION ${UI_DEST}) install(FILES toolbar_mark_menu.ui DESTINATION ${UI_DEST}) install(FILES toolbar_menu.ui DESTINATION ${UI_DEST}) diff --git a/ui/account_list.glade b/ui/account_list.glade index 58a72c4e..fa18a997 100644 --- a/ui/account_list.glade +++ b/ui/account_list.glade @@ -17,6 +17,11 @@ gtk-edit + + + list-remove-symbolic + + True @@ -88,6 +93,19 @@ True + + + delete_account + True + False + toolbutton1 + True + + + False + True + + False diff --git a/ui/remove_confirm.glade b/ui/remove_confirm.glade new file mode 100644 index 00000000..898f7b69 --- /dev/null +++ b/ui/remove_confirm.glade @@ -0,0 +1,199 @@ + + + + + + + gtk-cancel + + + + + gtk-remove + + + + + True + False + 1 + + + True + False + 0 + gtk-dialog-warning + 6 + + + False + True + 6 + 0 + + + + + True + False + vertical + + + True + False + 20 + 0 + <span weight="bold" size="larger">Are you sure you want to remove this account?</span> + True + True + + + False + True + 0 + + + + + True + False + 0 + All email associated with this account will be removed from your computer. This will not affect email on the server. + True + + + False + False + 6 + 1 + + + + + True + False + True + 5 + 5 + + + True + False + 0 + Nickname: + + + 0 + 0 + 1 + 1 + + + + + True + False + 0 + Email address: + + + 0 + 1 + 1 + 1 + + + + + True + False + 0 + 6 + + + + + + 1 + 0 + 1 + 1 + + + + + True + False + 0 + 6 + + + + + + 1 + 1 + 1 + 1 + + + + + False + True + 6 + 2 + + + + + True + False + 12 + end + + + _Cancel + cancel_action + True + True + True + True + + + False + True + 0 + + + + + _Remove + remove_action + True + True + True + True + + + False + True + 1 + + + + + False + True + 3 + + + + + False + True + 1 + + + +