build fix: Only depend on Gee 0.8.3, since 0.8.4 isn't released yet
[platform/upstream/folks.git] / folks / individual.vala
index 2e38325..7fb026d 100644 (file)
@@ -62,7 +62,8 @@ public enum Folks.TrustLevel
 
 /**
  * A physical person, aggregated from the various {@link Persona}s the person
- * might have, such as their different IM addresses or vCard entries.
+ * might have, such as their different IM addresses or vCard entries. An
+ * individual must always contain at least one {@link Persona}.
  *
  * When choosing the values of single-valued properties (such as
  * {@link Individual.alias} and {@link Individual.avatar}; but not multi-valued
@@ -102,7 +103,7 @@ public class Folks.Individual : Object,
 {
   /* Stores the Personas contained in this Individual. */
   private HashSet<Persona> _persona_set =
-      new HashSet<Persona> (direct_hash, direct_equal);
+      new HashSet<Persona> ();
   /* Read-only view of the above set */
   private Set<Persona> _persona_set_ro;
   /* Mapping from PersonaStore -> number of Personas from that store contained
@@ -160,6 +161,9 @@ public class Folks.Individual : Object,
    */
   public async void change_avatar (LoadableIcon? avatar) throws PropertyError
     {
+      /* FIXME: Once https://bugzilla.gnome.org/show_bug.cgi?id=604827 is fixed,
+       * this should be rewritten to use async delegates passed to a generic
+       * _change_single_valued_property() method. */
       if ((this._avatar != null && ((!) this._avatar).equal (avatar)) ||
           (this._avatar == null && avatar == null))
         {
@@ -169,7 +173,7 @@ public class Folks.Individual : Object,
       debug ("Setting avatar of individual '%s' to '%p'…", this.id, avatar);
 
       PropertyError? persona_error = null;
-      var avatar_changed = false;
+      var prop_changed = false;
 
       /* Try to write it to only the writeable Personas which have the
        * "avatar" property as writeable. */
@@ -188,7 +192,7 @@ public class Folks.Individual : Object,
                 {
                   yield a.change_avatar (avatar);
                   debug ("    written to writeable persona '%s'", p.uid);
-                  avatar_changed = true;
+                  prop_changed = true;
                 }
               catch (PropertyError e)
                 {
@@ -202,10 +206,17 @@ public class Folks.Individual : Object,
             }
         }
 
-      /* Failure? */
-      if (avatar_changed == false)
+      /* Failure? Changing the property failed on every suitable persona found
+       * (and potentially zero suitable personas were found). */
+      if (prop_changed == false)
         {
-          assert (persona_error != null);
+          if (persona_error == null)
+            {
+              persona_error = new PropertyError.NOT_WRITEABLE (
+                  _("Failed to change property ‘%s’: No suitable personas were found."),
+                  "avatar");
+            }
+
           throw persona_error;
         }
     }
@@ -304,6 +315,9 @@ public class Folks.Individual : Object,
    */
   public async void change_alias (string alias) throws PropertyError
     {
+      /* FIXME: Once https://bugzilla.gnome.org/show_bug.cgi?id=604827 is fixed,
+       * this should be rewritten to use async delegates passed to a generic
+       * _change_single_valued_property() method. */
       if (this._alias == alias)
         {
           return;
@@ -312,7 +326,7 @@ public class Folks.Individual : Object,
       debug ("Setting alias of individual '%s' to '%s'…", this.id, alias);
 
       PropertyError? persona_error = null;
-      var alias_changed = false;
+      var prop_changed = false;
 
       /* Try to write it to only the writeable Personas which have "alias"
        * as a writeable property. */
@@ -331,7 +345,7 @@ public class Folks.Individual : Object,
                 {
                   yield a.change_alias (alias);
                   debug ("    written to writeable persona '%s'", p.uid);
-                  alias_changed = true;
+                  prop_changed = true;
                 }
               catch (PropertyError e)
                 {
@@ -345,16 +359,19 @@ public class Folks.Individual : Object,
             }
         }
 
-      /* Failure? */
-      if (alias_changed == false)
+      /* Failure? Changing the property failed on every suitable persona found
+       * (and potentially zero suitable personas were found). */
+      if (prop_changed == false)
         {
-          assert (persona_error != null);
+          if (persona_error == null)
+            {
+              persona_error = new PropertyError.NOT_WRITEABLE (
+                  _("Failed to change property ‘%s’: No suitable personas were found."),
+                  "alias");
+            }
+
           throw persona_error;
         }
-
-      /* Update our copy of the alias. */
-      this._alias = alias;
-      this.notify_property ("alias");
     }
 
   private StructuredName? _structured_name = null;
@@ -400,6 +417,10 @@ public class Folks.Individual : Object,
    */
   public async void change_nickname (string nickname) throws PropertyError
     {
+      /* FIXME: Once https://bugzilla.gnome.org/show_bug.cgi?id=604827 is fixed,
+       * this should be rewritten to use async delegates passed to a generic
+       * _change_single_valued_property() method. */
+
       // Normalise null values to the empty string
       if (nickname == null)
         {
@@ -414,7 +435,7 @@ public class Folks.Individual : Object,
       debug ("Setting nickname of individual '%s' to '%s'…", this.id, nickname);
 
       PropertyError? persona_error = null;
-      var nickname_changed = false;
+      var prop_changed = false;
 
       /* Try to write it to only the writeable Personas which have "nickname"
        * as a writeable property. */
@@ -433,7 +454,7 @@ public class Folks.Individual : Object,
                 {
                   yield n.change_nickname (nickname);
                   debug ("    written to writeable persona '%s'", p.uid);
-                  nickname_changed = true;
+                  prop_changed = true;
                 }
               catch (PropertyError e)
                 {
@@ -447,16 +468,19 @@ public class Folks.Individual : Object,
             }
         }
 
-      /* Failure? */
-      if (nickname_changed == false)
+      /* Failure? Changing the property failed on every suitable persona found
+       * (and potentially zero suitable personas were found). */
+      if (prop_changed == false)
         {
-          assert (persona_error != null);
+          if (persona_error == null)
+            {
+              persona_error = new PropertyError.NOT_WRITEABLE (
+                  _("Failed to change property ‘%s’: No suitable personas were found."),
+                  "nickname");
+            }
+
           throw persona_error;
         }
-
-      /* Update our copy of the nickname. */
-      this._nickname = nickname;
-      this.notify_property ("nickname");
     }
 
   private Gender _gender = Gender.UNSPECIFIED;
