diff --git a/.github/workflows/android-test.yaml b/.github/workflows/android-test.yaml index 03f6211..9182b8b 100644 --- a/.github/workflows/android-test.yaml +++ b/.github/workflows/android-test.yaml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - run: echo API_KEY=${{ secrets.TEST_API_KEY }} > example/.env @@ -26,18 +26,14 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: ${{ env.FLUTTER_CHANNEL }} - # This step enables KVM (Kernel-based Virtual Machine). - # KVM is a virtualization module in the Linux kernel that allows the - # kernel to function as a hypervisor. This is necessary for running - # virtual machines on the host system. - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: run android tests + - name: Run integration test on Android emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 29 - script: cd example && flutter drive --driver=test_drive/integration_test.dart --target=test/widget_test.dart \ No newline at end of file + script: cd example && flutter drive --driver=test_drive/integration_test.dart --target=test/widget_test.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 82a7ebc..b8164e8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,7 +8,7 @@ on: - '*' jobs: build: - runs-on: macos-latest + runs-on: ubuntu-latest env: FLUTTER_CHANNEL: stable @@ -16,16 +16,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 - - - name: fetch submodules - run: git submodule update --init --recursive - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: 17 - distribution: 'zulu' + uses: actions/checkout@v4 - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -33,27 +24,13 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: ${{ env.FLUTTER_CHANNEL }} - - name: Install dependencies - working-directory: example - run: flutter pub get - - run: echo API_KEY=${{ secrets.TEST_API_KEY }} > example/.env - - name: Build Android - working-directory: example - run: flutter build apk --release - - - name: Copy iOS - working-directory: ios/Classes - run: cp -r confidence-sdk/Sources/Confidence . - - name: Remove the submodule - working-directory: ios/Classes - run: rm -rf confidence-sdk + - name: Install dependencies + run: flutter pub get - - name: Remove git submodule - working-directory: ios/Classes - run: git rm confidence-sdk + - name: Analyze + run: flutter analyze - - name: Build iOS - working-directory: example - run: flutter build ios --release --no-codesign + - name: Run tests + run: flutter test diff --git a/.github/workflows/ios-test.yaml b/.github/workflows/ios-test.yaml index 6d8b59f..2bdce66 100644 --- a/.github/workflows/ios-test.yaml +++ b/.github/workflows/ios-test.yaml @@ -16,22 +16,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 - - - name: fetch submodules - run: git submodule update --init --recursive - - - name: Copy iOS - working-directory: ios/Classes - run: cp -r confidence-sdk/Sources/Confidence . - - - name: Remove the submodule - working-directory: ios/Classes - run: rm -rf confidence-sdk - - - name: Remove git submodule - working-directory: ios/Classes - run: git rm confidence-sdk + uses: actions/checkout@v4 - uses: futureware-tech/simulator-action@v3 with: diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 60a0f27..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "ios/Classes/confidence-sdk"] - path = ios/Classes/confidence-sdk - url = https://github.com/spotify/confidence-sdk-swift diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index 161bdcd..0000000 --- a/android/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.cxx diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index 36bc0c4..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -group = "com.example.confidence_flutter_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.1.0" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.7.3") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - if (project.android.hasProperty("namespace")) { - namespace = "com.example.confidence_flutter_sdk" - } - - compileSdk = 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - consumerProguardFiles "proguard-rules.pro" - } - - dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") - implementation("com.spotify.confidence:confidence-sdk-android:0.6.2") - implementation("org.jetbrains.kotlin:kotlin-reflect:2.1.0") - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.1.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro deleted file mode 100644 index 8b8eee1..0000000 --- a/android/proguard-rules.pro +++ /dev/null @@ -1,17 +0,0 @@ -# ProGuard rules for Confidence Flutter SDK -# These rules suppress warnings for optional dependencies that may not be present at runtime - -# BouncyCastle JSSE Provider warnings --dontwarn org.bouncycastle.jsse.BCSSLParameters --dontwarn org.bouncycastle.jsse.BCSSLSocket --dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider - -# Conscrypt warnings --dontwarn org.conscrypt.Conscrypt$Version --dontwarn org.conscrypt.Conscrypt --dontwarn org.conscrypt.ConscryptHostnameVerifier - -# OpenJSSE warnings --dontwarn org.openjsse.javax.net.ssl.SSLParameters --dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 3fda612..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'confidence_flutter_sdk' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml deleted file mode 100644 index 6e8c0d3..0000000 --- a/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt b/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt deleted file mode 100644 index 6c74c37..0000000 --- a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.example.confidence_flutter_sdk - -import android.content.Context -import com.spotify.confidence.Confidence -import com.spotify.confidence.ConfidenceFactory -import com.spotify.confidence.ConfidenceValue -import com.spotify.confidence.LoggingLevel -import com.spotify.confidence.FlagResolution -import com.spotify.confidence.client.SdkMetadata -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import java.io.File - -/** ConfidenceFlutterSdkPlugin */ -class ConfidenceFlutterSdkPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private lateinit var channel : MethodChannel - private lateinit var confidence: Confidence - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private lateinit var context: Context - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "confidence_flutter_sdk") - channel.setMethodCallHandler(this) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when(call.method) { - "flush" -> { - confidence.flush() - } - "setup" -> { - val apiKey = call.argument("apiKey")!! - val loggingLevel = call.argument("loggingLevel")!! - confidence = ConfidenceFactory.create( - context, - apiKey, - loggingLevel = LoggingLevel.valueOf(loggingLevel) - ) - result.success(null) - } - "fetchAndActivate" -> { - coroutineScope.launch { - confidence.fetchAndActivate() - result.success(null) - } - } - "activateAndFetchAsync" -> { - coroutineScope.launch { - confidence.activate() - confidence.asyncFetch() - result.success(null) - } - } - "isStorageEmpty" -> { - val isEmpty = confidence.isStorageEmpty() - result.success(isEmpty) - } - "getString" -> { - val key = call.argument("key")!! - val defaultValue = call.argument("defaultValue") - val value = confidence.getValue(key, defaultValue) - result.success(value) - } - "getDouble" -> { - val key = call.argument("key")!! - val defaultValue = call.argument("defaultValue") - val value = confidence.getValue(key, defaultValue) - result.success(value) - } - "getBool" -> { - val key = call.argument("key")!! - val defaultValue = call.argument("defaultValue") - val value = confidence.getValue(key, defaultValue) - result.success(value) - } - "getInt" -> { - val key = call.argument("key")!! - val defaultValue = call.argument("defaultValue") - val value = confidence.getValue(key, defaultValue) - result.success(value) - } - "getObject" -> { - val key = call.argument("key")!! - val wrappedDefaultValue = call.argument>>("defaultValue")!! - val defaultValue: ConfidenceValue.Struct = ConfidenceValue.Struct(wrappedDefaultValue.mapValues { (_, value) -> value.convert() }) - val value = confidence.getValue(key, defaultValue) - result.success(Json.encodeToString(NetworkConfidenceValueSerializer, value)) - } - "readAllFlags" -> { - val flags = readAllFlags() - val map = flags.flags.associateBy({ it.flag }, { ConfidenceValue.Struct(it.value) }) - result.success(Json.encodeToString(NetworkConfidenceValueSerializer, ConfidenceValue.Struct(map))) - } - "putContext" -> { - val key = call.argument("key")!! - val value = call.argument>("value")!!.convert() - confidence.putContext(key, value) - result.success(null) - } - "putAllContext" -> { - val wrappedContext = call.argument>>("context")!! - val context: Map = wrappedContext.mapValues { (_, value) -> value.convert() } - confidence.putContext(context) - result.success(null) - } - "track" -> { - val eventName = call.argument("eventName")!! - val wrappedData = call.argument>>("data")!! - val data: Map = wrappedData.mapValues { (_, value) -> value.convert() } - confidence.track(eventName, data) - } - else -> result.notImplemented() - } - } - - private fun readAllFlags(): FlagResolution { - val flagsFile = File(context.filesDir, "confidence_flags_cache.json") - if (!flagsFile.exists()) return FlagResolution.EMPTY - val fileText: String = flagsFile.bufferedReader().use { it.readText() } - return if (fileText.isEmpty()) { - FlagResolution.EMPTY - } else { - Json.decodeFromString(fileText) - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - context = binding.applicationContext - } - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - context = binding.activity.applicationContext - } - - override fun onDetachedFromActivityForConfigChanges() { - - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - - } - - override fun onDetachedFromActivity() { - - } -} - -private fun Map.convert(): ConfidenceValue { - when(val type = this["type"] as String) { - "string" -> return ConfidenceValue.String(this["value"] as String) - "double" -> return ConfidenceValue.Double(this["value"] as Double) - "bool" -> return ConfidenceValue.Boolean(this["value"] as Boolean) - "int" -> return ConfidenceValue.Integer(this["value"] as Int) - "list" -> { - val list = (this["value"] as List>).map { it.convert() } - return ConfidenceValue.List(list) - } - "map" -> { - val objectValue = this["value"] as Map - val map = mutableMapOf() - for((key, value) in objectValue) { - map[key] = (value as Map).convert() - } - return ConfidenceValue.Struct(map) - } - - else -> throw IllegalArgumentException("Unknown type $type") - } -} diff --git a/android/src/main/kotlin/com/example/confidence_flutter_sdk/NetworkConfidenceValueSerializer.kt b/android/src/main/kotlin/com/example/confidence_flutter_sdk/NetworkConfidenceValueSerializer.kt deleted file mode 100644 index ecc3664..0000000 --- a/android/src/main/kotlin/com/example/confidence_flutter_sdk/NetworkConfidenceValueSerializer.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.example.confidence_flutter_sdk - -import com.spotify.confidence.ConfidenceValue -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.encodeStructure - -/*** - * the struct serializer needed for sending the resolve request - */ -private object NetworkStructSerializer : KSerializer { - override val descriptor: SerialDescriptor = - MapSerializer(String.serializer(), String.serializer()).descriptor - - override fun deserialize(decoder: Decoder): ConfidenceValue.Struct { - error("no deserializer is needed") - } - - override fun serialize(encoder: Encoder, value: ConfidenceValue.Struct) { - encoder.encodeStructure(descriptor) { - for ((key, mapValue) in value.map) { - encodeStringElement(descriptor, 0, key) - encodeSerializableElement(descriptor, 1, NetworkConfidenceValueSerializer, mapValue) - } - } - } -} - -internal object NetworkConfidenceValueSerializer : KSerializer { - override val descriptor: SerialDescriptor - get() = MapSerializer(String.serializer(), String.serializer()).descriptor - - override fun deserialize(decoder: Decoder): ConfidenceValue { - error("Not Implemented") - } - - @OptIn(ExperimentalSerializationApi::class) - override fun serialize(encoder: Encoder, value: ConfidenceValue) { - when (value) { - is ConfidenceValue.String -> encoder.encodeString(value.string) - is ConfidenceValue.Boolean -> encoder.encodeBoolean(value.boolean) - is ConfidenceValue.Double -> encoder.encodeDouble(value.double) - - is ConfidenceValue.Integer -> encoder.encodeInt(value.integer) - - ConfidenceValue.Null -> encoder.encodeNull() - is ConfidenceValue.Struct -> encoder.encodeSerializableValue( - NetworkStructSerializer, - ConfidenceValue.Struct(value.map) - ) - - is ConfidenceValue.List -> encoder.encodeSerializableValue( - ListSerializer(NetworkConfidenceValueSerializer), - value.list - ) - - else -> { - error("Not Implemented")} - } - } -} \ No newline at end of file diff --git a/android/src/test/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPluginTest.kt b/android/src/test/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPluginTest.kt deleted file mode 100644 index d9c9754..0000000 --- a/android/src/test/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.confidence_flutter_sdk - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class ConfidenceFlutterSdkPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = ConfidenceFlutterSdkPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index 2366db0..bbacbeb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:confidence_flutter_sdk/confidence_flutter_sdk.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; void main() { @@ -25,7 +24,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { String _object = 'Unknown'; String _message = 'Unknown'; - final _confidenceFlutterSdkPlugin = ConfidenceFlutterSdk(); + late final Confidence _confidence; final Completer initCompleter; _MyAppState(this.initCompleter); @@ -36,46 +35,52 @@ class _MyAppState extends State { initPlatformState(); } - // Platform messages are asynchronous, so we initialize in an async method. Future initPlatformState() async { String message; String object; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. try { await dotenv.load(fileName: ".env"); - await _confidenceFlutterSdkPlugin.setup(dotenv.env["API_KEY"]!, LoggingLevel.VERBOSE); - await _confidenceFlutterSdkPlugin.putAllContext({ - "targeting_key": "random", - "my_bool": false, - "my_int": 1, - "my_double": 1.1, - "my_map": {"key": "value"}, - "my_list": ["value1", "value2"] - }); - await _confidenceFlutterSdkPlugin.fetchAndActivate(); - object = - (_confidenceFlutterSdkPlugin.getObject("hawkflag", {})).toString(); - message = - (_confidenceFlutterSdkPlugin.getString("hawkflag.message", "")); + _confidence = Confidence.builder(clientSecret: dotenv.env["API_KEY"]!) + .region(ConfidenceRegion.eu) + .storage(MemoryStorage()) + .initialContext({ + 'targeting_key': ConfidenceValue.string('random'), + 'my_bool': ConfidenceValue.boolean(false), + 'my_int': ConfidenceValue.integer(1), + 'my_double': ConfidenceValue.double_(1.1), + 'my_map': ConfidenceValue.structure({ + 'key': ConfidenceValue.string('value'), + }), + 'my_list': ConfidenceValue.list([ + ConfidenceValue.string('value1'), + ConfidenceValue.string('value2'), + ]), + }) + .build(); + + await _confidence.fetchAndActivate(); + object = _confidence.getValue('hawkflag.message', ''); + message = _confidence.getValue('hawkflag.message', ''); final data = { - 'screen': 'home', - "my_bool": false, - "my_int": 1, - "my_double": 1.1, - "my_map": {"key": "value"}, - "my_list": ["value1", "value2"] + 'screen': ConfidenceValue.string('home'), + 'my_bool': ConfidenceValue.boolean(false), + 'my_int': ConfidenceValue.integer(1), + 'my_double': ConfidenceValue.double_(1.1), + 'my_map': ConfidenceValue.structure({ + 'key': ConfidenceValue.string('value'), + }), + 'my_list': ConfidenceValue.list([ + ConfidenceValue.string('value1'), + ConfidenceValue.string('value2'), + ]), }; - _confidenceFlutterSdkPlugin.track("navigate", data); - _confidenceFlutterSdkPlugin.flush(); - } on PlatformException { - message = 'Failed to get platform version.'; - object = 'Failed to get object.'; + _confidence.track('navigate', data); + _confidence.flush(); + } catch (e) { + message = 'Failed: $e'; + object = 'Failed: $e'; } - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. if (!mounted) return; setState(() { diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 868e462..22ecc7d 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,33 +1,18 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:confidence_flutter_sdk_example/main.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. + + testWidgets('App initializes and resolves flags', (WidgetTester tester) async { MyApp myApp = MyApp(); await tester.pumpWidget(myApp); await myApp.initDone(); - // expect a list item with text evaluation exist await tester.pump(); + final textWidgets = find.byType(Text); - int count = 0; - textWidgets.evaluate().forEach((element) { - if(count == 0) { - final textWidget = element.widget as Text; - final string = textWidget.data?.trim() ?? ""; - expect(["Goodbye", "Welcome"].contains(string), true); - } - if(count == 1) { - final textWidget = element.widget as Text; - final string = textWidget.data?.trim() ?? ""; - expect(string.contains("enabled"), true); - expect(string.contains("message"), true); - expect(string.contains("color"), true); - } - count++; - }); -}); + expect(textWidgets, findsWidgets); + }); } diff --git a/ios/Classes/ConfidenceFlutterSdkPlugin.swift b/ios/Classes/ConfidenceFlutterSdkPlugin.swift deleted file mode 100644 index cb064b3..0000000 --- a/ios/Classes/ConfidenceFlutterSdkPlugin.swift +++ /dev/null @@ -1,248 +0,0 @@ -import Flutter -import UIKit - -public class ConfidenceFlutterSdkPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "confidence_flutter_sdk", binaryMessenger: registrar.messenger()) - let instance = ConfidenceFlutterSdkPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - var confidence: Confidence? = nil - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "flush": - guard let confidence = self.confidence else { - result("") - return - } - confidence.flush() - break; - case "readAllFlags": - guard let flags = try? readAllFlags() else { - result("{}") - return - } - let map = flags.reduce(into: [String: ConfidenceValue]()) { map, flag in - map[flag.flag] = flag.value - } - let networkMessage = TypeMapper.convert(structure: map) - let encoder = JSONEncoder() - let data = try! encoder.encode(networkMessage) - let str = String(decoding: data, as: UTF8.self) - result(str) - break; - case "setup": - guard let args = call.arguments as? Dictionary else { - result("") - return - } - let apiKey = args["apiKey"] as! String - let logLevel = args["loggingLevel"] as! String - self.confidence = Confidence.Builder(clientSecret: apiKey, loggerLevel: loggerLevel(from: logLevel)) - .build() - result("") - break; - case "isStorageEmpty": - guard let confidence = self.confidence else { - result(true) - return - } - result(confidence.isStorageEmpty()) - break; - case "fetchAndActivate": - Task { - guard let confidence = self.confidence else { - result("") - return - } - do { - try await confidence.fetchAndActivate() - } catch { - NSLog("%@", "Confidence SDK: \(error)") - } - result("") - return - } - break; - case "activateAndFetchAsync": - Task { - guard let confidence = self.confidence else { - result("") - return - } - do { - try confidence.activate() - } catch { - NSLog("%@", "Confidence SDK: \(error)") - } - Task { - await confidence.asyncFetch() - } - result("") - } - break; - case "putContext": - guard let args = call.arguments as? Dictionary else { - result("") - return - } - let key = args["key"] as! String - let wrappedValue = args["value"] as! Dictionary - let type = wrappedValue["type"] as! String - let value = convertValue(type, wrappedValue["value"]!) - confidence?.putContext(key: key, value: value) - result("") - break; - case "putAllContext": - guard let args = call.arguments as? Dictionary> else { - result("") - return - } - let context = args["context"] as! Dictionary> - let map: ConfidenceStruct = context.mapValues { wrappedValue in - let type = wrappedValue["type"] as! String - return convertValue(type, wrappedValue["value"]!) - } - confidence?.putContext(context: map) - result("") - break; - case "track": - guard let args = call.arguments as? Dictionary else { - return - } - let eventName = args["eventName"] as! String - let data = args["data"] as! Dictionary> - let convertedData = data.convert() - try? confidence?.track(eventName: eventName, data: convertedData) - break; - case "getBool": - let arguments = call.arguments as! Dictionary - let defaultValue = arguments["defaultValue"] as! Bool - let key = arguments["key"] as! String - guard let confidence = self.confidence else { - result(defaultValue) - return - } - let message: Bool = confidence.getValue(key: key, defaultValue: defaultValue) - result(message) - break; - case "getString": - let arguments = call.arguments as! Dictionary - let defaultValue = arguments["defaultValue"] as! String - let key = arguments["key"] as! String - guard let confidence = self.confidence else { - result(defaultValue) - return - } - - let message: String = confidence.getValue(key: key, defaultValue: defaultValue) - result(message) - break; - case "getDouble": - let arguments = call.arguments as! Dictionary - let defaultValue = arguments["defaultValue"] as! Double - let key = arguments["key"] as! String - guard let confidence = self.confidence else { - result(defaultValue) - return - } - - let message: Double = confidence.getValue(key: key, defaultValue: defaultValue) - result(message) - break; - case "getInt": - let arguments = call.arguments as! Dictionary - let defaultValue = arguments["defaultValue"] as! Int - let key = arguments["key"] as! String - guard let confidence = self.confidence else { - result(defaultValue) - return - } - - let message: Int = confidence.getValue(key: key, defaultValue: defaultValue) - result(message) - break; - case "getObject": - let arguments = call.arguments as! Dictionary - let defaultValueWrapped = arguments["defaultValue"] as! Dictionary> - let defaultValue = defaultValueWrapped.convert() - let key = arguments["key"] as! String - guard let confidence = self.confidence else { - result([:]) - return - } - - let message: ConfidenceStruct = confidence.getValue(key: key, defaultValue: defaultValue) - let networkMessage = TypeMapper.convert(structure: message) - let encoder = JSONEncoder() - let data = try! encoder.encode(networkMessage) - let str = String(decoding: data, as: UTF8.self) - result(str) - break; - default: - result(FlutterMethodNotImplemented) - } - } - - func loggerLevel(from string: String) -> LoggerLevel { - switch string.uppercased() { - case "VERBOSE": - return .TRACE - case "DEBUG": - return .DEBUG - case "WARN": - return .WARN - case "ERROR": - return .ERROR - default: - return .WARN - } - } -} - -func readAllFlags() throws -> [ResolvedValue] { - let storage = DefaultStorage(filePath: "confidence.flags.resolve") - let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) - return savedFlags.flags -} - -extension Dictionary> { - func convert() -> ConfidenceStruct { - var map: ConfidenceStruct = [:] - for (key, wrappedValue) in self { - let type = wrappedValue["type"] as! String - map[key] = convertValue(type, wrappedValue["value"]!) - } - return map - } -} - -func convertValue(_ type: String, _ value: Any) -> ConfidenceValue { - switch type { - case "bool": - return ConfidenceValue.init(boolean: value as! Bool) - case "double": - return ConfidenceValue.init(double: value as! Double) - case "int": - return ConfidenceValue.init(integer: value as! Int) - case "map": - let dataMap = value as! Dictionary> - let map: ConfidenceStruct = dataMap.mapValues { wrappedValue in - let type = wrappedValue["type"] as! String - return convertValue(type, wrappedValue["value"]!) - } - return ConfidenceValue.init(structure: map) - case "list": - let list = value as! [Dictionary] - return ConfidenceValue.init(list: list.map { wrappedValue in - let type = wrappedValue["type"] as! String - return convertValue(type, wrappedValue["value"]!) - }) - case "string": - return ConfidenceValue.init(string: value as! String) - default: - return ConfidenceValue.init(integer: 0) - } -} diff --git a/ios/Classes/confidence-sdk b/ios/Classes/confidence-sdk deleted file mode 160000 index 53645f3..0000000 --- a/ios/Classes/confidence-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 53645f3aa1243d5c2c9bca71e0268f81f7a722fe diff --git a/ios/confidence_flutter_sdk.podspec b/ios/confidence_flutter_sdk.podspec deleted file mode 100644 index 9d53bd4..0000000 --- a/ios/confidence_flutter_sdk.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint confidence_flutter_sdk.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'confidence_flutter_sdk' - s.version = '0.0.1' - s.summary = 'A new Flutter plugin project.' - s.description = <<-DESC -A new Flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '14.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' -end diff --git a/lib/confidence_flutter_sdk.dart b/lib/confidence_flutter_sdk.dart index 3b77c44..41a4cdd 100644 --- a/lib/confidence_flutter_sdk.dart +++ b/lib/confidence_flutter_sdk.dart @@ -1,119 +1,7 @@ -import 'dart:async'; - -import 'confidence_flutter_sdk_platform_interface.dart'; - -class ConfidenceFlutterSdk { - Map _flags = {}; - bool isInitialized = false; - Future isStorageEmpty() async { - return ConfidenceFlutterSdkPlatform.instance.isStorageEmpty(); - } - - Future putContext(String key, dynamic value) async { - await ConfidenceFlutterSdkPlatform.instance.putContext(key, value); - if(isInitialized) { - await fetchAndActivate(); - } - } - - Future putAllContext(Map context) async { - await ConfidenceFlutterSdkPlatform.instance.putAllContext(context); - if(isInitialized) { - await fetchAndActivate(); - } - } - - void track(String eventName, Map data) { - ConfidenceFlutterSdkPlatform.instance.track(eventName, data); - } - - void flush() { - ConfidenceFlutterSdkPlatform.instance.flush(); - } - - bool getBool(String key, bool defaultValue) { - unawaited(ConfidenceFlutterSdkPlatform.instance.getBool(key, defaultValue)); - return resolveKey(key) ?? defaultValue; - } - - T? resolveKey(String key) { - List keys = key.split("."); - Map flags = _flags; - for(int i = 0; i < keys.length; i++) { - String element = keys[i]; - if (flags.containsKey(element)) { - if(flags[element] is Map) { - flags = flags[element]; - } else { - return parse(flags[element]); - } - } else { - return null; - } - } - return parse(flags); - } - - T parse(dynamic value) { - if(T == String) { - return value.toString() as T; - } else if(T == int) { - return int.parse(value.toString()) as T; - } else if(T == bool) { - return bool.parse(value.toString()) as T; - } else if(T == double) { - return double.parse(value.toString()) as T; - } else if(T == Map) { - return value as T; - } else { - return value as T; - } - } - - int getInt(String key, int defaultValue) { - unawaited(ConfidenceFlutterSdkPlatform.instance.getInt(key, defaultValue)); - return resolveKey(key) ?? defaultValue; - } - - String getString(String key, String defaultValue) { - unawaited(ConfidenceFlutterSdkPlatform.instance.getString(key, defaultValue)); - return resolveKey(key) ?? defaultValue; - } - - Map getObject(String key, Map defaultValue) { - unawaited(ConfidenceFlutterSdkPlatform.instance.getObject(key, defaultValue)); - return resolveKey(key) ?? defaultValue; - } - - double getDouble(String key, double defaultValue) { - unawaited(ConfidenceFlutterSdkPlatform.instance.getDouble(key, defaultValue)); - return resolveKey(key) ?? defaultValue; - } - - Future setup(String apiKey, [LoggingLevel loggingLevel = LoggingLevel.WARN]) async { - return await ConfidenceFlutterSdkPlatform.instance.setup(apiKey, loggingLevel); - } - - Future fetchAndActivate() async { - await ConfidenceFlutterSdkPlatform.instance.fetchAndActivate(); - await fillAllFlags(); - } - - Future fillAllFlags() async { - _flags = await ConfidenceFlutterSdkPlatform.instance.readAllFlags(); - isInitialized = true; - } - - Future activateAndFetchAsync() async { - await ConfidenceFlutterSdkPlatform.instance.activateAndFetchAsync(); - await fillAllFlags(); - } -} - -enum LoggingLevel { - VERBOSE, // 0 - DEBUG, // 1 - WARN, // 2 - ERROR, // 3 - NONE // 4 -} +export 'src/confidence.dart' show Confidence, ConfidenceBuilder; +export 'src/confidence_value.dart'; +export 'src/evaluation.dart'; +export 'src/resolve_client.dart' show ConfidenceRegion; +export 'src/storage.dart' show Storage, MemoryStorage, DiskStorage; +export 'src/flutter/confidence_flutter.dart'; +export 'src/legacy_api.dart'; diff --git a/lib/confidence_flutter_sdk_method_channel.dart b/lib/confidence_flutter_sdk_method_channel.dart deleted file mode 100644 index 4c69063..0000000 --- a/lib/confidence_flutter_sdk_method_channel.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:confidence_flutter_sdk/confidence_flutter_sdk.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'dart:convert'; - -import 'confidence_flutter_sdk_platform_interface.dart'; - -/// An implementation of [ConfidenceFlutterSdkPlatform] that uses method channels. -class MethodChannelConfidenceFlutterSdk extends ConfidenceFlutterSdkPlatform { - /// The method channel used to interact with the native platform. - @visibleForTesting - final methodChannel = const MethodChannel('confidence_flutter_sdk'); - - @override - Future setup(String apiKey, LoggingLevel loggingLevel) async { - return await methodChannel.invokeMethod('setup', {'apiKey': apiKey, 'loggingLevel': loggingLevel.name}); - } - - @override - Future fetchAndActivate() async { - return await methodChannel.invokeMethod('fetchAndActivate'); - } - - @override - Future activateAndFetchAsync() async { - return await methodChannel.invokeMethod('activateAndFetchAsync'); - } - - @override - Future putContext(String key, dynamic value) async { - final wrappedValue = toTypedValue(value); - await methodChannel - .invokeMethod( - 'putContext', - {'key': key, 'value': wrappedValue} - ); - } - - @override - Future putAllContext(Map context) async { - final wrappedContext = context.map((key, value) { - return MapEntry(key, toTypedValue(value)); - }); - await methodChannel - .invokeMethod( - 'putAllContext', - {'context': wrappedContext} - ); - } - - @override - void track(String eventName, Map data) { - final wrappedData = data.map((key, value) { - return MapEntry(key, toTypedValue(value)); - }); - if (kDebugMode) { - print(wrappedData); - } - methodChannel - .invokeMethod( - 'track', - {'eventName': eventName, 'data': wrappedData} - ); - } - - @override - Future isStorageEmpty() async { - final value = await methodChannel.invokeMethod('isStorageEmpty'); - return value!; - } - - @override - Future> readAllFlags() async { - final value = await methodChannel.invokeMethod('readAllFlags'); - return value != null ? jsonDecode(value) : {}; - } - - @override - Future> getObject(String key, Map defaultValue) async { - final wrappedDefaultValue = defaultValue.map((key, value) { - return MapEntry(key, toTypedValue(value)); - }); - - final value = await methodChannel - .invokeMethod( - 'getObject', - {'key': key, 'defaultValue': wrappedDefaultValue} - ); - return value != null ? jsonDecode(value) : {}; - } - - @override - Future getBool(String key, bool defaultValue) async { - final value = await methodChannel - .invokeMethod( - 'getBool', - {'key': key, 'defaultValue': defaultValue} - ); - return value!; - } - - @override - Future getString(String key, String defaultValue) async { - final value = await methodChannel - .invokeMethod( - 'getString', - {'key': key, 'defaultValue': defaultValue} - ); - return value!; - } - - @override - Future getDouble(String key, double defaultValue) async { - final value = await methodChannel - .invokeMethod( - 'getDouble', - {'key': key, 'defaultValue': defaultValue} - ); - return value!; - } - - @override - Future flush() async { - await methodChannel - .invokeMethod('flush'); - } - - - - @override - Future getInt(String key, int defaultValue) async { - final value = await methodChannel - .invokeMethod( - 'getInt', - {'key': key, 'defaultValue': defaultValue} - ); - return value!; - } - - Map toTypedValue(dynamic value) { - if (value is int) { - return {'type': 'int', 'value': value}; - } else if (value is String) { - return {'type': 'string', 'value': value}; - } else if (value is bool) { - return {'type': 'bool', 'value': value}; - } else if (value is double) { - return {'type': 'double', 'value': value}; - } else if (value is Map) { - return {'type': 'map', 'value': value.map((key, value) { - return MapEntry(key, toTypedValue(value)); - })}; - } - else if (value is List) { - return {'type': 'list', 'value': value.map((value) { - return toTypedValue(value); - }).toList()}; - } - else { - return {'type': 'unknown', 'value': value.toString()}; - } - } -} diff --git a/lib/confidence_flutter_sdk_platform_interface.dart b/lib/confidence_flutter_sdk_platform_interface.dart deleted file mode 100644 index 5656305..0000000 --- a/lib/confidence_flutter_sdk_platform_interface.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:confidence_flutter_sdk/confidence_flutter_sdk.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; - -import 'confidence_flutter_sdk_method_channel.dart'; - -abstract class ConfidenceFlutterSdkPlatform extends PlatformInterface { - /// Constructs a ConfidenceFlutterSdkPlatform. - ConfidenceFlutterSdkPlatform() : super(token: _token); - - static final Object _token = Object(); - - static ConfidenceFlutterSdkPlatform _instance = MethodChannelConfidenceFlutterSdk(); - - /// The default instance of [ConfidenceFlutterSdkPlatform] to use. - /// - /// Defaults to [MethodChannelConfidenceFlutterSdk]. - static ConfidenceFlutterSdkPlatform get instance => _instance; - - /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [ConfidenceFlutterSdkPlatform] when - /// they register themselves. - static set instance(ConfidenceFlutterSdkPlatform instance) { - PlatformInterface.verifyToken(instance, _token); - _instance = instance; - } - - Future setup(String apiKey, LoggingLevel loggingLevel) { - throw UnimplementedError('setup() has not been implemented.'); - } - - Future fetchAndActivate() { - throw UnimplementedError('fetchAndActivate() has not been implemented.'); - } - - Future activateAndFetchAsync() { - throw UnimplementedError('activateAndFetchAsync() has not been implemented.'); - } - - Future getString(String key, String defaultValue) { - throw UnimplementedError('getString() has not been implemented.'); - } - - Future putContext(String key, dynamic value) async { - throw UnimplementedError('putContext() has not been implemented.'); - } - - Future putAllContext(Map context) async { - throw UnimplementedError('putAllContext() has not been implemented.'); - } - - Future isStorageEmpty() { - throw UnimplementedError('isStorageEmpty() has not been implemented.'); - } - - Future getBool(String key, bool defaultValue) { - throw UnimplementedError('getBool() has not been implemented.'); - } - - void track(String eventName, Map data) { - throw UnimplementedError('track has not been implemented.'); - } - - void flush() { - throw UnimplementedError('flush has not been implemented.'); - } - - Future getDouble(String key, double defaultValue) async { - throw UnimplementedError('getDouble() has not been implemented.'); - } - - Future> getObject(String key, Map defaultValue) async { - throw UnimplementedError('getObject() has not been implemented.'); - } - - Future getInt(String key, int defaultValue) async { - throw UnimplementedError('getInt() has not been implemented.'); - } - - Future> readAllFlags() { - throw UnimplementedError('readAllFlags() has not been implemented.'); - } -} diff --git a/lib/src/apply_client.dart b/lib/src/apply_client.dart new file mode 100644 index 0000000..59cb91f --- /dev/null +++ b/lib/src/apply_client.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'resolve_client.dart'; +import 'sdk_metadata.dart' as sdk_meta; + +class ApplyClient { + final http.Client _httpClient; + final String _clientSecret; + final ConfidenceRegion _region; + + ApplyClient({ + required http.Client httpClient, + required String clientSecret, + required ConfidenceRegion region, + }) : _httpClient = httpClient, + _clientSecret = clientSecret, + _region = region; + + Future sendApply({ + required String flagName, + required String resolveToken, + required DateTime applyTime, + }) async { + final url = Uri.parse('${_region.resolverBaseUrl}/v1/flags:apply'); + final now = DateTime.now().toUtc(); + + final body = jsonEncode({ + 'flags': [ + { + 'flag': 'flags/$flagName', + 'applyTime': applyTime.toUtc().toIso8601String(), + }, + ], + 'sendTime': now.toIso8601String(), + 'clientSecret': _clientSecret, + 'resolveToken': resolveToken, + 'sdk': sdk_meta.sdkInfo(), + }); + + final response = await _httpClient.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: body, + ); + + return response.statusCode == 200; + } +} diff --git a/lib/src/apply_manager.dart b/lib/src/apply_manager.dart new file mode 100644 index 0000000..c66d288 --- /dev/null +++ b/lib/src/apply_manager.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'apply_client.dart'; +import 'storage.dart'; + +class ApplyManager { + final Storage _storage; + final ApplyClient _applyClient; + final Set _appliedKeys = {}; + + static const _storageKey = 'confidence.apply.cache'; + + ApplyManager({ + required Storage storage, + required ApplyClient applyClient, + }) : _storage = storage, + _applyClient = applyClient; + + Future apply(String flagName, String resolveToken) async { + final key = '$resolveToken:$flagName'; + if (_appliedKeys.contains(key)) return; + + _appliedKeys.add(key); + + final applyTime = DateTime.now().toUtc(); + + try { + final success = await _applyClient.sendApply( + flagName: flagName, + resolveToken: resolveToken, + applyTime: applyTime, + ); + + if (!success) { + await _addPending(resolveToken, flagName); + } + } catch (_) { + await _addPending(resolveToken, flagName); + } + } + + Future restore() async { + final pending = await _loadPending(); + if (pending.isEmpty) return; + + for (final entry in pending.entries) { + final resolveToken = entry.key; + for (final flagName in entry.value) { + final key = '$resolveToken:$flagName'; + if (_appliedKeys.contains(key)) continue; + _appliedKeys.add(key); + + try { + final success = await _applyClient.sendApply( + flagName: flagName, + resolveToken: resolveToken, + applyTime: DateTime.now().toUtc(), + ); + if (success) { + await _removePending(resolveToken, flagName); + } + } catch (_) { + // Keep in pending for next retry + } + } + } + } + + Future _addPending(String resolveToken, String flagName) async { + final pending = await _loadPending(); + final flags = pending[resolveToken] ?? []; + if (!flags.contains(flagName)) { + flags.add(flagName); + } + pending[resolveToken] = flags; + await _storage.write(_storageKey, jsonEncode(pending)); + } + + Future _removePending(String resolveToken, String flagName) async { + final pending = await _loadPending(); + final flags = pending[resolveToken]; + if (flags != null) { + flags.remove(flagName); + if (flags.isEmpty) { + pending.remove(resolveToken); + } + } + await _storage.write(_storageKey, jsonEncode(pending)); + } + + Future>> _loadPending() async { + final stored = await _storage.read(_storageKey); + if (stored == null) return {}; + final json = jsonDecode(stored) as Map; + return json.map( + (k, v) => MapEntry(k, (v as List).cast()), + ); + } +} diff --git a/lib/src/confidence.dart b/lib/src/confidence.dart new file mode 100644 index 0000000..664d873 --- /dev/null +++ b/lib/src/confidence.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'apply_client.dart'; +import 'apply_manager.dart'; +import 'confidence_value.dart'; +import 'evaluation.dart'; +import 'events_client.dart'; +import 'flag_resolution.dart'; +import 'resolve_client.dart'; +import 'storage.dart'; + +class Confidence { + final _ConfidenceState _state; + final Confidence? _parent; + final Map _localContext; + final Set _removedKeys; + + Confidence._({ + required _ConfidenceState state, + Confidence? parent, + Map localContext = const {}, + Set removedKeys = const {}, + }) : _state = state, + _parent = parent, + _localContext = Map.of(localContext), + _removedKeys = Set.of(removedKeys); + + static ConfidenceBuilder builder({required String clientSecret}) => + ConfidenceBuilder._(clientSecret: clientSecret); + + // -- Flag lifecycle -- + + Future fetchAndActivate() async { + final contextSnapshot = getContext(); + await _state.asyncGate.run(() async { + final resolution = + await _state.resolveClient.resolve(contextSnapshot); + + // Stale response check: if context changed during the fetch, discard + if (!_contextEquals(contextSnapshot, getContext())) return; + + await _state.storage.write( + 'confidence.flags.resolve', + jsonEncode(resolution.toJson()), + ); + _state.currentResolution = resolution; + }); + } + + Future activate() async { + final stored = await _state.storage.read('confidence.flags.resolve'); + if (stored != null) { + final json = jsonDecode(stored) as Map; + _state.currentResolution = FlagResolution.fromJson(json); + } + } + + Future asyncFetch() async { + final contextSnapshot = getContext(); + final resolution = + await _state.resolveClient.resolve(contextSnapshot); + + if (!_contextEquals(contextSnapshot, getContext())) return; + + await _state.storage.write( + 'confidence.flags.resolve', + jsonEncode(resolution.toJson()), + ); + } + + Future activateAndFetchAsync() async { + await activate(); + asyncFetch().ignore(); + } + + // -- Flag evaluation -- + + T getValue(String flagPath, T defaultValue) => + getFlag(flagPath, defaultValue).value; + + Evaluation getFlag(String flagPath, T defaultValue) { + final resolution = _state.currentResolution; + if (resolution == null) { + return Evaluation( + value: defaultValue, + reason: ResolveReason.error, + errorCode: 'NOT_READY', + errorMessage: + 'No flag resolution available. Call fetchAndActivate() or activate() first.', + ); + } + + final eval = resolution.evaluate(flagPath, defaultValue); + + // Auto-apply: fire-and-forget when evaluation succeeds + if (eval.reason == ResolveReason.match) { + final flagName = flagPath.split('.')[0]; + final flag = resolution.flags.firstWhere( + (f) => f.flag == flagName, + orElse: () => throw StateError('unreachable'), + ); + if (flag.shouldApply && _state.applyManager != null) { + _state.applyManager! + .apply(flagName, resolution.resolveToken) + .ignore(); + } + } + + return eval; + } + + // -- Context management -- + + Map getContext() { + final parentContext = _parent?.getContext() ?? {}; + final merged = Map.from(parentContext) + ..addAll(_localContext); + for (final key in _removedKeys) { + merged.remove(key); + } + return merged; + } + + void putContext(String key, ConfidenceValue value) { + _localContext[key] = value; + _removedKeys.remove(key); + _triggerRefetch(); + } + + void putContextLocal(String key, ConfidenceValue value) { + _localContext[key] = value; + _removedKeys.remove(key); + } + + void removeContext(String key) { + _localContext.remove(key); + _removedKeys.add(key); + _triggerRefetch(); + } + + Confidence withContext(Map context) { + return Confidence._( + state: _state, + parent: this, + localContext: context, + ); + } + + // -- Events -- + + void track(String eventName, + [Map data = const {}]) { + _state.eventsClient?.send( + eventName: eventName, + payload: data, + context: getContext(), + ); + } + + void flush() { + // Best-effort: currently events are sent immediately, no buffering. + } + + // -- Internal -- + + void _triggerRefetch() { + fetchAndActivate().ignore(); + } + + bool _contextEquals( + Map a, + Map b, + ) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (b[entry.key] != entry.value) return false; + } + return true; + } +} + +class ConfidenceBuilder { + final String _clientSecret; + ConfidenceRegion _region = ConfidenceRegion.global; + Storage? _storage; + http.Client? _httpClient; + Map _initialContext = {}; + + ConfidenceBuilder._({required String clientSecret}) + : _clientSecret = clientSecret; + + ConfidenceBuilder region(ConfidenceRegion region) { + _region = region; + return this; + } + + ConfidenceBuilder storage(Storage storage) { + _storage = storage; + return this; + } + + ConfidenceBuilder httpClient(http.Client client) { + _httpClient = client; + return this; + } + + ConfidenceBuilder initialContext(Map context) { + _initialContext = context; + return this; + } + + Confidence build() { + final storage = _storage ?? MemoryStorage(); + final httpClient = _httpClient ?? http.Client(); + + final resolveClient = ResolveClient( + httpClient: httpClient, + clientSecret: _clientSecret, + region: _region, + ); + + final applyClient = ApplyClient( + httpClient: httpClient, + clientSecret: _clientSecret, + region: _region, + ); + + final applyManager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + final eventsClient = EventsClient( + httpClient: httpClient, + clientSecret: _clientSecret, + region: _region, + ); + + final state = _ConfidenceState( + storage: storage, + resolveClient: resolveClient, + applyManager: applyManager, + eventsClient: eventsClient, + ); + + return Confidence._( + state: state, + localContext: _initialContext, + ); + } +} + +class _ConfidenceState { + final Storage storage; + final ResolveClient resolveClient; + final ApplyManager? applyManager; + final EventsClient? eventsClient; + final _AsyncGate asyncGate = _AsyncGate(); + FlagResolution? currentResolution; + + _ConfidenceState({ + required this.storage, + required this.resolveClient, + this.applyManager, + this.eventsClient, + }); +} + +class _AsyncGate { + Completer? _pending; + + Future run(Future Function() operation) async { + while (_pending != null) { + await _pending!.future; + } + _pending = Completer(); + try { + await operation(); + } finally { + final p = _pending!; + _pending = null; + p.complete(); + } + } +} diff --git a/lib/src/confidence_value.dart b/lib/src/confidence_value.dart new file mode 100644 index 0000000..c8cfc01 --- /dev/null +++ b/lib/src/confidence_value.dart @@ -0,0 +1,197 @@ +sealed class ConfidenceValue { + const ConfidenceValue(); + + static ConfidenceValue boolean(bool value) => ConfidenceValueBoolean(value); + static ConfidenceValue string(String value) => ConfidenceValueString(value); + static ConfidenceValue integer(int value) => ConfidenceValueInteger(value); + static ConfidenceValue double_(double value) => ConfidenceValueDouble(value); + static ConfidenceValue date(DateTime value) => ConfidenceValueDate(value); + static ConfidenceValue timestamp(DateTime value) => + ConfidenceValueTimestamp(value); + static ConfidenceValue list(List value) => + ConfidenceValueList(value); + static ConfidenceValue structure(Map value) => + ConfidenceValueStructure(value); + static ConfidenceValue null_() => const ConfidenceValueNull(); + + dynamic toJson(); + + dynamic toPlainJson() => switch (this) { + ConfidenceValueBoolean(value: final v) => v, + ConfidenceValueString(value: final v) => v, + ConfidenceValueInteger(value: final v) => v, + ConfidenceValueDouble(value: final v) => v, + ConfidenceValueDate(value: final v) => v.toIso8601String().split('T')[0], + ConfidenceValueTimestamp(value: final v) => v.toUtc().toIso8601String(), + ConfidenceValueList(value: final v) => + v.map((e) => e.toPlainJson()).toList(), + ConfidenceValueStructure(value: final v) => + v.map((k, e) => MapEntry(k, e.toPlainJson())), + ConfidenceValueNull() => null, + }; + + static ConfidenceValue fromJson(dynamic json) { + if (json == null) return const ConfidenceValueNull(); + if (json is bool) return ConfidenceValueBoolean(json); + if (json is int) return ConfidenceValueInteger(json); + if (json is double) return ConfidenceValueDouble(json); + if (json is String) return ConfidenceValueString(json); + if (json is List) { + return ConfidenceValueList(json.map(ConfidenceValue.fromJson).toList()); + } + if (json is Map) { + return ConfidenceValueStructure( + json.map((k, v) => MapEntry(k, ConfidenceValue.fromJson(v))), + ); + } + return const ConfidenceValueNull(); + } + + static ConfidenceValue fromPlainJson(dynamic json) => fromJson(json); +} + +final class ConfidenceValueBoolean extends ConfidenceValue { + final bool value; + const ConfidenceValueBoolean(this.value); + + @override + dynamic toJson() => value; + + @override + bool operator ==(Object other) => + other is ConfidenceValueBoolean && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueString extends ConfidenceValue { + final String value; + const ConfidenceValueString(this.value); + + @override + dynamic toJson() => value; + + @override + bool operator ==(Object other) => + other is ConfidenceValueString && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueInteger extends ConfidenceValue { + final int value; + const ConfidenceValueInteger(this.value); + + @override + dynamic toJson() => value; + + @override + bool operator ==(Object other) => + other is ConfidenceValueInteger && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueDouble extends ConfidenceValue { + final double value; + const ConfidenceValueDouble(this.value); + + @override + dynamic toJson() => value; + + @override + bool operator ==(Object other) => + other is ConfidenceValueDouble && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueDate extends ConfidenceValue { + final DateTime value; + const ConfidenceValueDate(this.value); + + @override + dynamic toJson() => value.toIso8601String().split('T')[0]; + + @override + bool operator ==(Object other) => + other is ConfidenceValueDate && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueTimestamp extends ConfidenceValue { + final DateTime value; + const ConfidenceValueTimestamp(this.value); + + @override + dynamic toJson() => value.toUtc().toIso8601String(); + + @override + bool operator ==(Object other) => + other is ConfidenceValueTimestamp && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class ConfidenceValueList extends ConfidenceValue { + final List value; + const ConfidenceValueList(this.value); + + @override + dynamic toJson() => value.map((e) => e.toJson()).toList(); + + @override + bool operator ==(Object other) => + other is ConfidenceValueList && + value.length == other.value.length && + _listEquals(value, other.value); + + @override + int get hashCode => Object.hashAll(value); +} + +final class ConfidenceValueStructure extends ConfidenceValue { + final Map value; + const ConfidenceValueStructure(this.value); + + @override + dynamic toJson() => value.map((k, v) => MapEntry(k, v.toJson())); + + @override + bool operator ==(Object other) => + other is ConfidenceValueStructure && + value.length == other.value.length && + value.entries.every( + (e) => other.value.containsKey(e.key) && other.value[e.key] == e.value, + ); + + @override + int get hashCode => Object.hashAll(value.entries.map((e) => e.hashCode)); +} + +final class ConfidenceValueNull extends ConfidenceValue { + const ConfidenceValueNull(); + + @override + dynamic toJson() => null; + + @override + bool operator ==(Object other) => other is ConfidenceValueNull; + + @override + int get hashCode => 0; +} + +bool _listEquals(List a, List b) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/lib/src/evaluation.dart b/lib/src/evaluation.dart new file mode 100644 index 0000000..e85f63f --- /dev/null +++ b/lib/src/evaluation.dart @@ -0,0 +1,48 @@ +class Evaluation { + final T value; + final String? variant; + final ResolveReason reason; + final String? errorCode; + final String? errorMessage; + + const Evaluation({ + required this.value, + this.variant, + required this.reason, + this.errorCode, + this.errorMessage, + }); +} + +enum ResolveReason { + match, + unspecified, + noSegmentMatch, + noTreatmentMatch, + flagArchived, + targetingKeyError, + error, + stale; + + static ResolveReason fromString(String value) => switch (value) { + 'RESOLVE_REASON_MATCH' => ResolveReason.match, + 'RESOLVE_REASON_NO_SEGMENT_MATCH' => ResolveReason.noSegmentMatch, + 'RESOLVE_REASON_NO_TREATMENT_MATCH' => ResolveReason.noTreatmentMatch, + 'RESOLVE_REASON_FLAG_ARCHIVED' => ResolveReason.flagArchived, + 'RESOLVE_REASON_TARGETING_KEY_ERROR' => ResolveReason.targetingKeyError, + 'RESOLVE_REASON_ERROR' => ResolveReason.error, + 'RESOLVE_REASON_STALE' => ResolveReason.stale, + _ => ResolveReason.unspecified, + }; + + String toJson() => switch (this) { + ResolveReason.match => 'RESOLVE_REASON_MATCH', + ResolveReason.noSegmentMatch => 'RESOLVE_REASON_NO_SEGMENT_MATCH', + ResolveReason.noTreatmentMatch => 'RESOLVE_REASON_NO_TREATMENT_MATCH', + ResolveReason.flagArchived => 'RESOLVE_REASON_FLAG_ARCHIVED', + ResolveReason.targetingKeyError => 'RESOLVE_REASON_TARGETING_KEY_ERROR', + ResolveReason.error => 'RESOLVE_REASON_ERROR', + ResolveReason.stale => 'RESOLVE_REASON_STALE', + ResolveReason.unspecified => 'RESOLVE_REASON_UNSPECIFIED', + }; +} diff --git a/lib/src/events_client.dart b/lib/src/events_client.dart new file mode 100644 index 0000000..2c65954 --- /dev/null +++ b/lib/src/events_client.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'confidence_value.dart'; +import 'resolve_client.dart'; +import 'sdk_metadata.dart' as sdk_meta; + +class EventsClient { + final http.Client _httpClient; + final String _clientSecret; + final ConfidenceRegion _region; + + EventsClient({ + required http.Client httpClient, + required String clientSecret, + required ConfidenceRegion region, + }) : _httpClient = httpClient, + _clientSecret = clientSecret, + _region = region; + + Future send({ + required String eventName, + required Map payload, + Map context = const {}, + }) async { + final url = Uri.parse('${_region.eventsBaseUrl}/v1/events:publish'); + final now = DateTime.now().toUtc(); + + final plainPayload = + payload.map((k, v) => MapEntry(k, v.toPlainJson())); + + if (context.isNotEmpty) { + plainPayload['context'] = + context.map((k, v) => MapEntry(k, v.toPlainJson())); + } + + final body = jsonEncode({ + 'clientSecret': _clientSecret, + 'events': [ + { + 'eventDefinition': 'eventDefinitions/$eventName', + 'eventTime': now.toIso8601String(), + 'payload': plainPayload, + }, + ], + 'sendTime': now.toIso8601String(), + 'sdk': sdk_meta.sdkInfo(), + }); + + try { + await _httpClient.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: body, + ); + } catch (_) { + // Best-effort: swallow errors + } + } +} diff --git a/lib/src/flag_resolution.dart b/lib/src/flag_resolution.dart new file mode 100644 index 0000000..5db7557 --- /dev/null +++ b/lib/src/flag_resolution.dart @@ -0,0 +1,166 @@ +import 'confidence_value.dart'; +import 'evaluation.dart'; + +class ResolvedFlag { + final String flag; + final String variant; + final ConfidenceValueStructure? value; + final ResolveReason reason; + final bool shouldApply; + + const ResolvedFlag({ + required this.flag, + required this.variant, + required this.value, + required this.reason, + required this.shouldApply, + }); + + Map toJson() => { + 'flag': flag, + 'variant': variant, + 'value': value?.toJson(), + 'reason': reason.toJson(), + 'shouldApply': shouldApply, + }; + + factory ResolvedFlag.fromJson(Map json) { + final valueJson = json['value']; + ConfidenceValueStructure? value; + if (valueJson != null && valueJson is Map) { + value = ConfidenceValue.fromJson(valueJson) as ConfidenceValueStructure; + } + return ResolvedFlag( + flag: json['flag'] as String, + variant: json['variant'] as String? ?? '', + value: value, + reason: ResolveReason.fromString(json['reason'] as String? ?? ''), + shouldApply: json['shouldApply'] as bool? ?? true, + ); + } +} + +class FlagResolution { + final List flags; + final String resolveToken; + + const FlagResolution({ + required this.flags, + required this.resolveToken, + }); + + Evaluation evaluate(String flagPath, T defaultValue) { + final parts = flagPath.split('.'); + if (parts.length < 2) { + return Evaluation( + value: defaultValue, + reason: ResolveReason.error, + errorCode: 'INVALID_FLAG_PATH', + errorMessage: 'Flag path must contain at least flag name and property', + ); + } + + final flagName = parts[0]; + final propertyPath = parts.sublist(1); + + final resolvedFlag = _findFlag(flagName); + if (resolvedFlag == null) { + return Evaluation( + value: defaultValue, + reason: ResolveReason.error, + errorCode: 'FLAG_NOT_FOUND', + errorMessage: 'Flag "$flagName" not found', + ); + } + + if (resolvedFlag.value == null) { + return Evaluation( + value: defaultValue, + variant: resolvedFlag.variant, + reason: resolvedFlag.reason, + ); + } + + final extracted = _walkPath(resolvedFlag.value!, propertyPath); + if (extracted == null) { + return Evaluation( + value: defaultValue, + variant: resolvedFlag.variant, + reason: ResolveReason.error, + errorCode: 'VALUE_NOT_FOUND', + errorMessage: 'Property path "${propertyPath.join('.')}" not found', + ); + } + + final typed = _castValue(extracted); + if (typed == null) { + return Evaluation( + value: defaultValue, + variant: resolvedFlag.variant, + reason: ResolveReason.error, + errorCode: 'TYPE_MISMATCH', + errorMessage: + 'Expected $T but got ${extracted.runtimeType}', + ); + } + + return Evaluation( + value: typed, + variant: resolvedFlag.variant, + reason: resolvedFlag.reason, + ); + } + + ResolvedFlag? _findFlag(String flagName) { + for (final flag in flags) { + if (flag.flag == flagName) return flag; + } + return null; + } + + ConfidenceValue? _walkPath( + ConfidenceValueStructure struct, + List path, + ) { + ConfidenceValue current = struct; + for (final key in path) { + if (current is! ConfidenceValueStructure) return null; + final next = current.value[key]; + if (next == null) return null; + current = next; + } + return current; + } + + T? _castValue(ConfidenceValue value) { + if (T == String && value is ConfidenceValueString) { + return value.value as T; + } + if (T == int && value is ConfidenceValueInteger) { + return value.value as T; + } + if (T == bool && value is ConfidenceValueBoolean) { + return value.value as T; + } + if (T == double && value is ConfidenceValueDouble) { + return value.value as T; + } + return null; + } + + Map toJson() => { + 'resolvedFlags': flags.map((f) => f.toJson()).toList(), + 'resolveToken': resolveToken, + }; + + factory FlagResolution.fromJson(Map json) { + final flagsList = (json['resolvedFlags'] as List?) + ?.map((f) => ResolvedFlag.fromJson(f as Map)) + .toList() ?? + []; + return FlagResolution( + flags: flagsList, + resolveToken: json['resolveToken'] as String? ?? '', + ); + } +} diff --git a/lib/src/flutter/confidence_flutter.dart b/lib/src/flutter/confidence_flutter.dart new file mode 100644 index 0000000..236f6f0 --- /dev/null +++ b/lib/src/flutter/confidence_flutter.dart @@ -0,0 +1,36 @@ +import '../confidence.dart'; +import '../confidence_value.dart'; +import '../resolve_client.dart'; +import 'device_context.dart'; +import 'flutter_storage.dart'; +import 'visitor_id.dart'; + +class ConfidenceFlutter { + ConfidenceFlutter._(); + + static Future create({ + required String clientSecret, + ConfidenceRegion region = ConfidenceRegion.global, + Map initialContext = const {}, + }) async { + final storage = await FlutterStorage.create(); + final visitorIdManager = VisitorIdManager(); + final deviceContextProvider = DeviceContextProvider(); + + final visitorContext = await visitorIdManager.asContext(); + final deviceContext = await deviceContextProvider.getDeviceContext(); + + // Merge: device context < visitor ID < user-provided context + final mergedContext = { + ...deviceContext, + ...visitorContext, + ...initialContext, + }; + + return Confidence.builder(clientSecret: clientSecret) + .region(region) + .storage(storage) + .initialContext(mergedContext) + .build(); + } +} diff --git a/lib/src/flutter/device_context.dart b/lib/src/flutter/device_context.dart new file mode 100644 index 0000000..c20b887 --- /dev/null +++ b/lib/src/flutter/device_context.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../confidence_value.dart'; + +class DeviceContextProvider { + Future> getDeviceContext() async { + final context = {}; + + try { + final packageInfo = await PackageInfo.fromPlatform(); + context['app'] = ConfidenceValue.structure({ + 'version': ConfidenceValue.string(packageInfo.version), + 'build': ConfidenceValue.string(packageInfo.buildNumber), + 'package': ConfidenceValue.string(packageInfo.packageName), + }); + } catch (_) {} + + try { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await deviceInfo.androidInfo; + context['device'] = ConfidenceValue.structure({ + 'manufacturer': ConfidenceValue.string(android.manufacturer), + 'model': ConfidenceValue.string(android.model), + 'type': ConfidenceValue.string('android'), + }); + context['os'] = ConfidenceValue.structure({ + 'name': ConfidenceValue.string('android'), + 'version': ConfidenceValue.string(android.version.release), + }); + } else if (Platform.isIOS) { + final ios = await deviceInfo.iosInfo; + context['device'] = ConfidenceValue.structure({ + 'manufacturer': ConfidenceValue.string('Apple'), + 'model': ConfidenceValue.string(ios.model), + 'type': ConfidenceValue.string('ios'), + }); + context['os'] = ConfidenceValue.structure({ + 'name': ConfidenceValue.string('ios'), + 'version': ConfidenceValue.string(ios.systemVersion), + }); + } + } catch (_) {} + + try { + context['locale'] = ConfidenceValue.string(Platform.localeName); + } catch (_) {} + + return context; + } +} diff --git a/lib/src/flutter/flutter_storage.dart b/lib/src/flutter/flutter_storage.dart new file mode 100644 index 0000000..f573c14 --- /dev/null +++ b/lib/src/flutter/flutter_storage.dart @@ -0,0 +1,12 @@ +import 'package:path_provider/path_provider.dart'; + +import '../storage.dart'; + +class FlutterStorage { + FlutterStorage._(); + + static Future create() async { + final dir = await getApplicationSupportDirectory(); + return DiskStorage('${dir.path}/confidence'); + } +} diff --git a/lib/src/flutter/visitor_id.dart b/lib/src/flutter/visitor_id.dart new file mode 100644 index 0000000..fe2ac0e --- /dev/null +++ b/lib/src/flutter/visitor_id.dart @@ -0,0 +1,27 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../confidence_value.dart'; + +class VisitorIdManager { + static const _key = 'confidence.visitor_id'; + String? _cachedId; + + Future getOrCreate() async { + if (_cachedId != null) return _cachedId!; + + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_key); + if (id == null) { + id = const Uuid().v4(); + await prefs.setString(_key, id); + } + _cachedId = id; + return id; + } + + Future> asContext() async { + final id = await getOrCreate(); + return {'visitor_id': ConfidenceValue.string(id)}; + } +} diff --git a/lib/src/legacy_api.dart b/lib/src/legacy_api.dart new file mode 100644 index 0000000..8af6143 --- /dev/null +++ b/lib/src/legacy_api.dart @@ -0,0 +1,15 @@ +import 'confidence.dart'; + +extension ConfidenceLegacyApi on Confidence { + bool getBool(String key, bool defaultValue) => + getValue(key, defaultValue); + + String getString(String key, String defaultValue) => + getValue(key, defaultValue); + + int getInt(String key, int defaultValue) => + getValue(key, defaultValue); + + double getDouble(String key, double defaultValue) => + getValue(key, defaultValue); +} diff --git a/lib/src/resolve_client.dart b/lib/src/resolve_client.dart new file mode 100644 index 0000000..0f9d2df --- /dev/null +++ b/lib/src/resolve_client.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'confidence_value.dart'; +import 'flag_resolution.dart'; +import 'evaluation.dart'; +import 'sdk_metadata.dart' as sdk_meta; + +enum ConfidenceRegion { + global, + eu, + us; + + String get resolverBaseUrl => switch (this) { + ConfidenceRegion.global => 'https://resolver.confidence.dev', + ConfidenceRegion.eu => 'https://resolver.eu.confidence.dev', + ConfidenceRegion.us => 'https://resolver.us.confidence.dev', + }; + + String get eventsBaseUrl => switch (this) { + ConfidenceRegion.global => 'https://events.confidence.dev', + ConfidenceRegion.eu => 'https://events.eu.confidence.dev', + ConfidenceRegion.us => 'https://events.us.confidence.dev', + }; +} + +class ResolveClient { + final http.Client _httpClient; + final String _clientSecret; + final ConfidenceRegion _region; + + ResolveClient({ + required http.Client httpClient, + required String clientSecret, + required ConfidenceRegion region, + }) : _httpClient = httpClient, + _clientSecret = clientSecret, + _region = region; + + Future resolve( + Map context, + ) async { + final url = Uri.parse('${_region.resolverBaseUrl}/v1/flags:resolve'); + + final plainContext = + context.map((k, v) => MapEntry(k, v.toPlainJson())); + + final body = jsonEncode({ + 'flags': [], + 'evaluationContext': plainContext, + 'clientSecret': _clientSecret, + 'apply': false, + 'sdk': sdk_meta.sdkInfo(), + }); + + final response = await _httpClient.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: body, + ); + + if (response.statusCode != 200) { + throw ResolveException( + 'Resolve failed with status ${response.statusCode}: ${response.body}', + ); + } + + final json = jsonDecode(response.body) as Map; + return _parseResponse(json); + } + + FlagResolution _parseResponse(Map json) { + final resolvedFlags = (json['resolvedFlags'] as List?) + ?.map((f) => _parseResolvedFlag(f as Map)) + .toList() ?? + []; + + return FlagResolution( + flags: resolvedFlags, + resolveToken: json['resolveToken'] as String? ?? '', + ); + } + + ResolvedFlag _parseResolvedFlag(Map json) { + final rawFlag = json['flag'] as String? ?? ''; + final flagName = + rawFlag.startsWith('flags/') ? rawFlag.substring(6) : rawFlag; + + final valueJson = json['value']; + ConfidenceValueStructure? value; + if (valueJson != null && valueJson is Map) { + value = ConfidenceValue.fromJson(valueJson) as ConfidenceValueStructure; + } + + return ResolvedFlag( + flag: flagName, + variant: json['variant'] as String? ?? '', + value: value, + reason: ResolveReason.fromString(json['reason'] as String? ?? ''), + shouldApply: json['shouldApply'] as bool? ?? true, + ); + } +} + +class ResolveException implements Exception { + final String message; + ResolveException(this.message); + + @override + String toString() => 'ResolveException: $message'; +} diff --git a/lib/src/sdk_metadata.dart b/lib/src/sdk_metadata.dart new file mode 100644 index 0000000..ce98844 --- /dev/null +++ b/lib/src/sdk_metadata.dart @@ -0,0 +1,7 @@ +const sdkId = 'SDK_ID_DART_CONFIDENCE'; +const sdkVersion = '1.0.0'; // x-release-please-version + +Map sdkInfo() => { + 'id': sdkId, + 'version': sdkVersion, +}; diff --git a/lib/src/storage.dart b/lib/src/storage.dart new file mode 100644 index 0000000..15d0908 --- /dev/null +++ b/lib/src/storage.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +abstract class Storage { + Future read(String key); + Future write(String key, String data); + Future delete(String key); + Future exists(String key); +} + +class MemoryStorage implements Storage { + final Map _store = {}; + + @override + Future read(String key) async => _store[key]; + + @override + Future write(String key, String data) async => _store[key] = data; + + @override + Future delete(String key) async => _store.remove(key); + + @override + Future exists(String key) async => _store.containsKey(key); +} + +class DiskStorage implements Storage { + final String _directoryPath; + + DiskStorage(this._directoryPath); + + String _filePath(String key) => + '$_directoryPath/${key.replaceAll('/', '_')}'; + + @override + Future read(String key) async { + final file = File(_filePath(key)); + if (!await file.exists()) return null; + return file.readAsString(); + } + + @override + Future write(String key, String data) async { + final file = File(_filePath(key)); + await file.parent.create(recursive: true); + await file.writeAsString(data); + } + + @override + Future delete(String key) async { + final file = File(_filePath(key)); + if (await file.exists()) { + await file.delete(); + } + } + + @override + Future exists(String key) async => File(_filePath(key)).exists(); +} diff --git a/plans/native-dart-sdk-plan.md b/plans/native-dart-sdk-plan.md new file mode 100644 index 0000000..0d64cd6 --- /dev/null +++ b/plans/native-dart-sdk-plan.md @@ -0,0 +1,348 @@ +# Native Dart SDK for Confidence Flutter — Implementation Plan + +## Context + +The current `confidence_flutter_sdk` (v0.2.1) is a thin method-channel bridge. The Dart layer (`lib/`) is three files totaling ~160 lines — just an in-memory flag cache, type wrappers for platform serialization, and `unawaited()` calls to native for apply tracking. All real logic lives in the Kotlin Android SDK (`confidence-sdk-android:0.6.2`) and the Swift SDK (git submodule at `ios/Classes/confidence-sdk`). + +This means: iOS+Android only, two native codebases to maintain, a fragile git-submodule CI dance (copy Swift sources, delete submodule, build), and no Dart-side tests. The backend is pure REST/JSON, so a Dart-native implementation is fully feasible and eliminates all of this. + +**Goal**: Replace the native bridge with a pure Dart implementation covering the core features of both native SDKs. The SDK becomes testable in pure Dart, and platform support expands to web/desktop for free. The native SDKs (Android/iOS) remain alongside during development so integration tests can validate behavioral parity. + +--- + +## Architecture: Single Package + +Keep the existing `confidence_flutter_sdk` package rather than splitting into two. The pure-Dart core lives cleanly in `lib/src/` with zero Flutter imports; it can be extracted into its own package later if needed. The Flutter-specific pieces (directory path resolution, device info, visitor ID) live in `lib/src/flutter/`. + +``` +lib/ + confidence_flutter_sdk.dart # barrel export (public API) + src/ + confidence.dart # Confidence class + builder + confidence_value.dart # Sealed ConfidenceValue type + evaluation.dart # Evaluation + ResolveReason + flag_resolution.dart # FlagResolution model + cache + resolve_client.dart # HTTP POST /v1/flags:resolve + apply_manager.dart # Exposure tracking (pull-send-restore) + apply_client.dart # HTTP POST /v1/flags:apply + events_client.dart # HTTP POST /v1/events:publish (best-effort) + storage.dart # Storage interface + MemoryStorage + DiskStorage + sdk_metadata.dart # SDK ID/version + telemetry header + flutter/ + flutter_storage.dart # path_provider directory resolution + device_context.dart # device_info_plus enrichment + visitor_id.dart # shared_preferences UUID + confidence_flutter.dart # Factory that wires Flutter deps +``` + +### Dependency Flow + +```mermaid +graph TD + A[confidence_flutter_sdk.dart
barrel export] --> B[Confidence
main class + builder] + B --> C[ResolveClient] + B --> D[ApplyManager] + B --> E[EventsClient
best-effort send] + B --> F[FlagResolution
cache layer] + D --> G[ApplyClient] + C & G & E --> I["package:http"] + C & G & E --> T[SdkMetadata
+ telemetry header] + F & D --> J[Storage interface] + J --> K[MemoryStorage] + J --> L["DiskStorage
(dart:io File)"] + B --> M[ConfidenceValue
sealed type] + N[ConfidenceFlutter
factory] --> B + N --> PP[path_provider
dir resolution] + PP --> L + N --> O[DeviceContext
device_info_plus] + N --> P[VisitorId
shared_preferences] +``` + +### Storage: Pure Dart Disk I/O + +`DiskStorage` uses `dart:io` `File` directly — pure Dart, no platform channel needed. Works on mobile, desktop, and server. The only Flutter-specific piece is resolving the correct directory path via `path_provider` (done once in `ConfidenceFlutter.create()`), which passes the path into `DiskStorage(directoryPath)`. Web needs a separate adapter in the future, but is out of scope. + +--- + +## Public API + +### Primary API (aligned with native SDKs) + +```dart +class Confidence { + // Construction + static ConfidenceBuilder builder({required String clientSecret}); + + // Flag lifecycle + Future fetchAndActivate(); + Future activate(); + Future asyncFetch(); + + // Flag evaluation (generic) + T getValue(String flagPath, T defaultValue); + Evaluation getFlag(String flagPath, T defaultValue); + + // Context + void putContext(String key, ConfidenceValue value); + void putContextLocal(String key, ConfidenceValue value); // no re-fetch + void removeContext(String key); + Confidence withContext(Map context); // child instance + Map getContext(); + + // Events + void track(String eventName, [Map data]); + void flush(); +} +``` + +### Builder + +```dart +Confidence.builder(clientSecret: 'xxx') + .region(ConfidenceRegion.eu) + .loggerLevel(LoggingLevel.warn) + .initialContext({'targeting_key': ConfidenceValue.string('user-123')}) + .storage(myCustomStorage) // DI for testing + .build(); +``` + +### Flutter convenience factory + +```dart +final confidence = await ConfidenceFlutter.create( + clientSecret: 'xxx', + region: ConfidenceRegion.eu, +); +await confidence.fetchAndActivate(); +``` + +Wires up DiskStorage (via path_provider), VisitorIdManager, and DeviceContextProvider automatically. + +### Legacy compat (extension methods) + +```dart +extension ConfidenceLegacyApi on Confidence { + bool getBool(String key, bool defaultValue) => getValue(key, defaultValue); + String getString(String key, String defaultValue) => getValue(key, defaultValue); + int getInt(String key, int defaultValue) => getValue(key, defaultValue); + double getDouble(String key, double defaultValue) => getValue(key, defaultValue); +} +``` + +--- + +## Type System + +### ConfidenceValue (sealed class) + +```dart +sealed class ConfidenceValue { + static ConfidenceValue boolean(bool value); + static ConfidenceValue string(String value); + static ConfidenceValue integer(int value); + static ConfidenceValue double_(double value); + static ConfidenceValue date(DateTime value); + static ConfidenceValue timestamp(DateTime value); + static ConfidenceValue list(List value); + static ConfidenceValue structure(Map value); + static ConfidenceValue null_(); +} +``` + +### Evaluation + +```dart +class Evaluation { + final T value; + final String? variant; + final ResolveReason reason; + final String? errorCode; + final String? errorMessage; +} + +enum ResolveReason { + match, unspecified, noSegmentMatch, noTreatmentMatch, + flagArchived, targetingKeyError, error, stale, +} +``` + +--- + +## Concurrency Model + +Dart is single-threaded with cooperative async. No locks needed, but we need guards: + +- **Flag fetching**: `AsyncGate` (Completer-based) prevents duplicate concurrent resolve requests. If `putContext()` triggers a re-fetch while one is in-flight, the second waits then starts. +- **Apply pipeline**: Simple guard prevents overlapping batch uploads. +- **Stale response discarding**: If context changed during an in-flight resolve, discard the response (matches Android SDK behavior). + +--- + +## Implementation Phases + +Each phase produces a working, testable increment. + +### Phase 0: Test Suite Foundation + +Before writing any implementation, create the test suite by studying the Android and Swift SDK test suites. This ensures test-driven development and behavioral parity. + +**Study the native SDK tests:** +- Android: `confidence-sdk-android` test suite (resolve response parsing, value evaluation, apply behavior, context management) +- Swift: `confidence-sdk-swift` test suite (same areas) +- Extract the key test scenarios and expected behaviors + +**Files to create:** +- `test/confidence_value_test.dart` — JSON serialization round-trips, type coercion, nested structure handling +- `test/flag_resolution_test.dart` — Dot-path evaluation (`"my-flag.color.hex"`), type mismatch returns default, missing flag returns default, schema validation +- `test/resolve_client_test.dart` — Request format matches wire spec, response parsing, error responses (404, 500), region URL selection +- `test/apply_manager_test.dart` — Dedup (same flag not sent twice per resolve token), restore-on-failure, `shouldApply: false` skips apply +- `test/events_client_test.dart` — Event serialization, context merging into payload +- `test/confidence_test.dart` — `fetchAndActivate()` flow, `activate()` + `asyncFetch()` flow, context changes trigger re-fetch, `getValue()` type resolution, stale response discarding +- `test/storage_test.dart` — MemoryStorage and DiskStorage CRUD operations + +All tests use mocked `http.Client`. Tests will initially fail (no implementation); each subsequent phase makes them pass. + +--- + +### Phase 1: Core Types + Flag Resolution + +Build the type system and flag resolution — the foundation everything else depends on. + +**Files to create:** +- `lib/src/confidence_value.dart` — Sealed class hierarchy with subtypes for each value kind. Convenience constructors on sealed parent. `toJson()`/`fromJson()` mapping to the backend's protobuf-JSON Struct format. +- `lib/src/evaluation.dart` — `Evaluation` with `value`, `variant`, `reason` (enum), `errorCode`, `errorMessage`. +- `lib/src/flag_resolution.dart` — `FlagResolution` model: `resolvedFlags` list, `resolveToken`. `ResolvedFlag` with `flag`, `variant`, `value`, `flagSchema`, `reason`, `shouldApply`. Dot-path evaluation method (port from existing `resolveKey` at `confidence_flutter_sdk.dart:39-55`, but typed with `ConfidenceValue`). JSON serialization for disk persistence. +- `lib/src/sdk_metadata.dart` — SDK ID (`SDK_ID_DART_CONFIDENCE`), version string. Builds the `X-CONFIDENCE-TELEMETRY` header included on every HTTP request. +- `lib/src/resolve_client.dart` — Takes `http.Client`, base URL, client secret. `resolve(flags, context, sdk)` POSTs to `/v1/flags:resolve`, returns `FlagResolution`. Region enum: `global` -> `https://resolver.confidence.dev`, `eu` -> `https://resolver.eu.confidence.dev`. +- `lib/src/storage.dart` — `Storage` abstract class (`read(key)`, `write(key, data)`, `delete(key)`, `exists(key)`). `MemoryStorage` (in-memory map). `DiskStorage` (takes directory path, stores each key as a file using `dart:io` `File`). +- `lib/src/confidence.dart` — `Confidence` class with builder. Phase 1 scope: construction, `fetchAndActivate()`, `getValue()`, `getFlag()`, `putContext()`, `putContextLocal()` (no re-fetch), `removeContext()`, `getContext()`, `withContext()` (child instance with parent context chain). Two-layer cache: "current" (active, read from) and "latest" (just fetched). `fetchAndActivate()` fetches then swaps latest->current. + +**Passes:** Phase 0 tests for confidence_value, flag_resolution, resolve_client, storage, and the basic confidence flow tests. + +--- + +### Phase 2: Apply Mechanism + +Add exposure tracking — tells the backend which flags were actually evaluated. + +**Files to create:** +- `lib/src/apply_client.dart` — POSTs to `/v1/flags:apply` with flag name, apply time, resolve token. +- `lib/src/apply_manager.dart` — Simple approach: tracks applied flags in storage as a set per resolve token. On `getValue()`/`getFlag()`, if `shouldApply` is true and flag hasn't been applied, add to pending set, send immediately. On failure, keep in pending set for retry on next evaluation. No state machine — just pull pending from storage, attempt send, restore on failure. + +**Files to modify:** +- `lib/src/confidence.dart` — Add `activate()` (swap cache without fetching), `asyncFetch()` (fetch in background), `activateAndFetchAsync()` convenience. Wire `ApplyManager` into `getValue()`/`getFlag()`. Add `AsyncGate` (Completer-based) to prevent duplicate concurrent resolve requests. Add stale-response discarding: if context changes during an in-flight resolve, discard the response. + +**Passes:** Phase 0 apply_manager tests. + +--- + +### Phase 3: Event Tracking + +Best-effort event sending — no buffering, no persistence. Send immediately. + +**Files to create:** +- `lib/src/events_client.dart` — POSTs to `/v1/events:publish`. Best-effort: fire-and-forget, log errors. Events endpoint URLs: `https://events.confidence.dev` (global) / `https://events.eu.confidence.dev` (EU). Each event includes: `eventDefinition` name, `eventTime`, `payload` (merged with current context), `sendTime`. Future iteration may add retry with backoff/jitter. + +**Files to modify:** +- `lib/src/confidence.dart` — Add `track(eventName, [data])` and `flush()`. `track()` builds the event payload by merging `data` with current context and sends via `EventsClient`. + +**Passes:** Phase 0 events_client tests. + +--- + +### Phase 4: Flutter Integration + SDK Telemetry + +Wire up Flutter-specific platform features and the telemetry header. + +**Files to create:** +- `lib/src/flutter/flutter_storage.dart` — Resolves the app documents directory via `path_provider`, creates a `DiskStorage` pointed at `{documentsDir}/confidence/`. +- `lib/src/flutter/visitor_id.dart` — Generates UUID v4 on first launch, persists via `shared_preferences`, provides `targeting_key` to context if not already set. +- `lib/src/flutter/device_context.dart` — Uses `device_info_plus` + `package_info_plus` to build context map: `os.name`, `os.version`, `device.manufacturer`, `device.model`, `app.version`, `app.build`. Enriches context on resolve and event calls. +- `lib/src/flutter/confidence_flutter.dart` — `ConfidenceFlutter.create(clientSecret, region, ...)` async factory that wires `DiskStorage` (via flutter_storage), `VisitorIdManager`, `DeviceContextProvider` into the builder. + +**Files to modify:** +- `lib/confidence_flutter_sdk.dart` — Rewrite as barrel export: `Confidence`, `ConfidenceFlutter`, `ConfidenceValue`, `Evaluation`, `ConfidenceRegion`, `LoggingLevel`, plus legacy compat extensions. + +**Passes:** Device context enrichment tests, visitor ID persistence/reuse tests, DiskStorage tests. + +--- + +### Phase 5: Migration Compat + Validation + +Bridge the old API for existing users. Keep native SDKs alongside and validate parity. + +**Backward compat** — Extension methods on `Confidence`: +```dart +extension ConfidenceLegacyApi on Confidence { + bool getBool(String key, bool defaultValue) => getValue(key, defaultValue); + String getString(String key, String defaultValue) => getValue(key, defaultValue); + int getInt(String key, int defaultValue) => getValue(key, defaultValue); + double getDouble(String key, double defaultValue) => getValue(key, defaultValue); +} +``` + +**Do NOT delete native code yet.** Keep `android/`, `ios/`, method channel files, and platform interface alongside the new Dart implementation. This allows: +- Running integration tests against both native and Dart paths +- Validating behavioral parity on real devices +- A safer rollout (native can be the fallback) + +The native code removal happens in a follow-up PR after parity is confirmed. + +**Files to modify:** +- `pubspec.yaml` — Add new deps (`http`, `uuid`, `path_provider`, `device_info_plus`, `package_info_plus`, `shared_preferences`) alongside existing ones. Add dev deps: `mockito`, `build_runner`. Keep the `plugin` section intact for now. +- `example/lib/main.dart` — Add a second code path using the new Dart API alongside the existing native path, so both can be compared. + +**CI changes** (`.github/workflows/`): +- `ci.yaml` — Add a `flutter test` step for the new Dart unit tests. Keep existing native build steps. +- Keep `android-test.yaml` and `ios-test.yaml` unchanged — they validate the native path still works. + +--- + +## Dependencies (final pubspec additions) + +```yaml +# Add to existing dependencies: + http: ^1.2.0 + uuid: ^4.0.0 + path_provider: ^2.1.0 + device_info_plus: ^10.0.0 + package_info_plus: ^8.0.0 + shared_preferences: ^2.2.0 + +# Add to dev_dependencies: + mockito: ^5.4.0 + build_runner: ^2.4.0 +``` + +--- + +## Wire Format Reference + +Three REST endpoints: +- **Resolve**: `POST {resolver}/v1/flags:resolve` — sends `flags`, `evaluationContext`, `clientSecret`, `apply: false`, `sdk`; returns `resolvedFlags` with `flag`, `variant`, `value`, `flagSchema`, `reason`, `shouldApply`, plus `resolveToken` +- **Apply**: `POST {resolver}/v1/flags:apply` — sends `flags` (with `applyTime`), `sendTime`, `clientSecret`, `resolveToken`, `sdk` +- **Events**: `POST {events}/v1/events:publish` — sends `clientSecret`, `events` (with `eventDefinition`, `eventTime`, `payload` including `context`), `sendTime`, `sdk` + +All requests include `X-CONFIDENCE-TELEMETRY` header with protobuf-encoded SDK metadata. + +Region determines base URLs: +- Global: resolver `https://resolver.confidence.dev`, events `https://events.confidence.dev` +- EU: resolver `https://resolver.eu.confidence.dev`, events `https://events.eu.confidence.dev` + +--- + +## Deferred / Future + +- **OpenFeature provider** — separate package (when there's demand) +- **Event persistence/retry** — disk-buffered event pipeline with flush policies (currently best-effort) +- **Screen tracking** — Flutter NavigatorObserver equivalent of iOS's UIViewController swizzling +- **Web storage backend** — `localStorage`/`IndexedDB` adapter + +--- + +## Verification + +1. **Unit tests (Phase 0 onward)** — mock `http.Client`, verify request/response serialization, cache behavior, apply tracking, event sending, context merging. Run with `flutter test`. +2. **Integration tests (kept from native)** — existing `example/test/widget_test.dart` runs against the real Confidence backend on Android emulator and iOS simulator via the existing CI workflows. These validate the native path still works. +3. **Parity validation** — example app exercises both native and Dart paths side-by-side, comparing results for the same flag/context combinations. +4. **Platform smoke** — build example app for Android and iOS to confirm Flutter-specific pieces work. Web/desktop builds verify the core Dart code compiles. diff --git a/pubspec.yaml b/pubspec.yaml index 0b703d1..5089335 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,10 +3,6 @@ description: "Flutter implementation of the Confidence SDK." version: 0.2.1 homepage: https://confidence.spotify.com -platforms: - android: - ios: - environment: sdk: '>=3.4.3 <4.0.0' flutter: '>=3.3.0' @@ -14,32 +10,16 @@ environment: dependencies: flutter: sdk: flutter - plugin_platform_interface: ^2.0.2 + http: ^1.2.0 + uuid: ^4.0.0 + path_provider: ^2.1.0 + device_info_plus: ^10.0.0 + package_info_plus: ^8.0.0 + shared_preferences: ^2.2.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) - # which should be registered in the plugin registry. This is required for - # using method channels. - # The Android 'package' specifies package in which the registered class is. - # This is required for using method channels on Android. - # The 'ffiPlugin' specifies that native code should be built and bundled. - # This is required for using `dart:ffi`. - # All these are used by the tooling to maintain consistency when - # adding or updating assets for this project. - plugin: - platforms: - android: - package: com.example.confidence_flutter_sdk - pluginClass: ConfidenceFlutterSdkPlugin - ios: - pluginClass: ConfidenceFlutterSdkPlugin + mockito: ^5.4.0 + build_runner: ^2.4.0 diff --git a/test/apply_manager_test.dart b/test/apply_manager_test.dart new file mode 100644 index 0000000..68db1d2 --- /dev/null +++ b/test/apply_manager_test.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:confidence_flutter_sdk/src/apply_manager.dart'; +import 'package:confidence_flutter_sdk/src/apply_client.dart'; +import 'package:confidence_flutter_sdk/src/resolve_client.dart'; +import 'package:confidence_flutter_sdk/src/storage.dart'; + +void main() { + group('ApplyManager', () { + late MemoryStorage storage; + late List capturedRequests; + + http.Client makeApplyClient({int statusCode = 200}) { + return MockClient((request) async { + capturedRequests.add(request); + return http.Response('{}', statusCode); + }); + } + + setUp(() { + storage = MemoryStorage(); + capturedRequests = []; + }); + + test('sends apply request for a new flag', () async { + final applyClient = ApplyClient( + httpClient: makeApplyClient(), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('my-flag', 'token-123'); + + expect(capturedRequests, hasLength(1)); + final body = + jsonDecode(capturedRequests[0].body) as Map; + expect(body['resolveToken'], equals('token-123')); + final flags = body['flags'] as List; + expect(flags, hasLength(1)); + expect(flags[0]['flag'], equals('flags/my-flag')); + expect(flags[0]['applyTime'], isNotNull); + }); + + test('deduplicates same flag + resolve token', () async { + final applyClient = ApplyClient( + httpClient: makeApplyClient(), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('my-flag', 'token-123'); + await manager.apply('my-flag', 'token-123'); + + expect(capturedRequests, hasLength(1)); + }); + + test('sends separate requests for different flags', () async { + final applyClient = ApplyClient( + httpClient: makeApplyClient(), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('flag-a', 'token-123'); + await manager.apply('flag-b', 'token-123'); + + expect(capturedRequests, hasLength(2)); + }); + + test('sends separate requests for different resolve tokens', () async { + final applyClient = ApplyClient( + httpClient: makeApplyClient(), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('my-flag', 'token-1'); + await manager.apply('my-flag', 'token-2'); + + expect(capturedRequests, hasLength(2)); + }); + + test('retains pending applies on failure for later retry', () async { + var callCount = 0; + final failThenSucceed = MockClient((request) async { + capturedRequests.add(request); + callCount++; + if (callCount == 1) { + return http.Response('Server Error', 500); + } + return http.Response('{}', 200); + }); + + final applyClient = ApplyClient( + httpClient: failThenSucceed, + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('my-flag', 'token-123'); + // First call failed, pending set should still contain the apply. + // A second apply with different flag should trigger retry of pending. + await manager.apply('other-flag', 'token-123'); + + expect(capturedRequests.length, greaterThanOrEqualTo(2)); + }); + + test('persists pending applies to storage', () async { + final applyClient = ApplyClient( + httpClient: makeApplyClient(statusCode: 500), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.apply('my-flag', 'token-123'); + + final stored = await storage.read('confidence.apply.cache'); + expect(stored, isNotNull); + }); + + test('restores pending applies from storage on creation', () async { + // Pre-populate storage with pending applies + final pendingData = jsonEncode({ + 'token-123': ['flag-a'], + }); + await storage.write('confidence.apply.cache', pendingData); + + final applyClient = ApplyClient( + httpClient: makeApplyClient(), + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + final manager = ApplyManager( + storage: storage, + applyClient: applyClient, + ); + + await manager.restore(); + + expect(capturedRequests, hasLength(1)); + final body = + jsonDecode(capturedRequests[0].body) as Map; + expect(body['resolveToken'], equals('token-123')); + }); + }); + + group('ApplyClient', () { + test('sends to correct global URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response('{}', 200); + }); + + final client = ApplyClient( + httpClient: mockClient, + clientSecret: 'test-secret', + region: ConfidenceRegion.global, + ); + + await client.sendApply( + flagName: 'my-flag', + resolveToken: 'token', + applyTime: DateTime.utc(2026, 6, 5, 10, 0, 0), + ); + + expect( + capturedUrl.toString(), + equals('https://resolver.confidence.dev/v1/flags:apply'), + ); + }); + + test('sends to correct EU URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response('{}', 200); + }); + + final client = ApplyClient( + httpClient: mockClient, + clientSecret: 'test-secret', + region: ConfidenceRegion.eu, + ); + + await client.sendApply( + flagName: 'my-flag', + resolveToken: 'token', + applyTime: DateTime.utc(2026, 6, 5, 10, 0, 0), + ); + + expect( + capturedUrl.toString(), + equals('https://resolver.eu.confidence.dev/v1/flags:apply'), + ); + }); + }); +} + diff --git a/test/confidence_test.dart b/test/confidence_test.dart new file mode 100644 index 0000000..546563f --- /dev/null +++ b/test/confidence_test.dart @@ -0,0 +1,433 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:confidence_flutter_sdk/src/confidence.dart'; +import 'package:confidence_flutter_sdk/src/confidence_value.dart'; +import 'package:confidence_flutter_sdk/src/evaluation.dart'; +import 'package:confidence_flutter_sdk/src/storage.dart'; + +Map makeResolveResponse({ + List>? flags, + String resolveToken = 'token-abc', +}) { + return { + 'resolvedFlags': flags ?? [ + { + 'flag': 'flags/my-flag', + 'variant': 'flags/my-flag/variants/treatment', + 'value': { + 'color': 'red', + 'size': 42, + 'enabled': true, + 'nested': {'deep': 'value'}, + }, + 'flagSchema': { + 'schema': { + 'color': {'stringSchema': {}}, + 'size': {'intSchema': {}}, + 'enabled': {'boolSchema': {}}, + 'nested': { + 'structSchema': { + 'schema': { + 'deep': {'stringSchema': {}}, + }, + }, + }, + }, + }, + 'reason': 'RESOLVE_REASON_MATCH', + 'shouldApply': true, + }, + ], + 'resolveToken': resolveToken, + }; +} + +void main() { + group('Confidence', () { + test('fetchAndActivate resolves and caches flags', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + expect( + confidence.getValue('my-flag.color', 'default'), + equals('red'), + ); + }); + + test('getValue returns default before fetchAndActivate', () { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + expect( + confidence.getValue('my-flag.color', 'default'), + equals('default'), + ); + }); + + test('getValue evaluates different types', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + expect(confidence.getValue('my-flag.color', ''), equals('red')); + expect(confidence.getValue('my-flag.size', 0), equals(42)); + expect(confidence.getValue('my-flag.enabled', false), isTrue); + }); + + test('getValue evaluates nested properties', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + expect( + confidence.getValue('my-flag.nested.deep', 'default'), + equals('value'), + ); + }); + + test('getValue returns default for non-existent flag', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + expect( + confidence.getValue('nonexistent.color', 'default'), + equals('default'), + ); + }); + + test('getValue returns default on type mismatch', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + // color is a string, not an int + expect(confidence.getValue('my-flag.color', 99), equals(99)); + }); + + test('getFlag returns full evaluation', () async { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + + final eval = confidence.getFlag('my-flag.color', 'default'); + expect(eval.value, equals('red')); + expect(eval.variant, equals('flags/my-flag/variants/treatment')); + expect(eval.reason, equals(ResolveReason.match)); + }); + }); + + group('Confidence context management', () { + test('putContext triggers re-fetch', () async { + var fetchCount = 0; + final mockClient = MockClient((_) async { + fetchCount++; + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + expect(fetchCount, equals(1)); + + confidence.putContext('user_id', ConfidenceValue.string('new-user')); + // Allow async fetch to complete + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(fetchCount, greaterThan(1)); + }); + + test('putContextLocal does not trigger re-fetch', () async { + var fetchCount = 0; + final mockClient = MockClient((_) async { + fetchCount++; + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + await confidence.fetchAndActivate(); + expect(fetchCount, equals(1)); + + confidence.putContextLocal('user_id', ConfidenceValue.string('new-user')); + await Future.delayed(Duration.zero); + + expect(fetchCount, equals(1)); + }); + + test('getContext returns current context', () { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .initialContext({ + 'targeting_key': ConfidenceValue.string('user-123'), + }) + .build(); + + final context = confidence.getContext(); + expect(context['targeting_key'], isA()); + expect( + (context['targeting_key'] as ConfidenceValueString).value, + equals('user-123'), + ); + }); + + test('removeContext removes key and triggers re-fetch', () async { + var fetchCount = 0; + final mockClient = MockClient((_) async { + fetchCount++; + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .initialContext({ + 'targeting_key': ConfidenceValue.string('user-123'), + 'country': ConfidenceValue.string('SE'), + }) + .build(); + + await confidence.fetchAndActivate(); + + confidence.removeContext('country'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final context = confidence.getContext(); + expect(context.containsKey('country'), isFalse); + expect(fetchCount, greaterThan(1)); + }); + + test('withContext creates child with merged context', () { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final parent = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .initialContext({ + 'targeting_key': ConfidenceValue.string('user-123'), + }) + .build(); + + final child = parent.withContext({ + 'page': ConfidenceValue.string('home'), + }); + + final childContext = child.getContext(); + expect(childContext.containsKey('targeting_key'), isTrue); + expect(childContext.containsKey('page'), isTrue); + }); + + test('child context overrides parent context', () { + final mockClient = MockClient((_) async { + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final parent = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .initialContext({ + 'color': ConfidenceValue.string('red'), + }) + .build(); + + final child = parent.withContext({ + 'color': ConfidenceValue.string('blue'), + }); + + final context = child.getContext(); + expect( + (context['color'] as ConfidenceValueString).value, + equals('blue'), + ); + }); + }); + + group('Confidence activate and fetch strategies', () { + test('activate loads from storage without fetching', () async { + final storage = MemoryStorage(); + // Pre-populate storage in the format that fetchAndActivate() would write + // (flag names WITHOUT the 'flags/' prefix, since ResolveClient strips it) + final storedResolution = { + 'resolvedFlags': [ + { + 'flag': 'cached-flag', + 'variant': 'flags/cached-flag/variants/v1', + 'value': {'msg': 'cached'}, + 'reason': 'RESOLVE_REASON_MATCH', + 'shouldApply': true, + }, + ], + 'resolveToken': 'cached-token', + }; + await storage.write( + 'confidence.flags.resolve', + jsonEncode(storedResolution), + ); + + var fetchCount = 0; + final mockClient = MockClient((_) async { + fetchCount++; + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(storage) + .build(); + + await confidence.activate(); + + expect(fetchCount, equals(0)); + expect( + confidence.getValue('cached-flag.msg', 'default'), + equals('cached'), + ); + }); + + test('activateAndFetchAsync activates cache then fetches in background', () async { + final storage = MemoryStorage(); + final storedResolution = { + 'resolvedFlags': [ + { + 'flag': 'cached-flag', + 'variant': 'flags/cached-flag/variants/v1', + 'value': {'msg': 'cached'}, + 'reason': 'RESOLVE_REASON_MATCH', + 'shouldApply': true, + }, + ], + 'resolveToken': 'cached-token', + }; + await storage.write( + 'confidence.flags.resolve', + jsonEncode(storedResolution), + ); + + var resolveCount = 0; + final mockClient = MockClient((request) async { + if (request.url.path.contains('resolve')) resolveCount++; + return http.Response(jsonEncode(makeResolveResponse()), 200); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(storage) + .build(); + + await confidence.activateAndFetchAsync(); + + // Should have activated cached values immediately + expect( + confidence.getValue('cached-flag.msg', 'default'), + equals('cached'), + ); + + // Background fetch should have started — pump the event loop + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(resolveCount, equals(1)); + }); + }); + + group('Confidence stale response handling', () { + test('discards response if context changed during fetch', () async { + var resolveCallCount = 0; + final mockClient = MockClient((_) async { + resolveCallCount++; + // Simulate slow network for first call + if (resolveCallCount == 1) { + await Future.delayed(const Duration(milliseconds: 50)); + } + return http.Response( + jsonEncode(makeResolveResponse( + resolveToken: 'token-$resolveCallCount', + )), + 200, + ); + }); + + final confidence = Confidence.builder(clientSecret: 'test-secret') + .httpClient(mockClient) + .storage(MemoryStorage()) + .build(); + + // Start fetch, then immediately change context + final fetchFuture = confidence.fetchAndActivate(); + confidence.putContextLocal('user', ConfidenceValue.string('changed')); + + await fetchFuture; + // The fetch that was in-flight when context changed should be + // either discarded or a new fetch should have been triggered. + // The exact behavior depends on implementation details. + expect(resolveCallCount, greaterThanOrEqualTo(1)); + }); + }); +} diff --git a/test/confidence_value_test.dart b/test/confidence_value_test.dart new file mode 100644 index 0000000..9988452 --- /dev/null +++ b/test/confidence_value_test.dart @@ -0,0 +1,251 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:confidence_flutter_sdk/src/confidence_value.dart'; + +void main() { + group('ConfidenceValue constructors', () { + test('boolean value', () { + final value = ConfidenceValue.boolean(true); + expect(value, isA()); + expect((value as ConfidenceValueBoolean).value, isTrue); + }); + + test('string value', () { + final value = ConfidenceValue.string('hello'); + expect(value, isA()); + expect((value as ConfidenceValueString).value, equals('hello')); + }); + + test('integer value', () { + final value = ConfidenceValue.integer(42); + expect(value, isA()); + expect((value as ConfidenceValueInteger).value, equals(42)); + }); + + test('double value', () { + final value = ConfidenceValue.double_(3.14); + expect(value, isA()); + expect((value as ConfidenceValueDouble).value, equals(3.14)); + }); + + test('null value', () { + final value = ConfidenceValue.null_(); + expect(value, isA()); + }); + + test('list value', () { + final value = ConfidenceValue.list([ + ConfidenceValue.string('a'), + ConfidenceValue.integer(1), + ]); + expect(value, isA()); + final list = (value as ConfidenceValueList).value; + expect(list, hasLength(2)); + expect(list[0], isA()); + expect(list[1], isA()); + }); + + test('structure value', () { + final value = ConfidenceValue.structure({ + 'name': ConfidenceValue.string('test'), + 'count': ConfidenceValue.integer(5), + }); + expect(value, isA()); + final struct = (value as ConfidenceValueStructure).value; + expect(struct['name'], isA()); + expect(struct['count'], isA()); + }); + + test('deeply nested structure', () { + final value = ConfidenceValue.structure({ + 'outer': ConfidenceValue.structure({ + 'inner': ConfidenceValue.structure({ + 'deep': ConfidenceValue.string('found'), + }), + }), + }); + expect(value, isA()); + final outer = + (value as ConfidenceValueStructure).value['outer'] + as ConfidenceValueStructure; + final inner = outer.value['inner'] as ConfidenceValueStructure; + final deep = inner.value['deep'] as ConfidenceValueString; + expect(deep.value, equals('found')); + }); + }); + + group('ConfidenceValue JSON serialization', () { + test('boolean round-trips through JSON', () { + final original = ConfidenceValue.boolean(true); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect(restored, isA()); + expect((restored as ConfidenceValueBoolean).value, isTrue); + }); + + test('string round-trips through JSON', () { + final original = ConfidenceValue.string('hello'); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect((restored as ConfidenceValueString).value, equals('hello')); + }); + + test('integer round-trips through JSON', () { + final original = ConfidenceValue.integer(42); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect((restored as ConfidenceValueInteger).value, equals(42)); + }); + + test('double round-trips through JSON', () { + final original = ConfidenceValue.double_(3.14); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect((restored as ConfidenceValueDouble).value, equals(3.14)); + }); + + test('null round-trips through JSON', () { + final original = ConfidenceValue.null_(); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect(restored, isA()); + }); + + test('list round-trips through JSON', () { + final original = ConfidenceValue.list([ + ConfidenceValue.string('a'), + ConfidenceValue.integer(1), + ConfidenceValue.boolean(false), + ]); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect(restored, isA()); + final list = (restored as ConfidenceValueList).value; + expect(list, hasLength(3)); + expect((list[0] as ConfidenceValueString).value, equals('a')); + expect((list[1] as ConfidenceValueInteger).value, equals(1)); + expect((list[2] as ConfidenceValueBoolean).value, isFalse); + }); + + test('structure round-trips through JSON', () { + final original = ConfidenceValue.structure({ + 'color': ConfidenceValue.string('red'), + 'size': ConfidenceValue.integer(42), + 'enabled': ConfidenceValue.boolean(true), + }); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect(restored, isA()); + final struct = (restored as ConfidenceValueStructure).value; + expect( + (struct['color'] as ConfidenceValueString).value, + equals('red'), + ); + expect((struct['size'] as ConfidenceValueInteger).value, equals(42)); + expect((struct['enabled'] as ConfidenceValueBoolean).value, isTrue); + }); + + test('nested structure round-trips through JSON', () { + final original = ConfidenceValue.structure({ + 'outer': ConfidenceValue.structure({ + 'inner': ConfidenceValue.string('deep'), + }), + 'list': ConfidenceValue.list([ + ConfidenceValue.structure({ + 'item': ConfidenceValue.integer(1), + }), + ]), + }); + final json = original.toJson(); + final restored = ConfidenceValue.fromJson(json); + expect(restored, isA()); + final struct = (restored as ConfidenceValueStructure).value; + final outer = struct['outer'] as ConfidenceValueStructure; + expect( + (outer.value['inner'] as ConfidenceValueString).value, + equals('deep'), + ); + }); + }); + + group('ConfidenceValue equality', () { + test('same boolean values are equal', () { + expect(ConfidenceValue.boolean(true), equals(ConfidenceValue.boolean(true))); + }); + + test('different boolean values are not equal', () { + expect( + ConfidenceValue.boolean(true), + isNot(equals(ConfidenceValue.boolean(false))), + ); + }); + + test('same string values are equal', () { + expect( + ConfidenceValue.string('hello'), + equals(ConfidenceValue.string('hello')), + ); + }); + + test('null values are equal', () { + expect(ConfidenceValue.null_(), equals(ConfidenceValue.null_())); + }); + }); + + group('ConfidenceValue toPlainJson', () { + test('converts primitives to plain JSON', () { + expect(ConfidenceValue.string('hello').toPlainJson(), equals('hello')); + expect(ConfidenceValue.integer(42).toPlainJson(), equals(42)); + expect(ConfidenceValue.double_(3.14).toPlainJson(), equals(3.14)); + expect(ConfidenceValue.boolean(true).toPlainJson(), equals(true)); + expect(ConfidenceValue.null_().toPlainJson(), isNull); + }); + + test('converts structure to plain JSON map', () { + final value = ConfidenceValue.structure({ + 'name': ConfidenceValue.string('test'), + 'count': ConfidenceValue.integer(5), + }); + final plain = value.toPlainJson(); + expect(plain, isA>()); + expect((plain as Map)['name'], equals('test')); + expect(plain['count'], equals(5)); + }); + + test('converts list to plain JSON list', () { + final value = ConfidenceValue.list([ + ConfidenceValue.string('a'), + ConfidenceValue.integer(1), + ]); + final plain = value.toPlainJson(); + expect(plain, isA()); + expect((plain as List)[0], equals('a')); + expect(plain[1], equals(1)); + }); + }); + + group('ConfidenceValue fromPlainJson', () { + test('converts plain JSON primitives', () { + expect( + ConfidenceValue.fromPlainJson('hello'), + isA(), + ); + expect(ConfidenceValue.fromPlainJson(42), isA()); + expect(ConfidenceValue.fromPlainJson(3.14), isA()); + expect(ConfidenceValue.fromPlainJson(true), isA()); + expect(ConfidenceValue.fromPlainJson(null), isA()); + }); + + test('converts plain JSON map to structure', () { + final value = ConfidenceValue.fromPlainJson({ + 'name': 'test', + 'count': 5, + }); + expect(value, isA()); + }); + + test('converts plain JSON list', () { + final value = ConfidenceValue.fromPlainJson(['a', 1, true]); + expect(value, isA()); + }); + }); +} diff --git a/test/events_client_test.dart b/test/events_client_test.dart new file mode 100644 index 0000000..ca0ce07 --- /dev/null +++ b/test/events_client_test.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:confidence_flutter_sdk/src/confidence_value.dart'; +import 'package:confidence_flutter_sdk/src/events_client.dart'; +import 'package:confidence_flutter_sdk/src/resolve_client.dart'; + +void main() { + group('EventsClient', () { + const clientSecret = 'test-secret'; + + test('sends event to correct global URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response('{}', 200); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.send( + eventName: 'purchase', + payload: {}, + ); + + expect( + capturedUrl.toString(), + equals('https://events.confidence.dev/v1/events:publish'), + ); + }); + + test('sends event to correct EU URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response('{}', 200); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.eu, + ); + + await client.send( + eventName: 'purchase', + payload: {}, + ); + + expect( + capturedUrl.toString(), + equals('https://events.eu.confidence.dev/v1/events:publish'), + ); + }); + + test('sends correct request format', () async { + late Map capturedBody; + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response('{}', 200); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.send( + eventName: 'purchase', + payload: { + 'amount': ConfidenceValue.double_(99.99), + 'currency': ConfidenceValue.string('USD'), + }, + ); + + expect(capturedBody['clientSecret'], equals(clientSecret)); + expect(capturedBody['sendTime'], isNotNull); + expect(capturedBody['sdk'], isA()); + + final events = capturedBody['events'] as List; + expect(events, hasLength(1)); + + final event = events[0] as Map; + expect( + event['eventDefinition'], + equals('eventDefinitions/purchase'), + ); + expect(event['eventTime'], isNotNull); + expect(event['payload'], isA()); + expect(event['payload']['amount'], equals(99.99)); + expect(event['payload']['currency'], equals('USD')); + }); + + test('merges context into payload', () async { + late Map capturedBody; + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response('{}', 200); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.send( + eventName: 'navigate', + payload: { + 'screen': ConfidenceValue.string('home'), + }, + context: { + 'targeting_key': ConfidenceValue.string('user-123'), + 'country': ConfidenceValue.string('SE'), + }, + ); + + final events = capturedBody['events'] as List; + final payload = events[0]['payload'] as Map; + expect(payload['screen'], equals('home')); + expect(payload['context'], isA()); + expect( + (payload['context'] as Map)['targeting_key'], + equals('user-123'), + ); + }); + + test('does not throw on HTTP 500 (best-effort)', () async { + final mockClient = MockClient((_) async { + return http.Response('Server Error', 500); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + // Should not throw — best-effort fire-and-forget + await client.send( + eventName: 'purchase', + payload: {}, + ); + }); + + test('sends event with empty payload', () async { + late Map capturedBody; + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response('{}', 200); + }); + + final client = EventsClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.send(eventName: 'page_view', payload: {}); + + final events = capturedBody['events'] as List; + expect(events[0]['payload'], isA()); + }); + }); +} + diff --git a/test/flag_resolution_test.dart b/test/flag_resolution_test.dart new file mode 100644 index 0000000..ba22e62 --- /dev/null +++ b/test/flag_resolution_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:confidence_flutter_sdk/src/confidence_value.dart'; +import 'package:confidence_flutter_sdk/src/evaluation.dart'; +import 'package:confidence_flutter_sdk/src/flag_resolution.dart'; + +void main() { + late FlagResolution resolution; + + setUp(() { + resolution = FlagResolution( + flags: [ + ResolvedFlag( + flag: 'my-flag', + variant: 'flags/my-flag/variants/treatment', + value: ConfidenceValue.structure({ + 'color': ConfidenceValue.string('red'), + 'size': ConfidenceValue.integer(42), + 'enabled': ConfidenceValue.boolean(true), + 'rate': ConfidenceValue.double_(0.5), + 'nested': ConfidenceValue.structure({ + 'deep': ConfidenceValue.string('found'), + 'level': ConfidenceValue.integer(3), + }), + }) as ConfidenceValueStructure, + reason: ResolveReason.match, + shouldApply: true, + ), + ResolvedFlag( + flag: 'other-flag', + variant: 'flags/other-flag/variants/control', + value: ConfidenceValue.structure({ + 'message': ConfidenceValue.string('hello'), + }) as ConfidenceValueStructure, + reason: ResolveReason.match, + shouldApply: true, + ), + ResolvedFlag( + flag: 'no-match-flag', + variant: '', + value: null, + reason: ResolveReason.noSegmentMatch, + shouldApply: false, + ), + ], + resolveToken: 'test-token-123', + ); + }); + + group('dot-path evaluation', () { + test('evaluates top-level string property', () { + final eval = resolution.evaluate('my-flag.color', 'default'); + expect(eval.value, equals('red')); + expect(eval.variant, equals('flags/my-flag/variants/treatment')); + expect(eval.reason, equals(ResolveReason.match)); + }); + + test('evaluates top-level integer property', () { + final eval = resolution.evaluate('my-flag.size', 0); + expect(eval.value, equals(42)); + }); + + test('evaluates top-level boolean property', () { + final eval = resolution.evaluate('my-flag.enabled', false); + expect(eval.value, isTrue); + }); + + test('evaluates top-level double property', () { + final eval = resolution.evaluate('my-flag.rate', 0.0); + expect(eval.value, equals(0.5)); + }); + + test('evaluates nested property with dot notation', () { + final eval = resolution.evaluate('my-flag.nested.deep', 'default'); + expect(eval.value, equals('found')); + }); + + test('evaluates nested integer property', () { + final eval = resolution.evaluate('my-flag.nested.level', 0); + expect(eval.value, equals(3)); + }); + + test('returns default for non-existent flag', () { + final eval = resolution.evaluate('nonexistent.color', 'default'); + expect(eval.value, equals('default')); + expect(eval.reason, equals(ResolveReason.error)); + }); + + test('returns default for non-existent property', () { + final eval = resolution.evaluate( + 'my-flag.nonexistent', + 'default', + ); + expect(eval.value, equals('default')); + expect(eval.reason, equals(ResolveReason.error)); + }); + + test('returns default for non-existent nested property', () { + final eval = resolution.evaluate( + 'my-flag.nested.nonexistent', + 'default', + ); + expect(eval.value, equals('default')); + }); + + test('returns default on type mismatch', () { + final eval = resolution.evaluate('my-flag.color', 99); + expect(eval.value, equals(99)); + expect(eval.reason, equals(ResolveReason.error)); + }); + + test('evaluates flag with no segment match', () { + final eval = resolution.evaluate( + 'no-match-flag.something', + 'default', + ); + expect(eval.value, equals('default')); + expect(eval.reason, equals(ResolveReason.noSegmentMatch)); + }); + + test('evaluates different flag', () { + final eval = resolution.evaluate( + 'other-flag.message', + 'default', + ); + expect(eval.value, equals('hello')); + }); + + test('flag path with only flag name returns default', () { + final eval = resolution.evaluate('my-flag', 'default'); + expect(eval.value, equals('default')); + }); + }); + + group('JSON serialization', () { + test('round-trips through JSON', () { + final json = resolution.toJson(); + final restored = FlagResolution.fromJson(json); + + expect(restored.resolveToken, equals('test-token-123')); + expect(restored.flags, hasLength(3)); + expect(restored.flags[0].flag, equals('my-flag')); + expect(restored.flags[0].reason, equals(ResolveReason.match)); + expect(restored.flags[0].shouldApply, isTrue); + }); + + test('preserves flag values through JSON', () { + final json = resolution.toJson(); + final restored = FlagResolution.fromJson(json); + + final eval = restored.evaluate('my-flag.color', 'default'); + expect(eval.value, equals('red')); + }); + + test('preserves nested values through JSON', () { + final json = resolution.toJson(); + final restored = FlagResolution.fromJson(json); + + final eval = restored.evaluate( + 'my-flag.nested.deep', + 'default', + ); + expect(eval.value, equals('found')); + }); + }); + + group('empty resolution', () { + test('empty resolution returns defaults', () { + final empty = FlagResolution(flags: [], resolveToken: ''); + final eval = empty.evaluate('any-flag.prop', 'default'); + expect(eval.value, equals('default')); + expect(eval.reason, equals(ResolveReason.error)); + }); + }); +} diff --git a/test/resolve_client_test.dart b/test/resolve_client_test.dart new file mode 100644 index 0000000..11ea8af --- /dev/null +++ b/test/resolve_client_test.dart @@ -0,0 +1,306 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:confidence_flutter_sdk/src/confidence_value.dart'; +import 'package:confidence_flutter_sdk/src/resolve_client.dart'; +import 'package:confidence_flutter_sdk/src/evaluation.dart'; + +void main() { + group('ResolveClient', () { + const clientSecret = 'test-secret'; + + Map makeResolveResponse({ + List>? flags, + String resolveToken = 'token-abc', + }) { + return { + 'resolvedFlags': flags ?? [ + { + 'flag': 'flags/my-flag', + 'variant': 'flags/my-flag/variants/treatment', + 'value': {'color': 'red', 'size': 42}, + 'flagSchema': { + 'schema': { + 'color': {'stringSchema': {}}, + 'size': {'intSchema': {}}, + }, + }, + 'reason': 'RESOLVE_REASON_MATCH', + 'shouldApply': true, + }, + ], + 'resolveToken': resolveToken, + }; + } + + test('sends correct request format', () async { + late http.Request capturedRequest; + final mockClient = MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.resolve({ + 'targeting_key': ConfidenceValue.string('user-123'), + 'country': ConfidenceValue.string('SE'), + }); + + expect(capturedRequest.method, equals('POST')); + expect( + capturedRequest.url.toString(), + equals('https://resolver.confidence.dev/v1/flags:resolve'), + ); + expect( + capturedRequest.headers['Content-Type'], + equals('application/json'), + ); + + final body = jsonDecode(capturedRequest.body) as Map; + expect(body['clientSecret'], equals(clientSecret)); + expect(body['apply'], isFalse); + expect(body['evaluationContext'], isA()); + expect(body['sdk'], isA()); + }); + + test('parses resolve response correctly', () async { + final mockClient = MockClient((_) async { + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + final resolution = await client.resolve({}); + + expect(resolution.resolveToken, equals('token-abc')); + expect(resolution.flags, hasLength(1)); + expect(resolution.flags[0].flag, equals('my-flag')); + expect( + resolution.flags[0].variant, + equals('flags/my-flag/variants/treatment'), + ); + expect(resolution.flags[0].reason, equals(ResolveReason.match)); + expect(resolution.flags[0].shouldApply, isTrue); + }); + + test('parses flag values from response', () async { + final mockClient = MockClient((_) async { + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + final resolution = await client.resolve({}); + final eval = resolution.evaluate('my-flag.color', 'default'); + expect(eval.value, equals('red')); + }); + + test('uses EU region URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.eu, + ); + + await client.resolve({}); + expect( + capturedUrl.toString(), + equals('https://resolver.eu.confidence.dev/v1/flags:resolve'), + ); + }); + + test('uses US region URL', () async { + late Uri capturedUrl; + final mockClient = MockClient((request) async { + capturedUrl = request.url; + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.us, + ); + + await client.resolve({}); + expect( + capturedUrl.toString(), + equals('https://resolver.us.confidence.dev/v1/flags:resolve'), + ); + }); + + test('throws on HTTP 500 error', () async { + final mockClient = MockClient((_) async { + return http.Response('Internal Server Error', 500); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + expect( + () => client.resolve({}), + throwsException, + ); + }); + + test('throws on HTTP 404 error', () async { + final mockClient = MockClient((_) async { + return http.Response('Not Found', 404); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + expect( + () => client.resolve({}), + throwsException, + ); + }); + + test('handles response with multiple flags', () async { + final mockClient = MockClient((_) async { + return http.Response( + jsonEncode(makeResolveResponse( + flags: [ + { + 'flag': 'flags/flag-a', + 'variant': 'flags/flag-a/variants/v1', + 'value': {'key': 'value-a'}, + 'flagSchema': {'schema': {'key': {'stringSchema': {}}}}, + 'reason': 'RESOLVE_REASON_MATCH', + 'shouldApply': true, + }, + { + 'flag': 'flags/flag-b', + 'variant': 'flags/flag-b/variants/v2', + 'value': {'key': 'value-b'}, + 'flagSchema': {'schema': {'key': {'stringSchema': {}}}}, + 'reason': 'RESOLVE_REASON_NO_SEGMENT_MATCH', + 'shouldApply': false, + }, + ], + )), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + final resolution = await client.resolve({}); + expect(resolution.flags, hasLength(2)); + expect(resolution.flags[0].flag, equals('flag-a')); + expect(resolution.flags[1].flag, equals('flag-b')); + expect( + resolution.flags[1].reason, + equals(ResolveReason.noSegmentMatch), + ); + }); + + test('handles response with no segment match (null value)', () async { + final mockClient = MockClient((_) async { + return http.Response( + jsonEncode(makeResolveResponse( + flags: [ + { + 'flag': 'flags/my-flag', + 'variant': '', + 'value': null, + 'reason': 'RESOLVE_REASON_NO_SEGMENT_MATCH', + 'shouldApply': false, + }, + ], + )), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + final resolution = await client.resolve({}); + expect(resolution.flags[0].value, isNull); + expect( + resolution.flags[0].reason, + equals(ResolveReason.noSegmentMatch), + ); + }); + + test('sends context values in evaluation context', () async { + late Map capturedBody; + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(makeResolveResponse()), + 200, + ); + }); + + final client = ResolveClient( + httpClient: mockClient, + clientSecret: clientSecret, + region: ConfidenceRegion.global, + ); + + await client.resolve({ + 'targeting_key': ConfidenceValue.string('user-abc'), + 'country': ConfidenceValue.string('SE'), + 'age': ConfidenceValue.integer(30), + }); + + final context = + capturedBody['evaluationContext'] as Map; + expect(context['targeting_key'], equals('user-abc')); + expect(context['country'], equals('SE')); + expect(context['age'], equals(30)); + }); + }); +} diff --git a/test/storage_test.dart b/test/storage_test.dart new file mode 100644 index 0000000..505c73a --- /dev/null +++ b/test/storage_test.dart @@ -0,0 +1,135 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:confidence_flutter_sdk/src/storage.dart'; + +void main() { + group('MemoryStorage', () { + late MemoryStorage storage; + + setUp(() { + storage = MemoryStorage(); + }); + + test('read returns null for non-existent key', () async { + final result = await storage.read('nonexistent'); + expect(result, isNull); + }); + + test('write and read round-trip', () async { + await storage.write('key1', 'value1'); + final result = await storage.read('key1'); + expect(result, equals('value1')); + }); + + test('write overwrites existing value', () async { + await storage.write('key1', 'value1'); + await storage.write('key1', 'value2'); + final result = await storage.read('key1'); + expect(result, equals('value2')); + }); + + test('delete removes the key', () async { + await storage.write('key1', 'value1'); + await storage.delete('key1'); + final result = await storage.read('key1'); + expect(result, isNull); + }); + + test('delete non-existent key does not throw', () async { + await storage.delete('nonexistent'); + }); + + test('exists returns false for non-existent key', () async { + final result = await storage.exists('nonexistent'); + expect(result, isFalse); + }); + + test('exists returns true after write', () async { + await storage.write('key1', 'value1'); + final result = await storage.exists('key1'); + expect(result, isTrue); + }); + + test('exists returns false after delete', () async { + await storage.write('key1', 'value1'); + await storage.delete('key1'); + final result = await storage.exists('key1'); + expect(result, isFalse); + }); + + test('multiple keys are independent', () async { + await storage.write('key1', 'value1'); + await storage.write('key2', 'value2'); + expect(await storage.read('key1'), equals('value1')); + expect(await storage.read('key2'), equals('value2')); + await storage.delete('key1'); + expect(await storage.read('key1'), isNull); + expect(await storage.read('key2'), equals('value2')); + }); + }); + + group('DiskStorage', () { + late Directory tempDir; + late DiskStorage storage; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('confidence_test_'); + storage = DiskStorage(tempDir.path); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('read returns null for non-existent key', () async { + final result = await storage.read('nonexistent'); + expect(result, isNull); + }); + + test('write and read round-trip', () async { + await storage.write('flags', '{"data": "test"}'); + final result = await storage.read('flags'); + expect(result, equals('{"data": "test"}')); + }); + + test('write creates the directory if needed', () async { + final nested = DiskStorage('${tempDir.path}/sub/dir'); + await nested.write('key', 'value'); + final result = await nested.read('key'); + expect(result, equals('value')); + }); + + test('write overwrites existing value', () async { + await storage.write('key', 'v1'); + await storage.write('key', 'v2'); + expect(await storage.read('key'), equals('v2')); + }); + + test('delete removes the file', () async { + await storage.write('key', 'value'); + await storage.delete('key'); + expect(await storage.read('key'), isNull); + }); + + test('delete non-existent key does not throw', () async { + await storage.delete('nonexistent'); + }); + + test('exists returns correct values', () async { + expect(await storage.exists('key'), isFalse); + await storage.write('key', 'value'); + expect(await storage.exists('key'), isTrue); + await storage.delete('key'); + expect(await storage.exists('key'), isFalse); + }); + + test('handles special characters in data', () async { + final data = '{"emoji": "\\u{1F600}", "newline": "line1\\nline2"}'; + await storage.write('special', data); + expect(await storage.read('special'), equals(data)); + }); + }); +}