diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index 290d5b93..c22fafcb 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:theme="@style/Theme.EppoExample" tools:targetApi="30"> @@ -24,7 +24,12 @@ + android:name="cloud.eppo.androidexample.StandardClientActivity" + android:parentActivityName="cloud.eppo.androidexample.HomeActivity" /> + + \ No newline at end of file diff --git a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java b/example/src/main/java/cloud/eppo/androidexample/HomeActivity.java similarity index 60% rename from example/src/main/java/cloud/eppo/androidexample/MainActivity.java rename to example/src/main/java/cloud/eppo/androidexample/HomeActivity.java index c64f62d4..92c50bc1 100644 --- a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/HomeActivity.java @@ -14,16 +14,17 @@ import cloud.eppo.android.EppoClient; import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; +import java.io.File; -public class MainActivity extends AppCompatActivity { +public class HomeActivity extends AppCompatActivity { private static final String API_KEY = BuildConfig.API_KEY; // Set in root-level local.properties @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); + setContentView(R.layout.activity_home); Button button = findViewById(R.id.button_start_assigner); - Intent launchAssigner = new Intent(MainActivity.this, SecondActivity.class); + Intent launchAssigner = new Intent(HomeActivity.this, StandardClientActivity.class); button.setOnClickListener(view -> startActivity(launchAssigner)); @@ -32,15 +33,39 @@ protected void onCreate(Bundle savedInstanceState) { view -> startActivity(launchAssigner.putExtra(this.getPackageName() + ".offlineMode", false))); + Button precomputedButton = findViewById(R.id.button_start_precomputed); + Intent launchPrecomputed = new Intent(HomeActivity.this, PrecomputedActivity.class); + precomputedButton.setOnClickListener(view -> startActivity(launchPrecomputed)); + Button clearCacheButton = findViewById(R.id.button_clear_cache); clearCacheButton.setOnClickListener(view -> clearCacheFile()); } private void clearCacheFile() { + // Clear standard client cache String cacheFileNameSuffix = safeCacheKey(API_KEY); ConfigCacheFile cacheFile = new ConfigCacheFile(getApplication(), cacheFileNameSuffix); cacheFile.delete(); - Toast.makeText(this, "Cache Cleared", Toast.LENGTH_SHORT).show(); + + // Clear all precomputed client caches (they include subject key hash in filename) + File filesDir = getApplication().getFilesDir(); + File[] precomputedCaches = + filesDir.listFiles( + (dir, name) -> name.startsWith("eppo-sdk-precomputed-") && name.endsWith(".json")); + int precomputedCount = 0; + if (precomputedCaches != null) { + for (File file : precomputedCaches) { + if (file.delete()) { + precomputedCount++; + } + } + } + + String message = + precomputedCount > 0 + ? "Cache Cleared (" + precomputedCount + " precomputed)" + : "Cache Cleared"; + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } @Override diff --git a/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java b/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java new file mode 100644 index 00000000..14089de5 --- /dev/null +++ b/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java @@ -0,0 +1,277 @@ +package cloud.eppo.androidexample; + +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import cloud.eppo.android.EppoPrecomputedClient; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import com.geteppo.androidexample.BuildConfig; +import com.geteppo.androidexample.R; +import java.util.ArrayList; +import java.util.List; + +/** + * Example activity demonstrating the EppoPrecomputedClient. The precomputed client computes all + * flag assignments server-side for a specific subject, providing instant lookups. + */ +public class PrecomputedActivity extends AppCompatActivity { + private static final String TAG = PrecomputedActivity.class.getSimpleName(); + private static final String API_KEY = BuildConfig.API_KEY; + + private EditText subjectInput; + private EditText flagKeyInput; + private RadioGroup flagTypeGroup; + private TextView assignmentLog; + private ScrollView assignmentLogScrollView; + private TextView statusText; + private LinearLayout attributesContainer; + private Button getAssignmentButton; + private List attributeRows = new ArrayList<>(); + + private EppoPrecomputedClient precomputedClient; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_precomputed); + + // Enable the action bar back button + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle("Precomputed Client"); + } + + subjectInput = findViewById(R.id.precomputed_subject); + flagKeyInput = findViewById(R.id.precomputed_flag_key); + flagTypeGroup = findViewById(R.id.flag_type_group); + assignmentLog = findViewById(R.id.precomputed_assignment_log); + assignmentLogScrollView = findViewById(R.id.precomputed_assignment_log_scrollview); + statusText = findViewById(R.id.precomputed_status); + attributesContainer = findViewById(R.id.attributes_container); + + findViewById(R.id.btn_init_server).setOnClickListener(view -> initializeClient(false)); + findViewById(R.id.btn_init_disk).setOnClickListener(view -> initializeClient(true)); + getAssignmentButton = findViewById(R.id.btn_get_assignment); + getAssignmentButton.setEnabled(false); + getAssignmentButton.setOnClickListener(view -> getAssignment()); + findViewById(R.id.btn_add_attribute).setOnClickListener(view -> addAttributeRow("", "")); + + // Add default attributes + addAttributeRow("platform", "android"); + addAttributeRow("appVersion", BuildConfig.VERSION_NAME); + } + + private void addAttributeRow(String key, String value) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setLayoutParams( + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + EditText keyInput = new EditText(this); + keyInput.setHint("Key"); + keyInput.setText(key); + LinearLayout.LayoutParams keyParams = + new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + keyInput.setLayoutParams(keyParams); + keyInput.setTag("key"); + + EditText valueInput = new EditText(this); + valueInput.setHint("Value"); + valueInput.setText(value); + LinearLayout.LayoutParams valueParams = + new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + valueParams.setMarginStart(8); + valueInput.setLayoutParams(valueParams); + valueInput.setTag("value"); + + Button removeButton = new Button(this); + removeButton.setText("X"); + removeButton.setMinWidth(0); + removeButton.setMinHeight(0); + removeButton.setMinimumWidth(0); + removeButton.setMinimumHeight(0); + removeButton.setPadding(16, 8, 16, 8); + LinearLayout.LayoutParams removeParams = + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + removeParams.setMarginStart(8); + removeButton.setLayoutParams(removeParams); + removeButton.setOnClickListener( + v -> { + attributesContainer.removeView(row); + attributeRows.remove(row); + }); + + row.addView(keyInput); + row.addView(valueInput); + row.addView(removeButton); + + attributesContainer.addView(row); + attributeRows.add(row); + } + + private Attributes collectAttributes() { + Attributes attributes = new Attributes(); + for (View row : attributeRows) { + EditText keyInput = row.findViewWithTag("key"); + EditText valueInput = row.findViewWithTag("value"); + if (keyInput != null && valueInput != null) { + String key = keyInput.getText().toString().trim(); + String value = valueInput.getText().toString().trim(); + if (!key.isEmpty()) { + // Try to parse as number first + try { + double numValue = Double.parseDouble(value); + attributes.put(key, EppoValue.valueOf(numValue)); + } catch (NumberFormatException e) { + // Use as string + attributes.put(key, EppoValue.valueOf(value)); + } + } + } + } + return attributes; + } + + private void initializeClient(boolean offlineMode) { + String subjectKey = subjectInput.getText().toString(); + if (TextUtils.isEmpty(subjectKey)) { + appendToLog("Subject ID is required"); + return; + } + + String source = offlineMode ? "disk" : "server"; + statusText.setText("Initializing from " + source + "..."); + appendToLog( + "Initializing precomputed client for subject: " + subjectKey + " (from " + source + ")"); + + // Collect subject attributes from the UI + Attributes subjectAttributes = collectAttributes(); + appendToLog("Subject attributes: " + subjectAttributes.size() + " attributes"); + + new EppoPrecomputedClient.Builder(API_KEY, getApplication()) + .subjectKey(subjectKey) + .subjectAttributes(subjectAttributes) + .isGracefulMode(true) + .forceReinitialize(true) + .offlineMode(offlineMode) + .assignmentLogger( + assignment -> { + Log.d( + TAG, + "Assignment logged: " + + assignment.getFeatureFlag() + + " -> " + + assignment.getVariation()); + }) + .buildAndInitAsync() + .thenAccept( + client -> { + precomputedClient = client; + runOnUiThread( + () -> { + statusText.setText("Initialized for: " + subjectKey + " (from " + source + ")"); + appendToLog("Client initialized successfully from " + source + "!"); + getAssignmentButton.setEnabled(true); + }); + }) + .exceptionally( + error -> { + Log.e(TAG, "Failed to initialize", error); + runOnUiThread( + () -> { + statusText.setText("Initialization failed"); + appendToLog("Error: " + error.getMessage()); + }); + return null; + }); + } + + private void getAssignment() { + if (precomputedClient == null) { + appendToLog("Client not initialized. Click 'From Server' or 'From Disk' first."); + return; + } + + String flagKey = flagKeyInput.getText().toString(); + if (TextUtils.isEmpty(flagKey)) { + appendToLog("Flag key is required"); + return; + } + + int selectedTypeId = flagTypeGroup.getCheckedRadioButtonId(); + String result; + + try { + if (selectedTypeId == R.id.type_string) { + result = precomputedClient.getStringAssignment(flagKey, "(default)"); + appendToLog("String assignment for '" + flagKey + "': " + result); + } else if (selectedTypeId == R.id.type_boolean) { + boolean boolResult = precomputedClient.getBooleanAssignment(flagKey, false); + appendToLog("Boolean assignment for '" + flagKey + "': " + boolResult); + } else if (selectedTypeId == R.id.type_integer) { + int intResult = precomputedClient.getIntegerAssignment(flagKey, 0); + appendToLog("Integer assignment for '" + flagKey + "': " + intResult); + } else if (selectedTypeId == R.id.type_numeric) { + double numericResult = precomputedClient.getNumericAssignment(flagKey, 0.0); + appendToLog("Numeric assignment for '" + flagKey + "': " + numericResult); + } else if (selectedTypeId == R.id.type_json) { + // JSON assignments return JsonNode - for simplicity, we show as string + appendToLog("JSON assignment for '" + flagKey + "': (use getJSONAssignment() API)"); + } else { + appendToLog("Please select a flag type"); + } + } catch (Exception e) { + appendToLog("Error getting assignment: " + e.getMessage()); + } + } + + private void appendToLog(String message) { + assignmentLog.append(message + "\n\n"); + assignmentLogScrollView.post(() -> assignmentLogScrollView.fullScroll(View.FOCUS_DOWN)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onPause() { + super.onPause(); + if (precomputedClient != null) { + precomputedClient.pausePolling(); + } + } + + @Override + public void onResume() { + super.onResume(); + if (precomputedClient != null) { + precomputedClient.resumePolling(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (precomputedClient != null) { + precomputedClient.stopPolling(); + } + } +} diff --git a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java b/example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java similarity index 85% rename from example/src/main/java/cloud/eppo/androidexample/SecondActivity.java rename to example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java index 6c64d21b..28833165 100644 --- a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.text.TextUtils; import android.util.Log; +import android.view.MenuItem; import android.view.View; import android.widget.EditText; import android.widget.ScrollView; @@ -16,8 +17,8 @@ import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; -public class SecondActivity extends AppCompatActivity { - private static final String TAG = SecondActivity.class.getSimpleName(); +public class StandardClientActivity extends AppCompatActivity { + private static final String TAG = StandardClientActivity.class.getSimpleName(); private static final String API_KEY = BuildConfig.API_KEY; // Set in root-level local.properties private EditText experiment; private EditText subject; @@ -60,6 +61,12 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_assigner); + // Enable the action bar back button + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle("Standard Client"); + } + experiment = findViewById(R.id.experiment); subject = findViewById(R.id.subject); assignmentLog = findViewById(R.id.assignment_log); @@ -95,6 +102,15 @@ private void appendToAssignmentLogView(String message) { assignmentLogScrollView.post(() -> assignmentLogScrollView.fullScroll(View.FOCUS_DOWN)); } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + // Tie into the activity's lifecycle and pause/resume polling where appropriate. @Override diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_home.xml similarity index 82% rename from example/src/main/res/layout/activity_main.xml rename to example/src/main/res/layout/activity_home.xml index 80f4152f..bc1b2bd4 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_home.xml @@ -24,10 +24,15 @@ android:id="@+id/button_start_offline_assigner" android:layout_width="match_parent" android:layout_height="wrap_content" - - android:text="@string/start_offline_assignment_activity" /> +