diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..34b5787
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+.gradle/
+build/
+local.properties
+*.iml
+.idea/
+.DS_Store
+
+.claude/
+AUDIT_REPORT.md
+FIXES_IMPLEMENTED.md
diff --git a/README.md b/README.md
index dd30a9b..647eed8 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,24 @@
-# A/B Smartly Android SDK
+# ABsmartly Android SDK
A/B Smartly - Android SDK
+## Architecture
+
+The A/B Smartly Android SDK is a **thin wrapper** around the [A/B Smartly Java SDK](https://github.com/absmartly/java-sdk). All core functionality -- including experiment evaluation, variant assignment, context management, event tracking, and HTTP client operations -- is delegated to the Java SDK.
+
+The Android SDK provides Android-specific components:
+- **SqliteAndroidLocalCache**: An Android-optimized local cache implementation using SQLite for persisting context data and publish events
+- Android-specific dependency management, ProGuard/R8 consumer rules, and configuration
+
+For the full core API reference, please refer to the [Java SDK documentation](https://github.com/absmartly/java-sdk).
+
## Compatibility
-The A/B Smartly Android SDK is compatible with Android 5 and later (API level 21+).
-It uses [A/B Smartly - Java SDK.](https://github.com/absmartly/java-sdk)
+The A/B Smartly Android SDK is compatible with Android 5.0 and later (API level 21+).
The `android.permission.INTERNET` permission is required. To add this permission to your application ensure the following line is present in the `AndroidManifest.xml` file:
```xml
-
+
```
If you target Android 6.0 or earlier, a few extra steps are outlined below for installation and initialization.
@@ -18,36 +27,595 @@ If you target Android 6.0 or earlier, a few extra steps are outlined below for i
#### Gradle
-To install the ABSmartly Android SDK, place the following in your `build.gradle` and replace {VERSION} with the latest Android SDK version available in MavenCentral.
+To install the ABsmartly Android SDK, place the following in your `build.gradle` and replace `{VERSION}` with the latest SDK version available in MavenCentral.
```gradle
dependencies {
- implementation 'com.absmartly.sdk:android-sdk:{VERSION}'
+ implementation 'com.absmartly.sdk:android-sdk:{VERSION}'
}
```
#### Android 6.0 or earlier
-When targeting Android 6.0 or earlier, the default Java Security Provider will not work. Using [Conscrypt](https://github.com/google/conscrypt) is recommended. Follow these [instructions](https://github.com/google/conscrypt/blob/master/README.md) to install it as dependency.
-## Usage
+When targeting Android 6.0 or earlier, the default Java Security Provider will not work. Using [Conscrypt](https://github.com/google/conscrypt) is recommended. Follow these [instructions](https://github.com/google/conscrypt/blob/master/README.md) to install it as a dependency.
+
+#### ProGuard / R8 Rules
+
+The Android SDK ships with consumer ProGuard rules that are automatically applied when building your release APK. These rules ensure the JSON model classes used for serialization/deserialization are preserved. If you encounter issues, verify the following rules are active:
+
+```proguard
+-keep class com.absmartly.sdk.json.** { *; }
+-keep class com.fasterxml.jackson.** { *; }
+```
+
+## Getting Started
+
+Please follow the [installation](#installation) instructions before trying the following code.
+
+### Initialization
+
+This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console.
+
+#### Recommended: ABSmartlyAndroid Wrapper (Android Entry Point)
+
+`ABSmartlyAndroid` is the recommended Android entry point. It wires the Java SDK and the `SqliteAndroidLocalCache` together in a single step, so you don't need to configure each component separately.
+
+```java
+import com.absmartly.android.sdk.ABSmartlyAndroid;
+
+final ABSmartlyAndroid sdk = ABSmartlyAndroid.builder()
+ .endpoint("https://your-company.absmartly.io/v1")
+ .apiKey("YOUR-API-KEY")
+ .application("android-app")
+ .environment("production")
+ .context(getApplicationContext())
+ .build();
+```
+
+Or using the static factory method:
+
+```java
+final ABSmartlyAndroid sdk = ABSmartlyAndroid.create(
+ "https://your-company.absmartly.io/v1",
+ "YOUR-API-KEY",
+ "android-app",
+ "production",
+ getApplicationContext()
+);
+```
+
+`ABSmartlyAndroid` delegates `createContext`, `createContextWith`, and `close` to the underlying Java SDK and exposes `getCache()` to access the `SqliteAndroidLocalCache` directly. Call `getSdk()` to access the underlying `ABsmartly` Java SDK instance for advanced use cases.
+
+#### Advanced Configuration (Java SDK directly)
+
+For advanced use cases where you need full control over the Client and configuration:
+
+```java
+import com.absmartly.sdk.*;
+
+final ClientConfig clientConfig = ClientConfig.create()
+ .setEndpoint("https://your-company.absmartly.io/v1")
+ .setAPIKey("YOUR-API-KEY")
+ .setApplication("android-app")
+ .setEnvironment("production");
+
+final Client absmartlyClient = Client.create(clientConfig);
+
+final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create()
+ .setClient(absmartlyClient);
+
+final ABsmartly sdk = ABsmartly.create(sdkConfig);
+```
+
+#### Initializing with SqliteAndroidLocalCache manually
+
+If you are using the Java SDK directly and want to wire the SQLite cache yourself:
+
+```java
+import com.absmartly.sdk.*;
+import com.absmartly.android.sdk.cache.SqliteAndroidLocalCache;
+
+SqliteAndroidLocalCache cache = new SqliteAndroidLocalCache(getApplicationContext());
+
+final ClientConfig clientConfig = ClientConfig.create()
+ .setEndpoint("https://your-company.absmartly.io/v1")
+ .setAPIKey("YOUR-API-KEY")
+ .setApplication("android-app")
+ .setEnvironment("production");
+
+final Client absmartlyClient = Client.create(clientConfig);
+
+final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create()
+ .setClient(absmartlyClient);
+
+final ABsmartly sdk = ABsmartly.create(sdkConfig);
+```
+
+#### Android 6.0 or earlier
+
+When targeting Android 6.0 or earlier, set the default Java Security Provider for SSL to *Conscrypt* by creating the *Client* instance as follows:
+
+```java
+import com.absmartly.sdk.*;
+import org.conscrypt.Conscrypt;
+
+final ClientConfig clientConfig = ClientConfig.create()
+ .setEndpoint("https://your-company.absmartly.io/v1")
+ .setAPIKey("YOUR-API-KEY")
+ .setApplication("android-app")
+ .setEnvironment("production");
+
+final DefaultHTTPClientConfig httpClientConfig = DefaultHTTPClientConfig.create()
+ .setSecurityProvider(Conscrypt.newProvider());
+
+final DefaultHTTPClient httpClient = DefaultHTTPClient.create(httpClientConfig);
+
+final Client absmartlyClient = Client.create(clientConfig, httpClient);
+
+final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create()
+ .setClient(absmartlyClient);
-The usage follows mainly the A/B Smartly Java SDK, but some Android components was created to help integrate faster with out Java SDK in Android Applications.
+final ABsmartly sdk = ABsmartly.create(sdkConfig);
+```
+
+**SDK Options**
+
+| Config | Type | Required? | Default | Description |
+| :---------------------- | :-------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| endpoint | `String` | ✅ | `null` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` |
+| apiKey | `String` | ✅ | `null` | Your API key which can be found on the Web Console. |
+| environment | `String` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. |
+| application | `String` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. |
+| timeout | `int` | ❌ | `3000` | HTTP connection timeout in milliseconds |
+| retries | `int` | ❌ | `5` | Maximum number of retry attempts for failed HTTP requests |
+| contextEventLogger | `ContextEventLogger` | ❌ | `null` | Callback to handle SDK events (ready, exposure, goal, etc.) |
+| contextDataProvider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) |
+| contextEventHandler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) |
+| variableParser | `VariableParser` | ❌ | auto | Custom parser for experiment variables (advanced usage) |
+| audienceDeserializer | `AudienceDeserializer` | ❌ | auto | Custom deserializer for audience data (advanced usage) |
+
+## Creating a New Context
+
+### Synchronously
+
+```java
+final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", "device-unique-id");
+
+final Context context = sdk.createContext(contextConfig)
+ .waitUntilReady();
+```
+
+### Asynchronously
+
+On Android, context creation must happen off the main thread. Use the asynchronous API to avoid blocking the UI:
+
+```java
+final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", "device-unique-id");
+
+sdk.createContext(contextConfig)
+ .waitUntilReadyAsync()
+ .thenAccept(ctx -> {
+ runOnUiThread(() -> {
+ // safe to update UI here
+ });
+ });
+```
+
+### With Pre-fetched Data
+
+Creating a context involves a round-trip to the A/B Smartly event collector. You can avoid repeating the round-trip by re-using data previously retrieved, for example from a server-side SDK:
+
+```java
+final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", "device-unique-id");
+
+final Context context = sdk.createContextWith(contextConfig, prefetchedContextData);
+assert(context.isReady()); // no need to wait
+```
-## Sqllite Local Cache Implementation
+### Refreshing the Context with Fresh Experiment Data
+
+For long-running contexts, the context is usually created once when the application is first started. However, any experiments started after the context was created will not be triggered. To mitigate this, use `setRefreshInterval()` on the context config.
+
+```java
+final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", "device-unique-id")
+ .setRefreshInterval(TimeUnit.HOURS.toMillis(4)); // every 4 hours
+```
+
+Alternatively, call `refresh()` manually:
+
+```java
+context.refresh();
+```
-The usage of sqllite in Android Application is differente from Java Standard Applications then a specific implementation for Android is provided for this SDK.
+### Setting Extra Units
-[SqliteAndroidLocalCache.java](https://github.com/absmartly/android-sdk/blob/main/android-sdk/src/main/java/com/absmartly/android/sdk/cache/SqliteAndroidLocalCache.java)
+You can add additional units to a context by calling `setUnit()` or `setUnits()`. For example, when a user logs in to your application, you may want to add a user-level unit to the context. Note that **you cannot override an already set unit type** as that would be a change of identity, and will throw an exception. In this case, you must create a new context instead.
-## Memory Local Cache Implementation
+```java
+context.setUnit("db_user_id", "1000013");
+
+context.setUnits(Map.of(
+ "db_user_id", "1000013"
+));
+```
+
+## Basic Usage
+
+### Selecting a Treatment
+
+```java
+if (context.getTreatment("exp_test_experiment") == 0) {
+ // user is in control group (variant 0)
+} else {
+ // user is in treatment group
+}
+```
+
+### Treatment Variables
+
+```java
+final Object variable = context.getVariable("my_variable");
+```
+
+### Peek at Treatment Variants
+
+Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. The SDK provides `peekTreatment()` for that purpose.
+
+```java
+if (context.peekTreatment("exp_test_experiment") == 0) {
+ // user is in control group (variant 0)
+} else {
+ // user is in treatment group
+}
+```
+
+#### Peeking at Variables
+
+```java
+final Object variable = context.peekVariable("my_variable");
+```
+
+### Overriding Treatment Variants
+
+During development, it is useful to force a treatment for an experiment. This can be achieved with `setOverride()` and/or `setOverrides()`. These methods can be called before the context is ready.
+
+```java
+context.setOverride("exp_test_experiment", 1);
+
+context.setOverrides(Map.of(
+ "exp_test_experiment", 1,
+ "exp_another_experiment", 0
+));
+```
-The Memory Cache component provide by [A/B Smartly Java SDK](https://github.com/absmartly/java-sdk) is compatible to be used in Android Applications if needed.
+## Advanced
-## A/B Smartly Java SDK Usage
+### Context Attributes
-All details about how to use the [A/B Smartly Java SDK](https://github.com/absmartly/java-sdk) is in the java-sdk repository.
+Attributes can be set before the context is ready.
+
+```java
+context.setAttribute("user_agent", "Android/12");
+
+context.setAttributes(Map.of(
+ "customer_age", "new_customer"
+));
+```
+
+### Tracking Goals
+
+Goals are created in the A/B Smartly web console.
+
+```java
+context.track("payment", Map.of(
+ "item_count", 1,
+ "total_amount", 1999.99
+));
+```
+
+### Publishing Pending Data
+
+Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call `publish()` or `publishAsync()`.
+
+```java
+context.publish();
+```
+
+### Finalizing
+
+The `close()` and `closeAsync()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called.
+
+```java
+context.close();
+```
+
+### Custom Event Logger
+
+The SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`.
+
+```java
+public class CustomEventLogger implements ContextEventLogger {
+ @Override
+ public void handleEvent(Context context, ContextEventLogger.EventType event, Object data) {
+ switch (event) {
+ case Exposure:
+ final Exposure exposure = (Exposure) data;
+ Log.d("ABSmartly", "exposed to experiment " + exposure.name);
+ break;
+ case Goal:
+ final GoalAchievement goal = (GoalAchievement) data;
+ Log.d("ABSmartly", "goal tracked: " + goal.name);
+ break;
+ case Error:
+ Log.e("ABSmartly", "error: " + data);
+ break;
+ case Publish:
+ case Ready:
+ case Refresh:
+ case Close:
+ break;
+ }
+ }
+}
+```
+
+Usage:
+
+```java
+// For all contexts, during SDK initialization
+final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create();
+sdkConfig.setContextEventLogger(new CustomEventLogger());
+
+// OR during a particular context initialization
+final ContextConfig contextConfig = ContextConfig.create();
+contextConfig.setEventLogger(new CustomEventLogger());
+```
+
+**Event Types**
+
+| Event | When | Data |
+| ---------- | ---------------------------------------------------------- | -------------------------------------- |
+| `Error` | `Context` receives an error | `Throwable` object |
+| `Ready` | `Context` turns ready | `ContextData` used to initialize |
+| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh |
+| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector |
+| `Exposure` | `Context.getTreatment()` succeeds on first exposure | `Exposure` enqueued for publishing |
+| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing |
+| `Close` | `Context.close()` method succeeds the first time | `null` |
+
+## Platform-Specific Examples
+
+### Using with an Application Class
+
+Initialize the SDK once in your `Application` subclass so it is available throughout the app lifecycle:
+
+```java
+import android.app.Application;
+import com.absmartly.android.sdk.ABSmartlyAndroid;
+
+public class MyApplication extends Application {
+
+ private static ABSmartlyAndroid absmartly;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ absmartly = ABSmartlyAndroid.builder()
+ .endpoint("https://your-company.absmartly.io/v1")
+ .apiKey("YOUR-API-KEY")
+ .application("android-app")
+ .environment("production")
+ .context(getApplicationContext())
+ .build();
+ }
+
+ public static ABSmartlyAndroid getAbsmartly() {
+ return absmartly;
+ }
+}
+```
+
+### Using with Activities
+
+```java
+import android.os.Bundle;
+import android.content.SharedPreferences;
+import androidx.appcompat.app.AppCompatActivity;
+import com.absmartly.sdk.*;
+import java.util.UUID;
+
+public class MainActivity extends AppCompatActivity {
+
+ private Context absmartlyContext;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ABsmartly sdk = MyApplication.getAbsmartly();
+ String deviceId = getOrCreateDeviceId();
+
+ final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", deviceId);
+
+ absmartlyContext = sdk.createContext(contextConfig);
+
+ absmartlyContext.waitUntilReadyAsync()
+ .thenAccept(ctx -> {
+ runOnUiThread(() -> setupUI(ctx));
+ })
+ .exceptionally(throwable -> {
+ runOnUiThread(() -> setupDefaultUI());
+ return null;
+ });
+ }
+
+ private void setupUI(Context context) {
+ int treatment = context.getTreatment("exp_button_color");
+
+ if (treatment == 0) {
+ setContentView(R.layout.activity_main_control);
+ } else {
+ setContentView(R.layout.activity_main_treatment);
+ }
+ }
+
+ private void setupDefaultUI() {
+ setContentView(R.layout.activity_main_control);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (absmartlyContext != null) {
+ absmartlyContext.close();
+ }
+ }
+
+ private String getOrCreateDeviceId() {
+ SharedPreferences prefs = getSharedPreferences("absmartly", MODE_PRIVATE);
+ String deviceId = prefs.getString("device_id", null);
+ if (deviceId == null) {
+ deviceId = UUID.randomUUID().toString();
+ prefs.edit().putString("device_id", deviceId).apply();
+ }
+ return deviceId;
+ }
+}
+```
+
+### Using with Fragments
+
+```java
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.fragment.app.Fragment;
+import com.absmartly.sdk.*;
+
+public class ProductFragment extends Fragment {
+
+ private Context absmartlyContext;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ ABsmartly sdk = MyApplication.getAbsmartly();
+
+ final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", getDeviceId());
+
+ absmartlyContext = sdk.createContext(contextConfig);
+
+ absmartlyContext.waitUntilReadyAsync()
+ .thenAccept(ctx -> {
+ requireActivity().runOnUiThread(() -> {
+ int treatment = ctx.getTreatment("exp_product_layout");
+ if (treatment == 1) {
+ // apply treatment layout changes
+ }
+ });
+ });
+
+ return inflater.inflate(R.layout.fragment_product, container, false);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (absmartlyContext != null) {
+ absmartlyContext.close();
+ }
+ }
+
+ private String getDeviceId() {
+ return requireActivity()
+ .getSharedPreferences("absmartly", android.content.Context.MODE_PRIVATE)
+ .getString("device_id", "");
+ }
+}
+```
+
+### Lifecycle-Aware Context Cancellation
+
+Cancel in-flight context creation when the Activity or Fragment is destroyed:
+
+```java
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import com.absmartly.sdk.*;
+import java8.util.concurrent.CompletableFuture;
+
+public class MainActivity extends AppCompatActivity {
+
+ private CompletableFuture contextFuture;
+ private Context absmartlyContext;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ ABsmartly sdk = MyApplication.getAbsmartly();
+
+ final ContextConfig contextConfig = ContextConfig.create()
+ .setUnit("device_id", getOrCreateDeviceId());
+
+ absmartlyContext = sdk.createContext(contextConfig);
+ contextFuture = absmartlyContext.waitUntilReadyAsync();
+
+ contextFuture.thenAccept(ctx -> {
+ runOnUiThread(() -> setupUI(ctx));
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ if (contextFuture != null && !contextFuture.isDone()) {
+ contextFuture.cancel(true);
+ }
+
+ if (absmartlyContext != null) {
+ absmartlyContext.close();
+ }
+ }
+}
+```
+
+## Android-Specific Components
+
+### SqliteAndroidLocalCache
+
+The Android SDK provides an Android-optimized implementation using SQLite for persisting context data and pending publish events. This ensures events are not lost if the app is terminated before they can be sent to the collector.
+
+```java
+import com.absmartly.android.sdk.cache.SqliteAndroidLocalCache;
+
+SqliteAndroidLocalCache cache = new SqliteAndroidLocalCache(getApplicationContext());
+
+// Store context data
+cache.writeContextData(context.getData());
+
+// Retrieve context data
+ContextData cachedData = cache.getContextData();
+
+// Store a publish event for later retry
+cache.writePublishEvent(publishEvent);
+
+// Retrieve and clear all pending publish events
+List pendingEvents = cache.retrievePublishEvents();
+```
+
+The SQLite database is named `absmartly.db` and is automatically created in the application's default database directory.
## About A/B Smartly
+
**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them.
A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics.
@@ -57,4 +625,13 @@ A/B Smartly's real-time analytics helps engineering and product teams ensure tha
- [PHP SDK](https://www.github.com/absmartly/php-sdk)
- [Swift SDK](https://www.github.com/absmartly/swift-sdk)
- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk)
-- [Android SDK](https://www.github.com/absmartly/android-sdk)
+- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk)
+- [React SDK](https://www.github.com/absmartly/react-sdk)
+- [Angular SDK](https://www.github.com/absmartly/angular-sdk)
+- [Android SDK](https://www.github.com/absmartly/android-sdk) (this package)
+- [Python3 SDK](https://www.github.com/absmartly/python3-sdk)
+- [Go SDK](https://www.github.com/absmartly/go-sdk)
+- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk)
+- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk)
+- [Dart SDK](https://www.github.com/absmartly/dart-sdk)
+- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk)
diff --git a/android-sdk/build.gradle b/android-sdk/build.gradle
index 3aeddac..62aea31 100644
--- a/android-sdk/build.gradle
+++ b/android-sdk/build.gradle
@@ -10,11 +10,11 @@ ext {
android {
namespace 'com.absmartly.android.sdk'
- compileSdk 33
+ compileSdk 35
defaultConfig {
minSdk 21
- targetSdk 33
+ targetSdk 35
versionCode 1
versionName "1.0"
@@ -25,11 +25,17 @@ android {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ consumerProguardFiles 'consumer-proguard-rules.pro'
}
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_6
- targetCompatibility JavaVersion.VERSION_1_6
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
}
publishing {
singleVariant("release") {
@@ -40,15 +46,20 @@ android {
}
dependencies {
+ implementation 'androidx.annotation:annotation:1.7.1'
- implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.8.0'
testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.mockito:mockito-core:4.11.0'
+ testImplementation 'org.mockito:mockito-inline:4.11.0'
+ testImplementation 'org.robolectric:robolectric:4.10.3'
+
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- implementation 'com.absmartly.sdk:core-api:1.6.0'
- implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.4.2'
+ compileOnly files('../../java-sdk/core-api/build/libs/core-api.jar')
+ testImplementation files('../../java-sdk/core-api/build/libs/core-api.jar')
+ testImplementation 'net.sourceforge.streamsupport:streamsupport-minifuture:1.7.4'
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation 'org.conscrypt:conscrypt-android:2.5.2'
}
diff --git a/android-sdk/consumer-proguard-rules.pro b/android-sdk/consumer-proguard-rules.pro
new file mode 100644
index 0000000..db94338
--- /dev/null
+++ b/android-sdk/consumer-proguard-rules.pro
@@ -0,0 +1,12 @@
+# Keep Jackson annotations and model classes for ABSmartly SDK
+-keepattributes *Annotation*
+
+# Keep all classes in the json package
+-keep class com.absmartly.sdk.json.** { *; }
+
+# Keep Jackson serialization
+-keep class com.fasterxml.jackson.** { *; }
+-keepclassmembers class * {
+ @com.fasterxml.jackson.annotation.* ;
+ @com.fasterxml.jackson.annotation.* ;
+}
diff --git a/android-sdk/src/main/java/com/absmartly/android/sdk/ABSmartlyAndroid.java b/android-sdk/src/main/java/com/absmartly/android/sdk/ABSmartlyAndroid.java
new file mode 100644
index 0000000..2f34134
--- /dev/null
+++ b/android-sdk/src/main/java/com/absmartly/android/sdk/ABSmartlyAndroid.java
@@ -0,0 +1,151 @@
+package com.absmartly.android.sdk;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.absmartly.android.sdk.cache.SqliteAndroidLocalCache;
+import com.absmartly.sdk.ABsmartly;
+import com.absmartly.sdk.ABsmartlyConfig;
+import com.absmartly.sdk.Client;
+import com.absmartly.sdk.ClientConfig;
+import com.absmartly.sdk.ContextConfig;
+import com.absmartly.sdk.json.ContextData;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Objects;
+
+public class ABSmartlyAndroid implements Closeable {
+
+ public static ABSmartlyAndroid create(
+ @NonNull String endpoint,
+ @NonNull String apiKey,
+ @NonNull String application,
+ @NonNull String environment,
+ @NonNull Context androidContext) {
+ Objects.requireNonNull(endpoint, "endpoint is required");
+ Objects.requireNonNull(apiKey, "apiKey is required");
+ Objects.requireNonNull(application, "application is required");
+ Objects.requireNonNull(environment, "environment is required");
+ Objects.requireNonNull(androidContext, "Android Context is required");
+ return new ABSmartlyAndroid(endpoint, apiKey, application, environment, androidContext);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ private final ABsmartly sdk;
+ private final SqliteAndroidLocalCache cache;
+
+ private ABSmartlyAndroid(
+ @NonNull String endpoint,
+ @NonNull String apiKey,
+ @NonNull String application,
+ @NonNull String environment,
+ @NonNull Context androidContext) {
+ this.cache = new SqliteAndroidLocalCache(androidContext.getApplicationContext());
+
+ final ClientConfig clientConfig = ClientConfig.create()
+ .setEndpoint(endpoint)
+ .setAPIKey(apiKey)
+ .setApplication(application)
+ .setEnvironment(environment);
+
+ final ABsmartlyConfig sdkConfig = ABsmartlyConfig.create()
+ .setClient(Client.create(clientConfig));
+
+ this.sdk = ABsmartly.create(sdkConfig);
+ }
+
+ ABSmartlyAndroid(@NonNull ABsmartly sdk, @NonNull SqliteAndroidLocalCache cache) {
+ this.sdk = sdk;
+ this.cache = cache;
+ }
+
+ public com.absmartly.sdk.Context createContext(@NonNull ContextConfig config) {
+ return sdk.createContext(config);
+ }
+
+ public com.absmartly.sdk.Context createContextWith(@NonNull ContextConfig config, ContextData data) {
+ return sdk.createContextWith(config, data);
+ }
+
+ public ABsmartly getSdk() {
+ return sdk;
+ }
+
+ public SqliteAndroidLocalCache getCache() {
+ return cache;
+ }
+
+ @Override
+ public void close() throws IOException {
+ IOException sdkException = null;
+ try {
+ sdk.close();
+ } catch (IOException e) {
+ sdkException = e;
+ }
+ try {
+ cache.close();
+ } catch (Exception e) {
+ if (sdkException != null) {
+ sdkException.addSuppressed(e);
+ throw sdkException;
+ }
+ if (e instanceof IOException) {
+ throw (IOException) e;
+ }
+ throw new IOException(e);
+ }
+ if (sdkException != null) {
+ throw sdkException;
+ }
+ }
+
+ public static final class Builder {
+ private String endpoint;
+ private String apiKey;
+ private String application;
+ private String environment;
+ private Context androidContext;
+
+ private Builder() {}
+
+ public Builder endpoint(@NonNull String endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ public Builder apiKey(@NonNull String apiKey) {
+ this.apiKey = apiKey;
+ return this;
+ }
+
+ public Builder application(@NonNull String application) {
+ this.application = application;
+ return this;
+ }
+
+ public Builder environment(@NonNull String environment) {
+ this.environment = environment;
+ return this;
+ }
+
+ public Builder context(@NonNull Context androidContext) {
+ this.androidContext = androidContext;
+ return this;
+ }
+
+ public ABSmartlyAndroid build() {
+ if (endpoint == null) throw new IllegalStateException("endpoint is required");
+ if (apiKey == null) throw new IllegalStateException("apiKey is required");
+ if (application == null) throw new IllegalStateException("application is required");
+ if (environment == null) throw new IllegalStateException("environment is required");
+ if (androidContext == null) throw new IllegalStateException("Android Context is required");
+ return new ABSmartlyAndroid(endpoint, apiKey, application, environment, androidContext);
+ }
+ }
+}
diff --git a/android-sdk/src/main/java/com/absmartly/android/sdk/cache/SqliteAndroidLocalCache.java b/android-sdk/src/main/java/com/absmartly/android/sdk/cache/SqliteAndroidLocalCache.java
index ca3389e..fa582da 100644
--- a/android-sdk/src/main/java/com/absmartly/android/sdk/cache/SqliteAndroidLocalCache.java
+++ b/android-sdk/src/main/java/com/absmartly/android/sdk/cache/SqliteAndroidLocalCache.java
@@ -1,112 +1,199 @@
package com.absmartly.android.sdk.cache;
-
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
-import com.absmartly.sdk.cache.LocalCache;
import com.absmartly.sdk.json.ContextData;
import com.absmartly.sdk.json.PublishEvent;
import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.MapperFeature;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
-public class SqliteAndroidLocalCache extends SQLiteOpenHelper implements LocalCache {
+public class SqliteAndroidLocalCache extends SQLiteOpenHelper {
+ private static final String TAG = "ABSmartlyCache";
private static final int DATABASE_VERSION = 1;
private static final String DATABASE_NAME = "absmartly.db";
- private final ObjectMapper mapper;
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
- public SqliteAndroidLocalCache(Context context) {
+ public SqliteAndroidLocalCache(@NonNull Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
- this.mapper = new ObjectMapper();
}
@Override
- public void onCreate(SQLiteDatabase sqLiteDatabase) {
- sqLiteDatabase.execSQL(
- "create table if not exists events (id INTEGER PRIMARY KEY AUTOINCREMENT, event text)");
-
- sqLiteDatabase.execSQL(
- "create table if not exists context (id INTEGER PRIMARY KEY AUTOINCREMENT, context text)");
+ public void onCreate(@NonNull SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL)");
+ db.execSQL("CREATE TABLE IF NOT EXISTS context (id INTEGER PRIMARY KEY AUTOINCREMENT, context TEXT NOT NULL)");
}
@Override
- public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {
-
+ public void onUpgrade(@NonNull SQLiteDatabase db, int oldVersion, int newVersion) {
+ Log.e(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion + " — dropping all cached data");
+ db.execSQL("DROP TABLE IF EXISTS events");
+ db.execSQL("DROP TABLE IF EXISTS context");
+ onCreate(db);
}
- public String serializeEvent(PublishEvent event) {
+ @NonNull
+ private String serialize(@NonNull Object value) {
try {
- return this.mapper.writeValueAsString(event);
+ return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
- throw new RuntimeException(e);
+ Log.e(TAG, "Failed to serialize " + value.getClass().getSimpleName(), e);
+ throw new CacheSerializationException("Failed to serialize " + value.getClass().getSimpleName(), e);
}
}
- public PublishEvent deserializeEvent(String eventStr) {
+ @Nullable
+ private T deserialize(@Nullable String json, @NonNull Class clazz) {
+ if (json == null) {
+ Log.w(TAG, "Attempted to deserialize null JSON for " + clazz.getSimpleName());
+ return null;
+ }
try {
- return this.mapper.readValue(eventStr, PublishEvent.class);
+ return MAPPER.readValue(json, clazz);
} catch (IOException e) {
- throw new RuntimeException(e);
+ Log.e(TAG, "Failed to deserialize " + clazz.getSimpleName() + " (length=" + json.length() + ")", e);
+ return null;
}
}
- public String serializeContext(ContextData context) {
+ @NonNull
+ private String serializeEvent(@NonNull PublishEvent event) {
+ return serialize(event);
+ }
+
+ @Nullable
+ private PublishEvent deserializeEvent(@Nullable String eventStr) {
+ return deserialize(eventStr, PublishEvent.class);
+ }
+
+ @NonNull
+ private String serializeContext(@NonNull ContextData context) {
+ return serialize(context);
+ }
+
+ @Nullable
+ private ContextData deserializeContext(@Nullable String contextStr) {
+ return deserialize(contextStr, ContextData.class);
+ }
+
+ public void writePublishEvent(@NonNull PublishEvent publishEvent) {
try {
- return this.mapper.writeValueAsString(context);
- } catch (JsonProcessingException e) {
- throw new RuntimeException(e);
+ final SQLiteDatabase db = getWritableDatabase();
+ db.beginTransaction();
+ try {
+ db.execSQL("INSERT INTO events (event) VALUES (?)", new Object[]{serializeEvent(publishEvent)});
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ } catch (SQLiteException e) {
+ Log.e(TAG, "Failed to write publish event", e);
+ throw new CacheOperationException("Failed to write publish event", e);
+ } catch (CacheSerializationException e) {
+ Log.e(TAG, "Failed to serialize publish event", e);
+ throw e;
}
}
- public ContextData deserializeContext(String eventStr) {
+ @NonNull
+ public List retrievePublishEvents() {
+ Cursor cursor = null;
try {
- return this.mapper.readValue(eventStr, ContextData.class);
- } catch (IOException e) {
- throw new RuntimeException(e);
+ final SQLiteDatabase db = getWritableDatabase();
+ db.beginTransaction();
+ try {
+ cursor = db.rawQuery("SELECT event FROM events", null);
+ List events = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ String eventStr = cursor.getString(0);
+ PublishEvent event = deserializeEvent(eventStr);
+ if (event != null) {
+ events.add(event);
+ } else {
+ Log.w(TAG, "Skipping corrupted event data");
+ }
+ }
+ db.execSQL("DELETE FROM events");
+ db.setTransactionSuccessful();
+ return events;
+ } finally {
+ db.endTransaction();
+ }
+ } catch (SQLiteException e) {
+ Log.e(TAG, "Failed to retrieve publish events", e);
+ throw new CacheOperationException("Failed to retrieve publish events", e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
}
}
-
- @Override
- public void writePublishEvent(PublishEvent publishEvent) {
- this.getWritableDatabase().execSQL("insert into events (event) values (?)", new Object[] { serializeEvent(publishEvent) });
+ public void writeContextData(@NonNull ContextData contextData) {
+ try {
+ final SQLiteDatabase db = getWritableDatabase();
+ db.beginTransaction();
+ try {
+ db.execSQL("DELETE FROM context");
+ db.execSQL("INSERT INTO context (context) VALUES (?)", new Object[]{serializeContext(contextData)});
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ } catch (SQLiteException e) {
+ Log.e(TAG, "Failed to write context data", e);
+ throw new CacheOperationException("Failed to write context data", e);
+ } catch (CacheSerializationException e) {
+ Log.e(TAG, "Failed to serialize context data", e);
+ throw e;
+ }
}
- @Override
- public List retrievePublishEvents() {
- Cursor cursor = this.getWritableDatabase().rawQuery("select event from events", null);
- List events = new ArrayList();
- while(cursor.moveToNext()){
- String eventStr = cursor.getString(0);
- events.add(this.deserializeEvent(eventStr));
+ @Nullable
+ public ContextData getContextData() {
+ Cursor cursor = null;
+ try {
+ final SQLiteDatabase db = getReadableDatabase();
+ cursor = db.rawQuery("SELECT context FROM context ORDER BY id DESC LIMIT 1", null);
+ if (cursor.moveToNext()) {
+ String contextStr = cursor.getString(0);
+ return deserializeContext(contextStr);
+ }
+ return null;
+ } catch (SQLiteException e) {
+ Log.e(TAG, "Failed to get context data", e);
+ throw new CacheOperationException("Failed to get context data", e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
}
- this.getWritableDatabase().execSQL("DELETE FROM events");
- return events;
}
- @Override
- public void writeContextData(ContextData contextData) {
- this.getWritableDatabase().execSQL("insert into context (context) values (?)", new Object[] { serializeContext(contextData) });
-
+ public static class CacheOperationException extends RuntimeException {
+ public CacheOperationException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
- @Override
- public ContextData getContextData() {
- Cursor cursor = this.getWritableDatabase().rawQuery("select context from context", null);
- ContextData contextData = null;
- if(cursor.moveToNext()){
- String contextStr = cursor.getString(0);
- contextData = this.deserializeContext(contextStr);
+ public static class CacheSerializationException extends RuntimeException {
+ public CacheSerializationException(String message, Throwable cause) {
+ super(message, cause);
}
- return contextData;
}
}
\ No newline at end of file
diff --git a/android-sdk/src/test/java/com/absmartly/android/sdk/ABSmartlyAndroidTest.java b/android-sdk/src/test/java/com/absmartly/android/sdk/ABSmartlyAndroidTest.java
new file mode 100644
index 0000000..ae1360e
--- /dev/null
+++ b/android-sdk/src/test/java/com/absmartly/android/sdk/ABSmartlyAndroidTest.java
@@ -0,0 +1,134 @@
+package com.absmartly.android.sdk;
+
+import android.content.Context;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import static org.junit.Assert.*;
+
+@RunWith(RobolectricTestRunner.class)
+public class ABSmartlyAndroidTest {
+
+ private Context androidContext;
+
+ @Before
+ public void setUp() {
+ androidContext = RuntimeEnvironment.getApplication();
+ }
+
+ @Test
+ public void builder_missingEndpoint_throwsIllegalStateException() {
+ try {
+ ABSmartlyAndroid.builder()
+ .apiKey("key")
+ .application("app")
+ .environment("env")
+ .context(androidContext)
+ .build();
+ fail("expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("endpoint"));
+ }
+ }
+
+ @Test
+ public void builder_missingApiKey_throwsIllegalStateException() {
+ try {
+ ABSmartlyAndroid.builder()
+ .endpoint("https://test.absmartly.io/v1")
+ .application("app")
+ .environment("env")
+ .context(androidContext)
+ .build();
+ fail("expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("apiKey"));
+ }
+ }
+
+ @Test
+ public void builder_missingApplication_throwsIllegalStateException() {
+ try {
+ ABSmartlyAndroid.builder()
+ .endpoint("https://test.absmartly.io/v1")
+ .apiKey("key")
+ .environment("env")
+ .context(androidContext)
+ .build();
+ fail("expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("application"));
+ }
+ }
+
+ @Test
+ public void builder_missingEnvironment_throwsIllegalStateException() {
+ try {
+ ABSmartlyAndroid.builder()
+ .endpoint("https://test.absmartly.io/v1")
+ .apiKey("key")
+ .application("app")
+ .context(androidContext)
+ .build();
+ fail("expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("environment"));
+ }
+ }
+
+ @Test
+ public void builder_missingAndroidContext_throwsIllegalStateException() {
+ try {
+ ABSmartlyAndroid.builder()
+ .endpoint("https://test.absmartly.io/v1")
+ .apiKey("key")
+ .application("app")
+ .environment("env")
+ .build();
+ fail("expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("Context"));
+ }
+ }
+
+ @Test
+ public void builder_allFieldsSet_returnsNonNull() {
+ ABSmartlyAndroid.Builder builder = ABSmartlyAndroid.builder()
+ .endpoint("https://test.absmartly.io/v1")
+ .apiKey("test-api-key")
+ .application("test-app")
+ .environment("test-env")
+ .context(androidContext);
+
+ assertNotNull(builder);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void create_nullEndpoint_throwsNullPointerException() {
+ ABSmartlyAndroid.create(null, "key", "app", "env", androidContext);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void create_nullApiKey_throwsNullPointerException() {
+ ABSmartlyAndroid.create("https://test.absmartly.io/v1", null, "app", "env", androidContext);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void create_nullApplication_throwsNullPointerException() {
+ ABSmartlyAndroid.create("https://test.absmartly.io/v1", "key", null, "env", androidContext);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void create_nullEnvironment_throwsNullPointerException() {
+ ABSmartlyAndroid.create("https://test.absmartly.io/v1", "key", "app", null, androidContext);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void create_nullContext_throwsNullPointerException() {
+ ABSmartlyAndroid.create("https://test.absmartly.io/v1", "key", "app", "env", null);
+ }
+}
diff --git a/android-sdk/src/test/java/com/absmartly/android/sdk/SqliteAndroidLocalCacheTest.java b/android-sdk/src/test/java/com/absmartly/android/sdk/SqliteAndroidLocalCacheTest.java
new file mode 100644
index 0000000..b9e9936
--- /dev/null
+++ b/android-sdk/src/test/java/com/absmartly/android/sdk/SqliteAndroidLocalCacheTest.java
@@ -0,0 +1,273 @@
+package com.absmartly.android.sdk;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+
+import com.absmartly.android.sdk.cache.SqliteAndroidLocalCache;
+import com.absmartly.sdk.json.ContextData;
+import com.absmartly.sdk.json.Experiment;
+import com.absmartly.sdk.json.PublishEvent;
+import com.absmartly.sdk.json.Unit;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+@RunWith(RobolectricTestRunner.class)
+public class SqliteAndroidLocalCacheTest {
+
+ private SqliteAndroidLocalCache cache;
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = RuntimeEnvironment.getApplication();
+ cache = new SqliteAndroidLocalCache(context);
+ }
+
+ @After
+ public void tearDown() {
+ if (cache != null) {
+ cache.close();
+ }
+ }
+
+ @Test
+ public void testWriteAndRetrievePublishEvent() {
+ PublishEvent event = new PublishEvent();
+ event.hashed = true;
+ event.publishedAt = System.currentTimeMillis();
+ event.units = new Unit[0];
+
+ cache.writePublishEvent(event);
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertEquals(event.hashed, events.get(0).hashed);
+ assertEquals(event.publishedAt, events.get(0).publishedAt);
+ }
+
+ @Test
+ public void testWriteMultiplePublishEvents() {
+ PublishEvent event1 = new PublishEvent();
+ event1.hashed = true;
+ event1.publishedAt = 1000L;
+ event1.units = new Unit[0];
+
+ PublishEvent event2 = new PublishEvent();
+ event2.hashed = false;
+ event2.publishedAt = 2000L;
+ event2.units = new Unit[0];
+
+ cache.writePublishEvent(event1);
+ cache.writePublishEvent(event2);
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(2, events.size());
+ assertEquals(event1.hashed, events.get(0).hashed);
+ assertEquals(event2.hashed, events.get(1).hashed);
+ }
+
+ @Test
+ public void testRetrievePublishEventsClearsTable() {
+ PublishEvent event = new PublishEvent();
+ event.hashed = true;
+ event.publishedAt = System.currentTimeMillis();
+ event.units = new Unit[0];
+
+ cache.writePublishEvent(event);
+
+ List firstRetrieval = cache.retrievePublishEvents();
+ assertEquals(1, firstRetrieval.size());
+
+ List secondRetrieval = cache.retrievePublishEvents();
+ assertNotNull(secondRetrieval);
+ assertEquals(0, secondRetrieval.size());
+ }
+
+ @Test
+ public void testRetrievePublishEventsWhenEmpty() {
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(0, events.size());
+ }
+
+ @Test
+ public void testWriteAndGetContextData() {
+ ContextData contextData = new ContextData();
+ contextData.experiments = new Experiment[0];
+
+ cache.writeContextData(contextData);
+
+ ContextData retrieved = cache.getContextData();
+
+ assertNotNull(retrieved);
+ assertNotNull(retrieved.experiments);
+ }
+
+ @Test
+ public void testGetContextDataWhenEmpty() {
+ ContextData retrieved = cache.getContextData();
+
+ assertNull(retrieved);
+ }
+
+ @Test
+ public void testWriteContextDataOverwritesPrevious() {
+ ContextData contextData1 = new ContextData();
+ Experiment exp1 = new Experiment();
+ exp1.id = 1;
+ exp1.name = "experiment1";
+ contextData1.experiments = new Experiment[]{exp1};
+
+ cache.writeContextData(contextData1);
+
+ ContextData contextData2 = new ContextData();
+ Experiment exp2 = new Experiment();
+ exp2.id = 2;
+ exp2.name = "experiment2";
+ contextData2.experiments = new Experiment[]{exp2};
+
+ cache.writeContextData(contextData2);
+
+ ContextData retrieved = cache.getContextData();
+
+ assertNotNull(retrieved);
+ assertNotNull(retrieved.experiments);
+ assertEquals(1, retrieved.experiments.length);
+ assertEquals(2, retrieved.experiments[0].id);
+ assertEquals("experiment2", retrieved.experiments[0].name);
+ }
+
+ @Test
+ public void testPersistenceAcrossInstances() {
+ PublishEvent event = new PublishEvent();
+ event.hashed = true;
+ event.publishedAt = 99999L;
+ event.units = new Unit[0];
+
+ cache.writePublishEvent(event);
+ cache.close();
+
+ SqliteAndroidLocalCache newCache = new SqliteAndroidLocalCache(context);
+ List events = newCache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertEquals(event.hashed, events.get(0).hashed);
+
+ newCache.close();
+ }
+
+ @Test
+ public void testContextDataPersistenceAcrossInstances() {
+ ContextData contextData = new ContextData();
+ Experiment exp = new Experiment();
+ exp.id = 456;
+ exp.name = "persistent-experiment";
+ contextData.experiments = new Experiment[]{exp};
+
+ cache.writeContextData(contextData);
+ cache.close();
+
+ SqliteAndroidLocalCache newCache = new SqliteAndroidLocalCache(context);
+ ContextData retrieved = newCache.getContextData();
+
+ assertNotNull(retrieved);
+ assertNotNull(retrieved.experiments);
+ assertEquals(1, retrieved.experiments.length);
+ assertEquals(456, retrieved.experiments[0].id);
+
+ newCache.close();
+ }
+
+ @Test
+ public void testDeserializeCorruptedEventDataSkipsInvalid() {
+ SQLiteDatabase db = cache.getWritableDatabase();
+ db.execSQL("INSERT INTO events (event) VALUES (?)", new Object[]{"not valid json{"});
+ db.execSQL("INSERT INTO events (event) VALUES (?)", new Object[]{"{\"hashed\":true,\"publishedAt\":1234,\"units\":[]}"});
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertTrue(events.get(0).hashed);
+ }
+
+ @Test
+ public void testDeserializeUnknownFieldsSucceeds() {
+ SQLiteDatabase db = cache.getWritableDatabase();
+ db.execSQL("INSERT INTO events (event) VALUES (?)",
+ new Object[]{"{\"hashed\":false,\"publishedAt\":5678,\"units\":[],\"unknownField\":\"value\"}"});
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertFalse(events.get(0).hashed);
+ }
+
+ @Test
+ public void testDeserializeEventWithNullFields() {
+ SQLiteDatabase db = cache.getWritableDatabase();
+ db.execSQL("INSERT INTO events (event) VALUES (?)",
+ new Object[]{"{\"hashed\":false,\"publishedAt\":0,\"units\":null}"});
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertNull(events.get(0).units);
+ }
+
+ @Test
+ public void testDeserializeCorruptedContextDataReturnsNull() {
+ SQLiteDatabase db = cache.getWritableDatabase();
+ db.execSQL("INSERT INTO context (context) VALUES (?)", new Object[]{"invalid json"});
+
+ ContextData retrieved = cache.getContextData();
+
+ assertNull(retrieved);
+ }
+
+ @Test
+ public void testWritePublishEventWithNullUnits() {
+ PublishEvent event = new PublishEvent();
+ event.hashed = true;
+ event.publishedAt = 42L;
+ event.units = null;
+
+ cache.writePublishEvent(event);
+
+ List events = cache.retrievePublishEvents();
+
+ assertNotNull(events);
+ assertEquals(1, events.size());
+ assertNull(events.get(0).units);
+ }
+
+ @Test
+ public void testWritePublishEventIsTransactional() {
+ PublishEvent event = new PublishEvent();
+ event.hashed = true;
+ event.publishedAt = 100L;
+ event.units = new Unit[0];
+
+ cache.writePublishEvent(event);
+
+ List events = cache.retrievePublishEvents();
+ assertEquals(1, events.size());
+ }
+}
diff --git a/android-sdk/src/test/java/com/absmartly/android/sdk/UnitTest.java b/android-sdk/src/test/java/com/absmartly/android/sdk/UnitTest.java
deleted file mode 100644
index b5afd0c..0000000
--- a/android-sdk/src/test/java/com/absmartly/android/sdk/UnitTest.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.absmartly.android.sdk;
-
-import org.junit.Test;
-
-import static org.junit.Assert.*;
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * @see Testing documentation
- */
-public class UnitTest {
- @Test
- public void addition_isCorrect() {
- assertEquals(4, 2 + 2);
- }
-}
\ No newline at end of file
diff --git a/android-sdk/src/test/resources/robolectric.properties b/android-sdk/src/test/resources/robolectric.properties
new file mode 100644
index 0000000..4f3945f
--- /dev/null
+++ b/android-sdk/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=33
diff --git a/build.gradle b/build.gradle
index 55b568c..176046b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id 'com.android.application' version '7.4.1' apply false
- id 'com.android.library' version '7.4.1' apply false
- id "io.github.gradle-nexus.publish-plugin" version "1.1.0"
+ id 'com.android.application' version '8.2.2' apply false
+ id 'com.android.library' version '8.2.2' apply false
+ id "io.github.gradle-nexus.publish-plugin" version "1.3.0"
}
ext {
- VERSION = "1.0.0_SNAPSHOT"
+ VERSION = "1.0.0-SNAPSHOT"
GROUP_ID = "com.absmartly.sdk"
is_release_version = !VERSION.endsWith("SNAPSHOT")
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 4302636..9ebd589 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Tue Mar 07 10:30:20 WET 2023
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
diff --git a/local.properties b/local.properties
deleted file mode 100644
index c021788..0000000
--- a/local.properties
+++ /dev/null
@@ -1,10 +0,0 @@
-## This file is automatically generated by Android Studio.
-# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
-#
-# This file should *NOT* be checked into Version Control Systems,
-# as it contains information specific to your local configuration.
-#
-# Location of the SDK. This is only used by Gradle.
-# For customization when using a Version Control System, please read the
-# header note.
-sdk.dir=/Users/hermeswaldemarin/Library/Android/sdk
\ No newline at end of file