diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 638c753ea..317003eb6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -590,6 +590,7 @@ FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; + FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */; }; FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; }; FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; }; FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; }; @@ -1705,6 +1706,7 @@ FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; + FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigContactsSpec.swift; sourceTree = ""; }; FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = ""; }; FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = ""; }; @@ -4014,6 +4016,7 @@ FD8ECF802934385900C0D1BB /* LibSessionUtil */ = { isa = PBXGroup; children = ( + FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */, FD8ECF812934387A00C0D1BB /* ConfigUserProfileSpec.swift */, ); path = LibSessionUtil; @@ -6007,6 +6010,7 @@ FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */, FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, + FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, diff --git a/SessionMessagingKit/Database/Models/SharedConfigDump.swift b/SessionMessagingKit/Database/Models/SharedConfigDump.swift index c5278a68f..ab22c481f 100644 --- a/SessionMessagingKit/Database/Models/SharedConfigDump.swift +++ b/SessionMessagingKit/Database/Models/SharedConfigDump.swift @@ -15,6 +15,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, Identifiable, FetchableR public enum Variant: String, Codable, DatabaseValueConvertible, CaseIterable { case userProfile + case contacts } public var id: Variant { variant } @@ -32,6 +33,7 @@ public extension ConfigDump.Variant { var configMessageKind: SharedConfigMessage.Kind { switch self { case .userProfile: return .userProfile + case .contacts: return .contacts } } } diff --git a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift index 32c29557d..7f295e39a 100644 --- a/SessionMessagingKit/LibSessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/LibSessionUtil/SessionUtil.swift @@ -24,6 +24,7 @@ import SessionUtilitiesKit // MARK: - Configs private static var userProfileConfig: Atomic?> = Atomic(nil) + private static var contactsConfig: Atomic?> = Atomic(nil) // MARK: - Variables @@ -32,6 +33,9 @@ import SessionUtilitiesKit switch variant { case .userProfile: return (userProfileConfig.wrappedValue.map { config_needs_push($0) } ?? false) + + case .contacts: + return (contactsConfig.wrappedValue.map { config_needs_push($0) } ?? false) } } } @@ -40,6 +44,7 @@ import SessionUtilitiesKit private static func config(for variant: ConfigDump.Variant) -> Atomic?> { switch variant { case .userProfile: return SessionUtil.userProfileConfig + case .contacts: return SessionUtil.contactsConfig } } @@ -49,6 +54,7 @@ import SessionUtilitiesKit guard let secretKey: [UInt8] = ed25519SecretKey else { return } SessionUtil.userProfileConfig.mutate { $0 = loadState(for: .userProfile, secretKey: secretKey) } + SessionUtil.contactsConfig.mutate { $0 = loadState(for: .contacts, secretKey: secretKey) } } private static func loadState( @@ -73,17 +79,17 @@ import SessionUtilitiesKit // Setup initial variables (including getting the memory address for any cached data) var conf: UnsafeMutablePointer? = nil let error: UnsafeMutablePointer? = nil - let cachedDump: (data: UnsafePointer, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in + let cachedDump: (data: UnsafePointer, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in return unsafeBytes.baseAddress.map { ( - $0.assumingMemoryBound(to: CChar.self), + $0.assumingMemoryBound(to: UInt8.self), unsafeBytes.count ) } } // No need to deallocate the `cachedDump.data` as it'll automatically be cleaned up by - // the `cachedData` lifecycle, but need to deallocate the `error` if it gets set + // the `cachedDump` lifecycle, but need to deallocate the `error` if it gets set defer { error?.deallocate() } @@ -94,6 +100,9 @@ import SessionUtilitiesKit switch variant { case .userProfile: return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .contacts: + return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) } }() diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a index 5e58f5b33..3f991bb6a 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a index 8c1e89f23..4ee48d353 100644 Binary files a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a and b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/ios-arm64_x86_64-simulator/libsession-util.a differ diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap index 5580079fa..d5540495e 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/module.modulemap @@ -4,8 +4,10 @@ module SessionUtil { header "session/config.h" header "session/config/error.h" header "session/config/user_profile.h" + header "session/config/contacts.h" header "session/config/encrypt.h" header "session/config/base.h" + header "session/config/profile_pic.h" header "session/xed25519.h" export * } diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp index 86f42533d..be3a4f9cb 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/base.hpp @@ -237,6 +237,13 @@ class ConfigBase { /// otherwise. const std::string* string() const { return get_clean(); } + /// Returns the value as a ustring_view, if it exists and is a string; nullopt otherwise. + std::optional uview() const { + if (auto* s = get_clean()) + return ustring_view{reinterpret_cast(s->data()), s->size()}; + return std::nullopt; + } + /// returns the value as a string_view or a fallback if the value doesn't exist (or isn't a /// string). The returned view is directly into the value (or fallback) and so mustn't be /// used beyond the validity of either. @@ -278,8 +285,12 @@ class ConfigBase { /// intermediate dicts needed to reach the given key, including replacing non-dict values if /// they currently exist along the path. void operator=(std::string value) { assign_if_changed(std::move(value)); } - /// Same as above, but takes a string_view for convenience. + /// Same as above, but takes a string_view for convenience (this makes a copy). void operator=(std::string_view value) { *this = std::string{value}; } + /// Same as above, but takes a ustring_view + void operator=(ustring_view value) { + *this = std::string{reinterpret_cast(value.data()), value.size()}; + } /// Replace the current value with the given integer. See above. void operator=(int64_t value) { assign_if_changed(value); } /// Replace the current value with the given set. See above. @@ -378,6 +389,14 @@ class ConfigBase { // nothing. virtual void load_extra_data(oxenc::bt_dict extra) {} + // Called to load an ed25519 key for encryption; this is meant for use by single-ownership + // config types, like UserProfile, but not shared config types (closed groups). + // + // Takes a binary string which is either the 32-byte seed, or 64-byte libsodium secret (which is + // just the seed and pubkey concatenated together), and then calls `key(...)` with the seed. + // Throws std::invalid_argument if given something that doesn't match the required input. + void load_key(ustring_view ed25519_secretkey); + public: virtual ~ConfigBase(); diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h new file mode 100644 index 000000000..a889c7cb9 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.h @@ -0,0 +1,147 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "base.h" +#include "profile_pic.h" + +typedef struct contacts_contact { + char session_id[67]; // in hex; 66 hex chars + null terminator. + + // These can be NULL. When setting, either NULL or empty string will clear the setting. + const char* name; + const char* nickname; + user_profile_pic profile_pic; + + bool approved; + bool approved_me; + bool blocked; + +} contacts_contact; + +/// Constructs a contacts config object and sets a pointer to it in `conf`. +/// +/// \param ed25519_secretkey must be the 32-byte secret key seed value. (You can also pass the +/// pointer to the beginning of the 64-byte value libsodium calls the "secret key" as the first 32 +/// bytes of that are the seed). This field cannot be null. +/// +/// \param dump - if non-NULL this restores the state from the dumped byte string produced by a past +/// instantiation's call to `dump()`. To construct a new, empty object this should be NULL. +/// +/// \param dumplen - the length of `dump` when restoring from a dump, or 0 when `dump` is NULL. +/// +/// \param error - the pointer to a buffer in which we will write an error string if an error +/// occurs; error messages are discarded if this is given as NULL. If non-NULL this must be a +/// buffer of at least 256 bytes. +/// +/// Returns 0 on success; returns a non-zero error code and write the exception message as a +/// C-string into `error` (if not NULL) on failure. +/// +/// When done with the object the `config_object` must be destroyed by passing the pointer to +/// config_free() (in `session/config/base.h`). +int contacts_init( + config_object** conf, + const unsigned char* ed25519_secretkey, + const unsigned char* dump, + size_t dumplen, + char* error) __attribute__((warn_unused_result)); + +/// Returns true if session_id has the right form (66 hex digits). This is a quick check, not a +/// robust one: it does not check the leading byte prefix, nor the cryptographic properties of the +/// pubkey for actual validity. +bool session_id_is_valid(const char* session_id); + +/// Fills `contact` with the contact info given a session ID (specified as a null-terminated hex +/// string), if the contact exists, and returns true. If the contact does not exist then `contact` +/// is left unchanged and false is returned. +bool contacts_get(const config_object* conf, contacts_contact* contact, const char* session_id) + __attribute__((warn_unused_result)); + +/// Same as the above except that when the contact does not exist, this sets all the contact fields +/// to defaults and loads it with the given session_id. +/// +/// Returns true as long as it is given a valid session_id. A false return is considered an error, +/// and means the session_id was not a valid session_id. +/// +/// This is the method that should usually be used to create or update a contact, followed by +/// setting fields in the contact, and then giving it to contacts_set(). +bool contacts_get_or_create( + const config_object* conf, contacts_contact* contact, const char* session_id) + __attribute__((warn_unused_result)); + +/// Adds or updates a contact from the given contact info struct. +void contacts_set(config_object* conf, const contacts_contact* contact); + +// NB: wrappers for set_name, set_nickname, etc. C++ methods are deliberately omitted as they would +// save very little in actual calling code. The procedure for updating a single field without them +// is simple enough; for example to update `approved` and leave everything else unchanged: +// +// contacts_contact c; +// if (contacts_get_or_create(conf, &c, some_session_id)) { +// const char* new_nickname = "Joe"; +// c.approved = new_nickname; +// contacts_set_or_create(conf, &c); +// } else { +// // some_session_id was invalid! +// } + +/// Erases a contact from the contact list. session_id is in hex. Returns true if the contact was +/// found and removed, false if the contact was not present. You must not call this during +/// iteration; see details below. +bool contacts_erase(config_object* conf, const char* session_id); + +/// Functions for iterating through the entire contact list, in sorted order. Intended use is: +/// +/// contacts_contact c; +/// contacts_iterator *it = contacts_iterator_new(contacts); +/// for (; !contacts_iterator_done(it, &c); contacts_iterator_advance(it)) { +/// // c.session_id, c.nickname, etc. are loaded +/// } +/// contacts_iterator_free(it); +/// +/// It is permitted to modify records (e.g. with a call to `contacts_set`) and add records while +/// iterating. +/// +/// If you need to remove while iterating then usage is slightly different: you must advance the +/// iteration by calling either contacts_iterator_advance if not deleting, or +/// contacts_iterator_erase to erase and advance. Usage looks like this: +/// +/// contacts_contact c; +/// contacts_iterator *it = contacts_iterator_new(contacts); +/// while (!contacts_iterator_done(it, &c)) { +/// // c.session_id, c.nickname, etc. are loaded +/// +/// bool should_delete = /* ... */; +/// +/// if (should_delete) +/// contacts_iterator_erase(it); +/// else +/// contacts_iterator_advance(it); +/// } +/// contacts_iterator_free(it); +/// +/// + +typedef struct contacts_iterator { + void* _internals; +} contacts_iterator; + +// Starts a new iterator. +contacts_iterator* contacts_iterator_new(const config_object* conf); +// Frees an iterator once no longer needed. +void contacts_iterator_free(contacts_iterator* it); + +// Returns true if iteration has reached the end. Otherwise `c` is populated and false is returned. +bool contacts_iterator_done(contacts_iterator* it, contacts_contact* c); + +// Advances the iterator. +void contacts_iterator_advance(contacts_iterator* it); + +// Erases the current contact while advancing the iterator to the next contact in the iteration. +void contacts_iterator_erase(config_object* conf, contacts_iterator* it); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp new file mode 100644 index 000000000..1601c6146 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/contacts.hpp @@ -0,0 +1,187 @@ +#pragma once + +#include +#include +#include +#include + +#include "base.hpp" +#include "namespaces.hpp" +#include "profile_pic.hpp" + +extern "C" struct contacts_contact; + +namespace session::config { + +/// keys used in this config, either currently or in the past (so that we don't reuse): +/// +/// c - dict of contacts; within this dict each key is the session pubkey (binary, 33 bytes) and +/// value is a dict containing keys: +/// +/// ! - dummy value that is always set to an empty string. This ensures that we always have at +/// least one key set, which is required to keep the dict value alive (empty dicts get +/// pruned when serialied). +/// n - contact name (string) +/// N - contact nickname (string) +/// p - profile url (string) +/// q - profile decryption key (binary) +/// a - 1 if approved, omitted otherwise (int) +/// A - 1 if remote has approved me, omitted otherwise (int) +/// b - 1 if contact is blocked, omitted otherwise + +/// Struct containing contact info. Note that data must be copied/used immediately as the data will +/// not remain valid beyond other calls into the library. When settings things in this externally +/// (e.g. to pass into `set()`), take note that the `name` and `nickname` are string_views: that is, +/// they must reference existing string data that remains valid for the duration of the contact_info +/// instance. +struct contact_info { + std::string session_id; // in hex + std::optional name; + std::optional nickname; + std::optional profile_picture; + bool approved = false; + bool approved_me = false; + bool blocked = false; + + contact_info(std::string sid); + + // Internal ctor/method for C API implementations: + contact_info(const struct contacts_contact& c); // From c struct + void into(contacts_contact& c); // Into c struct + + private: + friend class Contacts; + + void load(const dict& info_dict); +}; + +class Contacts : public ConfigBase { + + public: + // No default constructor + Contacts() = delete; + + /// Constructs a contact list from existing data (stored from `dump()`) and the user's secret + /// key for generating the data encryption key. To construct a blank list (i.e. with no + /// pre-existing dumped data to load) pass `std::nullopt` as the second argument. + /// + /// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt the + /// data when pushing/pulling from the swarm. This can either be the full 64-byte value (which + /// is technically the 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of + /// the secret key. + /// + /// \param dumped - either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. + Contacts(ustring_view ed25519_secretkey, std::optional dumped); + + Namespace storage_namespace() const override { return Namespace::Contacts; } + + const char* encryption_domain() const override { return "Contacts"; } + + /// Looks up and returns a contact by session ID (hex). Returns nullopt if the session ID was + /// not found, otherwise returns a filled out `contact_info`. + std::optional get(std::string_view pubkey_hex) const; + + /// Similar to get(), but if the session ID does not exist this returns a filled-out + /// contact_info containing the session_id (all other fields will be empty/defaulted). This is + /// intended to be combined with `set` to set-or-create a record. Note that this does not add + /// the session id to the contact list when called: that requires also calling `set` with this + /// value. + contact_info get_or_create(std::string_view pubkey_hex) const; + + /// Sets or updates multiple contact info values at once with the given info. The usual use is + /// to access the current info, change anything desired, then pass it back into set_contact, + /// e.g.: + /// + /// auto c = contacts.get_or_create(pubkey); + /// c.name = "Session User 42"; + /// c.nickname = "BFF"; + /// contacts.set(c); + void set(const contact_info& contact); + + /// Alternative to `set()` for setting individual fields. + void set_name(std::string_view session_id, std::string_view name); + void set_nickname(std::string_view session_id, std::string_view nickname); + void set_profile_pic(std::string_view session_id, profile_pic pic); + void set_approved(std::string_view session_id, bool approved); + void set_approved_me(std::string_view session_id, bool approved_me); + void set_blocked(std::string_view session_id, bool blocked); + + /// Removes a contact, if present. Returns true if it was found and removed, false otherwise. + /// Note that this removes all fields related to a contact, even fields we do not know about. + bool erase(std::string_view session_id); + + struct iterator; + + /// This works like erase, but takes an iterator to the contact to remove. The element is + /// removed and the iterator to the next element after the removed one is returned. This is + /// intended for use where elements are to be removed during iteration: see below for an + /// example. + iterator erase(iterator it); + + /// Iterators for iterating through all contacts. Typically you access this implicit via a for + /// loop over the `Contacts` object: + /// + /// for (auto& contact : contacts) { + /// // use contact.session_id, contact.name, etc. + /// } + /// + /// This iterates in sorted order through the session_ids. + /// + /// It is permitted to modify and add records while iterating (e.g. by modifying `contact` and + /// then calling set()). + /// + /// If you need to erase the current contact during iteration then care is required: you need to + /// advance the iterator via the iterator version of erase when erasing an element rather than + /// incrementing it regularly. For example: + /// + /// for (auto it = contacts.begin(); it != contacts.end(); ) { + /// if (should_remove(*it)) + /// it = contacts.erase(it); + /// else + /// ++it; + /// } + /// + /// Alternatively, you can use the first version with two loops: the first loop through all + /// contacts doesn't erase but just builds a vector of IDs to erase, then the second loops + /// through that vector calling `erase()` for each one. + /// + iterator begin() const { return iterator{data["c"].dict()}; } + iterator end() const { return iterator{nullptr}; } + + using iterator_category = std::input_iterator_tag; + using value_type = contact_info; + using reference = value_type&; + using pointer = value_type*; + using difference_type = std::ptrdiff_t; + + struct iterator { + private: + std::shared_ptr _val; + dict::const_iterator _it; + const dict* _contacts; + void _load_info(); + iterator(const dict* contacts) : _contacts{contacts} { + if (_contacts) { + _it = _contacts->begin(); + _load_info(); + } + } + friend class Contacts; + + public: + bool operator==(const iterator& other) const; + bool operator!=(const iterator& other) const { return !(*this == other); } + bool done() const; // Equivalent to comparing against the end iterator + contact_info& operator*() const { return *_val; } + contact_info* operator->() const { return _val.get(); } + iterator& operator++(); + iterator operator++(int) { + auto copy{*this}; + ++*this; + return copy; + } + }; +}; + +} // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp index f5ab57c20..1ba0226ca 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/namespaces.hpp @@ -6,6 +6,7 @@ namespace session::config { enum class Namespace : std::int16_t { UserProfile = 2, + Contacts = 3, ClosedGroupInfo = 11, }; diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h new file mode 100644 index 000000000..dc9887dd8 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.h @@ -0,0 +1,21 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +typedef struct user_profile_pic { + // Null-terminated C string containing the uploaded URL of the pic. Will be NULL if there is no + // profile pic. + const char* url; + // The profile pic decryption key, in bytes. This is a byte buffer of length `keylen`, *not* a + // null-terminated C string. Will be NULL if there is no profile pic. + const unsigned char* key; + size_t keylen; +} user_profile_pic; + +#ifdef __cplusplus +} +#endif diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp new file mode 100644 index 000000000..00cd90062 --- /dev/null +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/profile_pic.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "session/types.hpp" + +namespace session::config { +// Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end +// of the string view: that is, it views into a full std::string). +struct profile_pic { + std::string_view url; + ustring_view key; + + profile_pic(std::string_view url, ustring_view key) : url{url}, key{key} {} + + // Returns true if either url or key are empty + bool empty() const { return url.empty() || key.empty(); } + + // Guard against accidentally passing in a temporary string or ustring: + template < + typename UrlType, + typename KeyType, + std::enable_if_t< + std::is_same_v || std::is_same_v>> + profile_pic(UrlType&& url, KeyType&& key) = delete; +}; + +} // namespace session::config diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.h b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.h index 8b438591e..03f382c85 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.h +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.h @@ -5,6 +5,7 @@ extern "C" { #endif #include "base.h" +#include "profile_pic.h" /// Constructs a user profile config object and sets a pointer to it in `conf`. /// @@ -41,16 +42,6 @@ const char* user_profile_get_name(const config_object* conf); /// error (and sets the config_object's error string). int user_profile_set_name(config_object* conf, const char* name); -typedef struct user_profile_pic { - // Null-terminated C string containing the uploaded URL of the pic. Will be NULL if there is no - // profile pic. - const char* url; - // The profile pic decryption key, in bytes. This is a byte buffer of length `keylen`, *not* a - // null-terminated C string. Will be NULL if there is no profile pic. - const unsigned char* key; - size_t keylen; -} user_profile_pic; - // Obtains the current profile pic. The pointers in the returned struct will be NULL if a profile // pic is not currently set, and otherwise should be copied right away (they will not be valid // beyond other API calls on this config object). diff --git a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp index f13a83867..cb3b1eb32 100644 --- a/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp +++ b/SessionMessagingKit/LibSessionUtil/libsession-util.xcframework/session/config/user_profile.hpp @@ -5,6 +5,7 @@ #include "base.hpp" #include "namespaces.hpp" +#include "profile_pic.hpp" namespace session::config { @@ -14,13 +15,6 @@ namespace session::config { /// p - user profile url /// q - user profile decryption key (binary) -// Profile pic info. Note that `url` is null terminated (though the null lies just beyond the end -// of the string view: that is, it views into a full std::string). -struct profile_pic { - std::string_view url; - ustring_view key; -}; - class UserProfile final : public ConfigBase { public: @@ -31,12 +25,13 @@ class UserProfile final : public ConfigBase { /// key for generating the data encryption key. To construct a blank profile (i.e. with no /// pre-existing dumped data to load) pass `std::nullopt` as the second argument. /// - /// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt user - /// profile messages; these can either be the full 64-byte value (which is technically the - /// 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of the secret key. + /// \param ed25519_secretkey - contains the libsodium secret key used to encrypt/decrypt the + /// data when pushing/pulling from the swarm. This can either be the full 64-byte value (which + /// is technically the 32-byte seed followed by the 32-byte pubkey), or just the 32-byte seed of + /// the secret key. /// - /// \param dumped - either `std::nullopt` to construct a new, empty user profile; or binary - /// state data that was previously dumped from a UserProfile object by calling `dump()`. + /// \param dumped - either `std::nullopt` to construct a new, empty object; or binary state data + /// that was previously dumped from an instance of this class by calling `dump()`. UserProfile(ustring_view ed25519_secretkey, std::optional dumped); Namespace storage_namespace() const override { return Namespace::UserProfile; } @@ -44,7 +39,7 @@ class UserProfile final : public ConfigBase { const char* encryption_domain() const override { return "UserProfile"; } /// Returns the user profile name, or std::nullopt if there is no profile name set. - const std::optional get_name() const; + std::optional get_name() const; /// Sets the user profile name; if given an empty string then the name is removed. void set_name(std::string_view new_name); @@ -57,9 +52,6 @@ class UserProfile final : public ConfigBase { /// one is empty. void set_profile_pic(std::string_view url, ustring_view key); void set_profile_pic(profile_pic pic); - - private: - void load_key(ustring_view ed25519_secretkey); }; } // namespace session::config diff --git a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift index ad4054fb4..96bb65f4c 100644 --- a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift @@ -21,10 +21,12 @@ public final class SharedConfigMessage: ControlMessage { public enum Kind: CustomStringConvertible, Codable { case userProfile + case contacts public var description: String { switch self { case .userProfile: return "userProfile" + case .contacts: return "contacts" } } } @@ -74,6 +76,7 @@ public final class SharedConfigMessage: ControlMessage { kind: { switch sharedConfigMessage.kind { case .userProfile: return .userProfile + case .contacts: return .contacts } }(), seqNo: sharedConfigMessage.seqno, @@ -87,6 +90,7 @@ public final class SharedConfigMessage: ControlMessage { kind: { switch self.kind { case .userProfile: return .userProfile + case .contacts: return .contacts } }(), seqno: self.seqNo, @@ -121,6 +125,7 @@ public extension SharedConfigMessage.Kind { var configDumpVariant: ConfigDump.Variant { switch self { case .userProfile: return .userProfile + case .contacts: return .contacts } } } diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 26060b939..b13cf6585 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -3714,17 +3714,20 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { @objc public enum SNProtoSharedConfigMessageKind: Int32 { case userProfile = 1 + case contacts = 2 } private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind { switch value { case .userProfile: return .userProfile + case .contacts: return .contacts } } private class func SNProtoSharedConfigMessageKindUnwrap(_ value: SNProtoSharedConfigMessageKind) -> SessionProtos_SharedConfigMessage.Kind { switch value { case .userProfile: return .userProfile + case .contacts: return .contacts } } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 0d8cd6e48..3dd86bfb7 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -1621,6 +1621,7 @@ struct SessionProtos_SharedConfigMessage { enum Kind: SwiftProtobuf.Enum { typealias RawValue = Int case userProfile // = 1 + case contacts // = 2 init() { self = .userProfile @@ -1629,6 +1630,7 @@ struct SessionProtos_SharedConfigMessage { init?(rawValue: Int) { switch rawValue { case 1: self = .userProfile + case 2: self = .contacts default: return nil } } @@ -1636,6 +1638,7 @@ struct SessionProtos_SharedConfigMessage { var rawValue: Int { switch self { case .userProfile: return 1 + case .contacts: return 2 } } @@ -3332,5 +3335,6 @@ extension SessionProtos_SharedConfigMessage: SwiftProtobuf.Message, SwiftProtobu extension SessionProtos_SharedConfigMessage.Kind: SwiftProtobuf._ProtoNameProviding { static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "USER_PROFILE"), + 2: .same(proto: "CONTACTS"), ] } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 13ec388c2..839dd78b6 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -275,6 +275,7 @@ message AttachmentPointer { message SharedConfigMessage { enum Kind { USER_PROFILE = 1; + CONTACTS = 2; } // @required diff --git a/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift b/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift new file mode 100644 index 000000000..b2f9c7744 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/ConfigContactsSpec.swift @@ -0,0 +1,324 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit + +import Quick +import Nimble + +/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches +class ConfigContactsSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + it("generates Contact configs correctly") { + let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let identity = try! Identity.generate(from: seed) + var edSK: [UInt8] = identity.ed25519KeyPair.secretKey + expect(edSK.toHexString().suffix(64)) + .to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7")) + expect(identity.x25519KeyPair.publicKey.toHexString()) + .to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72")) + expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString())) + + // Initialize a brand new, empty config because we have no dump data to deal with. + let error: UnsafeMutablePointer? = nil + var conf: UnsafeMutablePointer? = nil + expect(contacts_init(&conf, &edSK, nil, 0, error)).to(equal(0)) + error?.deallocate() + + // Empty contacts shouldn't have an existing contact + var definitelyRealId: [CChar] = "050000000000000000000000000000000000000000000000000000000000000000" + .bytes + .map { CChar(bitPattern: $0) } + let contactPtr: UnsafeMutablePointer? = nil + expect(contacts_get(conf, contactPtr, &definitelyRealId)).to(beFalse()) + + var contact2: contacts_contact = contacts_contact() + expect(contacts_get_or_create(conf, &contact2, &definitelyRealId)).to(beTrue()) + expect(contact2.name).to(beNil()) + expect(contact2.nickname).to(beNil()) + expect(contact2.approved).to(beFalse()) + expect(contact2.approved_me).to(beFalse()) + expect(contact2.blocked).to(beFalse()) + expect(contact2.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(contact2.profile_pic.url).to(beNil()) + expect(contact2.profile_pic.key).to(beNil()) + expect(contact2.profile_pic.keylen).to(equal(0)) + + // We don't need to push anything, since this is a default contact + expect(config_needs_push(conf)).to(beFalse()) + // And we haven't changed anything so don't need to dump to db + expect(config_needs_dump(conf)).to(beFalse()) + + var toPush: UnsafeMutablePointer? = nil + var toPushLen: Int = 0 + // We don't need to push since we haven't changed anything, so this call is mainly just for + // testing: + let seqno: Int64 = config_push(conf, &toPush, &toPushLen) + expect(toPush).toNot(beNil()) + expect(seqno).to(equal(0)) + expect(toPushLen).to(equal(256)) + + // Update the contact data + let contact2Name: [CChar] = "Joe" + .bytes + .map { CChar(bitPattern: $0) } + let contact2Nickname: [CChar] = "Joey" + .bytes + .map { CChar(bitPattern: $0) } + contact2Name.withUnsafeBufferPointer { contact2.name = $0.baseAddress } + contact2Nickname.withUnsafeBufferPointer { contact2.nickname = $0.baseAddress } + contact2.approved = true + contact2.approved_me = true + + // Update the contact + contacts_set(conf, &contact2) + + // Ensure the contact details were updated + var contact3: contacts_contact = contacts_contact() + expect(contacts_get(conf, &contact3, &definitelyRealId)).to(beTrue()) + expect(String(cString: contact3.name)).to(equal("Joe")) + expect(String(cString: contact3.nickname)).to(equal("Joey")) + expect(contact3.approved).to(beTrue()) + expect(contact3.approved_me).to(beTrue()) + expect(contact3.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(contact3.profile_pic.url).to(beNil()) + expect(contact3.profile_pic.key).to(beNil()) + expect(contact3.profile_pic.keylen).to(equal(0)) + expect(contact3.blocked).to(beFalse()) + + let contact3SessionId: [CChar] = withUnsafeBytes(of: contact3.session_id) { [UInt8]($0) } + .map { CChar($0) } + expect(contact3SessionId).to(equal(definitelyRealId.nullTerminated())) + + // Since we've made changes, we should need to push new config to the swarm, *and* should need + // to dump the updated state: + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + + var toPush2: UnsafeMutablePointer? = nil + var toPush2Len: Int = 0 + let seqno2: Int64 = config_push(conf, &toPush2, &toPush2Len); + // incremented since we made changes (this only increments once between + // dumps; even though we changed multiple fields here). + expect(seqno2).to(equal(1)) + toPush2?.deallocate() + + // Pretend we uploaded it + config_confirm_pushed(conf, seqno2) + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beTrue()) + + // NB: Not going to check encrypted data and decryption here because that's general (not + // specific to contacts) and is covered already in the user profile tests. + var dump1: UnsafeMutablePointer? = nil + var dump1Len: Int = 0 + config_dump(conf, &dump1, &dump1Len) + + let error2: UnsafeMutablePointer? = nil + var conf2: UnsafeMutablePointer? = nil + expect(contacts_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0)) + error?.deallocate() + dump1?.deallocate() + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + var toPush3: UnsafeMutablePointer? = nil + var toPush3Len: Int = 0 + let seqno3: Int64 = config_push(conf, &toPush3, &toPush3Len); + expect(seqno3).to(equal(1)) + toPush3?.deallocate() + + // Because we just called dump() above, to load up contacts2 + expect(config_needs_dump(conf)).to(beFalse()) + + // Ensure the contact details were updated + var contact4: contacts_contact = contacts_contact() + expect(contacts_get(conf2, &contact4, &definitelyRealId)).to(beTrue()) + expect(String(cString: contact4.name)).to(equal("Joe")) + expect(String(cString: contact4.nickname)).to(equal("Joey")) + expect(contact4.approved).to(beTrue()) + expect(contact4.approved_me).to(beTrue()) + expect(contact4.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(contact4.profile_pic.url).to(beNil()) + expect(contact4.profile_pic.key).to(beNil()) + expect(contact4.profile_pic.keylen).to(equal(0)) + expect(contact4.blocked).to(beFalse()) + + var anotherId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111111" + .bytes + .map { CChar(bitPattern: $0) } + var contact5: contacts_contact = contacts_contact() + expect(contacts_get_or_create(conf2, &contact5, &anotherId)).to(beTrue()) + expect(contact5.name).to(beNil()) + expect(contact5.nickname).to(beNil()) + expect(contact5.approved).to(beFalse()) + expect(contact5.approved_me).to(beFalse()) + expect(contact5.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(contact5.profile_pic.url).to(beNil()) + expect(contact5.profile_pic.key).to(beNil()) + expect(contact5.profile_pic.keylen).to(equal(0)) + expect(contact5.blocked).to(beFalse()) + + // We're not setting any fields, but we should still keep a record of the session id + contacts_set(conf2, &contact5) + expect(config_needs_push(conf2)).to(beTrue()) + + var toPush4: UnsafeMutablePointer? = nil + var toPush4Len: Int = 0 + let seqno4: Int64 = config_push(conf2, &toPush4, &toPush4Len); + expect(seqno4).to(equal(2)) + + // Check the merging + var mergeData: [UnsafePointer?] = [UnsafePointer(toPush4)] + var mergeSize: [Int] = [toPush4Len] + expect(config_merge(conf, &mergeData, &mergeSize, 1)).to(equal(1)) + config_confirm_pushed(conf2, seqno4) + toPush4?.deallocate() + + expect(config_needs_push(conf)).to(beFalse()) + + var toPush5: UnsafeMutablePointer? = nil + var toPush5Len: Int = 0 + let seqno5: Int64 = config_push(conf2, &toPush5, &toPush5Len); + expect(seqno5).to(equal(2)) + toPush5?.deallocate() + + // Iterate through and make sure we got everything we expected + var sessionIds: [String] = [] + var nicknames: [String] = [] + var contact6: contacts_contact = contacts_contact() + let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) + while !contacts_iterator_done(contactIterator, &contact6) { + sessionIds.append( + String(cString: withUnsafeBytes(of: contact6.session_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + ) + nicknames.append( + contact6.nickname.map { String(cString: $0) } ?? + "(N/A)" + ) + contacts_iterator_advance(contactIterator) + } + contacts_iterator_free(contactIterator) // Need to free the iterator + + expect(sessionIds.count).to(equal(2)) + expect(sessionIds.first).to(equal(String(cString: definitelyRealId.nullTerminated()))) + expect(sessionIds.last).to(equal(String(cString: anotherId.nullTerminated()))) + expect(nicknames.first).to(equal("Joey")) + expect(nicknames.last).to(equal("(N/A)")) + + // Conflict! Oh no! + + // On client 1 delete a contact: + contacts_erase(conf, definitelyRealId) + + // Client 2 adds a new friend: + var thirdId: [CChar] = "052222222222222222222222222222222222222222222222222222222222222222" + .bytes + .map { CChar(bitPattern: $0) } + let nickname7: [CChar] = "Nickname 3" + .bytes + .map { CChar(bitPattern: $0) } + let profileUrl7: [CChar] = "http://example.com/huge.bmp" + .bytes + .map { CChar(bitPattern: $0) } + let profileKey7: [UInt8] = "qwerty".bytes + var contact7: contacts_contact = contacts_contact() + expect(contacts_get_or_create(conf2, &contact7, &thirdId)).to(beTrue()) + nickname7.withUnsafeBufferPointer { contact7.nickname = $0.baseAddress } + contact7.approved = true + contact7.approved_me = true + profileUrl7.withUnsafeBufferPointer { contact7.profile_pic.url = $0.baseAddress } + profileKey7.withUnsafeBufferPointer { contact7.profile_pic.key = $0.baseAddress } + contact7.profile_pic.keylen = 6 + contacts_set(conf2, &contact7) + + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_push(conf2)).to(beTrue()) + + var toPush6: UnsafeMutablePointer? = nil + var toPush6Len: Int = 0 + let seqno6: Int64 = config_push(conf, &toPush6, &toPush6Len); + expect(seqno6).to(equal(3)) + + var toPush7: UnsafeMutablePointer? = nil + var toPush7Len: Int = 0 + let seqno7: Int64 = config_push(conf2, &toPush7, &toPush7Len); + expect(seqno7).to(equal(3)) + + expect(String(pointer: toPush6, length: toPush6Len, encoding: .ascii)) + .toNot(equal(String(pointer: toPush7, length: toPush7Len, encoding: .ascii))) + + config_confirm_pushed(conf, seqno6) + config_confirm_pushed(conf2, seqno7) + + var mergeData2: [UnsafePointer?] = [UnsafePointer(toPush7)] + var mergeSize2: [Int] = [toPush7Len] + expect(config_merge(conf, &mergeData2, &mergeSize2, 1)).to(equal(1)) + expect(config_needs_push(conf)).to(beTrue()) + + var mergeData3: [UnsafePointer?] = [UnsafePointer(toPush6)] + var mergeSize3: [Int] = [toPush6Len] + expect(config_merge(conf2, &mergeData3, &mergeSize3, 1)).to(equal(1)) + expect(config_needs_push(conf2)).to(beTrue()) + toPush6?.deallocate() + toPush7?.deallocate() + + var toPush8: UnsafeMutablePointer? = nil + var toPush8Len: Int = 0 + let seqno8: Int64 = config_push(conf, &toPush8, &toPush8Len); + expect(seqno8).to(equal(4)) + + var toPush9: UnsafeMutablePointer? = nil + var toPush9Len: Int = 0 + let seqno9: Int64 = config_push(conf2, &toPush9, &toPush9Len); + expect(seqno9).to(equal(seqno8)) + + expect(String(pointer: toPush8, length: toPush8Len, encoding: .ascii)) + .to(equal(String(pointer: toPush9, length: toPush9Len, encoding: .ascii))) + toPush8?.deallocate() + toPush9?.deallocate() + + config_confirm_pushed(conf, seqno8) + config_confirm_pushed(conf2, seqno9) + + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_push(conf2)).to(beFalse()) + + // Validate the changes + var sessionIds2: [String] = [] + var nicknames2: [String] = [] + var contact8: contacts_contact = contacts_contact() + let contactIterator2: UnsafeMutablePointer = contacts_iterator_new(conf) + while !contacts_iterator_done(contactIterator2, &contact8) { + sessionIds2.append( + String(cString: withUnsafeBytes(of: contact8.session_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + ) + nicknames2.append( + contact8.nickname.map { String(cString: $0) } ?? + "(N/A)" + ) + contacts_iterator_advance(contactIterator2) + } + contacts_iterator_free(contactIterator2) // Need to free the iterator + + expect(sessionIds2.count).to(equal(2)) + expect(sessionIds2.first).to(equal(String(cString: anotherId.nullTerminated()))) + expect(sessionIds2.last).to(equal(String(cString: thirdId.nullTerminated()))) + expect(nicknames2.first).to(equal("(N/A)")) + expect(nicknames2.last).to(equal("Nickname 3")) + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift index 93ba46791..ef9324420 100644 --- a/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/ConfigUserProfileSpec.swift @@ -13,7 +13,7 @@ class ConfigUserProfileSpec: QuickSpec { // MARK: - Spec override func spec() { - it("generates configs correctly") { + it("generates UserProfile configs correctly") { let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately @@ -309,11 +309,11 @@ class ConfigUserProfileSpec: QuickSpec { // down more than one). var mergeData2: [UnsafePointer?] = [UnsafePointer(toPush3)] var mergeSize2: [Int] = [toPush3Len] - config_merge(conf2, &mergeData2, &mergeSize2, 1) + expect(config_merge(conf2, &mergeData2, &mergeSize2, 1)).to(equal(1)) toPush3?.deallocate() var mergeData3: [UnsafePointer?] = [UnsafePointer(toPush4)] var mergeSize3: [Int] = [toPush4Len] - config_merge(conf, &mergeData3, &mergeSize3, 1) + expect(config_merge(conf, &mergeData3, &mergeSize3, 1)).to(equal(1)) toPush4?.deallocate() // Now after the merge we *will* want to push from both client, since both will have generated a diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index cf350e86d..85e9cd6d6 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -63,3 +63,11 @@ public extension Array where Element == String { return self.reversed() } } + +public extension Array where Element == CChar { + func nullTerminated() -> [Element] { + guard self.last != CChar(0) else { return self } + + return self.appending(CChar(0)) + } +}