Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ protected CachingConfigurationStore(
*
* @param config the configuration to seed (must not be null)
*/
void seedCache(@NotNull Configuration config) {
public void seedCache(@NotNull Configuration config) {
if (config == null) {
throw new IllegalArgumentException("config must not be null");
}
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
plugins {
id 'com.android.application' version '8.10.1' apply false
id 'com.android.library' version '8.10.1' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '2.0.21' apply false
}

task clean(type: Delete) {
Expand Down
65 changes: 65 additions & 0 deletions kotlin-example/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localProperties.load(new FileInputStream(localPropertiesFile))
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Gradle script loads local.properties via new FileInputStream(...) but never closes the stream. In long-lived Gradle daemons this can leak file descriptors. Use localPropertiesFile.withInputStream { localProperties.load(it) } (or a try-with-resources equivalent) to ensure the stream is closed.

Suggested change
localProperties.load(new FileInputStream(localPropertiesFile))
localPropertiesFile.withInputStream { localProperties.load(it) }

Copilot uses AI. Check for mistakes.
}

android {
namespace 'cloud.eppo.kotlinexample'
compileSdk 34

buildFeatures {
buildConfig true
}

defaultConfig {
applicationId 'cloud.eppo.kotlinexample'
minSdk 26
targetSdk 34
versionCode 1
versionName '1.0'

testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

buildConfigField "String", "EPPO_API_KEY",
"\"" + (localProperties['cloud.eppo.apiKey'] ?: "set-eppo-api-key-in-local.properties") + "\""
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = '1.8'
}
}

dependencies {
implementation project(':android-sdk-framework')
// sdk-common-jvm:4.0.0-SNAPSHOT is the split version compatible with eppo-sdk-framework:0.1.0-SNAPSHOT.
// 3.13.1 is the old monolithic version that bundles framework classes — it conflicts with
// eppo-sdk-framework (which comes transitively from :android-sdk-framework).
implementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3'
implementation 'org.slf4j:slf4j-api:1.7.36'
runtimeOnly 'org.slf4j:slf4j-android:1.7.36'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
}
1 change: 1 addition & 0 deletions kotlin-example/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
21 changes: 21 additions & 0 deletions kotlin-example/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".EppoApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/Theme.KotlinExample">

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cloud.eppo.kotlinexample

import android.app.Application
import android.util.Log
import cloud.eppo.OkHttpEppoClient
import cloud.eppo.android.framework.BaseAndroidClient

class EppoApplication : Application() {

override fun onCreate() {
super.onCreate()
initEppoClient()
}

private fun initEppoClient() {
BaseAndroidClient.Builder(
BuildConfig.EPPO_API_KEY,
this,
KotlinxConfigurationParser(),
OkHttpEppoClient()
)
.isGracefulMode(true)
.buildAndInitAsync()
.handle { _, ex ->
if (ex != null) {
Log.e(TAG, "Eppo initialization failed", ex)
} else {
Log.i(TAG, "Eppo client initialized")
}
null
}
}

companion object {
private const val TAG = "EppoApplication"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cloud.eppo.kotlinexample

import cloud.eppo.JacksonConfigurationParser
import cloud.eppo.api.dto.BanditParametersResponse
import cloud.eppo.api.dto.FlagConfigResponse
import cloud.eppo.parser.ConfigurationParseException
import cloud.eppo.parser.ConfigurationParser
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

/**
* [ConfigurationParser] implementation that uses kotlinx.serialization for JSON flag values.
*
* Flag configuration and bandit parameter parsing is delegated to [JacksonConfigurationParser]
* because the framework DTO types ([FlagConfigResponse], [BanditParametersResponse]) have
* hand-rolled Jackson deserializers in sdk-common-jvm that handle Eppo's obfuscated config
* format. Only [parseJsonValue] uses kotlinx.serialization, since that is the method that
* returns the user-facing [JsonElement] type.
*/
class KotlinxConfigurationParser : ConfigurationParser<JsonElement> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥


private val delegate = JacksonConfigurationParser()
private val json = Json { ignoreUnknownKeys = true }

override fun parseFlagConfig(flagConfigJson: ByteArray): FlagConfigResponse =
delegate.parseFlagConfig(flagConfigJson)

override fun parseBanditParams(banditParamsJson: ByteArray): BanditParametersResponse =
delegate.parseBanditParams(banditParamsJson)

override fun parseJsonValue(jsonValue: String): JsonElement =
try {
json.parseToJsonElement(jsonValue)
} catch (e: Exception) {
throw ConfigurationParseException("Failed to parse JSON value: $jsonValue", e)
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thrown ConfigurationParseException message includes the full jsonValue string. This can unintentionally leak sensitive flag payloads into logs/crash reports and can also be very large. Prefer omitting the raw value (or logging a truncated/length-only version) and rely on the cause exception for debugging details.

Suggested change
throw ConfigurationParseException("Failed to parse JSON value: $jsonValue", e)
throw ConfigurationParseException(
"Failed to parse JSON value (length=${jsonValue.length})",
e,
)

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cloud.eppo.kotlinexample

import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import cloud.eppo.android.framework.BaseAndroidClient
import cloud.eppo.android.framework.exceptions.NotInitializedException
import cloud.eppo.api.Attributes
import kotlinx.serialization.json.JsonElement

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val statusText = findViewById<TextView>(R.id.statusText)
val assignmentText = findViewById<TextView>(R.id.assignmentText)

try {
val client = BaseAndroidClient.getInstance<JsonElement>()
val assignment = client.getStringAssignment(
"my-example-flag",
"user-123",
Attributes(),
"control"
)
statusText.text = getString(R.string.status_ready)
assignmentText.text = getString(R.string.assignment_result, assignment)
} catch (e: NotInitializedException) {
statusText.text = getString(R.string.status_not_initialized)
assignmentText.text = ""
}
}
}
42 changes: 42 additions & 0 deletions kotlin-example/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">

<TextView
android:id="@+id/titleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginTop="48dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_loading"
android:textSize="16sp"
android:layout_marginTop="24dp"
app:layout_constraintTop_toBottomOf="@id/titleText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

<TextView
android:id="@+id/assignmentText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/statusText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
10 changes: 10 additions & 0 deletions kotlin-example/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
8 changes: 8 additions & 0 deletions kotlin-example/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Eppo Kotlin Example</string>
<string name="status_loading">Loading…</string>
<string name="status_ready">Client ready</string>
<string name="status_not_initialized">Client not initialized yet</string>
<string name="assignment_result">Assignment: %s</string>
</resources>
12 changes: 12 additions & 0 deletions kotlin-example/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KotlinExample" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
</style>
</resources>
3 changes: 2 additions & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ dependencyResolutionManagement {
rootProject.name = "Eppo SDK"
include ':example'
include ':eppo'
include ':android-sdk-framework'
include ':android-sdk-framework'
include ':kotlin-example'
Loading