Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b82fbbf
feat: add android-sdk-framework module
typotter Mar 12, 2026
bbb0003
fix: update Maven snapshot repository URL to new Maven Central
typotter Mar 12, 2026
45934db
fix: address code review issues in android-sdk-framework
typotter Mar 23, 2026
882786a
fix: address Copilot review feedback on android-sdk-framework
typotter Mar 24, 2026
7944817
style: fix spotless formatting in AndroidBaseClient
typotter Mar 24, 2026
9aa5bd6
fix: address muse review feedback on android-sdk-framework
typotter Apr 16, 2026
264661f
fix: harden android-sdk-framework against deserialization and improve…
typotter Apr 16, 2026
aa31c74
fix: remove ObjectInputFilter — not available in Android SDK
typotter Apr 16, 2026
d4f57b6
docs: add CLAUDE.md, fix .gitignore credential exclusions, update Mak…
typotter Apr 22, 2026
2099f50
fix: address code review feedback on android-sdk-framework
typotter Apr 22, 2026
5bda22e
fix: make CachingConfigurationStore.seedCache() public
typotter Apr 29, 2026
62fc396
refactor: encapsulate seedCache() behind loadAndSeedFromStorage()
typotter Apr 30, 2026
edb6b14
fix: address code review feedback on android-sdk-framework
typotter May 8, 2026
4e501d9
refactor: remove singleton from BaseAndroidClient
typotter May 8, 2026
8d16eeb
fix: deadlock in loadAndSeedFromStorage() with single-thread IO executor
typotter May 8, 2026
99dfdcb
fix: add null checks to BaseCacheFile constructor
typotter May 8, 2026
6bc6813
fix: remove spurious failCount double-increment in buildAndInitAsync
typotter May 29, 2026
639dad6
fix: null-safe Boolean comparison and clarify failCount coordination
typotter May 29, 2026
3ea6600
fix: address threading and resource management feedback on android-sd…
typotter May 29, 2026
05d2ff5
fix: return uninitialized client instead of null in graceful-mode fal…
typotter May 29, 2026
09d03e0
style: fix spotless formatting in BaseAndroidClient
typotter May 29, 2026
bfdc0d9
chore: use 0.1.0-SNAPSHOT version until upstream deps release
typotter May 29, 2026
cbde4f7
refactor: rename BaseAndroidClient to BaseAndroidEppoClient
typotter May 29, 2026
c1e373c
fix: propagate close() through FileBackedConfigStore to release execu…
typotter May 29, 2026
7f26560
chore: address minor review feedback
typotter May 29, 2026
0bf7884
style: apply spotless formatting fixes
typotter May 29, 2026
e151d8d
test: consolidate polling tests and add mock verification
typotter Jun 12, 2026
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
11 changes: 7 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ captures/
# VSCode
.vscode

# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# Keystore files and credentials
*.jks
*.keystore
*.pem
*.p12
.env
credentials*

# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
Expand Down
76 changes: 76 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Eppo Android SDK

## Module Structure

Three-layer dependency chain:

```
eppo-sdk-framework (external, platform-neutral flag evaluation)
└── sdk-common-jvm (external, OkHttp + Jackson + JVM defaults)
└── :android-sdk-framework (Android abstractions)
└── :eppo (batteries-included public API)
```

| Module | Version | Artifact | Role |
|---|---|---|---|
| `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations |
| `:android-sdk-framework` | 0.1.0 | `cloud.eppo:android-sdk-framework` | `BaseAndroidClient<T>`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` |
| `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser<T>` |
| `sdk-common-jvm` | 3.13.1 (`:eppo` api) / 4.0.0-SNAPSHOT (`:android-sdk-framework` testImplementation) | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models |

`:eppo` and `:android-sdk-framework` are versioned together. `:example` is a sample app only.

## Android API Constraints

**compileSdk 34, minSdk 26.** The build target is Android's `android.jar`, not a full JDK.

APIs that are NOT available even though they appear in Java 9+ docs:

