feat(app): integrate Atomenta Feature Control bootstrap, cache and resume refresh

This commit is contained in:
Daniel Arantes Loverde
2026-04-16 14:48:39 -03:00
parent f8d2fb8eb7
commit 7f7d414e6c
6 changed files with 433 additions and 1 deletions

View File

@@ -1,3 +1,4 @@
import Foundation
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@@ -15,6 +16,7 @@ struct ContentView: View {
@State private var sessionExpiredObserver: NSObjectProtocol? @State private var sessionExpiredObserver: NSObjectProtocol?
#endif #endif
@State var cartResetObserver: Any? @State var cartResetObserver: Any?
@State var appResumeObserver: Any?
#if os(Android) #if os(Android)
@State var snackbarCenter = SnackbarCenter.shared @State var snackbarCenter = SnackbarCenter.shared
#else #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 { .onAppear {
attachCartResetObserverIfNeeded() attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
#if os(iOS) #if os(iOS)
attachSessionExpiredObserverIfNeeded() attachSessionExpiredObserverIfNeeded()
#endif #endif
} }
.onDisappear { .onDisappear {
detachCartResetObserver() detachCartResetObserver()
detachAppResumeObserver()
#if os(iOS) #if os(iOS)
detachSessionExpiredObserver() detachSessionExpiredObserver()
#endif #endif
@@ -179,6 +193,7 @@ struct ContentView: View {
// Keep local state when backend refresh fails transiently. // Keep local state when backend refresh fails transiently.
} }
await refreshFeatureFlags(forceRefresh: false)
isBootstrappingSession = false isBootstrappingSession = false
} }
@@ -189,7 +204,9 @@ struct ContentView: View {
object: nil, object: nil,
queue: nil queue: nil
) { _ in ) { _ in
appState.cart = CartState() Task { @MainActor in
appState.cart = CartState()
}
} }
} }
@@ -199,6 +216,26 @@ struct ContentView: View {
self.cartResetObserver = nil 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 @MainActor
private func hydrateAppState(with customer: CustomerProfile) { private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id appState.profile.id = customer.id
@@ -206,6 +243,7 @@ struct ContentView: View {
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -299,6 +337,46 @@ struct ContentView: View {
scheduleDisableAuthEntryPreparation() 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() { private func scheduleDisableAuthEntryPreparation() {
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 900_000_000) try? await Task.sleep(nanoseconds: 900_000_000)

View File

@@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo
/* SKIP @bridge */public func onResume() { /* SKIP @bridge */public func onResume() {
logger.debug("onResume") logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
} }
/* SKIP @bridge */public func onPause() { /* SKIP @bridge */public func onPause() {

View File

@@ -14,6 +14,17 @@ enum ApiConfig {
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! 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 // Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008" static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"

View File

@@ -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"
}
}

View File

@@ -4,4 +4,5 @@ extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification") static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification") static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
static let appDidResume = Notification.Name("AppDidResumeNotification")
} }

View File

@@ -6,10 +6,64 @@ struct AppState {
var cart = CartState() var cart = CartState()
var address = AddressState() var address = AddressState()
var favorites = FavoritesState() var favorites = FavoritesState()
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState() var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil 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 { enum AppModal: String, Identifiable {
case addressPicker case addressPicker
case filters case filters