diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83f4b4384..f98f1fe76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## 26.1.2
+* Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization.
+* Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization.
+* Added a new config option `setMetricProvider(MetricProvider)` to allow overriding default device metrics with custom values.
+
+## 26.1.1
+* Added Content feature method `previewContent(String contentId)` (Experimental!).
+* Improved content display and refresh mechanics.
+
+* Mitigated an issue about health checks storage in explicit storage mode.
+
## 26.1.0
* Extended server configuration capabilities with server-controlled listing filters:
* Event filters (blacklist/whitelist) to control which events are recorded.
diff --git a/README.md b/README.md
index 46f7bdc8b..c48233a73 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
[](https://app.codacy.com/gh/Countly/countly-sdk-android/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
-[](https://android-arsenal.com/api?level=9)
+
# Countly Android SDK
diff --git a/app-kotlin/build.gradle b/app-kotlin/build.gradle
index 607dfabb3..4b5e6d0c7 100644
--- a/app-kotlin/build.gradle
+++ b/app-kotlin/build.gradle
@@ -25,14 +25,7 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = '1.8'
- }
+
dataBinding {
enabled = true
}
diff --git a/app/build.gradle b/app/build.gradle
index 3e21dc9c0..507064199 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -28,10 +28,19 @@ repositories {
}
}
+kotlin {
+ jvmToolchain(17)
+}
+
android {
compileSdk 35
namespace 'ly.count.android.demo'
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
signingConfigs {
release {
storeFile file('keys')
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6ca09f3f2..c12a5034f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,7 @@
@@ -105,6 +106,11 @@
android:label="@string/activity_name_content_zone"
android:configChanges="orientation|screenSize"/>
+
+
@@ -149,6 +155,21 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleConsent.java b/app/src/main/java/ly/count/android/demo/ActivityExampleConsent.java
new file mode 100644
index 000000000..e52731dcf
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleConsent.java
@@ -0,0 +1,74 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.switchmaterial.SwitchMaterial;
+
+import ly.count.android.sdk.Countly;
+
+public class ActivityExampleConsent extends AppCompatActivity {
+
+ private static final String[][] FEATURES = {
+ {"sessions", "switchSessions"},
+ {"events", "switchEvents"},
+ {"views", "switchViews"},
+ {"crashes", "switchCrashes"},
+ {"attribution", "switchAttribution"},
+ {"users", "switchUsers"},
+ {"push", "switchPush"},
+ {"starRating", "switchStarRating"},
+ {"remoteConfig", "switchRemoteConfig"},
+ {"location", "switchLocation"},
+ {"feedback", "switchFeedback"},
+ {"apm", "switchApm"},
+ {"content", "switchContent"},
+ };
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_example_consent);
+
+ // Set initial switch states and listeners
+ for (String[] feature : FEATURES) {
+ String featureName = feature[0];
+ int resId = getResources().getIdentifier(feature[1], "id", getPackageName());
+ SwitchMaterial sw = findViewById(resId);
+ if (sw != null) {
+ sw.setChecked(Countly.sharedInstance().consent().getConsent(featureName));
+ sw.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (isChecked) {
+ Countly.sharedInstance().consent().giveConsent(new String[]{featureName});
+ } else {
+ Countly.sharedInstance().consent().removeConsent(new String[]{featureName});
+ }
+ });
+ }
+ }
+
+ findViewById(R.id.btnGiveAllConsent).setOnClickListener(v -> {
+ Countly.sharedInstance().consent().giveConsentAll();
+ refreshSwitches();
+ Toast.makeText(this, "All consent given", Toast.LENGTH_SHORT).show();
+ });
+
+ findViewById(R.id.btnRemoveAllConsent).setOnClickListener(v -> {
+ Countly.sharedInstance().consent().removeConsentAll();
+ refreshSwitches();
+ Toast.makeText(this, "All consent removed", Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ private void refreshSwitches() {
+ for (String[] feature : FEATURES) {
+ int resId = getResources().getIdentifier(feature[1], "id", getPackageName());
+ SwitchMaterial sw = findViewById(resId);
+ if (sw != null) {
+ sw.setChecked(Countly.sharedInstance().consent().getConsent(feature[0]));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleContentZone.java b/app/src/main/java/ly/count/android/demo/ActivityExampleContentZone.java
index 7d728f672..dc0b14478 100644
--- a/app/src/main/java/ly/count/android/demo/ActivityExampleContentZone.java
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleContentZone.java
@@ -38,5 +38,6 @@ public void onClickChangeDeviceIdContentZone(View v) {
String newDeviceId = deviceId.isEmpty() ? UUID.randomUUID().toString() : deviceId;
Countly.sharedInstance().deviceId().setID(newDeviceId);
+ Countly.sharedInstance().consent().giveConsentAll();
}
}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleCustomEvents.java b/app/src/main/java/ly/count/android/demo/ActivityExampleCustomEvents.java
index cfbfbe7a6..fd2ead970 100644
--- a/app/src/main/java/ly/count/android/demo/ActivityExampleCustomEvents.java
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleCustomEvents.java
@@ -1,91 +1,165 @@
package ly.count.android.demo;
import android.os.Bundle;
+import android.view.LayoutInflater;
import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Toast;
+
import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.textfield.TextInputEditText;
+
+import java.util.HashMap;
import java.util.Map;
-import java.util.Random;
-import java.util.concurrent.ConcurrentHashMap;
+
import ly.count.android.sdk.Countly;
-@SuppressWarnings("UnusedParameters")
public class ActivityExampleCustomEvents extends AppCompatActivity {
+ private TextInputEditText inputEventName, inputEventCount, inputEventSum, inputEventDuration;
+ private TextInputEditText inputTimedEventName;
+ private LinearLayout segmentationContainer;
+
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_example_custom_events);
- }
- public void onClickRecordEvent01(View v) {
- Countly.sharedInstance().events().recordEvent("Custom event 1");
+ inputEventName = findViewById(R.id.inputEventName);
+ inputEventCount = findViewById(R.id.inputEventCount);
+ inputEventSum = findViewById(R.id.inputEventSum);
+ inputEventDuration = findViewById(R.id.inputEventDuration);
+ inputTimedEventName = findViewById(R.id.inputTimedEventName);
+ segmentationContainer = findViewById(R.id.segmentationContainer);
+
+ findViewById(R.id.btnAddSegment).setOnClickListener(v -> addSegmentRow());
+ findViewById(R.id.btnRecordEvent).setOnClickListener(v -> recordCustomEvent());
+ findViewById(R.id.btnStartTimedEvent).setOnClickListener(v -> startTimedEvent());
+ findViewById(R.id.btnEndTimedEvent).setOnClickListener(v -> endTimedEvent());
+ findViewById(R.id.btnCancelTimedEvent).setOnClickListener(v -> cancelTimedEvent());
+ findViewById(R.id.btnPreset1).setOnClickListener(v -> recordPresetWithSegmentation());
+ findViewById(R.id.btnPreset2).setOnClickListener(v -> recordPresetWithCountSum());
+ findViewById(R.id.btnTriggerSend).setOnClickListener(v -> {
+ Countly.sharedInstance().requestQueue().attemptToSendStoredRequests();
+ Toast.makeText(this, "Sending stored requests", Toast.LENGTH_SHORT).show();
+ });
+
+ // Add one default segment row
+ addSegmentRow();
}
- public void onClickRecordEvent02(View v) {
- Countly.sharedInstance().events().recordEvent("Custom event 2", 3);
+ private void addSegmentRow() {
+ View row = LayoutInflater.from(this).inflate(R.layout.row_segmentation, segmentationContainer, false);
+ row.findViewById(R.id.btnRemoveSegment).setOnClickListener(v -> segmentationContainer.removeView(row));
+ segmentationContainer.addView(row);
}
- public void onClickRecordEvent03(View v) {
- Countly.sharedInstance().events().recordEvent("Custom event 3", 1, 134);
+ private Map collectSegmentation() {
+ Map segmentation = new HashMap<>();
+ for (int i = 0; i < segmentationContainer.getChildCount(); i++) {
+ View row = segmentationContainer.getChildAt(i);
+ EditText keyField = row.findViewById(R.id.inputSegKey);
+ EditText valueField = row.findViewById(R.id.inputSegValue);
+ String key = keyField.getText().toString().trim();
+ String value = valueField.getText().toString().trim();
+ if (!key.isEmpty() && !value.isEmpty()) {
+ // Try to parse as number
+ try {
+ if (value.contains(".")) {
+ segmentation.put(key, Double.parseDouble(value));
+ } else {
+ segmentation.put(key, Integer.parseInt(value));
+ }
+ } catch (NumberFormatException e) {
+ segmentation.put(key, value);
+ }
+ }
+ }
+ return segmentation;
}
- public void onClickRecordEvent04(View v) {
- Countly.sharedInstance().events().recordEvent("Custom event 4", null, 1, 0, 55);
+ private void recordCustomEvent() {
+ String eventName = inputEventName.getText() != null ? inputEventName.getText().toString().trim() : "";
+ if (eventName.isEmpty()) {
+ inputEventName.setError("Event name is required");
+ return;
+ }
+
+ int count = 1;
+ double sum = 0;
+ double duration = 0;
+
+ try {
+ String countStr = inputEventCount.getText() != null ? inputEventCount.getText().toString().trim() : "";
+ if (!countStr.isEmpty()) count = Integer.parseInt(countStr);
+ } catch (NumberFormatException ignored) {}
+
+ try {
+ String sumStr = inputEventSum.getText() != null ? inputEventSum.getText().toString().trim() : "";
+ if (!sumStr.isEmpty()) sum = Double.parseDouble(sumStr);
+ } catch (NumberFormatException ignored) {}
+
+ try {
+ String durStr = inputEventDuration.getText() != null ? inputEventDuration.getText().toString().trim() : "";
+ if (!durStr.isEmpty()) duration = Double.parseDouble(durStr);
+ } catch (NumberFormatException ignored) {}
+
+ Map segmentation = collectSegmentation();
+
+ if (segmentation.isEmpty()) {
+ Countly.sharedInstance().events().recordEvent(eventName, count, sum);
+ } else {
+ Countly.sharedInstance().events().recordEvent(eventName, segmentation, count, sum, duration);
+ }
+
+ Toast.makeText(this, "Event '" + eventName + "' recorded", Toast.LENGTH_SHORT).show();
}
- public void onClickRecordEvent05(View v) {
- Map segmentation = new ConcurrentHashMap<>();
- segmentation.put("wall", "green");
- Countly.sharedInstance().events().recordEvent("Custom event 5", segmentation, 1, 0, 0);
+ private void startTimedEvent() {
+ String name = inputTimedEventName.getText() != null ? inputTimedEventName.getText().toString().trim() : "";
+ if (name.isEmpty()) {
+ inputTimedEventName.setError("Event name is required");
+ return;
+ }
+ boolean started = Countly.sharedInstance().events().startEvent(name);
+ Toast.makeText(this, started ? "Timed event '" + name + "' started" : "Could not start (already running?)", Toast.LENGTH_SHORT).show();
}
- public void onClickRecordEvent06(View v) {
- Map segmentation = new ConcurrentHashMap<>();
- segmentation.put("wall", "red");
- segmentation.put("flowers", 3);
- segmentation.put("area", 1.23);
- segmentation.put("volume", 7.88);
- Countly.sharedInstance().events().recordEvent("Custom event 6", segmentation, 15, 0, 0);
+ private void endTimedEvent() {
+ String name = inputTimedEventName.getText() != null ? inputTimedEventName.getText().toString().trim() : "";
+ if (name.isEmpty()) {
+ inputTimedEventName.setError("Event name is required");
+ return;
+ }
+ boolean ended = Countly.sharedInstance().events().endEvent(name);
+ Toast.makeText(this, ended ? "Timed event '" + name + "' ended" : "Could not end (not running?)", Toast.LENGTH_SHORT).show();
}
- public void onClickRecordEvent07(View v) {
- Map segmentation = new ConcurrentHashMap<>();
- segmentation.put("wall", "blue");
- segmentation.put("flowers", new Random().nextInt());
- segmentation.put("area", new Random().nextDouble());
- segmentation.put("volume", new Random().nextDouble());
-
- Countly.sharedInstance().events().recordEvent("Custom event 7", segmentation, 25, 10, 0);
+ private void cancelTimedEvent() {
+ String name = inputTimedEventName.getText() != null ? inputTimedEventName.getText().toString().trim() : "";
+ if (name.isEmpty()) {
+ inputTimedEventName.setError("Event name is required");
+ return;
+ }
+ boolean cancelled = Countly.sharedInstance().events().cancelEvent(name);
+ Toast.makeText(this, cancelled ? "Timed event '" + name + "' cancelled" : "Could not cancel (not running?)", Toast.LENGTH_SHORT).show();
}
- public void onClickRecordEvent08(View v) {
- Map segmentation = new ConcurrentHashMap<>();
- segmentation.put("wall", "yellow");
- Countly.sharedInstance().events().recordEvent("Custom event 8", segmentation, 25, 10, 50);
- }
-
- public void onClickRecordEvent09(View v) {
- //start timed event
- Countly.sharedInstance().events().recordEvent("Custom event 9");
- }
-
- public void onClickRecordEvent10(View v) {
- //stop timed event
- Countly.sharedInstance().events().recordEvent("Custom event 9");
- }
-
- public void onClickRecordEvent12(View v) {
- //cancel timed event
- Countly.sharedInstance().events().cancelEvent("Custom event 9");
- }
-
- public void onClickRecordEvent11(View v) {
- Map segmentation = new ConcurrentHashMap<>();
- segmentation.put("wall", "orange");
- Countly.sharedInstance().events().recordEvent("Custom event 9", segmentation, 4, 34);
+ private void recordPresetWithSegmentation() {
+ Map segmentation = new HashMap<>();
+ segmentation.put("wall", "green");
+ segmentation.put("flowers", 3);
+ Countly.sharedInstance().events().recordEvent("Preset Segmentation Event", segmentation, 1, 0, 0);
+ Toast.makeText(this, "Preset segmentation event recorded", Toast.LENGTH_SHORT).show();
}
- public void onClickTriggerSendingEvents(View v) {
- Countly.sharedInstance().requestQueue().attemptToSendStoredRequests();
+ private void recordPresetWithCountSum() {
+ Map segmentation = new HashMap<>();
+ segmentation.put("wall", "blue");
+ Countly.sharedInstance().events().recordEvent("Preset Count Sum Event", segmentation, 5, 24.5, 0);
+ Toast.makeText(this, "Preset count+sum event recorded", Toast.LENGTH_SHORT).show();
}
}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleFragments.java b/app/src/main/java/ly/count/android/demo/ActivityExampleFragments.java
new file mode 100644
index 000000000..069f23c64
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleFragments.java
@@ -0,0 +1,42 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
+
+public class ActivityExampleFragments extends AppCompatActivity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_example_fragments);
+
+ // Show Fragment A by default
+ if (savedInstanceState == null) {
+ showFragment(new DemoFragmentA(), false);
+ }
+
+ findViewById(R.id.btn_fragment_a).setOnClickListener(v -> showFragment(new DemoFragmentA(), true));
+ findViewById(R.id.btn_fragment_b).setOnClickListener(v -> showFragment(new DemoFragmentB(), true));
+ findViewById(R.id.btn_fragment_c).setOnClickListener(v -> showFragment(new DemoFragmentC(), true));
+ }
+
+ private void showFragment(Fragment fragment, boolean addToBackStack) {
+ FragmentTransaction tx = getSupportFragmentManager()
+ .beginTransaction()
+ .setCustomAnimations(
+ android.R.anim.fade_in,
+ android.R.anim.fade_out,
+ android.R.anim.fade_in,
+ android.R.anim.fade_out
+ )
+ .replace(R.id.fragment_container, fragment);
+
+ if (addToBackStack) {
+ tx.addToBackStack(null);
+ }
+
+ tx.commit();
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleLocation.java b/app/src/main/java/ly/count/android/demo/ActivityExampleLocation.java
new file mode 100644
index 000000000..8af88e93e
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleLocation.java
@@ -0,0 +1,59 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.textfield.TextInputEditText;
+
+import ly.count.android.sdk.Countly;
+
+public class ActivityExampleLocation extends AppCompatActivity {
+
+ private TextInputEditText inputCountryCode, inputCity, inputLatitude, inputLongitude, inputIpAddress;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_example_location);
+
+ inputCountryCode = findViewById(R.id.inputCountryCode);
+ inputCity = findViewById(R.id.inputCity);
+ inputLatitude = findViewById(R.id.inputLatitude);
+ inputLongitude = findViewById(R.id.inputLongitude);
+ inputIpAddress = findViewById(R.id.inputIpAddress);
+
+ findViewById(R.id.btnSetLocation).setOnClickListener(v -> setLocation());
+ findViewById(R.id.btnDisableLocation).setOnClickListener(v -> {
+ Countly.sharedInstance().location().disableLocation();
+ Toast.makeText(this, "Location disabled", Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ private String getText(TextInputEditText field) {
+ return field.getText() != null ? field.getText().toString().trim() : "";
+ }
+
+ private void setLocation() {
+ String countryCode = getText(inputCountryCode);
+ String city = getText(inputCity);
+ String lat = getText(inputLatitude);
+ String lng = getText(inputLongitude);
+ String ip = getText(inputIpAddress);
+
+ String gpsCoords = null;
+ if (!lat.isEmpty() && !lng.isEmpty()) {
+ gpsCoords = lat + "," + lng;
+ }
+
+ Countly.sharedInstance().location().setLocation(
+ countryCode.isEmpty() ? null : countryCode,
+ city.isEmpty() ? null : city,
+ gpsCoords,
+ ip.isEmpty() ? null : ip
+ );
+
+ Toast.makeText(this, "Location set", Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleOthers.java b/app/src/main/java/ly/count/android/demo/ActivityExampleOthers.java
index 4cbccbe72..80b343064 100644
--- a/app/src/main/java/ly/count/android/demo/ActivityExampleOthers.java
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleOthers.java
@@ -4,6 +4,7 @@
import android.os.Bundle;
import android.util.Log;
import android.view.View;
+import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -24,30 +25,14 @@ public void onCreate(Bundle savedInstanceState) {
setContentView(R.layout.activity_example_others);
}
- public void onClickViewOther05(View v) {
- //set user location
- String countryCode = "us";
- String city = "Höuston";
- String latitude = "29.634933";
- String longitude = "-95.220255";
- String ipAddress = null;
-
- Countly.sharedInstance().location().setLocation(countryCode, city, latitude + "," + longitude, ipAddress);
- }
-
- public void onClickViewOther06(View v) {
- //disable location
- Countly.sharedInstance().location().disableLocation();
- }
-
- public void onClickViewOther08(View v) {
- //Clearing request queue
+ public void onClickClearRequestQueue(View v) {
Countly.sharedInstance().requestQueue().flushQueues();
+ Toast.makeText(this, "Request queue cleared", Toast.LENGTH_SHORT).show();
}
- public void onClickViewOther10(View v) {
- //Doing internally stored requests
+ public void onClickSendStoredRequests(View v) {
Countly.sharedInstance().requestQueue().attemptToSendStoredRequests();
+ Toast.makeText(this, "Sending stored requests", Toast.LENGTH_SHORT).show();
}
public void onAddDirectRequestClick(View v) {
@@ -94,47 +79,28 @@ public void onAddDirectRequestClick(View v) {
Log.e("Countly", "Failed to create JSON object", e);
}
Countly.sharedInstance().requestQueue().addDirectRequest(requestMap);
+ Toast.makeText(this, "Direct request added", Toast.LENGTH_SHORT).show();
}
- public void onClickTestcrashFilterSample(View v) {
+ public void onClickTestCrashFilterSample(View v) {
Countly.sharedInstance().crashes().recordUnhandledException(new Throwable("A really secret exception"));
- }
-
- public void onClickRemoveAllConsent(View v) {
- Countly.sharedInstance().consent().removeConsentAll();
- }
-
- public void onClickGiveAllConsent(View v) {
- Countly.sharedInstance().consent().giveConsentAll();
+ Toast.makeText(this, "Secret crash recorded (should be filtered)", Toast.LENGTH_SHORT).show();
}
public void onClickReportDirectAttribution(View v) {
Countly.sharedInstance().attribution().recordDirectAttribution("countly", "{'cid':'campaign_id', 'cuid':'campaign_user_id'}");
- }
-
- String GetAdvertisingID() {
- return "12345";//this is only a dummy value
+ Toast.makeText(this, "Direct attribution reported", Toast.LENGTH_SHORT).show();
}
public void onClickReportIndirectAttribution(View v) {
Map attributionValues = new ConcurrentHashMap<>();
- attributionValues.put(AttributionIndirectKey.AdvertisingID, GetAdvertisingID());
+ attributionValues.put(AttributionIndirectKey.AdvertisingID, "12345");
Countly.sharedInstance().attribution().recordIndirectAttribution(attributionValues);
+ Toast.makeText(this, "Indirect attribution reported", Toast.LENGTH_SHORT).show();
}
public void onClickAttributionTest(View v) {
Countly.sharedInstance().attribution().recordDirectAttribution("_special_test", "{'test_object':'some value', 'other value':'123'}");
- }
-
- public void onClickBeginSession(View v) {
- Countly.sharedInstance().sessions().beginSession();
- }
-
- public void onClickUpdateSession(View v) {
- Countly.sharedInstance().sessions().updateSession();
- }
-
- public void onClickEndSession(View v) {
- Countly.sharedInstance().sessions().endSession();
+ Toast.makeText(this, "Attribution test reported", Toast.LENGTH_SHORT).show();
}
}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleSessions.java b/app/src/main/java/ly/count/android/demo/ActivityExampleSessions.java
new file mode 100644
index 000000000..265a5961e
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleSessions.java
@@ -0,0 +1,32 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import ly.count.android.sdk.Countly;
+
+public class ActivityExampleSessions extends AppCompatActivity {
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_example_sessions);
+
+ findViewById(R.id.btnBeginSession).setOnClickListener(v -> {
+ Countly.sharedInstance().sessions().beginSession();
+ Toast.makeText(this, "Session begun", Toast.LENGTH_SHORT).show();
+ });
+
+ findViewById(R.id.btnUpdateSession).setOnClickListener(v -> {
+ Countly.sharedInstance().sessions().updateSession();
+ Toast.makeText(this, "Session updated", Toast.LENGTH_SHORT).show();
+ });
+
+ findViewById(R.id.btnEndSession).setOnClickListener(v -> {
+ Countly.sharedInstance().sessions().endSession();
+ Toast.makeText(this, "Session ended", Toast.LENGTH_SHORT).show();
+ });
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleUserDetails.java b/app/src/main/java/ly/count/android/demo/ActivityExampleUserDetails.java
index c1dbcd68f..964839596 100644
--- a/app/src/main/java/ly/count/android/demo/ActivityExampleUserDetails.java
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleUserDetails.java
@@ -1,90 +1,207 @@
package ly.count.android.demo;
import android.os.Bundle;
+import android.view.LayoutInflater;
import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.android.material.textfield.TextInputEditText;
+
import java.util.HashMap;
+import java.util.Map;
import ly.count.android.sdk.Countly;
-@SuppressWarnings("UnusedParameters")
public class ActivityExampleUserDetails extends AppCompatActivity {
+ private TextInputEditText inputName, inputUsername, inputEmail, inputOrganization;
+ private TextInputEditText inputPhone, inputGender, inputBirthYear, inputPictureUrl;
+ private TextInputEditText inputOpKey, inputOpValue;
+ private LinearLayout customPropsContainer;
+ private Spinner spinnerOperation;
+
+ private static final String[] OPERATIONS = {
+ "increment", "incrementBy", "multiply", "saveMax", "saveMin",
+ "setOnce", "push", "pushUnique", "pull"
+ };
+
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_example_user_details);
- }
-
- public void onClickUserData01(View v) {
- setUserData();
- }
-
- public void onClickUserData02(View v) {
- //providing any custom key values to store with user
- HashMap custom = new HashMap<>();
- custom.put("favoriteAnimal", "dog");
-
- //set multiple custom properties
- Countly.sharedInstance().userProfile().setProperties(custom);
- Countly.sharedInstance().userProfile().save();
- }
- public void onClickUserData03(View v) {
- //providing any custom key values to store with user
- HashMap custom = new HashMap<>();
- custom.put("leastFavoritePet", "cat");
-
- //set multiple custom properties
- Countly.sharedInstance().userProfile().setProperties(custom);
- Countly.sharedInstance().userProfile().save();
+ inputName = findViewById(R.id.inputName);
+ inputUsername = findViewById(R.id.inputUsername);
+ inputEmail = findViewById(R.id.inputEmail);
+ inputOrganization = findViewById(R.id.inputOrganization);
+ inputPhone = findViewById(R.id.inputPhone);
+ inputGender = findViewById(R.id.inputGender);
+ inputBirthYear = findViewById(R.id.inputBirthYear);
+ inputPictureUrl = findViewById(R.id.inputPictureUrl);
+ inputOpKey = findViewById(R.id.inputOpKey);
+ inputOpValue = findViewById(R.id.inputOpValue);
+ customPropsContainer = findViewById(R.id.customPropsContainer);
+ spinnerOperation = findViewById(R.id.spinnerOperation);
+
+ ArrayAdapter adapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_dropdown_item, OPERATIONS);
+ spinnerOperation.setAdapter(adapter);
+
+ findViewById(R.id.btnAddCustomProp).setOnClickListener(v -> addCustomPropRow());
+ findViewById(R.id.btnSetStandardProps).setOnClickListener(v -> setStandardProperties());
+ findViewById(R.id.btnSetCustomProps).setOnClickListener(v -> setCustomProperties());
+ findViewById(R.id.btnExecuteOp).setOnClickListener(v -> executeOperation());
+ findViewById(R.id.btnSaveProfile).setOnClickListener(v -> {
+ Countly.sharedInstance().userProfile().save();
+ Toast.makeText(this, "Profile saved to server", Toast.LENGTH_SHORT).show();
+ });
+ findViewById(R.id.btnClearProfile).setOnClickListener(v -> {
+ Countly.sharedInstance().userProfile().clear();
+ Toast.makeText(this, "Profile modifications cleared", Toast.LENGTH_SHORT).show();
+ });
+
+ // Add one default row
+ addCustomPropRow();
}
- public void onClickUserData04(View v) {
-
+ private void addCustomPropRow() {
+ // Reuse the same row_segmentation layout (key-value pair with remove button)
+ View row = LayoutInflater.from(this).inflate(R.layout.row_segmentation, customPropsContainer, false);
+ row.findViewById(R.id.btnRemoveSegment).setOnClickListener(v -> customPropsContainer.removeView(row));
+ customPropsContainer.addView(row);
}
- public void onClickUserData05(View v) {
-
+ private String getText(TextInputEditText field) {
+ return field.getText() != null ? field.getText().toString().trim() : "";
}
- public void setUserData() {
+ private void setStandardProperties() {
HashMap data = new HashMap<>();
- data.put("name", "First name Last name");
- data.put("username", "nickname");
- data.put("email", "test@test.com");
- data.put("organization", "Tester");
- data.put("phone", "+123456789");
- data.put("gender", "M");
- //provide url to picture
- //data.put("picture", "http://example.com/pictures/profile_pic.png");
- //or locally from device
- //data.put("picturePath", "/mnt/sdcard/portrait.jpg");
- data.put("byear", "1987");
-
- //providing any custom key values to store with user
- data.put("Top rated Country", "Turkey");
- data.put("Favourite city", "Istanbul");
- data.put("Favourite car", "VroomVroom");
-
- //set multiple custom properties
- Countly.sharedInstance().userProfile().setProperties(data);
- //set custom properties by one
- Countly.sharedInstance().userProfile().setProperty("test", "test");
+ String name = getText(inputName);
+ String username = getText(inputUsername);
+ String email = getText(inputEmail);
+ String org = getText(inputOrganization);
+ String phone = getText(inputPhone);
+ String gender = getText(inputGender);
+ String byear = getText(inputBirthYear);
+ String picture = getText(inputPictureUrl);
+
+ if (!name.isEmpty()) data.put("name", name);
+ if (!username.isEmpty()) data.put("username", username);
+ if (!email.isEmpty()) data.put("email", email);
+ if (!org.isEmpty()) data.put("organization", org);
+ if (!phone.isEmpty()) data.put("phone", phone);
+ if (!gender.isEmpty()) data.put("gender", gender);
+ if (!byear.isEmpty()) data.put("byear", byear);
+ if (!picture.isEmpty()) data.put("picture", picture);
+
+ if (data.isEmpty()) {
+ Toast.makeText(this, "Please fill at least one field", Toast.LENGTH_SHORT).show();
+ return;
+ }
- //increment used value by 1
- Countly.sharedInstance().userProfile().incrementBy("used", 1);
+ Countly.sharedInstance().userProfile().setProperties(data);
+ Toast.makeText(this, "Standard properties set (" + data.size() + " fields)", Toast.LENGTH_SHORT).show();
+ }
- //insert value to array of unique values
- Countly.sharedInstance().userProfile().pushUnique("type", "morning");
+ private void setCustomProperties() {
+ HashMap custom = new HashMap<>();
+ for (int i = 0; i < customPropsContainer.getChildCount(); i++) {
+ View row = customPropsContainer.getChildAt(i);
+ EditText keyField = row.findViewById(R.id.inputSegKey);
+ EditText valueField = row.findViewById(R.id.inputSegValue);
+ String key = keyField.getText().toString().trim();
+ String value = valueField.getText().toString().trim();
+ if (!key.isEmpty() && !value.isEmpty()) {
+ try {
+ if (value.contains(".")) {
+ custom.put(key, Double.parseDouble(value));
+ } else {
+ custom.put(key, Integer.parseInt(value));
+ }
+ } catch (NumberFormatException e) {
+ custom.put(key, value);
+ }
+ }
+ }
+
+ if (custom.isEmpty()) {
+ Toast.makeText(this, "Please add at least one property", Toast.LENGTH_SHORT).show();
+ return;
+ }
- //insert multiple values to same property
- Countly.sharedInstance().userProfile().pushUnique("skill", "fire");
- Countly.sharedInstance().userProfile().pushUnique("skill", "earth");
+ Countly.sharedInstance().userProfile().setProperties(custom);
+ Toast.makeText(this, "Custom properties set (" + custom.size() + " fields)", Toast.LENGTH_SHORT).show();
+ }
- Countly.sharedInstance().userProfile().save();
+ private void executeOperation() {
+ String key = getText(inputOpKey);
+ String value = getText(inputOpValue);
+
+ if (key.isEmpty()) {
+ inputOpKey.setError("Key is required");
+ return;
+ }
+
+ String operation = OPERATIONS[spinnerOperation.getSelectedItemPosition()];
+
+ switch (operation) {
+ case "increment":
+ Countly.sharedInstance().userProfile().increment(key);
+ break;
+ case "incrementBy":
+ try {
+ Countly.sharedInstance().userProfile().incrementBy(key, Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "Value must be a number", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ break;
+ case "multiply":
+ try {
+ Countly.sharedInstance().userProfile().multiply(key, Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "Value must be a number", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ break;
+ case "saveMax":
+ try {
+ Countly.sharedInstance().userProfile().saveMax(key, Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "Value must be a number", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ break;
+ case "saveMin":
+ try {
+ Countly.sharedInstance().userProfile().saveMin(key, Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "Value must be a number", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ break;
+ case "setOnce":
+ Countly.sharedInstance().userProfile().setOnce(key, value);
+ break;
+ case "push":
+ Countly.sharedInstance().userProfile().push(key, value);
+ break;
+ case "pushUnique":
+ Countly.sharedInstance().userProfile().pushUnique(key, value);
+ break;
+ case "pull":
+ Countly.sharedInstance().userProfile().pull(key, value);
+ break;
+ }
+
+ Toast.makeText(this, operation + " applied to '" + key + "'", Toast.LENGTH_SHORT).show();
}
}
-
diff --git a/app/src/main/java/ly/count/android/demo/DemoFragmentA.java b/app/src/main/java/ly/count/android/demo/DemoFragmentA.java
new file mode 100644
index 000000000..a79a99188
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/DemoFragmentA.java
@@ -0,0 +1,42 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+public class DemoFragmentA extends Fragment {
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_demo_a, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ view.findViewById(R.id.btn_action_a1).setOnClickListener(v ->
+ Toast.makeText(requireContext(), "Button 1 tapped - touch works!", Toast.LENGTH_SHORT).show()
+ );
+
+ view.findViewById(R.id.btn_action_a2).setOnClickListener(v ->
+ Toast.makeText(requireContext(), "Button 2 tapped - touch works!", Toast.LENGTH_SHORT).show()
+ );
+
+ // Populate list to test scrolling with overlay
+ String[] items = new String[30];
+ for (int i = 0; i < 30; i++) {
+ items[i] = "List item " + (i + 1) + " - test scrolling with overlay";
+ }
+ ListView listView = view.findViewById(R.id.list_a);
+ listView.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, items));
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/DemoFragmentB.java b/app/src/main/java/ly/count/android/demo/DemoFragmentB.java
new file mode 100644
index 000000000..234a1eed6
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/DemoFragmentB.java
@@ -0,0 +1,28 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+public class DemoFragmentB extends Fragment {
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_demo_b, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ view.findViewById(R.id.btn_action_b1).setOnClickListener(v ->
+ Toast.makeText(requireContext(), "Touch event works in Fragment B!", Toast.LENGTH_SHORT).show()
+ );
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/DemoFragmentC.java b/app/src/main/java/ly/count/android/demo/DemoFragmentC.java
new file mode 100644
index 000000000..c19b35210
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/DemoFragmentC.java
@@ -0,0 +1,43 @@
+package ly.count.android.demo;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+public class DemoFragmentC extends Fragment {
+
+ private int navCount = 0;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_demo_c, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ TextView logText = view.findViewById(R.id.text_log);
+
+ // Navigate to a different activity (tests overlay migration activity + fragment combo)
+ view.findViewById(R.id.btn_go_to_activity).setOnClickListener(v -> {
+ navCount++;
+ logText.append("\n[" + navCount + "] Navigating to Custom Events Activity...");
+ startActivity(new Intent(requireContext(), ActivityExampleCustomEvents.class));
+ });
+
+ // Pop back stack (tests overlay during fragment back navigation)
+ view.findViewById(R.id.btn_go_back).setOnClickListener(v -> {
+ navCount++;
+ logText.append("\n[" + navCount + "] Popping back stack...");
+ requireActivity().getSupportFragmentManager().popBackStack();
+ });
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/MainActivity.java b/app/src/main/java/ly/count/android/demo/MainActivity.java
index 4b3cd2e78..d0d30cf80 100644
--- a/app/src/main/java/ly/count/android/demo/MainActivity.java
+++ b/app/src/main/java/ly/count/android/demo/MainActivity.java
@@ -4,7 +4,10 @@
import android.os.Bundle;
import android.util.Log;
import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
import com.android.installreferrer.api.InstallReferrerClient;
import com.android.installreferrer.api.InstallReferrerStateListener;
import com.android.installreferrer.api.ReferrerDetails;
@@ -21,18 +24,19 @@ public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
- /*
- To send Referrer follow these steps.
- Steps:
- 1. Start Google Play on the device using campaign link,
- for example, https://play.google.com/store/apps/details?id=ly.count.android.demo&referrer=utm_source%3Dtest_source%26utm_medium%3Dtest_medium%26
- utm_term%3Dtest-term%26utm_content%3Dtest_content%26utm_campaign%3Dtest_name
- (You can use google play generator: https://developers.google.com/analytics/devguides/collection/android/v3/campaigns#google-play-url-builder)
- 2. DON'T TAP ON INSTALL BUTTON
- 3. Install your test build using adb.
-
- Google Play will be returning your test campaign now.
- */
+ TextView txtVersion = findViewById(R.id.txtSdkVersion);
+ txtVersion.setText("SDK v" + Countly.sharedInstance().COUNTLY_SDK_VERSION_STRING);
+
+ ImageView btnNightMode = findViewById(R.id.btnToggleNightMode);
+ btnNightMode.setOnClickListener(v -> {
+ int currentMode = AppCompatDelegate.getDefaultNightMode();
+ if (currentMode == AppCompatDelegate.MODE_NIGHT_YES) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ } else {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ }
+ });
+
referrerClient = InstallReferrerClient.newBuilder(this).build();
referrerClient.startConnection(new InstallReferrerStateListener() {
@Override
@@ -41,21 +45,11 @@ public void onInstallReferrerSetupFinished(int responseCode) {
case InstallReferrerClient.InstallReferrerResponse.OK:
try {
ReferrerDetails response = referrerClient.getInstallReferrer();
-
- //you would retrieve the referrer url
String referrerUrl = response.getInstallReferrer();
-
- //and then you would parse it and retrieve the required field to identify the campaign id and user id
String campaignId = "someCampaignId";
String userId = "someUserId";
-
- // The string is usually URL Encoded, so we need to decode it.
String referrer = URLDecoder.decode(referrerUrl, "UTF-8");
-
- // Log the referrer string.
Log.d(Countly.TAG, "Received Referrer url: " + referrer);
-
- //retrieve specific parts from the referrer url
String[] parts = referrer.split("&");
for (String part : parts) {
if (part.startsWith("countly_cid")) {
@@ -65,21 +59,15 @@ public void onInstallReferrerSetupFinished(int responseCode) {
userId = part.replace("countly_cuid=", "").trim();
}
}
-
- //you would then pass those retrieved values as manual attribution:
Countly.sharedInstance().attribution().recordDirectAttribution("countly", "{\"cid\":" + campaignId + ",\"cuid\":" + userId + "}");
- //Countly.sharedInstance().attribution().recordDirectAttribution(campaignId, userId);
-
referrerClient.endConnection();
} catch (Exception e) {
Log.e(demoTag, "Error while parsing referrer", e);
}
break;
case InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED:
- // API not available on the current Play Store app.
break;
case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE:
- // Connection couldn't be established.
break;
default:
break;
@@ -90,8 +78,6 @@ public void onInstallReferrerSetupFinished(int responseCode) {
public void onInstallReferrerServiceDisconnected() {
}
});
-
- //ActivityCompat.requestPermissions(this, new String[] { Manifest.permission.BROADCAST_CLOSE_SYSTEM_DIALOGS }, 123);
}
public void onClickButtonCustomEvents(View v) {
@@ -118,10 +104,6 @@ public void onClickButtonAutoViewTracking(View v) {
startActivity(new Intent(this, ActivityExampleAutoViewTracking.class));
}
- public void onClickButtonMultiThreading(View v) {
- //
- }
-
public void onClickButtonOthers(View v) {
startActivity(new Intent(this, ActivityExampleOthers.class));
}
@@ -145,4 +127,20 @@ public void onClickButtonRatings(View v) {
public void onClickContentZone(View v) {
startActivity(new Intent(this, ActivityExampleContentZone.class));
}
+
+ public void onClickButtonFragments(View v) {
+ startActivity(new Intent(this, ActivityExampleFragments.class));
+ }
+
+ public void onClickButtonConsent(View v) {
+ startActivity(new Intent(this, ActivityExampleConsent.class));
+ }
+
+ public void onClickButtonLocation(View v) {
+ startActivity(new Intent(this, ActivityExampleLocation.class));
+ }
+
+ public void onClickButtonSessions(View v) {
+ startActivity(new Intent(this, ActivityExampleSessions.class));
+ }
}
diff --git a/app/src/main/res/drawable/bg_card_strip_rounded.xml b/app/src/main/res/drawable/bg_card_strip_rounded.xml
new file mode 100644
index 000000000..ff2bcce93
--- /dev/null
+++ b/app/src/main/res/drawable/bg_card_strip_rounded.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_header_gradient.xml b/app/src/main/res/drawable/bg_header_gradient.xml
new file mode 100644
index 000000000..0e506b491
--- /dev/null
+++ b/app/src/main/res/drawable/bg_header_gradient.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_night_mode.xml b/app/src/main/res/drawable/ic_night_mode.xml
new file mode 100644
index 000000000..f6dbb4c75
--- /dev/null
+++ b/app/src/main/res/drawable/ic_night_mode.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_example_consent.xml b/app/src/main/res/layout/activity_example_consent.xml
new file mode 100644
index 000000000..6bb3ef7c1
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_consent.xml
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_custom_events.xml b/app/src/main/res/layout/activity_example_custom_events.xml
index dbab62414..77f908b37 100644
--- a/app/src/main/res/layout/activity_example_custom_events.xml
+++ b/app/src/main/res/layout/activity_example_custom_events.xml
@@ -1,135 +1,248 @@
-
-
-
+ android:fillViewport="true">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:padding="16dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_fragments.xml b/app/src/main/res/layout/activity_example_fragments.xml
new file mode 100644
index 000000000..0d0d2034e
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_fragments.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_location.xml b/app/src/main/res/layout/activity_example_location.xml
new file mode 100644
index 000000000..8a373e475
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_location.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_others.xml b/app/src/main/res/layout/activity_example_others.xml
index c34e5f215..be80aa208 100644
--- a/app/src/main/res/layout/activity_example_others.xml
+++ b/app/src/main/res/layout/activity_example_others.xml
@@ -1,131 +1,135 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:padding="16dp">
-
+ android:orientation="vertical">
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_sessions.xml b/app/src/main/res/layout/activity_example_sessions.xml
new file mode 100644
index 000000000..5fff2dee9
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_sessions.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_example_user_details.xml b/app/src/main/res/layout/activity_example_user_details.xml
index 6b7d8110c..9b47ce7de 100644
--- a/app/src/main/res/layout/activity_example_user_details.xml
+++ b/app/src/main/res/layout/activity_example_user_details.xml
@@ -1,54 +1,282 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+ android:clipToPadding="false"
+ android:padding="16dp"
+ tools:context=".ActivityExampleUserDetails">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index e4c0ef77c..5c0255684 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,128 +1,789 @@
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/fragment_demo_a.xml b/app/src/main/res/layout/fragment_demo_a.xml
new file mode 100644
index 000000000..fb9a5df5c
--- /dev/null
+++ b/app/src/main/res/layout/fragment_demo_a.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_demo_b.xml b/app/src/main/res/layout/fragment_demo_b.xml
new file mode 100644
index 000000000..cfc57f5e8
--- /dev/null
+++ b/app/src/main/res/layout/fragment_demo_b.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_demo_c.xml b/app/src/main/res/layout/fragment_demo_c.xml
new file mode 100644
index 000000000..ffa300f74
--- /dev/null
+++ b/app/src/main/res/layout/fragment_demo_c.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/row_segmentation.xml b/app/src/main/res/layout/row_segmentation.xml
new file mode 100644
index 000000000..97b7ab8dd
--- /dev/null
+++ b/app/src/main/res/layout/row_segmentation.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 000000000..bc7215392
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,33 @@
+
+
+
+ #56C467
+ #3DA64D
+ #1B3A1E
+
+
+ #66D477
+ #121212
+ #338A3E
+ #FFCC80
+ #4DB6AC
+ #1E1E1E
+ #121212
+ #000000
+ #E0E0E0
+ #9E9E9E
+ #2E2E2E
+
+
+ #66D477
+ #66D477
+ #EF5350
+ #64B5F6
+ #64B5F6
+ #FFB74D
+ #CE93D8
+
+
+ #2E7D32
+ #00695C
+
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
new file mode 100644
index 000000000..a5c5d2493
--- /dev/null
+++ b/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..fe6d7b837
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,33 @@
+
+
+
+ #56C467
+ #3DA64D
+ #E8F5E9
+
+
+ #56C467
+ #3DA64D
+ #2E7D32
+ #FFB74D
+ #26A69A
+ #FFFFFF
+ #F0F4F0
+ #FFFFFF
+ #1B2A1B
+ #5F7060
+ #C8E6C9
+
+
+ #56C467
+ #56C467
+ #EF5350
+ #42A5F5
+ #42A5F5
+ #FFA726
+ #AB47BC
+
+
+ #56C467
+ #26A69A
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 64cbdb81f..4c48d48aa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -67,4 +67,7 @@
Feedback examples
Content Zone
Device ID
+ Consent Management
+ Location
+ Sessions
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 09395851b..495264a89 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,17 +1,52 @@
-
-
+
-
+
-
+
-
+
+
+
+
+
+
+
diff --git a/gradle.properties b/gradle.properties
index aae29e8ac..7f471cbdd 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,7 +22,7 @@ org.gradle.configureondemand=true
android.useAndroidX=true
android.enableJetifier=true
# RELEASE FIELD SECTION
-VERSION_NAME=26.1.0
+VERSION_NAME=26.1.2
GROUP=ly.count.android
POM_URL=https://github.com/Countly/countly-sdk-android
POM_SCM_URL=https://github.com/Countly/countly-sdk-android
diff --git a/sdk/src/androidTest/AndroidManifest.xml b/sdk/src/androidTest/AndroidManifest.xml
index c790cbd36..1aab0afbd 100644
--- a/sdk/src/androidTest/AndroidManifest.xml
+++ b/sdk/src/androidTest/AndroidManifest.xml
@@ -13,6 +13,9 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:launchMode="singleTop"
android:exported="false"/>
+
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java
new file mode 100644
index 000000000..438ad7d5c
--- /dev/null
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java
@@ -0,0 +1,793 @@
+package ly.count.android.sdk;
+
+import android.app.Activity;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.graphics.PixelFormat;
+import android.view.View;
+import android.view.WindowManager;
+import androidx.annotation.Nullable;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Instrumented tests for ContentOverlayView.
+ *
+ * These tests cover logic that is hard or impossible to verify manually:
+ * - URL parsing (splitQuery) via contentUrlAction/widgetUrlAction
+ * - Action routing (event, resize_me, close, widget commands)
+ * - Close/destroy lifecycle and cleanup verification
+ * - WindowManager.LayoutParams creation (type, flags, offsets)
+ * - Initial state and configuration selection
+ *
+ * Maps to test plan sections: 10 (Token/WM), 12 (Actions/Communication), 13 (Cleanup/Memory)
+ */
+@RunWith(AndroidJUnit4.class)
+public class ContentOverlayViewTests {
+
+ private ContentOverlayView overlay;
+ private ActivityScenario scenario;
+
+ /**
+ * Bare activity used as a host for ContentOverlayView in tests.
+ * Declared in sdk/src/androidTest/AndroidManifest.xml.
+ */
+ public static class OverlayTestActivity extends Activity {
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+ }
+
+ @Before
+ public void setUp() {
+ TestUtils.getCountlyStore().clear();
+ Countly.sharedInstance().halt();
+
+ CountlyConfig config = TestUtils.createBaseConfig();
+ Countly.sharedInstance().init(config);
+ }
+
+ @After
+ public void tearDown() {
+ if (overlay != null && scenario != null) {
+ try {
+ scenario.onActivity(activity -> overlay.destroy());
+ } catch (Exception ignored) {
+ }
+ overlay = null;
+ }
+ if (scenario != null) {
+ try {
+ scenario.close();
+ } catch (Exception ignored) {
+ }
+ scenario = null;
+ }
+ TestUtils.getCountlyStore().clear();
+ Countly.sharedInstance().halt();
+ }
+
+ // ===================== Helpers =====================
+
+ private ContentOverlayView createOverlay(Activity activity) {
+ return createOverlay(activity, null, null);
+ }
+
+ private ContentOverlayView createOverlay(Activity activity,
+ @Nullable ContentCallback callback, @Nullable Runnable onClose) {
+ TransparentActivityConfig portrait = new TransparentActivityConfig(0, 0, 300, 500);
+ portrait.url = "about:blank";
+ portrait.useSafeArea = false;
+
+ TransparentActivityConfig landscape = new TransparentActivityConfig(0, 0, 500, 300);
+ landscape.url = "about:blank";
+ landscape.useSafeArea = false;
+
+ return new ContentOverlayView(
+ activity, portrait, landscape,
+ activity.getResources().getConfiguration().orientation,
+ callback,
+ onClose != null ? onClose : () -> {
+ }
+ );
+ }
+
+ private Object getField(String fieldName) throws Exception {
+ Field field = ContentOverlayView.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(overlay);
+ }
+
+ private WindowManager.LayoutParams invokeCreateWindowParams(
+ Activity activity, TransparentActivityConfig config) throws Exception {
+ Method method = ContentOverlayView.class.getDeclaredMethod(
+ "createWindowParams", Activity.class, TransparentActivityConfig.class);
+ method.setAccessible(true);
+ return (WindowManager.LayoutParams) method.invoke(overlay, activity, config);
+ }
+
+ /**
+ * Launches the test activity, runs the given action on the main thread,
+ * and stores the scenario for cleanup.
+ */
+ private void withActivity(ActivityAction action) {
+ scenario = ActivityScenario.launch(OverlayTestActivity.class);
+ scenario.onActivity(action::run);
+ }
+
+ @FunctionalInterface
+ interface ActivityAction {
+ void run(Activity activity);
+ }
+
+ // ===================== contentUrlAction — URL Parsing & Routing =====================
+
+ /**
+ * Valid event action URL is recognized and returns true.
+ * Tests splitQuery parsing of JSON array in "event" parameter.
+ */
+ @Test
+ public void contentUrlAction_eventAction_returnsTrue() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event"
+ + "&event=[{\"key\":\"test_key\",\"sg\":{\"color\":\"blue\"}}]";
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * Event with "segmentation" field (alternative to "sg") is parsed.
+ */
+ @Test
+ public void contentUrlAction_eventWithSegmentationField_returnsTrue() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event"
+ + "&event=[{\"key\":\"click\",\"segmentation\":{\"button\":\"submit\"}}]";
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * Multiple events in a single JSON array are all processed without error.
+ */
+ @Test
+ public void contentUrlAction_multipleEvents_returnsTrue() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event"
+ + "&event=[{\"key\":\"e1\",\"sg\":{\"k\":\"v1\"}},{\"key\":\"e2\",\"sg\":{\"k\":\"v2\"}}]";
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * resize_me action parses JSON and updates portrait/landscape configs.
+ */
+ @Test
+ public void contentUrlAction_resizeMe_updatesConfig() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ float density = activity.getResources().getDisplayMetrics().density;
+
+ String resizeJson = "{\"p\":{\"x\":5,\"y\":10,\"w\":200,\"h\":400},\"l\":{\"x\":10,\"y\":5,\"w\":400,\"h\":200}}";
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=resize_me&resize_me=" + resizeJson;
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+
+ // Verify portrait config was updated with density scaling
+ Assert.assertEquals((int) Math.ceil(5 * density), (int) overlay.configPortrait.x);
+ Assert.assertEquals((int) Math.ceil(10 * density), (int) overlay.configPortrait.y);
+ Assert.assertEquals((int) Math.ceil(200 * density), (int) overlay.configPortrait.width);
+ Assert.assertEquals((int) Math.ceil(400 * density), (int) overlay.configPortrait.height);
+
+ // Verify landscape config was updated with density scaling
+ Assert.assertEquals((int) Math.ceil(10 * density), (int) overlay.configLandscape.x);
+ Assert.assertEquals((int) Math.ceil(5 * density), (int) overlay.configLandscape.y);
+ Assert.assertEquals((int) Math.ceil(400 * density), (int) overlay.configLandscape.width);
+ Assert.assertEquals((int) Math.ceil(200 * density), (int) overlay.configLandscape.height);
+ });
+ }
+
+ /**
+ * close=1 in the URL triggers overlay close.
+ */
+ @Test
+ public void contentUrlAction_closeAction_closesOverlay() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&close=1";
+ overlay.contentUrlAction(url, overlay.webView);
+
+ try {
+ Assert.assertTrue("isClosed should be true", (Boolean) getField("isClosed"));
+ } catch (Exception e) {
+ Assert.fail("Failed to read isClosed: " + e);
+ }
+ });
+ }
+
+ /**
+ * Combined action + close=1: both the event action and the close are executed.
+ */
+ @Test
+ public void contentUrlAction_combinedEventAndClose_bothExecute() {
+ AtomicBoolean closeCalled = new AtomicBoolean(false);
+
+ withActivity(activity -> {
+ overlay = createOverlay(activity, null, () -> closeCalled.set(true));
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event"
+ + "&event=[{\"key\":\"t\",\"sg\":{\"k\":\"v\"}}]&close=1";
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+ });
+
+ Assert.assertTrue("onClose should have been called", closeCalled.get());
+ }
+
+ /**
+ * Unknown action type still returns true (it IS a valid countly action URL).
+ */
+ @Test
+ public void contentUrlAction_unknownAction_returnsTrue() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=unknown_xyz";
+ Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * URL with wrong prefix (not COMM_URL) is not recognized — returns false.
+ */
+ @Test
+ public void contentUrlAction_wrongPrefix_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = "https://example.com/?cly_x_action_event=1&action=event";
+ Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * Missing cly_x_action_event parameter — returns false.
+ */
+ @Test
+ public void contentUrlAction_missingActionFlag_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?action=event";
+ Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ /**
+ * cly_x_action_event=0 (not "1") — returns false.
+ */
+ @Test
+ public void contentUrlAction_actionFlagNotOne_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=0&action=event";
+ Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView));
+ });
+ }
+
+ @Test
+ public void contentUrlAction_nullUrl_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ Assert.assertFalse(overlay.contentUrlAction(null, overlay.webView));
+ });
+ }
+
+ @Test
+ public void contentUrlAction_nullWebView_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event";
+ Assert.assertFalse(overlay.contentUrlAction(url, null));
+ });
+ }
+
+ /**
+ * After close(), contentUrlAction returns false (isClosed guard).
+ */
+ @Test
+ public void contentUrlAction_whenClosed_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ // Save webView reference before close destroys it
+ android.webkit.WebView wv = overlay.webView;
+ overlay.close(new HashMap<>());
+
+ String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event";
+ Assert.assertFalse(overlay.contentUrlAction(url, wv));
+ });
+ }
+
+ // ===================== widgetUrlAction =====================
+
+ /**
+ * Widget close command triggers both close and the cancel runnable.
+ */
+ @Test
+ public void widgetUrlAction_closeCommand_closesAndRunsCancel() {
+ AtomicBoolean closeCalled = new AtomicBoolean(false);
+ AtomicBoolean cancelCalled = new AtomicBoolean(false);
+
+ withActivity(activity -> {
+ overlay = createOverlay(activity, null, () -> closeCalled.set(true));
+ overlay.setOnWidgetCancelRunnable(() -> cancelCalled.set(true));
+
+ String url = Utils.COMM_URL + "/?cly_widget_command=1&close=1";
+ Assert.assertTrue(overlay.widgetUrlAction(url, overlay.webView));
+ });
+
+ Assert.assertTrue("onClose should have been called", closeCalled.get());
+ Assert.assertTrue("cancel runnable should have been called", cancelCalled.get());
+ }
+
+ /**
+ * Non-widget URL returns false.
+ */
+ @Test
+ public void widgetUrlAction_nonWidgetUrl_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ String url = Utils.COMM_URL + "/?action=event";
+ Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView));
+ });
+ }
+
+ @Test
+ public void widgetUrlAction_nullUrl_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ Assert.assertFalse(overlay.widgetUrlAction(null, overlay.webView));
+ });
+ }
+
+ @Test
+ public void widgetUrlAction_nullWebView_returnsFalse() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ Assert.assertFalse(overlay.widgetUrlAction(null, null));
+ });
+ }
+
+ // ===================== Close & Destroy Lifecycle =====================
+
+ /**
+ * close() fires both the ContentCallback (with CLOSED status) and the onClose runnable.
+ */
+ @Test
+ public void close_runsCallbacksAndCloseRunnable() {
+ AtomicBoolean closeCalled = new AtomicBoolean(false);
+ AtomicBoolean callbackCalled = new AtomicBoolean(false);
+ AtomicReference callbackStatus = new AtomicReference<>();
+
+ withActivity(activity -> {
+ ContentCallback callback = (status, data) -> {
+ callbackCalled.set(true);
+ callbackStatus.set(status);
+ };
+ overlay = createOverlay(activity, callback, () -> closeCalled.set(true));
+ overlay.close(new HashMap<>());
+ });
+
+ Assert.assertTrue("onClose should have been called", closeCalled.get());
+ Assert.assertTrue("contentCallback should have been called", callbackCalled.get());
+ Assert.assertEquals(ContentStatus.CLOSED, callbackStatus.get());
+ }
+
+ /**
+ * Calling close() twice does not run callbacks a second time.
+ */
+ @Test
+ public void close_isIdempotent() {
+ AtomicInteger closeCount = new AtomicInteger(0);
+
+ withActivity(activity -> {
+ overlay = createOverlay(activity, null, closeCount::incrementAndGet);
+ overlay.close(new HashMap<>());
+ overlay.close(new HashMap<>()); // second call — should be no-op
+ });
+
+ Assert.assertEquals("onClose should be called exactly once", 1, closeCount.get());
+ }
+
+ /**
+ * close() destroys the WebView (sets it to null).
+ */
+ @Test
+ public void close_destroysWebView() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ Assert.assertNotNull("webView should exist before close", overlay.webView);
+ overlay.close(new HashMap<>());
+ Assert.assertNull("webView should be null after close", overlay.webView);
+ });
+ }
+
+ /**
+ * close() passes content data to the callback.
+ */
+ @Test
+ public void close_passesContentDataToCallback() {
+ AtomicReference