From c0fad034f2a1734d23c6c19115a87e140545f0b6 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Thu, 31 Aug 2023 18:40:21 +0200 Subject: [PATCH] Add User.Changed event --- Realm/Realm/Handles/SessionHandle.cs | 8 +- Realm/Realm/Handles/SyncUserHandle.cs | 73 +++++++++++++ Realm/Realm/Native/NativeCommon.cs | 1 + Realm/Realm/Sync/User.cs | 36 +++++++ Tests/Realm.Tests/Sync/UserManagementTests.cs | 102 ++++++++++++++++++ wrappers/src/sync_user_cs.cpp | 28 +++++ 6 files changed, 244 insertions(+), 4 deletions(-) diff --git a/Realm/Realm/Handles/SessionHandle.cs b/Realm/Realm/Handles/SessionHandle.cs index 463a25fe64..c9e98eb7bb 100644 --- a/Realm/Realm/Handles/SessionHandle.cs +++ b/Realm/Realm/Handles/SessionHandle.cs @@ -93,10 +93,10 @@ public static extern ulong register_progress_notifier(SessionHandle session, public static extern void unregister_progress_notifier(SessionHandle session, ulong token, out NativeException ex); [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncsession_register_property_changed_callback", CallingConvention = CallingConvention.Cdecl)] - public static extern SessionNotificationToken register_property_changed_callback(IntPtr session, IntPtr managed_session_handle, out NativeException ex); + public static extern SessionNotificationToken register_property_changed_callback(SessionHandle session, IntPtr managed_session_handle, out NativeException ex); [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncsession_unregister_property_changed_callback", CallingConvention = CallingConvention.Cdecl)] - public static extern void unregister_property_changed_callback(IntPtr session, SessionNotificationToken token, out NativeException ex); + public static extern void unregister_property_changed_callback(SessionHandle session, SessionNotificationToken token, out NativeException ex); [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncsession_wait", CallingConvention = CallingConvention.Cdecl)] public static extern void wait(SessionHandle session, IntPtr task_completion_source, ProgressDirection direction, out NativeException ex); @@ -196,7 +196,7 @@ public void SubscribeNotifications(Session session) var managedSessionHandle = GCHandle.Alloc(session, GCHandleType.Weak); var sessionPointer = GCHandle.ToIntPtr(managedSessionHandle); - _notificationToken = NativeMethods.register_property_changed_callback(handle, sessionPointer, out var ex); + _notificationToken = NativeMethods.register_property_changed_callback(this, sessionPointer, out var ex); ex.ThrowIfNecessary(); } @@ -204,7 +204,7 @@ public void UnsubscribeNotifications() { if (_notificationToken.HasValue) { - NativeMethods.unregister_property_changed_callback(handle, _notificationToken.Value, out var ex); + NativeMethods.unregister_property_changed_callback(this, _notificationToken.Value, out var ex); _notificationToken = null; ex.ThrowIfNecessary(); } diff --git a/Realm/Realm/Handles/SyncUserHandle.cs b/Realm/Realm/Handles/SyncUserHandle.cs index 21f132115f..88267f274c 100644 --- a/Realm/Realm/Handles/SyncUserHandle.cs +++ b/Realm/Realm/Handles/SyncUserHandle.cs @@ -21,8 +21,10 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; +using Realms.Logging; using Realms.Native; namespace Realms.Sync @@ -31,6 +33,9 @@ internal class SyncUserHandle : StandaloneHandle { private static class NativeMethods { + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void UserChangedCallback(IntPtr managed_user); + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncuser_get_id", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr get_user_id(SyncUserHandle user, IntPtr buffer, IntPtr buffer_length, out NativeException ex); @@ -109,13 +114,33 @@ public static extern void create_api_key(SyncUserHandle handle, AppHandle app, [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncuser_get_path_for_realm", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr get_path_for_realm(SyncUserHandle handle, [MarshalAs(UnmanagedType.LPWStr)] string? partition, IntPtr partition_len, IntPtr buffer, IntPtr bufsize, out NativeException ex); + + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncuser_register_changed_callback", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr register_changed_callback(SyncUserHandle user, IntPtr managed_user_handle, out NativeException ex); + + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncuser_unregister_property_changed_callback", CallingConvention = CallingConvention.Cdecl)] + public static extern void unregister_changed_callback(SyncUserHandle user, IntPtr token, out NativeException ex); + + [DllImport(InteropConfig.DLL_NAME, EntryPoint = "realm_syncuser_install_callbacks", CallingConvention = CallingConvention.Cdecl)] + public static extern void install_syncuser_callbacks(UserChangedCallback changed_callback); } + private IntPtr _notificationToken; + [Preserve] public SyncUserHandle(IntPtr handle) : base(handle) { } + public static void Initialize() + { + NativeMethods.UserChangedCallback changed = HandleUserChanged; + + GCHandle.Alloc(changed); + + NativeMethods.install_syncuser_callbacks(changed); + } + public string GetUserId() { return MarshalHelpers.GetString((IntPtr buffer, IntPtr length, out bool isNull, out NativeException ex) => @@ -400,5 +425,53 @@ public string GetRealmPath(string? partition = null) return NativeMethods.get_path_for_realm(this, partition, partition.IntPtrLength(), buffer, bufferLength, out ex); })!; } + + public void SubscribeNotifications(User user) + { + Debug.Assert(_notificationToken == IntPtr.Zero, $"{nameof(_notificationToken)} must be Zero before subscribing."); + + var managedUserHandle = GCHandle.Alloc(user, GCHandleType.Weak); + var sessionPointer = GCHandle.ToIntPtr(managedUserHandle); + _notificationToken = NativeMethods.register_changed_callback(this, sessionPointer, out var ex); + ex.ThrowIfNecessary(); + } + + public void UnsubscribeNotifications() + { + if (_notificationToken != IntPtr.Zero) + { + NativeMethods.unregister_changed_callback(this, _notificationToken, out var ex); + _notificationToken = IntPtr.Zero; + ex.ThrowIfNecessary(); + } + } + + [MonoPInvokeCallback(typeof(NativeMethods.UserChangedCallback))] + private static void HandleUserChanged(IntPtr managedUserHandle) + { + try + { + if (managedUserHandle == IntPtr.Zero) + { + return; + } + + var user = (User?)GCHandle.FromIntPtr(managedUserHandle).Target; + if (user is null) + { + // We're taking a weak handle to the session, so it's possible that it's been collected + return; + } + + ThreadPool.QueueUserWorkItem(_ => + { + user.RaiseChanged(); + }); + } + catch (Exception ex) + { + Logger.Default.Log(LogLevel.Error, $"An error has occurred while raising a property changed event: {ex}"); + } + } } } diff --git a/Realm/Realm/Native/NativeCommon.cs b/Realm/Realm/Native/NativeCommon.cs index c674d1e3d4..45e718b9e1 100644 --- a/Realm/Realm/Native/NativeCommon.cs +++ b/Realm/Realm/Native/NativeCommon.cs @@ -75,6 +75,7 @@ internal static void Initialize() SynchronizationContextScheduler.Initialize(); SharedRealmHandle.Initialize(); SessionHandle.Initialize(); + SyncUserHandle.Initialize(); HttpClientTransport.Initialize(); AppHandle.Initialize(); SubscriptionSetHandle.Initialize(); diff --git a/Realm/Realm/Sync/User.cs b/Realm/Realm/Sync/User.cs index 3a2f0261ff..d8c50755d8 100644 --- a/Realm/Realm/Sync/User.cs +++ b/Realm/Realm/Sync/User.cs @@ -18,6 +18,8 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; using MongoDB.Bson; @@ -34,6 +36,35 @@ namespace Realms.Sync /// public class User : IEquatable { + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "This is the private event - the public is uppercased.")] + private event EventHandler? _changed; + + /// + /// Occurs when a property value changes. + /// + public event EventHandler? Changed + { + add + { + if (_changed == null) + { + Handle.SubscribeNotifications(this); + } + + _changed += value; + } + + remove + { + _changed -= value; + + if (_changed == null) + { + Handle.UnsubscribeNotifications(); + } + } + } + /// /// Gets this user's refresh token. This is the user's credential for accessing MongoDB Atlas data and should be treated as sensitive information. /// @@ -297,6 +328,11 @@ public override string ToString() return $"User {Id}, State: {State}, Provider: {Provider}"; } + internal void RaiseChanged() + { + _changed?.Invoke(this, EventArgs.Empty); + } + /// /// A class exposing functionality for users to manage API keys from the client. It is always scoped /// to a particular and can only be accessed via . diff --git a/Tests/Realm.Tests/Sync/UserManagementTests.cs b/Tests/Realm.Tests/Sync/UserManagementTests.cs index e648e20207..e5ae8164a1 100644 --- a/Tests/Realm.Tests/Sync/UserManagementTests.cs +++ b/Tests/Realm.Tests/Sync/UserManagementTests.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading.Tasks; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using NUnit.Framework; @@ -983,6 +984,107 @@ public void UserToStringOverride() Assert.That(user.ToString(), Does.Contain(user.Provider.ToString())); } + [Test] + public void UserLogOut_RaisesChanged() + { + SyncTestHelpers.RunBaasTestAsync(async () => + { + var user = await GetUserAsync(); + + var tcs = new TaskCompletionSource(); + user.Changed += (s, _) => + { + try + { + Assert.That(s, Is.EqualTo(user)); + tcs.TrySetResult(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }; + + await user.LogOutAsync(); + + await tcs.Task; + + Assert.That(user.State, Is.EqualTo(UserState.Removed)); + }); + } + + [Test] + public void UserChanged_DoesntKeepObjectAlive() + { + SyncTestHelpers.RunBaasTestAsync(async () => + { + var references = await new Func>(async () => + { + var user = await GetUserAsync(); + user.Changed += (s, e) => { }; + + return new WeakReference(user); + })(); + + await TestHelpers.WaitUntilReferencesAreCollected(10000, references); + }); + } + + [Test] + public void UserCustomDataChange_RaisesChanged() + { + var tcs = new TaskCompletionSource(); + SyncTestHelpers.RunBaasTestAsync(async () => + { + var user = await GetUserAsync(); + user.Changed += OnUserChanged; + + var collection = user.GetMongoClient("BackingDB").GetDatabase(SyncTestHelpers.RemoteMongoDBName()).GetCollection("users"); + + var customDataId = ObjectId.GenerateNewId(); + + var customDataDoc = new BsonDocument + { + ["_id"] = ObjectId.GenerateNewId(), + ["user_id"] = user.Id, + ["age"] = 5 + }; + + await collection.InsertOneAsync(customDataDoc); + + var customUserData = await user.RefreshCustomDataAsync(); + Assert.That(customUserData!["age"].AsInt32, Is.EqualTo(5)); + + await tcs.Task; + + tcs = new(); + + // Unsubscribe and verify that it no longer raises user changed + user.Changed -= OnUserChanged; + + var filter = BsonDocument.Parse(@"{ + user_id: { $eq: """ + user.Id + @""" } + }"); + var update = BsonDocument.Parse(@"{ + $set: { + age: 199 + } + }"); + + await collection.UpdateOneAsync(filter, update); + + customUserData = await user.RefreshCustomDataAsync(); + Assert.That(customUserData!["age"].AsInt32, Is.EqualTo(199)); + + await TestHelpers.AssertThrows(() => tcs.Task.Timeout(2000)); + }); + + void OnUserChanged(object sender, EventArgs e) + { + tcs!.TrySetResult(); + } + } + private class CustomDataDocument { [Preserve] diff --git a/wrappers/src/sync_user_cs.cpp b/wrappers/src/sync_user_cs.cpp index 07422598df..5036f5c49f 100644 --- a/wrappers/src/sync_user_cs.cpp +++ b/wrappers/src/sync_user_cs.cpp @@ -29,6 +29,7 @@ #include #include #include "app_cs.hpp" +#include "marshalling.hpp" using namespace realm; using namespace realm::binding; @@ -36,6 +37,9 @@ using namespace app; using SharedSyncUser = std::shared_ptr; using SharedSyncSession = std::shared_ptr; +using UserChangedCallbackT = void(void* managed_user_handle); + +std::function s_user_changed_callback; namespace realm { namespace binding { @@ -86,6 +90,13 @@ void to_json(nlohmann::json& j, const SyncUserIdentity& i) } extern "C" { + REALM_EXPORT void realm_syncuser_install_callbacks(UserChangedCallbackT* user_changed_callback) + { + s_user_changed_callback = wrap_managed_callback(user_changed_callback); + + realm::binding::s_can_call_managed = true; + } + REALM_EXPORT void realm_syncuser_log_out(SharedSyncUser& user, NativeException::Marshallable& ex) { handle_errors(ex, [&] { @@ -160,6 +171,23 @@ extern "C" { }); } + REALM_EXPORT Subscribable::Token* realm_syncuser_register_changed_callback(SharedSyncUser& user, void* managed_user_handle, NativeException::Marshallable& ex) + { + return handle_errors(ex, [&] { + auto token = user->subscribe([managed_user_handle](const SyncUser&) { + s_user_changed_callback(managed_user_handle); + }); + return new Subscribable::Token(std::move(token)); + }); + } + + REALM_EXPORT void realm_syncuser_unregister_property_changed_callback(SharedSyncUser& user, Subscribable::Token& token, NativeException::Marshallable& ex) + { + handle_errors(ex, [&] { + user->unsubscribe(token); + }); + } + enum class UserProfileField : uint8_t { name, email,