Skip to content

feat: add android-sdk-framework module#248

Closed
typotter wants to merge 5 commits intomainfrom
pr1-add-framework-module
Closed

feat: add android-sdk-framework module#248
typotter wants to merge 5 commits intomainfrom
pr1-add-framework-module

Conversation

@typotter
Copy link
Copy Markdown
Collaborator

Summary

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 that extends the v4 Java SDK framework
  • CachingConfigurationStore: Abstract store for cached configurations with lifecycle management
  • FileBackedConfigStore: File-based implementation for configuration persistence
  • ConfigurationCodec: Pluggable serialization/deserialization interface with default Java serialization
  • ByteStore: Low-level byte storage abstraction
  • ConfigCacheFile: Disk cache file management with content-type based extensions

Dependencies

This module depends on:

  • v4 Java SDK framework (cloud.eppo:eppo-sdk-framework-jvm)
  • slf4j-android for logging

Testing

  • Framework module builds independently: ✅
  • Framework unit tests pass: ✅
  • Existing eppo module unchanged: ✅
  • Existing eppo tests pass: ✅

Notes

  • This PR adds the framework module as a standalone component
  • The eppo module is NOT modified and remains on v3 dependencies
  • Integration with the eppo module will happen in a follow-up PR

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.
@typotter
Copy link
Copy Markdown
Collaborator Author

@copilot review this PR

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 AndroidBaseClient with 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", "");
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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()).

Suggested change
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", "");

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +47
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);
}
});
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +103
public byte[] toBytes(@NotNull Configuration configuration) {
return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8);
}

@Override
@NotNull public Configuration fromBytes(byte[] bytes) {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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");
}

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +94
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);
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +51
dependencies {
api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'

Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +52
dependencies {
api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'

api 'com.google.code.gson:gson:2.10.1'
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +412 to +420
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);
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
- 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
@typotter
Copy link
Copy Markdown
Collaborator Author

Superseded by #251 (moved to force-push-friendly branch typo/v4/add-framework-module).

@typotter typotter closed this Mar 24, 2026
@typotter typotter reopened this Mar 24, 2026
@typotter typotter closed this Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants