Conversation
Add new android-sdk-framework module that provides base client functionality and configuration storage abstractions for Android SDK implementations. Key components: - AndroidBaseClient: Base class for Android SDK clients - CachingConfigurationStore: Abstract store for cached configurations - FileBackedConfigStore: File-based configuration persistence - ConfigurationCodec: Pluggable serialization/deserialization - ByteStore: Low-level byte storage abstraction This module uses the v4 Java SDK framework and is added as a standalone module without integration into the existing eppo module yet.
The v4 framework snapshots are deployed to the new Maven Central snapshot repository at https://central.sonatype.com/repository/maven-snapshots, not the old OSSRH at https://oss.sonatype.org/content/repositories/snapshots/
|
@copilot review this PR |
There was a problem hiding this comment.
Pull request overview
Adds a new android-sdk-framework Android library module intended to provide shared base-client behavior and configuration persistence/serialization primitives for Android SDK implementations (built on the v4 Java SDK framework).
Changes:
- Introduces
AndroidBaseClientwith a Builder-based initialization flow and polling pause/resume surface. - Adds configuration storage abstractions (
ByteStore,CachingConfigurationStore) with file-backed implementations and codecs (Java serialization default + Gson/JSON codec). - Adds Robolectric/unit tests and an instrumentation test covering polling pause/resume call-safety.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
settings.gradle |
Includes the new :android-sdk-framework module and adjusts snapshot Maven repo URL. |
android-sdk-framework/build.gradle |
Defines the new Android library module, dependencies, publishing/signing, and formatting config. |
android-sdk-framework/src/main/AndroidManifest.xml |
Declares an (empty) manifest for the library module. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java |
New base client implementation + Builder, initialization flow, singleton, and polling pause/resume API. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java |
Utility helpers for log tags and cache-key derivation. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java |
New checked exception wrapper for initialization failures. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java |
New runtime exception for uninitialized singleton access. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java |
Async byte storage abstraction interface. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java |
Base file wrapper providing basic filesystem operations/streams. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java |
Cache filename construction with content-type → extension mapping. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java |
File-backed implementation of ByteStore. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java |
Codec interface + default Java-serialization implementation. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java |
Gson/JSON codec for Configuration (uses reflection to access internals). |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java |
In-memory config cache + persistence via ByteStore and ConfigurationCodec. |
android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java |
Convenience store wiring CachingConfigurationStore to a disk file. |
android-sdk-framework/src/test/resources/flags-v1.json |
Large test fixture for configuration parsing/round-tripping. |
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java |
Unit tests for caching store behavior and error handling. |
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java |
Unit tests for the default Java-serialization codec. |
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java |
Unit tests for file-backed byte storage behavior. |
android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java |
Unit tests for file-backed config store wiring/behavior. |
android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java |
Instrumentation tests exercising polling pause/resume call sequences. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public static String safeCacheKey(String key) { | ||
| // Take the first eight characters to avoid the key being sensitive information | ||
| // Remove non-alphanumeric characters so it plays nice with filesystem | ||
| return key.substring(0, 8).replaceAll("\\W", ""); |
There was a problem hiding this comment.
safeCacheKey calls key.substring(0, 8) unconditionally, which will throw StringIndexOutOfBoundsException for API keys shorter than 8 characters (and NPE if key is null). Since this is used during client initialization, it can crash initialization for invalid/short keys; consider validating length (and null) and either rejecting with a clear IllegalArgumentException or safely truncating to min(8, key.length()).
| return key.substring(0, 8).replaceAll("\\W", ""); | |
| if (key == null || key.isEmpty()) { | |
| return ""; | |
| } | |
| int endIndex = Math.min(8, key.length()); | |
| return key.substring(0, endIndex).replaceAll("\\W", ""); |
| return CompletableFuture.runAsync( | ||
| () -> { | ||
| try (java.io.OutputStream out = cacheFile.getOutputStream()) { | ||
| out.write(bytes); | ||
| } catch (Exception e) { | ||
| throw new RuntimeException("Failed to write to cache file", e); | ||
| } | ||
| }); |
There was a problem hiding this comment.
FileBackedByteStore.write() can be called concurrently (e.g., multiple saves in flight). With the current implementation, two concurrent FileOutputStream writes to the same file can interleave and leave a partially-corrupted file that is neither of the written byte arrays. If ByteStore is expected to be safe under concurrency, serialize access (synchronized/lock) and/or write to a temp file then atomically rename/replace to guarantee readers see a complete payload.
| public byte[] toBytes(@NotNull Configuration configuration) { | ||
| return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8); | ||
| } | ||
|
|
||
| @Override | ||
| @NotNull public Configuration fromBytes(byte[] bytes) { |
There was a problem hiding this comment.
GsonConfigurationCodec.toBytes() / fromBytes() don't enforce the interface contract that inputs must be non-null. Passing null will currently throw a NullPointerException rather than the IllegalArgumentException behavior used by ConfigurationCodec.Default, and fromBytes will also accept empty input without a clear message. Add explicit argument validation and throw IllegalArgumentException with a consistent message.
| public byte[] toBytes(@NotNull Configuration configuration) { | |
| return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8); | |
| } | |
| @Override | |
| @NotNull public Configuration fromBytes(byte[] bytes) { | |
| public byte[] toBytes(@NotNull Configuration configuration) { | |
| if (configuration == null) { | |
| throw new IllegalArgumentException("configuration must not be null"); | |
| } | |
| return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8); | |
| } | |
| @Override | |
| @NotNull public Configuration fromBytes(byte[] bytes) { | |
| if (bytes == null || bytes.length == 0) { | |
| throw new IllegalArgumentException("bytes must not be null or empty"); | |
| } |
| static { | ||
| try { | ||
| FLAGS_FIELD = Configuration.class.getDeclaredField("flags"); | ||
| FLAGS_FIELD.setAccessible(true); | ||
| BANDIT_REFS_FIELD = Configuration.class.getDeclaredField("banditReferences"); | ||
| BANDIT_REFS_FIELD.setAccessible(true); | ||
| BANDITS_FIELD = Configuration.class.getDeclaredField("bandits"); | ||
| BANDITS_FIELD.setAccessible(true); | ||
| } catch (NoSuchFieldException e) { | ||
| throw new ExceptionInInitializerError(e); | ||
| } |
There was a problem hiding this comment.
This codec depends on reflection against private field names in cloud.eppo.api.Configuration (flags, banditReferences, bandits). In Android release builds with R8/proguard, those private members can be renamed/removed unless explicitly kept, which would make this codec fail at runtime with NoSuchFieldException/IllegalAccessException. Consider shipping appropriate consumer-rules.pro keep rules (or annotating the target fields/classes if possible) to keep these members when minification is enabled.
| dependencies { | ||
| api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' | ||
|
|
There was a problem hiding this comment.
This module declares version = "0.1.0" (non-SNAPSHOT) but depends on a SNAPSHOT artifact (cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT). If this module is intended to be publishable to Maven Central as a release, it should not depend on snapshot artifacts; either make this module version a SNAPSHOT as well during development, or depend on a released framework version.
| dependencies { | ||
| api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' | ||
|
|
||
| api 'com.google.code.gson:gson:2.10.1' |
There was a problem hiding this comment.
PR description says this depends on cloud.eppo:eppo-sdk-framework-jvm, but the Gradle dependency is cloud.eppo:eppo-sdk-framework. If the description is outdated, update it; otherwise, align the artifact coordinates so consumers know which dependency is required.
| public void resumePolling() { | ||
| if (pollingIntervalMs <= 0) { | ||
| Log.w( | ||
| TAG, | ||
| "resumePolling called, but polling was not started due to invalid polling interval."); | ||
| return; | ||
| } | ||
| super.startPolling(pollingIntervalMs, pollingJitterMs); | ||
| } |
There was a problem hiding this comment.
resumePolling() relies on pollingIntervalMs/pollingJitterMs, but these fields are never assigned anywhere in this class. As a result, after pausePolling() the client cannot actually resume polling because pollingIntervalMs stays at its default (0), so resumePolling() always returns early. Store the polling params when polling is started (e.g., override startPolling(...) to assign the fields before delegating to super.startPolling(...), similar to eppo/src/main/java/cloud/eppo/android/EppoClient.java).
- resumePolling(): store pollingIntervalMs/pollingJitterMs on instance so pause/resume works after initial polling is started via Builder - safeCacheKey(): guard against keys shorter than 8 characters with Math.min to avoid StringIndexOutOfBoundsException - BaseCacheFile.setContents(): use try-with-resources to close writer even when write() throws - FileBackedByteStore: use a dedicated single-thread executor for I/O instead of ForkJoinPool.commonPool() to avoid pool saturation on low-core-count devices - ConfigCacheFile: document why deprecated package-private constructors exist (v3→v4 migration support) - build.gradle: clarify intent of EPPO_VERSION build config field
- safeCacheKey(): add null/empty guard returning "" to match Copilot suggestion; also prevents NPE on null key during initialization - GsonConfigurationCodec: delete from framework module; uses reflection on private fields of Configuration which R8 will rename in release builds (no proguard keep rules), causing NoSuchFieldException at runtime — flagged by both Copilot and code reviewer
|
Superseded by #251 (moved to force-push-friendly branch typo/v4/add-framework-module). |
Summary
Add new android-sdk-framework module that provides base client functionality and configuration storage abstractions for Android SDK implementations.
Key Components
Dependencies
This module depends on:
Testing
Notes