feat(app): integrate Atomenta Feature Control bootstrap, cache and resume refresh
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@@ -15,6 +16,7 @@ struct ContentView: View {
|
||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||
#endif
|
||||
@State var cartResetObserver: Any?
|
||||
@State var appResumeObserver: Any?
|
||||
#if os(Android)
|
||||
@State var snackbarCenter = SnackbarCenter.shared
|
||||
#else
|
||||
@@ -80,14 +82,26 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: appState.profile.id) { _, _ in
|
||||
Task { @MainActor in
|
||||
await refreshFeatureFlags(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: appState.cart.storeId) { _, _ in
|
||||
Task { @MainActor in
|
||||
await refreshFeatureFlags(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
attachCartResetObserverIfNeeded()
|
||||
attachAppResumeObserverIfNeeded()
|
||||
#if os(iOS)
|
||||
attachSessionExpiredObserverIfNeeded()
|
||||
#endif
|
||||
}
|
||||
.onDisappear {
|
||||
detachCartResetObserver()
|
||||
detachAppResumeObserver()
|
||||
#if os(iOS)
|
||||
detachSessionExpiredObserver()
|
||||
#endif
|
||||
@@ -179,6 +193,7 @@ struct ContentView: View {
|
||||
// Keep local state when backend refresh fails transiently.
|
||||
}
|
||||
|
||||
await refreshFeatureFlags(forceRefresh: false)
|
||||
isBootstrappingSession = false
|
||||
}
|
||||
|
||||
@@ -189,7 +204,9 @@ struct ContentView: View {
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
appState.cart = CartState()
|
||||
Task { @MainActor in
|
||||
appState.cart = CartState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +216,26 @@ struct ContentView: View {
|
||||
self.cartResetObserver = nil
|
||||
}
|
||||
|
||||
private func attachAppResumeObserverIfNeeded() {
|
||||
guard appResumeObserver == nil else { return }
|
||||
appResumeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .appDidResume,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
Task { @MainActor in
|
||||
guard root == .main else { return }
|
||||
await refreshFeatureFlags(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detachAppResumeObserver() {
|
||||
guard let appResumeObserver else { return }
|
||||
NotificationCenter.default.removeObserver(appResumeObserver)
|
||||
self.appResumeObserver = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func hydrateAppState(with customer: CustomerProfile) {
|
||||
appState.profile.id = customer.id
|
||||
@@ -206,6 +243,7 @@ struct ContentView: View {
|
||||
appState.profile.email = customer.email
|
||||
appState.profile.phone = customer.phoneNumber ?? ""
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
@@ -299,6 +337,46 @@ struct ContentView: View {
|
||||
scheduleDisableAuthEntryPreparation()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshFeatureFlags(forceRefresh: Bool) async {
|
||||
guard root == .main else { return }
|
||||
|
||||
let subjectType: String
|
||||
let subjectId: String
|
||||
if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false {
|
||||
subjectType = "customer"
|
||||
subjectId = profileId
|
||||
} else {
|
||||
subjectType = "anonymous"
|
||||
subjectId = "anonymous-device"
|
||||
}
|
||||
|
||||
var attrs: [String: String] = [:]
|
||||
let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" {
|
||||
attrs["addressLabel"] = addressLabel
|
||||
}
|
||||
|
||||
let context = FeatureControlEvaluationContext(
|
||||
subjectType: subjectType,
|
||||
subjectId: subjectId,
|
||||
storeId: appState.cart.storeId,
|
||||
attributes: attrs
|
||||
)
|
||||
|
||||
let snapshot = await FeatureControlService.shared.evaluate(
|
||||
context: context,
|
||||
jwt: appState.session.jwt,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
appState.featureFlags = snapshot
|
||||
await FeatureControlService.shared.sendExposureEvents(
|
||||
snapshot: snapshot,
|
||||
context: context,
|
||||
jwt: appState.session.jwt
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleDisableAuthEntryPreparation() {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
|
||||
@@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo
|
||||
|
||||
/* SKIP @bridge */public func onResume() {
|
||||
logger.debug("onResume")
|
||||
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
||||
}
|
||||
|
||||
/* SKIP @bridge */public func onPause() {
|
||||
|
||||
@@ -14,6 +14,17 @@ enum ApiConfig {
|
||||
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
|
||||
}
|
||||
|
||||
static var featureControlBffURL: URL {
|
||||
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787"
|
||||
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
|
||||
}
|
||||
|
||||
static var featureControlEnvironment: String {
|
||||
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
|
||||
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return clean.isEmpty ? "production" : clean
|
||||
}
|
||||
|
||||
// Tokens provided by backend modules
|
||||
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
|
||||
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
struct FeatureControlRawFlag: Codable, Equatable {
|
||||
let enabled: Bool
|
||||
let variant: String
|
||||
let payload: FeatureControlJSONValue?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
enum FeatureControlJSONValue: Codable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: FeatureControlJSONValue])
|
||||
case array([FeatureControlJSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([String: FeatureControlJSONValue].self) {
|
||||
self = .object(value)
|
||||
} else if let value = try? container.decode([FeatureControlJSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
self = .null
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let value): try container.encode(value)
|
||||
case .number(let value): try container.encode(value)
|
||||
case .bool(let value): try container.encode(value)
|
||||
case .object(let value): try container.encode(value)
|
||||
case .array(let value): try container.encode(value)
|
||||
case .null: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureControlBootstrapRequest: Codable {
|
||||
struct Context: Codable {
|
||||
let subjectType: String
|
||||
let subjectId: String
|
||||
let storeId: String?
|
||||
let platform: String
|
||||
let appVersion: String
|
||||
let attributes: [String: String]
|
||||
}
|
||||
|
||||
let environment: String
|
||||
let keys: [String]
|
||||
let context: Context
|
||||
}
|
||||
|
||||
private struct FeatureControlBootstrapResponse: Codable {
|
||||
let ok: Bool
|
||||
let source: String?
|
||||
let configVersion: Int
|
||||
let evaluatedAt: String?
|
||||
let flags: [String: FeatureFlagValue]
|
||||
let raw: [String: FeatureControlRawFlag]
|
||||
}
|
||||
|
||||
private struct FeatureControlExposureRequest: Codable {
|
||||
struct Event: Codable {
|
||||
let featureKey: String
|
||||
let variant: String
|
||||
let subjectType: String
|
||||
let storeId: String?
|
||||
}
|
||||
|
||||
let events: [Event]
|
||||
}
|
||||
|
||||
private struct FeatureControlCacheEntry: Codable {
|
||||
let expiresAtUnixMs: Int64
|
||||
let snapshot: FeatureFlagsState
|
||||
}
|
||||
|
||||
struct FeatureControlEvaluationContext {
|
||||
let subjectType: String
|
||||
let subjectId: String
|
||||
let storeId: String?
|
||||
let attributes: [String: String]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FeatureControlService {
|
||||
static let shared = FeatureControlService()
|
||||
|
||||
private let session: URLSession
|
||||
private let cacheTTL: TimeInterval
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let userDefaults: UserDefaults
|
||||
private let defaultsPrefix = "feature-control.cache.v1."
|
||||
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
cacheTTL: TimeInterval = 60,
|
||||
userDefaults: UserDefaults = .standard
|
||||
) {
|
||||
self.session = session
|
||||
self.cacheTTL = cacheTTL
|
||||
self.userDefaults = userDefaults
|
||||
}
|
||||
|
||||
func evaluate(
|
||||
context: FeatureControlEvaluationContext,
|
||||
jwt: String?,
|
||||
forceRefresh: Bool = false
|
||||
) async -> FeatureFlagsState {
|
||||
let key = storageKey(for: context)
|
||||
if forceRefresh == false, let cached = loadFromCache(storageKey: key) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let requestBody = FeatureControlBootstrapRequest(
|
||||
environment: ApiConfig.featureControlEnvironment,
|
||||
keys: featureKeys(),
|
||||
context: .init(
|
||||
subjectType: context.subjectType,
|
||||
subjectId: context.subjectId,
|
||||
storeId: context.storeId,
|
||||
platform: platformName(),
|
||||
appVersion: appVersion(),
|
||||
attributes: context.attributes
|
||||
)
|
||||
)
|
||||
|
||||
do {
|
||||
let response = try await performBootstrapRequest(body: requestBody, jwt: jwt)
|
||||
let snapshot = FeatureFlagsState(
|
||||
configVersion: response.configVersion,
|
||||
evaluatedAt: response.evaluatedAt,
|
||||
source: response.source ?? "live",
|
||||
values: response.flags,
|
||||
raw: response.raw
|
||||
)
|
||||
saveToCache(snapshot: snapshot, storageKey: key)
|
||||
return snapshot
|
||||
} catch {
|
||||
if let cached = loadFromCache(storageKey: key) {
|
||||
return FeatureFlagsState(
|
||||
configVersion: cached.configVersion,
|
||||
evaluatedAt: cached.evaluatedAt,
|
||||
source: "cache_fallback",
|
||||
values: cached.values,
|
||||
raw: cached.raw
|
||||
)
|
||||
}
|
||||
return FeatureFlagsState(source: "defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async {
|
||||
guard snapshot.raw.isEmpty == false else { return }
|
||||
|
||||
let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in
|
||||
let key = entry.key
|
||||
let value = entry.value
|
||||
guard value.enabled || value.variant.lowercased() != "off" else { return nil }
|
||||
return .init(
|
||||
featureKey: key,
|
||||
variant: value.variant,
|
||||
subjectType: context.subjectType,
|
||||
storeId: context.storeId
|
||||
)
|
||||
}
|
||||
|
||||
guard events.isEmpty == false else { return }
|
||||
|
||||
let batched = Array(events.prefix(100))
|
||||
let payload = FeatureControlExposureRequest(events: batched)
|
||||
guard let body = try? encoder.encode(payload) else { return }
|
||||
|
||||
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure"))
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = 3
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let jwt, jwt.isEmpty == false {
|
||||
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
_ = try? await session.data(for: request)
|
||||
}
|
||||
|
||||
private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse {
|
||||
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap"))
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = try encoder.encode(body)
|
||||
request.timeoutInterval = 3
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let jwt, jwt.isEmpty == false {
|
||||
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
return try decoder.decode(FeatureControlBootstrapResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func storageKey(for context: FeatureControlEvaluationContext) -> String {
|
||||
let tokens = [
|
||||
ApiConfig.featureControlEnvironment,
|
||||
context.subjectType,
|
||||
context.subjectId,
|
||||
context.storeId ?? "none",
|
||||
platformName(),
|
||||
appVersion(),
|
||||
featureKeys().joined(separator: "|")
|
||||
]
|
||||
let base = tokens.joined(separator: "::")
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: " ", with: "_")
|
||||
return defaultsPrefix + base
|
||||
}
|
||||
|
||||
private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) {
|
||||
let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000)
|
||||
let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot)
|
||||
guard let data = try? encoder.encode(entry) else { return }
|
||||
userDefaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
private func loadFromCache(storageKey: String) -> FeatureFlagsState? {
|
||||
guard let data = userDefaults.data(forKey: storageKey),
|
||||
let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
guard entry.expiresAtUnixMs > now else {
|
||||
userDefaults.removeObject(forKey: storageKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
return entry.snapshot
|
||||
}
|
||||
|
||||
private func featureKeys() -> [String] {
|
||||
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3"
|
||||
let items = raw
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { $0.isEmpty == false }
|
||||
return items.isEmpty ? ["fc.checkout_v2"] : items
|
||||
}
|
||||
|
||||
private func platformName() -> String {
|
||||
#if os(Android)
|
||||
return "android"
|
||||
#else
|
||||
return "ios"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let version, version.isEmpty == false {
|
||||
return version
|
||||
}
|
||||
#endif
|
||||
return "0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
||||
static let appDidResume = Notification.Name("AppDidResumeNotification")
|
||||
}
|
||||
|
||||
@@ -6,10 +6,64 @@ struct AppState {
|
||||
var cart = CartState()
|
||||
var address = AddressState()
|
||||
var favorites = FavoritesState()
|
||||
var featureFlags = FeatureFlagsState()
|
||||
var homeFilters = HomeFiltersState()
|
||||
var activeModal: AppModal? = nil
|
||||
}
|
||||
|
||||
enum FeatureFlagValue: Codable, Equatable {
|
||||
case boolean(Bool)
|
||||
case text(String)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let boolValue = try? container.decode(Bool.self) {
|
||||
self = .boolean(boolValue)
|
||||
return
|
||||
}
|
||||
if let stringValue = try? container.decode(String.self) {
|
||||
self = .text(stringValue)
|
||||
return
|
||||
}
|
||||
if let intValue = try? container.decode(Int.self) {
|
||||
self = .text(String(intValue))
|
||||
return
|
||||
}
|
||||
if let doubleValue = try? container.decode(Double.self) {
|
||||
self = .text(String(doubleValue))
|
||||
return
|
||||
}
|
||||
self = .text("off")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .boolean(let value):
|
||||
try container.encode(value)
|
||||
case .text(let value):
|
||||
try container.encode(value)
|
||||
}
|
||||
}
|
||||
|
||||
var boolValue: Bool {
|
||||
switch self {
|
||||
case .boolean(let value):
|
||||
return value
|
||||
case .text(let value):
|
||||
return value.lowercased() == "on" || value.lowercased() == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureFlagsState: Codable, Equatable {
|
||||
var configVersion: Int = 0
|
||||
var evaluatedAt: String? = nil
|
||||
var source: String = "default"
|
||||
var values: [String: FeatureFlagValue] = [:]
|
||||
var raw: [String: FeatureControlRawFlag] = [:]
|
||||
}
|
||||
|
||||
enum AppModal: String, Identifiable {
|
||||
case addressPicker
|
||||
case filters
|
||||
|
||||
Reference in New Issue
Block a user