diff --git a/package-testing/android-relay/.gitignore b/package-testing/android-relay/.gitignore
new file mode 100644
index 0000000..74be4eb
--- /dev/null
+++ b/package-testing/android-relay/.gitignore
@@ -0,0 +1,12 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+**/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+.idea
+tmp
diff --git a/package-testing/android-relay/README.md b/package-testing/android-relay/README.md
new file mode 100644
index 0000000..1f0b655
--- /dev/null
+++ b/package-testing/android-relay/README.md
@@ -0,0 +1,19 @@
+# Eppo SDK Relay App
+
+This app connects to an Eppo Packing Test Runner Server and relays assignment requests to the SDK.
+
+## Running
+
+Build and run the app
+
+```shell
+# Uses default version of the SDK (4.2.0)
+./gradlew installDebug && \
+ adb shell am start -n cloud.eppo.android.sdkrelay/.TestClientActivity
+```
+
+## Build and run the app with a specific version of the SDK
+SDK_VERSION=4.2.0 ./build-and-run.sh
+
+## Build and run the app with the SDK at a specific Github REF
+SDK_REF=my/branch/name ./build-and-run.sh
diff --git a/package-testing/android-relay/app/build.gradle.kts b/package-testing/android-relay/app/build.gradle.kts
new file mode 100644
index 0000000..0a10a01
--- /dev/null
+++ b/package-testing/android-relay/app/build.gradle.kts
@@ -0,0 +1,112 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+
+ id("com.ncorti.ktfmt.gradle") version "0.20.1"
+}
+
+val apiKey: String = gradleLocalProperties(
+ project.rootDir,
+ providers
+).getProperty("cloud.eppo.apiKey")
+
+val testRunnerHost: String = System.getenv("TEST_RUNNER_HOST") ?: "http://10.0.2.2"
+val testRunnerPort: String = System.getenv("TEST_RUNNER_PORT") ?: "3000"
+
+val eppoAPIHost: String = System.getenv("EPPO_API_HOST") ?: "http://10.0.2.2"
+val eppoAPIPort: String = System.getenv("EPPO_API_PORT") ?: "5000"
+
+
+android {
+ buildFeatures.buildConfig = true
+
+ namespace = "cloud.eppo.android.sdkrelay"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "cloud.eppo.android.sdkrelay"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+
+ buildConfigField( "String", "API_KEY", "\"" + apiKey + "\"")
+ buildConfigField( "String", "TEST_RUNNER_HOST", "\"" +testRunnerHost+ "\"")
+ buildConfigField( "String", "TEST_RUNNER_PORT", "\"" +testRunnerPort+ "\"")
+ buildConfigField( "String", "EPPO_API_HOST", "\"" +eppoAPIHost+ "\"")
+ buildConfigField( "String", "EPPO_API_PORT", "\"" +eppoAPIPort+ "\"")
+ }
+
+ buildTypes {
+ debug {
+ enableUnitTestCoverage = true
+ enableAndroidTestCoverage = true
+ }
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.1"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+val sdkVersion = System.getenv("SDK_VERSION") ?: ""
+val sdkRef = System.getenv("SDK_REF") ?: ""
+
+dependencies {
+ implementation(libs.socketio)
+ implementation(libs.jackson.databind)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.runtime.livedata)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+
+ if (sdkVersion != "") {
+ implementation("cloud.eppo:android-sdk:${sdkVersion}")
+ } else if (sdkRef != "") {
+ implementation(project(":android-sdk")) // Requires the repo be cloned prior to building
+ } else {
+ // Default implementation
+ implementation(libs.eppo.android.sdk)
+ }
+}
diff --git a/package-testing/android-relay/app/proguard-rules.pro b/package-testing/android-relay/app/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/package-testing/android-relay/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/package-testing/android-relay/app/src/main/AndroidManifest.xml b/package-testing/android-relay/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9731d67
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/EppoSdkRelayApplication.kt b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/EppoSdkRelayApplication.kt
new file mode 100644
index 0000000..e7e994b
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/EppoSdkRelayApplication.kt
@@ -0,0 +1,5 @@
+package cloud.eppo.android.sdkrelay
+
+import android.app.Application
+
+class EppoSdkRelayApplication : Application() {}
diff --git a/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/SocketHandler.kt b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/SocketHandler.kt
new file mode 100644
index 0000000..2937250
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/SocketHandler.kt
@@ -0,0 +1,30 @@
+package cloud.eppo.android.sdkrelay
+
+import io.socket.client.IO
+import io.socket.client.Socket
+import java.net.URISyntaxException
+
+class SocketHandler {
+
+ private lateinit var _socket: Socket
+ val socket
+ get() = this._socket
+
+ @Synchronized
+ fun setSocket(host: String, port: String) {
+ try {
+ _socket = IO.socket("$host:$port")
+ } catch (_: URISyntaxException) {}
+ }
+
+ @Synchronized
+ fun establishConnection() {
+ _socket.connect()
+ }
+
+ @Synchronized
+ fun closeConnection() {
+ _socket.disconnect()
+ _socket.off()
+ }
+}
diff --git a/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/TestClientActivity.kt b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/TestClientActivity.kt
new file mode 100644
index 0000000..65c446f
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/java/cloud/eppo/android/sdkrelay/TestClientActivity.kt
@@ -0,0 +1,277 @@
+package cloud.eppo.android.sdkrelay
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import cloud.eppo.android.EppoClient
+import cloud.eppo.api.Attributes
+import cloud.eppo.api.EppoValue
+import cloud.eppo.logging.Assignment
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.socket.client.Ack
+import io.socket.engineio.client.EngineIOException
+import java.util.concurrent.CompletableFuture
+import org.json.JSONObject
+
+class TestClientActivity : ComponentActivity() {
+ private val objectMapper: ObjectMapper = ObjectMapper()
+ private lateinit var socketHandler: SocketHandler
+
+ data class AssignmentRequest(
+ val flag: String,
+ val subjectKey: String,
+ val assignmentType: String,
+ val subjectAttributes: JSONObject,
+ val defaultValue: Any
+ )
+
+ private val status = MutableLiveData()
+ private val assignmentLog = MutableLiveData()
+
+ private fun appendAssignmentLog(entry: String) {
+ assignmentLog.postValue("$entry\n${assignmentLog.value}")
+ }
+
+ private fun assignmentLogger(assignment: Assignment) {
+ val msg: String =
+ ((assignment.experiment + "-> subject: " + assignment.subject) +
+ " assigned to " +
+ assignment.experiment)
+ Log.d(TAG, msg)
+ appendAssignmentLog(msg)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize the socket connection
+ socketHandler = SocketHandler()
+ socketHandler.setSocket(
+ host = BuildConfig.TEST_RUNNER_HOST, port = BuildConfig.TEST_RUNNER_PORT)
+
+ // Set listeners
+ socketHandler.socket.on("connect_error") { args -> handleConnectError(args) }
+ socketHandler.socket.on("connect") { args -> handleConnect(args) }
+ socketHandler.socket.on("disconnect") { args -> handleDisconnect(args) }
+ socketHandler.socket.on("/sdk/reset") { args -> handleReset(args) }
+ socketHandler.socket.on("/flags/v1/assignment") { args -> handleAssignment(args) }
+
+ // Set the UI
+ setContent {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ TestClientScreen(status, assignmentLog, Modifier.padding(innerPadding))
+ }
+ }
+
+ // Connect to the test runner
+ status.postValue(getString(R.string.connecting))
+ reInitializeEppoClient().thenRun { socketHandler.establishConnection() }
+ }
+
+ private fun handleConnect(args: Array?) {
+ Log.d(TAG, "Connected")
+ status.postValue(getString(R.string.connected))
+ sendReady()
+ }
+
+ private fun handleConnectError(args: Array) {
+ val exception: EngineIOException = args[0] as EngineIOException
+ status.postValue(getString(R.string.status_connection_error))
+ Log.e(TAG, "Connection error", exception)
+ }
+
+ private fun handleAssignment(args: Array) {
+ Log.d(TAG, "Assignment Requested")
+ status.postValue(getString(R.string.status_assignment))
+
+ val requestObj = (args[0] as JSONObject)
+ val assignmentRequest =
+ AssignmentRequest(
+ flag = requestObj.getString("flag"),
+ subjectKey = requestObj.getString("subjectKey"),
+ assignmentType = requestObj.getString("assignmentType"),
+ subjectAttributes = requestObj.getJSONObject("subjectAttributes"),
+ defaultValue = requestObj.get("defaultValue"))
+
+ // Ack function for responding to the server.
+ val ack = args.last() as Ack
+
+ val client = EppoClient.getInstance()
+
+ val eppoValues: MutableMap = mutableMapOf()
+ assignmentRequest.subjectAttributes.keys().forEach {
+ val value = assignmentRequest.subjectAttributes.get(it.toString())
+ val typedValue: EppoValue =
+ when (value) {
+ is Int -> EppoValue.valueOf(value.toDouble())
+ is String -> EppoValue.valueOf(value)
+ is Double -> EppoValue.valueOf(value)
+ is Boolean -> EppoValue.valueOf(value)
+ is List<*> -> EppoValue.valueOf(value.map { insideIt -> insideIt.toString() })
+ else -> {
+ Log.e(TAG, "Invalid or null subject attribute type.")
+ EppoValue.nullValue()
+ }
+ }
+ eppoValues[it.toString()] = typedValue
+ }
+ val subjectAttributes = Attributes(eppoValues)
+
+ val result = getAssignmentFromClient(assignmentRequest, client, subjectAttributes)
+ val jsonResult = JSONObject()
+ when (result) {
+ is JsonNode -> jsonResult.put("result", JSONObject(result.toString()))
+ else -> jsonResult.put("result", result)
+ }
+ ack.call(jsonResult.toString())
+ }
+
+ private fun handleDisconnect(args: Array) {
+ Log.d(TAG, "Disconnected")
+ status.postValue(getString(R.string.status_disconnected))
+
+ // Shut down the connection and remove listeners
+ socketHandler.closeConnection()
+
+ runOnUiThread { Toast.makeText(this, "Test runner disconnected", Toast.LENGTH_LONG).show() }
+ }
+
+ private fun handleReset(args: Array) {
+ Log.d(TAG, "SDK Reset")
+
+ // Initialize the SDK
+ val ack = args.last() as Ack
+ reInitializeEppoClient().thenAccept { ack.call(true) }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ socketHandler.closeConnection()
+ }
+
+ private fun sendReady() {
+ socketHandler.socket.emit("READY", arrayOf(READY_PACKET)) {
+ Log.d(TAG, "Ready message acked")
+ }
+ }
+
+ private fun reInitializeEppoClient(): CompletableFuture {
+ // Most of the settings used here are intended for debugging only.
+ return EppoClient.Builder(API_KEY, application)
+ .forceReinitialize(true)
+ .ignoreCachedConfiguration(true)
+ .host(EPPO_API_ADDRESS)
+ .isGracefulMode(false) // Debug: surface exceptions
+ .assignmentLogger(::assignmentLogger)
+ .buildAndInitAsync()
+ }
+
+ private fun getAssignmentFromClient(
+ assignmentRequest: AssignmentRequest,
+ client: EppoClient,
+ subject: Attributes
+ ): Any {
+ return when (assignmentRequest.assignmentType) {
+ "STRING" ->
+ return client.getStringAssignment(
+ assignmentRequest.flag,
+ assignmentRequest.subjectKey,
+ subject,
+ (assignmentRequest.defaultValue as String))
+
+ "INTEGER" ->
+ return client.getIntegerAssignment(
+ assignmentRequest.flag,
+ assignmentRequest.subjectKey,
+ subject,
+ (assignmentRequest.defaultValue as Int))
+
+ "BOOLEAN" ->
+ return client.getBooleanAssignment(
+ assignmentRequest.flag,
+ assignmentRequest.subjectKey,
+ subject,
+ (assignmentRequest.defaultValue as Boolean))
+
+ "NUMERIC" -> {
+ val defaultNumericValue =
+ if (assignmentRequest.defaultValue is Int) assignmentRequest.defaultValue.toDouble()
+ else (assignmentRequest.defaultValue as Double)
+ return client.getDoubleAssignment(
+ assignmentRequest.flag, assignmentRequest.subjectKey, subject, defaultNumericValue)
+ }
+
+ "JSON" -> {
+ val defaultValue: JsonNode =
+ objectMapper.readTree((assignmentRequest.defaultValue as JSONObject).toString())
+ val jsonNodeResult =
+ client.getJSONAssignment(
+ assignmentRequest.flag, assignmentRequest.subjectKey, subject, defaultValue)
+
+ return jsonNodeResult
+ }
+
+ else -> "NO RESULT"
+ }
+ }
+
+ companion object {
+ private val TAG = TestClientActivity::class.qualifiedName
+ private const val API_KEY = BuildConfig.API_KEY
+ private const val READY_PACKET =
+ "{\"sdkName\":\"example\", \"supportsBandits\" : false, \"sdkType\":\"client\"}"
+ private const val EPPO_API_ADDRESS = "${BuildConfig.EPPO_API_HOST}:${BuildConfig.EPPO_API_PORT}"
+ }
+}
+
+@Composable
+fun TestClientScreen(
+ status: LiveData,
+ assignmentLog: LiveData,
+ modifier: Modifier = Modifier
+) {
+ val statusString: String? by status.observeAsState()
+ val assignmentLogText: String? by assignmentLog.observeAsState()
+
+ Column(modifier = modifier.padding(5.dp)) {
+ Status(statusString ?: stringResource(R.string.status_pending))
+ LogView(stringResource(R.string.label_assignment_log), assignmentLogText ?: "")
+ }
+}
+
+@Composable
+fun Status(status: String) {
+ Row() {
+ Text(stringResource(R.string.label_status_prefix), fontWeight = FontWeight.Bold)
+ Text(status, fontStyle = FontStyle.Italic)
+ }
+}
+
+@Composable
+fun LogView(name: String, log: String) {
+ Column {
+ Text(name, fontWeight = FontWeight.Bold, fontSize = 24.sp)
+ Text(log)
+ }
+}
diff --git a/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_background.xml b/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_foreground.xml b/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..7706ab9
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/package-testing/android-relay/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/package-testing/android-relay/app/src/main/res/values/strings.xml b/package-testing/android-relay/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f64d95a
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/values/strings.xml
@@ -0,0 +1,12 @@
+
+ Eppo SDK Relay
+ Automated Test Runner
+ Assignment Log
+ "Socket status: "
+ pending
+ Disconnected
+ Assigning
+ Connection Error
+ Connected
+ Connecting
+
diff --git a/package-testing/android-relay/app/src/main/res/xml/backup_rules.xml b/package-testing/android-relay/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..148c18b
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/package-testing/android-relay/app/src/main/res/xml/data_extraction_rules.xml b/package-testing/android-relay/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..0c4f95c
--- /dev/null
+++ b/package-testing/android-relay/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/package-testing/android-relay/build-and-run.sh b/package-testing/android-relay/build-and-run.sh
new file mode 100755
index 0000000..c313879
--- /dev/null
+++ b/package-testing/android-relay/build-and-run.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+# Set default values for vars
+
+: "${SDK_RELAY_HOST:=localhost}"
+: "${SDK_RELAY_PORT:=4000}"
+: "${SDK_REF:=main}"
+SDK="https://github.com/Eppo-exp/android-sdk.git"
+
+# Load the SDK from the repo only if a specific version is not set
+if [ -z "${SDK_VERSION}" ]; then
+ # checkout the specified ref of the SDK repo, build it, and then insert it into vendors here.
+ rm -Rf tmp
+ mkdir -p tmp
+
+ echo "Cloning ${SDK}@${SDK_REF}"
+ git clone -b ${SDK_REF} --depth 1 --single-branch ${SDK} tmp || ( echo "Cloning repo failed"; exit 1 )
+
+fi
+
+# The `build.gradle.kts` uses local code unless the $SDK_VERSION variable is set.
+
+# Now, build and install
+./gradlew assembleDebug installDebug && \
+ adb shell am start -n cloud.eppo.android.sdkrelay/.TestClientActivity
+
+# and launch activity
\ No newline at end of file
diff --git a/package-testing/android-relay/build.gradle.kts b/package-testing/android-relay/build.gradle.kts
new file mode 100644
index 0000000..c1e23bc
--- /dev/null
+++ b/package-testing/android-relay/build.gradle.kts
@@ -0,0 +1,5 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.jetbrains.kotlin.android) apply false
+}
diff --git a/package-testing/android-relay/gradle.properties b/package-testing/android-relay/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/package-testing/android-relay/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/package-testing/android-relay/gradle/libs.versions.toml b/package-testing/android-relay/gradle/libs.versions.toml
new file mode 100644
index 0000000..fc461a9
--- /dev/null
+++ b/package-testing/android-relay/gradle/libs.versions.toml
@@ -0,0 +1,39 @@
+[versions]
+agp = "8.5.2"
+androidSdk = "4.3.0"
+jacksonDatabind = "2.18.0"
+kotlin = "1.9.0"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+lifecycleRuntimeKtx = "2.6.1"
+activityCompose = "1.8.0"
+composeBom = "2024.04.01"
+socketIoVersion = "2.0.0"
+runtimeLivedata = "1.7.4"
+
+[libraries]
+eppo-android-sdk = { module = "cloud.eppo:android-sdk", version.ref = "androidSdk" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabind" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+socketio = { group = "io.socket", name = "socket.io-client", version.ref = "socketIoVersion" }
+androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+
diff --git a/package-testing/android-relay/gradle/wrapper/gradle-wrapper.jar b/package-testing/android-relay/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/package-testing/android-relay/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/package-testing/android-relay/gradle/wrapper/gradle-wrapper.properties b/package-testing/android-relay/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9c80fbd
--- /dev/null
+++ b/package-testing/android-relay/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Jun 20 10:42:02 PDT 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/package-testing/android-relay/gradlew b/package-testing/android-relay/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/package-testing/android-relay/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/package-testing/android-relay/gradlew.bat b/package-testing/android-relay/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/package-testing/android-relay/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/package-testing/android-relay/settings.gradle.kts b/package-testing/android-relay/settings.gradle.kts
new file mode 100644
index 0000000..faedfee
--- /dev/null
+++ b/package-testing/android-relay/settings.gradle.kts
@@ -0,0 +1,29 @@
+import java.net.URI
+
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ mavenLocal()
+ }
+}
+
+rootProject.name = "Eppo SDK Relay"
+include(":app")
+
+include(":android-sdk")
+project(":android-sdk").projectDir = File("tmp/eppo")