diff --git a/CHANGELOG.md b/CHANGELOG.md
index 789f46318..ee625412c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,5 @@
## XX.XX.XX
+* Added support for multiple independent SDK instances, each with isolated storage, request queue, and device ID. Access a named instance with `Countly.instance(name)` and initialize it yourself, manage instances with `Countly.getInstance(name)`, `Countly.listInstances()`, `Countly.haltAllInstances()`, and `Countly.removeInstance(name)`, and optionally record the intended name via `CountlyConfig.setInstanceName(String)`. `Countly.sharedInstance()` is unchanged, so existing integrations keep working.
* Improved the security of content, feedback widget, and push notification links by blocking the `data:`, `zip:`, and `intent:` URI schemes by default, both for opening links and for loading web view resources. They can be allowed with `setAllowedIntentSchemes(List)`.
## 26.1.5
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d1a77f1a5..6148884d5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -172,6 +172,11 @@
android:name=".ActivityExampleSessions"
android:label="@string/activity_name_sessions"
android:configChanges="orientation|screenSize"/>
+
+
diff --git a/app/src/main/java/ly/count/android/demo/ActivityExampleMultiInstance.java b/app/src/main/java/ly/count/android/demo/ActivityExampleMultiInstance.java
new file mode 100644
index 000000000..a531843b1
--- /dev/null
+++ b/app/src/main/java/ly/count/android/demo/ActivityExampleMultiInstance.java
@@ -0,0 +1,140 @@
+package ly.count.android.demo;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.List;
+
+import ly.count.android.sdk.Countly;
+import ly.count.android.sdk.CountlyConfig;
+
+/**
+ * Demonstrates running several independent Countly instances alongside the default (shared) one.
+ *
+ * Each named instance keeps its own request queue, event queue, device ID, consent state, logging
+ * state, and stored configuration, fully isolated from {@code Countly.sharedInstance()}. For demo
+ * simplicity the named instances are pointed at the same server and app key as the default instance
+ * (each with a distinct device ID); a real integration would use a separate Countly application's
+ * credentials.
+ */
+public class ActivityExampleMultiInstance extends AppCompatActivity {
+ private static final String ANALYTICS = "analytics";
+ private static final String BILLING = "billing";
+ private boolean analyticsLogging = true;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_example_multi_instance);
+
+ // --- analytics instance ---
+ findViewById(R.id.btnCreateAnalytics).setOnClickListener(v -> createAndInit(ANALYTICS, "analytics-device"));
+
+ findViewById(R.id.btnRecordEventAnalytics).setOnClickListener(v -> {
+ if (!requireInit(ANALYTICS)) {
+ return;
+ }
+ Countly.instance(ANALYTICS).events().recordEvent("analytics_event");
+ toast("Recorded 'analytics_event' on '" + ANALYTICS + "'");
+ });
+
+ findViewById(R.id.btnRecordViewAnalytics).setOnClickListener(v -> {
+ if (!requireInit(ANALYTICS)) {
+ return;
+ }
+ Countly.instance(ANALYTICS).views().startAutoStoppedView("AnalyticsScreen");
+ toast("Started view 'AnalyticsScreen' on '" + ANALYTICS + "'");
+ });
+
+ findViewById(R.id.btnToggleLogAnalytics).setOnClickListener(v -> {
+ if (!requireInit(ANALYTICS)) {
+ return;
+ }
+ analyticsLogging = !analyticsLogging;
+ Countly.instance(ANALYTICS).setLoggingEnabled(analyticsLogging);
+ toast("'" + ANALYTICS + "' logging " + (analyticsLogging ? "ENABLED" : "DISABLED") + " (default instance logging unaffected)");
+ });
+
+ findViewById(R.id.btnRemoveAnalytics).setOnClickListener(v -> {
+ if (Countly.getInstance(ANALYTICS) == null) {
+ toast("'" + ANALYTICS + "' is not registered, nothing to remove");
+ return;
+ }
+ Countly.removeInstance(ANALYTICS);
+ toast("Removed the '" + ANALYTICS + "' instance");
+ });
+
+ // --- billing instance (second named instance) ---
+ findViewById(R.id.btnCreateBilling).setOnClickListener(v -> createAndInit(BILLING, "billing-device"));
+
+ findViewById(R.id.btnRecordEventBilling).setOnClickListener(v -> {
+ if (!requireInit(BILLING)) {
+ return;
+ }
+ Countly.instance(BILLING).events().recordEvent("billing_event");
+ toast("Recorded 'billing_event' on '" + BILLING + "'");
+ });
+
+ // --- default instance (contrast) ---
+ findViewById(R.id.btnRecordEventDefault).setOnClickListener(v -> {
+ Countly.sharedInstance().events().recordEvent("default_event");
+ toast("Recorded 'default_event' on the default (shared) instance");
+ });
+
+ // --- registry ---
+ findViewById(R.id.btnList).setOnClickListener(v -> {
+ List names = Countly.listInstances();
+ toast("Named instances: " + (names.isEmpty() ? "(none)" : names));
+ });
+
+ findViewById(R.id.btnGetAnalytics).setOnClickListener(v -> {
+ Countly existing = Countly.getInstance(ANALYTICS);
+ if (existing == null) {
+ toast("'" + ANALYTICS + "' is not registered");
+ } else {
+ toast("'" + ANALYTICS + "' exists, initialized: " + existing.isInitialized());
+ }
+ });
+
+ findViewById(R.id.btnHaltAll).setOnClickListener(v -> {
+ Countly.haltAllInstances();
+ toast("Halted all instances (each reset but still registered)");
+ });
+ }
+
+ private void createAndInit(String name, String deviceId) {
+ Countly instance = Countly.instance(name);
+ if (instance.isInitialized()) {
+ toast("'" + name + "' is already initialized");
+ return;
+ }
+
+ // The name passed to Countly.instance(name) is what isolates this instance's storage. The
+ // distinct device ID keeps its identity separate. No application is set, so this instance
+ // records manually rather than tracking sessions/views automatically.
+ CountlyConfig config = new CountlyConfig(getApplicationContext(), App.getAppKey(), App.getServerUrl())
+ .setInstanceName(name)
+ .setDeviceId(deviceId)
+ .setLoggingEnabled(true);
+
+ instance.init(config);
+ toast("Initialized '" + name + "' (device id: " + deviceId + ")");
+ }
+
+ private boolean requireInit(String name) {
+ Countly instance = Countly.getInstance(name);
+ if (instance == null || !instance.isInitialized()) {
+ toast("Create and initialize the '" + name + "' instance first");
+ return false;
+ }
+ return true;
+ }
+
+ private void toast(String message) {
+ Log.d(Countly.TAG, "[MultiInstanceDemo] " + message);
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/app/src/main/java/ly/count/android/demo/App.java b/app/src/main/java/ly/count/android/demo/App.java
index d80828c01..b7279ce24 100644
--- a/app/src/main/java/ly/count/android/demo/App.java
+++ b/app/src/main/java/ly/count/android/demo/App.java
@@ -44,6 +44,16 @@ public class App extends Application {
private final static long applicationStartTimestamp = System.currentTimeMillis();
+ // Exposed so example activities (such as the multi-instance demo) can spin up additional named
+ // instances pointed at the same server and app key without duplicating the configuration.
+ public static String getServerUrl() {
+ return COUNTLY_SERVER_URL;
+ }
+
+ public static String getAppKey() {
+ return COUNTLY_APP_KEY;
+ }
+
@Override
public void onCreate() {
super.onCreate();
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 d0d30cf80..f9aa5b7d8 100644
--- a/app/src/main/java/ly/count/android/demo/MainActivity.java
+++ b/app/src/main/java/ly/count/android/demo/MainActivity.java
@@ -143,4 +143,8 @@ public void onClickButtonLocation(View v) {
public void onClickButtonSessions(View v) {
startActivity(new Intent(this, ActivityExampleSessions.class));
}
+
+ public void onClickButtonMultiInstance(View v) {
+ startActivity(new Intent(this, ActivityExampleMultiInstance.class));
+ }
}
diff --git a/app/src/main/res/layout/activity_example_multi_instance.xml b/app/src/main/res/layout/activity_example_multi_instance.xml
new file mode 100644
index 000000000..d87b01886
--- /dev/null
+++ b/app/src/main/res/layout/activity_example_multi_instance.xml
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 5c0255684..7936103d7 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -660,6 +660,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Consent ManagementLocationSessions
+ Multiple Instances
diff --git a/sdk/build.gradle b/sdk/build.gradle
index aadbb0d96..06a334a69 100644
--- a/sdk/build.gradle
+++ b/sdk/build.gradle
@@ -58,7 +58,7 @@ android {
testNamespace 'ly.count.android.sdk.test'
}
-def mockitoVersion = "4.11.0"
+def mockitoVersion = "5.14.2"
dependencies {
api fileTree(dir: 'libs', include: ['*.jar'])
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java
index 384656c63..42fdacf12 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java
@@ -392,7 +392,7 @@ public void integration_customSSLSocketFactory_takesPrecedenceOverPinning() thro
Assert.assertTrue(serverConn instanceof HttpsURLConnection);
Assert.assertSame("custom factory must win over pinning", customFactory, ((HttpsURLConnection) serverConn).getSSLSocketFactory());
} finally {
- Countly.publicKeyPinCertificates = null;
+ Countly.sharedInstance().halt();
}
}
@@ -413,7 +413,8 @@ public void integration_pinning_installsDistinctSocketFactory() throws Exception
Assert.assertNotSame("public key pinning must install its own socket factory", platformDefault, publicKeyPinningFactory);
Countly.sharedInstance().halt();
- Countly.publicKeyPinCertificates = null;
+ // pinning is now per-instance on the ConnectionQueue; halt() drops the queue, so the
+ // next init starts with a fresh, unpinned ConnectionQueue - no static reset needed.
// certificate pinning
Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl).enableCertificatePinning(certs));
@@ -421,8 +422,7 @@ public void integration_pinning_installsDistinctSocketFactory() throws Exception
Assert.assertNotNull(certificatePinningFactory);
Assert.assertNotSame("certificate pinning must install its own socket factory", platformDefault, certificatePinningFactory);
} finally {
- Countly.publicKeyPinCertificates = null;
- Countly.certificatePinCertificates = null;
+ Countly.sharedInstance().halt();
}
}
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java
index e457593b9..d5a6eebfb 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java
@@ -55,6 +55,9 @@ public void setUp() {
Countly.sharedInstance().halt();
Countly.sharedInstance().setLoggingEnabled(true);
freshConnQ = new ConnectionQueue();
+ // A bare ConnectionQueue has no owning Countly; give it one so beginSession/common-request
+ // data (which read the owner's SDK identity + session flag) behave as before.
+ freshConnQ.cly = Countly.sharedInstance();
Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, "http://countly.coupons.com"));
connQ = Countly.sharedInstance().connectionQueue_;
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java
index e7771eceb..7d765f2dc 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java
@@ -106,7 +106,7 @@ private ContentOverlayView createOverlay(Activity activity,
landscape.useSafeArea = false;
return new ContentOverlayView(
- activity, portrait, landscape,
+ Countly.sharedInstance(), activity, portrait, landscape,
activity.getResources().getConfiguration().orientation,
callback,
onClose != null ? onClose : () -> {
@@ -955,7 +955,7 @@ public void configs_storedCorrectly() {
landscape.useSafeArea = false;
overlay = new ContentOverlayView(
- activity, portrait, landscape,
+ Countly.sharedInstance(), activity, portrait, landscape,
Configuration.ORIENTATION_PORTRAIT, null, () -> {
}, null, null);
@@ -1167,6 +1167,28 @@ public void attachToActivity_addsToWindow() {
});
}
+ /**
+ * Process-global presentation guard: only one content/feedback overlay may be presented at a
+ * time across all instances. attachToActivity claims it, close() releases it, and the presenting
+ * overlay is not "other" to itself (so it can still refresh in place).
+ */
+ @Test
+ public void presentationGuard_isProcessGlobal_releasedOnClose() {
+ withActivity(activity -> {
+ overlay = createOverlay(activity);
+ ContentOverlayView other = createOverlay(activity); // created but never attached
+
+ overlay.attachToActivity(activity);
+ Assert.assertTrue("attach claims the presentation guard", ContentOverlayView.isOverlayPresented());
+ Assert.assertTrue("a different overlay must see one already presented", ContentOverlayView.isOtherOverlayPresented(other));
+ Assert.assertFalse("the presenting overlay is not 'other' to itself", ContentOverlayView.isOtherOverlayPresented(overlay));
+
+ overlay.close(null);
+ Assert.assertFalse("close releases the guard", ContentOverlayView.isOverlayPresented());
+ Assert.assertFalse(ContentOverlayView.isOtherOverlayPresented(other));
+ });
+ }
+
/**
* Calling attachToActivity with the same activity twice is safe (idempotent).
*/
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java
index a72dbba18..c82a7459a 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java
@@ -507,6 +507,19 @@ public void performMigration2To3_1() {
Assert.assertNull(sp.getString(MigrationHelper.legacyCACHED_PUSH_MESSAGING_MODE, null));
}
+ /**
+ * A named instance (ownsPushStorage=false) must not edit the shared, process-global push file.
+ */
+ @Test
+ public void performMigration2To3_namedInstance_leavesSharedPushUntouched() {
+ SharedPreferences sp = CountlyStore.createPreferencesPush(getApplicationContext());
+ sp.edit().putString(MigrationHelper.legacyCACHED_PUSH_MESSAGING_MODE, "abc").apply();
+
+ MigrationHelper mh = new MigrationHelper(cs, mockLog, getApplicationContext(), false);
+ mh.performMigration2To3(new HashMap<>());
+ Assert.assertEquals("abc", sp.getString(MigrationHelper.legacyCACHED_PUSH_MESSAGING_MODE, null));
+ }
+
/**
* Create a legacy entry
*
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java
index a9c44065e..566892c84 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java
@@ -196,15 +196,15 @@ public void startEndEvent_noSegments() throws InterruptedException {
Assert.assertTrue(res);
verify(eventQueueProvider, times(0)).recordEventToEventQueue(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), any(Long.class), any(Integer.class), any(Integer.class), any(String.class), any(String.class), any(String.class), any(String.class));
- Assert.assertEquals(1, ModuleEvents.timedEvents.size());
- Assert.assertTrue(ModuleEvents.timedEvents.containsKey(eventKey));
- Event startEvent = ModuleEvents.timedEvents.get(eventKey);
+ Assert.assertEquals(1, mCountly.moduleEvents.timedEvents.size());
+ Assert.assertTrue(mCountly.moduleEvents.timedEvents.containsKey(eventKey));
+ Event startEvent = mCountly.moduleEvents.timedEvents.get(eventKey);
Thread.sleep(1000);
res = mCountly.events().endEvent(eventKey);
Assert.assertTrue(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
ArgumentCaptor arg1 = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor arg2 = ArgumentCaptor.forClass(Integer.class);
@@ -230,9 +230,9 @@ public void startEndEvent_withSegments() throws InterruptedException {
Assert.assertTrue(res);
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), any(String.class));
- Assert.assertEquals(1, ModuleEvents.timedEvents.size());
- Assert.assertTrue(ModuleEvents.timedEvents.containsKey(eventKey));
- Event startEvent = ModuleEvents.timedEvents.get(eventKey);
+ Assert.assertEquals(1, mCountly.moduleEvents.timedEvents.size());
+ Assert.assertTrue(mCountly.moduleEvents.timedEvents.containsKey(eventKey));
+ Event startEvent = mCountly.moduleEvents.timedEvents.get(eventKey);
Thread.sleep(2000);
@@ -245,7 +245,7 @@ public void startEndEvent_withSegments() throws InterruptedException {
res = mCountly.events().endEvent(eventKey, segm, 6372, 5856.34d);
Assert.assertTrue(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
final Map segmVals = new HashMap<>();
segmVals.put("aa", "dd");
@@ -275,18 +275,18 @@ public void startCancelEndEvent() {
Assert.assertTrue(res);
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), any(String.class));
- Assert.assertEquals(1, ModuleEvents.timedEvents.size());
- Assert.assertTrue(ModuleEvents.timedEvents.containsKey(eventKey));
+ Assert.assertEquals(1, mCountly.moduleEvents.timedEvents.size());
+ Assert.assertTrue(mCountly.moduleEvents.timedEvents.containsKey(eventKey));
res = mCountly.events().cancelEvent(eventKey);
Assert.assertTrue(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
// TODO: Check these 2 null event IDs
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), isNull(String.class));
res = mCountly.events().endEvent(eventKey);
Assert.assertFalse(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), isNull(String.class));
}
@@ -297,12 +297,12 @@ public void startCancelStartEndEvent() throws InterruptedException {
Assert.assertTrue(res);
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), any(String.class));
- Assert.assertEquals(1, ModuleEvents.timedEvents.size());
- Assert.assertTrue(ModuleEvents.timedEvents.containsKey(eventKey));
+ Assert.assertEquals(1, mCountly.moduleEvents.timedEvents.size());
+ Assert.assertTrue(mCountly.moduleEvents.timedEvents.containsKey(eventKey));
res = mCountly.events().cancelEvent(eventKey);
Assert.assertTrue(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), isNull(String.class));
// finished first start and cancel
@@ -311,15 +311,15 @@ public void startCancelStartEndEvent() throws InterruptedException {
Assert.assertTrue(res);
verify(ep, times(0)).recordEventInternal(any(String.class), any(Map.class), any(Integer.class), any(Double.class), any(Double.class), isNull(UtilsTime.Instant.class), isNull(String.class));
- Assert.assertEquals(1, ModuleEvents.timedEvents.size());
- Assert.assertTrue(ModuleEvents.timedEvents.containsKey(eventKey));
- Event startEvent = ModuleEvents.timedEvents.get(eventKey);
+ Assert.assertEquals(1, mCountly.moduleEvents.timedEvents.size());
+ Assert.assertTrue(mCountly.moduleEvents.timedEvents.containsKey(eventKey));
+ Event startEvent = mCountly.moduleEvents.timedEvents.get(eventKey);
Thread.sleep(1000);
res = mCountly.events().endEvent(eventKey);
Assert.assertTrue(res);
- Assert.assertEquals(0, ModuleEvents.timedEvents.size());
+ Assert.assertEquals(0, mCountly.moduleEvents.timedEvents.size());
ArgumentCaptor arg = ArgumentCaptor.forClass(UtilsTime.Instant.class);
ArgumentCaptor argD = ArgumentCaptor.forClass(Double.class);
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleUserProfileTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleUserProfileTests.java
index c9e6bd55f..2a126c3af 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleUserProfileTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleUserProfileTests.java
@@ -455,7 +455,7 @@ public void internalLimit_testCustomData() {
mCountly.userProfile().setProperty("hair_skin_tone", "yellow");
mCountly.userProfile().setProperty("picturePath", "Test Test");
Assert.assertEquals(2, mCountly.moduleUserProfile.custom.size());
- Assert.assertNull(ModuleUserProfile.picturePath);
+ Assert.assertNull(mCountly.moduleUserProfile.picturePath);
Assert.assertEquals("black", mCountly.moduleUserProfile.custom.get("hair_color"));
Assert.assertEquals("yellow", mCountly.moduleUserProfile.custom.get("hair_skin_"));
}
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/MultiInstanceTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/MultiInstanceTests.java
new file mode 100644
index 000000000..05aebdb7b
--- /dev/null
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/MultiInstanceTests.java
@@ -0,0 +1,524 @@
+/*
+Copyright (c) 2012, 2013, 2014 Countly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+package ly.count.android.sdk;
+
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Integration tests for multi-instance support: independent Countly instances obtained via
+ * {@link Countly#instance(String)}, each with isolated storage, request queue, device id, logging,
+ * and timed-event state, while {@link Countly#sharedInstance()} stays a drop-in default that keeps
+ * the legacy storage location.
+ */
+@RunWith(AndroidJUnit4.class)
+public class MultiInstanceTests {
+ // Names this suite creates. They are halted and their namespaced storage cleared between tests
+ // so nothing leaks across tests or across test classes.
+ private static final String[] NAMES = { "instB", "instLog", "instLoud", "instTimed", "instFile", "instCreateA", "instCreateB", "ignoredName", "instRemove", "instFresh" };
+
+ private static final String PUSH_PREFS_FILE = "ly.count.android.api.messaging";
+
+ private static final String APP_KEY_A = "appKeyA";
+ private static final String APP_KEY_B = "appKeyB";
+ private static final String DEVICE_A = "deviceA";
+ private static final String DEVICE_B = "deviceB";
+
+ @Before
+ public void setUp() {
+ resetAll();
+ }
+
+ @After
+ public void tearDown() {
+ resetAll();
+ }
+
+ // A CountlyStore bound to a given storage namespace, using a real (silent) logger so cleanup and
+ // verification work on Android runtimes where Mockito cannot inject mocks.
+ private static CountlyStore store(String namespace) {
+ return new CountlyStore(TestUtils.getContext(), new ModuleLog(), false, namespace);
+ }
+
+ private void resetAll() {
+ Countly.sharedInstance().halt();
+ store("").clear();
+ for (String name : NAMES) {
+ // Use getInstance (never creates) so cleanup does not itself register names - the
+ // registry never removes instances, so creating here would defeat the getInstance-is-null
+ // expectation that the parity-API test relies on.
+ Countly existing = Countly.getInstance(name);
+ if (existing != null) {
+ existing.halt();
+ }
+ // Clear via an explicitly namespaced store so on-disk state is cleaned even for a name
+ // that has never been initialised (storageNamespace_ is only resolved at init time).
+ store(CountlyStore.sanitizeNamespace(name)).clear();
+ clearOpenUdid(CountlyStore.sanitizeNamespace(name));
+ }
+ clearOpenUdid("");
+ }
+
+ private static String openUdid(String namespace) {
+ return TestUtils.getContext().getSharedPreferences(
+ CountlyStore.namespacedName(ModuleDeviceId.PREFS_NAME, namespace), Context.MODE_PRIVATE)
+ .getString(ModuleDeviceId.PREF_KEY, null);
+ }
+
+ private static void clearOpenUdid(String namespace) {
+ TestUtils.getContext().getSharedPreferences(
+ CountlyStore.namespacedName(ModuleDeviceId.PREFS_NAME, namespace), Context.MODE_PRIVATE)
+ .edit().clear().apply();
+ }
+
+ private CountlyConfig baseConfig(String appKey, String deviceId) {
+ return new CountlyConfig(TestUtils.getContext(), appKey, TestUtils.commonURL)
+ .setDeviceId(deviceId)
+ .setLoggingEnabled(true)
+ .enableManualSessionControl();
+ }
+
+ private static Map firstRequestWithKey(Map[] rq, String key) {
+ for (Map request : rq) {
+ if (request != null && request.containsKey(key)) {
+ return request;
+ }
+ }
+ return null;
+ }
+
+ private static void assertAllRequestsCarryAppKey(Map[] rq, String expectedAppKey) {
+ for (Map request : rq) {
+ if (request != null) {
+ Assert.assertEquals("a request leaked from/into another instance", expectedAppKey, request.get("app_key"));
+ }
+ }
+ }
+
+ /**
+ * The core guarantee: the default instance and a named instance, initialised with different app
+ * keys and device ids, keep completely separate request queues and device identities. Neither
+ * instance's data ever appears in the other's storage.
+ */
+ @Test
+ public void namedAndDefaultInstances_isolateRequestQueuesAndDeviceId() {
+ Countly def = Countly.sharedInstance();
+ def.init(baseConfig(APP_KEY_A, DEVICE_A));
+
+ Countly named = Countly.instance("instB");
+ named.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instB"));
+
+ def.sessions().beginSession();
+ named.sessions().beginSession();
+
+ Map[] rqDefault = TestUtils.getCurrentRQ(def);
+ Map[] rqNamed = TestUtils.getCurrentRQ(named);
+
+ // each instance produced its own begin_session on the wire, tagged with its own identity
+ Map beginDefault = firstRequestWithKey(rqDefault, "begin_session");
+ Map beginNamed = firstRequestWithKey(rqNamed, "begin_session");
+ Assert.assertNotNull("default instance must have a begin_session request", beginDefault);
+ Assert.assertNotNull("named instance must have a begin_session request", beginNamed);
+ Assert.assertEquals(APP_KEY_A, beginDefault.get("app_key"));
+ Assert.assertEquals(DEVICE_A, beginDefault.get("device_id"));
+ Assert.assertEquals(APP_KEY_B, beginNamed.get("app_key"));
+ Assert.assertEquals(DEVICE_B, beginNamed.get("device_id"));
+
+ // no cross-talk: every request in each queue belongs only to that instance
+ assertAllRequestsCarryAppKey(rqDefault, APP_KEY_A);
+ assertAllRequestsCarryAppKey(rqNamed, APP_KEY_B);
+
+ // device id is persisted per-instance in each instance's own storage
+ Assert.assertEquals(DEVICE_A, TestUtils.getCountlyStore(def).getDeviceID());
+ Assert.assertEquals(DEVICE_B, TestUtils.getCountlyStore(named).getDeviceID());
+
+ // the default instance keeps the legacy (un-namespaced) storage; the named one does not
+ Assert.assertEquals("", def.storageNamespace_);
+ Assert.assertNotEquals("", named.storageNamespace_);
+ Assert.assertTrue(named.storageNamespace_.startsWith("instB"));
+ }
+
+ /**
+ * Regression: a brand-new named instance must honor its config's device ID even when the shared,
+ * primary-owned push preferences file already has data (as it does in a real app once the default
+ * instance has cached a push provider). Before the fix, anythingSetInStorage() counted the shared
+ * push file, so a fresh named store was misdetected as a legacy install, ran a schema migration,
+ * and that migration replaced the developer-supplied device ID with a generated OPEN_UDID.
+ */
+ @Test
+ public void freshNamedInstance_honorsSuppliedDeviceId_whenSharedPushPrefsExist() {
+ // Simulate the primary instance having cached a push provider into the shared push file.
+ TestUtils.getContext().getSharedPreferences(PUSH_PREFS_FILE, Context.MODE_PRIVATE)
+ .edit().putInt("PUSH_MESSAGING_PROVIDER", 1).apply();
+
+ Countly named = Countly.instance("instFresh");
+ named.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instFresh"));
+
+ // the fresh named store must not be treated as legacy: the supplied device ID is kept and
+ // its type stays DEVELOPER_SUPPLIED rather than falling back to a generated OPEN_UDID
+ Assert.assertEquals(DEVICE_B, TestUtils.getCountlyStore(named).getDeviceID());
+ Assert.assertEquals("DEVELOPER_SUPPLIED", TestUtils.getCountlyStore(named).getDeviceIDType());
+ }
+
+ /**
+ * Backward compatibility + file-level isolation: the default instance writes to the exact legacy
+ * SharedPreferences file, so an app upgrading from a single-instance SDK version keeps its data.
+ * A named instance writes only to its suffixed file, invisible to the legacy store.
+ */
+ @Test
+ public void defaultKeepsLegacyStorage_namedIsIsolatedAtFileLevel() {
+ // file naming: default -> legacy base name, named -> suffixed
+ Assert.assertEquals("COUNTLY_STORE", CountlyStore.namespacedName("COUNTLY_STORE", ""));
+ Assert.assertEquals("COUNTLY_STORE", CountlyStore.namespacedName("COUNTLY_STORE", null));
+ Assert.assertEquals("COUNTLY_STORE_abc", CountlyStore.namespacedName("COUNTLY_STORE", "abc"));
+
+ Countly def = Countly.sharedInstance();
+ def.init(baseConfig(APP_KEY_A, DEVICE_A));
+ def.sessions().beginSession();
+
+ Countly named = Countly.instance("instFile");
+ named.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instFile"));
+ named.sessions().beginSession();
+
+ // a brand-new legacy-scoped store (no namespace) sees the default instance's request, never
+ // the named instance's - proving the default still uses the legacy file and the named
+ // instance writes elsewhere
+ CountlyStore legacyStore = store("");
+ Map[] rqLegacy = TestUtils.getCurrentRQ("", legacyStore);
+ Assert.assertNotNull(firstRequestWithKey(rqLegacy, "begin_session"));
+ assertAllRequestsCarryAppKey(rqLegacy, APP_KEY_A);
+
+ // the named instance's namespaced store holds only its own data
+ Map[] rqNamed = TestUtils.getCurrentRQ(named);
+ Assert.assertNotNull(firstRequestWithKey(rqNamed, "begin_session"));
+ assertAllRequestsCarryAppKey(rqNamed, APP_KEY_B);
+ }
+
+ /**
+ * Registry semantics: instances are stable per name, the several ways of naming the default all
+ * resolve to the same object, and an instance survives halt() (state resets, identity does not).
+ */
+ @Test
+ public void instanceRegistry_returnsStableObjectsAndDefaultAliases() {
+ Countly a = Countly.instance("instB");
+ Assert.assertSame("same name must return the same object", a, Countly.instance("instB"));
+
+ Countly def = Countly.sharedInstance();
+ Assert.assertSame("null name is the default instance", def, Countly.instance(null));
+ Assert.assertSame("empty name is the default instance", def, Countly.instance(""));
+ Assert.assertSame("DEFAULT_NAME is the default instance", def, Countly.instance(Countly.DEFAULT_NAME));
+
+ Assert.assertNotSame("a named instance is not the default", def, a);
+
+ // halting resets state but keeps the object registered
+ a.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instB"));
+ a.halt();
+ Assert.assertSame("instance identity survives halt()", a, Countly.instance("instB"));
+ }
+
+ /**
+ * removeInstance halts a named instance AND drops it from the registry (unlike halt(), which keeps
+ * it registered), so the object graph it retains becomes GC-eligible - the fix for the registry
+ * growing without bound. The default instance can never be removed: it stays a stable object for
+ * sharedInstance().
+ */
+ @Test
+ public void removeInstance_deregistersAndHalts_defaultCannotBeRemoved() {
+ Countly named = Countly.instance("instRemove");
+ named.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instRemove"));
+ Assert.assertTrue(named.isInitialized());
+ Assert.assertSame("registered before removal", named, Countly.getInstance("instRemove"));
+ Assert.assertTrue("listed before removal", Countly.listInstances().contains("instRemove"));
+
+ Countly.removeInstance("instRemove");
+
+ // deregistered: getInstance no longer sees it and it drops out of the listing
+ Assert.assertNull("getInstance must be null after removal", Countly.getInstance("instRemove"));
+ Assert.assertFalse("must not be listed after removal", Countly.listInstances().contains("instRemove"));
+ // the removed handle was halted as part of removal
+ Assert.assertFalse("removed instance must be halted", named.isInitialized());
+ // a later instance(name) creates a fresh, uninitialized object rather than the removed one
+ Countly recreated = Countly.instance("instRemove");
+ Assert.assertNotSame("instance(name) after removal must create a new object", named, recreated);
+ Assert.assertFalse("recreated instance is uninitialized until init()", recreated.isInitialized());
+
+ // the default (shared) instance can not be removed: it must remain a stable object
+ Countly def = Countly.sharedInstance();
+ Countly.removeInstance(null);
+ Countly.removeInstance(Countly.DEFAULT_NAME);
+ Assert.assertSame("default instance survives removeInstance", def, Countly.sharedInstance());
+ }
+
+ /**
+ * The storage-namespace sanitizer produces file-safe names, is deterministic, and does not let
+ * two differently-spelled names collapse onto the same storage file.
+ */
+ @Test
+ public void sanitizeNamespace_isFileSafeAndCollisionResistant() {
+ Assert.assertEquals("", CountlyStore.sanitizeNamespace(null));
+ Assert.assertEquals("", CountlyStore.sanitizeNamespace(""));
+
+ String sanitized = CountlyStore.sanitizeNamespace("My App/Prod:1");
+ // only file-safe characters survive
+ Assert.assertTrue("sanitized namespace must be file-safe", sanitized.matches("[A-Za-z0-9_]+"));
+ // deterministic
+ Assert.assertEquals(sanitized, CountlyStore.sanitizeNamespace("My App/Prod:1"));
+
+ // two names that sanitize to the same prefix must still differ (hash suffix disambiguates)
+ Assert.assertNotEquals(CountlyStore.sanitizeNamespace("a.b"), CountlyStore.sanitizeNamespace("a-b"));
+ }
+
+ /**
+ * Logging is per-instance: enabling logging on one instance does not enable it on another. This
+ * exercises the ModuleLog decoupling from the singleton.
+ */
+ @Test
+ public void perInstanceLogging_isIndependent() {
+ // two named instances so the check does not depend on the heavily-shared default instance
+ Countly loud = Countly.instance("instLoud");
+ loud.init(baseConfig(APP_KEY_A, DEVICE_A).setInstanceName("instLoud").setLoggingEnabled(true));
+
+ Countly quiet = Countly.instance("instLog");
+ quiet.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instLog").setLoggingEnabled(false));
+
+ Assert.assertTrue(loud.isLoggingEnabled());
+ Assert.assertTrue(loud.L.loggingEnabled);
+ Assert.assertFalse(quiet.isLoggingEnabled());
+ Assert.assertFalse(quiet.L.loggingEnabled);
+
+ // toggling one instance's logging leaves the other untouched
+ quiet.setLoggingEnabled(true);
+ Assert.assertTrue(quiet.L.loggingEnabled);
+ Assert.assertTrue(loud.L.loggingEnabled);
+ }
+
+ /**
+ * Timed events are stored per-instance: a timed event started on one instance is invisible to
+ * another. This exercises the ModuleEvents.timedEvents static-to-instance conversion.
+ */
+ @Test
+ public void timedEvents_areIsolatedPerInstance() {
+ Countly one = Countly.sharedInstance();
+ one.init(baseConfig(APP_KEY_A, DEVICE_A));
+
+ Countly two = Countly.instance("instTimed");
+ two.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instTimed"));
+
+ Assert.assertTrue(one.events().startEvent("timer_one"));
+
+ // the timed event lives only on the instance that started it
+ Assert.assertEquals(1, one.moduleEvents.timedEvents.size());
+ Assert.assertTrue(one.moduleEvents.timedEvents.containsKey("timer_one"));
+ Assert.assertEquals(0, two.moduleEvents.timedEvents.size());
+ Assert.assertFalse(two.moduleEvents.timedEvents.containsKey("timer_one"));
+
+ // ending it on the other instance is a no-op; it stays owned by the first
+ Assert.assertFalse(two.events().endEvent("timer_one"));
+ Assert.assertEquals(1, one.moduleEvents.timedEvents.size());
+ }
+
+ /**
+ * Push is owned by the default ("primary") instance and its preferences live in a single shared
+ * file. Halting a named instance must not wipe that shared push state, while the default instance
+ * still clears it on halt (legacy behavior).
+ */
+ @Test
+ public void haltingNamedInstance_preservesPrimaryPushPrefs() {
+ // primary sets push consent on the shared push preferences file
+ store("").setConsentPush(true);
+ Assert.assertTrue(store("").getConsentPush());
+
+ // a named instance's lifecycle must not touch the shared push prefs
+ Countly named = Countly.instance("instB");
+ named.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instB"));
+ named.halt();
+ Assert.assertTrue("named instance halt must not wipe primary push consent", store("").getConsentPush());
+
+ // the default instance still owns and clears the shared push prefs on halt (backward compatible)
+ Countly def = Countly.sharedInstance();
+ def.init(baseConfig(APP_KEY_A, DEVICE_A));
+ def.halt();
+ Assert.assertFalse("default instance halt clears the shared push prefs", store("").getConsentPush());
+ }
+
+ /**
+ * The registry management API. instance(name) creates/accesses a handle but never initializes it
+ * (users init themselves); getInstance never creates; listInstances reports named instances only;
+ * haltAllInstances halts every instance while keeping identities registered.
+ */
+ @Test
+ public void registryApi_accessIsLazyAndUninitialized_listAndHaltAll() {
+ // getInstance never creates - null until the name is registered
+ Assert.assertNull(Countly.getInstance("instCreateA"));
+
+ // instance(name) creates the handle but does NOT auto-initialize it
+ Countly handle = Countly.instance("instCreateA");
+ Assert.assertNotNull(handle);
+ Assert.assertFalse("instance(name) must not auto-initialize", handle.isInitialized());
+ Assert.assertSame("instance(name) is just an accessor - same object each call", handle, Countly.getInstance("instCreateA"));
+
+ // the user initializes it explicitly; storage is isolated under the (sanitized) name
+ handle.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instCreateA"));
+ Assert.assertTrue(handle.isInitialized());
+ Assert.assertEquals(CountlyStore.sanitizeNamespace("instCreateA"), handle.storageNamespace_);
+
+ // using the app key as the instance name is the natural per-app-key isolation
+ Countly byAppKey = Countly.instance("instCreateB");
+ byAppKey.init(baseConfig("instCreateB", DEVICE_A));
+ Assert.assertEquals(CountlyStore.sanitizeNamespace("instCreateB"), byAppKey.storageNamespace_);
+
+ // listInstances reports the named instances but never the default
+ java.util.List names = Countly.listInstances();
+ Assert.assertTrue(names.contains("instCreateA"));
+ Assert.assertTrue(names.contains("instCreateB"));
+ Assert.assertFalse(names.contains(Countly.DEFAULT_NAME));
+
+ // haltAllInstances halts every instance while keeping their identities registered
+ Countly.haltAllInstances();
+ Assert.assertFalse(handle.isInitialized());
+ Assert.assertFalse(byAppKey.isInitialized());
+ Assert.assertSame(handle, Countly.instance("instCreateA"));
+ }
+
+ /**
+ * Data preservation on upgrade: an app coming from a previous single-instance SDK version already
+ * has data in the legacy (un-namespaced) store. Initializing the default instance must reuse that
+ * existing device ID and queued requests, never wipe or re-namespace them.
+ */
+ @Test
+ public void defaultInstance_readsPreExistingLegacyData_noDataLoss() {
+ // seed the legacy store the way an older SDK version would have left it
+ CountlyStore legacy = store("");
+ legacy.setDeviceID("legacy_device");
+ legacy.addRequest("app_key=" + APP_KEY_A + "&device_id=legacy_device&legacy_marker=1", false);
+
+ // initialize the default instance with NO device id in config, so the stored one must be kept
+ Countly def = Countly.sharedInstance();
+ def.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL).setLoggingEnabled(true));
+
+ // the default instance uses the legacy files (empty namespace) ...
+ Assert.assertEquals("", def.storageNamespace_);
+ // ... so the pre-existing device id and queued request are still there after init
+ Assert.assertEquals("legacy_device", store("").getDeviceID());
+ Assert.assertNotNull("a pre-existing queued request must survive init", firstRequestWithKey(TestUtils.getCurrentRQ(def), "legacy_marker"));
+ }
+
+ /**
+ * SSL certificate/public-key pinning material is held per-instance on each instance's own
+ * ConnectionQueue, not in a shared static. A pinned named instance must not leak its pins into an
+ * unpinned instance (the "last-init-wins" static hazard the refactor removed).
+ */
+ @Test
+ public void sslPinning_isIsolatedPerInstance() {
+ String[] pins = { "pin-for-named-instance" };
+ // A no-op custom socket factory is supplied only so the placeholder pin is not eagerly parsed
+ // into a TrustManager; we are asserting the pinning material is stored per-ConnectionQueue.
+ javax.net.ssl.SSLSocketFactory noopFactory = (javax.net.ssl.SSLSocketFactory) javax.net.ssl.SSLSocketFactory.getDefault();
+
+ Countly pinned = Countly.instance("instB");
+ pinned.init(baseConfig(APP_KEY_B, DEVICE_B).setInstanceName("instB")
+ .enablePublicKeyPinning(pins).setCustomSSLSocketFactory(noopFactory));
+
+ Countly plain = Countly.sharedInstance();
+ plain.init(baseConfig(APP_KEY_A, DEVICE_A));
+
+ // each ConnectionQueue holds only its own pinning material - no cross-instance static leakage
+ Assert.assertArrayEquals(pins, pinned.connectionQueue_.publicKeyPinCertificates);
+ Assert.assertNull("the unpinned instance must not inherit another instance's pins", plain.connectionQueue_.publicKeyPinCertificates);
+ }
+
+ /**
+ * When no device id is supplied, each instance generates its own OpenUDID in its own namespaced
+ * file, so two instances never collapse onto one shared generated device id (which would silently
+ * merge two apps' analytics). The default keeps the legacy openudid_prefs file.
+ */
+ @Test
+ public void generatedOpenUdidDeviceId_isIsolatedPerInstance() {
+ // start from clean OpenUDID files so both instances must generate fresh, independent ids
+ clearOpenUdid("");
+ clearOpenUdid(CountlyStore.sanitizeNamespace("instB"));
+
+ // neither config supplies a device id -> the OpenUDID (generated) path is exercised
+ Countly def = Countly.sharedInstance();
+ def.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL).setLoggingEnabled(true));
+ Countly named = Countly.instance("instB");
+ named.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_B, TestUtils.commonURL).setInstanceName("instB").setLoggingEnabled(true));
+
+ String defId = TestUtils.getCountlyStore(def).getDeviceID();
+ String namedId = TestUtils.getCountlyStore(named).getDeviceID();
+ Assert.assertNotNull(defId);
+ Assert.assertNotNull(namedId);
+ Assert.assertNotEquals("each instance must generate its own device id, not share one", defId, namedId);
+
+ // the generated OpenUDID lives in each instance's own file (default -> legacy, named -> suffixed)
+ String defOpenUdid = openUdid("");
+ String namedOpenUdid = openUdid(CountlyStore.sanitizeNamespace("instB"));
+ Assert.assertNotNull(defOpenUdid);
+ Assert.assertNotNull(namedOpenUdid);
+ Assert.assertNotEquals("named instance's OpenUDID must be isolated from the default's", defOpenUdid, namedOpenUdid);
+ }
+
+ /**
+ * setInstanceName is advisory: setting it on the default instance (obtained via sharedInstance())
+ * does NOT create a named instance or namespace storage, and the SDK warns loudly rather than
+ * silently landing data in the wrong place.
+ */
+ @Test
+ public void setInstanceNameOnDefaultInstance_isIgnoredButWarned() {
+ final List warnings = new ArrayList<>();
+ // A standalone default-named instance (instanceName_ == DEFAULT_NAME, exactly as
+ // sharedInstance() is) exercises the same "config names the default instance" path, while
+ // keeping the log-listener assertion independent of other tests mutating the shared default.
+ Countly def = new Countly();
+ def.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL)
+ .setDeviceId(DEVICE_A)
+ .setInstanceName("ignoredName")
+ .setLoggingEnabled(true)
+ .setLogListener((logMessage, logLevel) -> {
+ if (logLevel == ModuleLog.LogLevel.Warning) {
+ warnings.add(logMessage);
+ }
+ }));
+
+ // the default instance ignores the config name: legacy storage, not registered under the name
+ Assert.assertEquals("", def.storageNamespace_);
+ Assert.assertNull("setInstanceName on the default instance must not register a named instance", Countly.getInstance("ignoredName"));
+
+ // and it warns loudly instead of failing silently
+ boolean warned = false;
+ for (String w : warnings) {
+ if (w.contains("ignoredName")) {
+ warned = true;
+ break;
+ }
+ }
+ Assert.assertTrue("a warning must be logged when setInstanceName is set on the default instance", warned);
+ }
+}
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
index 2a7d7782d..f13790bfe 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java
@@ -495,6 +495,17 @@ protected static CountlyStore getCountlyStore() {
return new CountlyStore(getContext(), mock(ModuleLog.class), false);
}
+ /**
+ * A CountlyStore bound to a specific instance's storage namespace, for verifying that named
+ * instances persist their queues/device-id/config to isolated files.
+ */
+ protected static CountlyStore getCountlyStore(Countly countly) {
+ // A real (silent) ModuleLog rather than a mock: this store is only read for verification and
+ // never has its interactions verified, and a real logger keeps the helper usable on Android
+ // runtimes where the Mockito/ByteBuddy mock maker cannot inject classes.
+ return new CountlyStore(getContext(), new ModuleLog(), false, countly.storageNamespace_);
+ }
+
/**
* Get current request queue from target folder
*
@@ -511,8 +522,21 @@ protected static CountlyStore getCountlyStore() {
* @return array of request params
*/
protected static @NonNull Map[] getCurrentRQ(String filter) {
+ return getCurrentRQ(filter, getCountlyStore());
+ }
+
+ /**
+ * Get the request queue of a specific instance, read from that instance's namespaced storage.
+ *
+ * @return array of request params
+ */
+ protected static @NonNull Map[] getCurrentRQ(Countly countly) {
+ return getCurrentRQ("", getCountlyStore(countly));
+ }
+
+ protected static @NonNull Map[] getCurrentRQ(String filter, CountlyStore store) {
//get all request files from target folder
- String[] requests = getCountlyStore().getRequests();
+ String[] requests = store.getRequests();
//create array of request params
Map[] resultMapArray = new ConcurrentHashMap[requests.length];
diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java
index 0fefb84e6..e8555807c 100644
--- a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java
+++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java
@@ -66,7 +66,7 @@ public void joinCountlyStore() {
public void APITargeting() {
//The supported versions should be above this value
Assert.assertTrue(Build.VERSION.SDK_INT >= 21);
- Assert.assertTrue(Build.VERSION.SDK_INT <= 34);
+ Assert.assertTrue(Build.VERSION.SDK_INT <= 37);
}
/**
diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
index 9623c4acd..c356a7f00 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java
@@ -73,6 +73,15 @@ class ConnectionQueue implements RequestQueueProvider {
protected ConsentProvider consentProvider;//link to the consent module
protected ModuleRequestQueue moduleRequestQueue = null;//todo remove in the future
protected DeviceInfo deviceInfo = null;//todo ?remove in the future?
+
+ // Back-reference to the owning Countly instance. Used to read per-instance state (session
+ // flags, SDK identity, init state) instead of reaching for Countly.sharedInstance(), which
+ // would always resolve to the default instance and corrupt/misread it under multi-instance.
+ protected Countly cly = null;
+
+ // Per-instance certificate/public-key pinning material (moved off Countly's former statics).
+ protected String[] publicKeyPinCertificates = null;
+ protected String[] certificatePinCertificates = null;
StorageProvider storageProvider;
ConfigurationProvider configProvider;
RequestInfoProvider requestInfoProvider;
@@ -125,19 +134,19 @@ void setupSSLSocketFactory(SSLSocketFactory customSSLSocketFactory) {
// when both are set the custom factory wins and pinning is expected to be baked into it.
if (customSSLSocketFactory != null) {
sslSocketFactory_ = customSSLSocketFactory;
- if (Countly.publicKeyPinCertificates != null || Countly.certificatePinCertificates != null) {
+ if (publicKeyPinCertificates != null || certificatePinCertificates != null) {
L.w("[ConnectionQueue] A custom SSL socket factory is set, the built-in certificate/public key pinning trust manager will not be applied");
}
return;
}
- if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) {
+ if (publicKeyPinCertificates == null && certificatePinCertificates == null) {
sslSocketFactory_ = null;
return;
}
try {
- TrustManager[] tm = { new CertificateTrustManager(Countly.publicKeyPinCertificates, Countly.certificatePinCertificates) };
+ TrustManager[] tm = { new CertificateTrustManager(publicKeyPinCertificates, certificatePinCertificates) };
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tm, null);
sslSocketFactory_ = sslContext.getSocketFactory();
@@ -182,7 +191,7 @@ boolean checkInternalState() {
//assert baseInfoProvider.getServerURL() != null;
//assert UtilsNetworking.isValidURL(baseInfoProvider.getServerURL());
//assert storageProvider != null;
- //assert Countly.publicKeyPinCertificates != null && baseInfoProvider.getServerURL().startsWith("https");
+ //assert publicKeyPinCertificates != null && baseInfoProvider.getServerURL().startsWith("https");
if (context_ == null) {
if (L != null) {
@@ -208,7 +217,7 @@ boolean checkInternalState() {
}
return false;
}
- if (Countly.publicKeyPinCertificates != null && !baseInfoProvider.getServerURL().startsWith("https")) {
+ if (publicKeyPinCertificates != null && !baseInfoProvider.getServerURL().startsWith("https")) {
if (L != null) {
L.e("[Connection Queue] server must start with https once you specified public keys");
}
@@ -247,7 +256,7 @@ public void beginSession(boolean locationDisabled, @Nullable String locationCoun
}
}
- Countly.sharedInstance().isBeginSessionSent = true;
+ cly.isBeginSessionSent = true;
addRequestToQueue(data, false, null);
tick();
@@ -775,8 +784,8 @@ String prepareCommonRequestDataShort(@NonNull UtilsTime.Instant instant, @NonNul
return "app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey())
+ "&device_id=" + UtilsNetworking.urlEncodeString(deviceId)
+ "×tamp=" + instant.timestampMs
- + "&sdk_version=" + Countly.sharedInstance().COUNTLY_SDK_VERSION_STRING
- + "&sdk_name=" + Countly.sharedInstance().COUNTLY_SDK_NAME
+ + "&sdk_version=" + cly.COUNTLY_SDK_VERSION_STRING
+ + "&sdk_name=" + cly.COUNTLY_SDK_NAME
+ "&av=" + UtilsNetworking.urlEncodeString(deviceInfo.getAppVersionWithOverride(context_, metricOverride));
}
@@ -956,7 +965,7 @@ public void tick() {
boolean cpDoneIfOngoing = connectionProcessorFuture_ != null && connectionProcessorFuture_.isDone();
L.v("[ConnectionQueue] tick, IsRQEmpty:[" + rqEmpty + "], HasOngoingProcess:[" + (connectionProcessorFuture_ == null) + "], OngoingProcess_Done:[" + cpDoneIfOngoing + "]");
- if (!Countly.sharedInstance().isInitialized()) {
+ if (cly == null || !cly.isInitialized()) {
L.e("[ConnectionQueue] tick, SDK is not initialized");
//attempting to tick when the SDK is not initialized
return;
diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java
index e35199413..9dc797ca4 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java
@@ -46,12 +46,45 @@ class ContentOverlayView extends FrameLayout {
TransparentActivityConfig configPortrait;
TransparentActivityConfig configLandscape;
int currentOrientation;
+ // Owning Countly instance. Content/feedback events recorded from the overlay and its request
+ // flushes must go to the instance that opened the overlay, not to Countly.sharedInstance()
+ // (which would route every instance's content events into the default instance's queue).
+ @NonNull private final Countly cly;
private ContentCallback contentCallback;
private final Set allowedLinkSchemes;
private final ContentUrlHandler contentUrlHandler;
private Runnable onCloseRunnable;
private Runnable onWidgetCancelRunnable;
private boolean isClosed = false;
+
+ // Process-global presentation guard. The content and feedback modules are per-instance, but the
+ // overlay is bound to the single foreground Activity, so at most ONE content/feedback overlay may
+ // be presented at a time across ALL instances. Claimed in attachToActivity, released in
+ // close()/destroy() - never on background detach (the overlay is still the active presentation
+ // while backgrounded).
+ private static ContentOverlayView presentedOverlay;
+
+ /**
+ * @return true if any content or feedback overlay is currently presented, on any instance.
+ */
+ static boolean isOverlayPresented() {
+ return presentedOverlay != null && !presentedOverlay.isClosed;
+ }
+
+ /**
+ * @return true if an overlay OTHER than {@code self} is currently presented. This lets an
+ * instance refresh or replace its own overlay while still being blocked from stacking on top of a
+ * different instance's (or the other module's) overlay.
+ */
+ static boolean isOtherOverlayPresented(ContentOverlayView self) {
+ return presentedOverlay != null && presentedOverlay != self && !presentedOverlay.isClosed;
+ }
+
+ private void releasePresentationGuard() {
+ if (presentedOverlay == this) {
+ presentedOverlay = null;
+ }
+ }
private Activity currentHostActivity;
private WindowManager windowManager;
private boolean isAddedToWindow = false;
@@ -83,7 +116,8 @@ private static Context resolveOverlayContext(@NonNull Activity activity) {
return activity.getApplicationContext();
}
- @SuppressLint("SetJavaScriptEnabled") ContentOverlayView(@NonNull Activity activity,
+ @SuppressLint("SetJavaScriptEnabled") ContentOverlayView(@NonNull Countly cly,
+ @NonNull Activity activity,
@NonNull TransparentActivityConfig portrait,
@NonNull TransparentActivityConfig landscape,
int orientation,
@@ -97,6 +131,7 @@ private static Context resolveOverlayContext(@NonNull Activity activity) {
// resolveOverlayContext above.
super(resolveOverlayContext(activity));
+ this.cly = cly;
this.configPortrait = portrait;
this.configLandscape = landscape;
this.currentOrientation = orientation;
@@ -433,6 +468,10 @@ void attachToActivity(@NonNull Activity activity) {
return;
}
+ // Claim the process-global presentation guard: from here this overlay is the active
+ // presentation (idempotent across background/foreground reattaches).
+ presentedOverlay = this;
+
// Check if we're already attached to this activity
if (currentHostActivity == activity && isAddedToWindow) {
// Still check for orientation changes — WindowManager views don't get onConfigurationChanged
@@ -606,7 +645,7 @@ private TransparentActivityConfig setupConfig(@NonNull Context context, @NonNull
// Clamp dimensions on the copy so content doesn't exceed the safe area.
// This must be done here (not on the original configs) because SafeAreaCalculator
// can return stale WindowMetrics during orientation transitions.
- SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(context, Countly.sharedInstance().L);
+ SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(context, cly.L);
boolean isLandscape = currentOrientation == Configuration.ORIENTATION_LANDSCAPE;
int safeWidth = isLandscape ? safeArea.landscapeWidth : safeArea.portraitWidth;
int safeHeight = isLandscape ? safeArea.landscapeHeight : safeArea.portraitHeight;
@@ -694,7 +733,7 @@ private void notifyWebViewOfResize(@NonNull Activity activity) {
int widthPx, heightPx;
if (currentConfig.useSafeArea) {
- SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, Countly.sharedInstance().L);
+ SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, cly.L);
if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) {
widthPx = safeArea.landscapeWidth;
heightPx = safeArea.landscapeHeight;
@@ -815,13 +854,13 @@ private void eventAction(Map query) {
segmentation.put(key, value);
}
- Countly.sharedInstance().events().recordEvent(eventJson.get("key").toString(), segmentation);
+ cly.events().recordEvent(eventJson.get("key").toString(), segmentation);
} catch (JSONException e) {
Log.e(Countly.TAG, "[ContentOverlayView] eventAction, Failed to parse event JSON", e);
}
}
- Countly.sharedInstance().requestQueue().attemptToSendStoredRequests();
+ cly.requestQueue().attemptToSendStoredRequests();
}
}
@@ -1059,7 +1098,7 @@ private static boolean hasUriScheme(@NonNull String value) {
}
private void recalculateSafeAreaOffsets(@NonNull Activity activity) {
- SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, Countly.sharedInstance().L);
+ SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, cly.L);
// Update offsets with correct values from Activity context
configPortrait.topOffset = safeArea.portraitTopOffset;
@@ -1181,6 +1220,7 @@ void close(Map contentData) {
return;
}
isClosed = true;
+ releasePresentationGuard();
Log.d(Countly.TAG, "[ContentOverlayView] close, closing content overlay");
@@ -1207,6 +1247,7 @@ void destroy() {
exitImmersiveMode();
isClosed = true;
+ releasePresentationGuard();
unregisterOrientationCallback();
unregisterActivityLifecycleCallback();
diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java
index 1547d97df..925c70a40 100644
--- a/sdk/src/main/java/ly/count/android/sdk/Countly.java
+++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java
@@ -35,6 +35,7 @@ of this software and associated documentation files (the "Software"), to deal
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -98,8 +99,9 @@ public class Countly {
*/
protected static final long TIMER_DELAY_IN_SECONDS = 60;
- protected static String[] publicKeyPinCertificates;
- protected static String[] certificatePinCertificates;
+ // Certificate/public-key pinning material now lives per-instance on the owning ConnectionQueue
+ // (see ConnectionQueue#publicKeyPinCertificates). Keeping it static made two instances pointed
+ // at different servers share one pinning set (last-init-wins) — a correctness and security hazard.
interface LifecycleObserver {
boolean LifeCycleAtleastStarted();
@@ -134,11 +136,17 @@ public enum CountlyMessagingProvider {
static final int maxStackTraceLineLengthDefault = 200;
static final int maxStackTraceThreadCountDefault = 50;
- // see http://stackoverflow.com/questions/7048198/thread-safe-singletons-in-java
- private static class SingletonHolder {
- @SuppressLint("StaticFieldLeak")
- static final Countly instance = new Countly();
- }
+ // Reserved name of the default (shared) instance returned by sharedInstance(). The '[CLY]_'
+ // prefix is the SDK's internal-key convention, so it will not collide with a customer app key
+ // or instance name.
+ static final String DEFAULT_NAME = "[CLY]_default_instance";
+
+ // Registry of live Countly instances keyed by instance name (default instance under
+ // DEFAULT_NAME). Static for process-wide access exactly like the previous singleton; instances
+ // live for the process lifetime - halt() resets an instance's state but keeps the object
+ // registered, so repeated sharedInstance()/instance(name) calls return a stable object.
+ @SuppressLint("StaticFieldLeak")
+ static final Map instances_ = new ConcurrentHashMap<>();
// Test support only (default OFF, never enabled in production): instrumented tests create many
// detached "new Countly().init(...)" instances but usually halt only the singleton, so each
@@ -217,6 +225,11 @@ static void haltTrackedInstances() {
//reference to countly store
CountlyStore countlyStore;
+ // Storage namespace suffix for this instance's persisted files (main store + legacy OpenUDID).
+ // Empty for the default instance -> legacy file names (backward compatible); derived from the
+ // instance name for named instances so their storage is fully isolated.
+ String storageNamespace_ = "";
+
//overrides
boolean isHttpPostForced = false;//when true, all data sent to the server will be sent using HTTP POST
@@ -248,6 +261,18 @@ static void haltTrackedInstances() {
boolean applicationClassProvided = false;
+ // The name this instance is registered under in the process-wide registry. Authoritative for
+ // storage namespacing: DEFAULT_NAME -> legacy files; any other name -> isolated, suffixed files.
+ String instanceName_ = DEFAULT_NAME;
+
+ // Process-global lifecycle/component callbacks are registered on the Application per init().
+ // We keep references so halt() can unregister them; otherwise every init/halt cycle leaks a
+ // callback bound to a dead instance that keeps receiving Activity/config events - a real hazard
+ // once multiple instances come and go in one process.
+ private Application lifecycleApplication_;
+ private Application.ActivityLifecycleCallbacks activityLifecycleCallbacks_;
+ private ComponentCallbacks componentCallbacks_;
+
public static class CountlyFeatureNames {
public static final String sessions = "sessions";
public static final String events = "events";
@@ -270,10 +295,112 @@ public static class CountlyFeatureNames {
}
/**
- * Returns the Countly singleton.
+ * Returns the default (shared) Countly instance. Existing single-instance integrations use this
+ * method and are unaffected by multi-instance support - the default instance keeps the legacy
+ * storage location and behavior.
*/
public static Countly sharedInstance() {
- return SingletonHolder.instance;
+ return instance(DEFAULT_NAME);
+ }
+
+ /**
+ * Returns the Countly instance registered under the given name, creating it (uninitialized) if it
+ * does not yet exist. The {@code name} argument is the sole identity of the instance: it is what
+ * isolates the instance's storage (request queue, event queue, device id, configuration) from
+ * every other instance. Any stable string works; passing your app key as the name is the natural
+ * choice for one instance per Countly application. A null or empty name returns the default
+ * (shared) instance. The returned instance is not initialized - call {@code init(config)} on it.
+ *
+ * @param name the instance name (sole identity of the instance)
+ * @return the (possibly newly created, uninitialized) Countly instance registered under that name
+ */
+ public static Countly instance(String name) {
+ final String key = (name == null || name.isEmpty()) ? DEFAULT_NAME : name;
+ return instances_.computeIfAbsent(key, k -> {
+ Countly c = new Countly();
+ c.instanceName_ = k;
+ // Give named instances a distinct logcat tag so their console output is attributable; the
+ // default instance keeps the plain "Countly" tag for backward compatibility.
+ if (!DEFAULT_NAME.equals(k)) {
+ c.L.setTag(TAG + "-" + k);
+ }
+ return c;
+ });
+ }
+
+ /**
+ * Returns the Countly instance registered under the given name, or null if no such instance has
+ * been created yet. Unlike {@link #instance(String)} this never creates a new instance. A null or
+ * empty name refers to the default (shared) instance.
+ *
+ * @param name the instance name
+ * @return the existing instance, or null if none is registered under that name
+ */
+ public static Countly getInstance(String name) {
+ final String key = (name == null || name.isEmpty()) ? DEFAULT_NAME : name;
+ return instances_.get(key);
+ }
+
+ /**
+ * Returns the names of all currently registered named instances. The default (shared) instance is
+ * not included - it is always reachable via {@link #sharedInstance()}.
+ *
+ * @return a snapshot list of registered named-instance names (may be empty)
+ */
+ public static List listInstances() {
+ List names = new ArrayList<>(instances_.size());
+ for (String key : instances_.keySet()) {
+ if (!DEFAULT_NAME.equals(key)) {
+ names.add(key);
+ }
+ }
+ return names;
+ }
+
+ /**
+ * Halts every registered instance (including the default), resetting each instance's state and
+ * clearing its stored data. The instances remain registered, so a later {@code instance(name)} or
+ * {@code sharedInstance()} returns the same (now halted) object, ready to be initialised again.
+ */
+ public static void haltAllInstances() {
+ for (Countly c : instances_.values()) {
+ c.halt();
+ }
+ }
+
+ /**
+ * Halts the named instance and removes it from the process-wide registry. Unlike {@link #halt()}
+ * (which resets an instance but keeps it registered so it can be initialised again), this
+ * additionally deregisters the object: afterwards {@link #getInstance(String)} returns null for
+ * that name and {@link #instance(String)} creates a fresh, uninitialized instance. Use this to
+ * reclaim an instance you no longer need - without it the registry retains every instance ever
+ * created for the process lifetime, which matters if instances are keyed by dynamic (unbounded)
+ * names.
+ *
+ * The default (shared) instance can not be removed: it must remain a stable object for
+ * {@link #sharedInstance()}, so a null, empty, or default name is a no-op (warned, not silent).
+ * Any reference a caller still holds to the removed instance becomes detached (halted and no
+ * longer registered); obtain a fresh handle via {@link #instance(String)} instead.
+ *
+ * @param name the instance name to halt and deregister
+ */
+ public static void removeInstance(String name) {
+ final String key = (name == null || name.isEmpty()) ? DEFAULT_NAME : name;
+ if (DEFAULT_NAME.equals(key)) {
+ sharedInstance().L.w("[Countly] removeInstance, the default (shared) instance can not be removed; use halt() to reset it. Ignoring.");
+ return;
+ }
+ //remove first so a concurrent instance(name) creates a fresh object rather than handing back
+ //the one being torn down; then halt the removed object to stop its timer, unregister its
+ //global callbacks, and clear its stored data. Once it is out of the static registry and the
+ //caller drops its handle, the whole instance (context, config, queues) becomes GC-eligible.
+ Countly c = instances_.remove(key);
+ if (c == null) {
+ sharedInstance().L.d("[Countly] removeInstance, no instance registered under [" + key + "], nothing to remove");
+ return;
+ }
+ c.L.i("[Countly] removeInstance, halting and deregistering instance [" + key + "]");
+ c.halt();
}
/**
@@ -352,6 +479,26 @@ public synchronized Countly init(CountlyConfig config) {
throw new IllegalArgumentException("valid appKey is required, but was provided either 'null' or empty String");
}
+ //resolve this instance's storage namespace from the name it is registered under. The default
+ //(shared) instance keeps the legacy, un-namespaced files for backward compatibility; a named
+ //instance gets an isolated, sanitized suffix so its queues, device id, and config never
+ //collide with another instance's storage.
+ //CountlyConfig.setInstanceName is advisory: the effective name is the one passed to
+ //instance(name). Warn loudly (never silently) when the config's name cannot take effect, so a
+ //misconfiguration does not silently land data in the wrong storage namespace.
+ if (config.instanceName != null && !config.instanceName.isEmpty() && !config.instanceName.equals(instanceName_)) {
+ if (DEFAULT_NAME.equals(instanceName_)) {
+ L.w("[Init] CountlyConfig.setInstanceName [" + config.instanceName + "] was set, but this handle is the default instance (obtained via sharedInstance()); the name is ignored and legacy storage is used. To create an isolated named instance use Countly.instance(\"" + config.instanceName + "\").init(config).");
+ } else {
+ L.w("[Init] CountlyConfig instanceName [" + config.instanceName + "] differs from the name this instance was obtained with [" + instanceName_ + "]; using [" + instanceName_ + "].");
+ }
+ }
+ if (DEFAULT_NAME.equals(instanceName_)) {
+ storageNamespace_ = "";
+ } else {
+ storageNamespace_ = CountlyStore.sanitizeNamespace(instanceName_);
+ }
+
if (config.application == null) {
L.w("[Init] Initialising the SDK without providing the application class. Some functionality will not work.");
}
@@ -480,7 +627,7 @@ public synchronized Countly init(CountlyConfig config) {
//we are running a test and using a mock object
countlyStore = config.countlyStore;
} else {
- countlyStore = new CountlyStore(config.context, L, config.explicitStorageModeEnabled);
+ countlyStore = new CountlyStore(config.context, L, config.explicitStorageModeEnabled, storageNamespace_);
config.setCountlyStore(countlyStore);
}
@@ -541,11 +688,15 @@ public synchronized Countly init(CountlyConfig config) {
if (config.immediateRequestGenerator == null) {
config.immediateRequestGenerator = new ImmediateRequestGenerator() {
@Override public ImmediateRequestI CreateImmediateRequestMaker() {
- return (new ImmediateRequestMaker());
+ ImmediateRequestMaker maker = new ImmediateRequestMaker();
+ maker.useSerialExecutor = useSerialExecutorInternal;
+ return maker;
}
@Override public ImmediateRequestI CreatePreflightRequestMaker() {
- return (new PreflightRequestMaker());
+ PreflightRequestMaker maker = new PreflightRequestMaker();
+ maker.useSerialExecutor = useSerialExecutorInternal;
+ return maker;
}
};
}
@@ -595,7 +746,7 @@ public synchronized Countly init(CountlyConfig config) {
Map migrationParams = new HashMap<>();
migrationParams.put(MigrationHelper.key_from_0_to_1_custom_id_set, config.deviceID != null);
- MigrationHelper mHelper = new MigrationHelper(config.storageProvider, L, context_);
+ MigrationHelper mHelper = new MigrationHelper(config.storageProvider, L, context_, storageNamespace_.isEmpty());
mHelper.doWork(migrationParams);
} catch (Exception ex) {
L.e("[Init] SDK failed while performing data migration. SDK is not capable to initialize.");
@@ -700,16 +851,17 @@ public synchronized Countly init(CountlyConfig config) {
}
if (config.publicKeyPinningCertificates != null) {
- sharedInstance().L.i("[Init] Enabling public key pinning");
- publicKeyPinCertificates = config.publicKeyPinningCertificates;
+ L.i("[Init] Enabling public key pinning");
+ connectionQueue_.publicKeyPinCertificates = config.publicKeyPinningCertificates;
}
if (config.certificatePinningCertificates != null) {
- Countly.sharedInstance().L.i("[Init] Enabling certificate pinning");
- certificatePinCertificates = config.certificatePinningCertificates;
+ L.i("[Init] Enabling certificate pinning");
+ connectionQueue_.certificatePinCertificates = config.certificatePinningCertificates;
}
//initialize networking queues
+ connectionQueue_.cly = this;
connectionQueue_.L = L;
connectionQueue_.healthTracker = config.healthTracker;
connectionQueue_.configProvider = config.configProvider;
@@ -757,7 +909,8 @@ public synchronized Countly init(CountlyConfig config) {
//set global application listeners
if (config.application != null) {
L.d("[Countly] Calling registerActivityLifecycleCallbacks");
- config.application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
+ lifecycleApplication_ = config.application;
+ activityLifecycleCallbacks_ = new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
if (L.logEnabled()) {
@@ -831,9 +984,10 @@ public void onActivityDestroyed(Activity activity) {
module.onActivityDestroyed(activity);
}
}
- });
+ };
+ config.application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks_);
- config.application.registerComponentCallbacks(new ComponentCallbacks() {
+ componentCallbacks_ = new ComponentCallbacks() {
@Override
public void onConfigurationChanged(Configuration configuration) {
L.d("[Countly] ComponentCallbacks, onConfigurationChanged");
@@ -844,7 +998,8 @@ public void onConfigurationChanged(Configuration configuration) {
public void onLowMemory() {
L.d("[Countly] ComponentCallbacks, onLowMemory");
}
- });
+ };
+ config.application.registerComponentCallbacks(componentCallbacks_);
} else {
L.d("[Countly] Global activity listeners not registred due to no Application class");
}
@@ -998,6 +1153,20 @@ public synchronized void halt() {
L.SetListener(null);
stopTimer();
+ //unregister the process-global lifecycle/component callbacks this instance registered at
+ //init so a halted instance stops receiving Activity/configuration events and does not leak
+ if (lifecycleApplication_ != null) {
+ if (activityLifecycleCallbacks_ != null) {
+ lifecycleApplication_.unregisterActivityLifecycleCallbacks(activityLifecycleCallbacks_);
+ activityLifecycleCallbacks_ = null;
+ }
+ if (componentCallbacks_ != null) {
+ lifecycleApplication_.unregisterComponentCallbacks(componentCallbacks_);
+ componentCallbacks_ = null;
+ }
+ lifecycleApplication_ = null;
+ }
+
if (connectionQueue_ != null) {
if (countlyStore != null) {
countlyStore.clear();
@@ -1231,9 +1400,12 @@ public void setLoggingEnabled(final boolean enableLogging) {
if (enableLogging && loggingForcedOffForProduction) {
//logging is suppressed for production builds, keep console output off
enableLogging_ = false;
+ L.setLoggingEnabled(false);
return;
}
enableLogging_ = enableLogging;
+ //mirror the resolved flag into this instance's logger so console output is gated per-instance
+ L.setLoggingEnabled(enableLogging_);
L.d("Enabling logging");
}
diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java
index 7eb028097..84c08b93a 100644
--- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java
+++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java
@@ -80,6 +80,12 @@ public class CountlyConfig {
*/
protected String appKey = null;
+ // Optional, advisory instance name. This field does NOT by itself create or namespace an
+ // instance - the effective instance name is the one passed to Countly.instance(name). At init
+ // this value is only cross-checked against that registry name and a warning is logged on a
+ // mismatch (or when it is set on the default instance, where it is ignored).
+ protected String instanceName = null;
+
/**
* unique ID for the device the app is running on; note that null in deviceID means that Countly will fall back to UUID.
*/
@@ -314,6 +320,24 @@ public synchronized CountlyConfig setAppKey(String appKey) {
return this;
}
+ /**
+ * Optionally records the intended instance name on this config. The effective name of an instance
+ * is the one you pass to {@code Countly.instance(name)} - that is what creates the instance and
+ * isolates its storage (request queue, event queue, device id, configuration). This setter does
+ * not create or namespace anything on its own; at init the value is only cross-checked against
+ * the name the instance was obtained with, and a warning is logged if they differ (or if it is
+ * set on the default instance obtained via {@code sharedInstance()}, where it is ignored). To
+ * create an isolated named instance, use {@code Countly.instance(name).init(config)} - passing
+ * your app key as the name is the natural choice for one instance per Countly application.
+ *
+ * @param instanceName the name to record on the config (should match the name passed to instance())
+ * @return Returns the same config object for convenient linking
+ */
+ public synchronized CountlyConfig setInstanceName(String instanceName) {
+ this.instanceName = instanceName;
+ return this;
+ }
+
/**
* unique ID for the device the app is running on; note that null in deviceID means that Countly will fall back to UUID.
*
diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java b/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java
index 6b0a5092f..8435b12bf 100644
--- a/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java
+++ b/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java
@@ -75,6 +75,10 @@ public class CountlyStore implements StorageProvider, EventQueueProvider {
private final SharedPreferences preferences_;
private final SharedPreferences preferencesPush_;
+ // True only for the default-instance store. The push preferences file is shared process-wide
+ // (push is owned by the default/"primary" instance), so only the default instance may clear it -
+ // otherwise halting a named instance would wipe the primary instance's push consent and cache.
+ private final boolean ownsPushStorage;
private static final String CONSENT_GCM_PREFERENCES = "ly.count.android.api.messaging.consent.gcm";
@@ -109,15 +113,67 @@ public CountlyStore(final Context context, ModuleLog logModule) {
}
public CountlyStore(final Context context, ModuleLog logModule, boolean explicitStorageModeEnabled) {
+ this(context, logModule, explicitStorageModeEnabled, null);
+ }
+
+ /**
+ * @param storageNamespace suffix that isolates this instance's persisted state. A null or empty
+ * namespace keeps the legacy file name (used by the default instance),
+ * so an app upgrading from a single-instance SDK version keeps its
+ * request queue, event queue, device id, and schema version intact.
+ * Non-default instances get a suffixed file, isolating their storage.
+ */
+ public CountlyStore(final Context context, ModuleLog logModule, boolean explicitStorageModeEnabled, String storageNamespace) {
if (context == null) {
throw new IllegalArgumentException("must provide valid context");
}
this.explicitStorageModeEnabled = explicitStorageModeEnabled;
- preferences_ = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
+ this.ownsPushStorage = (storageNamespace == null || storageNamespace.isEmpty());
+ preferences_ = context.getSharedPreferences(namespacedName(PREFERENCES, storageNamespace), Context.MODE_PRIVATE);
+ // Push preferences intentionally stay on the shared legacy file: push is owned by the
+ // default ("primary") instance and there is a single push registration per process.
preferencesPush_ = createPreferencesPush(context);
L = logModule;
}
+ /**
+ * Builds a SharedPreferences file name for a storage namespace. An empty or null namespace maps
+ * to the legacy base name (default instance, backward compatible); otherwise base + "_" + ns.
+ */
+ static String namespacedName(String base, String storageNamespace) {
+ if (storageNamespace == null || storageNamespace.isEmpty()) {
+ return base;
+ }
+ return base + "_" + storageNamespace;
+ }
+
+ /**
+ * Turns an instance name into a file-name-safe storage namespace. Non-alphanumeric characters
+ * are replaced with '_', and a short deterministic FNV-1a hash of the raw name is appended so
+ * two names that sanitize to the same string (e.g. "a.b" and "a-b") still get distinct files.
+ */
+ static String sanitizeNamespace(String name) {
+ if (name == null || name.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder(name.length() + 9);
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
+ sb.append(c);
+ } else {
+ sb.append('_');
+ }
+ }
+ int hash = 0x811C9DC5; // FNV-1a 32-bit offset basis
+ for (int i = 0; i < name.length(); i++) {
+ hash ^= name.charAt(i);
+ hash *= 0x01000193; // FNV prime
+ }
+ sb.append('_').append(Integer.toHexString(hash));
+ return sb.toString();
+ }
+
public void setLimits(final int maxRequestQueueSize) {
this.maxRequestQueueSize = maxRequestQueueSize;
}
@@ -881,7 +937,11 @@ public synchronized void clear() {
esRequestQueueCache = null;
esEventQueueCache = null;
- preferencesPush_.edit().clear().apply();
+ // Only the default instance owns the shared push preferences file; a named instance must not
+ // wipe the primary instance's push consent/cache when it is halted or cleared.
+ if (ownsPushStorage) {
+ preferencesPush_.edit().clear().apply();
+ }
}
@Nullable
@@ -965,17 +1025,25 @@ public void setDataSchemaVersion(int version) {
return true;
}
- if (preferencesPush_.getInt(CACHED_PUSH_MESSAGING_PROVIDER, -100) != -100) {
- return true;
- }
+ // The push preferences file is shared process-wide and owned by the default ("primary")
+ // instance. Only the owning instance may treat push data as evidence that ITS storage has
+ // been used before. For a named instance the shared push file is not its own data, so
+ // counting it here would misdetect a brand-new named store as a legacy install and trigger
+ // a schema migration - which, on a fresh store, overrides a developer-supplied device ID
+ // with a generated OPEN_UDID. A named instance's freshness is judged by its own store only.
+ if (ownsPushStorage) {
+ if (preferencesPush_.getInt(CACHED_PUSH_MESSAGING_PROVIDER, -100) != -100) {
+ return true;
+ }
- if (preferencesPush_.getString(CACHED_PUSH_ACTION_ID, null) != null) {
- return true;
- }
+ if (preferencesPush_.getString(CACHED_PUSH_ACTION_ID, null) != null) {
+ return true;
+ }
- //noinspection RedundantIfStatement
- if (preferencesPush_.getString(CACHED_PUSH_ACTION_INDEX, null) != null) {
- return true;
+ //noinspection RedundantIfStatement
+ if (preferencesPush_.getString(CACHED_PUSH_ACTION_INDEX, null) != null) {
+ return true;
+ }
}
return false;
diff --git a/sdk/src/main/java/ly/count/android/sdk/ImmediateRequestMaker.java b/sdk/src/main/java/ly/count/android/sdk/ImmediateRequestMaker.java
index 3f6df650b..dc8f61d90 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ImmediateRequestMaker.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ImmediateRequestMaker.java
@@ -24,6 +24,9 @@ protected interface InternalImmediateRequestCallback {
InternalImmediateRequestCallback callback;
ModuleLog L;
+ // Set by the owning instance's ImmediateRequestGenerator so the executor choice follows the
+ // instance that issued the request rather than Countly.sharedInstance().
+ boolean useSerialExecutor = false;
@Override
public void doWork(@NonNull String requestData, @Nullable String customEndpoint, @NonNull ConnectionProcessor cp, boolean requestShouldBeDelayed, boolean networkingIsEnabled, @NonNull InternalImmediateRequestCallback callback, @NonNull ModuleLog log) {
@@ -31,7 +34,7 @@ public void doWork(@NonNull String requestData, @Nullable String customEndpoint,
assert cp != null;
assert log != null;
assert callback != null;
- if (Countly.sharedInstance().useSerialExecutorInternal) {
+ if (useSerialExecutor) {
log.d("[ImmediateRequestMaker] Using serial executor");
this.execute(requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log);
} else {
diff --git a/sdk/src/main/java/ly/count/android/sdk/MigrationHelper.java b/sdk/src/main/java/ly/count/android/sdk/MigrationHelper.java
index 44dd24e27..e6bb602cf 100644
--- a/sdk/src/main/java/ly/count/android/sdk/MigrationHelper.java
+++ b/sdk/src/main/java/ly/count/android/sdk/MigrationHelper.java
@@ -26,12 +26,19 @@ class MigrationHelper {
StorageProvider storage;
ModuleLog L;
Context cachedContext;
+ // Only the default instance owns the shared push file; steps touching it run only for the owner.
+ boolean ownsPushStorage = true;
static final public String legacyDeviceIDTypeValue_AdvertisingID = "ADVERTISING_ID";
public static final String legacyCACHED_PUSH_MESSAGING_MODE = "PUSH_MESSAGING_MODE";
public MigrationHelper(@NonNull StorageProvider storage, @NonNull ModuleLog moduleLog, @NonNull Context context) {
+ this(storage, moduleLog, context, true);
+ }
+
+ /** ownsPushStorage: true only for the default instance, which owns the shared push file. */
+ public MigrationHelper(@NonNull StorageProvider storage, @NonNull ModuleLog moduleLog, @NonNull Context context, boolean ownsPushStorage) {
assert storage != null;
assert moduleLog != null;
assert context != null;
@@ -39,6 +46,7 @@ public MigrationHelper(@NonNull StorageProvider storage, @NonNull ModuleLog modu
this.storage = storage;
L = moduleLog;
cachedContext = context;
+ this.ownsPushStorage = ownsPushStorage;
L.v("[MigrationHelper] Initialising");
}
@@ -248,6 +256,10 @@ void performMigration1To2(@NonNull Map migrationParams) {
* @param migrationParams
*/
void performMigration2To3(@NonNull Map migrationParams) {
+ // Only the owner (default instance) may edit the shared, process-global push file.
+ if (!ownsPushStorage) {
+ return;
+ }
SharedPreferences sp = CountlyStore.createPreferencesPush(cachedContext);
sp.edit().remove(legacyCACHED_PUSH_MESSAGING_MODE).apply();
}
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java
index d900a25af..3abf9df5c 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java
@@ -253,7 +253,7 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) {
currentVUserPropertyCacheLimit = extractValue(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, currentVUserPropertyCacheLimit, Integer.class, (Integer value) -> value > 0);
clyConfig.setMaxRequestQueueSize(extractValue(keyRReqQueueSize, sb, clyConfig.maxRequestQueueSize, clyConfig.maxRequestQueueSize, Integer.class, (Integer value) -> value > 0));
- clyConfig.setEventQueueSizeToSend(extractValue(keyREventQueueSize, sb, clyConfig.eventQueueSizeThreshold, Countly.sharedInstance().EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0));
+ clyConfig.setEventQueueSizeToSend(extractValue(keyREventQueueSize, sb, clyConfig.eventQueueSizeThreshold, _cly.EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0));
clyConfig.setLoggingEnabled(extractValue(keyRLogging, sb, clyConfig.loggingEnabled, clyConfig.loggingEnabled));
clyConfig.setUpdateSessionTimerDelay(extractValue(keyRSessionUpdateInterval, sb, clyConfig.sessionUpdateTimerDelay, Long.valueOf(Countly.TIMER_DELAY_IN_SECONDS).intValue(), Integer.class, (Integer value) -> value > 0));
clyConfig.sdkInternalLimits.setMaxKeyLength(extractValue(keyRLimitKeyLength, sb, clyConfig.sdkInternalLimits.maxKeyLength, Countly.maxKeyLengthDefault, Integer.class, (Integer value) -> value > 0));
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java
index 5571dd2b6..0c90eaf15 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java
@@ -306,6 +306,15 @@ private void showContentOverlay(@NonNull Activity activity, @NonNull Map timedEvents = new HashMap<>();
+ // Per-instance timed-event store. Was 'static', which let a timed event started on one Countly
+ // instance be ended/cancelled/cleared by another; each instance now owns its own timed events.
+ final Map timedEvents = new HashMap<>();
final static String ACTION_EVENT_KEY = "[CLY]_action";
final static String VISIBILITY_KEY = "cly_v";
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java
index 506c724a1..62317f89a 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java
@@ -299,9 +299,9 @@ void presentFeedbackWidgetInternal(@Nullable final CountlyFeedbackWidget widgetI
widgetListUrl.append("&app_key=");
widgetListUrl.append(UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey()));
widgetListUrl.append("&sdk_version=");
- widgetListUrl.append(Countly.sharedInstance().COUNTLY_SDK_VERSION_STRING);
+ widgetListUrl.append(_cly.COUNTLY_SDK_VERSION_STRING);
widgetListUrl.append("&sdk_name=");
- widgetListUrl.append(Countly.sharedInstance().COUNTLY_SDK_NAME);
+ widgetListUrl.append(_cly.COUNTLY_SDK_NAME);
widgetListUrl.append("&platform=android");
// TODO: this will be the base for the custom segmentation users can send while presenting a widget
@@ -496,6 +496,13 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url
};
}
+ // Only one content or feedback overlay may be presented at a time across the whole process,
+ // including other SDK instances (the overlay is bound to the single foreground Activity).
+ if (ContentOverlayView.isOtherOverlayPresented(feedbackOverlay)) {
+ L.w("[ModuleFeedback] a content or feedback overlay is already being shown (possibly by another instance), skipping this widget");
+ return;
+ }
+
// Clean up any existing feedback overlay
if (feedbackOverlay != null) {
feedbackOverlay.destroy();
@@ -504,6 +511,7 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url
final Activity hostActivity = activity;
feedbackOverlay = new ContentOverlayView(
+ _cly,
hostActivity,
pConfig,
lConfig,
@@ -593,9 +601,9 @@ void getFeedbackWidgetDataInternal(@Nullable CountlyFeedbackWidget widgetInfo, @
requestData.append(UtilsNetworking.urlEncodeString(widgetInfo.widgetId));
requestData.append("&shown=1");
requestData.append("&sdk_version=");
- requestData.append(Countly.sharedInstance().COUNTLY_SDK_VERSION_STRING);
+ requestData.append(_cly.COUNTLY_SDK_VERSION_STRING);
requestData.append("&sdk_name=");
- requestData.append(Countly.sharedInstance().COUNTLY_SDK_NAME);
+ requestData.append(_cly.COUNTLY_SDK_NAME);
requestData.append("&platform=android");
requestData.append("&app_version=");
requestData.append(cachedAppVersion);
@@ -606,7 +614,7 @@ void getFeedbackWidgetDataInternal(@Nullable CountlyFeedbackWidget widgetInfo, @
L.d("[ModuleFeedback] Using following request params for retrieving widget data:[" + requestDataStr + "]");
- (new ImmediateRequestMaker()).doWork(requestDataStr, widgetDataEndpoint, cp, false, networkingIsEnabled, new ImmediateRequestMaker.InternalImmediateRequestCallback() {
+ iRGenerator.CreateImmediateRequestMaker().doWork(requestDataStr, widgetDataEndpoint, cp, false, networkingIsEnabled, new ImmediateRequestMaker.InternalImmediateRequestCallback() {
@Override public void callback(JSONObject checkResponse) {
if (checkResponse == null) {
L.d("[ModuleFeedback] Not possible to retrieve widget data. Probably due to lack of connection to the server");
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleLog.java b/sdk/src/main/java/ly/count/android/sdk/ModuleLog.java
index a8b98c1f7..ccd499d0c 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleLog.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleLog.java
@@ -16,10 +16,26 @@ public enum LogLevel {Verbose, Debug, Info, Warning, Error}
int countWarnings = 0;
int countErrors = 0;
+ // Per-instance logging state. In a multi-instance setup every Countly object owns its own
+ // ModuleLog, so console output honors that instance's own config instead of the singleton's.
+ // loggingEnabled is mirrored from the owning Countly (see Countly#setLoggingEnabled); tag is set
+ // once per named instance in Countly.instance(name) (named instances get "Countly-", the
+ // default keeps the plain "Countly" tag) so a named instance's logcat output is attributable.
+ boolean loggingEnabled = false;
+ String tag = Countly.TAG;
+
void SetListener(LogCallback logListener) {
this.logListener = logListener;
}
+ void setLoggingEnabled(boolean loggingEnabled) {
+ this.loggingEnabled = loggingEnabled;
+ }
+
+ void setTag(String tag) {
+ this.tag = tag;
+ }
+
void trackWarning() {
if (healthTracker == null) {
countWarnings++;
@@ -60,8 +76,8 @@ public void v(String msg) {
if (!logEnabled()) {
return;
}
- if (Countly.sharedInstance().isLoggingEnabled()) {
- Log.v(Countly.TAG, msg);
+ if (loggingEnabled) {
+ Log.v(tag, msg);
}
informListener(msg, null, LogLevel.Verbose);
}
@@ -70,8 +86,8 @@ public void d(String msg) {
if (!logEnabled()) {
return;
}
- if (Countly.sharedInstance().isLoggingEnabled()) {
- Log.d(Countly.TAG, msg);
+ if (loggingEnabled) {
+ Log.d(tag, msg);
}
informListener(msg, null, LogLevel.Debug);
}
@@ -80,8 +96,8 @@ public void i(String msg) {
if (!logEnabled()) {
return;
}
- if (Countly.sharedInstance().isLoggingEnabled()) {
- Log.i(Countly.TAG, msg);
+ if (loggingEnabled) {
+ Log.i(tag, msg);
}
informListener(msg, null, LogLevel.Info);
}
@@ -95,8 +111,8 @@ public void w(String msg, Throwable t) {
if (!logEnabled()) {
return;
}
- if (Countly.sharedInstance().isLoggingEnabled()) {
- Log.w(Countly.TAG, msg);
+ if (loggingEnabled) {
+ Log.w(tag, msg);
}
informListener(msg, null, LogLevel.Warning);
}
@@ -110,14 +126,14 @@ public void e(String msg, Throwable t) {
if (!logEnabled()) {
return;
}
- if (Countly.sharedInstance().isLoggingEnabled()) {
- Log.e(Countly.TAG, msg, t);
+ if (loggingEnabled) {
+ Log.e(tag, msg, t);
}
informListener(msg, t, LogLevel.Error);
}
public boolean logEnabled() {
- return logListener != null || Countly.sharedInstance().isLoggingEnabled();
+ return logListener != null || loggingEnabled;
}
private void informListener(String msg, final Throwable t, final LogLevel level) {
@@ -133,7 +149,7 @@ private void informListener(String msg, final Throwable t, final LogLevel level)
logListener.LogHappened(msg, level);
}
} catch (Exception ex) {
- Log.e(Countly.TAG, "[ModuleLog] Failed to inform listener [" + ex.toString() + "]");
+ Log.e(tag, "[ModuleLog] Failed to inform listener [" + ex.toString() + "]");
}
}
}
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
index c0c1d8d0b..ebf418eb0 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java
@@ -345,7 +345,7 @@ void clearValueStoreInternal() {
RemoteConfigValueStore rcvs = loadConfig();
return rcvs.getAllValuesLegacy();
} catch (Exception ex) {
- Countly.sharedInstance().L.e("[ModuleRemoteConfig] getAllRemoteConfigValuesInternal, Call failed:[" + ex.toString() + "]");
+ L.e("[ModuleRemoteConfig] getAllRemoteConfigValuesInternal, Call failed:[" + ex.toString() + "]");
return new HashMap<>();
}
}
@@ -357,7 +357,7 @@ void clearValueStoreInternal() {
RemoteConfigValueStore rcvs = loadConfig();
return rcvs.getAllValues();
} catch (Exception ex) {
- Countly.sharedInstance().L.e("[ModuleRemoteConfig] getAllRemoteConfigValuesInternal, Call failed:[" + ex.toString() + "]");
+ L.e("[ModuleRemoteConfig] getAllRemoteConfigValuesInternal, Call failed:[" + ex.toString() + "]");
return new HashMap<>();
}
}
diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java
index e91dce3fb..03301123b 100644
--- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java
+++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java
@@ -30,7 +30,8 @@ public class ModuleUserProfile extends ModuleBase {
String org;
String phone;
String picture;
- static String picturePath;//protected only for testing
+ String picturePath;//protected only for testing. Per-instance: was 'static', which let the last
+ // instance to set a profile-picture path clobber it for every other instance.
String gender;
Map custom;
Map customMods;
diff --git a/sdk/src/main/java/ly/count/android/sdk/PreflightRequestMaker.java b/sdk/src/main/java/ly/count/android/sdk/PreflightRequestMaker.java
index e733316d7..73a54d81d 100644
--- a/sdk/src/main/java/ly/count/android/sdk/PreflightRequestMaker.java
+++ b/sdk/src/main/java/ly/count/android/sdk/PreflightRequestMaker.java
@@ -11,6 +11,9 @@ class PreflightRequestMaker extends AsyncTask