Skip to content

Commit f41c48c

Browse files
feat: Android OpenFeature Provider (#227)
* feat: initial OpenFeature Provider interface * feat: add new Provider files * feat: add openfeature example app * chore: log * feat: fix tests * fix: OpenFeature test mocks and add JSON assertions * chore: change key name * feat: add openfeature provider classes to proguard * chore: revert logLevel name change * docs: add README.md to /openfeature folder * chore: revert back to withLogLevel() * chore: cleanup DevCycleContextMapper/kt * fix: remove as Nubmber castings * feat: cleanup DevCycleContextMapper.kt * feat: add event mapper class and tests * feat: add tests for json value conversion * feat: support eval reasons in OpenFeature provider * feat: update event mapper unwrapValue * feat: add support for and in ProviderEvaluation.metadata * feat: only set metadata if we have value
1 parent 65dedd6 commit f41c48c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2630
-3
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ all around the world.
66

77
## Requirements
88

9-
This version of the DevCycle Android Client SDK supports a minimum Android API Version 21.
9+
This version of the DevCycle Android Client SDK supports a minimum Android API Version 23.
1010

1111
## Installation
1212

@@ -20,6 +20,32 @@ implementation("com.devcycle:android-client-sdk:2.5.0")
2020

2121
To find usage documentation, visit out [docs](https://docs.devcycle.com/docs/sdk/client-side-sdks/android#usage).
2222

23+
## OpenFeature Provider
24+
25+
The DevCycle Android SDK includes support for [OpenFeature](https://openfeature.dev/), a vendor-agnostic feature flag API. This allows you to use DevCycle with the OpenFeature SDK for standardized feature flag evaluation.
26+
27+
### Basic Usage
28+
29+
```kotlin
30+
import com.devcycle.sdk.android.openfeature.DevCycleProvider
31+
import dev.openfeature.sdk.OpenFeatureAPI
32+
33+
// Initialize the DevCycle provider
34+
val provider = DevCycleProvider(
35+
sdkKey = "<DEVCYCLE_MOBILE_SDK_KEY>",
36+
context = applicationContext
37+
)
38+
39+
// Set the provider with OpenFeature
40+
OpenFeatureAPI.setProviderAndWait(provider)
41+
42+
// Use OpenFeature client for flag evaluation
43+
val client = OpenFeatureAPI.getClient()
44+
val flagValue = client.getBooleanValue("my-feature-flag", false)
45+
```
46+
47+
The provider automatically handles context mapping between OpenFeature's `EvaluationContext` and DevCycle's user model, supporting standard attributes like `email`, `name`, `country`, and custom data.
48+
2349
## Running the included Example Apps
2450

2551
To run the examples you will need to include your Mobile SDK Key and a Variable Key. The Variable

android-client-sdk/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ dependencies {
138138
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlin_reflect_version")
139139
implementation("androidx.core:core-ktx:$androidx_version")
140140

141+
// OpenFeature Android SDK
142+
implementation("dev.openfeature:android-sdk:0.4.1")
143+
141144
testImplementation("org.junit.jupiter:junit-jupiter-api:$junit_version")
142145
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit_version")
143146
testImplementation("org.junit.jupiter:junit-jupiter-params:$junit_version")

android-client-sdk/proguard-rules.pro

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,8 @@
115115
# com.launchdarkly:okhttp-eventsource rules
116116
-keep class com.launchdarkly.eventsource.** { *; }
117117
-dontwarn com.launchdarkly.eventsource.**
118+
119+
# OpenFeature integration classes
120+
-keep class com.devcycle.sdk.android.openfeature.** { *; }
121+
-keep class dev.openfeature.sdk.** { *; }
122+
-dontwarn dev.openfeature.sdk.**

android-client-sdk/src/main/java/com/devcycle/sdk/android/api/DevCycleOptions.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class DevCycleOptions(
6363
this.disableConfigCache = disableConfigCache
6464
return this
6565
}
66+
6667
fun disableRealtimeUpdates(disableRealtimeUpdates: Boolean): DevCycleOptionsBuilder {
6768
this.disableRealtimeUpdates = disableRealtimeUpdates
6869
return this
@@ -77,12 +78,12 @@ class DevCycleOptions(
7778
this.eventsApiProxyUrl = eventsApiProxyUrl
7879
return this
7980
}
80-
81+
8182
fun logLevel(logLevel: LogLevel): DevCycleOptionsBuilder {
8283
this.logLevel = logLevel
8384
return this
8485
}
85-
86+
8687
fun build(): DevCycleOptions {
8788
return DevCycleOptions(
8889
flushEventsIntervalMs,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.devcycle.sdk.android.openfeature
2+
3+
import com.devcycle.sdk.android.model.DevCycleUser
4+
import com.devcycle.sdk.android.util.DevCycleLogger
5+
import dev.openfeature.sdk.EvaluationContext
6+
import dev.openfeature.sdk.Value
7+
8+
object DevCycleContextMapper {
9+
10+
fun evaluationContextToDevCycleUser(context: EvaluationContext?): DevCycleUser? {
11+
if (context == null) return null
12+
13+
val builder = DevCycleUser.builder()
14+
var hasTargetingKey = false
15+
var hasStandardAttributes = false
16+
var isAnonymousExplicitlySet = false
17+
18+
// Map targeting key to user ID if available
19+
context.getTargetingKey()?.let { targetingKey ->
20+
if (targetingKey.isNotBlank()) {
21+
builder.withUserId(targetingKey)
22+
hasTargetingKey = true
23+
}
24+
}
25+
26+
// Map standard attributes
27+
context.getValue("email")?.let { email ->
28+
if (email is Value.String) {
29+
email.asString()?.let { emailStr ->
30+
builder.withEmail(emailStr)
31+
hasStandardAttributes = true
32+
}
33+
}
34+
}
35+
36+
context.getValue("name")?.let { name ->
37+
if (name is Value.String) {
38+
name.asString()?.let { nameStr ->
39+
builder.withName(nameStr)
40+
hasStandardAttributes = true
41+
}
42+
}
43+
}
44+
45+
context.getValue("country")?.let { country ->
46+
if (country is Value.String) {
47+
country.asString()?.let { countryStr ->
48+
builder.withCountry(countryStr)
49+
hasStandardAttributes = true
50+
}
51+
}
52+
}
53+
54+
context.getValue("isAnonymous")?.let { isAnonymous ->
55+
if (isAnonymous is Value.Boolean) {
56+
isAnonymous.asBoolean()?.let { isAnonBool ->
57+
builder.withIsAnonymous(isAnonBool)
58+
hasStandardAttributes = true
59+
isAnonymousExplicitlySet = true
60+
}
61+
}
62+
}
63+
64+
// Map custom data
65+
val customData = mutableMapOf<String, Any>()
66+
val privateCustomData = mutableMapOf<String, Any>()
67+
68+
// Use direct asMap method call instead of reflection
69+
context.asMap().forEach { (key, value) ->
70+
when (key) {
71+
"email" -> {
72+
// Only skip if it was successfully processed as a string above
73+
if (value !is Value.String) {
74+
val convertedValue = convertValueToAny(value)
75+
if (convertedValue != null) {
76+
customData[key] = convertedValue
77+
}
78+
}
79+
}
80+
"name" -> {
81+
// Only skip if it was successfully processed as a string above
82+
if (value !is Value.String) {
83+
val convertedValue = convertValueToAny(value)
84+
if (convertedValue != null) {
85+
customData[key] = convertedValue
86+
}
87+
}
88+
}
89+
"country" -> {
90+
// Only skip if it was successfully processed as a string above
91+
if (value !is Value.String) {
92+
val convertedValue = convertValueToAny(value)
93+
if (convertedValue != null) {
94+
customData[key] = convertedValue
95+
}
96+
}
97+
}
98+
"isAnonymous" -> {
99+
// Only skip if it was successfully processed as a boolean above
100+
if (value !is Value.Boolean) {
101+
val convertedValue = convertValueToAny(value)
102+
if (convertedValue != null) {
103+
customData[key] = convertedValue
104+
}
105+
}
106+
}
107+
"customData" -> {
108+
// Handle nested customData structure by flattening it
109+
if (value is Value.Structure) {
110+
val structureMap = convertValueToAny(value) as? Map<*, *>
111+
structureMap?.forEach { (nestedKey, nestedValue) ->
112+
if (nestedKey is String && nestedValue != null) {
113+
customData[nestedKey] = nestedValue
114+
}
115+
}
116+
} else {
117+
val convertedValue = convertValueToAny(value)
118+
if (convertedValue != null) {
119+
customData[key] = convertedValue
120+
}
121+
}
122+
}
123+
else -> {
124+
val convertedValue = convertValueToAny(value)
125+
if (convertedValue != null) {
126+
if (key.startsWith("private_")) {
127+
privateCustomData[key.removePrefix("private_")] = convertedValue
128+
} else {
129+
customData[key] = convertedValue
130+
}
131+
}
132+
}
133+
}
134+
}
135+
136+
if (customData.isNotEmpty()) {
137+
builder.withCustomData(customData)
138+
}
139+
140+
if (privateCustomData.isNotEmpty()) {
141+
builder.withPrivateCustomData(privateCustomData)
142+
}
143+
144+
// Only return a user if we have meaningful data
145+
return if (hasTargetingKey || hasStandardAttributes || customData.isNotEmpty() || privateCustomData.isNotEmpty()) {
146+
// If user has a targeting key, they should be considered identified (not anonymous)
147+
// unless explicitly set to anonymous via a boolean value
148+
if (hasTargetingKey && !isAnonymousExplicitlySet) {
149+
builder.withIsAnonymous(false)
150+
}
151+
152+
return builder.build()
153+
} else {
154+
return null
155+
}
156+
}
157+
158+
private fun convertValueToAny(value: Value): Any? {
159+
return when (value) {
160+
is Value.Boolean -> value.asBoolean()
161+
is Value.Integer -> value.asInteger()
162+
is Value.Double -> value.asDouble()
163+
is Value.String -> value.asString()
164+
is Value.Structure -> {
165+
// Access structure directly
166+
val structureMap = value.structure
167+
structureMap?.mapValues { (_, v) ->
168+
convertValueToAny(v)
169+
}?.filterValues { it != null }
170+
}
171+
is Value.List -> {
172+
// Access list directly
173+
val list = value.list
174+
list?.mapNotNull { convertValueToAny(it) } ?: emptyList<Any>()
175+
}
176+
else -> {
177+
// Ensure the string representation is safe
178+
val stringValue = value.toString()
179+
if (stringValue.isNotBlank()) stringValue else null
180+
}
181+
}
182+
}
183+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.devcycle.sdk.android.openfeature
2+
3+
import com.devcycle.sdk.android.model.DevCycleEvent
4+
import com.devcycle.sdk.android.util.DevCycleLogger
5+
import dev.openfeature.sdk.TrackingEventDetails
6+
import dev.openfeature.sdk.Value
7+
import java.math.BigDecimal
8+
9+
object DevCycleEventMapper {
10+
11+
/**
12+
* Converts OpenFeature tracking event details to a DevCycle event
13+
*
14+
* @param trackingEventName The name/type of the event to track
15+
* @param details Optional tracking event details containing value and metadata
16+
* @return DevCycleEvent ready to be tracked
17+
*/
18+
fun openFeatureEventToDevCycleEvent(
19+
trackingEventName: String,
20+
details: TrackingEventDetails?
21+
): DevCycleEvent {
22+
val builder = DevCycleEvent.builder().withType(trackingEventName)
23+
24+
// Process numeric value if provided
25+
details?.value?.let { value ->
26+
builder.withValue(BigDecimal.valueOf(value.toDouble()))
27+
}
28+
29+
// Process metadata from structure if provided - unwrap Value objects to raw values
30+
details?.structure?.asMap()?.let { detailData ->
31+
val metadata = mutableMapOf<String, Any>()
32+
detailData.forEach { (key, value) ->
33+
val unwrappedValue = unwrapValue(value)
34+
if (unwrappedValue != null) {
35+
metadata[key] = unwrappedValue
36+
}
37+
}
38+
if (metadata.isNotEmpty()) {
39+
builder.withMetaData(metadata)
40+
}
41+
}
42+
43+
return builder.build()
44+
}
45+
46+
/**
47+
* Unwraps OpenFeature Value objects to raw Java types recursively
48+
*/
49+
private fun unwrapValue(value: Any): Any? {
50+
return when (value) {
51+
is Value.String -> value.asString()
52+
is Value.Boolean -> value.asBoolean()
53+
is Value.Integer -> value.asInteger()
54+
is Value.Double -> value.asDouble()
55+
is Value.Structure -> {
56+
// Recursively unwrap nested structure
57+
val structureMap = value.structure
58+
structureMap?.mapValues { (_, v) ->
59+
unwrapValue(v)
60+
}?.filterValues { it != null }
61+
}
62+
is Value.List -> {
63+
// Recursively unwrap nested list
64+
val list = value.list
65+
list?.mapNotNull { unwrapValue(it) }
66+
}
67+
else -> null
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)