@@ -481,7 +505,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_urls (true);
+          this._update_urls (true, false, false);
           return this._urls_ro;
         }
       set { this.change_urls.begin (value); } /* not writeable */
@@ -498,7 +522,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_phone_numbers (true);
+          this._update_phone_numbers (true, false, false);
           return this._phone_numbers_ro;
         }
       set { this.change_phone_numbers.begin (value); } /* not writeable */
@@ -515,7 +539,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_email_addresses (true);
+          this._update_email_addresses (true, false, false);
           return this._email_addresses_ro;
         }
       set { this.change_email_addresses.begin (value); } /* not writeable */
@@ -532,7 +556,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_roles (true);
+          this._update_roles (true, false, false);
           return this._roles_ro;
         }
       set { this.change_roles.begin (value); } /* not writeable */
@@ -549,7 +573,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_local_ids (true);
+          this._update_local_ids (true, false, false);
           return this._local_ids_ro;
         }
       set { this.change_local_ids.begin (value); } /* not writeable */
@@ -590,7 +614,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_notes (true);
+          this._update_notes (true, false, false);
           return this._notes_ro;
         }
       set { this.change_notes.begin (value); } /* not writeable */
@@ -607,7 +631,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_postal_addresses (true);
+          this._update_postal_addresses (true, false, false);
           return this._postal_addresses_ro;
         }
       set { this.change_postal_addresses.begin (value); } /* not writeable */
@@ -635,6 +659,9 @@ public class Folks.Individual : Object,
    */
   public async void change_is_favourite (bool is_favourite) throws PropertyError
     {
+      /* FIXME: Once https://bugzilla.gnome.org/show_bug.cgi?id=604827 is fixed,
+       * this should be rewritten to use async delegates passed to a generic
+       * _change_single_valued_property() method. */
       if (this._is_favourite == is_favourite)
         {
           return;
@@ -644,7 +671,7 @@ public class Folks.Individual : Object,
         is_favourite ? "TRUE" : "FALSE");
 
       PropertyError? persona_error = null;
-      var is_favourite_changed = false;
+      var prop_changed = false;
 
       /* Try to write it to only the Personas which have "is-favourite" as a
        * writeable property.
@@ -667,7 +694,7 @@ public class Folks.Individual : Object,
                 {
                   yield a.change_is_favourite (is_favourite);
                   debug ("    written to persona '%s'", p.uid);
-                  is_favourite_changed = true;
+                  prop_changed = true;
                 }
               catch (PropertyError e)
                 {
@@ -681,16 +708,19 @@ public class Folks.Individual : Object,
             }
         }
 
-      /* Failure? */
-      if (is_favourite_changed == false)
+      /* Failure? Changing the property failed on every suitable persona found
+       * (and potentially zero suitable personas were found). */
+      if (prop_changed == false)
         {
-          assert (persona_error != null);
+          if (persona_error == null)
+            {
+              persona_error = new PropertyError.NOT_WRITEABLE (
+                  _("Failed to change property ‘%s’: No suitable personas were found."),
+                  "is-favourite");
+            }
+
           throw persona_error;
         }
-
-      /* Update our copy of the property. */
-      this._is_favourite = is_favourite;
-      this.notify_property ("is-favourite");
     }
 
   private HashSet<string>? _groups = null;
@@ -704,7 +734,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_groups (true);
+          this._update_groups (true, false, false);
           return this._groups_ro;
         }
       set { this.change_groups.begin (value); }
@@ -717,10 +747,13 @@ public class Folks.Individual : Object,
    */
   public async void change_groups (Set<string> groups) throws PropertyError
     {
+      /* FIXME: Once https://bugzilla.gnome.org/show_bug.cgi?id=604827 is fixed,
+       * this should be rewritten to use async delegates passed to a generic
+       * _change_single_valued_property() method. */
       debug ("Setting '%s' groups…", this.id);
 
       PropertyError? persona_error = null;
-      var groups_changed = false;
+      var prop_changed = false;
 
       /* Try to write it to only the Personas which have "groups" as a
        * writeable property. */
@@ -739,7 +772,7 @@ public class Folks.Individual : Object,
                 {
                   yield g.change_groups (groups);
                   debug ("    written to persona '%s'", p.uid);
-                  groups_changed = true;
+                  prop_changed = true;
                 }
               catch (PropertyError e)
                 {
@@ -753,10 +786,17 @@ public class Folks.Individual : Object,
             }
         }
 
-      /* Failure? */
-      if (groups_changed == false)
+      /* Failure? Changing the property failed on every suitable persona found
+       * (and potentially zero suitable personas were found). */
+      if (prop_changed == false)
         {
-          assert (persona_error != null);
+          if (persona_error == null)
+            {
+              persona_error = new PropertyError.NOT_WRITEABLE (
+                  _("Failed to change property ‘%s’: No suitable personas were found."),
+                  "groups");
+            }
+
           throw persona_error;
         }
     }
