From ed5cb99ca84aa7eb78bbb0a3fb51a5d715b38324 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 29 Apr 2026 16:54:50 -0600 Subject: [PATCH] feat: add kotlin-example module using android-sdk-framework New :kotlin-example Android app demonstrating direct use of :android-sdk-framework (not :eppo). - KotlinxConfigurationParser implements ConfigurationParser using kotlinx.serialization for parseJsonValue; delegates parseFlagConfig and parseBanditParams to JacksonConfigurationParser (framework DTOs have hand-rolled Jackson deserializers in sdk-common-jvm) - OkHttpEppoClient from sdk-common-jvm:4.0.0-SNAPSHOT for HTTP (OkHttp 4.x) - BaseAndroidClient.Builder wired in EppoApplication.onCreate() - MainActivity shows flag assignment result via getStringAssignment() - Make CachingConfigurationStore.seedCache() public (was package-private, inaccessible from BaseAndroidClient in a different package) --- .../storage/CachingConfigurationStore.java | 2 +- build.gradle | 2 + kotlin-example/build.gradle | 65 +++++++++++++++++++ kotlin-example/proguard-rules.pro | 1 + kotlin-example/src/main/AndroidManifest.xml | 21 ++++++ .../eppo/kotlinexample/EppoApplication.kt | 37 +++++++++++ .../KotlinxConfigurationParser.kt | 37 +++++++++++ .../cloud/eppo/kotlinexample/MainActivity.kt | 35 ++++++++++ .../src/main/res/layout/activity_main.xml | 42 ++++++++++++ kotlin-example/src/main/res/values/colors.xml | 10 +++ .../src/main/res/values/strings.xml | 8 +++ kotlin-example/src/main/res/values/themes.xml | 12 ++++ settings.gradle | 3 +- 13 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 kotlin-example/build.gradle create mode 100644 kotlin-example/proguard-rules.pro create mode 100644 kotlin-example/src/main/AndroidManifest.xml create mode 100644 kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/EppoApplication.kt create mode 100644 kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/KotlinxConfigurationParser.kt create mode 100644 kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/MainActivity.kt create mode 100644 kotlin-example/src/main/res/layout/activity_main.xml create mode 100644 kotlin-example/src/main/res/values/colors.xml create mode 100644 kotlin-example/src/main/res/values/strings.xml create mode 100644 kotlin-example/src/main/res/values/themes.xml diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java index 2b83b4f3..e1488b3c 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java @@ -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"); } diff --git a/build.gradle b/build.gradle index fa71816d..4dd0a870 100644 --- a/build.gradle +++ b/build.gradle @@ -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) { diff --git a/kotlin-example/build.gradle b/kotlin-example/build.gradle new file mode 100644 index 00000000..7e42e56f --- /dev/null +++ b/kotlin-example/build.gradle @@ -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)) +} + +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' +} diff --git a/kotlin-example/proguard-rules.pro b/kotlin-example/proguard-rules.pro new file mode 100644 index 00000000..fb164d66 --- /dev/null +++ b/kotlin-example/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/kotlin-example/src/main/AndroidManifest.xml b/kotlin-example/src/main/AndroidManifest.xml new file mode 100644 index 00000000..57ba9687 --- /dev/null +++ b/kotlin-example/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/EppoApplication.kt b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/EppoApplication.kt new file mode 100644 index 00000000..bae4c22d --- /dev/null +++ b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/EppoApplication.kt @@ -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" + } +} diff --git a/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/KotlinxConfigurationParser.kt b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/KotlinxConfigurationParser.kt new file mode 100644 index 00000000..8f448cad --- /dev/null +++ b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/KotlinxConfigurationParser.kt @@ -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 { + + 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) + } +} diff --git a/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/MainActivity.kt b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/MainActivity.kt new file mode 100644 index 00000000..8c07c4b9 --- /dev/null +++ b/kotlin-example/src/main/kotlin/cloud/eppo/kotlinexample/MainActivity.kt @@ -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(R.id.statusText) + val assignmentText = findViewById(R.id.assignmentText) + + try { + val client = BaseAndroidClient.getInstance() + 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 = "" + } + } +} diff --git a/kotlin-example/src/main/res/layout/activity_main.xml b/kotlin-example/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..0a3dfbdf --- /dev/null +++ b/kotlin-example/src/main/res/layout/activity_main.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/kotlin-example/src/main/res/values/colors.xml b/kotlin-example/src/main/res/values/colors.xml new file mode 100644 index 00000000..ca1931bc --- /dev/null +++ b/kotlin-example/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/kotlin-example/src/main/res/values/strings.xml b/kotlin-example/src/main/res/values/strings.xml new file mode 100644 index 00000000..bf5d1c4d --- /dev/null +++ b/kotlin-example/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Eppo Kotlin Example + Loading… + Client ready + Client not initialized yet + Assignment: %s + diff --git a/kotlin-example/src/main/res/values/themes.xml b/kotlin-example/src/main/res/values/themes.xml new file mode 100644 index 00000000..94148a06 --- /dev/null +++ b/kotlin-example/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + diff --git a/settings.gradle b/settings.gradle index a31545d6..2c18a7a0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,4 +19,5 @@ dependencyResolutionManagement { rootProject.name = "Eppo SDK" include ':example' include ':eppo' -include ':android-sdk-framework' \ No newline at end of file +include ':android-sdk-framework' +include ':kotlin-example' \ No newline at end of file