- `java.io.ObjectInputFilter` — Java 9+, absent from `android.jar` at all API levels. Do not use it in any module that compiles against Android.
- `java.lang.ProcessHandle` — Java 9+, absent.
- `java.util.concurrent.Flow` — Java 9+, absent.

When a dependency or pattern requires a Java 9+ API, it will compile locally (against a full JDK) but fail CI (which uses Android's SDK). Check `android.jar` contents before adding new Java 9+ usages.

## Key Abstractions

`ByteStore` — read/write interface for raw bytes; Android implementation uses `FileBackedByteStore` (app private internal storage). Inaccessible to other apps on non-rooted devices.

`ConfigurationCodec` — serialization interface; default uses Java serialization. The `Default` implementation does not apply `ObjectInputFilter` (not available in Android SDK); the deserialization risk is low because bytes come from the SDK's own write path and live in private storage.

`CachingConfigurationStore` — wraps `ByteStore` + `ConfigurationCodec` with an in-memory `AtomicReference`; optimistic write with compareAndSet revert on IO failure.

## Build & Test

```bash
make test-data # clone sdk-test-data and copy UFC fixtures into androidTest/assets
make test # run test-data then ./gradlew connectedCheck (requires connected device/emulator)
./gradlew :android-sdk-framework:test # unit tests (Robolectric)
./gradlew spotlessApply # auto-fix formatting
```

## Publishing

Both modules use the `vanniktech` Maven publish plugin targeting **Sonatype Central Portal** (not the legacy OSSRH).

Required in `~/.gradle/gradle.properties`:
```
mavenCentralUsername=<your-central-portal-token-username>
mavenCentralPassword=<your-central-portal-token-password>
```

Publish flow enforces `-Prelease` or `-Psnapshot` flag:
```bash
./gradlew :eppo:publish -Prelease # release build
./gradlew :android-sdk-framework:publish -Psnapshot # snapshot build
```

`make publish-release` runs tests then checks for `mavenCentralUsername`/`mavenCentralPassword` before publishing `:eppo`.

GPG signing uses `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` environment variables (CI) or `signing.*` properties (local).

## Gradle Conventions

- Spotless (Google Java Format) enforced on all `.java` files — run `spotlessApply` before committing.
- `checkVersion` task validates the `-Prelease`/`-Psnapshot` flag before any publish task runs.
- Snapshot repo: `https://central.sonatype.com/repository/maven-snapshots/` — scope with `content { includeModule(...) }` to avoid querying it for all artifacts.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test: test-data

check-maven-credentials-and-publish:
# $(INFO)Checking required gradle configuration(END)
@for required_property in "OSSRH_USERNAME" "OSSRH_PASSWORD"; do \
@for required_property in "mavenCentralUsername" "mavenCentralPassword"; do \
cat ~/.gradle/gradle.properties | grep -q $$required_property; \
if [ $$? != 0 ]; then \
echo "$(ERROR)ERROR: ~/.gradle/gradle.properties file is missing property: $$required_property$(END)"; \
Expand All @@ -63,7 +63,7 @@ check-maven-credentials-and-publish:
done

# $(INFO)Publishing release(END)
./gradlew :eppo:publishReleasePublicationToMavenRepository
./gradlew :eppo:publish -Prelease

.PHONY: publish-release
publish-release: test check-maven-credentials-and-publish
181 changes: 181 additions & 0 deletions android-sdk-framework/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
plugins {
id 'com.android.library'
id 'maven-publish'
id "com.vanniktech.maven.publish" version "0.32.0"
id 'signing'
id "com.diffplug.spotless" version "8.0.0"
}

group = "cloud.eppo"
version = "0.1.0"

android {
namespace "cloud.eppo.android.framework"
compileSdk 34

buildFeatures.buildConfig true

defaultConfig {
minSdk 26
targetSdk 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
def FRAMEWORK_VERSION = "FRAMEWORK_VERSION"
// EPPO_VERSION is used as the sdkVersion reported to Eppo. It matches FRAMEWORK_VERSION
// because the framework and eppo modules are versioned together.
def EPPO_VERSION = "EPPO_VERSION"
release {
minifyEnabled false
buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\""
buildConfigField "String", EPPO_VERSION, "\"${project.version}\""
}
debug {
minifyEnabled false
buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\""
buildConfigField "String", EPPO_VERSION, "\"${project.version}\""
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

testOptions {
unitTests.returnDefaultValues = true
}
}

dependencies {
api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'

implementation 'org.slf4j:slf4j-api:1.7.36'
runtimeOnly 'org.slf4j:slf4j-android:1.7.36'
compileOnly 'org.jetbrains:annotations:24.0.0'

testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
Comment on lines +51 to +58
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.14.2'
testImplementation 'org.robolectric:robolectric:4.12.1'

androidTestImplementation 'junit:junit:4.13.2'
androidTestImplementation 'org.mockito:mockito-android:5.14.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:core:1.6.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.19.1'
}

spotless {
format 'misc', {
target '*.gradle', '.gitattributes', '.gitignore'

trimTrailingWhitespace()
leadingTabsToSpaces(2)
endWithNewline()
}
java {
target '**/*.java'

googleJavaFormat()
formatAnnotations()
}
}

signing {
if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) {
useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE)
sign publishing.publications
} else {
required = false
}
}

tasks.withType(Sign) {
onlyIf {
(System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) ||
(project.hasProperty('signing.keyId') &&
project.hasProperty('signing.password') &&
project.hasProperty('signing.secretKeyRingFile'))
}
}

mavenPublishing {
publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL)
signAllPublications()
coordinates("cloud.eppo", "android-sdk-framework", project.version)

pom {
name = 'Eppo Android SDK Framework'
description = 'Android SDK Framework for Eppo - Library-independent EppoClient and PrecomputedEppoClient (abstracts JSON, HTTP, storage)'
url = 'https://github.com/Eppo-exp/android-sdk'
licenses {
license {
name = 'MIT License'
url = 'http://www.opensource.org/licenses/mit-license.php'
}
}
developers {
developer {
name = 'Eppo'
email = 'sdk@geteppo.com'
}
}
scm {
connection = 'scm:git:git://github.com/Eppo-exp/android-sdk.git'
developerConnection = 'scm:git:ssh://github.com/Eppo-exp/android-sdk.git'
url = 'https://github.com/Eppo-exp/android-sdk/tree/main'
}
}
}

