Skip to content

Commit 13f469b

Browse files
Add Firebase RemoteConfig realtime update bindings (#121)
* Add RemoteConfig realtime update bindings * Tighten RemoteConfig E2E callback assertion
1 parent fff3ab0 commit 13f469b

4 files changed

Lines changed: 240 additions & 0 deletions

File tree

source/Firebase/RemoteConfig/ApiDefinition.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ namespace Firebase.RemoteConfig
2020
// typedef void (^_Nullable)(BOOL changed, NSError *_Nullable error);
2121
delegate void RemoteConfigActivateCompletionHandler (bool changed, [NullAllowed] NSError error);
2222

23+
// typedef void (^FIRRemoteConfigUpdateCompletion)(FIRRemoteConfigUpdate * _Nullable, NSError * _Nullable);
24+
delegate void RemoteConfigUpdateCompletionHandler ([NullAllowed] RemoteConfigUpdate configUpdate, [NullAllowed] NSError error);
25+
26+
// @interface FIRConfigUpdateListenerRegistration : NSObject
27+
[DisableDefaultCtor]
28+
[BaseType (typeof (NSObject), Name = "FIRConfigUpdateListenerRegistration")]
29+
interface ConfigUpdateListenerRegistration
30+
{
31+
// - (void)remove;
32+
[Export ("remove")]
33+
void Remove ();
34+
}
35+
2336
// @interface FIRRemoteConfigValue : NSObject <NSCopying>
2437
[DisableDefaultCtor]
2538
[BaseType (typeof (NSObject), Name = "FIRRemoteConfigValue")]
@@ -64,6 +77,16 @@ interface RemoteConfigSettings
6477
double FetchTimeout { get; set; }
6578
}
6679

80+
// @interface FIRRemoteConfigUpdate : NSObject
81+
[DisableDefaultCtor]
82+
[BaseType (typeof (NSObject), Name = "FIRRemoteConfigUpdate")]
83+
interface RemoteConfigUpdate
84+
{
85+
// @property(nonatomic, readonly, nonnull) NSSet<NSString *> *updatedKeys;
86+
[Export ("updatedKeys")]
87+
NSSet<NSString> UpdatedKeys { get; }
88+
}
89+
6790
// @interface FIRRemoteConfig : NSObject <NSFastEnumeration>
6891
[DisableDefaultCtor]
6992
[BaseType (typeof (NSObject), Name = "FIRRemoteConfig")]
@@ -81,6 +104,14 @@ interface RemoteConfig
81104
[Field ("FIRRemoteConfigErrorDomain", "__Internal")]
82105
NSString ErrorDomain { get; }
83106

107+
// extern NSString *const _Nonnull FIRRemoteConfigUpdateErrorDomain;
108+
[Field ("FIRRemoteConfigUpdateErrorDomain", "__Internal")]
109+
NSString UpdateErrorDomain { get; }
110+
111+
// extern NSString *const _Nonnull FIRRemoteConfigCustomSignalsErrorDomain;
112+
[Field ("FIRRemoteConfigCustomSignalsErrorDomain", "__Internal")]
113+
NSString CustomSignalsErrorDomain { get; }
114+
84115
// @property(nonatomic, readonly, strong, nullable) NSDate *lastFetchTime;
85116
[NullAllowed]
86117
[Export ("lastFetchTime", ArgumentSemantic.Strong)]
@@ -167,5 +198,14 @@ interface RemoteConfig
167198
[return: NullAllowed]
168199
[Export ("defaultValueForKey:")]
169200
RemoteConfigValue GetDefaultValue ([NullAllowed] string key);
201+
202+
// - (FIRConfigUpdateListenerRegistration *_Nonnull)addOnConfigUpdateListener:(FIRRemoteConfigUpdateCompletion _Nonnull)listener;
203+
[Export ("addOnConfigUpdateListener:")]
204+
ConfigUpdateListenerRegistration AddOnConfigUpdateListener (RemoteConfigUpdateCompletionHandler listener);
205+
206+
// - (void)setCustomSignals:(nonnull NSDictionary<NSString *, NSObject *> *)customSignals withCompletion:(void (^_Nullable)(NSError *_Nullable error))completionHandler;
207+
[Async]
208+
[Export ("setCustomSignals:withCompletion:")]
209+
void SetCustomSignals (NSDictionary<NSString, NSObject> customSignals, [NullAllowed] Action<NSError> completionHandler);
170210
}
171211
}

source/Firebase/RemoteConfig/Enums.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ public enum RemoteConfigError : long
2828
InternalError = 8003
2929
}
3030

