From 4795a4c6aab32c7d9a23752bbf7b3f48e1c958db Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Tue, 26 Nov 2024 19:00:07 -0500 Subject: [PATCH 1/8] [Config] Port 'ConfigExperiment' to Swift --- .../SwiftNew/ConfigExperiment.swift | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift new file mode 100644 index 00000000000..7d4c72d5503 --- /dev/null +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -0,0 +1,192 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseABTesting +import Foundation + +/// Handles experiment information update and persistence. +/*@objc(RCNConfigExperiment)*/ open class ConfigExperiment: NSObject { + private static let experimentMetadataKeyLastStartTime = "last_experiment_start_time" + private static let serviceOrigin = "frc" + + private var experimentPayloads: [Data] + private var experimentMetadata: [String: Any]? + private var activeExperimentPayloads: [Data] + private let dbManager: ConfigDBManager? + private let experimentController: ExperimentController + private let experimentStartTimeDateFormatter: DateFormatter + + /// Designated initializer; + public init(DBManager: ConfigDBManager?, + experimentController controller: ExperimentController?) { + experimentPayloads = [] + experimentMetadata = [:] + activeExperimentPayloads = [] + experimentStartTimeDateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + // Locale needs to be hardcoded. See + // https://developer.apple.com/library/ios/#qa/qa1480/_index.html for more details. + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + // TODO(ncooke3): Trace back and see why timeZone is set twice. + dateFormatter.timeZone = TimeZone(abbreviation: "UTC") + return dateFormatter + }() + dbManager = DBManager + experimentController = controller! + super.init() + loadExperimentFromTable() + } + + private func loadExperimentFromTable() { + guard let dbManager else { return } + + let completionHandler: (Bool, [String: Sendable]?) -> Void = { [weak self] _, result in + guard let self else { return } + + if result?[ConfigConstants.experimentTableKeyPayload] != nil { + self.experimentPayloads.removeAll() + if let experiments = result?[ConfigConstants.experimentTableKeyPayload] as? [Data] { + for experiment in experiments { + do { + try JSONSerialization.jsonObject(with: experiment) + self.experimentPayloads.append(experiment) + } catch { + RCLog.warning("I-RCN000031", "Experiment payload could not be parsed as JSON.") + } + } + } + } + + if result?[ConfigConstants.experimentTableKeyMetadata] != nil { + self + .experimentMetadata = + result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Any] + } + + if result?[ConfigConstants.experimentTableKeyActivePayload] != nil { + self.activeExperimentPayloads.removeAll() + if let experiments = result?[ConfigConstants.experimentTableKeyActivePayload] as? [Data] { + for experiment in experiments { + do { + try JSONSerialization.jsonObject(with: experiment) + self.activeExperimentPayloads.append(experiment) + } catch { + RCLog.warning( + "I-RCN000031", + "Activated experiment payload could not be parsed as JSON." + ) + } + } + } + } + } + + dbManager.loadExperiment(completionHandler: completionHandler) + } + + /// Update/Persist experiment information from config fetch response. + open func updateExperiments(withResponse response: [[String: Any]]?) { + // Cache fetched experiment payloads. + experimentPayloads.removeAll() + dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload) + + if let response { + for experiment in response { + do { + let jsonData = try JSONSerialization.data(withJSONObject: experiment) + experimentPayloads.append(jsonData) + dbManager? + .insertExperimentTable( + withKey: ConfigConstants.experimentTableKeyPayload, + value: jsonData + ) + } catch { + RCLog.error("I-RCN000030", "Invalid experiment payload to be serialized.") + } + } + } + } + + /// Update experiments to Firebase Analytics when `activateWithCompletion:` happens. + open func updateExperiments(handler: (((any Error)?) -> Void)? = nil) { + let lifecycleEvent = LifecycleEvents() + + // Get the last experiment start time prior to the latest payload. + let lastStartTime = experimentMetadata?[Self.experimentMetadataKeyLastStartTime] as? Double + + // Update the last experiment start time with the latest payload. + updateExperimentStartTime() + experimentController + .updateExperiments( + withServiceOrigin: Self.serviceOrigin, + events: lifecycleEvent, + policy: .discardOldest, + lastStartTime: lastStartTime, + payloads: experimentPayloads, + completionHandler: handler + ) + + // Update activated experiments payload and metadata in DB. + updateActiveExperimentsInDB() + } + + private func updateExperimentStartTime() { + let existingLastStartTime = + experimentMetadata?[Self.experimentMetadataKeyLastStartTime] as? Double + + let latestStartTime = latestStartTime(existingLastStartTime: existingLastStartTime ?? 0) + + experimentMetadata?[Self.experimentMetadataKeyLastStartTime] = latestStartTime + + guard let experimentMetadata, JSONSerialization.isValidJSONObject(experimentMetadata) else { + RCLog.error("I-RCN000028", "Invalid fetched experiment metadata to be serialized.") + return + } + + if let serializedExperimentMetadata = try? JSONSerialization.data( + withJSONObject: experimentMetadata, + options: .prettyPrinted + ) { + dbManager? + .insertExperimentTable( + withKey: ConfigConstants.experimentTableKeyMetadata, + value: serializedExperimentMetadata + ) + } + } + + private func updateActiveExperimentsInDB() { + // Put current fetched experiment payloads into activated experiment DB. + activeExperimentPayloads.removeAll() + dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyActivePayload) + for data in experimentPayloads { + activeExperimentPayloads.append(data) + dbManager? + .insertExperimentTable( + withKey: ConfigConstants.experimentTableKeyActivePayload, + value: data + ) + } + } + + private func latestStartTime(existingLastStartTime: Double) -> TimeInterval { + experimentController + .latestExperimentStartTimestampBetweenTimestamp( + existingLastStartTime, + andPayloads: experimentPayloads + ) + } +} From 97cfe7e17fb43d776e91ce06b32dfffd3e4d1c8c Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Tue, 3 Dec 2024 16:11:58 -0500 Subject: [PATCH 2/8] Remove ported ObjC files --- .../Sources/RCNConfigExperiment.h | 38 ---- .../Sources/RCNConfigExperiment.m | 202 ------------------ 2 files changed, 240 deletions(-) delete mode 100644 FirebaseRemoteConfig/Sources/RCNConfigExperiment.h delete mode 100644 FirebaseRemoteConfig/Sources/RCNConfigExperiment.m diff --git a/FirebaseRemoteConfig/Sources/RCNConfigExperiment.h b/FirebaseRemoteConfig/Sources/RCNConfigExperiment.h deleted file mode 100644 index 79051faa87b..00000000000 --- a/FirebaseRemoteConfig/Sources/RCNConfigExperiment.h +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2019 Google - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -@class FIRExperimentController; -@class RCNConfigDBManager; - -/// Handles experiment information update and persistence. -@interface RCNConfigExperiment : NSObject - -/// Designated initializer; -- (nonnull instancetype)initWithDBManager:(RCNConfigDBManager *_Nullable)DBManager - experimentController:(FIRExperimentController *_Nullable)controller - NS_DESIGNATED_INITIALIZER; - -/// Use `initWithDBManager:` instead. -- (nonnull instancetype)init NS_UNAVAILABLE; - -/// Update/Persist experiment information from config fetch response. -- (void)updateExperimentsWithResponse:(NSArray *> *_Nullable)response; - -/// Update experiments to Firebase Analytics when `activateWithCompletion:` happens. -- (void)updateExperimentsWithHandler:(nullable void (^)(NSError *_Nullable error))handler; -@end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigExperiment.m b/FirebaseRemoteConfig/Sources/RCNConfigExperiment.m deleted file mode 100644 index a0a1b840e49..00000000000 --- a/FirebaseRemoteConfig/Sources/RCNConfigExperiment.m +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright 2019 Google - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" - -#import "FirebaseABTesting/Sources/Private/FirebaseABTestingInternal.h" -#import "FirebaseCore/Extension/FirebaseCoreInternal.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigDefines.h" - -#import "FirebaseRemoteConfig/FirebaseRemoteConfig-Swift.h" - -static NSString *const kExperimentMetadataKeyLastStartTime = @"last_experiment_start_time"; - -static NSString *const kServiceOrigin = @"frc"; -static NSString *const kMethodNameLatestStartTime = - @"latestExperimentStartTimestampBetweenTimestamp:andPayloads:"; - -@interface RCNConfigExperiment () -@property(nonatomic, strong) - NSMutableArray *experimentPayloads; ///< Experiment payloads. -@property(nonatomic, strong) - NSMutableDictionary *experimentMetadata; ///< Experiment metadata -@property(nonatomic, strong) - NSMutableArray *activeExperimentPayloads; ///< Activated experiment payloads. -@property(nonatomic, strong) RCNConfigDBManager *DBManager; ///< Database Manager. -@property(nonatomic, strong) FIRExperimentController *experimentController; -@property(nonatomic, strong) NSDateFormatter *experimentStartTimeDateFormatter; -@end - -@implementation RCNConfigExperiment -/// Designated initializer -- (instancetype)initWithDBManager:(RCNConfigDBManager *)DBManager - experimentController:(FIRExperimentController *)controller { - self = [super init]; - if (self) { - _experimentPayloads = [[NSMutableArray alloc] init]; - _experimentMetadata = [[NSMutableDictionary alloc] init]; - _activeExperimentPayloads = [[NSMutableArray alloc] init]; - _experimentStartTimeDateFormatter = [[NSDateFormatter alloc] init]; - [_experimentStartTimeDateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]; - [_experimentStartTimeDateFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]]; - // Locale needs to be hardcoded. See - // https://developer.apple.com/library/ios/#qa/qa1480/_index.html for more details. - [_experimentStartTimeDateFormatter - setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]]; - [_experimentStartTimeDateFormatter setTimeZone:[NSTimeZone timeZoneWithAbbreviation:@"UTC"]]; - - _DBManager = DBManager; - _experimentController = controller; - [self loadExperimentFromTable]; - } - return self; -} - -typedef void (^RCNDBCompletion)(BOOL success, NSDictionary *result); -- (void)loadExperimentFromTable { - if (!_DBManager) { - return; - } - __weak RCNConfigExperiment *weakSelf = self; - RCNDBCompletion completionHandler = ^(BOOL success, NSDictionary *result) { - RCNConfigExperiment *strongSelf = weakSelf; - if (strongSelf == nil) { - return; - } - if (result[@RCNExperimentTableKeyPayload]) { - [strongSelf->_experimentPayloads removeAllObjects]; - for (NSData *experiment in result[@RCNExperimentTableKeyPayload]) { - NSError *error; - id experimentPayloadJSON = [NSJSONSerialization JSONObjectWithData:experiment - options:kNilOptions - error:&error]; - if (!experimentPayloadJSON || error) { - FIRLogWarning(kFIRLoggerRemoteConfig, @"I-RCN000031", - @"Experiment payload could not be parsed as JSON."); - } else { - [strongSelf->_experimentPayloads addObject:experiment]; - } - } - } - if (result[@RCNExperimentTableKeyMetadata]) { - strongSelf->_experimentMetadata = [result[@RCNExperimentTableKeyMetadata] mutableCopy]; - } - - /// Load activated experiments payload and metadata. - if (result[@RCNExperimentTableKeyActivePayload]) { - [strongSelf->_activeExperimentPayloads removeAllObjects]; - for (NSData *experiment in result[@RCNExperimentTableKeyActivePayload]) { - NSError *error; - id experimentPayloadJSON = [NSJSONSerialization JSONObjectWithData:experiment - options:kNilOptions - error:&error]; - if (!experimentPayloadJSON || error) { - FIRLogWarning(kFIRLoggerRemoteConfig, @"I-RCN000031", - @"Activated experiment payload could not be parsed as JSON."); - } else { - [strongSelf->_activeExperimentPayloads addObject:experiment]; - } - } - } - }; - [_DBManager loadExperimentWithCompletionHandler:completionHandler]; -} - -- (void)updateExperimentsWithResponse:(NSArray *> *)response { - // cache fetched experiment payloads. - [_experimentPayloads removeAllObjects]; - [_DBManager deleteExperimentTableForKey:@RCNExperimentTableKeyPayload]; - - for (NSDictionary *experiment in response) { - NSError *error; - NSData *JSONPayload = [NSJSONSerialization dataWithJSONObject:experiment - options:kNilOptions - error:&error]; - if (!JSONPayload || error) { - FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000030", - @"Invalid experiment payload to be serialized."); - } else { - [_experimentPayloads addObject:JSONPayload]; - [_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyPayload - value:JSONPayload - completionHandler:nil]; - } - } -} - -- (void)updateExperimentsWithHandler:(void (^)(NSError *_Nullable))handler { - FIRLifecycleEvents *lifecycleEvent = [[FIRLifecycleEvents alloc] init]; - - // Get the last experiment start time prior to the latest payload. - NSTimeInterval lastStartTime = - [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue]; - - // Update the last experiment start time with the latest payload. - [self updateExperimentStartTime]; - [self.experimentController - updateExperimentsWithServiceOrigin:kServiceOrigin - events:lifecycleEvent - policy:ABTExperimentPayloadExperimentOverflowPolicyDiscardOldest - lastStartTime:lastStartTime - payloads:_experimentPayloads - completionHandler:handler]; - - /// Update activated experiments payload and metadata in DB. - [self updateActiveExperimentsInDB]; -} - -- (void)updateExperimentStartTime { - NSTimeInterval existingLastStartTime = - [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue]; - - NSTimeInterval latestStartTime = - [self latestStartTimeWithExistingLastStartTime:existingLastStartTime]; - - _experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime); - - if (![NSJSONSerialization isValidJSONObject:_experimentMetadata]) { - FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000028", - @"Invalid fetched experiment metadata to be serialized."); - return; - } - NSError *error; - NSData *serializedExperimentMetadata = - [NSJSONSerialization dataWithJSONObject:_experimentMetadata - options:NSJSONWritingPrettyPrinted - error:&error]; - [_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata - value:serializedExperimentMetadata - completionHandler:nil]; -} - -- (void)updateActiveExperimentsInDB { - /// Put current fetched experiment payloads into activated experiment DB. - [_activeExperimentPayloads removeAllObjects]; - [_DBManager deleteExperimentTableForKey:@RCNExperimentTableKeyActivePayload]; - for (NSData *experiment in _experimentPayloads) { - [_activeExperimentPayloads addObject:experiment]; - [_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyActivePayload - value:experiment - completionHandler:nil]; - } -} - -- (NSTimeInterval)latestStartTimeWithExistingLastStartTime:(NSTimeInterval)existingLastStartTime { - return [self.experimentController - latestExperimentStartTimestampBetweenTimestamp:existingLastStartTime - andPayloads:_experimentPayloads]; -} -@end From e6822a5a23819d8568218c64ed10a60cbf600def Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Tue, 3 Dec 2024 16:20:22 -0500 Subject: [PATCH 3/8] Write up ported class --- FirebaseABTesting/Sources/ABTExperimentPayload.m | 2 ++ .../Sources/Private/ABTExperimentPayload.h | 11 ++--------- .../FirebaseABTesting/FIRExperimentController.h | 14 ++++++++++---- FirebaseRemoteConfig/Sources/FIRRemoteConfig.m | 1 - FirebaseRemoteConfig/Sources/RCNConfigFetch.m | 1 - .../SwiftNew/ConfigExperiment.swift | 14 ++++++-------- .../Tests/Unit/RCNConfigExperimentTest.m | 4 ++-- .../Tests/Unit/RCNRemoteConfigTest.m | 1 - 8 files changed, 22 insertions(+), 26 deletions(-) diff --git a/FirebaseABTesting/Sources/ABTExperimentPayload.m b/FirebaseABTesting/Sources/ABTExperimentPayload.m index 6bc2aaaa604..0033022a6cf 100644 --- a/FirebaseABTesting/Sources/ABTExperimentPayload.m +++ b/FirebaseABTesting/Sources/ABTExperimentPayload.m @@ -14,6 +14,8 @@ #import "FirebaseABTesting/Sources/Private/ABTExperimentPayload.h" +#import "FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h" + static NSString *const kExperimentPayloadKeyExperimentID = @"experimentId"; static NSString *const kExperimentPayloadKeyVariantID = @"variantId"; diff --git a/FirebaseABTesting/Sources/Private/ABTExperimentPayload.h b/FirebaseABTesting/Sources/Private/ABTExperimentPayload.h index b2f2da05406..94a4af1fb37 100644 --- a/FirebaseABTesting/Sources/Private/ABTExperimentPayload.h +++ b/FirebaseABTesting/Sources/Private/ABTExperimentPayload.h @@ -14,16 +14,9 @@ #import -NS_ASSUME_NONNULL_BEGIN +#import "FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h" -/// Policy for handling the case where there's an overflow of experiments for an installation -/// instance. -typedef NS_ENUM(int32_t, ABTExperimentPayloadExperimentOverflowPolicy) { - ABTExperimentPayloadExperimentOverflowPolicyUnrecognizedValue = 999, - ABTExperimentPayloadExperimentOverflowPolicyUnspecified = 0, - ABTExperimentPayloadExperimentOverflowPolicyDiscardOldest = 1, - ABTExperimentPayloadExperimentOverflowPolicyIgnoreNewest = 2, -}; +NS_ASSUME_NONNULL_BEGIN @interface ABTExperimentLite : NSObject @property(nonatomic, readonly, copy) NSString *experimentId; diff --git a/FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h b/FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h index 3bd757d8823..7a74551284f 100644 --- a/FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h +++ b/FirebaseABTesting/Sources/Public/FirebaseABTesting/FIRExperimentController.h @@ -14,15 +14,21 @@ #import +#import "FIRLifecycleEvents.h" + @class ABTExperimentPayload; -// Forward declaration to avoid importing into the module header -typedef NS_ENUM(int32_t, ABTExperimentPayloadExperimentOverflowPolicy); +/// Policy for handling the case where there's an overflow of experiments for an installation +/// instance. +typedef NS_ENUM(int32_t, ABTExperimentPayloadExperimentOverflowPolicy) { + ABTExperimentPayloadExperimentOverflowPolicyUnrecognizedValue = 999, + ABTExperimentPayloadExperimentOverflowPolicyUnspecified = 0, + ABTExperimentPayloadExperimentOverflowPolicyDiscardOldest = 1, + ABTExperimentPayloadExperimentOverflowPolicyIgnoreNewest = 2, +}; NS_ASSUME_NONNULL_BEGIN -@class FIRLifecycleEvents; - /// The default experiment overflow policy, that is to discard the experiment with the oldest start /// time when users start the experiment on the web console. extern const ABTExperimentPayloadExperimentOverflowPolicy FIRDefaultExperimentOverflowPolicy; diff --git a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m index 2c825ce466b..ca38f0f8841 100644 --- a/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m +++ b/FirebaseRemoteConfig/Sources/FIRRemoteConfig.m @@ -23,7 +23,6 @@ #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h" #import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" #import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "FirebaseRemoteConfig/Sources/RCNConfigValue_Internal.h" #import "FirebaseRemoteConfig/Sources/RCNPersonalization.h" diff --git a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m index 39e5ecd5d0e..3c54afb8c85 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m @@ -22,7 +22,6 @@ #import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h" #import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" #import "FirebaseRemoteConfig/FirebaseRemoteConfig-Swift.h" diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index 7d4c72d5503..0d515adc919 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -16,7 +16,7 @@ import FirebaseABTesting import Foundation /// Handles experiment information update and persistence. -/*@objc(RCNConfigExperiment)*/ open class ConfigExperiment: NSObject { +@objc(RCNConfigExperiment) public class ConfigExperiment: NSObject { private static let experimentMetadataKeyLastStartTime = "last_experiment_start_time" private static let serviceOrigin = "frc" @@ -28,19 +28,17 @@ import Foundation private let experimentStartTimeDateFormatter: DateFormatter /// Designated initializer; - public init(DBManager: ConfigDBManager?, - experimentController controller: ExperimentController?) { + @objc public init(DBManager: ConfigDBManager?, + experimentController controller: ExperimentController?) { experimentPayloads = [] experimentMetadata = [:] activeExperimentPayloads = [] experimentStartTimeDateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Locale needs to be hardcoded. See // https://developer.apple.com/library/ios/#qa/qa1480/_index.html for more details. dateFormatter.locale = Locale(identifier: "en_US_POSIX") - // TODO(ncooke3): Trace back and see why timeZone is set twice. dateFormatter.timeZone = TimeZone(abbreviation: "UTC") return dateFormatter }() @@ -98,7 +96,7 @@ import Foundation } /// Update/Persist experiment information from config fetch response. - open func updateExperiments(withResponse response: [[String: Any]]?) { + @objc public func updateExperiments(withResponse response: [[String: Any]]?) { // Cache fetched experiment payloads. experimentPayloads.removeAll() dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload) @@ -121,7 +119,7 @@ import Foundation } /// Update experiments to Firebase Analytics when `activateWithCompletion:` happens. - open func updateExperiments(handler: (((any Error)?) -> Void)? = nil) { + @objc public func updateExperiments(handler: (((any Error)?) -> Void)? = nil) { let lifecycleEvent = LifecycleEvents() // Get the last experiment start time prior to the latest payload. @@ -134,7 +132,7 @@ import Foundation withServiceOrigin: Self.serviceOrigin, events: lifecycleEvent, policy: .discardOldest, - lastStartTime: lastStartTime, + lastStartTime: lastStartTime ?? 0, payloads: experimentPayloads, completionHandler: handler ) diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m index 635aae34a98..32bb69576f5 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m @@ -19,8 +19,6 @@ @import FirebaseRemoteConfig; -#import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" - #import "FirebaseRemoteConfig/Sources/Private/RCNConfigSettings.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" #import "FirebaseRemoteConfig/Sources/RCNConfigDefines.h" @@ -31,6 +29,8 @@ #import "Interop/Analytics/Public/FIRAnalyticsInterop.h" +#import "FirebaseRemoteConfig/FirebaseRemoteConfig-Swift.h" + // Surface the internal FIRExperimentController initializer. @interface FIRExperimentController () - (instancetype)initWithAnalytics:(nullable id)analytics; diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m index 02b19519515..677d6859d9c 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNRemoteConfigTest.m @@ -25,7 +25,6 @@ #import "FirebaseRemoteConfig/Sources/Private/RCNConfigFetch.h" #import "FirebaseRemoteConfig/Sources/Public/FirebaseRemoteConfig/FIRRemoteConfig.h" #import "FirebaseRemoteConfig/Sources/RCNConfigConstants.h" -#import "FirebaseRemoteConfig/Sources/RCNConfigExperiment.h" #import "FirebaseRemoteConfig/Sources/RCNConfigRealtime.h" #import "FirebaseRemoteConfig/Tests/Unit/RCNTestUtilities.h" From 3d430acaec724566a8785245e9b1cf3decaa4be3 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Tue, 3 Dec 2024 16:26:38 -0500 Subject: [PATCH 4/8] Expose some internal API to ObjC --- FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift | 12 ++++++------ .../Tests/Unit/RCNConfigExperimentTest.m | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index 0d515adc919..f323b7d6d9a 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -20,9 +20,9 @@ import Foundation private static let experimentMetadataKeyLastStartTime = "last_experiment_start_time" private static let serviceOrigin = "frc" - private var experimentPayloads: [Data] - private var experimentMetadata: [String: Any]? - private var activeExperimentPayloads: [Data] + @objc private var experimentPayloads: [Data] + @objc private var experimentMetadata: [String: Any]? + @objc private var activeExperimentPayloads: [Data] private let dbManager: ConfigDBManager? private let experimentController: ExperimentController private let experimentStartTimeDateFormatter: DateFormatter @@ -48,7 +48,7 @@ import Foundation loadExperimentFromTable() } - private func loadExperimentFromTable() { + @objc private func loadExperimentFromTable() { guard let dbManager else { return } let completionHandler: (Bool, [String: Sendable]?) -> Void = { [weak self] _, result in @@ -141,7 +141,7 @@ import Foundation updateActiveExperimentsInDB() } - private func updateExperimentStartTime() { + @objc private func updateExperimentStartTime() { let existingLastStartTime = experimentMetadata?[Self.experimentMetadataKeyLastStartTime] as? Double @@ -166,7 +166,7 @@ import Foundation } } - private func updateActiveExperimentsInDB() { + @objc private func updateActiveExperimentsInDB() { // Put current fetched experiment payloads into activated experiment DB. activeExperimentPayloads.removeAll() dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyActivePayload) diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m index 32bb69576f5..2de42eb01cd 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m @@ -40,7 +40,6 @@ @interface RCNConfigExperiment () @property(nonatomic, copy) NSMutableArray *experimentPayloads; @property(nonatomic, copy) NSMutableDictionary *experimentMetadata; @property(nonatomic, copy) NSMutableArray *activeExperimentPayloads; -@property(nonatomic, strong) RCNConfigDBManager *DBManager; - (NSTimeInterval)updateExperimentStartTime; - (void)loadExperimentFromTable; - (void)updateActiveExperimentsInDB; From b4d81bbf209fff731c81f912d7df52ab57258ea3 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Tue, 3 Dec 2024 16:35:14 -0500 Subject: [PATCH 5/8] Add TODO and mark class as final --- FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index f323b7d6d9a..66818fecfd5 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -15,8 +15,11 @@ import FirebaseABTesting import Foundation +// TODO(ncooke3): Once everything is ported, the `@objc` and `public` access +// can be removed. + /// Handles experiment information update and persistence. -@objc(RCNConfigExperiment) public class ConfigExperiment: NSObject { +@objc(RCNConfigExperiment) public final class ConfigExperiment: NSObject { private static let experimentMetadataKeyLastStartTime = "last_experiment_start_time" private static let serviceOrigin = "frc" From 6fa19f08c5434ef95400485c6f9d4953d5e51543 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Wed, 4 Dec 2024 12:28:08 -0500 Subject: [PATCH 6/8] Fix crash in unit test --- FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index 66818fecfd5..8cc44f940ec 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -27,7 +27,7 @@ import Foundation @objc private var experimentMetadata: [String: Any]? @objc private var activeExperimentPayloads: [Data] private let dbManager: ConfigDBManager? - private let experimentController: ExperimentController + private let experimentController: ExperimentController? private let experimentStartTimeDateFormatter: DateFormatter /// Designated initializer; @@ -46,7 +46,7 @@ import Foundation return dateFormatter }() dbManager = DBManager - experimentController = controller! + experimentController = controller super.init() loadExperimentFromTable() } @@ -130,7 +130,7 @@ import Foundation // Update the last experiment start time with the latest payload. updateExperimentStartTime() - experimentController + experimentController? .updateExperiments( withServiceOrigin: Self.serviceOrigin, events: lifecycleEvent, @@ -184,10 +184,10 @@ import Foundation } private func latestStartTime(existingLastStartTime: Double) -> TimeInterval { - experimentController + experimentController? .latestExperimentStartTimestampBetweenTimestamp( existingLastStartTime, andPayloads: experimentPayloads - ) + ) ?? 0 } } From 475a6799533e6445d66127343cdf8c56290eade2 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Thu, 5 Dec 2024 11:11:18 -0500 Subject: [PATCH 7/8] Make experimentMetadata property non-null --- .../SwiftNew/ConfigExperiment.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index 8cc44f940ec..1e0dadf27c3 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -24,7 +24,7 @@ import Foundation private static let serviceOrigin = "frc" @objc private var experimentPayloads: [Data] - @objc private var experimentMetadata: [String: Any]? + @objc private var experimentMetadata: [String: Any] @objc private var activeExperimentPayloads: [Data] private let dbManager: ConfigDBManager? private let experimentController: ExperimentController? @@ -54,7 +54,7 @@ import Foundation @objc private func loadExperimentFromTable() { guard let dbManager else { return } - let completionHandler: (Bool, [String: Sendable]?) -> Void = { [weak self] _, result in + let completionHandler: (Bool, [String: Any]?) -> Void = { [weak self] _, result in guard let self else { return } if result?[ConfigConstants.experimentTableKeyPayload] != nil { @@ -71,10 +71,9 @@ import Foundation } } - if result?[ConfigConstants.experimentTableKeyMetadata] != nil { - self - .experimentMetadata = - result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Any] + if let experimentTable = + result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Any] { + self.experimentMetadata = experimentTable } if result?[ConfigConstants.experimentTableKeyActivePayload] != nil { @@ -126,7 +125,7 @@ import Foundation let lifecycleEvent = LifecycleEvents() // Get the last experiment start time prior to the latest payload. - let lastStartTime = experimentMetadata?[Self.experimentMetadataKeyLastStartTime] as? Double + let lastStartTime = experimentMetadata[Self.experimentMetadataKeyLastStartTime] as? Double // Update the last experiment start time with the latest payload. updateExperimentStartTime() @@ -146,13 +145,13 @@ import Foundation @objc private func updateExperimentStartTime() { let existingLastStartTime = - experimentMetadata?[Self.experimentMetadataKeyLastStartTime] as? Double + experimentMetadata[Self.experimentMetadataKeyLastStartTime] as? Double let latestStartTime = latestStartTime(existingLastStartTime: existingLastStartTime ?? 0) - experimentMetadata?[Self.experimentMetadataKeyLastStartTime] = latestStartTime + experimentMetadata[Self.experimentMetadataKeyLastStartTime] = latestStartTime - guard let experimentMetadata, JSONSerialization.isValidJSONObject(experimentMetadata) else { + guard JSONSerialization.isValidJSONObject(experimentMetadata) else { RCLog.error("I-RCN000028", "Invalid fetched experiment metadata to be serialized.") return } From f4ed0115375a87b733ecf6927503e1c86e44b578 Mon Sep 17 00:00:00 2001 From: Nick Cooke Date: Thu, 5 Dec 2024 11:23:50 -0500 Subject: [PATCH 8/8] Make dbManager non-optional as it should be non-nil in practice --- .../SwiftNew/ConfigExperiment.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift index 1e0dadf27c3..b15edf7b70a 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigExperiment.swift @@ -26,12 +26,15 @@ import Foundation @objc private var experimentPayloads: [Data] @objc private var experimentMetadata: [String: Any] @objc private var activeExperimentPayloads: [Data] - private let dbManager: ConfigDBManager? + private let dbManager: ConfigDBManager + // TODO(ncooke3): This property could be made non-optional after ensuring the + // unit tests properly configure the default app. This is because the + // experiment controller comes from the ABTesting component. private let experimentController: ExperimentController? private let experimentStartTimeDateFormatter: DateFormatter /// Designated initializer; - @objc public init(DBManager: ConfigDBManager?, + @objc public init(DBManager: ConfigDBManager, experimentController controller: ExperimentController?) { experimentPayloads = [] experimentMetadata = [:] @@ -52,8 +55,6 @@ import Foundation } @objc private func loadExperimentFromTable() { - guard let dbManager else { return } - let completionHandler: (Bool, [String: Any]?) -> Void = { [weak self] _, result in guard let self else { return } @@ -101,14 +102,14 @@ import Foundation @objc public func updateExperiments(withResponse response: [[String: Any]]?) { // Cache fetched experiment payloads. experimentPayloads.removeAll() - dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload) + dbManager.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload) if let response { for experiment in response { do { let jsonData = try JSONSerialization.data(withJSONObject: experiment) experimentPayloads.append(jsonData) - dbManager? + dbManager .insertExperimentTable( withKey: ConfigConstants.experimentTableKeyPayload, value: jsonData @@ -160,7 +161,7 @@ import Foundation withJSONObject: experimentMetadata, options: .prettyPrinted ) { - dbManager? + dbManager .insertExperimentTable( withKey: ConfigConstants.experimentTableKeyMetadata, value: serializedExperimentMetadata @@ -171,10 +172,10 @@ import Foundation @objc private func updateActiveExperimentsInDB() { // Put current fetched experiment payloads into activated experiment DB. activeExperimentPayloads.removeAll() - dbManager?.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyActivePayload) + dbManager.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyActivePayload) for data in experimentPayloads { activeExperimentPayloads.append(data) - dbManager? + dbManager .insertExperimentTable( withKey: ConfigConstants.experimentTableKeyActivePayload, value: data