ConfigExperiment.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. // Copyright 2024 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import FirebaseABTesting
  15. import Foundation
  16. // TODO(ncooke3): Once everything is ported, the `@objc` and `public` access
  17. // can be removed.
  18. /// Handles experiment information update and persistence.
  19. @objc(RCNConfigExperiment) open class ConfigExperiment: NSObject {
  20. private static let experimentMetadataKeyLastStartTime = "last_experiment_start_time"
  21. private static let serviceOrigin = "frc"
  22. @objc var experimentPayloads: [Data]
  23. @objc var experimentMetadata: [String: Any]
  24. @objc var activeExperimentPayloads: [Data]
  25. private let dbManager: ConfigDBManager
  26. // TODO(ncooke3): This property could be made non-optional after ensuring the
  27. // unit tests properly configure the default app. This is because the
  28. // experiment controller comes from the ABTesting component.
  29. private let experimentController: ExperimentController?
  30. private let experimentStartTimeDateFormatter: DateFormatter
  31. /// Designated initializer;
  32. @objc public init(dbManager: ConfigDBManager,
  33. experimentController controller: ExperimentController?) {
  34. experimentPayloads = []
  35. experimentMetadata = [:]
  36. activeExperimentPayloads = []
  37. experimentStartTimeDateFormatter = {
  38. let dateFormatter = DateFormatter()
  39. dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
  40. // Locale needs to be hardcoded. See
  41. // https://developer.apple.com/library/ios/#qa/qa1480/_index.html for more details.
  42. dateFormatter.locale = Locale(identifier: "en_US_POSIX")
  43. dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
  44. return dateFormatter
  45. }()
  46. self.dbManager = dbManager
  47. experimentController = controller
  48. super.init()
  49. loadExperimentFromTable()
  50. }
  51. @objc private func loadExperimentFromTable() {
  52. let completionHandler: (Bool, [String: Any]?) -> Void = { [weak self] _, result in
  53. guard let self else { return }
  54. if result?[ConfigConstants.experimentTableKeyPayload] != nil {
  55. self.experimentPayloads.removeAll()
  56. if let experiments = result?[ConfigConstants.experimentTableKeyPayload] as? [Data] {
  57. for experiment in experiments {
  58. do {
  59. try JSONSerialization.jsonObject(with: experiment)
  60. self.experimentPayloads.append(experiment)
  61. } catch {
  62. RCLog.warning("I-RCN000031", "Experiment payload could not be parsed as JSON.")
  63. }
  64. }
  65. }
  66. }
  67. if let experimentTable =
  68. result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Any] {
  69. self.experimentMetadata = experimentTable
  70. }
  71. if result?[ConfigConstants.experimentTableKeyActivePayload] != nil {
  72. self.activeExperimentPayloads.removeAll()
  73. if let experiments = result?[ConfigConstants.experimentTableKeyActivePayload] as? [Data] {
  74. for experiment in experiments {
  75. do {
  76. try JSONSerialization.jsonObject(with: experiment)
  77. self.activeExperimentPayloads.append(experiment)
  78. } catch {
  79. RCLog.warning(
  80. "I-RCN000031",
  81. "Activated experiment payload could not be parsed as JSON."
  82. )
  83. }
  84. }
  85. }
  86. }
  87. }
  88. dbManager.loadExperiment(completionHandler: completionHandler)
  89. }
  90. /// Update/Persist experiment information from config fetch response.
  91. @objc public func updateExperiments(withResponse response: [[String: Any]]?) {
  92. // Cache fetched experiment payloads.
  93. experimentPayloads.removeAll()
  94. dbManager.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload)
  95. if let response {
  96. for experiment in response {
  97. do {
  98. let jsonData = try JSONSerialization.data(withJSONObject: experiment)
  99. experimentPayloads.append(jsonData)
  100. dbManager
  101. .insertExperimentTable(
  102. withKey: ConfigConstants.experimentTableKeyPayload,
  103. value: jsonData
  104. )
  105. } catch {
  106. RCLog.error("I-RCN000030", "Invalid experiment payload to be serialized.")
  107. }
  108. }
  109. }
  110. }
  111. /// Update experiments to Firebase Analytics when `activateWithCompletion:` happens.
  112. @objc open func updateExperiments(handler: (((any Error)?) -> Void)? = nil) {
  113. let lifecycleEvent = LifecycleEvents()
  114. // Get the last experiment start time prior to the latest payload.
  115. let lastStartTime = experimentMetadata[Self.experimentMetadataKeyLastStartTime] as? Double
  116. // Update the last experiment start time with the latest payload.
  117. updateExperimentStartTime()
  118. experimentController?
  119. .updateExperiments(
  120. withServiceOrigin: Self.serviceOrigin,
  121. events: lifecycleEvent,
  122. policy: .discardOldest,
  123. lastStartTime: lastStartTime ?? 0,
  124. payloads: experimentPayloads,
  125. completionHandler: handler
  126. )
  127. // Update activated experiments payload and metadata in DB.
  128. updateActiveExperimentsInDB()
  129. }
  130. @objc func updateExperimentStartTime() {
  131. let existingLastStartTime =
  132. experimentMetadata[Self.experimentMetadataKeyLastStartTime] as? Double
  133. let latestStartTime = latestStartTime(existingLastStartTime: existingLastStartTime ?? 0)
  134. experimentMetadata[Self.experimentMetadataKeyLastStartTime] = latestStartTime
  135. guard JSONSerialization.isValidJSONObject(experimentMetadata) else {
  136. RCLog.error("I-RCN000028", "Invalid fetched experiment metadata to be serialized.")
  137. return
  138. }
  139. if let serializedExperimentMetadata = try? JSONSerialization.data(
  140. withJSONObject: experimentMetadata,
  141. options: .prettyPrinted
  142. ) {
  143. dbManager
  144. .insertExperimentTable(
  145. withKey: ConfigConstants.experimentTableKeyMetadata,
  146. value: serializedExperimentMetadata
  147. )
  148. }
  149. }
  150. @objc private func updateActiveExperimentsInDB() {
  151. // Put current fetched experiment payloads into activated experiment DB.
  152. activeExperimentPayloads.removeAll()
  153. dbManager.deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyActivePayload)
  154. for data in experimentPayloads {
  155. activeExperimentPayloads.append(data)
  156. dbManager
  157. .insertExperimentTable(
  158. withKey: ConfigConstants.experimentTableKeyActivePayload,
  159. value: data
  160. )
  161. }
  162. }
  163. private func latestStartTime(existingLastStartTime: Double) -> TimeInterval {
  164. experimentController?
  165. .latestExperimentStartTimestampBetweenTimestamp(
  166. existingLastStartTime,
  167. andPayloads: experimentPayloads
  168. ) ?? 0
  169. }
  170. }