31+
[Native]
32+
public enum RemoteConfigUpdateError : long
33+
{
34+
StreamError = 8001,
35+
NotFetched = 8002,
36+
MessageInvalid = 8003,
37+
Unavailable = 8004
38+
}
39+
40+
[Native]
41+
public enum RemoteConfigCustomSignalsError : long
42+
{
43+
Unknown = 8101,
44+
InvalidValueType = 8102,
45+
LimitExceeded = 8103
46+
}
47+
3148
[Native]
3249
public enum RemoteConfigSource : long
3350
{

tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
using ObjCRuntime;
1313
#endif
1414

15+
#if ENABLE_RUNTIME_DRIFT_CASE_REMOTECONFIG_REALTIME_CUSTOMSIGNALS
16+
using Firebase.RemoteConfig;
17+
using Foundation;
18+
using ObjCRuntime;
19+
#endif
20+
1521
#if ENABLE_RUNTIME_DRIFT_CASE_DATABASE_SERVERVALUE_INCREMENT
1622
using Firebase.Database;
1723
using Foundation;
@@ -295,6 +301,168 @@ void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEven
295301
}
296302
#endif
297303

304+
#if ENABLE_RUNTIME_DRIFT_CASE_REMOTECONFIG_REALTIME_CUSTOMSIGNALS
305+
static async Task<string> VerifyRemoteConfigRealtimeCustomSignalsAsync()
306+
{
307+
const string listenerSelector = "addOnConfigUpdateListener:";
308+
const string customSignalsSelector = "setCustomSignals:withCompletion:";
309+
310+
var listenerSignature = typeof(RemoteConfig).GetMethod(
311+
nameof(RemoteConfig.AddOnConfigUpdateListener),
312+
BindingFlags.Instance | BindingFlags.Public,
313+
binder: null,
314+
types: new[] { typeof(RemoteConfigUpdateCompletionHandler) },
315+
modifiers: null);
316+
if (listenerSignature is null)
317+
{
318+
throw new InvalidOperationException(
319+
$"Expected managed API '{nameof(RemoteConfig.AddOnConfigUpdateListener)}({typeof(RemoteConfigUpdateCompletionHandler).FullName})' was not found.");
320+
}
321+
322+
if (listenerSignature.ReturnType != typeof(ConfigUpdateListenerRegistration))
323+
{
324+
throw new InvalidOperationException(
325+
$"Managed signature regression: expected '{nameof(RemoteConfig.AddOnConfigUpdateListener)}' to return '{typeof(ConfigUpdateListenerRegistration).FullName}', observed '{listenerSignature.ReturnType.FullName}'.");
326+
}
327+
328+
var customSignalsSignature = typeof(RemoteConfig).GetMethod(
329+
nameof(RemoteConfig.SetCustomSignals),
330+
BindingFlags.Instance | BindingFlags.Public,
331+
binder: null,
332+
types: new[] { typeof(NSDictionary<NSString, NSObject>), typeof(Action<NSError>) },
333+
modifiers: null);
334+
if (customSignalsSignature is null)
335+
{
336+
throw new InvalidOperationException(
337+
$"Expected managed API '{nameof(RemoteConfig.SetCustomSignals)}({typeof(NSDictionary<NSString, NSObject>).FullName}, {typeof(Action<NSError>).FullName})' was not found.");
338+
}
339+
340+
var remoteConfig = RemoteConfig.SharedInstance;
341+
if (remoteConfig is null)
342+
{
343+
throw new InvalidOperationException("Firebase.RemoteConfig.RemoteConfig.SharedInstance returned null after App.Configure().");
344+
}
345+
346+
if (!remoteConfig.RespondsToSelector(new Selector(listenerSelector)))
347+
{
348+
throw new InvalidOperationException($"Native FIRRemoteConfig does not respond to expected selector '{listenerSelector}'.");
349+
}
350+
351+
if (!remoteConfig.RespondsToSelector(new Selector(customSignalsSelector)))
352+
{
353+
throw new InvalidOperationException($"Native FIRRemoteConfig does not respond to expected selector '{customSignalsSelector}'.");
354+
}
355+
356+
var listenerInvoked = false;
357+
var listenerUpdateWasNull = false;
358+
NSError? listenerError = null;
359+
var customSignalsCompletionInvoked = false;
360+
NSError? customSignalsCompletionError = null;
361+
NSException? marshaledException = null;
362+
MarshalObjectiveCExceptionMode? marshaledExceptionMode = null;
363+
var customSignalsCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
364+
365+
void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args)
366+
{
367+
marshaledException ??= args.Exception;
368+
marshaledExceptionMode ??= args.ExceptionMode;
369+
}
370+
371+
Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException;
372+
try
373+
{
374+
using var signalKey = new NSString("codex_signal");
375+
using var signalValue = new NSString("enabled");
376+
using var customSignals = NSDictionary<NSString, NSObject>.FromObjectsAndKeys(
377+
new NSObject[] { signalValue },
378+
new[] { signalKey },
379+
1);
380+
381+
ConfigUpdateListenerRegistration registration;
382+
try
383+
{
384+
registration = remoteConfig.AddOnConfigUpdateListener((configUpdate, error) =>
385+
{
386+
listenerInvoked = true;
387+
listenerUpdateWasNull = configUpdate is null;
388+
listenerError = error;
389+
});
390+
}
391+
catch (ObjCException ex)
392+
{
393+
throw new InvalidOperationException(
394+
$"Selector '{listenerSelector}' should not throw after the missing binding is added, but observed {ex.GetType().FullName}. " +
395+
$"Listener delegate type: {typeof(RemoteConfigUpdateCompletionHandler).FullName}. " +
396+
$"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " +
397+
$"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " +
398+
$"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.",
399+
ex);
400+
}
401+
402+
using (registration)
403+
{
404+
if (registration is null)
405+
{
406+
throw new InvalidOperationException($"Selector '{listenerSelector}' returned null registration.");
407+
}
408+
409+
registration.Remove();
410+
}
411+
412+
try
413+
{
414+
remoteConfig.SetCustomSignals(customSignals, error =>
415+
{
416+
customSignalsCompletionInvoked = true;
417+
customSignalsCompletionError = error;
418+
customSignalsCompletionSource.TrySetResult(true);
419+
});
420+
}
421+
catch (ObjCException ex)
422+
{
423+
throw new InvalidOperationException(
424+
$"Selector '{customSignalsSelector}' should not throw after the missing binding is added, but observed {ex.GetType().FullName}. " +
425+
$"Signals dictionary type: {customSignals.GetType().FullName}. Completion delegate type: {typeof(Action<NSError>).FullName}. " +
426+
$"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " +
427+
$"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " +
428+
$"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.",
429+
ex);
430+
}
431+
432+
var completedTask = await Task.WhenAny(customSignalsCompletionSource.Task, Task.Delay(AsyncTimeout));
433+
if (marshaledException is not null)
434+
{
435+
throw new InvalidOperationException(
436+
$"RemoteConfig missing-surface selectors completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " +
437+
$"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.");
438+
}
439+
440+
if (completedTask != customSignalsCompletionSource.Task)
441+
{
442+
throw new TimeoutException(
443+
$"Selector '{customSignalsSelector}' did not invoke its completion callback within {AsyncTimeout.TotalSeconds} seconds. " +
444+
$"Signals dictionary type: {customSignals.GetType().FullName}. Completion delegate type: {typeof(Action<NSError>).FullName}.");
445+
}
446+
447+
return
448+
$"Selectors '{listenerSelector}' and '{customSignalsSelector}' completed without ObjC exception after the missing bindings were added. " +
449+
$"Listener delegate type: {typeof(RemoteConfigUpdateCompletionHandler).FullName}. " +
450+
$"Registration type: {typeof(ConfigUpdateListenerRegistration).FullName}. " +
451+
$"Update type: {typeof(RemoteConfigUpdate).FullName}. " +
452+
$"Signals dictionary type: {customSignals.GetType().FullName}. " +
453+
$"Custom signals callback observed: {completedTask == customSignalsCompletionSource.Task}. " +
454+
$"Custom signals callback invoked: {customSignalsCompletionInvoked}. " +
455+
$"Custom signals NSError: {FormatDetail(customSignalsCompletionError?.LocalizedDescription)}. " +
456+
$"Listener invoked: {listenerInvoked}. Listener update was null: {listenerUpdateWasNull}. " +
457+
$"Listener NSError: {FormatDetail(listenerError?.LocalizedDescription)}.";
458+
}
459+
finally
460+
{
461+
Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException;
462+
}
463+
}
464+
#endif
465+
298466
#if ENABLE_RUNTIME_DRIFT_CASE_DATABASE_SERVERVALUE_INCREMENT
299467
static Task<string> VerifyDatabaseServerValueIncrementAsync()
300468
{

tests/E2E/Firebase.Foundation/runtime-drift-cases.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@
1212
"bindingPackage": "AdamE.Firebase.iOS.Analytics",
1313
"packages": []
1414
},
15+
{
16+
"id": "remoteconfig-realtime-customsignals",
17+
"method": "VerifyRemoteConfigRealtimeCustomSignalsAsync",
18+
"bindingPackage": "AdamE.Firebase.iOS.RemoteConfig",
19+
"packages": [
20+
{
21+
"id": "AdamE.Firebase.iOS.RemoteConfig",
22+
"version": "12.6.0"
23+
},
24+
{
25+
"id": "AdamE.Firebase.iOS.ABTesting",
26+
"version": "12.6.0"
27+
}
28+
]
29+
},
1530
{
1631
"id": "database-servervalue-increment",
1732
"method": "VerifyDatabaseServerValueIncrementAsync",

0 commit comments

Comments
 (0)