@@ -771,7 +811,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_im_addresses (true);
+          this._update_im_addresses (true, false, false);
           return this._im_addresses;
         }
       set { this.change_im_addresses.begin (value); } /* not writeable */
@@ -788,7 +828,7 @@ public class Folks.Individual : Object,
     {
       get
         {
-          this._update_web_service_addresses (true);
+          this._update_web_service_addresses (true, false, false);
           return this._web_service_addresses;
         }
       /* Not writeable: */
@@ -823,7 +863,7 @@ public class Folks.Individual : Object,
 
   public DateTime? last_im_interaction_datetime
     {
-      get 
+      get
         {
           if (this._last_im_interaction_datetime == null)
             {
@@ -852,7 +892,7 @@ public class Folks.Individual : Object,
    */
   public uint call_interaction_count
     {
-      get 
+      get
         {
           uint counter = 0;
           /* Iterate over all personas and sum up their call interaction counts*/
@@ -902,6 +942,8 @@ public class Folks.Individual : Object,
   /**
    * The set of {@link Persona}s encapsulated by this Individual.
    *
+   * There must always be at least one Persona in this set.
+   *
    * No order is specified over the set of personas, as such an order may be
    * different across each of the properties implemented by the personas (e.g.
    * should they be ordered by presence, name, star sign, etc.?).
@@ -1110,13 +1152,13 @@ public class Folks.Individual : Object,
   public Individual (Set<Persona>? personas)
     {
       Object (personas: personas);
-    }
 
-  construct
-    {
       debug ("Creating new Individual with %u Personas: %p",
           this._persona_set.size, this);
+    }
 
+  construct
+    {
       this._persona_set_ro = this._persona_set.read_only_view;
     }
 
@@ -1335,15 +1377,117 @@ public class Folks.Individual : Object,
       setter (candidate_p);
     }
 
-  private void _update_groups (bool create_if_not_exist)
+  /* Delegate to add the values of a property from all personas to the
+   * collection of values for that property in this individual.
+   *
+   * Used in _update_multi_valued_property(), below. */
+  private delegate bool MultiValuedPropertySetter ();
+
+  /* Delegate to get whether a multi-valued property in this Individual has not
+   * been initialised yet (and is thus still null).
+   *
+   * Used in _update_multi_valued_property(), below. */
+  private delegate bool PropertyIsNull ();
+
+  /* Delegate to create a new empty collection for a multi-valued property in
+   * this Individual and assign it to the property.
+   *
+   * Used in _update_multi_valued_property(), below. */
+  private delegate void CollectionCreator ();
+
+  /*
+   * Update a multi-valued property from the values in the personas.
+   *
+   * Multi-valued properties are ones such as {@link Individual.notes} or
+   * {@link Individual.email_addresses} which have multiple values taken as the
+   * union of the values listed by the personas for those properties.
+   *
+   * This function handles lazy instantiation of the multi-valued property. If
+   * ``create_if_not_exist`` is ``true``, the property is guaranteed to be
+   * created (by ``create_collection``) and set to a non-``null`` value before
+   * this function returns.
+   *
+   * If ``create_if_not_exist`` is ``false``, however, the property may not be
+   * instantiated if it hasn't already been accessed through its property
+   * getter. In this case, a change notification will be emitted for the
+   * property and this function will return immediately.
+   *
+   * If ``force_update`` is ``true``, then existing values get updated (if
+   * the current value is different) or created (according to the
+   * ``create_if_not_exist`` value). Otherwise the function only ensures
+   * that there is a value (if ``create_if_not_exist`` is set) and leaves
+   * existing values unchanged.
+   *
+   * If the property value is to be instantiated, or already has been
+   * instantiated, its value is updated by ``setter`` from the values of the
+   * property in the individual's personas.
+   *
+   * @param prop_name name of the property being set, as used in
+   * {@link Persona.writeable_properties}
+   * @param create_if_not_exist ``true`` to ensure the property is non-null;
+   * ``false`` otherwise
+   * @param prop_is_null function returning ``true`` iff the property is
+   * currently ``null``
+   * @param create_collection function creating a new collection/container for
+   * the property values and assigning it to the property (and updating the
+   * property's read-only view as necessary)
+   * @param setter function which adds the values from the individual's
+   * personas' values for the property to the individual's value for the
+   * property; it returns ``true`` if the property value has changed
+   * @since 0.7.4
+   */
+  private void _update_multi_valued_property (string prop_name,
+      bool create_if_not_exist, PropertyIsNull prop_is_null,
+      CollectionCreator create_collection, MultiValuedPropertySetter setter,
+      bool emit_notification = true,
+      bool force_update = true)
+    {
+      /* If the set of values doesn't exist, and we're not meant to lazily
+       * create it, then simply emit a notification (since the set might've
+       * changed — we can't be sure, but emitting is a safe over-estimate) and
+       * return. */
+      bool created = false;
+      if (prop_is_null ())
+        {
+          /* Notify and return. */
+          if (create_if_not_exist == false)
+            {
+              if (emit_notification)
+                {
+                  this.notify_property (prop_name);
+                }
+              return;
+            }
+
+          /* Lazily instantiate the set of IM addresses. */
+          create_collection ();
+          created = true;
+        }
+
+      /* Re-populate the collection as the union of the values in the
+       * individual's personas. Do this when an empty property was just
+       * created or we were asked to explicitly (usually because the caller
+       * knows that the current value is out-dated).
+       */
+      if ((created || force_update) && setter () == true && emit_notification)
+        {
+          this.notify_property (prop_name);
+        }
+    }
+
+  private void _update_groups (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
       /* If the set of groups doesn't exist, and we're not meant to lazily
        * create it, then simply emit a notification (since the set might've
        * changed — we can't be sure, but emitting is a safe over-estimate) and
        * return. */
+      bool created = false;
       if (this._groups == null && create_if_not_exist == false)
         {
-          this.notify_property ("groups");
+          if (emit_notification)
+            {
+              this.notify_property ("groups");
+            }
           return;
         }
 
@@ -1352,8 +1496,13 @@ public class Folks.Individual : Object,
         {
           this._groups = new HashSet<string> ();
           this._groups_ro = this._groups.read_only_view;
+          created = true;
         }
 
+      /* Don't touch existing content in get(). */
+      if (!created && !force_update)
+         return;
+
       var new_groups = new HashSet<string> ();
 
       /* FIXME: this should partition the personas by store (maybe we should
@@ -1376,7 +1525,7 @@ public class Folks.Individual : Object,
 
       foreach (var group in new_groups)
         {
-          if (this._groups.add (group))
+          if (this._groups.add (group) && emit_notification)
             {
               this.group_changed (group, true);
             }
@@ -1394,7 +1543,10 @@ public class Folks.Individual : Object,
         {
           unowned string group = (string) l;
           this._groups.remove (group);
-          this.group_changed (group, false);
+          if (emit_notification)
+            {
+              this.group_changed (group, false);
+            }
         });
     }
 
@@ -1562,91 +1714,109 @@ public class Folks.Individual : Object,
         this.trust_level = trust_level;
     }
 
-  private void _update_im_addresses (bool create_if_not_exist)
+  private void _update_im_addresses (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of IM addresses doesn't exist, and we're not meant to lazily
-       * create it, then simply emit a notification (since the set might've
-       * changed — we can't be sure, but emitting is a safe over-estimate) and
-       * return. */
-      if (this._im_addresses == null && create_if_not_exist == false)
-        {
-          this.notify_property ("im-addresses");
-          return;
-        }
-
-      /* Lazily instantiate the set of IM addresses. */
-      else if (this._im_addresses == null)
-        {
-          this._im_addresses = new HashMultiMap<string, ImFieldDetails> (
-              null, null, ImFieldDetails.hash,
-              (EqualFunc) ImFieldDetails.equal);
-        }
-
-      /* populate the IM addresses as the union of our Personas' addresses */
-      this._im_addresses.clear ();
-
-      foreach (var persona in this._persona_set)
-        {
-          if (persona is ImDetails)
+      this._update_multi_valued_property ("im-addresses",
+          create_if_not_exist, () => { return this._im_addresses == null; },
+          () =>
             {
-              var im_details = (ImDetails) persona;
-              foreach (var cur_protocol in im_details.im_addresses.get_keys ())
-                {
-                  var cur_addresses =
-                      im_details.im_addresses.get (cur_protocol);
+              this._im_addresses = new HashMultiMap<string, ImFieldDetails> (
+                  null, null,
+                  (Gee.HashDataFunc) AbstractFieldDetails<string>.hash_static,
+                  (Gee.EqualDataFunc) AbstractFieldDetails<string>.equal_static);
+            },
+          () =>
+            {
+              var new_im_addresses = new HashMultiMap<string, ImFieldDetails> (
+                  null, null,
+                  (Gee.HashDataFunc) AbstractFieldDetails<string>.hash_static,
+                  (Gee.EqualDataFunc) AbstractFieldDetails<string>.equal_static);
 
-                  foreach (var address in cur_addresses)
+              foreach (var persona in this._persona_set)
+                {
+                  /* We only care about personas implementing the given interface. */
+                  var im_details = persona as ImDetails;
+                  if (im_details != null)
                     {
-                      this._im_addresses.set (cur_protocol, address);
+                      foreach (var cur_protocol in
+                          im_details.im_addresses.get_keys ())
+                        {
+                          var cur_addresses =
+                              im_details.im_addresses.get (cur_protocol);
+
+                          foreach (var address in cur_addresses)
+                            {
+                              new_im_addresses.set (cur_protocol, address);
+                            }
+                        }
                     }
                 }
-            }
-        }
-      this.notify_property ("im-addresses");
-    }
 
-  private void _update_web_service_addresses (bool create_if_not_exist)
-    {
-      /* If the set of web service addresses doesn't exist, and we're not meant
-       * to lazily create it, then simply emit a notification (since the set
-       * might've changed — we can't be sure, but emitting is a safe
-       * over-estimate) and return. */
-      if (this._web_service_addresses == null && create_if_not_exist == false)
-        {
-          this.notify_property ("web-service-addresses");
-          return;
-        }
-
-      /* Lazily instantiate the set of web service addresses. */
-      else if (this._web_service_addresses == null)
-        {
-          this._web_service_addresses =
-              new HashMultiMap<string, WebServiceFieldDetails> (null, null,
-                  (GLib.HashFunc) WebServiceFieldDetails.hash,
-                  (GLib.EqualFunc) WebServiceFieldDetails.equal);;
-        }
+              if (!Utils.multi_map_str_afd_equal (new_im_addresses,
+                  this._im_addresses))
+                {
+                  this._im_addresses = new_im_addresses;
+                  return true;
+                }
 
-      /* populate the web service addresses as the union of our Personas' addresses */
-      this._web_service_addresses.clear ();
+              return false;
+            }, emit_notification, force_update);
+    }
 
-      foreach (var persona in this.personas)
-        {
-          if (persona is WebServiceDetails)
+  private void _update_web_service_addresses (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
+    {
+      this._update_multi_valued_property ("web-service-addresses",
+          create_if_not_exist,
+          () => { return this._web_service_addresses == null; },
+          () =>
             {
-              var web_service_details = (WebServiceDetails) persona;
-              foreach (var cur_web_service in
-                  web_service_details.web_service_addresses.get_keys ())
+              this._web_service_addresses =
+                  new HashMultiMap<string, WebServiceFieldDetails> (null, null,
+                      (Gee.HashDataFunc)
+                          AbstractFieldDetails<string>.hash_static,
+                      (Gee.EqualDataFunc)
+                          AbstractFieldDetails<string>.equal_static);
+            },
+          () =>
+            {
+              var new_web_service_addresses =
+                  new HashMultiMap<string, WebServiceFieldDetails> (null, null,
+                      (Gee.HashDataFunc)
+                          AbstractFieldDetails<string>.hash_static,
+                      (Gee.EqualDataFunc)
+                          AbstractFieldDetails<string>.equal_static);
+
+              foreach (var persona in this._persona_set)
                 {
-                  var cur_addresses =
-                      web_service_details.web_service_addresses.get (
-                          cur_web_service);
+                  /* We only care about personas implementing the given interface. */
+                  var web_service_details = persona as WebServiceDetails;
+                  if (web_service_details != null)
+                    {
+                      foreach (var cur_web_service in
+                          web_service_details.web_service_addresses.get_keys ())
+                        {
+                          var cur_addresses =
+                              web_service_details.web_service_addresses.get (
+                                  cur_web_service);
+
+                          foreach (var ws_fd in cur_addresses)
+                            {
+                              new_web_service_addresses.set (cur_web_service,
+                                  ws_fd);
+                            }
+                        }
+                    }
+                }
 
-                  foreach (var ws_fd in cur_addresses)
-                    this._web_service_addresses.set (cur_web_service, ws_fd);
+              if (!Utils.multi_map_str_afd_equal (new_web_service_addresses,
+                  this._web_service_addresses))
+                {
+                  this._web_service_addresses = new_web_service_addresses;
+                  return true;
                 }
-            }
-        }
-      this.notify_property ("web-service-addresses");
+
+              return false;
+            }, emit_notification, force_update);
     }
 
   private void _connect_to_persona (Persona persona)
@@ -1686,7 +1856,7 @@ public class Folks.Individual : Object,
         }
       /* Subscribe to the interactions signal for the persona */
       var p_interaction_details = persona as InteractionDetails;
-      if (p_interaction_details != null) 
+      if (p_interaction_details != null)
         {
           persona.notify["im-interaction-count"].connect (this._notify_im_interaction_count_cb);
           persona.notify["call-interaction-count"].connect (this._notify_call_interaction_count_cb);
@@ -1827,7 +1997,7 @@ public class Folks.Individual : Object,
 
       /* Unsubscribe from the interactions signal for the persona */
       var p_interaction_details = persona as InteractionDetails;
-      if (p_interaction_details != null) 
+      if (p_interaction_details != null)
         {
           persona.notify["im-interaction-count"].disconnect (this._notify_im_interaction_count_cb);
           persona.notify["call-interaction-count"].disconnect (this._notify_call_interaction_count_cb);
@@ -1889,270 +2059,299 @@ public class Folks.Individual : Object,
         });
     }
 
-  private void _update_urls (bool create_if_not_exist)
+  private void _update_urls (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of URIs doesn't exist, and we're not meant to lazily create
-       * it, then simply emit a notification (since the set might've changed —
-       * we can't be sure, but emitting is a safe over-estimate) and return. */
-      if (this._urls == null && create_if_not_exist == false)
-        {
-          this.notify_property ("urls");
-          return;
-        }
-
-      /* Lazily instantiate the set of URIs. */
-      else if (this._urls == null)
-        {
-          this._urls = new HashSet<UrlFieldDetails> (
-              (GLib.HashFunc) UrlFieldDetails.hash,
-              (GLib.EqualFunc) UrlFieldDetails.equal);
-          this._urls_ro = this._urls.read_only_view;
-        }
-
-      /* Populate the URLs as the union of our Personas' URLs.
-       * If the same URL exists multiple times we merge the parameters. */
-      var urls_set = new HashMap<string, UrlFieldDetails> (
-          null, null, (GLib.EqualFunc) UrlFieldDetails.equal);
-
-      this._urls.clear ();
-
-      foreach (var persona in this._persona_set)
-        {
-          var url_details = persona as UrlDetails;
-          if (url_details != null)
+      this._update_multi_valued_property ("urls", create_if_not_exist,
+          () => { return this._urls == null; },
+          () =>
             {
-              foreach (var url_fd in ((!) url_details).urls)
+              this._urls = new HashSet<UrlFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              this._urls_ro = this._urls.read_only_view;
+            },
+          () =>
+            {
+              var new_urls = new HashSet<UrlFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              var urls_set = new HashMap<unowned string,
+                  unowned UrlFieldDetails> (
+                    null, null,  AbstractFieldDetails<string>.equal_static);
+
+              foreach (var persona in this._persona_set)
                 {
-                  var existing = urls_set.get (url_fd.value);
-                  if (existing != null)
-                    existing.extend_parameters (url_fd.parameters);
-                  else
+                  /* We only care about personas implementing the given
+                   * interface. If the same URL exists multiple times we merge
+                   * the parameters. */
+                  var url_details = persona as UrlDetails;
+                  if (url_details != null)
                     {
-                      var new_url_fd = new UrlFieldDetails (url_fd.value);
-                      new_url_fd.extend_parameters (url_fd.parameters);
-                      urls_set.set (new_url_fd.value, new_url_fd);
-                      this._urls.add (new_url_fd);
+                      foreach (var url_fd in ((!) url_details).urls)
+                        {
+                          var existing = urls_set.get (url_fd.value);
+                          if (existing != null)
+                            {
+                              existing.extend_parameters (url_fd.parameters);
+                            }
+                          else
+                            {
+                              var new_url_fd =
+                                  new UrlFieldDetails (url_fd.value);
+                              new_url_fd.extend_parameters (url_fd.parameters);
+                              urls_set.set (new_url_fd.value, new_url_fd);
+                              new_urls.add (new_url_fd);
+                            }
+                        }
                     }
                 }
-            }
-        }
 
-      this.notify_property ("urls");
+              if (!Utils.set_afd_equal (new_urls, this._urls))
+                {
+                  this._urls = new_urls;
+                  this._urls_ro = new_urls.read_only_view;
+                  return true;
+                }
+
+              return false;
+            }, emit_notification, force_update);
     }
 
-  private void _update_phone_numbers (bool create_if_not_exist)
+  private void _update_phone_numbers (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of phone numbers doesn't exist, and we're not meant to
-       * lazily create it, then simply emit a notification (since the set
-       * might've changed — we can't be sure, but emitting is a safe
-       * over-estimate) and return. */
-      if (this._phone_numbers == null && create_if_not_exist == false)
-        {
-          this.notify_property ("phone-numbers");
-          return;
-        }
-
-      /* Lazily instantiate the set of phone numbers. */
-      else if (this._phone_numbers == null)
-        {
-          this._phone_numbers = new HashSet<PhoneFieldDetails> (
-              (GLib.HashFunc) PhoneFieldDetails.hash,
-              (GLib.EqualFunc) PhoneFieldDetails.equal);
-          this._phone_numbers_ro = this._phone_numbers.read_only_view;
-        }
-
-      /* Populate the phone numbers as the union of our Personas' numbers
-       * If the same number exists multiple times we merge the parameters. */
-      var phone_numbers_set = new HashMap<string, PhoneFieldDetails> (
-              null, null, (GLib.EqualFunc) PhoneFieldDetails.equal);
-
-      this._phone_numbers.clear ();
-
-      foreach (var persona in this._persona_set)
-        {
-          var phone_details = persona as PhoneDetails;
-          if (phone_details != null)
+      this._update_multi_valued_property ("phone-numbers", create_if_not_exist,
+          () => { return this._phone_numbers == null; },
+          () =>
+            {
+              this._phone_numbers = new HashSet<PhoneFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              this._phone_numbers_ro = this._phone_numbers.read_only_view;
+            },
+          () =>
             {
-              foreach (var phone_fd in ((!) phone_details).phone_numbers)
+              var new_phone_numbers = new HashSet<PhoneFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              var phone_numbers_set = new HashMap<string, PhoneFieldDetails> (
+                  null, null, AbstractFieldDetails<string>.equal_static);
+
+              foreach (var persona in this._persona_set)
                 {
-                  var existing = phone_numbers_set.get (phone_fd.value);
-                  if (existing != null)
-                    existing.extend_parameters (phone_fd.parameters);
-                  else
+                  /* We only care about personas implementing the given
+                   * interface. If the same phone number exists multiple times
+                   * we merge the parameters. */
+                  var phone_details = persona as PhoneDetails;
+                  if (phone_details != null)
                     {
-                      var new_fd = new PhoneFieldDetails (phone_fd.value);
-                      new_fd.extend_parameters (phone_fd.parameters);
-                      phone_numbers_set.set (new_fd.value, new_fd);
-                      this._phone_numbers.add (new_fd);
+                      foreach (var phone_fd in ((!) phone_details).phone_numbers)
+                        {
+                          var existing = phone_numbers_set.get (phone_fd.value);
+                          if (existing != null)
+                            {
+                              existing.extend_parameters (phone_fd.parameters);
+                            }
+                          else
+                            {
+                              var new_fd =
+                                  new PhoneFieldDetails (phone_fd.value);
+                              new_fd.extend_parameters (phone_fd.parameters);
+                              phone_numbers_set.set (new_fd.value, new_fd);
+                              new_phone_numbers.add (new_fd);
+                            }
+                        }
                     }
                 }
-            }
-        }
 
-      this.notify_property ("phone-numbers");
+              if (!Utils.set_afd_equal (new_phone_numbers, this._phone_numbers))
+                {
+                  this._phone_numbers = new_phone_numbers;
+                  this._phone_numbers_ro = new_phone_numbers.read_only_view;
+                  return true;
+                }
+
+              return false;
+            }, emit_notification, force_update);
     }
 
-  private void _update_email_addresses (bool create_if_not_exist)
+  private void _update_email_addresses (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of e-mail addresses doesn't exist, and we're not meant to
-       * lazily create it, then simply emit a notification (since the set
-       * might've changed — we can't be sure, but emitting is a safe
-       * over-estimate) and return. */
-      if (this._email_addresses == null && create_if_not_exist == false)
-        {
-          this.notify_property ("email-addresses");
-          return;
-        }
-
-      /* Lazily instantiate the set of e-mail addresses. */
-      else if (this._email_addresses == null)
-        {
-          this._email_addresses = new HashSet<EmailFieldDetails> (
-              (GLib.HashFunc) EmailFieldDetails.hash,
-              (GLib.EqualFunc) EmailFieldDetails.equal);
-          this._email_addresses_ro = this._email_addresses.read_only_view;
-        }
-
-      /* Populate the email addresses as the union of our Personas' addresses.
-       * If the same address exists multiple times we merge the parameters. */
-      var emails_set = new HashMap<string, EmailFieldDetails> (
-          null, null, (GLib.EqualFunc) EmailFieldDetails.equal);
-
-      this._email_addresses.clear ();
-
-      foreach (var persona in this._persona_set)
-        {
-          var email_details = persona as EmailDetails;
-          if (email_details != null)
+      this._update_multi_valued_property ("email-addresses",
+          create_if_not_exist, () => { return this._email_addresses == null; },
+          () =>
             {
-              foreach (var email_fd in ((!) email_details).email_addresses)
+              this._email_addresses = new HashSet<EmailFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              this._email_addresses_ro = this._email_addresses.read_only_view;
+            },
+          () =>
+            {
+              var new_email_addresses = new HashSet<EmailFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              var emails_set = new HashMap<string, EmailFieldDetails> (
+                  null, null, AbstractFieldDetails<string>.equal_static);
+
+              foreach (var persona in this._persona_set)
                 {
-                  var existing = emails_set.get (email_fd.value);
-                  if (existing != null)
-                    existing.extend_parameters (email_fd.parameters);
-                  else
+                  /* We only care about personas implementing the given
+                   * interface. If the same e-mail address exists multiple times
+                   * we merge the parameters. */
+                  var email_details = persona as EmailDetails;
+                  if (email_details != null)
                     {
-                      var new_email_fd = new EmailFieldDetails (email_fd.value,
-                          email_fd.parameters);
-                      emails_set.set (new_email_fd.value, new_email_fd);
-                      this._email_addresses.add (new_email_fd);
+                      foreach (var email_fd in ((!) email_details).email_addresses)
+                        {
+                          var existing = emails_set.get (email_fd.value);
+                          if (existing != null)
+                            {
+                              existing.extend_parameters (email_fd.parameters);
+                            }
+                          else
+                            {
+                              var new_email_fd =
+                                  new EmailFieldDetails (email_fd.value,
+                                      email_fd.parameters);
+                              emails_set.set (new_email_fd.value, new_email_fd);
+                              new_email_addresses.add (new_email_fd);
+                            }
+                        }
                     }
                 }
-            }
-        }
 
-      this.notify_property ("email-addresses");
+              if (!Utils.set_afd_equal (new_email_addresses,
+                  this._email_addresses))
+                {
+                  this._email_addresses = new_email_addresses;
+                  this._email_addresses_ro = new_email_addresses.read_only_view;
+                  return true;
+                }
+
+              return false;
+            }, emit_notification, force_update);
     }
 
-  private void _update_roles (bool create_if_not_exist)
+  private void _update_roles (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of roles doesn't exist, and we're not meant to
-       * lazily create it, then simply emit a notification (since the set
-       * might've changed — we can't be sure, but emitting is a safe
-       * over-estimate) and return. */
-      if (this._roles == null && create_if_not_exist == false)
-        {
-          this.notify_property ("roles");
-          return;
-        }
-
-      /* Lazily instantiate the set of roles. */
-      else if (this._roles == null)
-        {
-          this._roles = new HashSet<RoleFieldDetails> (
-              (GLib.HashFunc) RoleFieldDetails.hash,
-              (GLib.EqualFunc) RoleFieldDetails.equal);
-          this._roles_ro = this._roles.read_only_view;
-        }
+      this._update_multi_valued_property ("roles", create_if_not_exist,
+          () => { return this._roles == null; },
+          () =>
+            {
+              this._roles = new HashSet<RoleFieldDetails> (
+                  AbstractFieldDetails<Role>.hash_static,
+                  AbstractFieldDetails<Role>.equal_static);
+              this._roles_ro = this._roles.read_only_view;
+            },
+          () =>
+            {
+              var new_roles = new HashSet<RoleFieldDetails> (
+                  AbstractFieldDetails<Role>.hash_static,
+                  AbstractFieldDetails<Role>.equal_static);
 
-      this._roles.clear ();
+              foreach (var persona in this._persona_set)
+                {
+                  var role_details = persona as RoleDetails;
+                  if (role_details != null)
+                    {
+                      foreach (var role_fd in ((!) role_details).roles)
+                        {
+                          new_roles.add (role_fd);
+                        }
+                    }
+                }
 
-      foreach (var persona in this._persona_set)
-        {
-          var role_details = persona as RoleDetails;
-          if (role_details != null)
-            {
-              foreach (var role_fd in ((!) role_details).roles)
+              if (!Utils.set_afd_equal (new_roles, this._roles))
                 {
-                  this._roles.add (role_fd);
+                  this._roles = new_roles;
+                  this._roles_ro = new_roles.read_only_view;
+                  return true;
                 }
-            }
-        }
 
-      this.notify_property ("roles");
+              return false;
+            }, emit_notification, force_update);
     }
 
-  private void _update_local_ids (bool create_if_not_exist)
+  private void _update_local_ids (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of local IDs doesn't exist, and we're not meant to
-       * lazily create it, then simply emit a notification (since the set
-       * might've changed — we can't be sure, but emitting is a safe
-       * over-estimate) and return. */
-      if (this._local_ids == null && create_if_not_exist == false)
-        {
-          this.notify_property ("local-ids");
-          return;
-        }
-
-      /* Lazily instantiate the set of local IDs. */
-      else if (this._local_ids == null)
-        {
-          this._local_ids = new HashSet<string> ();
-          this._local_ids_ro = this._local_ids.read_only_view;
-        }
+      this._update_multi_valued_property ("local-ids", create_if_not_exist,
+          () => { return this._local_ids == null; },
+          () =>
+            {
+              this._local_ids = new HashSet<string> ();
+              this._local_ids_ro = this._local_ids.read_only_view;
+            },
+          () =>
+            {
+              var new_local_ids = new HashSet<string> ();
 
-      this._local_ids.clear ();
+              foreach (var persona in this._persona_set)
+                {
+                  var local_id_details = persona as LocalIdDetails;
+                  if (local_id_details != null)
+                    {
+                      foreach (var id in ((!) local_id_details).local_ids)
+                        {
+                          new_local_ids.add (id);
+                        }
+                    }
+                }
 
-      foreach (var persona in this._persona_set)
-        {
-          var local_ids_details = persona as LocalIdDetails;
-          if (local_ids_details != null)
-            {
-              foreach (var id in ((!) local_ids_details).local_ids)
+              if (new_local_ids.size != this._local_ids.size ||
+                  !new_local_ids.contains_all (this._local_ids))
                 {
-                  this._local_ids.add (id);
+                  this._local_ids = new_local_ids;
+                  this._local_ids_ro = new_local_ids.read_only_view;
+                  return true;
                 }
-            }
-        }
 
-      this.notify_property ("local-ids");
+              return false;
+            }, emit_notification, force_update);
     }
 
-  private void _update_postal_addresses (bool create_if_not_exist)
+  private void _update_postal_addresses (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of addresses doesn't exist, and we're not meant to lazily
-       * create it, then simply emit a notification (since the set might've
-       * changed — we can't be sure, but emitting is a safe over-estimate) and
-       * return. */
-      if (this._postal_addresses == null && create_if_not_exist == false)
-        {
-          this.notify_property ("postal-addresses");
-          return;
-        }
-
-      /* Lazily instantiate the set of addresses. */
-      else if (this._postal_addresses == null)
-        {
-          this._postal_addresses = new HashSet<PostalAddressFieldDetails> (
-              (GLib.HashFunc) PostalAddressFieldDetails.hash,
-              (GLib.EqualFunc) PostalAddressFieldDetails.equal);
-          this._postal_addresses_ro = this._postal_addresses.read_only_view;
-        }
-
-      this._postal_addresses.clear ();
-
       /* FIXME: Detect duplicates somehow? */
-      foreach (var persona in this._persona_set)
-        {
-          var address_details = persona as PostalAddressDetails;
-          if (address_details != null)
+      this._update_multi_valued_property ("postal-addresses",
+          create_if_not_exist, () => { return this._postal_addresses == null; },
+          () =>
             {
-              foreach (var pafd in ((!) address_details).postal_addresses)
-                this._postal_addresses.add (pafd);
-            }
-        }
+              this._postal_addresses = new HashSet<PostalAddressFieldDetails> (
+                  AbstractFieldDetails<PostalAddress>.hash_static,
+                  AbstractFieldDetails<PostalAddress>.equal_static);
+              this._postal_addresses_ro = this._postal_addresses.read_only_view;
+            },
+          () =>
+            {
+              var new_postal_addresses =
+                  new HashSet<PostalAddressFieldDetails> (
+                      AbstractFieldDetails<PostalAddress>.hash_static,
+                      AbstractFieldDetails<PostalAddress>.equal_static);
 
-      this.notify_property ("postal-addresses");
+              foreach (var persona in this._persona_set)
+                {
+                  var postal_address_details = persona as PostalAddressDetails;
+                  if (postal_address_details != null)
+                    {
+                      foreach (var pafd in
+                          ((!) postal_address_details).postal_addresses)
+                        {
+                          new_postal_addresses.add (pafd);
+                        }
+                    }
+                }
+
+              if (!Utils.set_afd_equal (new_postal_addresses,
+                  this._postal_addresses))
+                {
+                  this._postal_addresses = new_postal_addresses;
+                  this._postal_addresses_ro =
+                      new_postal_addresses.read_only_view;
+                  return true;
+                }
+
+              return false;
+            }, emit_notification, force_update);
     }
 
   private void _update_birthday ()
@@ -2207,42 +2406,44 @@ public class Folks.Individual : Object,
         });
     }
 
