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 Management Location Sessions + 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 implements ImmediateRequestMaker.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 ImmediateRequestMaker.InternalImmediateRequestCallback callback, @NonNull ModuleLog log) { @@ -18,7 +21,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("[PreflightRequestMaker] Using serial executor"); this.execute(requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log); } else {