task checkNoSnapshotApiDeps {
doLast {
// Only enforce on release builds. Snapshot builds are expected to depend on SNAPSHOT
// upstream artifacts; enforcing here would block legitimate snapshot publish flows.
if (!project.hasProperty('release')) return
def apiConfig = configurations.findByName('api')
if (apiConfig != null) {
apiConfig.dependencies.each { dep ->
if (dep.version?.endsWith('SNAPSHOT')) {
throw new GradleException(
"api dependency '${dep.group}:${dep.name}:${dep.version}' is a SNAPSHOT. " +
"SNAPSHOT api dependencies leak to consumers. Use implementation scope instead."
)
}
}
}
}
}

task checkVersion {
dependsOn checkNoSnapshotApiDeps
doLast {
if (!project.hasProperty('release') && !project.hasProperty('snapshot')) {
throw new GradleException("You must specify either -Prelease or -Psnapshot")
}
if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) {
throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version")
}
if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) {

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.

🤖 Comment from AI: This is the line that makes the snapshot publish path impossible for the new module. Since version is fixed at 0.1.0, ./gradlew :android-sdk-framework:publish -Psnapshot always throws here (You cannot specify -Psnapshot with a non-SNAPSHOT version). Release publish is blocked too while the api dependency is still 0.1.0-SNAPSHOT. Could we either make the module version 0.1.0-SNAPSHOT until upstream is released, or derive the version from the snapshot/release flag so the snapshot flow can actually publish?

throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version")
}
project.ext.shouldPublish = true
}
}

tasks.named('publish').configure {
dependsOn checkVersion
}

tasks.withType(PublishToMavenLocal).configureEach {
dependsOn checkVersion
}

tasks.withType(PublishToMavenRepository) {
onlyIf {
project.ext.has('shouldPublish') && project.ext.shouldPublish
}
}
Loading
Loading