-  private void _update_notes (bool create_if_not_exist)
+  private void _update_notes (bool create_if_not_exist, bool emit_notification = true, bool force_update = true)
     {
-      /* If the set of notes doesn't exist, and we're not meant to lazily
-       * create it, then simply emit a notification (since the set might've
-       * changed — we can't be sure, but emitting is a safe over-estimate) and
-       * return. */
-      if (this._notes == null && create_if_not_exist == false)
-        {
-          this.notify_property ("notes");
-          return;
-        }
-
-      /* Lazily instantiate the set of notes. */
-      else if (this._notes == null)
-        {
-          this._notes = new HashSet<NoteFieldDetails> (
-              (GLib.HashFunc) NoteFieldDetails.hash,
-              (GLib.EqualFunc) NoteFieldDetails.equal);
-          this._notes_ro = this._notes.read_only_view;
-        }
+      this._update_multi_valued_property ("notes", create_if_not_exist,
+          () => { return this._notes == null; },
+          () =>
+            {
+              this._notes = new HashSet<NoteFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
+              this._notes_ro = this._notes.read_only_view;
+            },
+          () =>
+            {
+              var new_notes = new HashSet<NoteFieldDetails> (
+                  AbstractFieldDetails<string>.hash_static,
+                  AbstractFieldDetails<string>.equal_static);
 
-      this._notes.clear ();
+              foreach (var persona in this._persona_set)
+                {
+                  var note_details = persona as NoteDetails;
+                  if (note_details != null)
+                    {
+                      foreach (var n in ((!) note_details).notes)
+                        {
+                          new_notes.add (n);
+                        }
+                    }
+                }
 
-      foreach (var persona in this._persona_set)
-        {
-          var note_details = persona as NoteDetails;
-          if (note_details != null)
-            {
-              foreach (var n in ((!) note_details).notes)
+              if (!Utils.set_afd_equal (new_notes, this._notes))
                 {
-                  this._notes.add (n);
+                  this._notes = new_notes;
+                  this._notes_ro = new_notes.read_only_view;
+                  return true;
                 }
-            }
-        }
 
-      this.notify_property ("notes");
+              return false;
+            }, emit_notification, force_update);
     }
 
   private void _set_personas (Set<Persona>? personas,