diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b35c260f..2019873f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ ## XX.XX.XX +* Added support for multi-instancing, each with isolated storage, request queue, and device ID. Access a named instance with `Countly.instance(name)` and initialize it yourself, and manage instances with `Countly.getInstance(name)`, `Countly.listInstances()`, and `Countly.removeInstance(name)`. + + `Countly.sharedInstance()` is unchanged, so existing integrations keep working. A named instance starts from empty storage and generates its own device ID, so do not move an existing integration onto one. + + Push notifications and native crash reporting are process wide and stay with the default instance, and at most one content or feedback widget is displayed at a time across all instances. * 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)`. * Added a new configuration option `enableClearStoredDeviceId()` that clears the stored device ID during init, so the SDK resolves a device ID from scratch instead of reusing the stored one. - * Updated the `androidx.annotation` dependency to 1.10.0, the `androidx.lifecycle` dependencies to 2.8.7, and the symbol upload plugin's OkHttp dependency to 4.12.0. ## 26.1.5 diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..a0167e498 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/debug/java/ly/count/android/demo/MultiInstanceHarness.java b/app/src/debug/java/ly/count/android/demo/MultiInstanceHarness.java new file mode 100644 index 000000000..e44676fd9 --- /dev/null +++ b/app/src/debug/java/ly/count/android/demo/MultiInstanceHarness.java @@ -0,0 +1,133 @@ +package ly.count.android.demo; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.os.Debug; +import android.util.Log; +import java.io.File; +import java.util.List; +import ly.count.android.sdk.Countly; +import ly.count.android.sdk.CountlyConfig; + +/** + * Debug-only, intent-driven driver for multi-instance manual testing. No UI, finishes immediately. + * Lives in src/debug so no tracked app file changes and the staging/multi-instance comparison stays valid. + * + * Every op logs under tag MIH so logcat is the single source of evidence. + */ +public class MultiInstanceHarness extends Activity { + private static final String T = "MIH"; + + @Override protected void onCreate(Bundle b) { + super.onCreate(b); + String op = str("op", "stats"); + Log.i(T, "---- op=" + op + " begin"); + long t0 = System.currentTimeMillis(); + try { + run(op); + } catch (Throwable t) { + Log.e(T, "op=" + op + " THREW " + t.getClass().getSimpleName() + ": " + t.getMessage(), t); + } + Log.i(T, "---- op=" + op + " end took_ms=" + (System.currentTimeMillis() - t0)); + stats("after-" + op); + finish(); + } + + private void run(String op) { + switch (op) { + case "init": initOne(str("name", null), str("appkey", null), str("salt", null), str("deviceid", null)); break; + case "event": inst().events().recordEvent(str("key", "harness_event")); break; + case "view": inst().views().startAutoStoppedView(str("viewname", "harness_view")); break; + case "userprop": inst().userProfile().setProperty("harness_prop", str("val", "v")); inst().userProfile().save(); break; + case "beginsession":inst().sessions().beginSession(); break; + case "endsession": inst().sessions().endSession(); break; + case "flush": inst().requestQueue().attemptToSendStoredRequests(); break; + case "stop": inst().stop(); Log.i(T, "stopped " + str("name", "?")); break; + case "halt": inst().halt(); Log.i(T, "halted " + str("name", "?")); break; + case "remove": Countly.removeInstance(str("name", null)); Log.i(T, "removed " + str("name", "?")); break; + case "haltall": Countly.haltAllInstances(); Log.i(T, "halted all"); break; + case "list": break; // stats() prints the registry + case "bulk": bulk(getIntent().getIntExtra("count", 10), str("appkey", null)); break; + default: Log.w(T, "unknown op " + op); + } + } + + /** Initialises one named instance. A null/empty name means the default (shared) instance. */ + private void initOne(String name, String appKey, String salt, String deviceId) { + String key = appKey != null ? appKey : App.getAppKey(); + CountlyConfig cfg = new CountlyConfig(getApplication(), key, App.getServerUrl()) + .setLoggingEnabled(true) + .setEventQueueSizeToSend(1); // send immediately so requests are observable + if (deviceId != null) { + cfg.setDeviceId(deviceId); + } + if (salt != null) { + cfg.setParameterTamperingProtectionSalt(salt); + } + Countly c = (name == null || name.isEmpty()) ? Countly.sharedInstance() : Countly.instance(name); + c.init(cfg); + Log.i(T, "init name=[" + name + "] appkey=[" + key + "] salt=[" + salt + "] deviceid=[" + deviceId + + "] initialised=" + c.isInitialized()); + } + + /** Phase E: create and initialise N instances as fast as possible and report what breaks. */ + private void bulk(int count, String appKey) { + String key = appKey != null ? appKey : App.getAppKey(); + int ok = 0; + String firstFailure = null; + for (int i = 0; i < count; i++) { + String name = "bulk_" + i; + try { + Countly c = Countly.instance(name); + c.init(new CountlyConfig(getApplication(), key, App.getServerUrl()) + .setLoggingEnabled(false) // keep logcat survivable at scale + .setDeviceId("dev_" + name)); + if (c.isInitialized()) { + ok++; + } + } catch (Throwable t) { + if (firstFailure == null) { + firstFailure = "at i=" + i + " " + t.getClass().getName() + ": " + t.getMessage(); + Log.e(T, "bulk FIRST FAILURE " + firstFailure, t); + } + } + if (i % 25 == 0) { + Log.i(T, "bulk progress i=" + i + " ok=" + ok + " " + resources()); + } + } + Log.i(T, "bulk DONE requested=" + count + " initialised_ok=" + ok + + " first_failure=" + (firstFailure == null ? "none" : firstFailure)); + } + + private Countly inst() { + String name = str("name", null); + Countly c = (name == null || name.isEmpty()) ? Countly.sharedInstance() : Countly.getInstance(name); + if (c == null) { + throw new IllegalStateException("no instance registered under [" + name + "]"); + } + return c; + } + + private String resources() { + Runtime r = Runtime.getRuntime(); + File prefs = new File(getApplicationInfo().dataDir, "shared_prefs"); + String[] files = prefs.list(); + return "threads=" + Thread.activeCount() + + " heap_used_mb=" + ((r.totalMemory() - r.freeMemory()) / 1048576) + + " heap_max_mb=" + (r.maxMemory() / 1048576) + + " native_mb=" + (Debug.getNativeHeapAllocatedSize() / 1048576) + + " prefs_files=" + (files == null ? -1 : files.length); + } + + private void stats(String when) { + List named = Countly.listInstances(); + Log.i(T, "STATS[" + when + "] registered_named=" + named.size() + " " + resources()); + Log.i(T, "STATS[" + when + "] names=" + named); + } + + private String str(String k, String dflt) { + String v = getIntent().getStringExtra(k); + return (v == null || v.isEmpty()) ? dflt : v; + } +} 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..0d92745be --- /dev/null +++ b/app/src/main/java/ly/count/android/demo/ActivityExampleMultiInstance.java @@ -0,0 +1,159 @@ +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 -> { + Countly analytics = requireInitialized(ANALYTICS); + if (analytics == null) { + return; + } + analytics.events().recordEvent("analytics_event"); + toast("Recorded 'analytics_event' on '" + ANALYTICS + "'"); + }); + + findViewById(R.id.btnRecordViewAnalytics).setOnClickListener(v -> { + Countly analytics = requireInitialized(ANALYTICS); + if (analytics == null) { + return; + } + analytics.views().startAutoStoppedView("AnalyticsScreen"); + toast("Started view 'AnalyticsScreen' on '" + ANALYTICS + "'"); + }); + + findViewById(R.id.btnToggleLogAnalytics).setOnClickListener(v -> { + Countly analytics = requireInitialized(ANALYTICS); + if (analytics == null) { + return; + } + analyticsLogging = !analyticsLogging; + 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 -> { + Countly billing = requireInitialized(BILLING); + if (billing == null) { + return; + } + billing.events().recordEvent("billing_event"); + toast("Recorded 'billing_event' on '" + BILLING + "'"); + }); + + // --- default instance (contrast) --- + findViewById(R.id.btnRecordEventDefault).setOnClickListener(v -> { + //the default instance can be halted from this very screen ("Halt All Instances"), and the + //module accessors return null when an instance is not initialised + if (!Countly.sharedInstance().isInitialized()) { + toast("The default instance is not initialized (halt all resets it too). Restart the app to init it again."); + return; + } + 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("Registered 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 every instance, the default one included. Stored data was ERASED: device IDs, consent, unsent requests and push preferences"); + }); + } + + 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 class is given, so the SDK + // cannot observe the activity lifecycle for this instance - session control is switched to + // manual, otherwise an automatic session would begin at init (the app is in the foreground) + // that nothing could ever end, updating forever even in the background. + CountlyConfig config = new CountlyConfig(getApplicationContext(), App.getAppKey(), App.getServerUrl()) + .setDeviceId(deviceId) + .enableManualSessionControl() + .setLoggingEnabled(true); + + instance.init(config); + toast("Initialized '" + name + "' (device id: " + deviceId + ")"); + } + + /** + * Returns the initialized instance registered under the name, or null (with a hint toast). The + * returned handle - not a fresh {@code Countly.instance(name)} lookup - must be used for the + * follow-up call: re-fetching through instance(name) would silently re-create a fresh, + * uninitialized instance if removeInstance(name) ran in between, and its module accessors + * (events(), views(), ...) would then return null. + */ + private Countly requireInitialized(String name) { + Countly instance = Countly.getInstance(name); + if (instance == null || !instance.isInitialized()) { + toast("Create and initialize the '" + name + "' instance first"); + return null; + } + return instance; + } + + 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/src/androidTest/AndroidManifest.xml b/sdk/src/androidTest/AndroidManifest.xml index 1aab0afbd..e64c1368d 100644 --- a/sdk/src/androidTest/AndroidManifest.xml +++ b/sdk/src/androidTest/AndroidManifest.xml @@ -16,6 +16,9 @@ + diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConfigSdkInternalLimitsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConfigSdkInternalLimitsTests.java new file mode 100644 index 000000000..7b29d33db --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConfigSdkInternalLimitsTests.java @@ -0,0 +1,82 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ConfigSdkInternalLimitsTests { + + /** + * Every Countly instance keeps its own limits, copied from the developer's config at init, because the + * server behaviour settings resolve them per instance. That copy is hand-written, so this test walks the + * declared fields and fails when one of them is not copied - which is what would otherwise happen + * silently the next time someone adds a limit, leaving the new limit shared through the config again. + */ + @Test + public void copyFrom_copiesEveryDeclaredField() throws Exception { + ConfigSdkInternalLimits source = new ConfigSdkInternalLimits(); + ConfigSdkInternalLimits target = new ConfigSdkInternalLimits(); + + List copied = new ArrayList<>(); + int distinctValue = 11; + + for (Field field : ConfigSdkInternalLimits.class.getDeclaredFields()) { + if (field.isSynthetic() || Modifier.isStatic(field.getModifiers())) { + continue; + } + + Class type = field.getType(); + if (type == Integer.class || type == int.class) { + //a value distinct from both the default and every other field, so a copy that assigns the + //wrong field is caught as well as one that assigns nothing + field.set(source, distinctValue); + distinctValue += 7; + copied.add(field); + } else { + Assert.fail("ConfigSdkInternalLimits." + field.getName() + " has unhandled type " + type + + ". Extend copyFrom() and this test to cover it."); + } + } + + Assert.assertFalse("no fields found - this test would prove nothing", copied.isEmpty()); + + target.copyFrom(source); + + for (Field field : copied) { + Assert.assertEquals("copyFrom() did not copy '" + field.getName() + + "'. Add it to ConfigSdkInternalLimits.copyFrom(), otherwise instances share that limit.", + field.get(source), field.get(target)); + } + } + + /** + * The minimum clamping moved out of Countly#onSdkConfigurationChanged and onto the limits themselves, so + * a server sending 0 or a negative limit can not make the SDK truncate everything to nothing. Limits that + * were never set stay unset - null means "use the SDK default", not "clamp me to 1". + */ + @Test + public void clampToMinimums_raisesSetLimitsBelowOne_andLeavesUnsetOnesAlone() { + ConfigSdkInternalLimits limits = new ConfigSdkInternalLimits(); + + limits.maxKeyLength = 0; + limits.maxValueSize = -5; + limits.maxSegmentationValues = 1; + limits.maxBreadcrumbCount = 40; + //maxStackTraceLinesPerThread and maxStackTraceLineLength deliberately left null + + limits.clampToMinimums(); + + Assert.assertEquals(Integer.valueOf(1), limits.maxKeyLength); + Assert.assertEquals(Integer.valueOf(1), limits.maxValueSize); + Assert.assertEquals("a limit already at the minimum is untouched", Integer.valueOf(1), limits.maxSegmentationValues); + Assert.assertEquals("a valid limit is untouched", Integer.valueOf(40), limits.maxBreadcrumbCount); + Assert.assertNull("an unset limit must stay unset so the SDK default applies", limits.maxStackTraceLinesPerThread); + Assert.assertNull("an unset limit must stay unset so the SDK default applies", limits.maxStackTraceLineLength); + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index 463766908..200ad3088 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -239,7 +239,7 @@ public void testUrlConnectionForEventData() throws IOException { assertFalse(urlConnection.getUseCaches()); assertTrue(urlConnection.getDoInput()); assertFalse(urlConnection.getDoOutput()); - assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + null)), urlConnection.getURL()); + assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + null, new ModuleLog())), urlConnection.getURL()); } /** @@ -252,7 +252,7 @@ public void testUrlConnectionForEventData() throws IOException { public void urlConnectionForEventDataWithSalt() throws IOException { final String eventData = "blahblahblahasd"; final URLConnection urlConnection = connectionProcessor.urlConnectionForServerRequest(eventData, null); - assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + testSaltValue)), urlConnection.getURL()); + assertEquals(new URL(connectionProcessor.getServerURL() + "/i?" + eventData + "&checksum256=" + sha256Hash(eventData + testSaltValue, new ModuleLog())), urlConnection.getURL()); } /** @@ -266,7 +266,7 @@ public void urlConnectionForEventDataWithSaltCustomEndpoint() throws IOException final String eventData = "blahblahblah123"; final String endpoint = "/thisthat"; final URLConnection urlConnection = connectionProcessor.urlConnectionForServerRequest(eventData, endpoint); - assertEquals(new URL(connectionProcessor.getServerURL() + endpoint + "?" + eventData + "&checksum256=" + sha256Hash(eventData + testSaltValue)), urlConnection.getURL()); + assertEquals(new URL(connectionProcessor.getServerURL() + endpoint + "?" + eventData + "&checksum256=" + sha256Hash(eventData + testSaltValue, new ModuleLog())), urlConnection.getURL()); } /** 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..7fbeb3de5 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -1,18 +1,23 @@ package ly.count.android.sdk; import android.app.Activity; +import android.content.Context; import android.content.res.Configuration; import android.os.Bundle; import android.view.Gravity; import android.graphics.PixelFormat; import android.view.View; import android.view.WindowManager; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.core.app.ActivityScenario; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -42,6 +47,8 @@ public class ContentOverlayViewTests { private ContentOverlayView overlay; private ActivityScenario scenario; + //every overlay createOverlay() hands out, so tearDown can release them all - see createOverlay + private final List createdOverlays = new ArrayList<>(); /** * Bare activity used as a host for ContentOverlayView in tests. @@ -54,6 +61,26 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } } + /** + * A host activity that hands out no WindowManager, so ContentOverlayView's window attach fails after + * the presentation guard has already been claimed. A real Activity subclass rather than a mock: + * mocking Activity from inside ActivityScenario.onActivity (the main thread) deadlocks. + * Declared in sdk/src/androidTest/AndroidManifest.xml. + */ + public static class NoWindowManagerActivity extends Activity { + // Off until the activity is up: the framework itself needs the WindowManager to build the + // activity's window, so withholding it from the start would break the launch. + volatile boolean withholdWindowManager = false; + + @Override + public Object getSystemService(@NonNull String name) { + if (withholdWindowManager && Context.WINDOW_SERVICE.equals(name)) { + return null; + } + return super.getSystemService(name); + } + } + @Before public void setUp() { TestUtils.getCountlyStore().clear(); @@ -72,6 +99,27 @@ public void tearDown() { } overlay = null; } + overlay = null; + // Destroy every overlay this test created, not just the one the `overlay` field happens to hold: + // each one registered process-global orientation and activity-lifecycle callbacks in its + // constructor, and any that survives keeps receiving events for the rest of the instrumentation + // process, perturbing later classes that assert exact request-queue contents. destroy() is + // idempotent, so overlays a test already released are unaffected. + if (!createdOverlays.isEmpty()) { + final List stranded = new ArrayList<>(createdOverlays); + createdOverlays.clear(); + try { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (ContentOverlayView view : stranded) { + try { + view.destroy(); + } catch (Exception ignored) { + } + } + }); + } catch (Exception ignored) { + } + } if (scenario != null) { try { scenario.close(); @@ -105,8 +153,8 @@ private ContentOverlayView createOverlay(Activity activity, landscape.url = "about:blank"; landscape.useSafeArea = false; - return new ContentOverlayView( - activity, portrait, landscape, + ContentOverlayView created = new ContentOverlayView( + Countly.sharedInstance(), activity, portrait, landscape, activity.getResources().getConfiguration().orientation, callback, onClose != null ? onClose : () -> { @@ -114,6 +162,12 @@ private ContentOverlayView createOverlay(Activity activity, null, contentUrlHandler ); + //Every ContentOverlayView constructor registers process-global orientation and activity-lifecycle + //callbacks, so one that is never destroyed keeps receiving events for the rest of the instrumentation + //process and perturbs later test classes that assert exact request-queue contents. Most tests here + //keep their overlay in a local, which tearDown can not see, so track them all centrally. + createdOverlays.add(created); + return created; } private Object getField(String fieldName) throws Exception { @@ -955,7 +1009,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 +1221,116 @@ 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)); + + // the constructor registers process-global orientation and lifecycle callbacks, so an + // overlay that is never attached still has to be destroyed or it keeps receiving events + // for the rest of the test run + other.destroy(); + }); + } + + /** + * The guard is claimed before the window attach, so an attach that then FAILS must hand it back. + * Otherwise nothing ever releases it - the overlay was never shown, so no close()/destroy() follows - + * and every later content and feedback overlay in the process is silently blocked for good. + */ + @Test + public void presentationGuard_isReleasedWhenTheWindowAttachFails() { + ActivityScenario brokenScenario = ActivityScenario.launch(NoWindowManagerActivity.class); + try { + brokenScenario.onActivity(activity -> { + overlay = createOverlay(activity); + ContentOverlayView other = createOverlay(activity); // created but never attached + + // Without a WindowManager the attach cannot complete: measuring the window throws, and + // even if it did not, addToWindow would find no WindowManager. Either way the guard has + // already been claimed by then. attachToActivity rethrows so the caller's error handling + // is unchanged - what must NOT survive is a stranded guard. + activity.withholdWindowManager = true; + try { + overlay.attachToActivity(activity); + } catch (RuntimeException expected) { + // the attach failed loudly; that is the case under test + } + + Assert.assertFalse("a failed attach must not leave the process-global guard claimed", + ContentOverlayView.isOverlayPresented()); + Assert.assertFalse("an overlay that never attached must not block the next one", + ContentOverlayView.isOtherOverlayPresented(other)); + + // Both overlays registered process-global callbacks in their constructors. tearDown cannot + // reach either one - it destroys `overlay` only through the `scenario` field, and this test + // runs in a locally scoped scenario - so release both here, while the host activity is + // still alive and destroy() can remove a window on the main thread. + other.destroy(); + overlay.destroy(); + overlay = null; + }); + } finally { + brokenScenario.close(); + } + } + + /** + * When the presentation guard blocks a feedback widget, the developer's callback must still be + * resolved. Every other early exit in the present path reports back, so a caller that awaits + * onFinished/onClosed before continuing would otherwise wait forever. + */ + @Test + public void feedbackWidgetBlockedByPresentationGuard_reportsToTheDeveloperCallback() { + withActivity(activity -> { + // a foreign overlay holds the process-global guard + overlay = createOverlay(activity); + overlay.attachToActivity(activity); + Assert.assertTrue(ContentOverlayView.isOverlayPresented()); + + ModuleFeedback.CountlyFeedbackWidget widget = new ModuleFeedback.CountlyFeedbackWidget(); + widget.widgetId = "widgetBlockedByGuard"; + widget.type = ModuleFeedback.FeedbackWidgetType.survey; + //a non-empty version takes the overlay path directly, with no preflight network call + widget.widgetVersion = "1"; + + final AtomicReference reportedError = new AtomicReference<>(null); + final AtomicBoolean finishedCalled = new AtomicBoolean(false); + final AtomicBoolean closedCalled = new AtomicBoolean(false); + + Countly.sharedInstance().feedback().presentFeedbackWidget(widget, activity, null, + new ModuleFeedback.FeedbackCallback() { + @Override public void onClosed() { + closedCalled.set(true); + } + + @Override public void onFinished(String error) { + finishedCalled.set(true); + reportedError.set(error); + } + }); + + Assert.assertTrue("the blocked widget must report back to the developer callback", finishedCalled.get()); + Assert.assertNotNull("the callback must be told why the widget was not shown", reportedError.get()); + Assert.assertFalse("the widget was never shown, so it must not report as closed", closedCalled.get()); + }); + } + /** * Calling attachToActivity with the same activity twice is safe (idempotent). */ diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyInstanceLeakCleanup.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyInstanceLeakCleanup.java index 822dcfb3a..6e21932be 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyInstanceLeakCleanup.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyInstanceLeakCleanup.java @@ -26,11 +26,11 @@ * last Activity; it is cleared so a prior test's Activity does not seed a later init. * * - *

The reset runs on the main thread: {@code halt()} clears each instance's module list, and the - * SDK's Activity lifecycle callbacks ({@code onStartInternal}/{@code onStopInternal}) iterate that - * same list on the main thread. Doing the reset off-thread races an in-flight Activity teardown and - * throws {@link java.util.ConcurrentModificationException}, crashing the whole instrumentation run; - * {@code runOnMainSync} serializes the reset with those callbacks. + *

The reset runs on the main thread. {@code halt()} nulls each instance's module fields, and + * {@code CountlyLifecycleDispatcher} delivers Activity lifecycle callbacks - which read those fields and + * cross-reference each other through the Countly instance - on the main thread. {@code runOnMainSync} + * serializes the reset against an in-flight callback; doing it off-thread races one and can NPE out of + * {@code Activity.onStop}, which crashes the whole instrumentation run. * *

Wired in by {@code InstrumentationTestRunner}, so no per-test-class change is required. */ diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreTests.java index fd9ba21b5..28f19f922 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreTests.java @@ -257,7 +257,7 @@ public void getEventList_badJSONStored() { event2.timestamp = UtilsTime.getCurrentInstant().timestampMs - 60_000; //insert bad entry - final String joinedEventsWithBadJSON = event1.toJSON().toString() + ":::blah:::" + event2.toJSON().toString(); + final String joinedEventsWithBadJSON = event1.toJSON(new ModuleLog()).toString() + ":::blah:::" + event2.toJSON(new ModuleLog()).toString(); final SharedPreferences prefs = TestUtils.getContext().getSharedPreferences(countlyStoreName, Context.MODE_PRIVATE); prefs.edit().putString("EVENTS", joinedEventsWithBadJSON).commit(); @@ -281,7 +281,7 @@ public void getEventList_nullEntryStored() { event2.timestamp = UtilsTime.getCurrentInstant().timestampMs - 60_000; //insert null entry - final String joinedEventsWithBadJSON = event1.toJSON().toString() + ":::{\"key\":null}:::" + event2.toJSON().toString(); + final String joinedEventsWithBadJSON = event1.toJSON(new ModuleLog()).toString() + ":::{\"key\":null}:::" + event2.toJSON(new ModuleLog()).toString(); final SharedPreferences prefs = TestUtils.getContext().getSharedPreferences(countlyStoreName, Context.MODE_PRIVATE); prefs.edit().putString("EVENTS", joinedEventsWithBadJSON).commit(); @@ -777,7 +777,7 @@ public void getEventsForRequestAndEmptyEventQueueWithSimpleEvents() throws Unsup final Event event2 = CreateEvent(eKeys[1]); store.addEvent(event2); - final String jsonToEncode = "[" + event1.toJSON().toString() + "," + event2.toJSON().toString() + "]"; + final String jsonToEncode = "[" + event1.toJSON(new ModuleLog()).toString() + "," + event2.toJSON(new ModuleLog()).toString() + "]"; final String expected = URLEncoder.encode(jsonToEncode, "UTF-8"); assertEquals(expected, sp.getEventsForRequestAndEmptyEventQueue()); assertEquals(0, sp.getEventQueueSize()); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTimerTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTimerTests.java index 1bb96422f..8b7d82f69 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTimerTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTimerTests.java @@ -1,7 +1,9 @@ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -28,6 +30,43 @@ public void tearDown() { Assert.assertNull(countlyTimer.timerService); } + /** + * stopTimer used to shutdown() and then awaitTermination(1s), shutdownNow(), awaitTermination(1s) - up to + * two seconds of blocking on the caller. The callers are the main thread: a module's halt() during a + * teardown, and startTimer() itself when the server changes a timer's interval (that arrives on + * ImmediateRequestMaker's onPostExecute). In Countly's own stopTimer the wait was worse than slow: it ran + * inside the synchronized tearDown() while onTimer() is synchronized on the same instance, so it waited + * for a tick that could not start until tearDown() returned - and entering a synchronized block is not + * interruptible, so shutdownNow() could not break it either. It burned the full two seconds and logged + * "Global timer must be locked". A running tick must therefore never be waited on. + */ + @Test + public void stopTimerDoesNotBlockOnARunningTick() throws Exception { + final CountDownLatch tickStarted = new CountDownLatch(1); + final CountDownLatch releaseTick = new CountDownLatch(1); + + CountlyTimer.TIMER_DELAY_MS = 50; + countlyTimer.startTimer(1, () -> { + tickStarted.countDown(); + try { + //a deliberately slow tick: this is what stopTimer must not wait for + releaseTick.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + //an interrupt is fine, it just means the executor was forced down + } + }, mockLog); + + Assert.assertTrue("the tick should have started", tickStarted.await(3, TimeUnit.SECONDS)); + + long startedAt = System.currentTimeMillis(); + countlyTimer.stopTimer(mockLog); + long elapsed = System.currentTimeMillis() - startedAt; + + releaseTick.countDown(); + Assert.assertTrue("stopTimer blocked for " + elapsed + "ms while a tick was running; it must not wait" + + " for one, because its callers are on the main thread", elapsed < 1000); + } + @Test public void validateInitialValues() { Assert.assertNull(countlyTimer.timerService); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/EventTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/EventTests.java index 2d0114ade..1985e0f24 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/EventTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/EventTests.java @@ -176,7 +176,7 @@ public void equalsAndHashCodeValidation() { */ void fromJSON_CompareExpectedToParsed(@NonNull JSONObject jsonObj, @Nullable final Event expectedEvent) throws JSONException { //validate events as they are parsed - final Event parsedEvent = Event.fromJSON(jsonObj); + final Event parsedEvent = Event.fromJSON(jsonObj, new ModuleLog()); if (!jsonObj.isNull(Event.KEY_KEY)) { assertEquals(expectedEvent.key, parsedEvent.key); @@ -267,7 +267,7 @@ JSONObject CreateEventJsonObj(@NonNull String key, @Nullable Object segmentation @Test public void fromJSON_nullJSONObj() { try { - Event.fromJSON(null); + Event.fromJSON(null, new ModuleLog()); fail("Expected NPE when calling Event.fromJSON with null"); } catch (NullPointerException ignored) { // success @@ -282,7 +282,7 @@ public void fromJSON_nullJSONObj() { @Test public void fromJSON_noKeyCausesJSONException() { final JSONObject jsonObj = new JSONObject(); - assertNull(Event.fromJSON(jsonObj)); + assertNull(Event.fromJSON(jsonObj, new ModuleLog())); } /** @@ -294,7 +294,7 @@ public void fromJSON_noKeyCausesJSONException() { public void fromJSON_KeyNull() throws JSONException { final JSONObject jsonObj = new JSONObject(); jsonObj.put(Event.KEY_KEY, JSONObject.NULL); - assertNull(Event.fromJSON(jsonObj)); + assertNull(Event.fromJSON(jsonObj, new ModuleLog())); } /** @@ -305,7 +305,7 @@ public void fromJSON_KeyNull() throws JSONException { @Test public void fromJSON_KeyEmpty() throws JSONException { final JSONObject jsonObj = CreateEventJsonObj("", null); - assertNull(Event.fromJSON(jsonObj)); + assertNull(Event.fromJSON(jsonObj, new ModuleLog())); } /** @@ -408,7 +408,7 @@ public void fromJSON_segmentationNotADictionary() throws JSONException { final Event expected = new Event(); expected.key = "eventKey"; final JSONObject jsonObj = CreateEventJsonObj(expected.key, 1234); - assertNull(Event.fromJSON(jsonObj)); + assertNull(Event.fromJSON(jsonObj, new ModuleLog())); } /** @@ -465,7 +465,7 @@ public void fromJSON_withSegmentation_nonStringValues() throws JSONException { public void toJSON_nullSegmentation() throws JSONException { final Event event = new Event(); event.key = "eventKey"; - final JSONObject jsonObj = event.toJSON(); + final JSONObject jsonObj = event.toJSON(new ModuleLog()); assertNull(event.segmentation); @@ -492,7 +492,7 @@ public void toJSON_emptySegmentation() throws JSONException { final Event event = new Event(); event.key = "eventKey"; event.segmentation = new HashMap<>(); - final JSONObject jsonObj = event.toJSON(); + final JSONObject jsonObj = event.toJSON(new ModuleLog()); assertEquals(6, jsonObj.length()); assertEquals(event.key, jsonObj.getString(Event.KEY_KEY)); @@ -528,7 +528,7 @@ public void toJSON_FullWithSegmentation() throws JSONException { event.segmentation.put("segkey1", 123); event.segmentation.put("segkey2", 544.43d); event.segmentation.put("segkey3", true); - final JSONObject jsonObj = event.toJSON(); + final JSONObject jsonObj = event.toJSON(new ModuleLog()); assertEquals(11, jsonObj.length()); assertEquals(event.key, jsonObj.getString(Event.KEY_KEY)); assertEquals(event.timestamp, jsonObj.getInt(Event.TIMESTAMP_KEY)); @@ -556,7 +556,7 @@ public void toJSON_sumNaNCausesJSONException() throws JSONException { final Event event = new Event(); event.key = "eventKey"; event.sum = Double.NaN; - final JSONObject jsonObj = event.toJSON(); + final JSONObject jsonObj = event.toJSON(new ModuleLog()); assertEquals(5, jsonObj.length()); assertEquals(event.key, jsonObj.getString(Event.KEY_KEY)); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java index 4da8e01b4..99e3da2f4 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java @@ -57,7 +57,7 @@ private void assertBlocked(WebResourceResponse response) { /** Dangerous local/script sub-resource schemes are blocked; https/http load (default denylist). */ @Test public void shouldInterceptRequest_defaultDenylist() { - ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null); + ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null, new ModuleLog()); Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png"))); Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("http://example.com/a.js"))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///data/data/ly.count.android.sdk/shared_prefs/secret.xml"))); @@ -76,7 +76,7 @@ public void shouldInterceptRequest_defaultDenylist() { @Test public void shouldInterceptRequest_allowlistThreaded() { ModuleRatings.FeedbackDialogWebViewClient client = - new ModuleRatings.FeedbackDialogWebViewClient(new HashSet<>(Arrays.asList("myapp"))); + new ModuleRatings.FeedbackDialogWebViewClient(new HashSet<>(Arrays.asList("myapp")), new ModuleLog()); // https always loads (serves the widget itself) Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png"))); // a listed non-web scheme loads @@ -91,7 +91,7 @@ public void shouldInterceptRequest_allowlistThreaded() { /** The deprecated String overload must not NPE on a null url (a null scheme is blocked, fail-secure). */ @Test public void shouldInterceptRequest_stringOverload_nullSafe() { - ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null); + ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null, new ModuleLog()); assertBlocked(client.shouldInterceptRequest(null, (String) null)); // null url -> null scheme -> blocked, no NPE assertBlocked(client.shouldInterceptRequest(null, "file:///etc/hosts")); Assert.assertNull(client.shouldInterceptRequest(null, "https://example.com/a.png")); 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..ed9ae1ef6 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/MigrationHelperTests.java @@ -49,6 +49,72 @@ public Map GetMigrationParams_0_1(boolean customIdProvided) { return migrationParams; } + /** + * Legacy store with a queued request and NOTHING stored about the device ID. 0->1 must persist the + * developer-supplied ID and not just its type: 3->4 aborts on a null ID while the schema still + * advances, which would leave the queued legacy request without a device_id forever. + */ + @Test + public void performMigration0to1_bothNull_storesSuppliedIdSoQueuedRequestsGetDeviceId() { + cs.clear(); + cs.addRequest("fff", false); + Assert.assertNull(cs.getDeviceID()); + Assert.assertNull(cs.getDeviceIDType()); + + Map params = GetMigrationParams_0_1(true); + params.put(MigrationHelper.key_from_0_to_1_custom_id_value, "user-42"); + + MigrationHelper mh = new MigrationHelper(cs, mockLog, getApplicationContext()); + Assert.assertEquals(0, mh.getCurrentSchemaVersion()); + mh.doWork(params); + + Assert.assertEquals(latestSchemaVersion, mh.getCurrentSchemaVersion()); + Assert.assertEquals("user-42", cs.getDeviceID()); + Assert.assertEquals(DeviceIdType.DEVELOPER_SUPPLIED.toString(), cs.getDeviceIDType()); + + //3->4 had a real ID to work with, so the queued legacy request carries it + String[] requests = cs.getRequests(); + Assert.assertEquals(1, requests.length); + Assert.assertTrue("queued legacy request must carry the device id after migration, was:[" + requests[0] + "]", + requests[0].contains("device_id=user-42")); + } + + /** + * Same starting state, but temporary ID mode - which is signalled by a config flag, not by + * config.deviceID. Writing OPEN_UDID plus a generated UUID here would make DeviceId adopt that pair + * and ignore the temporary ID, so temporary mode would never be entered. + */ + @Test + public void performMigration0to1_bothNull_preservesTemporaryIdMode() { + cs.clear(); + cs.addRequest("fff", false); + + Map params = GetMigrationParams_0_1(false); + params.put(MigrationHelper.key_from_0_to_1_temp_id_enabled, true); + + MigrationHelper mh = new MigrationHelper(cs, mockLog, getApplicationContext()); + mh.doWork(params); + + Assert.assertEquals(DeviceId.temporaryCountlyDeviceId, cs.getDeviceID()); + Assert.assertEquals(DeviceIdType.TEMPORARY_ID.toString(), cs.getDeviceIDType()); + } + + /** + * Without a supplied ID or temporary mode there is nothing to preserve, so the legacy behaviour of + * generating an OPEN_UDID stands. + */ + @Test + public void performMigration0to1_bothNull_stillGeneratesOpenUdidWhenNothingToPreserve() { + cs.clear(); + cs.addRequest("fff", false); + + MigrationHelper mh = new MigrationHelper(cs, mockLog, getApplicationContext()); + mh.doWork(GetMigrationParams_0_1(false)); + + Assert.assertEquals(DeviceIdType.OPEN_UDID.toString(), cs.getDeviceIDType()); + validateGeneratedUUID(cs.getDeviceID()); + } + void validateGeneratedUUID(String deviceId) { assertNotNull(deviceId); Assert.assertTrue(deviceId.length() > 10); @@ -435,7 +501,7 @@ public void performMigration1To2_1() throws JSONException { cs.setRemoteConfigValues("{" + rcEntryLegacy("a", 123) + "," + rcEntryLegacy("b", "fg") + "," + rcEntryLegacy("c", jsonArray) + "," + rcEntryLegacy("d", jsonObject) + "}"); mh.performMigration1To2(new HashMap<>()); - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false, new ModuleLog()); Assert.assertEquals(4, rcvs.values.length()); @@ -462,7 +528,7 @@ public void performMigration1To2_2() { cs.setRemoteConfigValues(""); mh.performMigration1To2(new HashMap<>()); - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false, new ModuleLog()); Assert.assertEquals(0, rcvs.values.length()); } @@ -476,7 +542,7 @@ public void performMigration1To2_3() { cs.setRemoteConfigValues(null); mh.performMigration1To2(new HashMap<>()); - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false, new ModuleLog()); Assert.assertEquals(0, rcvs.values.length()); } @@ -490,7 +556,7 @@ public void performMigration1To2_4() { cs.setRemoteConfigValues("dsfsdf"); mh.performMigration1To2(new HashMap<>()); - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(cs.getRemoteConfigValues(), false, new ModuleLog()); Assert.assertEquals(0, rcvs.values.length()); } @@ -507,6 +573,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/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 7ab4084fd..f2bdb0f0d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -959,8 +959,9 @@ public void scenario_consentRequiredDisabled() throws JSONException { .defaults(); Countly.sharedInstance().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); - // Verify initial state - Assert.assertFalse(Countly.sharedInstance().config_.shouldRequireConsent); + // Verify initial state. The consent requirement is asserted on the instance, not on the config: the + // SDK resolves it per instance and no longer writes it back onto the developer's CountlyConfig. + Assert.assertFalse(Countly.sharedInstance().moduleConsent.requiresConsent); serverConfigBuilder.validateAgainst(Countly.sharedInstance()); // use a feature that is not affected directly from the server configuration @@ -971,7 +972,10 @@ public void scenario_consentRequiredDisabled() throws JSONException { serverConfigBuilder.consentRequired(true); Countly.sharedInstance().sdkIsInitialised = false; Countly.sharedInstance().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); - Assert.assertTrue(Countly.sharedInstance().config_.shouldRequireConsent); + Assert.assertTrue("the server's consent requirement must take effect on the instance", + Countly.sharedInstance().moduleConsent.requiresConsent); + Assert.assertFalse("...without being written back onto the developer's config, which a second instance may also hold", + Countly.sharedInstance().config_.shouldRequireConsent); serverConfigBuilder.validateAgainst(Countly.sharedInstance()); Assert.assertEquals(3, TestUtils.getCurrentRQ().length); // first attribution request, empty consent, empty location @@ -1015,9 +1019,13 @@ public void eventQueueSize() throws JSONException { Assert.assertEquals(1, TestUtils.getCurrentRQ().length); Assert.assertEquals(1, TestUtils.getCountlyStore().getEventQueueSize()); - validateEventInRQ("test_event", TestUtils.map(), 0, 1, 0, 3); - validateEventInRQ("test_event_1", TestUtils.map(), 0, 1, 1, 3); - validateEventInRQ("test_event_2", TestUtils.map(), 0, 1, 2, 3); + // Looked up by key, not by position: which slot an event lands in inside a batch depends on when the + // event queue was flushed relative to the session and timer machinery, and this test is about the + // batch SIZE the server configuration asked for, not about ordering. Asserting the index made this + // test fail intermittently on this branch AND on staging (measured 5/12 and 2/4 full runs). + validateEventInRQByKey("test_event", TestUtils.map(), 0, 1, 3); + validateEventInRQByKey("test_event_1", TestUtils.map(), 0, 1, 3); + validateEventInRQByKey("test_event_2", TestUtils.map(), 0, 1, 3); } /** @@ -1283,10 +1291,36 @@ private void feedbackFlow_allFeatures() { Countly.sharedInstance().feedback().reportFeedbackWidgetManually(widget, null, null); } + /** + * Server behaviour settings must be able to RAISE a developer-set internal limit, not just lower it. + * Precedence is SERVER > STORED > PROVIDED > DEVELOPER, so the server value wins whichever direction it + * moves - it is not an AND-gate or a min() of the two. Every other limits assertion in this suite happens + * to move the value DOWN from the SDK default, so this direction was untested. + */ + @Test + public void internalLimits_serverCanRaiseTheDeveloperLimit() throws JSONException { + countlyStore.setServerConfig(new ServerConfigBuilder().defaults().keyLengthLimit(500).build()); + + CountlyConfig config = TestUtils.createBaseConfig().setLoggingEnabled(false); + config.sdkInternalLimits.setMaxKeyLength(10); + + Countly countly = new Countly().init(config); + + Assert.assertEquals("the server must be able to raise a developer limit; this is not an AND-gate", + Integer.valueOf(500), countly.sdkInternalLimits_.maxKeyLength); + Assert.assertEquals("the developer's config must not be rewritten by the resolution", + Integer.valueOf(10), config.sdkInternalLimits.maxKeyLength); + } + private static void validateEventInRQ(String eventName, Map segmentation, int idx, int rqCount, int eventIdx, int eventCount) throws JSONException { ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, eventName, segmentation, 1, 0.0, 0.0, "_CLY_", "_CLY_", "_CLY_", "_CLY_", idx, rqCount, eventIdx, eventCount); } + /** Order-independent variant: asserts the event is in the request's batch, whatever slot it occupies. */ + private static void validateEventInRQByKey(String eventName, Map segmentation, int idx, int rqCount, int eventCount) throws JSONException { + ModuleEventsTests.validateEventInRQByKey(TestUtils.commonDeviceId, eventName, segmentation, 1, 0.0, 0.0, "_CLY_", "_CLY_", "_CLY_", "_CLY_", idx, rqCount, eventCount); + } + private void base_allFeatures(Consumer consumer, int hc, int fc, int rc, int cc, int scc) throws JSONException, InterruptedException { ServerConfigBuilder sc = new ServerConfigBuilder(); consumer.accept(sc); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java index fb4c3f80a..4161d3b59 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -79,7 +79,10 @@ private void setIsCurrentlyInContentZone(ModuleContent module, boolean value) th private Activity getCurrentActivity(ModuleContent module) throws Exception { java.lang.reflect.Field field = ModuleContent.class.getDeclaredField("currentActivity"); field.setAccessible(true); - return (Activity) field.get(module); + //the module holds the activity weakly (so an instance without lifecycle callbacks cannot pin + //a destroyed activity); unwrap to keep these identity assertions meaningful + java.lang.ref.WeakReference ref = (java.lang.ref.WeakReference) field.get(module); + return ref != null ? (Activity) ref.get() : null; } // ======== previewContent public API tests ======== 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..ba57f5b62 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java @@ -38,6 +38,7 @@ public class ModuleEventsTests { @Before public void setUp() { + TestUtils.alignToSecondBoundary(); final CountlyStore countlyStore = new CountlyStore(TestUtils.getContext(), mock(ModuleLog.class)); countlyStore.clear(); @@ -196,15 +197,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 +231,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 +246,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 +276,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 +298,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 +312,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); @@ -998,6 +999,60 @@ protected static void validateEventInRQ(String eventName, Map ex validateEventInRQ(TestUtils.commonDeviceId, eventName, expectedSegmentation, count, sum, duration, "_CLY_", "_CLY_", "_CLY_", "_CLY_", idx, rqCount, 0, 1); } + /** + * Validates an event in a request by LOOKING IT UP BY KEY instead of by position in the events array. + *

+ * Prefer this over the positional overload whenever a test only cares that an event was batched into a + * request, which is almost always. The position of an event inside a batch depends on when the event + * queue happened to be flushed relative to the session/timer machinery, so asserting an index makes a + * test fail intermittently for a reason that has nothing to do with what it is testing. When the event is + * genuinely absent this dumps the whole events array, so the failure says what the request actually + * carried rather than just naming the key that did not match. + */ + protected static void validateEventInRQByKey(String deviceId, String eventName, Map expectedSegmentation, int count, Double sum, Double duration, String id, String pvid, String cvid, String peid, int idx, int rqCount, int eventCount) + throws JSONException { + Map[] RQ = TestUtils.getCurrentRQ(); + if (rqCount > -1) { + Assert.assertEquals(rqCount, RQ.length); + } + TestUtils.validateRequiredParams(RQ[idx], deviceId); + if (!RQ[idx].containsKey("events")) { + Assert.fail("Not an event request idx:[" + idx + "], request:[" + RQ[idx] + "]"); + } + JSONArray events = new JSONArray(RQ[idx].get("events")); + Assert.assertEquals("event count in request idx:[" + idx + "], events:[" + events + "]", eventCount, events.length()); + + JSONObject match = null; + for (int a = 0; a < events.length(); a++) { + if (eventName.equals(events.getJSONObject(a).optString("key"))) { + match = events.getJSONObject(a); + break; + } + } + Assert.assertNotNull("event [" + eventName + "] is not in the request. The request carried:[" + events + "]", match); + validateEvent(match, eventName, expectedSegmentation, count, sum, duration, id, pvid, cvid, peid); + } + + /** + * The 'dur' value of the single event in request [idx]. + *

+ * For tests whose expected duration comes from a {@code Thread.sleep}: the SDK reports view durations in + * WHOLE SECONDS, so a 1000 ms sleep plus any scheduling delay measures 1 or 2 depending on which side of a + * second boundary the clock lands on. Asserting an exact value makes such a test fail on a loaded machine + * for a reason unrelated to what it covers, so read the measured duration, bound-check it, and pass it into + * the normal validation - every other assertion stays exact. + */ + protected static double readSingleEventDuration(int idx) throws JSONException { + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertTrue("no request at index [" + idx + "], the queue holds [" + RQ.length + "]", idx < RQ.length); + JSONArray events = new JSONArray(RQ[idx].get("events")); + return events.getJSONObject(0).optDouble("dur", 0.0d); + } + + protected static void validateEventInRQByKey(String eventName, Map expectedSegmentation, int count, double sum, double duration, int idx, int rqCount, int eventCount) throws JSONException { + validateEventInRQByKey(TestUtils.commonDeviceId, eventName, expectedSegmentation, count, sum, duration, "_CLY_", "_CLY_", "_CLY_", "_CLY_", idx, rqCount, eventCount); + } + protected static void validateEventInRQ(String deviceId, String eventName, Map expectedSegmentation, int count, Double sum, Double duration, String id, String pvid, String cvid, String peid, int idx, int rqCount, int eventIdx, int eventCount) throws JSONException { Map[] RQ = TestUtils.getCurrentRQ(); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java index dcba891cf..3ebe6f76b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java @@ -44,7 +44,7 @@ public void tearDown() { @Test public void parseFeedbackList_null() throws JSONException { - List ret = ModuleFeedback.parseFeedbackList(null); + List ret = ModuleFeedback.parseFeedbackList(null, new ModuleLog()); Assert.assertNotNull(ret); Assert.assertEquals(0, ret.size()); } @@ -56,7 +56,7 @@ public void parseFeedbackList_oneGoodWithGarbage() throws JSONException { JSONObject jObj = new JSONObject(requestJson); - List ret = ModuleFeedback.parseFeedbackList(jObj); + List ret = ModuleFeedback.parseFeedbackList(jObj, new ModuleLog()); Assert.assertNotNull(ret); Assert.assertEquals(1, ret.size()); ValidateReturnedFeedbackWidget(ModuleFeedback.FeedbackWidgetType.nps, "fsdfsdf", "5f97284635935cc338e78200", new String[] { "/" }, ret.get(0)); @@ -69,7 +69,7 @@ public void parseFeedbackList() throws JSONException { JSONObject jObj = new JSONObject(requestJson); - List ret = ModuleFeedback.parseFeedbackList(jObj); + List ret = ModuleFeedback.parseFeedbackList(jObj, new ModuleLog()); Assert.assertNotNull(ret); Assert.assertEquals(4, ret.size()); @@ -100,7 +100,7 @@ public void parseFaultyFeedbackList() throws JSONException { JSONObject jObj = new JSONObject(requestJson); - List ret = ModuleFeedback.parseFeedbackList(jObj); + List ret = ModuleFeedback.parseFeedbackList(jObj, new ModuleLog()); Assert.assertNotNull(ret); Assert.assertEquals(6, ret.size()); @@ -699,7 +699,10 @@ private void fillFeedbackWidgetSegmentationParams(Map segmentati private Activity getCurrentActivity(ModuleFeedback module) throws Exception { java.lang.reflect.Field field = ModuleFeedback.class.getDeclaredField("currentActivity"); field.setAccessible(true); - return (Activity) field.get(module); + //the module holds the activity weakly (so an instance without lifecycle callbacks cannot pin + //a destroyed activity); unwrap to keep these identity assertions meaningful + java.lang.ref.WeakReference ref = (java.lang.ref.WeakReference) field.get(module); + return ref != null ? (Activity) ref.get() : null; } // ======== Activity reference / leak prevention tests (issue #556) ======== diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLifecycleDispatchTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLifecycleDispatchTests.java new file mode 100644 index 000000000..0127710fd --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLifecycleDispatchTests.java @@ -0,0 +1,198 @@ +package ly.count.android.sdk; + +import android.app.Activity; +import android.content.res.Configuration; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +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; + +/** + * Makes the "wire a new override into the dispatcher" rule enforceable instead of aspirational. + *

+ * Lifecycle hooks used to be delivered by iterating Countly's mutable {@code modules} list. Android + * delivers them on the main thread while a teardown on another thread clears that list and nulls the + * module fields, which killed a CI run with {@code ModuleViews.resetFirstView()} thrown out of + * {@code Activity.onStop}. Dispatch therefore calls a fixed set of modules directly. That trades the race + * for two silent failure modes this test closes: + *

    + *
  1. A module overrides a hook and is never added to the call site, so the hook does nothing.
  2. + *
  3. The call site's order drifts from the order init adds modules to {@code modules}, silently + * reordering side effects between modules that used to run in list order.
  4. + *
+ * The expected order is derived from the live {@code modules} list of a real initialised instance, so it + * tracks init order automatically rather than restating it. + */ +@RunWith(AndroidJUnit4.class) +public class ModuleLifecycleDispatchTests { + + // What Countly's dispatch path calls, hook by hook, IN CALL ORDER. Keep in sync with the call sites. + private static final String[] WIRED_ON_ACTIVITY_STARTED = { "ModuleViews", "ModuleAPM", "ModuleFeedback", "ModuleContent" }; + private static final String[] WIRED_ON_ACTIVITY_STOPPED = { "ModuleViews", "ModuleFeedback", "ModuleContent", "ModuleHealthCheck" }; + private static final String[] WIRED_ON_ACTIVITY_DESTROYED = { "ModuleFeedback", "ModuleContent" }; + private static final String[] WIRED_ON_CONFIGURATION_CHANGED = { "ModuleViews" }; + private static final String[] WIRED_CALLBACK_ON_RESUMED = { "ModuleRatings", "ModuleAPM" }; + private static final String[] WIRED_CALLBACK_ON_STOPPED = { "ModuleAPM" }; + + private Countly countly; + + @Before public void setUp() { + TestUtils.getCountlyStore().clear(); + countly = new Countly().init(TestUtils.createBaseConfig()); + } + + @After public void cleanUp() { + TestUtils.getCountlyStore().clear(); + } + + /** The order init adds modules to {@code modules}, filtered to the ones overriding this hook. */ + private List expectedOrderFor(String methodName, Class... paramTypes) { + List expected = new ArrayList<>(); + for (ModuleBase module : countly.modules) { + if (overridesHook(module.getClass(), methodName, paramTypes)) { + expected.add(module.getClass().getSimpleName()); + } + } + return expected; + } + + /** + * True when this module declares the hook itself, which for a direct ModuleBase subclass is exactly what + * "overrides it" means. Scanning getDeclaredMethods rather than calling getDeclaredMethod keeps the + * not-overridden case a plain false instead of an exception that has to be caught and ignored. + */ + private static boolean overridesHook(Class moduleClass, String methodName, Class[] paramTypes) { + for (Method declared : moduleClass.getDeclaredMethods()) { + if (declared.getName().equals(methodName) && Arrays.equals(declared.getParameterTypes(), paramTypes)) { + return true; + } + } + return false; + } + + private void assertWiring(String hook, String[] wired, String methodName, Class... paramTypes) { + List expected = expectedOrderFor(methodName, paramTypes); + List actual = Arrays.asList(wired); + Assert.assertEquals("The dispatcher's calls for " + hook + " must be exactly the modules that override" + + " it, in the order init adds them to `modules`. A module missing here is a hook that silently" + + " does nothing; a module listed that no longer overrides it is dead code; a different order" + + " silently reorders side effects. Fix the call site (see the note on ModuleBase#" + hook + ")." + + "\n expected (live modules list): " + expected + + "\n wired at the call site: " + actual, expected, actual); + } + + @Test + public void everyLifecycleOverrideIsWiredIntoTheDispatcherInModuleListOrder() { + assertWiring("onActivityStarted", WIRED_ON_ACTIVITY_STARTED, "onActivityStarted", Activity.class, int.class); + assertWiring("onActivityStopped", WIRED_ON_ACTIVITY_STOPPED, "onActivityStopped", int.class); + assertWiring("onActivityDestroyed", WIRED_ON_ACTIVITY_DESTROYED, "onActivityDestroyed", Activity.class); + assertWiring("onConfigurationChanged", WIRED_ON_CONFIGURATION_CHANGED, "onConfigurationChanged", Configuration.class); + assertWiring("callbackOnActivityResumed", WIRED_CALLBACK_ON_RESUMED, "callbackOnActivityResumed", Activity.class); + assertWiring("callbackOnActivityStopped", WIRED_CALLBACK_ON_STOPPED, "callbackOnActivityStopped", Activity.class); + } + + /** + * getCurrentRQ sizes its array to the whole queue and leaves a null hole for every request the filter + * rejected, so matches have to be counted rather than read off .length. + */ + private static int countRequestsWithKey(Countly countly, String key) { + int found = 0; + for (Map request : TestUtils.getCurrentRQ(key, TestUtils.getCountlyStore(countly))) { + if (request != null && request.containsKey(key)) { + found++; + } + } + return found; + } + + /** + * The CI crash, reproduced deterministically. The run died at test 108 of 1023 with + * {@code ModuleViews.resetFirstView() on a null object reference} thrown out of {@code Activity.onStop}: + * the main thread was inside {@code ModuleSessions.endSessionInternal} when a teardown on another thread + * nulled {@code moduleViews}. Nulling the field by hand here is exactly that interleaving, without + * needing to win a race. Modules must snapshot siblings reached through {@code _cly} and bail, so this + * has to complete silently. + */ + @Test + public void aLifecycleStopWithANulledSiblingModuleDoesNotThrow() { + Countly.lifecycleStateOverrideForTests = true; + Countly foreground = Countly.instance("dispatchNulledSibling"); + foreground.init(TestUtils.createBaseConfig("deviceNulledSibling")); + + // Take this instance out of the process-wide dispatcher before touching anything. Other test classes + // start and stop real activities, and an ambient event reaching this instance begins and ends extra + // sessions - which made this test pass alone and fail inside the suite on an absolute count. + CountlyLifecycleDispatcher.getInstance().removeInstanceAndQuiesce(foreground, new ModuleLog()); + + // Initialising in the foreground already began a session and opened one activity, so onStopInternal + // below takes the count to 0 - which is the only path that reaches endSessionInternal. Calling + // onStartInternal first would make it 2 -> 1 and skip the branch entirely. + Assert.assertTrue("a session must be running for this to exercise endSessionInternal", + foreground.moduleSessions.sessionIsRunning()); + int endSessionsBefore = countRequestsWithKey(foreground, "end_session"); + + // the interleaving: teardown has nulled the module fields while this dispatch is in flight + foreground.moduleViews = null; + + // Called directly rather than through halt(), on purpose: teardown wraps its flush in a try/catch, + // which would swallow the very NPE this test exists to catch. + foreground.onStopInternal(); + + // A missing sibling must skip only its own step. resetFirstView is what needs moduleViews, so it is + // the only thing lost here - end_session still has to go out, because a session that begins and never + // ends is a worse outcome than one that ends without its trailing bookkeeping. + Assert.assertEquals("end_session must still be sent when only the views module is gone", + endSessionsBefore + 1, countRequestsWithKey(foreground, "end_session")); + + // the event path reads _cly.moduleViews for the view names, so it needs the same treatment + foreground.moduleEvents.recordEventInternal("someKey", null, 1, 0.0d, 0.0d, null, null); + + Countly.lifecycleStateOverrideForTests = false; + Countly.removeInstance("dispatchNulledSibling"); + } + + /** + * The point of the class: ONE registration against the Application feeds EVERY live instance, and a + * torn-down instance drops out of it. Before the dispatcher each instance registered its own callbacks, + * so N instances meant N registrations, and a halted instance kept receiving events until the + * unregistration at the end of teardown - after its modules had already been nulled. + */ + @Test + public void oneRegistrationFansOutToEveryInstanceAndStopsAtTeardown() { + CountlyLifecycleDispatcher dispatcher = CountlyLifecycleDispatcher.getInstance(); + Assert.assertTrue("init must have registered the dispatcher", dispatcher.isRegistered()); + + Countly second = Countly.instance("dispatchFanOut"); + second.init(TestUtils.createBaseConfig("deviceFanOut")); + Assert.assertFalse("no start seen yet by the default instance", countly.hasBeenCalledOnStart()); + Assert.assertFalse("no start seen yet by the named instance", second.hasBeenCalledOnStart()); + + Activity activity = org.mockito.Mockito.mock(Activity.class); + dispatcher.onActivityStarted(activity); + + // hasBeenCalledOnStart is set at the end of onStartInternal, so it proves the event reached each + // instance and ran the whole method - not just that the dispatcher held a reference + Assert.assertTrue("the default instance must have seen onActivityStarted", countly.hasBeenCalledOnStart()); + Assert.assertTrue("the named instance must have seen the same event from the one registration", second.hasBeenCalledOnStart()); + + second.halt(); + Assert.assertFalse("a halted instance must not still be initialised", second.isInitialized()); + + // The events below used to crash the process: they reached an instance whose module fields were + // already null, and the resulting NPE escaped Activity.onStop. They must now be silent no-ops. + dispatcher.onActivityStarted(activity); + dispatcher.onActivityResumed(activity); + dispatcher.onActivityStopped(activity); + dispatcher.onActivityDestroyed(activity); + dispatcher.onConfigurationChanged(TestUtils.getContext().getResources().getConfiguration()); + + Countly.removeInstance("dispatchFanOut"); + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java index 97fe9fc7c..38cda9021 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java @@ -128,7 +128,7 @@ public void loadRatingPreferencesBadJson() { StorageProvider cs = mCountly.connectionQueue_.getStorageProvider(); cs.setStarRatingPreferences("./{}23[]d"); Assert.assertEquals("./{}23[]d", cs.getStarRatingPreferences()); - ModuleRatings.StarRatingPreferences srp = ModuleRatings.loadStarRatingPreferences(cs); + ModuleRatings.StarRatingPreferences srp = ModuleRatings.loadStarRatingPreferences(cs, new ModuleLog()); Assert.assertEquals("", srp.appVersion); Assert.assertEquals(5, srp.sessionLimit); @@ -282,7 +282,7 @@ private Map prepareRatingSegmentation(String rating, String widg // // ModuleRatings mr = new ModuleRatings(mCountly, config); // - // ModuleRatings.StarRatingPreferences srp = ModuleRatings.loadStarRatingPreferences(sp); + // ModuleRatings.StarRatingPreferences srp = ModuleRatings.loadStarRatingPreferences(sp, new ModuleLog()); // // Assert.assertTrue(mr.getIfStarRatingShouldBeShownAutomatically()); // Assert.assertTrue(srp.automaticRatingShouldBeShown); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRemoteConfigTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRemoteConfigTests.java index 3fc61cbec..22ec10a98 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRemoteConfigTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRemoteConfigTests.java @@ -43,7 +43,7 @@ public void valuesClearedOnConsentRemoval() { //set RC String[] rcArr = { rcEStr("a", 123), rcEStr("b", "fg") }; - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); Assert.assertEquals(123, countly.remoteConfig().getValue("a").value); Assert.assertEquals("fg", countly.remoteConfig().getValue("b").value); @@ -166,7 +166,7 @@ public void rcValueCaching() { Assert.assertEquals(0, countly.remoteConfig().getValues().size()); String[] rcArr = new String[] { rcEStr("a", 123), rcEStr("b", "fg") }; - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); Assert.assertEquals(2, countly.remoteConfig().getValues().size()); assertCValueCachedState(countly.remoteConfig().getValues(), false); @@ -194,7 +194,7 @@ public void rcValueCaching() { } //entering temp ID mode should trigger caching. Lack of consent should leave no impact on this - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); countly.deviceId().enableTemporaryIdMode(); for (int b = 0; b < 2; b++) { @@ -219,7 +219,7 @@ public void rcValueCaching() { public void validateValuePersistence() { //set RC String[] rcArr = new String[] { rcEStr("a", 123), rcEStr("b", "fg") }; - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); CountlyConfig config = new CountlyConfig(TestUtils.getContext(), "appkey", "http://test.count.ly").setDeviceId("1234").setLoggingEnabled(true).enableCrashReporting(); config.enableRemoteConfigValueCaching(); @@ -244,7 +244,7 @@ public void validateClear() { //set RC String[] rcArr = new String[] { rcEStr("a", 123), rcEStr("b", "fg") }; - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); Assert.assertEquals(123, countly.remoteConfig().getValue("a").value); Assert.assertEquals("fg", countly.remoteConfig().getValue("b").value); @@ -253,7 +253,7 @@ public void validateClear() { Assert.assertEquals(0, countly.remoteConfig().getValues().size()); - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); Assert.assertEquals(123, countly.remoteConfig().getValue("a").value); Assert.assertEquals("fg", countly.remoteConfig().getValue("b").value); @@ -357,7 +357,7 @@ public void validateGetters() throws JSONException { JSONArray jArrI = new JSONArray("[3,\"44\",5.1,7.7]"); JSONObject jObjI = new JSONObject("{\"q\":6,\"w\":\"op\"}"); String[] rcArr = new String[] { rcEStr("a", 123, false), rcEStr("b", "fg"), rcEStr("c", 222222222222L, false), rcEStr("d", 1.5d), rcEStr("e", jArrI, false), rcEStr("f", jObjI) }; - countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false).dataToString()); + countlyStore.setRemoteConfigValues(RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()).dataToString()); Assert.assertEquals(123, countly.remoteConfig().getValue("a").value); Assert.assertEquals(123, countly.remoteConfig().getValueAndEnroll("a").value); @@ -509,9 +509,9 @@ public void validateMergeReceivedResponse() throws Exception { Countly countly = new Countly(); countly.init(cc); - RemoteConfigValueStore rcvs1 = RemoteConfigValueStore.dataFromString("{\"a\": 123,\"b\": \"fg\"}", false); - RemoteConfigValueStore rcvs2 = RemoteConfigValueStore.dataFromString("{\"b\": 33.44,\"c\": \"ww\"}", false); - RemoteConfigValueStore rcvs3 = RemoteConfigValueStore.dataFromString("{\"t\": {},\"87\": \"yy\"}", false); + RemoteConfigValueStore rcvs1 = RemoteConfigValueStore.dataFromString("{\"a\": 123,\"b\": \"fg\"}", false, new ModuleLog()); + RemoteConfigValueStore rcvs2 = RemoteConfigValueStore.dataFromString("{\"b\": 33.44,\"c\": \"ww\"}", false, new ModuleLog()); + RemoteConfigValueStore rcvs3 = RemoteConfigValueStore.dataFromString("{\"t\": {},\"87\": \"yy\"}", false, new ModuleLog()); //check initial state Map vals = countly.remoteConfig().getAllValues(); @@ -519,7 +519,7 @@ public void validateMergeReceivedResponse() throws Exception { Assert.assertEquals(0, vals.size()); //add first values without clearing - countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(false, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs1.values)); + countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(false, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs1.values, new ModuleLog())); vals = countly.remoteConfig().getAllValues(); Assert.assertEquals(2, vals.size()); @@ -527,7 +527,7 @@ public void validateMergeReceivedResponse() throws Exception { Assert.assertEquals("fg", vals.get("b")); //add second pair of values without clearing - countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(false, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs2.values)); + countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(false, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs2.values, new ModuleLog())); vals = countly.remoteConfig().getAllValues(); Assert.assertEquals(3, vals.size()); @@ -536,7 +536,7 @@ public void validateMergeReceivedResponse() throws Exception { Assert.assertEquals("ww", vals.get("c")); //add third pair with full clear - countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(true, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs3.values)); + countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(true, RemoteConfigHelper.DownloadedValuesIntoMap(rcvs3.values, new ModuleLog())); vals = countly.remoteConfig().getAllValues(); Assert.assertEquals(2, vals.size()); @@ -559,9 +559,9 @@ public void concurrentMergeAndReads() throws Exception { }; Countly countly = new Countly().init(cc); - RemoteConfigValueStore rcvsA = RemoteConfigValueStore.dataFromString("{\"k1\":123,\"k2\":\"v2\"}", false); - RemoteConfigValueStore rcvsB = RemoteConfigValueStore.dataFromString("{\"k2\":777,\"k3\":{}}", false); - RemoteConfigValueStore rcvsC = RemoteConfigValueStore.dataFromString("{\"k4\":true,\"k5\":55.5}", false); + RemoteConfigValueStore rcvsA = RemoteConfigValueStore.dataFromString("{\"k1\":123,\"k2\":\"v2\"}", false, new ModuleLog()); + RemoteConfigValueStore rcvsB = RemoteConfigValueStore.dataFromString("{\"k2\":777,\"k3\":{}}", false, new ModuleLog()); + RemoteConfigValueStore rcvsC = RemoteConfigValueStore.dataFromString("{\"k4\":true,\"k5\":55.5}", false, new ModuleLog()); RemoteConfigValueStore[] arr = new RemoteConfigValueStore[] { rcvsA, rcvsB, rcvsC }; @@ -581,7 +581,7 @@ public void concurrentMergeAndReads() throws Exception { for (int j = 0; j < MERGE_OPS; j++) { RemoteConfigValueStore pick = arr[(idx + j) % arr.length]; boolean clear = (j % 10) == 0; // occasionally force a clear path - countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(clear, RemoteConfigHelper.DownloadedValuesIntoMap(pick.values)); + countly.moduleRemoteConfig.mergeCheckResponseIntoCurrentValues(clear, RemoteConfigHelper.DownloadedValuesIntoMap(pick.values, new ModuleLog())); } } catch (Exception ex) { failure[0] = ex; diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleSessionsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleSessionsTests.java index 30574b6b8..0038571b3 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleSessionsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleSessionsTests.java @@ -14,6 +14,7 @@ public class ModuleSessionsTests { @Before public void setUp() { TestUtils.getCountlyStore().clear(); + TestUtils.alignToSecondBoundary(); } @After @@ -234,6 +235,7 @@ protected static Map validateSessionUpdateRequest(int idx, Integ TestUtils.validateRequiredParams(TestUtils.getCurrentRQ()[idx], deviceId); if (duration != null) { + //exact - see TestUtils#alignToSecondBoundary Assert.assertEquals(duration.toString(), request.get("session_duration")); } 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/ModuleViewsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java index e35faea45..f555fd645 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java @@ -47,6 +47,7 @@ public class ModuleViewsTests { @Before public void setUp() { + TestUtils.alignToSecondBoundary(); countlyStore = new CountlyStore(TestUtils.getContext(), mock(ModuleLog.class)); countlyStore.clear(); idx = 0;//reset the index for the view ID generator @@ -2168,7 +2169,22 @@ static void validateView(String viewName, Double viewDuration, int idx, int size viewSegmentation.put("cly_pvn", pvn); } - ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, ModuleViews.VIEW_EVENT_KEY, viewSegmentation, 1, 0.0, viewDuration, id, pvid, "_CLY_", "_CLY_", idx, size, 0, 1); + //The SDK reports view durations in WHOLE SECONDS (currentTimestampSeconds() - viewStartTimeSeconds), + //and the tests that expect a non-zero duration produce it with Thread.sleep. A 1000 ms sleep plus any + //scheduling delay therefore measures 1 or 2 depending on which side of a second boundary the clock + //lands on, which is why CI failed with expected:<1.0> but was:<2.0> on a test that is not about + //timing at all. So for a non-zero expected duration, require AT LEAST that many seconds (and no more + //than two extra), then assert the rest of the event exactly with the value actually measured. + //A zero expected duration stays exact: a view-start event must report no duration. + Double durationToAssert = viewDuration; + if (viewDuration != null && viewDuration > 0.0) { + double measured = ModuleEventsTests.readSingleEventDuration(idx); + Assert.assertTrue("view [" + viewName + "] duration: expected at least [" + viewDuration + "]s, measured [" + measured + "]s", + measured >= viewDuration && measured <= viewDuration + 2.0); + durationToAssert = measured; + } + + ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, ModuleViews.VIEW_EVENT_KEY, viewSegmentation, 1, 0.0, durationToAssert, id, pvid, "_CLY_", "_CLY_", idx, size, 0, 1); } //todo extract orientation tests } 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..b28681a09 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/MultiInstanceTests.java @@ -0,0 +1,1296 @@ +/* +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.app.Activity; +import android.app.Application; +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import ly.count.android.sdk.messaging.ModulePush; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.mockito.Mockito.mock; + +/** + * 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", "instCfgA", "instCfgB", "instPushBcast" }; + + 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(""); + clearNativeDumps(); + // the dispatcher is process-wide: drop instances a test (or another suite) left behind and + // zero the simulated activity count so lifecycle simulations start from a known state + CountlyLifecycleDispatcher.getInstance().resetForTests(); + } + + // The single process-wide folder sdk-native hands to breakpad. It has no instance concept, so only + // the default instance may consume it - these helpers let the tests prove that. + private static File nativeDumpFolder() { + return new File(TestUtils.getContext().getCacheDir().getAbsolutePath() + File.separator + "Countly" + File.separator + "CrashDumps"); + } + + private static File writeFakeNativeDump(String name) throws IOException { + File folder = nativeDumpFolder(); + folder.mkdirs(); + File dump = new File(folder, name); + //Files.write rather than a FileOutputStream: it closes the handle itself, so an assertion failing + //mid-test cannot leak one + Files.write(dump.toPath(), new byte[] { 1, 2, 3, 4 }); + return dump; + } + + private static void clearNativeDumps() { + File[] files = nativeDumpFolder().listFiles(); + if (files != null) { + for (File file : files) { + file.delete(); + } + } + } + + 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(); + } + + //Deliberately returns null, not an empty map: callers assert notNull to mean "this request was sent", + //and an empty map would make every one of those assertions pass whether the request existed or not. + @SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull") + 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)); + + 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)); + + // The mechanism under test: a fresh named store must judge its own freshness by its own file, so + // it starts at the latest schema and runs no migration at all. Asserting the schema (not only the + // resulting device ID) is what separates the gated path from the un-gated one - the legacy path + // reaches the same device ID through MigrationHelper, so the ID alone proves nothing. + Assert.assertEquals("a fresh named store must not be treated as a legacy install", + MigrationHelper.DATA_SCHEMA_VERSIONS, TestUtils.getCountlyStore(named).getDataSchemaVersion()); + + // and the supplied device ID is kept rather than replaced by a generated OPEN_UDID + Assert.assertEquals(DEVICE_B, TestUtils.getCountlyStore(named).getDeviceID()); + Assert.assertEquals("DEVELOPER_SUPPLIED", TestUtils.getCountlyStore(named).getDeviceIDType()); + + // the default instance owns the push file, so its own freshness check still consults it + Assert.assertTrue(store("").anythingSetInStorage()); + } + + /** + * Push ownership has two halves: the store write, and the process-global {@code CONSENT_BROADCAST} + * that CountlyPush reacts to by registering the DEFAULT instance's token. A named instance must + * suppress the broadcast as well, otherwise granting push consent on it would drive the default + * instance's push registration. + */ + @Test + public void namedInstance_doesNotFireTheProcessGlobalPushConsentBroadcast() { + //Its OWN instance name. Sharing a name with another test made this flaky: the registry keeps an + //instance across tests, so if the other user of that name ran first this instance already had push + //consent, giveConsent below became a no-op, doPushConsentSpecialAction never ran and the log line + //this test looks for was never produced. JUnit does not guarantee method order, hence intermittent. + Countly named = Countly.instance("instPushBcast"); + named.init(baseConfig(APP_KEY_B, DEVICE_B).setRequiresConsent(true)); + + Assert.assertFalse("precondition: push consent must start ungranted, otherwise giveConsent below is a no-op and this test proves nothing", + named.consent().getConsent(Countly.CountlyFeatureNames.push)); + + //Copy-on-write, not ArrayList: this instance is initialised, so its logger is called from the SDK's + //background threads (network, timer) while the assertions below read the list. With a plain + //ArrayList this threw ConcurrentModificationException out of AbstractCollection.toString while + //building the "Log was:" message - the read side has to be snapshot-based. + final List namedLog = new CopyOnWriteArrayList<>(); + named.L.SetListener((logMessage, logLevel) -> namedLog.add(logMessage)); + + named.consent().giveConsent(new String[] { Countly.CountlyFeatureNames.push }); + + //stop capturing before asserting, so nothing further arrives while the list is being read + named.L.SetListener(null); + + boolean broadcastSuppressed = false; + for (String message : namedLog) { + if (message.contains("named instance does not own push, skipping the process-global consent broadcast")) { + broadcastSuppressed = true; + break; + } + } + Assert.assertTrue("push consent must actually have been granted, otherwise the broadcast path was never reached", + named.consent().getConsent(Countly.CountlyFeatureNames.push)); + Assert.assertTrue("a named instance must suppress the process-global push consent broadcast. Log was:" + namedLog, broadcastSuppressed); + + // and it must not have written the owner's push consent either + Assert.assertFalse("a named instance must not grant push consent in the shared push file", + store("").getConsentPush()); + } + + /** + * The half of {@code ModuleCrash.halt()} that cannot unlink: once the host app (or another instance) + * installs a handler on top, ours is stuck in the middle of the process-global chain. Halting must + * then leave the foreign handler alone and simply stop recording, rather than restoring over it. + */ + @Test + public void haltingInstance_keepsAForeignCrashHandlerAndStopsRecording() { + Thread.UncaughtExceptionHandler originalDefault = Thread.getDefaultUncaughtExceptionHandler(); + try { + Countly named = Countly.instance("instCfgB"); + CountlyConfig config = baseConfig(APP_KEY_B, DEVICE_B); + config.crashes.enableCrashReporting(); + named.init(config); + + ModuleCrash moduleCrash = named.moduleCrash; + Assert.assertTrue("the instance must have installed its crash handler", moduleCrash.unhandledCrashHandlerInstalled); + Thread.UncaughtExceptionHandler countlyHandler = Thread.getDefaultUncaughtExceptionHandler(); + + // the host app installs its own handler on top of ours + Thread.UncaughtExceptionHandler foreign = (thread, throwable) -> countlyHandler.uncaughtException(thread, throwable); + Thread.setDefaultUncaughtExceptionHandler(foreign); + + named.halt(); + + // there is no way to remove a link from the middle of the chain, so the app's handler stays + Assert.assertSame("halt must not restore over a handler the app installed later", + foreign, Thread.getDefaultUncaughtExceptionHandler()); + // and ours is neutralised rather than left recording into torn-down queues + Assert.assertFalse("a halted instance must no longer consider its crash handler installed", + moduleCrash.unhandledCrashHandlerInstalled); + } finally { + Thread.setDefaultUncaughtExceptionHandler(originalDefault); + } + } + + /** + * Storage isolation has two halves, and the dangerous half is the one that deletes. Every other + * isolation test here only checks what a store contains after recording; this one covers the read + * side, because a named instance's ConnectionProcessor removes requests from whatever store it was + * handed. Wired to the wrong store it would drain, and delete, the default integration's queue. + */ + @Test + public void namedInstanceConnectionProcessor_readsAndRemovesFromItsOwnStore() { + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + def.sessions().beginSession(); + + Countly named = Countly.instance("instB"); + named.init(baseConfig(APP_KEY_B, DEVICE_B)); + named.sessions().beginSession(); + + ConnectionProcessor namedProcessor = named.connectionQueue_.createConnectionProcessor(); + ConnectionProcessor defaultProcessor = def.connectionQueue_.createConnectionProcessor(); + + // each processor must be bound to its own instance's store, and to the server URL it was + // configured with rather than the other instance's + Assert.assertSame("a named instance's processor must read its own store", + named.countlyStore, namedProcessor.getCountlyStore()); + Assert.assertSame(def.countlyStore, defaultProcessor.getCountlyStore()); + Assert.assertNotSame(namedProcessor.getCountlyStore(), defaultProcessor.getCountlyStore()); + + // and the request each one would drain belongs to that instance only + String[] namedRequests = namedProcessor.getCountlyStore().getRequests(); + String[] defaultRequests = defaultProcessor.getCountlyStore().getRequests(); + Assert.assertEquals(1, namedRequests.length); + Assert.assertEquals(1, defaultRequests.length); + Assert.assertTrue("the named processor must see only its own app key's request", namedRequests[0].contains("app_key=" + APP_KEY_B)); + Assert.assertTrue(defaultRequests[0].contains("app_key=" + APP_KEY_A)); + + // removing through the named processor's store must not touch the default instance's queue + namedProcessor.getCountlyStore().removeRequest(namedRequests[0]); + Assert.assertEquals(0, TestUtils.getCurrentRQ(named).length); + Assert.assertEquals("draining a named instance must never delete the default instance's requests", + 1, TestUtils.getCurrentRQ(def).length); + } + + /** + * The namespace ends up in a SharedPreferences file name, and Android silently stops persisting once + * a file name passes the 255-byte filesystem limit. A long instance name must therefore be capped, + * while still producing distinct namespaces for distinct names. + */ + @Test + public void sanitizeNamespace_capsTheFileNameLength() { + StringBuilder longName = new StringBuilder(); + for (int i = 0; i < 400; i++) { + longName.append('x'); + } + + String sanitized = CountlyStore.sanitizeNamespace(longName.toString()); + String fileName = CountlyStore.namespacedName("COUNTLY_STORE", sanitized); + Assert.assertTrue("the derived file name must stay well inside the 255 byte limit, was " + fileName.length(), + fileName.length() < 200); + + // two long names sharing the truncated prefix must still get their own file + String other = CountlyStore.sanitizeNamespace(longName + "-other"); + Assert.assertNotEquals(sanitized, other); + } + + /** + * 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)); + 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)); + 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)); + named.sessions().beginSession(); + Assert.assertTrue(named.isInitialized()); + Assert.assertSame("registered before removal", named, Countly.getInstance("instRemove")); + Assert.assertTrue("listed before removal", Countly.listInstances().contains("instRemove")); + Assert.assertEquals(1, TestUtils.getCurrentRQ(named).length); + + 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 stopped as part of removal + Assert.assertFalse("removed instance must be stopped", named.isInitialized()); + + // ...but its recorded data survives. Deregistering an instance must not throw away requests it + // has not sent to the server yet. + CountlyStore removedStore = store(CountlyStore.sanitizeNamespace("instRemove")); + Map[] rqAfterRemoval = TestUtils.getCurrentRQ("", removedStore); + Assert.assertEquals("removeInstance must not discard unsent requests", 1, + countRequestsWithKey(rqAfterRemoval, "begin_session")); + // the session was open when the instance was removed, so it must have been closed on the way out - + // otherwise the server keeps an open session and a later init of this name opens a second one + Assert.assertEquals("removeInstance must end the session it leaves behind", 1, + countRequestsWithKey(rqAfterRemoval, "end_session")); + Assert.assertEquals("removeInstance must not discard the stored device id", + DEVICE_B, removedStore.getDeviceID()); + + // 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()); + + // and re-initialising that name resumes from the data that was left behind + recreated.init(baseConfig(APP_KEY_B, DEVICE_B)); + Assert.assertEquals("re-initialising a removed name must resume from its kept queue", + 1, countRequestsWithKey(TestUtils.getCurrentRQ(recreated), "begin_session")); + + // an explicit halt() is still the way to erase an instance's data + recreated.halt(); + Assert.assertEquals("halt() must still clear the instance's storage", + 0, TestUtils.getCurrentRQ("", store(CountlyStore.sanitizeNamespace("instRemove"))).length); + + // 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).setLoggingEnabled(true)); + + Countly quiet = Countly.instance("instLog"); + quiet.init(baseConfig(APP_KEY_B, DEVICE_B).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)); + + 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)); + 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 invariant the per-instance limits exist for: one instance's server behaviour settings must not + * retruncate another instance's data, even when both were built from the SAME CountlyConfig. + *

+ * Precedence is SERVER > STORED > PROVIDED > DEVELOPER, so the instance whose own store carries a limit + * must honour it, while the instance whose store carries nothing must keep the developer's value. Before + * the limits moved onto the instance, both read the config's nested limits object, so the first instance + * to resolve its settings silently changed truncation for the second. + */ + @Test + public void internalLimits_serverSettingsOfOneInstanceDoNotRetruncateAnother() throws JSONException { + //only instB's own namespaced store carries a server behaviour setting, and it lowers the key length + String storedSbs = new JSONObject() + .put("t", 1L) + .put("v", 1) + .put("c", new JSONObject().put("lkl", 5)) + .toString(); + store(CountlyStore.sanitizeNamespace("instB")).setServerConfig(storedSbs); + + CountlyConfig shared = baseConfig(APP_KEY_A, DEVICE_A); + shared.sdkInternalLimits.setMaxKeyLength(40); + + Countly withServerLimit = Countly.instance("instB"); + withServerLimit.init(shared); + + Countly withoutServerLimit = Countly.instance("instFresh"); + withoutServerLimit.init(shared); + + Assert.assertEquals("the instance whose stored settings lower the limit must use the server value", + Integer.valueOf(5), withServerLimit.sdkInternalLimits_.maxKeyLength); + Assert.assertEquals("the other instance must keep the developer's limit, not inherit the server's", + Integer.valueOf(40), withoutServerLimit.sdkInternalLimits_.maxKeyLength); + Assert.assertEquals("and the developer's own config must be left untouched", + Integer.valueOf(40), shared.sdkInternalLimits.maxKeyLength); + } + + /** + * The registry management API. instance(name) creates/accesses a handle but never initializes it + * (users init themselves); getInstance never creates; listInstances reports every registered instance + * including the default; 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)); + 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 every registered instance, the default included under its reserved name + List names = Countly.listInstances(); + Assert.assertTrue(names.contains("instCreateA")); + Assert.assertTrue(names.contains("instCreateB")); + Assert.assertTrue("the default instance is registered and must be listed too", 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) .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).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); + } + + private static int countRequestsWithKey(Map[] rq, String key) { + int count = 0; + for (Map request : rq) { + if (request != null && request.containsKey(key)) { + count++; + } + } + return count; + } + + /** + * Whether an instance recorded the given event, looking in both places it can be: its event queue, + * and - once the queue has been drained into a request, which init does immediately - the "events" + * parameter of one of its queued requests. The request-side match drops the "[CLY]" prefix so it + * holds whether or not the parameter value is URL-encoded. + */ + private static boolean recordedEvent(Countly countly, String eventKey) { + for (Event event : TestUtils.getCountlyStore(countly).getEventList()) { + if (eventKey.equals(event.key)) { + return true; + } + } + + String unencodedPartOfKey = eventKey.replace("[CLY]", ""); + for (Map request : TestUtils.getCurrentRQ(countly)) { + String events = request == null ? null : request.get("events"); + if (events != null && events.contains(unencodedPartOfKey)) { + return true; + } + } + return false; + } + + /** + * The same config-caching hazard within one instance: halt() throws away the instance's + * ConnectionQueue and builds a new one, so a reused config that still cached the old one as + * requestQueueProvider would leave the re-initialised instance writing through a torn-down queue. + * init() must rebuild whatever it derived itself, not just when the namespace changes. + */ + @Test + public void reusedConfigObject_isRebuiltWhenTheSameInstanceIsReinitialised() { + CountlyConfig shared = baseConfig(APP_KEY_B, DEVICE_B); + + Countly named = Countly.instance("instB"); + named.init(shared); + RequestQueueProvider firstQueue = shared.requestQueueProvider; + Assert.assertNotNull(firstQueue); + + named.halt(); + named.init(shared); + + Assert.assertNotSame("a re-initialised instance must not keep the ConnectionQueue halt() discarded", + firstQueue, shared.requestQueueProvider); + + // and it still works end to end, writing into its own namespaced store + named.sessions().beginSession(); + Map[] rq = TestUtils.getCurrentRQ(named); + Assert.assertNotNull("the re-initialised instance must record through its live queue", firstRequestWithKey(rq, "begin_session")); + assertAllRequestsCarryAppKey(rq, APP_KEY_B); + } + + /** + * One CountlyConfig may initialise several instances. What used to make that unsafe was the SDK writing + * its own resolved state back onto the config object, so this asserts the three things that had to be + * true before sharing could be allowed: the second instance really initialises, the two instances do not + * share the internal limits they read on every recorded event, and a value the developer changes between + * the two inits is honoured rather than reset to what the first init saw. + */ + @Test + public void sharedConfigObject_secondInstanceInitialisesWithIsolatedState() { + CountlyConfig shared = baseConfig(APP_KEY_A, DEVICE_A) + .setRequiresConsent(true) + .setConsentEnabled(new String[] { Countly.CountlyFeatureNames.sessions }); + shared.sdkInternalLimits.setMaxKeyLength(40); + + Countly def = Countly.sharedInstance(); + def.init(shared); + Assert.assertTrue("the first instance initialises normally", def.isInitialized()); + def.sessions().beginSession(); + + // the developer changes their mind about the device id before building the second instance + shared.setDeviceId(DEVICE_B); + + Countly named = Countly.instance("instCfgA"); + named.init(shared); + + Assert.assertTrue("a second instance must initialise from a shared config", named.isInitialized()); + Assert.assertEquals(CountlyStore.sanitizeNamespace("instCfgA"), named.storageNamespace_); + + // the change made between the two inits wins - it must not be reset to what the first init saw + Assert.assertEquals("the device id the developer set before the second init must be used", + DEVICE_B, store(CountlyStore.sanitizeNamespace("instCfgA")).getDeviceID()); + Assert.assertEquals("the first instance keeps its own device id", DEVICE_A, store("").getDeviceID()); + + // limits are per instance, so one instance's resolved limits can not retruncate the other's data + Assert.assertNotSame("instances must not share the limits object they read on every event", + def.sdkInternalLimits_, named.sdkInternalLimits_); + named.sdkInternalLimits_.setMaxKeyLength(7); + Assert.assertEquals("changing one instance's limits must not touch the other's", + Integer.valueOf(40), def.sdkInternalLimits_.maxKeyLength); + + // and the first instance is otherwise untouched by the second init + Assert.assertTrue(def.isInitialized()); + Assert.assertEquals(APP_KEY_A, def.moduleRequestQueue.baseInfoProvider.getAppKey()); + Map[] rqDefault = TestUtils.getCurrentRQ(def); + Assert.assertEquals(1, countRequestsWithKey(rqDefault, "begin_session")); + assertAllRequestsCarryAppKey(rqDefault, APP_KEY_A); + Assert.assertTrue("the first instance keeps the consent requirement it was configured with", + def.moduleConsent.requiresConsent); + Assert.assertTrue("the second instance inherits the consent requirement the config carries", + named.moduleConsent.requiresConsent); + } + + /** + * Regression: setConsentPush writes into the process-shared, primary-owned push preferences file. + * ModuleConsent calls it on every init (doPushConsentSpecialAction(true) when consent is not + * required), so before the ownership gate merely creating a named instance re-granted the primary + * instance's persisted push consent - the value getConsentPushNoInit reads before init. + */ + @Test + public void namedInstance_cannotOverwritePrimaryPushConsent() { + // a named-namespace store must not be able to write the shared push consent at all + store("").setConsentPush(true); + store(CountlyStore.sanitizeNamespace("instB")).setConsentPush(false); + Assert.assertTrue("a named instance's store must not overwrite the shared push consent", + store("").getConsentPush()); + + // nor may initialising a named instance flip it - the primary has revoked push consent here + store("").setConsentPush(false); + Countly named = Countly.instance("instB"); + named.init(baseConfig(APP_KEY_B, DEVICE_B)); + Assert.assertFalse("initialising a named instance must not re-grant the primary's push consent", + store("").getConsentPush()); + + // the primary instance still owns the flag and its own init stores the default consent + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + Assert.assertTrue("the default instance still owns and writes the shared push consent", + store("").getConsentPush()); + } + + /** + * Regression: the cached push click lives in the shared, primary-owned push file, and + * ModuleEvents.initFinished reads AND clears it on every instance. Whichever instance initialised + * first therefore recorded another instance's push action under its own app key, then deleted it. + */ + @Test + public void namedInstance_doesNotConsumePrimaryCachedPushClick() { + CountlyStore.cachePushData("msgIdA", "0", TestUtils.getContext()); + + // a named instance initialising first must neither record nor drain the primary's push click + Countly named = Countly.instance("instB"); + named.init(baseConfig(APP_KEY_B, DEVICE_B)); + + Assert.assertFalse("a named instance must not record the primary instance's push click", + recordedEvent(named, ModulePush.PUSH_EVENT_ACTION)); + + String[] stillCached = store("").getCachedPushData(); + Assert.assertEquals("the primary's cached push click must survive a named instance's init", "msgIdA", stillCached[0]); + Assert.assertEquals("0", stillCached[1]); + + // the owning (default) instance still consumes it, and clears it afterwards + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + + Assert.assertTrue("the default instance must record its own cached push click", + recordedEvent(def, ModulePush.PUSH_EVENT_ACTION)); + Assert.assertNull("the owner clears the cached push click once recorded", store("").getCachedPushData()[0]); + } + + /** + * Regression: with cached push data present, anythingSetInStorage() makes a brand-new DEFAULT store + * look like a legacy install, so performMigration0To1 ran against empty storage - and its both-null + * branch hardcoded OPEN_UDID, generating a UUID that permanently replaced the developer's device ID. + */ + @Test + public void defaultInstance_keepsSuppliedDeviceId_whenSharedPushDataMakesStoreLookLegacy() { + // The realistic trigger: CountlyPush.init() stores the messaging provider with no + // isInitialized() guard, so an app that inits push before Countly writes this on a fresh + // install. A push click cached pre-init does the same. + CountlyStore.storeMessagingProvider(1, TestUtils.getContext()); + CountlyStore.cachePushData("msgIdA", "0", TestUtils.getContext()); + + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + + Assert.assertEquals("a developer-supplied device ID must survive the legacy migration path", + DEVICE_A, TestUtils.getCountlyStore(def).getDeviceID()); + Assert.assertEquals("DEVELOPER_SUPPLIED", TestUtils.getCountlyStore(def).getDeviceIDType()); + + // and it is the ID that actually goes on the wire + def.sessions().beginSession(); + Map begin = firstRequestWithKey(TestUtils.getCurrentRQ(def), "begin_session"); + Assert.assertNotNull(begin); + Assert.assertEquals(DEVICE_A, begin.get("device_id")); + } + + /** + * Deliberate policy: without crash consent the dump is dropped rather than cached for a later run. + * Retaining it would need its own retention policy, and a minidump is raw process memory we do not + * want left on the device waiting for a consent that may never come. + */ + @Test + public void nativeCrashDump_isDroppedWhenThereIsNoCrashConsent() throws IOException { + File dump = writeFakeNativeDump("dump_no_consent"); + + Countly def = Countly.sharedInstance(); + //consent required but none granted -> the dump can not be reported + def.init(baseConfig(APP_KEY_A, DEVICE_A).setRequiresConsent(true)); + + Assert.assertNull("a dump must not be reported without crash consent", + firstRequestWithKey(TestUtils.getCurrentRQ(def), "crash")); + Assert.assertFalse("a dump that can not be reported must be removed, not cached", dump.exists()); + } + + /** + * Regression: sdk-native writes minidumps into one fixed process-wide directory, and + * checkForNativeCrashDumps ran on every instance, reading AND deleting them. Whichever instance + * initialised first uploaded every dump - raw process memory - under its own app key and server URL. + */ + @Test + public void namedInstance_doesNotConsumePrimaryNativeCrashDumps() throws IOException { + File dump = writeFakeNativeDump("dump_a"); + + Countly named = Countly.instance("instB"); + named.init(baseConfig(APP_KEY_B, DEVICE_B)); + + Assert.assertTrue("a named instance must not delete the process-wide native crash dump", dump.exists()); + Assert.assertNull("a named instance must not report the process-wide native crash dump", + firstRequestWithKey(TestUtils.getCurrentRQ(named), "crash")); + + // the owning (default) instance still consumes and reports it, under its own app key + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + + Map crashRequest = firstRequestWithKey(TestUtils.getCurrentRQ(def), "crash"); + Assert.assertNotNull("the default instance must still report the native crash dump", crashRequest); + Assert.assertEquals(APP_KEY_A, crashRequest.get("app_key")); + Assert.assertFalse("the owner consumes the dump once reported", dump.exists()); + } + + /** + * Regression: ModuleCrash.halt() was empty, so the uncaught-exception handler it installed stayed + * the process default forever, holding the whole instance graph. A halted or removed instance was + * therefore never collectable (contradicting what removeInstance documents) and kept recording + * crashes into its own torn-down queues. halt() must unlink it from the global handler chain. + */ + @Test + public void haltingInstance_restoresTheUncaughtExceptionHandler() { + Thread.UncaughtExceptionHandler original = Thread.getDefaultUncaughtExceptionHandler(); + try { + Countly named = Countly.instance("instB"); + CountlyConfig config = baseConfig(APP_KEY_B, DEVICE_B); + config.crashes.enableCrashReporting(); + named.init(config); + + Assert.assertNotSame("enabling crash reporting must install a handler", + original, Thread.getDefaultUncaughtExceptionHandler()); + + named.halt(); + + Assert.assertSame("halt must restore the handler it wrapped, so the instance stops being reachable from the process-global chain", + original, Thread.getDefaultUncaughtExceptionHandler()); + } finally { + //never leak a test handler into the rest of the suite + Thread.setDefaultUncaughtExceptionHandler(original); + } + } + + /** + * Regression: feedback widget parsing logged through Countly.sharedInstance().L. Because ModuleLog + * now carries a per-instance log listener, that delivered one instance's widget metadata (ids, + * types, tags) to the DEFAULT instance's listener. Diagnostics must follow the instance that owns + * the data. + */ + @Test + public void feedbackWidgetParsing_reportsToTheOwningInstancesLogger() throws JSONException { + final List namedLog = new CopyOnWriteArrayList<>(); + ModuleLog namedLogger = new ModuleLog(); + namedLogger.SetListener((logMessage, logLevel) -> namedLog.add(logMessage)); + + // A listener on the DEFAULT instance's logger. That instance is deliberately left uninitialised: + // an initialised one logs from background threads, which would make the assertion below racy. + final List defaultLog = new CopyOnWriteArrayList<>(); + Countly.sharedInstance().L.SetListener((logMessage, logLevel) -> defaultLog.add(logMessage)); + + // an entry with an empty widget id makes parseFeedbackList emit a diagnostic about it + JSONObject response = new JSONObject(); + response.put("result", new JSONArray().put(new JSONObject().put("_id", "").put("type", "nps"))); + ModuleFeedback.parseFeedbackList(response, namedLogger); + + boolean reachedOwnLogger = false; + for (String message : namedLog) { + if (message.contains("parseFeedbackList")) { + reachedOwnLogger = true; + break; + } + } + Assert.assertTrue("widget parsing diagnostics must reach the logger they were given", reachedOwnLogger); + + for (String message : defaultLog) { + Assert.assertFalse("another instance's widget parsing must never reach the default instance's log listener", + message.contains("parseFeedbackList")); + } + } + + /** + * Custom network headers were taken from the config by reference, and the runtime setter mutates + * that same map in place. Two instances configured from one map therefore shared it, so adding an + * Authorization header to the instance talking to server A also sent that credential to server B. + */ + //The HashMaps below are what a developer realistically passes in; making them concurrent would test + //something no caller does. They are local to one test thread and never shared. + @SuppressWarnings("PMD.UseConcurrentHashMap") + @Test + public void customNetworkRequestHeaders_areNotSharedBetweenInstances() { + Map developerMap = new HashMap<>(); + developerMap.put("X-Env", "prod"); + + Countly def = Countly.sharedInstance(); + def.init(baseConfig(APP_KEY_A, DEVICE_A).addCustomNetworkRequestHeaders(developerMap)); + + Countly named = Countly.instance("instCfgA"); + named.init(baseConfig(APP_KEY_B, DEVICE_B).addCustomNetworkRequestHeaders(developerMap)); + + // both start from the same configured headers + Assert.assertEquals("prod", def.requestHeaderCustomValues.get("X-Env")); + Assert.assertEquals("prod", named.requestHeaderCustomValues.get("X-Env")); + + Map credential = new HashMap<>(); + credential.put("Authorization", "Bearer tenant-a-token"); + def.requestQueue().addCustomNetworkRequestHeaders(credential); + + Assert.assertEquals("Bearer tenant-a-token", def.requestHeaderCustomValues.get("Authorization")); + Assert.assertFalse("one instance's credentials must never reach another instance's requests", + named.requestHeaderCustomValues.containsKey("Authorization")); + Assert.assertFalse("the SDK must not mutate the map the developer handed to the config", + developerMap.containsKey("Authorization")); + } + + /** + * onRegistrationId is driven by an OS push callback that can arrive before init or after halt. It + * used to dereference the config unconditionally, so those arrivals crashed the host app inside a + * push callback. It must log and ignore instead. + */ + @Test + public void onRegistrationId_beforeInitAndAfterHalt_isIgnoredInsteadOfCrashing() { + Countly def = Countly.sharedInstance(); + Assert.assertFalse(def.isInitialized()); + + // before any init there is no config and no queue, so this used to throw straight into the push + // callback that called it + def.onRegistrationId("token-before-init", Countly.CountlyMessagingProvider.FCM); + Assert.assertNull("a token arriving before init must be ignored, not accepted", def.lastRegistrationCallID); + Assert.assertEquals("an ignored token must not queue a request", 0, TestUtils.getCurrentRQ("", store("")).length); + + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + def.halt(); + Assert.assertFalse(def.isInitialized()); + + // after halt the config object lingers but the connection queue is gone + def.onRegistrationId("token-after-halt", Countly.CountlyMessagingProvider.FCM); + Assert.assertNull("a token arriving after halt must be ignored too", def.lastRegistrationCallID); + + // and an initialised instance still accepts the token. The request itself is queued only after a + // ten second delay, so assert on the accepted-call state rather than waiting for the queue. + def.init(baseConfig(APP_KEY_A, DEVICE_A)); + def.onRegistrationId("real-token", Countly.CountlyMessagingProvider.FCM); + Assert.assertEquals("an initialised instance must accept the push token", "real-token", def.lastRegistrationCallID); + } + + /** + * The uncaught-exception chain runs developer code (crash filters) on a thread that is already + * crashing. A filter that throws must not swallow the delegation: every handler below this + * instance - other Countly instances and ultimately Android's own KillApplicationHandler - still + * has to run, otherwise the crash is reported by nobody, no crash dialog shows, and the process + * is left alive with a dead thread. + */ + @Test + public void throwingCrashFilter_doesNotSwallowDownstreamHandlers() { + Thread.UncaughtExceptionHandler originalDefault = Thread.getDefaultUncaughtExceptionHandler(); + try { + final boolean[] previousHandlerRan = { false }; + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> previousHandlerRan[0] = true); + + Countly named = Countly.instance("instCfgB"); + CountlyConfig config = baseConfig(APP_KEY_B, DEVICE_B); + config.crashes.enableCrashReporting(); + config.crashes.setGlobalCrashFilterCallback(crash -> { + throw new IllegalStateException("filter deliberately failing"); + }); + named.init(config); + + Thread.UncaughtExceptionHandler chainHead = Thread.getDefaultUncaughtExceptionHandler(); + Assert.assertTrue("the instance must have installed its crash handler", named.moduleCrash.unhandledCrashHandlerInstalled); + + // simulate the runtime dispatching an uncaught exception into the chain + chainHead.uncaughtException(Thread.currentThread(), new RuntimeException("boom")); + + Assert.assertTrue("a throwing crash filter must not stop delegation to the previous handler", previousHandlerRan[0]); + } finally { + Thread.setDefaultUncaughtExceptionHandler(originalDefault); + } + } + + /** + * halt() promises "destroys all stored data ... the next session starts as a new user". The + * generated device id used to survive the wipe in the separate OpenUDID cache file, so a + * halted-and-reinitialised instance silently came back as the same user. The wipe must cover the + * OpenUDID cache too. + */ + @Test + public void halt_clearsGeneratedOpenUdid_nextInitIsANewUser() { + // no device id supplied -> the generated (OpenUDID) path is exercised + Countly def = Countly.sharedInstance(); + def.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL).setLoggingEnabled(true)); + String firstId = TestUtils.getCountlyStore(def).getDeviceID(); + Assert.assertNotNull(firstId); + Assert.assertEquals("the generated id and the OpenUDID cache must agree", firstId, openUdid("")); + + def.halt(); + Assert.assertNull("halt must wipe the OpenUDID cache file too", openUdid("")); + + def.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL).setLoggingEnabled(true)); + String secondId = TestUtils.getCountlyStore(def).getDeviceID(); + Assert.assertNotNull(secondId); + Assert.assertNotEquals("after halt the next init must start as a new user, not resurrect the old id", firstId, secondId); + } + + /** + * A halted instance must stop receiving process lifecycle events entirely: teardown removes it + * from the process-wide CountlyLifecycleDispatcher before nulling anything, and the tearingDown + * gate drops an event already in flight. Before the dispatcher, this was the CME/NPE window + * between the main thread's dispatch and a background teardown. + */ + @Test + public void haltedInstance_stopsReceivingActivityLifecycle() { + Application app = (Application) TestUtils.getContext().getApplicationContext(); + Countly instance = Countly.instance("instCfgA"); + // no manual session control: lifecycle events must drive the session automatically + instance.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_A, TestUtils.commonURL) + .setDeviceId(DEVICE_A) + .setLoggingEnabled(true) + .setApplication(app)); + + String ns = CountlyStore.sanitizeNamespace("instCfgA"); + Activity activity = mock(Activity.class); + + // simulate the OS starting an activity: the registered instance auto-begins a session + CountlyLifecycleDispatcher.getInstance().onActivityStarted(activity); + Assert.assertNotNull("a registered instance must receive lifecycle events and begin a session", + firstRequestWithKey(TestUtils.getCurrentRQ("", store(ns)), "begin_session")); + + instance.halt(); // clears storage and deregisters from the dispatcher + + CountlyLifecycleDispatcher.getInstance().onActivityStarted(activity); + CountlyLifecycleDispatcher.getInstance().onActivityStopped(activity); + Assert.assertEquals("a halted instance must not receive lifecycle events any more", + 0, store(ns).getRequests().length); + } + + /** + * The exact-count contract the foreground seed relies on: registered by CountlyInitProvider before + * any activity can start, the dispatcher counts starts and stops exactly, never underflows, and + * reports exactness so a late (provider-stripped) registration falls back to the heuristic. + */ + @Test + public void lifecycleDispatcher_exactCountContract() { + CountlyLifecycleDispatcher dispatcher = CountlyLifecycleDispatcher.getInstance(); + Assert.assertTrue("registered by the provider before any activity, the count must be exact", + dispatcher.hasExactActivityCount()); + Assert.assertEquals(0, dispatcher.getStartedActivityCount()); + + Activity activity = mock(Activity.class); + dispatcher.onActivityStarted(activity); + dispatcher.onActivityStarted(activity); + Assert.assertEquals(2, dispatcher.getStartedActivityCount()); + dispatcher.onActivityStopped(activity); + Assert.assertEquals(1, dispatcher.getStartedActivityCount()); + dispatcher.onActivityStopped(activity); + dispatcher.onActivityStopped(activity); // an extra stop must not underflow the count + Assert.assertEquals(0, dispatcher.getStartedActivityCount()); + } + + /** + * Foreground-at-init comes from the dispatcher's exact started-activity count (registered by + * CountlyInitProvider before any activity can start), not from ProcessLifecycleOwner's debounced + * state: an instance initialised while an activity is started seeds its activity counter exactly + * and auto-begins its session immediately - deterministically, with no ~700ms debounce window. + */ + @Test + public void initWhileActivityStarted_seedsExactForegroundAndBeginsSession() { + // the test runner pins the foreground override to "background" for isolation; this test is + // specifically about the exact-count path, which the override outranks - clear it (the runner + // restores it after the test) + Countly.lifecycleStateOverrideForTests = null; + + CountlyLifecycleDispatcher.getInstance().onActivityStarted(mock(Activity.class)); // the app is now "in the foreground" + + Application app = (Application) TestUtils.getContext().getApplicationContext(); + Countly instance = Countly.instance("instCfgB"); + instance.init(new CountlyConfig(TestUtils.getContext(), APP_KEY_B, TestUtils.commonURL) + .setDeviceId(DEVICE_B) + .setLoggingEnabled(true) + .setApplication(app)); + + String ns = CountlyStore.sanitizeNamespace("instCfgB"); + Assert.assertNotNull("an init while an activity is started must seed foreground from the exact count and begin a session", + firstRequestWithKey(TestUtils.getCurrentRQ("", store(ns)), "begin_session")); + } + + /** + * Two CountlyStore objects can be live over one namespace: removeInstance() keeps the data and + * documents the name as immediately reusable, while the removed instance's ConnectionProcessor + * may still be draining the kept queue on its non-awaited executor. The queue mutation is a + * read-modify-write of one joined string, synchronized per store OBJECT, so without a shared + * file-level monitor two stores lose each other's updates. Hammer one file from two stores on two + * threads and require that not a single request is lost or duplicated. + */ + @Test + public void concurrentStoresOverOneNamespace_loseNoRequests() throws InterruptedException { + final String ns = CountlyStore.sanitizeNamespace("instB"); + final CountlyStore first = store(ns); + final CountlyStore second = store(ns); + final int perWriter = 40; + + Thread writerA = new Thread(() -> { + for (int i = 0; i < perWriter; i++) { + first.addRequest("a_" + i, false); + } + }); + Thread writerB = new Thread(() -> { + for (int i = 0; i < perWriter; i++) { + second.addRequest("b_" + i, false); + } + }); + writerA.start(); + writerB.start(); + writerA.join(); + writerB.join(); + + List finalQueue = new ArrayList<>(Arrays.asList(store(ns).getRequests())); + for (int i = 0; i < perWriter; i++) { + Assert.assertTrue("request a_" + i + " was lost by a concurrent writer", finalQueue.contains("a_" + i)); + Assert.assertTrue("request b_" + i + " was lost by a concurrent writer", finalQueue.contains("b_" + i)); + } + Assert.assertEquals("no request may be duplicated either", perWriter * 2, finalQueue.size()); + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigValueStoreTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigValueStoreTests.java index 83b7302aa..095608a27 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigValueStoreTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/RemoteConfigValueStoreTests.java @@ -31,13 +31,13 @@ public void setUp() { */ @Test public void rcvsSerializeDeserialize() throws JSONException { - RemoteConfigValueStore remoteConfigValueStore = RemoteConfigValueStore.dataFromString(null, false); + RemoteConfigValueStore remoteConfigValueStore = RemoteConfigValueStore.dataFromString(null, false, new ModuleLog()); remoteConfigValueStore.values.put("fd", 12); remoteConfigValueStore.values.put("2fd", 142); remoteConfigValueStore.values.put("f3d", 123); - RemoteConfigValueStore.dataFromString(remoteConfigValueStore.dataToString(), false); + RemoteConfigValueStore.dataFromString(remoteConfigValueStore.dataToString(), false, new ModuleLog()); } /** @@ -45,12 +45,12 @@ public void rcvsSerializeDeserialize() throws JSONException { */ @Test public void rcvsDataFromStringNullEmpty() { - RemoteConfigValueStore rcvs1 = RemoteConfigValueStore.dataFromString(null, false); + RemoteConfigValueStore rcvs1 = RemoteConfigValueStore.dataFromString(null, false, new ModuleLog()); Assert.assertNotNull(rcvs1); Assert.assertNotNull(rcvs1.values); Assert.assertEquals(0, rcvs1.values.length()); - RemoteConfigValueStore rcvs2 = RemoteConfigValueStore.dataFromString("", false); + RemoteConfigValueStore rcvs2 = RemoteConfigValueStore.dataFromString("", false, new ModuleLog()); Assert.assertNotNull(rcvs2); Assert.assertNotNull(rcvs2.values); Assert.assertEquals(0, rcvs2.values.length()); @@ -62,7 +62,7 @@ public void rcvsDataFromStringNullEmpty() { @Test public void rcvsDataFromStringSamples_1() { String[] rcArr = new String[] { rcEStr("a", 123, false), rcEStr("b", "fg", false) }; - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), true); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), true, new ModuleLog()); Assert.assertNotNull(rcvs); Assert.assertNotNull(rcvs.values); Assert.assertEquals(2, rcvs.values.length()); @@ -87,7 +87,7 @@ public void rcvsDataFromStringSamples_2() throws JSONException { JSONObject jObjI = new JSONObject("{\"q\":6,\"w\":\"op\"}"); String[] rcArr = { rcEStr("321", 123, false), rcEStr("😀", "😁"), rcEStr("c", jArrI), rcEStr("d", 6.5), rcEStr("e", jObjI) }; - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), true); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), true, new ModuleLog()); Assert.assertNotNull(rcvs); Assert.assertNotNull(rcvs.values); @@ -141,7 +141,7 @@ public void rcvsDataFromStringSamples_2() throws JSONException { @Test public void dataFromString_CurrentStructure() { String[] rcArr = { rcEStr("a", 123), rcEStr("b", "ccx", false) }; - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()); Assert.assertEquals(123, rcvs.getValue("a").value); Assert.assertTrue(rcvs.getValue("a").isCurrentUsersData); @@ -156,10 +156,10 @@ public void dataFromString_CurrentStructure() { @Test public void rcvsMergeValues_1() throws JSONException { String[] rcArr = { rcEStr("a", 123), rcEStr("b", "fg") }; - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcArrIntoJSON(rcArr), false, new ModuleLog()); JSONObject obj = new JSONObject("{\"b\": 123.3,\"c\": \"uio\"}"); - Map newRC = RemoteConfigHelper.DownloadedValuesIntoMap(obj); + Map newRC = RemoteConfigHelper.DownloadedValuesIntoMap(obj, new ModuleLog()); rcvs.mergeValues(newRC, false); Assert.assertEquals(3, rcvs.values.length()); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index 436c604ce..c8884e710 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -353,29 +353,33 @@ private void validateFeatureFlags(Countly countly) { private void validateIntervalsAndSizes(Countly countly) { Assert.assertEquals(config.get(keyRServerConfigUpdateInterval), countly.moduleConfiguration.serverConfigUpdateInterval); - Assert.assertEquals(config.get(keyRReqQueueSize), countly.config_.maxRequestQueueSize); + // These are asserted on the instance's own resolved settings, not on the CountlyConfig: the SDK no + // longer writes them back onto the config, so that a config shared by two instances can not carry one + // instance's server-resolved settings into the other. This is also the stronger assertion - these are + // the values the SDK actually acts on. + Assert.assertEquals(config.get(keyRReqQueueSize), countly.moduleConfiguration.currentVMaxRequestQueueSize); Assert.assertEquals(config.get(keyREventQueueSize), countly.EVENT_QUEUE_SIZE_THRESHOLD); - Assert.assertEquals(config.get(keyRLogging), countly.config_.loggingEnabled); + Assert.assertEquals(config.get(keyRLogging), countly.moduleConfiguration.currentVLoggingEnabled); try { - Assert.assertEquals(config.get(keyRSessionUpdateInterval), countly.config_.sessionUpdateTimerDelay); + Assert.assertEquals(config.get(keyRSessionUpdateInterval), countly.moduleConfiguration.currentVSessionUpdateTimerDelay); } catch (AssertionError _ignored) { // This is a workaround for the issue where sessionUpdateTimerDelay is null by default - Assert.assertNull(countly.config_.sessionUpdateTimerDelay); + Assert.assertNull(countly.moduleConfiguration.currentVSessionUpdateTimerDelay); } - Assert.assertEquals(config.get(keyRContentZoneInterval), countly.config_.content.zoneTimerInterval); - Assert.assertEquals(config.get(keyRConsentRequired), countly.config_.shouldRequireConsent); - Assert.assertEquals(config.get(keyRDropOldRequestTime), countly.config_.dropAgeHours); + Assert.assertEquals(config.get(keyRContentZoneInterval), countly.moduleConfiguration.currentVZoneTimerInterval); + Assert.assertEquals(config.get(keyRConsentRequired), countly.moduleConfiguration.currentVRequiresConsent); + Assert.assertEquals(config.get(keyRDropOldRequestTime), countly.moduleConfiguration.currentVDropAgeHours); } private void validateLimits(Countly countly) { - Assert.assertEquals(config.get(keyRLimitKeyLength), countly.config_.sdkInternalLimits.maxKeyLength); - Assert.assertEquals(config.get(keyRLimitValueSize), countly.config_.sdkInternalLimits.maxValueSize); - Assert.assertEquals(config.get(keyRLimitSegValues), countly.config_.sdkInternalLimits.maxSegmentationValues); - Assert.assertEquals(config.get(keyRLimitBreadcrumb), countly.config_.sdkInternalLimits.maxBreadcrumbCount); - Assert.assertEquals(config.get(keyRLimitTraceLength), countly.config_.sdkInternalLimits.maxStackTraceLineLength); - Assert.assertEquals(config.get(keyRLimitTraceLine), countly.config_.sdkInternalLimits.maxStackTraceLinesPerThread); + Assert.assertEquals(config.get(keyRLimitKeyLength), countly.sdkInternalLimits_.maxKeyLength); + Assert.assertEquals(config.get(keyRLimitValueSize), countly.sdkInternalLimits_.maxValueSize); + Assert.assertEquals(config.get(keyRLimitSegValues), countly.sdkInternalLimits_.maxSegmentationValues); + Assert.assertEquals(config.get(keyRLimitBreadcrumb), countly.sdkInternalLimits_.maxBreadcrumbCount); + Assert.assertEquals(config.get(keyRLimitTraceLength), countly.sdkInternalLimits_.maxStackTraceLineLength); + Assert.assertEquals(config.get(keyRLimitTraceLine), countly.sdkInternalLimits_.maxStackTraceLinesPerThread); Assert.assertEquals(config.get(keyRUserPropertyCacheLimit), countly.moduleConfiguration.getUserPropertyCacheLimit()); } 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..c59c0730a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java @@ -374,6 +374,34 @@ public static void validateRecordEventInternalMock(EventProvider ep, String even validateRecordEventInternalMock(ep, eventKey, segmentation, 1, 0.0, 0.0, null, null, 0, 1); } + /** + * Waits until the current wall-clock second has enough room left that a test cannot accidentally cross a + * second boundary while it runs. + *

+ * The SDK measures durations with {@link UtilsTime#currentTimestampSeconds()}, which truncates + * ({@code System.currentTimeMillis() / 1000}). A view started at x.999 and stopped at x+1.001 therefore + * reports one whole second for two milliseconds of work, and a test that sleeps 1000ms starting from + * x.900 reports two. Both are pure boundary luck, not slowness, and they are why the ModuleViews duration + * tests failed intermittently - one of them carrying the comment "duration comes off sometimes". + *

+ * Starting each duration-sensitive test just after a boundary removes the problem without a tolerance on + * the assertions and without a clock hook in production code. It costs at most half a second, and only + * for the tests that call it. + */ + public static void alignToSecondBoundary() { + long remainderMs = System.currentTimeMillis() % 1000L; + //500ms of headroom: enough for a "no duration" test to start and stop a view, and enough that a + //sleep(1000) plus its overshoot still lands inside the next second + if (remainderMs <= 500L) { + return; + } + try { + Thread.sleep(1000L - remainderMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + public static void validateRecordEventInternalMock(EventProvider ep, String eventKey, Map segmentation, String idOverride, int index, Integer interactionCount) { validateRecordEventInternalMock(ep, eventKey, segmentation, 1, 0.0, 0.0, null, idOverride, index, interactionCount); } @@ -436,6 +464,11 @@ public static void validateRecordEventInternalMock(final @NonNull EventProvider Assert.assertTrue(cDuration >= 0); if (duration != null) { + //Exact, deliberately. Durations come from UtilsTime.currentTimestampSeconds(), which truncates + //(System.currentTimeMillis() / 1000), so a view spanning a second boundary reports a whole second + //for microseconds of work. That is handled by starting these tests inside a fresh second - see + //alignToSecondBoundary - rather than by loosening this assertion: "this view recorded no duration" + //is exactly the kind of thing these tests exist to catch. Assert.assertEquals(duration, cDuration); } @@ -495,6 +528,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 +555,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/UtilsNetworkingTest.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsNetworkingTest.java index d597dcd85..4e59adf2a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsNetworkingTest.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsNetworkingTest.java @@ -63,7 +63,7 @@ public void testSHA256() { String[] list_b = { "c641f7596a2959479d66f5b1ff9d11b5aaa24c185e27a636de242fab1e19d924", "3d8ad9c4c9194e2e1be44b408849bc4bad1c2624196440d016e14217ce2d5d24", "78cb302c277088418b8c91332eac2e336f6de107f3a336ddf05333b74778b92c" }; for (int a = 0; a < list_a.length; a++) { - Assert.assertEquals(UtilsNetworking.sha256Hash(list_a[a]), list_b[a]); + Assert.assertEquals(UtilsNetworking.sha256Hash(list_a[a], new ModuleLog()), list_b[a]); } } } 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/ConfigSdkInternalLimits.java b/sdk/src/main/java/ly/count/android/sdk/ConfigSdkInternalLimits.java index 713517c7c..86731976d 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigSdkInternalLimits.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigSdkInternalLimits.java @@ -1,5 +1,7 @@ package ly.count.android.sdk; +import androidx.annotation.NonNull; + public class ConfigSdkInternalLimits { //SDK internal limits protected Integer maxKeyLength; @@ -11,6 +13,51 @@ public class ConfigSdkInternalLimits { protected Integer maxStackTraceLineLength; protected int maxStackTraceThreadCount = 50; + /** + * Copies every limit onto this object. Each Countly instance keeps its own limits (see + * {@code Countly#sdkInternalLimits_}) seeded from the developer's config, because the server behaviour + * settings resolve these values per instance and two instances may be configured from one CountlyConfig. + *

+ * ADDING A FIELD ABOVE MEANS ADDING IT HERE. {@code ConfigSdkInternalLimitsTests} reflects over the + * declared fields and fails if one is missed, so a forgotten field cannot ship silently. + */ + void copyFrom(@NonNull ConfigSdkInternalLimits other) { + maxKeyLength = other.maxKeyLength; + maxValueSize = other.maxValueSize; + maxValueSizePicture = other.maxValueSizePicture; + maxSegmentationValues = other.maxSegmentationValues; + maxBreadcrumbCount = other.maxBreadcrumbCount; + maxStackTraceLinesPerThread = other.maxStackTraceLinesPerThread; + maxStackTraceLineLength = other.maxStackTraceLineLength; + maxStackTraceThreadCount = other.maxStackTraceThreadCount; + } + + /** + * Raises any set limit below 1 to 1. Called after the server behaviour settings have been applied, so a + * server sending a nonsensical limit can not make the SDK truncate everything to nothing. A limit that + * was never set stays unset, because null means "use the SDK default", not "clamp me to 1". + */ + void clampToMinimums() { + if (maxKeyLength != null) { + maxKeyLength = Math.max(maxKeyLength, 1); + } + if (maxValueSize != null) { + maxValueSize = Math.max(maxValueSize, 1); + } + if (maxSegmentationValues != null) { + maxSegmentationValues = Math.max(maxSegmentationValues, 1); + } + if (maxBreadcrumbCount != null) { + maxBreadcrumbCount = Math.max(maxBreadcrumbCount, 1); + } + if (maxStackTraceLinesPerThread != null) { + maxStackTraceLinesPerThread = Math.max(maxStackTraceLinesPerThread, 1); + } + if (maxStackTraceLineLength != null) { + maxStackTraceLineLength = Math.max(maxStackTraceLineLength, 1); + } + } + /** * Sets how many segmentation values can be recorded when recording an event or view. * Values exceeding this count will be ignored. The default value is 100 developer entries. diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java index c631acfae..453811d31 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -107,14 +107,14 @@ private enum RequestResult { // for binary images, checksum will be calculated without url encoded value of the requestData // because they sent as form-data and server calculates it that way if (!hasPicturePath) { - String checksum = UtilsNetworking.sha256Hash(requestData + requestInfoProvider_.getRequestSalt()); + String checksum = UtilsNetworking.sha256Hash(requestData + requestInfoProvider_.getRequestSalt(), L); requestData += "&checksum256=" + checksum; L.v("[ConnectionProcessor] The following checksum was added:[" + checksum + "]"); approximateDateSize += requestData.length(); // add request data to the estimated data size } } else { urlStr += "?" + requestData; - String checksum = UtilsNetworking.sha256Hash(requestData + requestInfoProvider_.getRequestSalt()); + String checksum = UtilsNetworking.sha256Hash(requestData + requestInfoProvider_.getRequestSalt(), L); urlStr += "&checksum256=" + checksum; L.v("[ConnectionProcessor] The following checksum was added:[" + checksum + "]"); } @@ -188,7 +188,7 @@ private enum RequestResult { } approximateDateSize += 4 + boundary.length(); // 4 is the length of the static parts of the entry - approximateDateSize += addTextMultipart(writer, "checksum256", UtilsNetworking.sha256Hash(UtilsNetworking.urlDecodeString(requestData) + requestInfoProvider_.getRequestSalt()), boundary); + approximateDateSize += addTextMultipart(writer, "checksum256", UtilsNetworking.sha256Hash(UtilsNetworking.urlDecodeString(requestData) + requestInfoProvider_.getRequestSalt(), L), boundary); // End of multipart/form-data. writer.append("--").append(boundary).append("--").append(CRLF).flush(); @@ -531,7 +531,7 @@ public void run() { } responseCode = httpConn.getResponseCode(); - responseString = Utils.inputStreamToString(connInputStream); + responseString = Utils.inputStreamToString(connInputStream, L); } long readingStreamTime = UtilsTime.getNanoTime() - pccTsReadingStream; 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..2825ceb81 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -33,6 +33,7 @@ of this software and associated documentation files (the "Software"), to deal import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -73,6 +74,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 +135,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 +192,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 +218,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 +257,7 @@ public void beginSession(boolean locationDisabled, @Nullable String locationCoun } } - Countly.sharedInstance().isBeginSessionSent = true; + cly.isBeginSessionSent = true; addRequestToQueue(data, false, null); tick(); @@ -365,16 +375,26 @@ public void tokenSession(String token, Countly.CountlyMessagingProvider provider L.d("[Connection Queue] Waiting for 10 seconds before adding token request to queue"); - // To ensure begin_session will be fully processed by the server before token_session - final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(); - worker.schedule(new Runnable() { - @Override - public void run() { - L.d("[Connection Queue] Finished waiting 10 seconds adding token request"); - addRequestToQueue(data, false, null); - tick(); - } - }, 10, TimeUnit.SECONDS); + // To ensure begin_session will be fully processed by the server before token_session. + // Scheduled on this queue's own backoff scheduler, NOT a method-local executor: the local one + // leaked a worker thread per token refresh and outlived shutdownExecutors(), so a teardown inside + // the 10-second window let the task write a token request carrying the pre-teardown device id + // into a store that halt() had just cleared. The backoff scheduler is shutdownNow()-ed on + // teardown, which cancels a not-yet-started task - exactly the wanted lifecycle. + try { + backoffScheduler_.schedule(new Runnable() { + @Override + public void run() { + L.d("[Connection Queue] Finished waiting 10 seconds adding token request"); + addRequestToQueue(data, false, null); + tick(); + } + }, 10, TimeUnit.SECONDS); + } catch (RejectedExecutionException ex) { + //the scheduler is already shut down: this queue was discarded by a teardown, drop the token + //request rather than writing into a store the owning instance no longer manages + L.d("[Connection Queue] tokenSession, queue is already torn down, dropping the token request"); + } } /** @@ -775,8 +795,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)); } @@ -928,7 +948,7 @@ public String prepareServerConfigRequest() { * Ensures that an executor has been created for ConnectionProcessor instances to be submitted to. */ void ensureExecutor() { - if (executor_ == null) { + if (executor_ == null || executor_.isShutdown()) { if (L != null) { L.v("[ConnectionQueue] ensureExecutor, Creating new executor"); } @@ -936,6 +956,27 @@ void ensureExecutor() { } } + /** + * Releases this queue's worker threads. Called when the owning instance is halted, which discards + * the whole ConnectionQueue and builds a new one on the next init - without this, every halt/init + * cycle and every instance would strand its request executor and backoff scheduler threads, which + * are non-daemon and therefore live for the rest of the process. + *

+ * {@code shutdown()} rather than {@code shutdownNow()}: a request already being sent is allowed to + * finish rather than being interrupted mid-flight. + */ + void shutdownExecutors() { + if (executor_ != null) { + //shutdown(), not shutdownNow(): a request already on the wire is allowed to finish rather than + //being interrupted mid-flight + executor_.shutdown(); + } + //shutdownNow() for the backoff scheduler: a retry that has not started yet must NOT fire after the + //owning instance is gone, because tick() would then revive this discarded queue through + //ensureExecutor() and drain with a context that teardown already cleared + backoffScheduler_.shutdownNow(); + } + /** * Starts ConnectionProcessor instances running in the background to * process the local connection queue data. @@ -956,7 +997,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..ae34f7f36 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,64 @@ 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; + } + } + + /** + * The guard is claimed in {@link #attachToActivity(Activity)} before the window attach, so that a + * second overlay can not slip in mid-attach. If the attach then fails there is no presentation to + * protect, and holding the guard would block every later content and feedback overlay in the + * process for good - so hand it back on every failure path. + */ + private void abandonFailedAttach(@NonNull String reason) { + // A refresh of an already-attached overlay can also fail. The overlay is still a live window in + // that case, so it IS the active presentation: keep the guard, and above all do not touch + // isAddedToWindow, or removeFromWindow() would later skip the real window and orphan it. + if (isAddedToWindow) { + Log.w(Countly.TAG, "[ContentOverlayView] attach step failed (" + reason + ") while still attached, keeping the presentation guard"); + return; + } + + releasePresentationGuard(); + Log.w(Countly.TAG, "[ContentOverlayView] attach failed (" + reason + "), released the presentation guard"); + } private Activity currentHostActivity; private WindowManager windowManager; private boolean isAddedToWindow = false; @@ -83,7 +135,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 +150,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 +487,31 @@ void attachToActivity(@NonNull Activity activity) { return; } + // The guard is the single definition of "at most one content or feedback overlay at a time". + // Enforce it here too, not only at the two module call sites: the modules re-attach their cached + // overlay when the app returns to the foreground without re-checking, so an overlay that was + // never shown (or whose earlier attach failed) could otherwise steal the presentation from the + // overlay that is actually on screen. Self-aware, so refreshing the presenting overlay is fine. + if (isOtherOverlayPresented(this)) { + Log.w(Countly.TAG, "[ContentOverlayView] attachToActivity, another content or feedback overlay is already presented, skipping"); + return; + } + + // Claim the process-global presentation guard: from here this overlay is the active + // presentation (idempotent across background/foreground reattaches). + presentedOverlay = this; + + // Anything thrown while measuring or attaching would otherwise strand the guard, blocking every + // later content and feedback overlay in the process. Release and rethrow so behaviour is unchanged. + try { + attachToActivityInternal(activity); + } catch (RuntimeException | Error t) { + abandonFailedAttach("unexpected " + t.getClass().getSimpleName() + " during attach"); + throw t; + } + } + + private void attachToActivityInternal(@NonNull Activity activity) { // Check if we're already attached to this activity if (currentHostActivity == activity && isAddedToWindow) { // Still check for orientation changes — WindowManager views don't get onConfigurationChanged @@ -476,6 +555,8 @@ private void addToWindow(@NonNull Activity activity, @NonNull WindowManager.Layo WindowManager wm = (WindowManager) activity.getSystemService(Context.WINDOW_SERVICE); if (wm == null) { Log.w(Countly.TAG, "[ContentOverlayView] addToWindow, WindowManager is null, skipping"); + isAddedToWindow = false; + abandonFailedAttach("no WindowManager"); return; } @@ -492,7 +573,12 @@ private void addToWindow(@NonNull Activity activity, @NonNull WindowManager.Layo Log.w(Countly.TAG, "[ContentOverlayView] addToWindow, token not ready, retrying on next frame"); View decor = activity.getWindow().getDecorView(); decor.post(() -> { - if (isClosed || activity.isFinishing() || isAddedToWindow) { + if (isClosed || isAddedToWindow) { + //close()/destroy() already released the guard, or another attach won the race + return; + } + if (activity.isFinishing()) { + abandonFailedAttach("host activity finished before the retry"); return; } try { @@ -503,11 +589,13 @@ private void addToWindow(@NonNull Activity activity, @NonNull WindowManager.Layo } catch (Exception e2) { Log.e(Countly.TAG, "[ContentOverlayView] addToWindow, retry also failed", e2); isAddedToWindow = false; + abandonFailedAttach("retry threw " + e2.getClass().getSimpleName()); } }); } catch (Exception e) { Log.e(Countly.TAG, "[ContentOverlayView] addToWindow, failed to add view", e); isAddedToWindow = false; + abandonFailedAttach("addView threw " + e.getClass().getSimpleName()); } } @@ -606,7 +694,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 +782,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 +903,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 +1147,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 +1269,7 @@ void close(Map contentData) { return; } isClosed = true; + releasePresentationGuard(); Log.d(Countly.TAG, "[ContentOverlayView] close, closing content overlay"); @@ -1207,6 +1296,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..eb0f791cd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -24,17 +24,18 @@ of this software and associated documentation files (the "Software"), to deal import android.annotation.SuppressLint; import android.app.Activity; import android.app.Application; -import android.content.ComponentCallbacks; import android.content.Context; import android.content.res.Configuration; -import android.os.Bundle; +import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ProcessLifecycleOwner; import java.util.ArrayList; 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,13 +99,31 @@ 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(); } + /** + * Whether the process is at least started, asked through this instance's config. + *

+ * Null-safe on purpose: init derives the observer onto the CountlyConfig, and a config shared by two + * instances is briefly without one while the second init restores the developer's values. A live sibling + * instance can reach the foreground check in that window - through a consent change or a device-id change - + * and must not crash the caller's thread over it. Treated as "not started" when unknown, which is the + * conservative answer: it suppresses an automatic session rather than opening a spurious one. + */ + boolean lifeCycleAtleastStarted() { + if (config_ == null || config_.lifecycleObserver == null) { + L.d("[Countly] lifeCycleAtleastStarted, no lifecycle observer available yet, treating the app as not started"); + return false; + } + return config_.lifecycleObserver.LifeCycleAtleastStarted(); + } + /** * Enum used in Countly.initMessaging() method which controls what kind of * app installation it is. Later (in Countly Dashboard or when calling Countly API method), @@ -134,11 +153,26 @@ 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 {@link #sharedInstance()}, and the name it is + * listed under by {@link #listInstances()}. The {@code [CLY]_} prefix is the SDK's internal-key convention, + * so it will not collide with a customer app key or instance name. + *

+ * Public because {@code listInstances()} returns it: without the constant a caller would have to hardcode + * the literal to tell the default instance apart from a named one. + */ + public 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<>(); + + //Serialises creation in instance(name) against removal in removeInstance(name), so a name is never + //created and removed at the same time. Plain reads go straight to the concurrent map. + private static final Object instancesLock_ = new Object(); // 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 @@ -178,7 +212,12 @@ static void haltTrackedInstances() { private int activityCount_; boolean disableUpdateSessionRequests_ = false;//todo, move to module after 'setDisableUpdateSessionRequests' is removed - boolean sdkIsInitialised = false; + //volatile: written under the instance monitor (init/tearDown) but read lock-free from other + //threads - the network executor's tick() guard, push token callbacks, the lifecycle dispatch + //gates, and app threads following the documented getInstance(name) + isInitialized() pattern. + //Without it a reader has no happens-before edge with init and could see true while the module + //fields are still being published. + volatile boolean sdkIsInitialised = false; BaseInfoProvider baseInfoProvider; RequestQueueProvider requestQueueProvider; @@ -217,6 +256,23 @@ static void haltTrackedInstances() { //reference to countly store CountlyStore countlyStore; + //This instance's DeviceInfo. Every init() overwrites config.deviceInfo, so reading it back off a + //reused config would hand this instance another instance's foreground/background state. + DeviceInfo deviceInfo_; + + // 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_ = ""; + + // This instance's internal limits, seeded at init from the developer's config and then resolved + // further by this instance's server behaviour settings. They live here rather than on the + // CountlyConfig because ~30 call sites read them on every recorded event, view, crash and user + // property, and two instances may legitimately be configured from one config object - sharing the + // config's nested limits object would let one instance's /o/sdk response silently retruncate the + // other instance's data. + final ConfigSdkInternalLimits sdkInternalLimits_ = new ConfigSdkInternalLimits(); + //overrides boolean isHttpPostForced = false;//when true, all data sent to the server will be sent using HTTP POST @@ -248,6 +304,29 @@ 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; + + // True once the registry has handed this object out under instanceName_. A detached "new Countly()" is + // never registered, which is exactly why the stale-handle check in init has to consult this and not just + // compare against instances_ - every detached instance carries the DEFAULT_NAME default. + private boolean wasRegistered_ = false; + + // Set as the FIRST act of teardown, before anything is nulled. Android delivers lifecycle callbacks on + // the main thread while halt()/removeInstance() can run on any thread, so an event can already be in + // flight when teardown starts; this gate makes every dispatch entry point no-op for a dying instance. + // Volatile: written by the tearing-down thread, read by the main thread. + private volatile boolean tearingDown = false; + + // 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. + // Lifecycle callbacks are no longer registered per instance: CountlyLifecycleDispatcher holds the one + // process-wide registration and this instance simply adds/removes itself from its list. That removes both + // the N-registrations-for-N-instances problem and the per-init re-registration leak. + public static class CountlyFeatureNames { public static final String sessions = "sessions"; public static final String events = "events"; @@ -270,10 +349,252 @@ 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. + *

+ * A named instance starts from empty storage. It does not inherit anything from + * {@link #sharedInstance()}: it generates its own device id, so the server sees a new user, and it can not + * send what the default instance has queued. So do not move an existing integration onto a named instance + * to reuse its app key as the name - that re-identifies every install and abandons whatever the default + * instance had not sent yet. Keep {@code sharedInstance()} as the primary and add named instances + * alongside it for the app keys that are genuinely new. + *

+ * Push notifications and native crash dumps are not multi-instance. Both are process-wide and + * owned by the default (shared) instance: {@code CountlyPush} has one static registration, one token + * and one shared preferences file, and sdk-native writes minidumps to one fixed directory. A named + * instance therefore touches neither - its push consent changes, push tokens, push clicks and native + * dumps are all left to the default instance. Initialise push on {@link #sharedInstance()}. + *

+ * Each initialised instance has a real resource footprint: its own request-queue executor, + * backoff scheduler and session-heartbeat timer (roughly three threads), its own SharedPreferences + * files, and a 60-second heartbeat while in the foreground. A handful of instances is fine; designs + * that mint instances from unbounded dynamic names should reuse a small fixed set instead, and + * {@link #removeInstance(String)} what they no longer need. + * + * @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; + + //Deliberately NOT Map.computeIfAbsent: that is API 24, this SDK is minSdk 21 and does not enable + //core library desugaring, so it would throw NoSuchMethodError on API 21-23 - on the very first + //sharedInstance() call. Double-checked locking uses only pre-24 APIs and, unlike putIfAbsent, + //never constructs a losing duplicate (the constructor starts a scheduled-executor thread). + Countly existing = instances_.get(key); + if (existing != null) { + return existing; + } + + synchronized (instancesLock_) { + existing = instances_.get(key); + if (existing != null) { + return existing; + } + Countly c = new Countly(); + c.instanceName_ = key; + // 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(key)) { + c.L.setTag(TAG + "-" + key); + } + c.wasRegistered_ = true; + instances_.put(key, c); + return c; + } + } + + /** + * Logs a registry-level message without going through {@link #sharedInstance()}, which would create + * and register a default instance just to log. Prefers the default instance's logger when that object + * already exists, so the message also reaches that instance's log listener and health tracker. + *

+ * The logcat fallback is gated on some instance actually having console logging on: raw + * {@code android.util.Log} bypasses {@link CountlyConfig#setLoggingEnabled(boolean)} and + * {@link CountlyConfig#disableSDKLoggingInProduction()}, and these messages carry an instance name, + * which is commonly the app key - it must not surface in a build that asked for no logging. + */ + private static void logWithoutCreatingDefault(String message, boolean warning) { + Countly existingDefault = instances_.get(DEFAULT_NAME); + if (existingDefault != null && existingDefault.L.logEnabled()) { + //the default instance's logger can actually deliver this, so let it - the message reaches that + //instance's log listener and health tracker + if (warning) { + existingDefault.L.w(message); + } else { + existingDefault.L.d(message); + } + return; + } + + //Otherwise fall through rather than returning: a default instance exists in the registry as soon as + //anything calls sharedInstance() (a push broadcast is enough), and if it was never initialised its + //logger is unarmed - so returning there would drop registry diagnostics even when a named instance + //has logging on. Match the SDK's own logEnabled(), which counts a log listener too, not just console. + for (Countly c : instances_.values()) { + if (c.L.logEnabled()) { + if (warning) { + c.L.w(message); + } else { + c.L.d(message); + } + return; + } + } + + boolean consoleLoggingWanted = false; + for (Countly c : instances_.values()) { + if (c.L.loggingEnabled) { + consoleLoggingWanted = true; + break; + } + } + + if (!consoleLoggingWanted) { + return; + } + + if (warning) { + Log.w(TAG, message); + } else { + Log.d(TAG, message); + } + } + + /** + * 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 instances, including the default (shared) instance, + * which is listed under its reserved name {@link #DEFAULT_NAME}. An instance is registered from the + * moment {@code sharedInstance()} or {@code instance(name)} first hands it out, whether or not it has + * been initialised - use {@link #getInstance(String)} plus {@link #isInitialized()} to tell those apart. + * + * @return a snapshot list of registered instance names + */ + public static List listInstances() { + return new ArrayList<>(instances_.keySet()); + } + + /** + * Halts every registered instance, including the default (shared) one. + *

+ * Testing purposes only, like the {@link #halt()} it is built on. It is not a "reset the SDK" + * call for production code, and the paragraph below about leaving instances uninitialised is why. + *

+ * This destroys stored data. Each instance is reset exactly as {@link #halt()} resets it, which + * erases that instance's persisted state: its device ID and ID type (including the generated-UUID + * cache), its consent, its queued requests and events, its cached remote-config values and its schema + * version - and, for the default instance, the process-wide push preferences (push consent and cached + * push data) as well. Anything recorded but not yet sent is gone, and the next session starts as a + * new user. + *

+ * Only instances initialised in this process run have storage to clear. An instance that is + * registered but was never initialised (obtained but not yet {@code init()}-ed, or freshly re-obtained + * after {@link #removeInstance(String)} or a process restart) has no store object, so its persisted + * files from earlier runs are left untouched - erasing those requires initialising that name first and + * then halting it. + *

+ * The instances remain registered, so a later {@code instance(name)} or {@code sharedInstance()} returns + * the same (now halted) object, ready to be initialised again. To stop an instance without discarding + * its data, use {@link #stop()}, or {@link #removeInstance(String)} to also deregister the name - both + * keep everything on disk. + *

+ * After this call every instance is uninitialised, so every module accessor returns {@code null} - + * {@code sharedInstance().events()}, {@code .views()}, {@code .requestQueue()}, {@code .attribution()} + * and the rest. Code that reaches for one of them without a null check, or without re-initialising + * first, will throw a {@code NullPointerException}. This bit the sample app during on-device testing: + * one call here left two of its screens crashing on {@code requestQueue()} and {@code attribution()}. + * Guard with {@link #isInitialized()} or call {@code init(config)} again before using any instance. + */ + public static void haltAllInstances() { + for (Countly c : instances_.values()) { + try { + c.halt(); + } catch (Throwable t) { + //one instance failing to halt must not leave the remaining ones running: this is a + //process-wide reset, so it has to be all-or-as-much-as-possible rather than stopping at the + //first failure + c.L.e("[Countly] haltAllInstances, failed to halt an instance, continuing with the rest, [" + t + "]"); + } + } + } + + /** + * 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. + *

+ * The instance's stored data is kept: its queued requests and events, device ID, consent state + * and cached remote-config values stay on disk, so nothing recorded but not yet sent is lost, and + * initialising that name again resumes from where it left off. Removing frees the in-memory instance, + * not its storage - so if you key instances by short-lived, dynamic names, their files accumulate. + * Call {@link #halt()} on the instance first when you want its data erased as well. + *

+ * Treat removal as an exclusive operation on that name: code still using the name on other threads + * should be quiesced first, because {@code instance(name)} after removal hands out a fresh, + * uninitialised object whose module accessors return null until it is initialised. + * + * @param name the instance name to stop and deregister + */ + public static void removeInstance(String name) { + final String key = (name == null || name.isEmpty()) ? DEFAULT_NAME : name; + if (DEFAULT_NAME.equals(key)) { + logWithoutCreatingDefault("[Countly] removeInstance, the default (shared) instance can not be removed; use halt() to reset it. Ignoring.", true); + return; + } + Countly c; + //Deregister under the same lock instance(name) creates under, so a concurrent creation either + //completes before this removal or begins after it - a brand new instance can never be removed + //while its creator is still inside instance(). Remove before halting so that creation hands back + //a fresh object rather than the one being torn down. + synchronized (instancesLock_) { + c = instances_.remove(key); + } + + if (c == null) { + logWithoutCreatingDefault("[Countly] removeInstance, no instance registered under [" + key + "], nothing to remove", false); + return; + } + //Stop outside the lock: teardown does real work (timers, callbacks, threads) and must not block + //instance() creation. The removed object becomes GC-eligible once the caller drops its handle, + //unless ModuleCrash#halt could not unlink from the process-global handler chain. + //Deliberately NOT halt(): deregistering an instance must not throw away data it recorded but has + //not sent yet. Callers who want the data gone call halt() on the instance first. + c.L.i("[Countly] removeInstance, stopping and deregistering instance [" + key + "], stored data is kept"); + c.stop(); } /** @@ -352,6 +673,36 @@ 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 name + //passed to instance(name) is the sole identity of an instance, the config plays no part in it + //(CountlyConfig.setInstanceName was removed before release for exactly that reason). 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. + if (DEFAULT_NAME.equals(instanceName_)) { + storageNamespace_ = ""; + } else { + storageNamespace_ = CountlyStore.sanitizeNamespace(instanceName_); + } + + //A CountlyConfig may be shared by several instances. What used to make that unsafe was the SDK + //writing its own resolved state back onto the object: the internal limits now live per instance + //(see sdkInternalLimits_), and DerivedFieldSnapshot resets the objects init derives, so each init + //starts from what the developer configured rather than from the previous instance's leftovers. + if (config.initialisedForNamespace != null && !storageNamespace_.equals(config.initialisedForNamespace)) { + L.w("[Init] This CountlyConfig was already used to initialise another instance. Each instance keeps its own storage, device id and internal limits, but the values you set on this object apply to every instance built from it, and the settings this instance resolves from the server are written onto it. Prefer a fresh CountlyConfig per instance."); + } + + //A handle whose name was deregistered by removeInstance() is still a fully functional object with the + //same instanceName_, so re-initialising it would build a second store and queue over the namespace a + //freshly obtained instance(name) is already using - two sessions, two timers, and two writers + //read-modify-writing one request queue. Refuse instead: the caller must take a fresh handle. + Countly registered = instances_.get(instanceName_); + if (wasRegistered_ && registered != this) { + L.e("[Init] This handle for instance [" + instanceName_ + "] was removed from the registry and can not be initialised again; another object is registered under that name. Obtain a fresh handle with Countly.instance(name)."); + return this; + } + if (config.application == null) { L.w("[Init] Initialising the SDK without providing the application class. Some functionality will not work."); } @@ -463,6 +814,12 @@ public synchronized Countly init(CountlyConfig config) { config.sdkInternalLimits.maxStackTraceLineLength = maxStackTraceLineLengthDefault; } + //Take this instance's own copy of the resolved limits. From here on the SDK reads and writes + //sdkInternalLimits_, never config.sdkInternalLimits, so the developer's config object is left + //alone and a second instance built from the same config gets its own limits. ModuleConfiguration + //is constructed below and layers the server behaviour settings on top of this copy. + sdkInternalLimits_.copyFrom(config.sdkInternalLimits); + long timerDelay = TIMER_DELAY_IN_SECONDS; if (config.sessionUpdateTimerDelay != null) { //if we need to change the timer delay, do that first @@ -475,12 +832,33 @@ public synchronized Countly init(CountlyConfig config) { L.i("[Init] Explicit storage mode is being enabled"); } + //init() and the module constructors write their results back onto the config: the store, the + //queues, every provider back-reference, the DeviceInfo, the temporary-device-id sentinel and + //the server-resolved settings. Re-initialising this instance must therefore start from what + //the developer configured, not from the previous init's leftovers - halt() throws away the + //ConnectionQueue, so a cached requestQueueProvider would write through a torn-down queue. + //This also runs when a config is shared across instances, which is supported: every init starts + //from the values the developer set rather than from the previous instance's leftovers. + //Sharing one CountlyConfig across instances is supported, and init mutates that shared object while + //deriving the store, the queues and the providers. init is synchronized on THIS Countly, not on the + //config, so two instances initialising on two threads would interleave those writes and could adopt + //each other's store. Serialise the whole derived-field region on the config itself. The config + //monitor is always taken after the instance monitor and never the other way round, so this cannot + //invert a lock order. + synchronized (config) { + if (config.derivedFieldSnapshot == null) { + config.derivedFieldSnapshot = new CountlyConfig.DerivedFieldSnapshot(config); + } else { + config.derivedFieldSnapshot.restoreOnto(config); + } + config.initialisedForNamespace = storageNamespace_; + //set or create the CountlyStore if (config.countlyStore != null) { //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,15 +919,23 @@ 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; } }; } + //captured before the default observer is derived below: an explicitly injected observer + //(tests, embedders with their own lifecycle source) must stay authoritative for the + //foreground seed too, ahead of the dispatcher's exact count + final boolean lifecycleObserverInjected = config.lifecycleObserver != null; if (config.lifecycleObserver == null) { config.lifecycleObserver = new LifecycleObserver() { @Override public boolean LifeCycleAtleastStarted() { @@ -561,7 +947,9 @@ public synchronized Countly init(CountlyConfig config) { if (config.metricProviderOverride != null) { L.d("[Init] Custom metric provider was provided"); } - config.deviceInfo = new DeviceInfo(config.metricProviderOverride); + deviceInfo_ = new DeviceInfo(config.metricProviderOverride); + deviceInfo_.L = L; + config.deviceInfo = deviceInfo_; if (config.tamperingProtectionSalt != null) { L.d("[Init] Parameter tampering protection salt set"); @@ -594,8 +982,10 @@ public synchronized Countly init(CountlyConfig config) { try { Map migrationParams = new HashMap<>(); migrationParams.put(MigrationHelper.key_from_0_to_1_custom_id_set, config.deviceID != null); + migrationParams.put(MigrationHelper.key_from_0_to_1_custom_id_value, config.deviceID); + migrationParams.put(MigrationHelper.key_from_0_to_1_temp_id_enabled, config.temporaryDeviceIdEnabled); - 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."); @@ -650,6 +1040,10 @@ public synchronized Countly init(CountlyConfig config) { moduleRequestQueue.consentProvider = config.consentProvider; moduleHealthCheck.consentProvider = config.consentProvider; moduleRequestQueue.deviceIdProvider = config.deviceIdProvider; + //these two are constructed before ModuleDeviceId exists, so their own field is still null; + //fill it in here rather than have them read the shared config at request time + moduleConfiguration.deviceIdProvider = config.deviceIdProvider; + moduleHealthCheck.deviceIdProvider = config.deviceIdProvider; moduleConsent.eventProvider = config.eventProvider; moduleConsent.deviceIdProvider = config.deviceIdProvider; moduleDeviceId.eventProvider = config.eventProvider; @@ -664,7 +1058,19 @@ public synchronized Countly init(CountlyConfig config) { if (config.customNetworkRequestHeaders != null) { L.i("[Countly] Calling addCustomNetworkRequestHeaders"); - requestHeaderCustomValues = config.customNetworkRequestHeaders; + //Defensive copy: the config stores the caller's map by reference, and + //addCustomNetworkRequestHeaders mutates this field in place. Two instances configured + //from one map would otherwise share it, so adding an Authorization header to one + //instance would send it to the other instance's server too. + //It does NOT make the map thread-safe: addCustomNetworkRequestHeaders still mutates this same + //map in place while ConnectionProcessor iterates it on the network executor. That race is + //pre-existing and unchanged here. + //The trade-off is a behaviour change: until 26.1.5 the SDK held the caller's own map and + //re-read it before every request, so mutating it after init changed later requests. It no + //longer does, so say so out loud rather than letting an app silently keep sending a stale + //header (a rotated auth token being the case that matters). + requestHeaderCustomValues = new HashMap<>(config.customNetworkRequestHeaders); + L.i("[Countly] init, custom network request headers are copied at init: later changes to the map you passed to CountlyConfig will NOT be picked up. Use Countly.requestQueue().addCustomNetworkRequestHeaders(...) to change them while the SDK is running"); connectionQueue_.setRequestHeaderCustomValues(requestHeaderCustomValues); } @@ -678,9 +1084,11 @@ public synchronized Countly init(CountlyConfig config) { L.d("[Init] Enabling tamper protection"); } - if (config.dropAgeHours > 0) { + //resolved value, not the config's: ModuleConfiguration has already layered the stored server + //behaviour settings on top of what the developer configured + if (moduleConfiguration.currentVDropAgeHours > 0) { L.d("[Init] Enabling drop older request threshold"); - countlyStore.setRequestAgeLimit(config.dropAgeHours); + countlyStore.setRequestAgeLimit(moduleConfiguration.currentVDropAgeHours); } if (config.pushIntentAddMetadata) { @@ -688,28 +1096,30 @@ public synchronized Countly init(CountlyConfig config) { addMetadataToPushIntents = config.pushIntentAddMetadata; } - if (config.eventQueueSizeThreshold != null) { - L.d("[Init] Setting event queue size: [" + config.eventQueueSizeThreshold + "]"); + //resolved value, not the config's - see the drop-age comment above + if (moduleConfiguration.currentVEventQueueSizeThreshold != null) { + L.d("[Init] Setting event queue size: [" + moduleConfiguration.currentVEventQueueSizeThreshold + "]"); - if (config.eventQueueSizeThreshold < 1) { + if (moduleConfiguration.currentVEventQueueSizeThreshold < 1) { L.d("[Init] queue size can't be less than zero"); - config.eventQueueSizeThreshold = 1; + moduleConfiguration.currentVEventQueueSizeThreshold = 1; } - EVENT_QUEUE_SIZE_THRESHOLD = config.eventQueueSizeThreshold; + EVENT_QUEUE_SIZE_THRESHOLD = moduleConfiguration.currentVEventQueueSizeThreshold; } 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; @@ -724,28 +1134,47 @@ public synchronized Countly init(CountlyConfig config) { connectionQueue_.setRequestHeaderCustomValues(requestHeaderCustomValues); connectionQueue_.setMetricOverride(config.metricOverride); connectionQueue_.setContext(context_); + final String requestSaltSnapshot = config.tamperingProtectionSalt; connectionQueue_.requestInfoProvider = new RequestInfoProvider() { + //These are called from the network thread, outside any try block, for every request the + //ConnectionProcessor drains. requestQueue() returns null as soon as sdkIsInitialised is + //cleared, and teardown clears it while a processor is still finishing - the teardown flush + //deliberately puts one in flight - so read the modules directly and fall back to the + //configured values instead of throwing out of run() and stopping the drain. @Override public boolean isHttpPostForced() { - return requestQueue().isHttpPostForced(); + return moduleRequestQueue != null ? moduleRequestQueue.isHttpPostForcedInternal() : isHttpPostForced; } @Override public boolean isDeviceAppCrawler() { - return requestQueue().isDeviceAppCrawler(); + //false when the module is gone: never DROP a queued request on the way out + return moduleRequestQueue != null && moduleRequestQueue.isDeviceAppCrawlerInternal(); } @Override public boolean ifShouldIgnoreCrawlers() { - return requestQueue().ifShouldIgnoreCrawlers(); + //true matches the field's own default + return moduleRequestQueue == null || moduleRequestQueue.ifShouldIgnoreCrawlersInternal(); } @Override public int getRequestDropAgeHours() { - return config.dropAgeHours; + //read live on every send, so it must be this instance's resolved value and not a config + //field another instance may also be resolving into + return moduleConfiguration != null ? moduleConfiguration.currentVDropAgeHours : config.dropAgeHours; } @Override public String getRequestSalt() { - return config.tamperingProtectionSalt; + //frozen at init like the app key and server URL, and unlike the live reads above: + //those return values the SDK itself resolves per instance, while the salt is only + //ever set by the developer - reading it live off a (shareable) config would let a + //mutation made for another instance silently re-salt this instance's requests and + //stall its queue against a salt-enforcing server + return requestSaltSnapshot; } }; + //Cleared before the SDK counts as initialised, and unconditionally: the lifecycle gate reads both + //flags, so a re-init must not leave a stale tearingDown behind even for an instance that has no + //Application and therefore never joins the dispatcher. + tearingDown = false; sdkIsInitialised = true; //AFTER THIS POINT THE SDK IS COUNTED AS INITIALISED @@ -755,104 +1184,50 @@ public synchronized Countly init(CountlyConfig config) { trackedInstancesForTests.add(this); } //set global application listeners + int exactStartedActivityCount = -1; if (config.application != null) { - L.d("[Countly] Calling registerActivityLifecycleCallbacks"); - config.application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { - @Override - public void onActivityCreated(Activity activity, Bundle bundle) { - if (L.logEnabled()) { - L.d("[Countly] onActivityCreated, " + activity.getClass().getSimpleName()); - } - //for (ModuleBase module : modules) { - // module.callbackOnActivityCreated(activity); - //} - } - - @Override - public void onActivityStarted(Activity activity) { - if (L.logEnabled()) { - L.d("[Countly] onActivityStarted, " + activity.getClass().getSimpleName()); - } - onStartInternal(activity); - //for (ModuleBase module : modules) { - // module.callbackOnActivityStarted(activity); - //} - } - - @Override - public void onActivityResumed(Activity activity) { - if (L.logEnabled()) { - L.d("[Countly] onActivityResumed, " + activity.getClass().getSimpleName()); - } - //for star rating - for (ModuleBase module : modules) { - module.callbackOnActivityResumed(activity); - } - } - - @Override - public void onActivityPaused(Activity activity) { - if (L.logEnabled()) { - L.d("[Countly] onActivityPaused, " + activity.getClass().getSimpleName()); - } - //for (ModuleBase module : modules) { - // module.callbackOnActivityPaused(activity); - //} - } - - @Override - public void onActivityStopped(Activity activity) { - if (L.logEnabled()) { - L.d("[Countly] onActivityStopped, " + activity.getClass().getSimpleName()); - } - onStopInternal(); - //for APM - for (ModuleBase module : modules) { - module.callbackOnActivityStopped(activity); - } - } - - @Override - public void onActivitySaveInstanceState(Activity activity, Bundle bundle) { - if (L.logEnabled()) { - L.d("[Countly] onActivitySaveInstanceState, " + activity.getClass().getSimpleName()); - } - //for (ModuleBase module : modules) { - // module.callbackOnActivitySaveInstanceState(activity); - //} - } - - @Override - public void onActivityDestroyed(Activity activity) { - if (L.logEnabled()) { - L.d("[Countly] onActivityDestroyed, " + activity.getClass().getSimpleName()); - } - for (ModuleBase module : modules) { - module.onActivityDestroyed(activity); - } - } - }); - - config.application.registerComponentCallbacks(new ComponentCallbacks() { - @Override - public void onConfigurationChanged(Configuration configuration) { - L.d("[Countly] ComponentCallbacks, onConfigurationChanged"); - onConfigurationChangedInternal(configuration); - } - - @Override - public void onLowMemory() { - L.d("[Countly] ComponentCallbacks, onLowMemory"); - } - }); + //One process-wide registration, owned by CountlyLifecycleDispatcher, instead of a fresh + //ActivityLifecycleCallbacks per instance. Registration is idempotent: CountlyInitProvider + //normally does it before Application.onCreate, and this call covers an app that removed the + //provider from its manifest. + L.d("[Countly] Registering with the process-wide lifecycle dispatcher"); + CountlyLifecycleDispatcher.getInstance().register(config.application); + //the returned snapshot is atomic with joining the dispatcher: this instance receives + //exactly the events after the snapshot, so seeding from it can neither miss nor + //double-count an activity start that races init + exactStartedActivityCount = CountlyLifecycleDispatcher.getInstance().addInstance(this); } else { L.d("[Countly] Global activity listeners not registred due to no Application class"); + if (moduleSessions != null && moduleSessions.automaticSessionTrackingEnabled() && lifeCycleAtleastStarted()) { + //scoped to foreground init, which is the moment a session auto-begins (initFinished + //below): without the Application class the SDK never sees activity stops, so that + //session sends updates forever - background time included - unless the app calls + //onStop() itself. Loud, because the resulting damage (inflated session durations) is + //silent and server-side. A background init auto-begins nothing and stays quiet here. + L.w("[Countly] Automatic session tracking is enabled, the app is in the foreground, and no Application class was provided: a session will begin now, but the SDK cannot observe the activity lifecycle, so it will only end if onStop() is called manually. Provide the Application class on the config, or use config.enableManualSessionControl()."); + } } - if (config_.lifecycleObserver.LifeCycleAtleastStarted()) { + //foreground-seed precedence mirrors lifecycleStateAtLeastStartedInternal: an injected + //observer and the test override are authoritative sources and must also drive the seed, + //otherwise an instance would seed itself "foreground" from the dispatcher count while + //every other foreground decision (auto session begin, timer heartbeat) says "background" + if (exactStartedActivityCount >= 0 && !lifecycleObserverInjected && lifecycleStateOverrideForTests == null) { + //the dispatcher was registered before the first activity (CountlyInitProvider), so this + //is the exact number of currently started activities - unlike ProcessLifecycleOwner, + //whose ~700ms stop-debounce can report "foreground" right after the app left it and + //seed a phantom count that never drains (a session that never ends) + L.d("[Countly] Seeding the activity counter from the lifecycle dispatcher: [" + exactStartedActivityCount + "] activities are started."); + activityCount_ = exactStartedActivityCount; + if (activityCount_ > 0) { + deviceInfo_.inForeground(); + } + } else if (lifeCycleAtleastStarted()) { + //no trustworthy exact count (no Application class, provider stripped from the + //manifest) or an authoritative observer/override is present - use the observer chain L.d("[Countly] SDK detects that the app is in the foreground. Increasing the activity counter and setting the foreground state."); activityCount_++; - config.deviceInfo.inForeground(); + deviceInfo_.inForeground(); } // Seed modules with the current activity if the app is already in the foreground. @@ -862,13 +1237,16 @@ public void onLowMemory() { Activity seedActivity = null; if (config.initialActivity != null && !config.initialActivity.isFinishing()) { seedActivity = config.initialActivity; - config.initialActivity = null; } else { Activity holderActivity = CountlyActivityHolder.getInstance().getActivity(); if (holderActivity != null && !holderActivity.isFinishing()) { seedActivity = holderActivity; } } + //cleared unconditionally, not just on the seeded path: a finishing activity left on the + //config would be pinned by it for as long as the config lives, and a later init (of this or + //another instance) must never re-seed a possibly destroyed activity + config.initialActivity = null; if (seedActivity != null) { L.d("[Countly] Seeding modules with initial activity: [" + seedActivity.getClass().getSimpleName() + "]"); @@ -883,7 +1261,13 @@ public void onLowMemory() { module.initFinished(config); } + //Record what the SDK ended up writing onto the config. A later init of this instance (or of + //another instance built from the same config) resets a value only if it still holds this, so the + //SDK's own write-backs are undone while anything the developer changed in between is honoured. + config.derivedFieldSnapshot.captureApplied(config); + L.i("[Init] Finished initialising SDK"); + } } else { //if this is not the first time we are calling init L.i("[Init] Getting in the 'else' block"); @@ -909,27 +1293,25 @@ boolean lifecycleStateAtLeastStartedInternal() { if (lifecycleStateOverrideForTests != null) { return lifecycleStateOverrideForTests; } - return ProcessLifecycleOwner.get().getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED); - } - - private void stopTimer() { - L.i("[Countly] stopTimer, Stopping global timer"); - if (timerService_ != null) { - try { - timerService_.shutdown(); - if (!timerService_.awaitTermination(1, TimeUnit.SECONDS)) { - timerService_.shutdownNow(); - if (!timerService_.awaitTermination(1, TimeUnit.SECONDS)) { - L.e("[Countly] stopTimer, Global timer must be locked"); - } - } - } catch (Throwable t) { - L.e("[Countly] stopTimer, Error while stopping global timer " + t); - } + //the dispatcher's started-activity count is exact when CountlyInitProvider registered it + //before the first activity - prefer it over ProcessLifecycleOwner, whose ~700ms + //stop-debounce keeps reporting "foreground" for a while after the app left it + CountlyLifecycleDispatcher dispatcher = CountlyLifecycleDispatcher.getInstance(); + if (dispatcher.hasExactActivityCount()) { + return dispatcher.getStartedActivityCount() > 0; } + return ProcessLifecycleOwner.get().getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED); } - void onSdkConfigurationChanged(@NonNull CountlyConfig config) { + //synchronized: unlike the lifecycle events, this is NOT routed through the dispatcher's tearingDown + //gate - the /o/sdk response lands via an async callback with no cancellation handle, so it can run + //concurrently with tearDown() on another thread. The null-guard below only covers the sequential + //case; without the instance monitor a teardown could shut the timer down between the guard and + //startTimerService (RejectedExecutionException on a shut-down executor), null moduleConfiguration + //under the dereferences below, or clear the modules list mid-iteration. Lock order stays + //instance -> config: the only synchronous caller (init) already holds the instance monitor. No + //wait-while-holding is introduced: tearDown only shuts the timer down, the drain is outside it. + synchronized void onSdkConfigurationChanged(@NonNull CountlyConfig config) { L.i("[Countly] onSdkConfigurationChanged"); if (config_ == null) { @@ -937,72 +1319,219 @@ void onSdkConfigurationChanged(@NonNull CountlyConfig config) { return; } - setLoggingEnabled(config.loggingEnabled); + //Nothing ever nulls config_, but tearDown nulls moduleConfiguration - and the resolved settings below + //are read off it, and it can land after removeInstance()/halt() and would otherwise dereference null + //and crash the host app. + if (moduleConfiguration == null) { + L.d("[Countly] onSdkConfigurationChanged, this instance was torn down before the response arrived, ignoring it"); + return; + } + + //Read the settings this instance resolved, not the config: the config may be shared with another + //instance and is no longer written to by the SDK. + setLoggingEnabled(moduleConfiguration.currentVLoggingEnabled); long timerDelay = TIMER_DELAY_IN_SECONDS; - if (config.sessionUpdateTimerDelay != null) { - timerDelay = config.sessionUpdateTimerDelay; + if (moduleConfiguration.currentVSessionUpdateTimerDelay != null) { + timerDelay = moduleConfiguration.currentVSessionUpdateTimerDelay; } startTimerService(timerService_, timerFuture, timerDelay); - config.maxRequestQueueSize = Math.max(config.maxRequestQueueSize, 1); - countlyStore.setLimits(config.maxRequestQueueSize); + moduleConfiguration.currentVMaxRequestQueueSize = Math.max(moduleConfiguration.currentVMaxRequestQueueSize, 1); + countlyStore.setLimits(moduleConfiguration.currentVMaxRequestQueueSize); - config.dropAgeHours = Math.max(config.dropAgeHours, 0); - if (config.dropAgeHours > 0) { - countlyStore.setRequestAgeLimit(config.dropAgeHours); + moduleConfiguration.currentVDropAgeHours = Math.max(moduleConfiguration.currentVDropAgeHours, 0); + if (moduleConfiguration.currentVDropAgeHours > 0) { + countlyStore.setRequestAgeLimit(moduleConfiguration.currentVDropAgeHours); } - config.eventQueueSizeThreshold = Math.max(config.eventQueueSizeThreshold, 1); - EVENT_QUEUE_SIZE_THRESHOLD = config.eventQueueSizeThreshold; - - // Have a look at the SDK limit values - if (config.sdkInternalLimits.maxKeyLength != null) { - config.sdkInternalLimits.maxKeyLength = Math.max(config.sdkInternalLimits.maxKeyLength, 1); + if (moduleConfiguration.currentVEventQueueSizeThreshold != null) { + moduleConfiguration.currentVEventQueueSizeThreshold = Math.max(moduleConfiguration.currentVEventQueueSizeThreshold, 1); + EVENT_QUEUE_SIZE_THRESHOLD = moduleConfiguration.currentVEventQueueSizeThreshold; } - if (config.sdkInternalLimits.maxValueSize != null) { - config.sdkInternalLimits.maxValueSize = Math.max(config.sdkInternalLimits.maxValueSize, 1); - } + // Have a look at the SDK limit values. These are this instance's own limits, which + // ModuleConfiguration has just written the server-resolved values into - the config object the + // developer handed us is never touched, so a config shared by two instances stays clean. + sdkInternalLimits_.clampToMinimums(); - if (config.sdkInternalLimits.maxSegmentationValues != null) { - config.sdkInternalLimits.maxSegmentationValues = Math.max(config.sdkInternalLimits.maxSegmentationValues, 1); + for (ModuleBase module : modules) { + module.onSdkConfigurationChanged(config); } + } + + /** + * Immediately disables session and event tracking and clears any stored session and event data. + * Testing Purposes Only! + * + * This will destroy all stored data, including the device ID and its generated-UUID cache, so the + * next init starts as a new user. Only an instance that was initialised in this process run has a + * store to clear - called before init, this resets the object but leaves earlier runs' files on disk. + */ + public void halt() { + //NOT synchronized: the quiesce below hops through the main looper, and the main thread may be inside + //a lifecycle dispatch at that moment. Holding this instance's monitor while waiting for that hop + //would deadlock until the timeout. tearDown is itself synchronized, so the critical section is still + //serialised - only the quiesce and the timer drain sit outside it, which is the whole point. + unsubscribeFromLifecycleBeforeTeardown(); + ScheduledExecutorService timerToDrain = tearDown(true); + awaitTimerServiceTermination(timerToDrain); + } + + /** + * Stops this instance without erasing anything it has stored. + *

+ * Session and event tracking stop, the timer and worker threads are released and the modules are torn + * down - but the request queue, event queue, device ID and consent state stay on disk. Initialising this + * instance again with {@code init(config)} resumes from where it left off, and anything recorded but not + * yet sent still gets sent. + *

+ * The instance stays registered, so {@link #instance(String)} keeps returning this same object. The two + * related calls do strictly more: {@link #halt()} is this plus erasing the stored data, and + * {@link #removeInstance(String)} is this plus deregistering the name. + *

+ * Stopping an instance that was never initialised only resets the object; files written by an earlier run + * are left alone. Call it from the main thread, and stop recording through this instance on other threads + * first, since everything the modules own goes away here. + */ + public void stop() { + //see halt() for why this is not synchronized + unsubscribeFromLifecycleBeforeTeardown(); + ScheduledExecutorService timerToDrain = tearDown(false); + awaitTimerServiceTermination(timerToDrain); + } + + /** + * Drops this instance's lifecycle subscription and waits until no lifecycle event can be in flight toward + * it. MUST run before {@link #tearDown} takes the instance monitor - see + * {@code CountlyLifecycleDispatcher#removeInstanceAndQuiesce}. + */ + private void unsubscribeFromLifecycleBeforeTeardown() { + tearingDown = true; + CountlyLifecycleDispatcher.getInstance().removeInstanceAndQuiesce(this, L); + } - if (config.sdkInternalLimits.maxBreadcrumbCount != null) { - config.sdkInternalLimits.maxBreadcrumbCount = Math.max(config.sdkInternalLimits.maxBreadcrumbCount, 1); + /** + * Drains an already-shut-down timer service, WITHOUT the instance monitor. That is what makes the wait + * safe: a tick blocked on the monitor can acquire it, see the torn-down state, no-op and let the await + * finish. Inside the monitor the same wait was a self-deadlock that burned its full timeout, because the + * tick it waited for could not start until teardown returned - and entering a synchronized block is not + * interruptible, so shutdownNow() could not break it either. + */ + private void awaitTimerServiceTermination(@Nullable ScheduledExecutorService service) { + if (service == null) { + return; } + try { + if (!service.awaitTermination(1, TimeUnit.SECONDS)) { + service.shutdownNow(); + if (!service.awaitTermination(1, TimeUnit.SECONDS)) { + L.e("[Countly] awaitTimerServiceTermination, the global timer must be locked"); + } + } + } catch (Throwable t) { + L.e("[Countly] awaitTimerServiceTermination, error while stopping the global timer " + t); + } + } - if (config.sdkInternalLimits.maxStackTraceLinesPerThread != null) { - config.sdkInternalLimits.maxStackTraceLinesPerThread = Math.max(config.sdkInternalLimits.maxStackTraceLinesPerThread, 1); + /** + * Gets everything that only exists in memory into the store, so a teardown that promises to keep this + * instance's data actually keeps it. Each step is guarded and wrapped: a teardown must complete even if + * one of these fails, otherwise the instance is left half torn down. + */ + private void flushInFlightStateBeforeTeardown() { + //Views FIRST. Stopping a view records its duration into the EVENT queue, and ending the session is + //what drains that queue into the request queue (ModuleSessions#endSessionInternal calls + //sendEventsIfNeeded). Ending the session first would leave every view-end event stranded in the event + //queue with nothing left to flush it. + try { + if (moduleViews != null) { + L.d("[Countly] tearDown, stopping open views so their durations are recorded"); + moduleViews.stopAllViewsInternal(null); + } + } catch (Throwable t) { + L.w("[Countly] tearDown, failed to stop the open views, [" + t + "]"); } - if (config.sdkInternalLimits.maxStackTraceLineLength != null) { - config.sdkInternalLimits.maxStackTraceLineLength = Math.max(config.sdkInternalLimits.maxStackTraceLineLength, 1); + + try { + if (moduleSessions != null && moduleSessions.sessionRunning) { + L.d("[Countly] tearDown, ending the open session so it is not left open"); + //the consent-checking variant, so a teardown never sends what the app did not agree to + moduleSessions.endSessionInternal(); + } + } catch (Throwable t) { + L.w("[Countly] tearDown, failed to end the open session, [" + t + "]"); } - for (ModuleBase module : modules) { - module.onSdkConfigurationChanged(config); + try { + //endSessionInternal already saves the profile when a session was running; this covers the case + //where none was. saveInternal is a no-op when there is nothing pending. + if (moduleUserProfile != null) { + L.d("[Countly] tearDown, saving pending user profile changes"); + moduleUserProfile.saveInternal(); + } + } catch (Throwable t) { + L.w("[Countly] tearDown, failed to save the pending user profile changes, [" + t + "]"); + } + + try { + //In explicit storage mode the request and event queues live only in memory until something asks + //for them to be persisted, and after this teardown nothing can: requestQueue() returns null once + //the instance is no longer initialised. This runs LAST so it captures everything the flush above + //queued. A request the in-flight processor acknowledges after this point stays in the persisted + //queue and is retried on the next init - a duplicate is better than a silent loss, and the server + //deduplicates on the request id. + if (countlyStore != null && config_ != null && config_.explicitStorageModeEnabled) { + L.d("[Countly] tearDown, writing the explicit-storage-mode caches to persistence"); + countlyStore.esWriteCacheToStorage(null); + } + } catch (Throwable t) { + L.w("[Countly] tearDown, failed to persist the explicit storage mode caches, [" + t + "]"); } } /** - * Immediately disables session and event tracking and clears any stored session and event data. - * Testing Purposes Only! - * - * This will destroy all stored data + * @param clearStoredData whether to also erase this instance's persisted data. The teardown itself is + * identical either way; only {@link #halt()} destroys data. */ - public synchronized void halt() { - L.i("Halting Countly!"); + private synchronized ScheduledExecutorService tearDown(boolean clearStoredData) { + //Lifecycle events were already stopped AND quiesced by the caller, outside this monitor, so by now no + //dispatch can be in flight toward this instance. The flag is set there too; it stays as the backstop + //for what quiesce cannot cover - a late registration (provider stripped from the manifest) and a + //wedged main looper that never ran the drain hop. + tearingDown = true; + + L.i("Halting Countly!" + (clearStoredData ? " Stored data will be cleared." : " Stored data is kept.")); + + //When the data is being kept (removeInstance), flush what is still only in memory BEFORE anything is + //torn down: the module halts below only clear flags, so an open session would never get its + //end_session, open views would lose their duration, and pending profile edits would be dropped - + //and a later init of this instance would then send a second begin_session with no end in between. + //Skipped for halt(), which is about to erase the store anyway. + if (!clearStoredData) { + flushInFlightStateBeforeTeardown(); + } + sdkIsInitialised = false; L.SetListener(null); - stopTimer(); + + //shut the timer down without waiting; the drain happens in the caller's epilogue, after this monitor + //is released. A tick that already started blocks here, then no-ops - onTimer rechecks isInitialized(). + ScheduledExecutorService timerToDrain = timerService_; + if (timerToDrain != null) { + L.i("[Countly] tearDown, stopping the global timer"); + timerToDrain.shutdown(); + } if (connectionQueue_ != null) { - if (countlyStore != null) { + if (clearStoredData && countlyStore != null) { countlyStore.clear(); } connectionQueue_.setContext(null); + //init builds a fresh ConnectionQueue, so release this one's worker threads instead of + //stranding them; they are non-daemon and would otherwise outlive every halt/init cycle. + connectionQueue_.shutdownExecutors(); connectionQueue_ = null; } @@ -1013,6 +1542,15 @@ public synchronized void halt() { } modules.clear(); + //A dispatch that passed the tearingDown gate a moment before this method set it can still be running + //on the main thread while these fields go null, and modules reach each other through _cly. That is + //what crashed a CI run (ModuleSessions.endSessionInternal -> _cly.moduleViews.resetFirstView()). + //Rather than lock - teardown cannot wait for in-flight dispatches while holding this monitor, because + //onConfigurationChangedInternal is synchronized on the same instance and would deadlock against a + //main thread already blocked on it - every cross-module read now snapshots the sibling into a local + //and checks it, so nulling below cannot produce an NPE. If a new one is added, snapshot it too: + //`if (_cly.moduleX != null) { _cly.moduleX.y(); }` is NOT enough, the field can go null between the + //check and the use. moduleCrash = null; moduleViews = null; moduleEvents = null; @@ -1038,6 +1576,8 @@ public synchronized void halt() { connectionQueue_ = new ConnectionQueue(); timerService_ = Executors.newSingleThreadScheduledExecutor(); + + return timerToDrain; } synchronized void notifyDeviceIdChange(boolean withoutMerge) { @@ -1048,6 +1588,117 @@ synchronized void notifyDeviceIdChange(boolean withoutMerge) { } } + /** + * Lifecycle dispatch entry points, called by {@link CountlyLifecycleDispatcher} on the main thread. + *

+ * Each one gates on {@code tearingDown} and on the SDK being initialised, so an event that arrives while + * this instance is being destroyed is dropped rather than reaching half-nulled state. That is the whole + * point of the gate: the alternative was an NPE escaping {@code Activity.onStop}, which Android turns + * into a host-app crash. + */ + //No module consumes these four callbacks - the module loops were already commented out before the + //dispatcher existed. They are kept as log-only so the SDK's log output, which support reads off + //customer devices, is byte-identical to what the per-instance callbacks produced. + void dispatchActivityCreated(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityCreated, " + activity.getClass().getSimpleName()); + } + } + + void dispatchActivityPaused(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityPaused, " + activity.getClass().getSimpleName()); + } + } + + void dispatchActivitySaveInstanceState(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivitySaveInstanceState, " + activity.getClass().getSimpleName()); + } + } + + void dispatchLowMemory() { + if (tearingDown || !sdkIsInitialised) { + return; + } + L.d("[Countly] ComponentCallbacks, onLowMemory"); + } + + void dispatchActivityStarted(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityStarted, " + activity.getClass().getSimpleName()); + } + onStartInternal(activity); + } + + void dispatchActivityResumed(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityResumed, " + activity.getClass().getSimpleName()); + } + //Hardcoded, in the order init adds the modules to `modules` - see the note on + //ModuleBase#callbackOnActivityResumed + if (moduleRatings != null) { + moduleRatings.callbackOnActivityResumed(activity); + } + if (moduleAPM != null) { + moduleAPM.callbackOnActivityResumed(activity); + } + } + + void dispatchActivityStopped(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityStopped, " + activity.getClass().getSimpleName()); + } + onStopInternal(); + //hardcoded on purpose - see the note on ModuleBase#callbackOnActivityStopped + if (moduleAPM != null) { + moduleAPM.callbackOnActivityStopped(activity); + } + } + + void dispatchActivityDestroyed(@NonNull Activity activity) { + if (tearingDown || !sdkIsInitialised) { + return; + } + if (L.logEnabled()) { + L.d("[Countly] onActivityDestroyed, " + activity.getClass().getSimpleName()); + } + //Hardcoded, in the order init adds the modules to `modules` - see the note on + //ModuleBase#onActivityDestroyed + if (moduleFeedback != null) { + moduleFeedback.onActivityDestroyed(activity); + } + if (moduleContent != null) { + moduleContent.onActivityDestroyed(activity); + } + } + + void dispatchConfigurationChanged(@NonNull Configuration configuration) { + if (tearingDown || !sdkIsInitialised) { + return; + } + L.d("[Countly] ComponentCallbacks, onConfigurationChanged"); + onConfigurationChangedInternal(configuration); + } + void onStartInternal(Activity activity) { if (L.logEnabled()) { String activityName = "NULL ACTIVITY PROVIDED"; @@ -1071,10 +1722,22 @@ void onStartInternal(Activity activity) { } } - config_.deviceInfo.inForeground(); + deviceInfo_.inForeground(); - for (ModuleBase module : modules) { - module.onActivityStarted(activity, activityCount_); + //Hardcoded rather than iterating the mutable modules list: teardown clears that list from another + //thread, which used to mean a ConcurrentModificationException or an NPE on the main thread. + //Adding a module that overrides this hook means wiring it in here - see ModuleBase#onActivityStarted. + if (moduleViews != null) { + moduleViews.onActivityStarted(activity, activityCount_); + } + if (moduleAPM != null) { + moduleAPM.onActivityStarted(activity, activityCount_); + } + if (moduleFeedback != null) { + moduleFeedback.onActivityStarted(activity, activityCount_); + } + if (moduleContent != null) { + moduleContent.onActivityStarted(activity, activityCount_); } calledAtLeastOnceOnStart = true; @@ -1096,18 +1759,29 @@ void onStopInternal() { moduleSessions.endSessionInternal(); } - config_.deviceInfo.inBackground(); + deviceInfo_.inBackground(); - for (ModuleBase module : modules) { - module.onActivityStopped(activityCount_); + //Hardcoded - see ModuleBase#onActivityStopped + if (moduleViews != null) { + moduleViews.onActivityStopped(activityCount_); + } + if (moduleFeedback != null) { + moduleFeedback.onActivityStopped(activityCount_); + } + if (moduleContent != null) { + moduleContent.onActivityStopped(activityCount_); + } + if (moduleHealthCheck != null) { + moduleHealthCheck.onActivityStopped(activityCount_); } } public synchronized void onConfigurationChangedInternal(Configuration newConfig) { L.i("Calling [onConfigurationChangedInternal]"); - for (ModuleBase module : modules) { - module.onConfigurationChanged(newConfig); + //Hardcoded - see ModuleBase#onConfigurationChanged + if (moduleViews != null) { + moduleViews.onConfigurationChanged(newConfig); } } @@ -1198,13 +1872,22 @@ synchronized void onTimer() { * DON'T USE THIS!!!! */ public void onRegistrationId(String registrationId, CountlyMessagingProvider provider) { - //if this call is done by CountlyPush, it is assumed that the SDK is already initialised - if (!config_.consentProvider.getConsent(CountlyFeatureNames.push)) { + //CountlyPush assumes the SDK is already initialised, but it is driven by an OS callback that can + //arrive at any time - so check instead of dereferencing a config that may not exist yet. + //Locals, not the fields: this runs on the push provider's thread, and a concurrent + //halt()/removeInstance() nulls these fields between any check here and their use below - the + //locals make the check-then-use atomic without taking the instance monitor on an OS callback. + ModuleConsent consent = moduleConsent; + ConnectionQueue queue = connectionQueue_; + if (!isInitialized() || consent == null || queue == null) { + L.w("[onRegistrationId] Calling this before the SDK is initialized."); return; } - if (!isInitialized()) { - L.w("[onRegistrationId] Calling this before the SDK is initialized."); + //read consent off this instance's own module, never off the config object (which a developer may + //have handed to another instance) + if (!consent.getConsent(CountlyFeatureNames.push)) { + return; } //debouncing the call @@ -1224,16 +1907,19 @@ public void onRegistrationId(String registrationId, CountlyMessagingProvider pro lastRegistrationCallID = registrationId; lastRegistrationCallProvider = provider; - connectionQueue_.tokenSession(registrationId, provider); + queue.tokenSession(registrationId, provider); } 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 82ae069fe..9b62ef33c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.app.Application; import android.content.Context; +import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -31,6 +32,11 @@ public class CountlyConfig { protected RequestQueueProvider requestQueueProvider = null; + // Storage namespace of the instance that last init()ed with this config; null until one does. + String initialisedForNamespace = null; + // What these fields held before any init() touched them. See DerivedFieldSnapshot. + DerivedFieldSnapshot derivedFieldSnapshot = null; + protected DeviceIdProvider deviceIdProvider = null; protected ViewIdProvider viewIdProvider = null; @@ -520,6 +526,11 @@ public synchronized CountlyConfig setAutoTrackingExceptions(Class[] exceptions) /** * Allows you to add custom header key/value pairs to each request + *

+ * The SDK copies these entries when it initialises, so changing the map you passed in afterwards has + * no effect on the requests it sends. To change a header while the SDK is running - rotating an + * authorization token, for example - call + * {@code Countly.sharedInstance().requestQueue().addCustomNetworkRequestHeaders(Map)}. * * @return Returns the same config object for convenient linking */ @@ -1291,4 +1302,156 @@ public synchronized CountlyConfig disableViewRestartForManualRecording() { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public final ConfigExperimental experimental = new ConfigExperimental(); + + /** + * What a config's derived fields held before any {@code init()} touched them. + *

+ * A CountlyConfig is meant to be used by exactly one Countly instance, but nothing stops a developer + * from passing one config to two instances - and {@code init()} plus the module constructors write + * their results back onto the config (the store, the queues, every {@code *Provider} back-reference, + * the DeviceInfo, and the temporary-device-id sentinel). Reusing such a config would silently hand + * the next instance the previous instance's objects: its store and request queue, its consent and + * device-id providers, even its app key and server URL through {@code baseInfoProvider}. + *

+ * So the first {@code init()} to use a config snapshots these fields, and every later {@code init()} + * restores the snapshot before it starts. Each init then sees exactly what the developer configured, + * whether it is a second instance or the same instance re-initialised after {@code halt()}. + *

+ * The same applies to the values the server behaviour settings resolve to: {@code ModuleConfiguration} + * writes those back onto the config from its own constructor, reading the STORED settings, so no + * server round trip is needed for one instance's settings to become the next instance's configuration. + * They are snapshotted as well, because inheriting a stored "consent not required" would silently + * switch consent gating off for an instance whose developer required it. + *

+ * Deliberately NOT snapshotted: {@link CountlyConfig#initialActivity} (init clears it on purpose - a + * later init must not re-seed a possibly destroyed activity) and the idempotent value normalisations + * (server-URL trailing slash and the queue-size clamps), which yield the same result when re-applied. + *

+ * Note the residual limitation: {@code init()} also aliases the config into the instance's + * {@code config_}, so two instances handed one config keep reading the same object after init. + * Restoring at init fixes what an instance STARTS with, not later cross-writes. One config per + * instance remains the rule, which is why init warns loudly when it sees reuse. + */ + static final class DerivedFieldSnapshot { + private final CountlyStore countlyStore; + private final StorageProvider storageProvider; + private final EventQueueProvider eventQueueProvider; + private final RequestQueueProvider requestQueueProvider; + private final EventProvider eventProvider; + private final ConsentProvider consentProvider; + private final DeviceIdProvider deviceIdProvider; + private final BaseInfoProvider baseInfoProvider; + private final ViewIdProvider viewIdProvider; + private final ConfigurationProvider configProvider; + private final HealthTracker healthTracker; + private final DeviceInfo deviceInfo; + private final ImmediateRequestGenerator immediateRequestGenerator; + private final Countly.LifecycleObserver lifecycleObserver; + private final SafeIDGenerator safeViewIDGenerator; + private final SafeIDGenerator safeEventIDGenerator; + //What the developer had set on the config before any init touched it, and what the SDK left on it at + //the end of the last init. A value is only reset when it still holds what the SDK left - see + //restoreValuesOnto. The values themselves are the ones the SDK writes back: the temporary-device-id + //sentinel and the settings the server behaviour settings resolve. + private Object[] originalValues; + private Object[] appliedValues; + + DerivedFieldSnapshot(@NonNull CountlyConfig config) { + countlyStore = config.countlyStore; + storageProvider = config.storageProvider; + eventQueueProvider = config.eventQueueProvider; + requestQueueProvider = config.requestQueueProvider; + eventProvider = config.eventProvider; + consentProvider = config.consentProvider; + deviceIdProvider = config.deviceIdProvider; + baseInfoProvider = config.baseInfoProvider; + viewIdProvider = config.viewIdProvider; + configProvider = config.configProvider; + healthTracker = config.healthTracker; + deviceInfo = config.deviceInfo; + immediateRequestGenerator = config.immediateRequestGenerator; + lifecycleObserver = config.lifecycleObserver; + safeViewIDGenerator = config.safeViewIDGenerator; + safeEventIDGenerator = config.safeEventIDGenerator; + originalValues = readValues(config); + } + + /** + * The values the SDK itself writes back onto the config, in a fixed order. Held as an array rather + * than as three parallel copies of every field, so that "what the developer set", "what the SDK last + * left here" and "what is here now" can be compared position by position. + *

+ * ADDING A VALUE THE SDK WRITES ONTO THE CONFIG MEANS ADDING IT TO BOTH readValues AND writeValue. + */ + private static Object[] readValues(@NonNull CountlyConfig config) { + //Only deviceID: ModuleDeviceId writes the temporary-device-id sentinel onto the config, and that + //is now the ONLY value the SDK writes back. The settings the server behaviour settings resolve + //used to be here too; they are resolved per instance in ModuleConfiguration instead, so nothing + //has to be undone for them and a shared config is never mutated by the SDK. + return new Object[] { + config.deviceID, + }; + } + + private static void writeValue(@NonNull CountlyConfig config, int index, Object value) { + switch (index) { + case 0: config.deviceID = (String) value; break; + default: break; + } + } + + /** + * Records what the config holds now, at the end of a successful init, as "what the SDK left here". + * A later init restores a value only when it is still untouched since this point - so the SDK's own + * write-backs (the temporary-device-id sentinel, the server-resolved settings) are undone, while a + * value the developer deliberately changed between the two inits is honoured. + */ + void captureApplied(@NonNull CountlyConfig config) { + appliedValues = readValues(config); + } + + private void restoreValuesOnto(@NonNull CountlyConfig config) { + if (appliedValues == null) { + //no init has completed with this config yet, so nothing has been written back to undo + return; + } + Object[] current = readValues(config); + for (int i = 0; i < current.length; i++) { + if (equal(current[i], appliedValues[i])) { + //untouched since the SDK wrote it, so undo the SDK's write + writeValue(config, i, originalValues[i]); + } else { + //the developer changed it between inits: honour it AND adopt it as the new baseline. + //Leaving the baseline frozen at the first init would revert this value on a LATER init, + //once the SDK had written the developer's own value back and current == applied again. + originalValues[i] = current[i]; + } + } + } + + private static boolean equal(Object a, Object b) { + return a == null ? b == null : a.equals(b); + } + + void restoreOnto(@NonNull CountlyConfig config) { + restoreValuesOnto(config); + config.countlyStore = countlyStore; + config.storageProvider = storageProvider; + config.eventQueueProvider = eventQueueProvider; + config.requestQueueProvider = requestQueueProvider; + config.eventProvider = eventProvider; + config.consentProvider = consentProvider; + config.deviceIdProvider = deviceIdProvider; + config.baseInfoProvider = baseInfoProvider; + config.viewIdProvider = viewIdProvider; + config.configProvider = configProvider; + config.healthTracker = healthTracker; + config.deviceInfo = deviceInfo; + config.immediateRequestGenerator = immediateRequestGenerator; + config.lifecycleObserver = lifecycleObserver; + config.safeViewIDGenerator = safeViewIDGenerator; + config.safeEventIDGenerator = safeEventIDGenerator; + } + + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyInitProvider.java b/sdk/src/main/java/ly/count/android/sdk/CountlyInitProvider.java index 099d1b632..39c23b1ad 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyInitProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyInitProvider.java @@ -1,13 +1,11 @@ package ly.count.android.sdk; -import android.app.Activity; import android.app.Application; import android.content.ContentProvider; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -31,41 +29,13 @@ public boolean onCreate() { } Context appContext = context.getApplicationContext(); - if (appContext instanceof Application) { - ((Application) appContext).registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { - @Override - public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - CountlyActivityHolder.getInstance().setActivity(activity); - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - CountlyActivityHolder.getInstance().setActivity(activity); - } - - @Override - public void onActivityResumed(@NonNull Activity activity) { - CountlyActivityHolder.getInstance().setActivity(activity); - } - - @Override - public void onActivityPaused(@NonNull Activity activity) { - } - - @Override - public void onActivityStopped(@NonNull Activity activity) { - } - - @Override - public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { - } - - @Override - public void onActivityDestroyed(@NonNull Activity activity) { - CountlyActivityHolder.getInstance().clearActivity(activity); - } - }); - } + //One registration for the whole process. The dispatcher also feeds CountlyActivityHolder, so the + //behaviour this provider shipped for (capturing the current Activity before Application.onCreate, + //which single-activity frameworks depend on) is unchanged - it just no longer needs its own + //callbacks object, and every Countly instance now shares this one registration. + //fromProvider: content providers run before Application.onCreate, so no activity can have + //started yet - which is what makes the dispatcher's started-activity count exact + CountlyLifecycleDispatcher.getInstance().register(appContext, true); return false; } diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyLifecycleDispatcher.java b/sdk/src/main/java/ly/count/android/sdk/CountlyLifecycleDispatcher.java new file mode 100644 index 000000000..261353c80 --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyLifecycleDispatcher.java @@ -0,0 +1,275 @@ +package ly.count.android.sdk; + +import android.app.Activity; +import android.app.Application; +import android.content.ComponentCallbacks; +import android.content.Context; +import android.content.res.Configuration; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * The single process-wide bridge between Android's lifecycle callbacks and the SDK's instances. + *

+ * Android delivers {@link Application.ActivityLifecycleCallbacks} on the main thread, while + * {@code halt()} / {@code removeInstance()} can be called from any thread the app chooses. Before this + * class each Countly instance registered its own callbacks and dispatched by iterating its mutable + * {@code modules} list, which gave two race outcomes against a concurrent teardown: a + * {@code ConcurrentModificationException} while the list was being cleared, or a + * {@code NullPointerException} once the module fields had been nulled. The second one is not theoretical - + * it killed a CI run at test 108 of 1023 with + * {@code ModuleViews.resetFirstView() on a null object reference} thrown out of {@code Activity.onStop}, + * which Android turns into a host-app crash. + *

+ * Two properties fix that, and neither needs a lock on the dispatch path: + *

    + *
  1. ONE registration for the whole process, holding instances in a {@link CopyOnWriteArrayList}. + * Iteration is over an immutable snapshot, so a teardown removing an instance mid-dispatch can never + * provoke a {@code ConcurrentModificationException}, and N instances no longer mean N registrations + * against the {@code Application}.
  2. + *
  3. Teardown deregisters as its FIRST act, before it nulls anything. An event already in flight is + * stopped by the instance's own {@code tearingDown} gate; every later event never reaches it.
  4. + *
+ * Registration happens from {@link CountlyInitProvider} before {@code Application.onCreate}, and again + * (idempotently) from {@code Countly.init} so an app that removed the provider from its manifest still + * gets lifecycle events. + */ +class CountlyLifecycleDispatcher implements Application.ActivityLifecycleCallbacks, ComponentCallbacks { + + private static final CountlyLifecycleDispatcher instance = new CountlyLifecycleDispatcher(); + + // Copy-on-write: the main thread iterates this while any thread may be tearing an instance down. + private final CopyOnWriteArrayList instances = new CopyOnWriteArrayList<>(); + + // Guards the one-time registration. Volatile because init can run on any thread while the provider + // has already registered on the main thread. + private volatile boolean registered = false; + + // Makes {count mutation + instance-list capture} atomic per started/stopped event, and + // {addInstance + count snapshot} atomic against it. Held for nanoseconds (an int and a list + // reference) - dispatch into instances always happens OUTSIDE this lock, so the main thread never + // meaningfully blocks here. + private final Object stateLock = new Object(); + + private int startedActivityCount = 0; + + // True only when register() ran from CountlyInitProvider, i.e. before any activity could have + // started. Only then is startedActivityCount the exact process-wide truth; a late registration + // (provider stripped from the manifest, first registration at init time) has missed events and + // callers must fall back to the ProcessLifecycleOwner heuristic. + private volatile boolean countExactSinceProcessStart = false; + + private CountlyLifecycleDispatcher() { + } + + static CountlyLifecycleDispatcher getInstance() { + return instance; + } + + /** + * Registers this dispatcher against the Application exactly once per process. Safe to call repeatedly + * and from any thread: the second and later calls do nothing. + */ + void register(@Nullable Context context) { + register(context, false); + } + + /** + * @param fromProvider true when called by {@link CountlyInitProvider} (before any activity can + * exist), which is what makes the started-activity count exact from process start + */ + void register(@Nullable Context context, boolean fromProvider) { + if (registered || context == null) { + return; + } + Context appContext = context.getApplicationContext(); + if (!(appContext instanceof Application)) { + return; + } + synchronized (instance) { + if (registered) { + return; + } + Application application = (Application) appContext; + application.registerActivityLifecycleCallbacks(this); + application.registerComponentCallbacks(this); + countExactSinceProcessStart = fromProvider; + registered = true; + } + } + + boolean isRegistered() { + return registered; + } + + /** + * Adds the instance and returns the started-activity count it should seed itself with, taken + * atomically with joining: the instance will receive exactly the events after this snapshot, + * never one that is also included in it. + * + * @return the exact number of currently started activities, or -1 when the dispatcher was not + * registered before the first activity and the count is therefore not trustworthy + */ + int addInstance(@NonNull Countly countly) { + synchronized (stateLock) { + if (!instances.contains(countly)) { + instances.add(countly); + } + return countExactSinceProcessStart ? startedActivityCount : -1; + } + } + + /** True when {@link #register} ran before any activity could have started (provider path). */ + boolean hasExactActivityCount() { + return countExactSinceProcessStart; + } + + /** The number of currently started activities; only meaningful when {@link #hasExactActivityCount()}. */ + int getStartedActivityCount() { + synchronized (stateLock) { + return startedActivityCount; + } + } + + // Test support only: instrumented tests share one process and one dispatcher, so a test that + // simulates lifecycle events must start from a known state without depending on every other test + // having halted its instances. + void resetForTests() { + synchronized (stateLock) { + instances.clear(); + startedActivityCount = 0; + } + } + + /** + * Stops delivering lifecycle events to this instance. Teardown calls this before it nulls anything, so + * that the window in which an event can reach a half-destroyed instance is closed rather than guarded. + */ + /** + * Removes the instance and does not return until no lifecycle event can still be in flight toward it, so + * its owner may tear state down without racing the main thread. This is what turns the teardown race from + * "guarded" into "closed": {@code tearingDown} and the per-dereference null checks stop the crash, but + * only this stops the event from arriving at all. + *

+ * Dispatch runs on the main thread, so the guarantee is "the main thread has moved past any dispatch that + * could still see this instance". Called ON the main thread that is already true - we are the dispatch + * thread. From any other thread it costs one posted no-op to the main looper: by the time it runs, any + * dispatch that captured the instance before its removal has finished, because both run on that thread. + *

+ * The caller MUST NOT hold a lock the main thread might want - the teardown path calls this before it + * takes the instance monitor for exactly that reason, since the main thread could be blocked on that + * monitor inside a dispatch and the two would deadlock until the timeout. If the looper cannot run the + * hop within a second (wedged main thread) this gives up: the instance is already removed, and the + * per-dispatch gate still stops a straggler. + */ + void removeInstanceAndQuiesce(@NonNull Countly countly, @NonNull ModuleLog L) { + instances.remove(countly); + + Looper mainLooper = Looper.getMainLooper(); + if (mainLooper == null || Looper.myLooper() == mainLooper) { + return; + } + + final CountDownLatch drained = new CountDownLatch(1); + if (!new Handler(mainLooper).post(new Runnable() { + @Override public void run() { + drained.countDown(); + } + })) { + L.w("[CountlyLifecycleDispatcher] removeInstanceAndQuiesce, the main looper refused the drain hop, proceeding with teardown"); + return; + } + try { + if (!drained.await(1, TimeUnit.SECONDS)) { + L.w("[CountlyLifecycleDispatcher] removeInstanceAndQuiesce, the main thread did not drain within 1s, proceeding with teardown"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + L.w("[CountlyLifecycleDispatcher] removeInstanceAndQuiesce, interrupted while waiting for the main thread to drain"); + } + } + + void removeInstance(@NonNull Countly countly) { + instances.remove(countly); + } + + @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + CountlyActivityHolder.getInstance().setActivity(activity); + for (Countly countly : instances) { + countly.dispatchActivityCreated(activity); + } + } + + @Override public void onActivityStarted(@NonNull Activity activity) { + CountlyActivityHolder.getInstance().setActivity(activity); + //count and list snapshot move together: an instance added concurrently either is in the + //snapshot and gets this event (its addInstance() count predates the increment) or is not and + //has the event in its seeded count - never both, never neither + Object[] toNotify; + synchronized (stateLock) { + startedActivityCount++; + toNotify = instances.toArray(); + } + for (Object countly : toNotify) { + ((Countly) countly).dispatchActivityStarted(activity); + } + } + + @Override public void onActivityResumed(@NonNull Activity activity) { + CountlyActivityHolder.getInstance().setActivity(activity); + for (Countly countly : instances) { + countly.dispatchActivityResumed(activity); + } + } + + @Override public void onActivityPaused(@NonNull Activity activity) { + for (Countly countly : instances) { + countly.dispatchActivityPaused(activity); + } + } + + @Override public void onActivityStopped(@NonNull Activity activity) { + //see onActivityStarted for the count/snapshot pairing + Object[] toNotify; + synchronized (stateLock) { + if (startedActivityCount > 0) { + startedActivityCount--; + } + toNotify = instances.toArray(); + } + for (Object countly : toNotify) { + ((Countly) countly).dispatchActivityStopped(activity); + } + } + + @Override public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { + for (Countly countly : instances) { + countly.dispatchActivitySaveInstanceState(activity); + } + } + + @Override public void onActivityDestroyed(@NonNull Activity activity) { + CountlyActivityHolder.getInstance().clearActivity(activity); + for (Countly countly : instances) { + countly.dispatchActivityDestroyed(activity); + } + } + + @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { + for (Countly countly : instances) { + countly.dispatchConfigurationChanged(newConfig); + } + } + + @Override public void onLowMemory() { + for (Countly countly : instances) { + countly.dispatchLowMemory(); + } + } +} 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..2768789c0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java @@ -34,6 +34,7 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -75,6 +76,29 @@ public class CountlyStore implements StorageProvider, EventQueueProvider { private final SharedPreferences preferences_; private final SharedPreferences preferencesPush_; + //this instance's namespaced generated-UUID cache; only clear() touches it here, reads/writes + //happen in ModuleDeviceId#getUUID + private final SharedPreferences preferencesOpenUdid_; + // 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; + + // One lock object per request-queue backing file, shared by every CountlyStore ever opened over + // that file. The store's own methods are synchronized on the store INSTANCE, which suffices while a + // namespace has exactly one live store - but removeInstance() keeps the data and documents that the + // name is immediately reusable, while the removed instance's ConnectionProcessor may still be + // draining the kept queue on its non-awaited executor. That drain and the successor's store are two + // different objects over one file: without a common monitor their read-modify-writes of the joined + // queue string lose each other's updates (a request silently dropped, or an acknowledged one + // resurrected and sent twice). Entries are never removed - one small Object per namespace used in + // the process lifetime, same order of magnitude as the instance registry itself. + private static final ConcurrentHashMap requestQueueFileLocks = new ConcurrentHashMap<>(); + + // This store's entry of requestQueueFileLocks. Always taken INSIDE the instance monitor (the + // synchronized methods below), never the other way around, so the lock order instance -> file lock + // is process-wide consistent and cannot deadlock. + private final Object requestQueueLock; private static final String CONSENT_GCM_PREFERENCES = "ly.count.android.api.messaging.consent.gcm"; @@ -109,15 +133,89 @@ 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()); + String prefsFileName = namespacedName(PREFERENCES, storageNamespace); + preferences_ = context.getSharedPreferences(prefsFileName, Context.MODE_PRIVATE); + //not computeIfAbsent: that is API 24 and this SDK is minSdk 21 without core library desugaring + //(same constraint as Countly.instance()). putIfAbsent is on ConcurrentMap since API 9. + Object lock = requestQueueFileLocks.get(prefsFileName); + if (lock == null) { + requestQueueFileLocks.putIfAbsent(prefsFileName, new Object()); + lock = requestQueueFileLocks.get(prefsFileName); + } + requestQueueLock = lock; + // 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); + // This instance's generated-UUID cache (see ModuleDeviceId#getUUID), opened here so clear() + // can wipe it without retaining the caller's Context. + preferencesOpenUdid_ = context.getSharedPreferences(namespacedName(ModuleDeviceId.PREFS_NAME, storageNamespace), Context.MODE_PRIVATE); 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; + } + + // Longest sanitized prefix kept before the hash suffix. The namespace ends up inside a + // SharedPreferences file name ("COUNTLY_STORE_.xml"), and file names are capped at 255 bytes on + // Android's filesystems. Past that limit SharedPreferences does not throw, it silently stops + // persisting - so a long instance name would look like it works while losing every write. The hash + // suffix still keeps truncated names distinct from each other. + static final int MAX_NAMESPACE_PREFIX_LENGTH = 100; + + /** + * 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. + * The readable part is capped at {@link #MAX_NAMESPACE_PREFIX_LENGTH} characters; the hash is + * always computed over the full name, so two long names sharing a prefix still get distinct files. + */ + static String sanitizeNamespace(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + int prefixLength = Math.min(name.length(), MAX_NAMESPACE_PREFIX_LENGTH); + StringBuilder sb = new StringBuilder(prefixLength + 9); + for (int i = 0; i < prefixLength; 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; } @@ -361,7 +459,7 @@ public synchronized List getEventList() { final List events = new ArrayList<>(array.length); for (String s : array) { try { - final Event event = Event.fromJSON(new JSONObject(s)); + final Event event = Event.fromJSON(new JSONObject(s), L); if (event != null) { events.add(event); } @@ -419,7 +517,7 @@ public synchronized String getEventsForRequestAndEmptyEventQueue() { final JSONArray eventArray = new JSONArray();//todo: possibly transform to json array by hand for (Event e : events) { - eventArray.put(e.toJSON()); + eventArray.put(e.toJSON(L)); } String result = eventArray.toString(); @@ -465,18 +563,22 @@ public synchronized void addRequest(@NonNull final String requestStr, final bool return; } - List requests = new ArrayList<>(Arrays.asList(getRequests())); + //the read-modify-write below must be atomic against every other store over the same file, + //not just against this store's own methods - see requestQueueLock + synchronized (requestQueueLock) { + List requests = new ArrayList<>(Arrays.asList(getRequests())); - L.v("[CountlyStore] addRequest, s:[" + writeInSync + "] new q size:[" + (requests.size() + 1) + "] r:[" + requestStr + "]"); - if (requests.size() >= maxRequestQueueSize) { - checkAndRemoveTooOldRequests(requests); // remove too old requests - if (requests.size() >= maxRequestQueueSize) { // remove oldest if nothing is too old - deleteOldestRequests(requests); + L.v("[CountlyStore] addRequest, s:[" + writeInSync + "] new q size:[" + (requests.size() + 1) + "] r:[" + requestStr + "]"); + if (requests.size() >= maxRequestQueueSize) { + checkAndRemoveTooOldRequests(requests); // remove too old requests + if (requests.size() >= maxRequestQueueSize) { // remove oldest if nothing is too old + deleteOldestRequests(requests); + } } - } - requests.add(requestStr); - storageWriteRequestQueue(Utils.joinCountlyStore(requests, DELIMITER), writeInSync); + requests.add(requestStr); + storageWriteRequestQueue(Utils.joinCountlyStore(requests, DELIMITER), writeInSync); + } if (pcc != null) { pcc.TrackCounterTimeNs("CountlyStore_addRequest", UtilsTime.getNanoTime() - tsStart); @@ -527,12 +629,15 @@ synchronized void deleteOldestRequest_reworked() { tsStart = UtilsTime.getNanoTime(); } - //todo rework to not need an array and joining by removing the first substring until the delimiter - String[] requests = getRequests(); + //atomic against other stores over the same file - see requestQueueLock + synchronized (requestQueueLock) { + //todo rework to not need an array and joining by removing the first substring until the delimiter + String[] requests = getRequests(); - L.i("[CountlyStore] deleteOldestRequest, Will remove the oldest request"); + L.i("[CountlyStore] deleteOldestRequest, Will remove the oldest request"); - storageWriteRequestQueue(Utils.joinCountlyStoreArray_reworked(requests, DELIMITER, 1), false); + storageWriteRequestQueue(Utils.joinCountlyStoreArray_reworked(requests, DELIMITER, 1), false); + } if (pcc != null) { pcc.TrackCounterTimeNs("CountlyStore_deleteOldestRequest", UtilsTime.getNanoTime() - tsStart); @@ -601,9 +706,12 @@ public synchronized void removeRequest(final String requestStr) { } if (requestStr != null && requestStr.length() > 0) { - final List requests = new ArrayList<>(Arrays.asList(getRequests())); - if (requests.remove(requestStr)) { - storageWriteRequestQueue(Utils.joinCountlyStore(requests, DELIMITER), false); + //atomic against other stores over the same file - see requestQueueLock + synchronized (requestQueueLock) { + final List requests = new ArrayList<>(Arrays.asList(getRequests())); + if (requests.remove(requestStr)) { + storageWriteRequestQueue(Utils.joinCountlyStore(requests, DELIMITER), false); + } } } @@ -635,7 +743,10 @@ public synchronized void replaceRequests_reworked(@NonNull final String[] newReq } if (newRequests != null) { - storageWriteRequestQueue(Utils.joinCountlyStoreArray_reworked(newRequests, DELIMITER), false); + //atomic against other stores over the same file - see requestQueueLock + synchronized (requestQueueLock) { + storageWriteRequestQueue(Utils.joinCountlyStoreArray_reworked(newRequests, DELIMITER), false); + } } if (pcc != null) { @@ -650,7 +761,10 @@ public synchronized void replaceRequestList(@NonNull final List newReque } if (newRequests != null) { - storageWriteRequestQueue(Utils.joinCountlyStore(newRequests, DELIMITER), false); + //atomic against other stores over the same file - see requestQueueLock + synchronized (requestQueueLock) { + storageWriteRequestQueue(Utils.joinCountlyStore(newRequests, DELIMITER), false); + } } if (pcc != null) { @@ -678,7 +792,7 @@ void addEvent(final Event event) { final List events = getEventList(); if (events.size() < MAX_EVENTS) {//todo looks weird events.add(event); - writeEventDataToStorage(joinEvents(events, DELIMITER, pcc)); + writeEventDataToStorage(joinEvents(events, DELIMITER, pcc, L)); } if (pcc != null) { @@ -726,10 +840,21 @@ public synchronized String getCachedAdvertisingId() { } void setConsentPush(boolean consentValue) { + // The push file is shared and owned by the default instance. ModuleConsent calls this on every + // init, so without the gate creating a named instance would re-grant the owner's push consent. + if (!ownsPushStorage) { + L.d("[CountlyStore] setConsentPush, this instance does not own the shared push storage, skipping the push consent write"); + return; + } preferencesPush_.edit().putBoolean(CONSENT_GCM_PREFERENCES, consentValue).apply(); } Boolean getConsentPush() { + // Symmetry with setConsentPush: a named instance has no push, so it must not read the owner's + // consent either. + if (!ownsPushStorage) { + return false; + } return preferencesPush_.getBoolean(CONSENT_GCM_PREFERENCES, false); } @@ -797,7 +922,7 @@ public synchronized void removeEvents(final List eventsToRemove) { if (eventsToRemove != null && eventsToRemove.size() > 0) { final List events = getEventList(); if (events.removeAll(eventsToRemove)) { - storageWriteEventQueue(joinEvents(events, DELIMITER, pcc), false); + storageWriteEventQueue(joinEvents(events, DELIMITER, pcc, L), false); } } @@ -814,7 +939,7 @@ public synchronized void removeEvents(final List eventsToRemove) { * @param delimiter delimiter to use, should not be something that can be found in URL-encoded JSON string */ @SuppressWarnings("SameParameterValue") - static String joinEvents(final List collection, final String delimiter, PerformanceCounterCollector pcc) { + static String joinEvents(final List collection, final String delimiter, PerformanceCounterCollector pcc, @NonNull ModuleLog L) { long tsStart = 0L; if (pcc != null) { tsStart = UtilsTime.getNanoTime(); @@ -822,7 +947,7 @@ static String joinEvents(final List collection, final String delimiter, P final List strings = new ArrayList<>(collection.size()); for (Event e : collection) { - strings.add(e.toJSON().toString()); + strings.add(e.toJSON(L).toString()); } String ret = Utils.joinCountlyStore(strings, delimiter); @@ -845,12 +970,22 @@ public static synchronized void cachePushData(String id_key, String index_key, C String[] getCachedPushData() { String[] res = new String[2]; + // ModuleEvents reads and clears this on EVERY instance, so without the gate the first instance + // to init would record the owner's push click under its own app key and then delete it. + if (!ownsPushStorage) { + return res; + } res[0] = preferencesPush_.getString(CACHED_PUSH_ACTION_ID, null); res[1] = preferencesPush_.getString(CACHED_PUSH_ACTION_INDEX, null); return res; } void clearCachedPushData() { + // Only the owner may drain the shared push click cache; see getCachedPushData. + if (!ownsPushStorage) { + L.d("[CountlyStore] clearCachedPushData, this instance does not own the shared push storage, skipping"); + return; + } SharedPreferences.Editor spe = preferencesPush_.edit(); spe.remove(CACHED_PUSH_ACTION_ID); @@ -868,20 +1003,34 @@ public static int getMessagingProvider(Context context) { return sp.getInt(CACHED_PUSH_MESSAGING_PROVIDER, 0); } - // for unit testing + // used by halt(): erases everything this instance persisted public synchronized void clear() { - final SharedPreferences.Editor prefsEditor = preferences_.edit(); - prefsEditor.remove(EVENTS_PREFERENCE); - prefsEditor.remove(REQUEST_PREFERENCE); - prefsEditor.clear(); - prefsEditor.apply(); + //under the file lock so a still-draining processor of a removed sibling store cannot interleave + //its read-modify-write with the wipe + synchronized (requestQueueLock) { + final SharedPreferences.Editor prefsEditor = preferences_.edit(); + prefsEditor.remove(EVENTS_PREFERENCE); + prefsEditor.remove(REQUEST_PREFERENCE); + prefsEditor.clear(); + prefsEditor.apply(); + } //clear explicit storage things esDirtyFlag = false; esRequestQueueCache = null; esEventQueueCache = null; - preferencesPush_.edit().clear().apply(); + //The generated-UUID cache lives in its own (namespaced) file, not in the main store. Without + //wiping it too, a halt-then-init would silently re-adopt the pre-halt device id through + //ModuleDeviceId#getUUID - and halt()/haltAllInstances() promise that erasing stored data makes + //the next session start as a new user (which is also what a privacy-driven erase expects). + preferencesOpenUdid_.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 +1114,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/CountlyTimer.java b/sdk/src/main/java/ly/count/android/sdk/CountlyTimer.java index b3713dd47..42f6f880b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyTimer.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyTimer.java @@ -8,19 +8,24 @@ class CountlyTimer { ScheduledExecutorService timerService; + //Volatile: written by whichever thread calls stopTimer/startTimer, read by the timer thread. + private volatile boolean stopped = false; protected static int TIMER_DELAY_MS = 0; // for testing purposes protected void stopTimer(@NonNull ModuleLog L) { if (timerService != null) { L.i("[CountlyTimer] stopTimer, Stopping timer"); try { + //Set before shutting down so a tick that is already running, or one that wins the race with + //shutdown(), returns without doing any work. + stopped = true; + //shutdown() only, and deliberately no awaitTermination. The runnable is scheduled with + //scheduleWithFixedDelay, so shutdown() cancels every future execution on its own. Awaiting it + //blocked the caller for up to 1s + 1s, and the callers are the main thread: module halt() + //during a teardown, and startTimer() restarting a timer when the server changes its interval + //(that arrives on ImmediateRequestMaker's onPostExecute, i.e. the main thread). Neither can + //afford a two second stall. timerService.shutdown(); - if (!timerService.awaitTermination(1, TimeUnit.SECONDS)) { - timerService.shutdownNow(); - if (!timerService.awaitTermination(1, TimeUnit.SECONDS)) { - L.e("[CountlyTimer] stopTimer, Global timer must be locked"); - } - } } catch (Exception e) { L.e("[CountlyTimer] stopTimer, Error while stopping global timer " + e); } @@ -59,7 +64,18 @@ protected void startTimer(long timerDelay, long initialDelayMS, @NonNull Runnabl stopTimer(L); } + stopped = false; timerService = Executors.newSingleThreadScheduledExecutor(); - timerService.scheduleWithFixedDelay(runnable, initialDelayMS, timerDelayInternal, TimeUnit.MILLISECONDS); + //Wrapped rather than scheduled directly. shutdown() already prevents any further execution of a + //fixed-delay task, so this gate covers only the narrow case it cannot: a tick that has been dequeued + //and is about to enter run() at the instant stopTimer lands. It is free, so it is worth closing - but + //it is deliberately not covered by a test, because that interleaving cannot be forced deterministically + //and a test that passes either way is worse than none. + timerService.scheduleWithFixedDelay(() -> { + if (stopped) { + return; + } + runnable.run(); + }, initialDelayMS, timerDelayInternal, TimeUnit.MILLISECONDS); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/CrashData.java b/sdk/src/main/java/ly/count/android/sdk/CrashData.java index 062720fd4..c5bb260e7 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CrashData.java +++ b/sdk/src/main/java/ly/count/android/sdk/CrashData.java @@ -196,10 +196,15 @@ private void calculateChecksums(@NonNull String[] checksumArrayToSet) { assert breadcrumbs != null; assert crashMetrics != null; - checksumArrayToSet[0] = UtilsNetworking.sha256Hash(stackTrace); - checksumArrayToSet[1] = UtilsNetworking.sha256Hash(crashSegmentation.toString()); - checksumArrayToSet[2] = UtilsNetworking.sha256Hash(breadcrumbs.toString()); - checksumArrayToSet[3] = UtilsNetworking.sha256Hash(crashMetrics.toString()); - checksumArrayToSet[4] = UtilsNetworking.sha256Hash(fatal + ""); + //CrashData is public API the host app can construct, so there is no SDK instance here to attribute a + //log to - hence a silent logger, created explicitly rather than hidden behind an overload. sha256Hash + //swallows any Throwable (a missing SHA-256 provider, or an OOM on a very large stack trace), so + //nothing here can fail loudly either way. + final ModuleLog L = new ModuleLog(); + checksumArrayToSet[0] = UtilsNetworking.sha256Hash(stackTrace, L); + checksumArrayToSet[1] = UtilsNetworking.sha256Hash(crashSegmentation.toString(), L); + checksumArrayToSet[2] = UtilsNetworking.sha256Hash(breadcrumbs.toString(), L); + checksumArrayToSet[3] = UtilsNetworking.sha256Hash(crashMetrics.toString(), L); + checksumArrayToSet[4] = UtilsNetworking.sha256Hash(fatal + "", L); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/DeviceInfo.java b/sdk/src/main/java/ly/count/android/sdk/DeviceInfo.java index 43800653b..f3517fc49 100644 --- a/sdk/src/main/java/ly/count/android/sdk/DeviceInfo.java +++ b/sdk/src/main/java/ly/count/android/sdk/DeviceInfo.java @@ -65,6 +65,9 @@ class DeviceInfo { MetricProvider mp; private final MetricProvider mpOverride; + // Logger of the owning instance. The default MetricProvider below is an inner class, so it reads + // this field instead of Countly.sharedInstance().L - no change to the public MetricProvider API. + @NonNull ModuleLog L = new ModuleLog(); public DeviceInfo(MetricProvider mpOverride) { this.mpOverride = mpOverride != null ? mpOverride : new MetricProvider() {}; @@ -121,7 +124,7 @@ public String getResolution(@NonNull final Context context) { final DisplayMetrics metrics = getDisplayMetrics(context); resolution = metrics.widthPixels + "x" + metrics.heightPixels; } catch (Throwable t) { - Countly.sharedInstance().L.i("[DeviceInfo] Device resolution cannot be determined"); + L.i("[DeviceInfo] Device resolution cannot be determined"); } return resolution; } @@ -190,7 +193,7 @@ public String getCarrier(@NonNull final Context context) { } if (carrier == null || carrier.length() == 0) { carrier = ""; - Countly.sharedInstance().L.i("[DeviceInfo] No carrier found"); + L.i("[DeviceInfo] No carrier found"); } if (carrier.equals("--")) { carrier = ""; @@ -231,7 +234,7 @@ public String getAppVersion(@NonNull final Context context) { result = tmpVersion; } } catch (PackageManager.NameNotFoundException e) { - Countly.sharedInstance().L.i("[DeviceInfo] No app version found"); + L.i("[DeviceInfo] No app version found"); } return result; } @@ -248,11 +251,11 @@ public String getStore(@NonNull final Context context) { try { result = context.getPackageManager().getInstallerPackageName(context.getPackageName()); } catch (Exception e) { - Countly.sharedInstance().L.d("[DeviceInfo, getStore] Can't get Installer package "); + L.d("[DeviceInfo, getStore] Can't get Installer package "); } if (result == null || result.length() == 0) { result = ""; - Countly.sharedInstance().L.d("[DeviceInfo, getStore] No store found"); + L.d("[DeviceInfo, getStore] No store found"); } return result; } @@ -357,7 +360,7 @@ public DiskMetric getDiskSpaces(Context context) { } } } catch (Exception e) { - Countly.sharedInstance().L.w("[DeviceInfo] getDiskSpaces, Got exception while trying to get all volumes storage", e); + L.w("[DeviceInfo] getDiskSpaces, Got exception while trying to get all volumes storage", e); } } else { try { @@ -374,14 +377,14 @@ public DiskMetric getDiskSpaces(Context context) { long freeBytes = availableBlocks * blockSize; usedBytes = totalBytes - freeBytes; } catch (Exception e) { - Countly.sharedInstance().L.w("[DeviceInfo] getDiskSpaces, Got exception while trying to get all volumes storage", e); + L.w("[DeviceInfo] getDiskSpaces, Got exception while trying to get all volumes storage", e); } } long totalMb = totalBytes / 1024 / 1024; long usedMb = usedBytes / 1024 / 1024; - Countly.sharedInstance().L.d("[DeviceInfo] getDiskSpaces, totalSpaceInMB:[" + totalMb + "], usedSpaceInMB:[" + usedMb + "]"); + L.d("[DeviceInfo] getDiskSpaces, totalSpaceInMB:[" + totalMb + "], usedSpaceInMB:[" + usedMb + "]"); return new DiskMetric(Long.toString(totalMb), Long.toString(usedMb)); } @@ -406,7 +409,7 @@ public String getBatteryLevel(Context context) { } } } catch (Exception e) { - Countly.sharedInstance().L.i("Can't get battery level"); + L.i("Can't get battery level"); } return null; } @@ -462,7 +465,7 @@ public String isOnline(Context context) { } return "false"; } catch (Exception e) { - Countly.sharedInstance().L.w("isOnline, Got exception determining netwprl connectivity", e); + L.w("isOnline, Got exception determining netwprl connectivity", e); } return null; } @@ -649,7 +652,7 @@ String getMetrics(@NonNull final Context context, @Nullable final Map1 can store the ID and not just its type. + static final String key_from_0_to_1_custom_id_value = "0_1_custom_id_value"; + // Temporary-ID mode comes from a config flag, not from config.deviceID, so 0->1 has to be told. + static final String key_from_0_to_1_temp_id_enabled = "0_1_temp_id_enabled"; static final String param_key_device_id = "device_id"; static final String param_key_override_id = "override_id"; static final String param_key_old_device_id = "old_device_id"; 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 +50,7 @@ public MigrationHelper(@NonNull StorageProvider storage, @NonNull ModuleLog modu this.storage = storage; L = moduleLog; cachedContext = context; + this.ownsPushStorage = ownsPushStorage; L.v("[MigrationHelper] Initialising"); } @@ -158,19 +170,33 @@ void setInitialSchemaVersion() { void performMigration0To1(@NonNull Map migrationParams) { String deviceIDType = storage.getDeviceIDType(); String deviceID = storage.getDeviceID(); + boolean customIdProvided = Boolean.TRUE.equals(migrationParams.get(key_from_0_to_1_custom_id_set)); if (deviceIDType == null && deviceID == null) { - //if both the ID and type are null we are in big trouble - //set type to OPEN_UDID and generate the ID afterwards - storage.setDeviceIDType(DeviceIdType.OPEN_UDID.toString()); - deviceIDType = DeviceIdType.OPEN_UDID.toString(); + //Nothing stored yet: honour an init-supplied ID or temporary mode instead of generating a + //UUID below. Store the ID too - 3->4 aborts on a null one, leaving legacy requests without it. + String customId = (String) migrationParams.get(key_from_0_to_1_custom_id_value); + boolean tempIdEnabled = Boolean.TRUE.equals(migrationParams.get(key_from_0_to_1_temp_id_enabled)); + + if (customIdProvided && Utils.isNotNullOrEmpty(customId)) { + storage.setDeviceIDType(DeviceIdType.DEVELOPER_SUPPLIED.toString()); + storage.setDeviceID(customId); + deviceIDType = DeviceIdType.DEVELOPER_SUPPLIED.toString(); + deviceID = customId; + } else if (tempIdEnabled) { + //writing OPEN_UDID plus a generated UUID here would make DeviceId adopt that pair, so + //temporary ID mode would never be entered + storage.setDeviceIDType(DeviceIdType.TEMPORARY_ID.toString()); + storage.setDeviceID(DeviceId.temporaryCountlyDeviceId); + deviceIDType = DeviceIdType.TEMPORARY_ID.toString(); + deviceID = DeviceId.temporaryCountlyDeviceId; + } else { + //nothing to preserve, generate below + storage.setDeviceIDType(DeviceIdType.OPEN_UDID.toString()); + deviceIDType = DeviceIdType.OPEN_UDID.toString(); + } } else if (deviceIDType == null) { //if the type is null, but the ID value is not null, we have to guess the type - Boolean customIdProvided = (Boolean) migrationParams.get(key_from_0_to_1_custom_id_set); - if (customIdProvided == null) { - customIdProvided = false; - } - if (customIdProvided) { //if a custom device ID is provided during init, assume that the previous type was dev supplied storage.setDeviceIDType(DeviceIdType.DEVELOPER_SUPPLIED.toString()); @@ -248,6 +274,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/ModuleAPM.java b/sdk/src/main/java/ly/count/android/sdk/ModuleAPM.java index 3bafa8443..1a5167819 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleAPM.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleAPM.java @@ -103,12 +103,12 @@ void endTraceInternal(@NonNull String traceKey, @NonNull Map cu //custom metrics provided //remove reserved keys removeReservedInvalidKeys(customMetrics); - UtilsInternalLimits.truncateSegmentationKeys(customMetrics, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleAPM] endTraceInternal"); - UtilsInternalLimits.truncateSegmentationValues(customMetrics, _cly.config_.sdkInternalLimits.maxSegmentationValues, "[ModuleAPM] endTraceInternal", L); + UtilsInternalLimits.truncateSegmentationKeys(customMetrics, _cly.sdkInternalLimits_.maxKeyLength, L, "[ModuleAPM] endTraceInternal"); + UtilsInternalLimits.truncateSegmentationValues(customMetrics, _cly.sdkInternalLimits_.maxSegmentationValues, "[ModuleAPM] endTraceInternal", L); } String metricString = customMetricsToString(customMetrics); - String truncatedTraceKey = UtilsInternalLimits.truncateKeyLength(traceKey, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleAPM] endTraceInternal"); + String truncatedTraceKey = UtilsInternalLimits.truncateKeyLength(traceKey, _cly.sdkInternalLimits_.maxKeyLength, L, "[ModuleAPM] endTraceInternal"); String modifiedTraceKey = validateAndModifyTraceKey(truncatedTraceKey); requestQueueProvider.sendAPMCustomTrace(modifiedTraceKey, durationMs, startTimestamp, currentTimestamp, metricString); @@ -324,7 +324,7 @@ void recordNetworkRequestInternal(String networkTraceKey, int responseCode, int } //validate trace key - networkTraceKey = UtilsInternalLimits.truncateKeyLength(networkTraceKey, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleAPM] recordNetworkRequestInternal"); + networkTraceKey = UtilsInternalLimits.truncateKeyLength(networkTraceKey, _cly.sdkInternalLimits_.maxKeyLength, L, "[ModuleAPM] recordNetworkRequestInternal"); networkTraceKey = validateAndModifyTraceKey(networkTraceKey); Long responseTimeMs = endTimestamp - startTimestamp; @@ -473,28 +473,30 @@ void onConsentChanged(@NonNull final List consentChangeDelta, final bool if (consentChangeDelta.contains(Countly.CountlyFeatureNames.apm)) { if (!newConsent) { //in case APM consent is removed, clear custom and network traces - _cly.moduleAPM.clearNetworkTraces(); - _cly.moduleAPM.cancelAllTracesInternal(); + //called directly: _cly.moduleAPM is this very module, so the hop through _cly added nothing + //but a field that teardown nulls + clearNetworkTraces(); + cancelAllTracesInternal(); } } } @Override void initFinished(@NonNull CountlyConfig config) { - if (_cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (_cly.lifeCycleAtleastStarted()) { L.d("[ModuleAPM] SDK detects that the app is in the foreground. Increasing the activity counter."); activitiesOpen++; } // we only do this adjustment if we track it automatically - if (trackForegroundBackground && !manualForegroundBackgroundTriggers && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (trackForegroundBackground && !manualForegroundBackgroundTriggers && _cly.lifeCycleAtleastStarted()) { L.d("[ModuleAPM] SDK detects that the app is in the foreground. Starting to track foreground time"); calculateAppRunningTimes(activitiesOpen - 1, activitiesOpen); } - if (config.apm.trackAppStartTime && !config.apm.appLoadedManualTrigger && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (config.apm.trackAppStartTime && !config.apm.appLoadedManualTrigger && _cly.lifeCycleAtleastStarted()) { L.d("[ModuleAPM] SDK detects that the app is in the foreground. Recording automatic app start duration"); long currentTimestamp = System.currentTimeMillis(); recordAppStart(currentTimestamp); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java b/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java index c58265db0..de693d73a 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java @@ -50,12 +50,31 @@ void halt() { * * @param newConfig */ + /* + * WIRE A NEW OVERRIDE INTO THE DISPATCHER - this applies to every lifecycle hook in this class, not + * just to onConfigurationChanged. + * + * Lifecycle hooks are no longer delivered by iterating Countly's `modules` list. Android delivers them + * on the main thread while a teardown on another thread clears that list and nulls the module fields, + * which killed a CI run with an NPE escaping Activity.onStop - Android turns that into a host-app + * crash. CountlyLifecycleDispatcher therefore holds one process-wide registration, and the dispatch + * path calls a fixed set of modules directly from Countly#dispatchActivity* and the + * onStart/onStop/onConfigurationChanged internals. + * + * So overriding one of these methods does nothing on its own. Add the module to the call site too, at + * the position init adds it to `modules`: the loops these calls replaced ran in that order and side + * effects between modules depend on it. ModuleLifecycleDispatchTests derives both the set and the + * order from a live instance's `modules` list and fails on a missing, stale, or reordered call, so + * this rule is enforced rather than hoped for. + */ void onConfigurationChanged(Configuration newConfig) { } /** * Called manually by a countly call from the developer */ + /* Overriding this is not enough: it must also be wired into the dispatch call site, in modules-list + * order. See the note above onConfigurationChanged. */ void onActivityStarted(Activity activity, int updatedActivityCount) { } @@ -70,6 +89,8 @@ void onInitialActivitySeeded(@NonNull Activity activity) { /** * Called manually by a countly call from the developer */ + /* Overriding this is not enough: it must also be wired into the dispatch call site, in modules-list + * order. See the note above onConfigurationChanged. */ void onActivityStopped(int updatedActivityCount) { } @@ -78,6 +99,8 @@ void onActivityStopped(int updatedActivityCount) { * clear them here (using identity comparison) to prevent leaking destroyed activities * through the Countly singleton. */ + /* Overriding this is not enough: it must also be wired into the dispatch call site, in modules-list + * order. See the note above onConfigurationChanged. */ void onActivityDestroyed(@NonNull Activity activity) { } @@ -87,6 +110,8 @@ void onActivityDestroyed(@NonNull Activity activity) { //void callbackOnActivityStarted(Activity activity) { //} // + /* Overriding this is not enough: it must also be wired into the dispatch call site, in modules-list + * order. See the note above onConfigurationChanged. */ void callbackOnActivityResumed(Activity activity) { } @@ -94,6 +119,8 @@ void callbackOnActivityResumed(Activity activity) { //void callbackOnActivityPaused(Activity activity) { //} // + /* Overriding this is not enough: it must also be wired into the dispatch call site, in modules-list + * order. See the note above onConfigurationChanged. */ void callbackOnActivityStopped(Activity activity) { } // 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..229391d17 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -98,6 +98,18 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { Set currentVJourneyTriggerEvents = new HashSet<>(); Set currentVJourneyTriggerViews = new HashSet<>(); + // Settings the SBS layers resolve that used to be written back onto the CountlyConfig. Per instance, so + // a config shared between instances can not carry one instance's resolved settings into another. + // Consumers read these (Countly#onSdkConfigurationChanged, ModuleConsent, ModuleContent, the request + // drop-age provider) instead of reading the config. + int currentVMaxRequestQueueSize; + Integer currentVEventQueueSizeThreshold; + boolean currentVLoggingEnabled; + Integer currentVSessionUpdateTimerDelay; + int currentVDropAgeHours; + boolean currentVRequiresConsent; + int currentVZoneTimerInterval; + // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours int currentServerConfigUpdateInterval = 4; @@ -107,6 +119,10 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { ModuleConfiguration(@NonNull Countly cly, @NonNull CountlyConfig config) { super(cly, config); L.v("[ModuleConfiguration] Initialising"); + //Publish ourselves on the instance before resolving anything. updateConfigVariables below can call + //Countly#onSdkConfigurationChanged, which reads this instance's resolved settings through + //_cly.moduleConfiguration - and init only assigns that field after this constructor returns. + cly.moduleConfiguration = this; config.configProvider = this; configProvider = this; @@ -117,6 +133,19 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { config.countlyStore.setConfigurationProvider(this); + //Seed the settings the SBS layers resolve from the developer's config. These live here, per instance, + //rather than being written back onto the CountlyConfig: the config object may be shared by several + //instances, and writing our resolved values onto it would both hand them to the other instance and + //poison the "provided" layer of its next resolve - which for shouldRequireConsent means silently + //switching consent gating off for an instance whose developer required it. + currentVMaxRequestQueueSize = config.maxRequestQueueSize; + currentVEventQueueSizeThreshold = config.eventQueueSizeThreshold; + currentVLoggingEnabled = config.loggingEnabled; + currentVSessionUpdateTimerDelay = config.sessionUpdateTimerDelay; + currentVDropAgeHours = config.dropAgeHours; + currentVRequiresConsent = config.shouldRequireConsent; + currentVZoneTimerInterval = config.content.zoneTimerInterval; + //seed the automatic tracking flags from the local config: it is the lowest-precedence layer. //the SBS layers (provided -> stored -> server) override these in updateConfigVariables, giving the precedence //server SBS > stored SBS > provided SBS > developer config @@ -252,19 +281,27 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { currentVBOMDuration = extractValue(keyRBOMDuration, sb, currentVBOMDuration, currentVBOMDuration, Integer.class, (Integer value) -> value > 0); 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.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)); - clyConfig.sdkInternalLimits.setMaxValueSize(extractValue(keyRLimitValueSize, sb, clyConfig.sdkInternalLimits.maxValueSize, Countly.maxValueSizeDefault, Integer.class, (Integer value) -> value > 0)); - clyConfig.sdkInternalLimits.setMaxSegmentationValues(extractValue(keyRLimitSegValues, sb, clyConfig.sdkInternalLimits.maxSegmentationValues, Countly.maxSegmentationValuesDefault, Integer.class, (Integer value) -> value > 0)); - clyConfig.sdkInternalLimits.setMaxBreadcrumbCount(extractValue(keyRLimitBreadcrumb, sb, clyConfig.sdkInternalLimits.maxBreadcrumbCount, Countly.maxBreadcrumbCountDefault, Integer.class, (Integer value) -> value > 0)); - clyConfig.sdkInternalLimits.setMaxStackTraceLinesPerThread(extractValue(keyRLimitTraceLine, sb, clyConfig.sdkInternalLimits.maxStackTraceLinesPerThread, Countly.maxStackTraceLinesPerThreadDefault, Integer.class, (Integer value) -> value > 0)); - clyConfig.sdkInternalLimits.setMaxStackTraceLineLength(extractValue(keyRLimitTraceLength, sb, clyConfig.sdkInternalLimits.maxStackTraceLineLength, Countly.maxStackTraceLineLengthDefault, Integer.class, (Integer value) -> value > 0)); - clyConfig.content.setZoneTimerInterval(extractValue(keyRContentZoneInterval, sb, clyConfig.content.zoneTimerInterval, clyConfig.content.zoneTimerInterval, Integer.class, (Integer value) -> value >= 16)); - clyConfig.setRequiresConsent(extractValue(keyRConsentRequired, sb, clyConfig.shouldRequireConsent, clyConfig.shouldRequireConsent)); - clyConfig.setRequestDropAgeHours(extractValue(keyRDropOldRequestTime, sb, clyConfig.dropAgeHours, clyConfig.dropAgeHours, Integer.class, (Integer value) -> value >= 0)); + //Resolved onto this instance, never back onto clyConfig - see the field declarations. The provided + //layer is this instance's own seeded value, which is what the developer configured. + currentVMaxRequestQueueSize = extractValue(keyRReqQueueSize, sb, currentVMaxRequestQueueSize, currentVMaxRequestQueueSize, Integer.class, (Integer value) -> value > 0); + currentVEventQueueSizeThreshold = extractValue(keyREventQueueSize, sb, currentVEventQueueSizeThreshold, _cly.EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0); + currentVLoggingEnabled = extractValue(keyRLogging, sb, currentVLoggingEnabled, currentVLoggingEnabled); + currentVSessionUpdateTimerDelay = extractValue(keyRSessionUpdateInterval, sb, currentVSessionUpdateTimerDelay, Long.valueOf(Countly.TIMER_DELAY_IN_SECONDS).intValue(), Integer.class, (Integer value) -> value > 0); + //Internal limits are resolved onto THIS instance's limits, not onto the shared CountlyConfig: they + //are read live on every event, view, crash and user property, so writing them back onto a config + //that a second instance may also hold would let this instance's /o/sdk response retruncate that + //instance's data. The provided layer is our own seeded copy, which already holds what the developer + //configured (Countly#init seeds it right after validating the config's limit overrides). + ConfigSdkInternalLimits limits = _cly.sdkInternalLimits_; + limits.setMaxKeyLength(extractValue(keyRLimitKeyLength, sb, limits.maxKeyLength, Countly.maxKeyLengthDefault, Integer.class, (Integer value) -> value > 0)); + limits.setMaxValueSize(extractValue(keyRLimitValueSize, sb, limits.maxValueSize, Countly.maxValueSizeDefault, Integer.class, (Integer value) -> value > 0)); + limits.setMaxSegmentationValues(extractValue(keyRLimitSegValues, sb, limits.maxSegmentationValues, Countly.maxSegmentationValuesDefault, Integer.class, (Integer value) -> value > 0)); + limits.setMaxBreadcrumbCount(extractValue(keyRLimitBreadcrumb, sb, limits.maxBreadcrumbCount, Countly.maxBreadcrumbCountDefault, Integer.class, (Integer value) -> value > 0)); + limits.setMaxStackTraceLinesPerThread(extractValue(keyRLimitTraceLine, sb, limits.maxStackTraceLinesPerThread, Countly.maxStackTraceLinesPerThreadDefault, Integer.class, (Integer value) -> value > 0)); + limits.setMaxStackTraceLineLength(extractValue(keyRLimitTraceLength, sb, limits.maxStackTraceLineLength, Countly.maxStackTraceLineLengthDefault, Integer.class, (Integer value) -> value > 0)); + currentVZoneTimerInterval = extractValue(keyRContentZoneInterval, sb, currentVZoneTimerInterval, currentVZoneTimerInterval, Integer.class, (Integer value) -> value >= 16); + currentVRequiresConsent = extractValue(keyRConsentRequired, sb, currentVRequiresConsent, currentVRequiresConsent); + currentVDropAgeHours = extractValue(keyRDropOldRequestTime, sb, currentVDropAgeHours, currentVDropAgeHours, Integer.class, (Integer value) -> value >= 0); updateListingFilters(); @@ -592,9 +629,9 @@ void fetchConfigFromServer(@NonNull CountlyConfig config) { return; } - // why _cly? because module configuration is created before module device id, so we need to access it like this - // call order to module device id is after module configuration and device id provider is module device id - if (_cly.config_.deviceIdProvider.isTemporaryIdEnabled()) { + // this module is constructed before ModuleDeviceId, so its own deviceIdProvider is filled in by + // the provider wiring block in Countly#init rather than by the ModuleBase constructor + if (deviceIdProvider.isTemporaryIdEnabled()) { //temporary id mode enabled, abort L.d("[ModuleConfiguration] fetchConfigFromServer, fetch config from the server is aborted, temporary device ID mode is set"); return; diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java index 2671bfd82..a984dbc7b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java @@ -45,7 +45,10 @@ public enum ConsentChangeSource {ChangeConsentCall, DeviceIDChangedNotMerged} consentProvider = this; config.consentProvider = this; L.v("[ModuleConsent] constructor, Initialising"); - L.i("[ModuleConsent] Is consent required? [" + config.shouldRequireConsent + "]"); + //the value ModuleConfiguration resolved for THIS instance (developer config plus the stored server + //behaviour settings), not the shared config object, which the SDK no longer writes to + final boolean resolvedRequiresConsent = cly.moduleConfiguration.currentVRequiresConsent; + L.i("[ModuleConsent] Is consent required? [" + resolvedRequiresConsent + "]"); //setup initial consent data structure //initialize all features to "false" @@ -54,8 +57,8 @@ public enum ConsentChangeSource {ChangeConsentCall, DeviceIDChangedNotMerged} } //react to given consent during init - if (config.shouldRequireConsent) { - requiresConsent = config.shouldRequireConsent; + if (resolvedRequiresConsent) { + requiresConsent = resolvedRequiresConsent; if (config.enabledFeatureNames == null && !config.enableAllConsents) { L.i("[ModuleConsent] constructor, Consent has been required but no consent was given during init"); } else { @@ -152,7 +155,13 @@ public void checkAllConsentInternal() { */ void doPushConsentSpecialAction(final boolean consentValue) { L.d("[ModuleConsent] doPushConsentSpecialAction, consentValue: [" + consentValue + "]"); + // Push is owned process-wide by the default instance. setConsentPush gates the store write; the + // broadcast is gated here because CountlyPush reacts to it by registering the DEFAULT's token. _cly.countlyStore.setConsentPush(consentValue); + if (!_cly.storageNamespace_.isEmpty()) { + L.d("[ModuleConsent] doPushConsentSpecialAction, named instance does not own push, skipping the process-global consent broadcast"); + return; + } _cly.context_.sendBroadcast(new Intent(Countly.CONSENT_BROADCAST)); } @@ -293,7 +302,14 @@ void initFinished(@NonNull final CountlyConfig config) { @Override void onSdkConfigurationChanged(@NonNull CountlyConfig config) { - requiresConsent = config.shouldRequireConsent; + //Reached on the main thread from the server-config response, so a teardown can have nulled + //moduleConfiguration in between; keep the current value rather than crashing on a dying instance. + ModuleConfiguration configurationModule = _cly.moduleConfiguration; + if (configurationModule != null) { + requiresConsent = configurationModule.currentVRequiresConsent; + } else { + L.w("[ModuleConsent] onSdkConfigurationChanged, the configuration module is gone, keeping the current requiresConsent value"); + } } @Override 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..855bd0355 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -9,6 +9,7 @@ import android.util.DisplayMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.lang.ref.WeakReference; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -30,19 +31,30 @@ public class ModuleContent extends ModuleBase { private int waitForDelay = 0; int CONTENT_START_DELAY_MS = 4000; // 4 seconds - private Activity currentActivity; + //Weak, like CountlyActivityHolder: the clearing path (onActivityDestroyed) only runs when the + //instance was initialised with an Application class - an instance without one (seeded through + //onInitialActivitySeeded) would otherwise pin the seeding Activity for as long as this instance + //lives, with the process-lifetime instance registry as the GC root. + private WeakReference currentActivity; ContentOverlayView contentOverlay; // Buffered content when no activity is available private Map pendingContentConfigs; + private @Nullable Activity getCurrentActivity() { + return currentActivity != null ? currentActivity.get() : null; + } + ModuleContent(@NonNull Countly cly, @NonNull CountlyConfig config) { super(cly, config); - L.v("[ModuleContent] Initialising, zoneTimerInterval: [" + config.content.zoneTimerInterval + "], globalContentCallback: [" + config.content.globalContentCallback + "]"); + //the resolved interval, which is what this module actually uses below - logging the config's value + //would print a number the SDK is not honouring once the server behaviour settings override it + L.v("[ModuleContent] Initialising, zoneTimerInterval: [" + cly.moduleConfiguration.currentVZoneTimerInterval + "], globalContentCallback: [" + config.content.globalContentCallback + "]"); iRGenerator = config.immediateRequestGenerator; contentInterface = new Content(); countlyTimer = new CountlyTimer(); - zoneTimerInterval = config.content.zoneTimerInterval; + //resolved for this instance by ModuleConfiguration, not read off the shared config + zoneTimerInterval = cly.moduleConfiguration.currentVZoneTimerInterval; webViewEnabled = config.webViewEnabled; globalContentCallback = config.content.globalContentCallback; if (!webViewEnabled) { @@ -52,7 +64,16 @@ public class ModuleContent extends ModuleBase { @Override void onSdkConfigurationChanged(@NonNull CountlyConfig config) { - zoneTimerInterval = config.content.zoneTimerInterval; + //Reached on the main thread from the server-config response, so a teardown can have nulled + //moduleConfiguration in between; keep the current value rather than crashing on a dying instance. + ModuleConfiguration configurationModule = _cly.moduleConfiguration; + if (configurationModule != null) { + zoneTimerInterval = configurationModule.currentVZoneTimerInterval; + } else { + //only the interval refresh is skipped - the content zone work below does not need that module, + //so keeping the current interval is better than dropping the whole configuration change + L.w("[ModuleContent] onSdkConfigurationChanged, the configuration module is gone, keeping the current zone timer interval"); + } if (!configProvider.getContentZoneEnabled()) { exitContentZoneInternal(); } else { @@ -74,7 +95,7 @@ void initFinished(@NotNull CountlyConfig config) { @Override void onInitialActivitySeeded(@NonNull Activity activity) { L.d("[ModuleContent] onInitialActivitySeeded, activity: [" + activity.getClass().getSimpleName() + "]"); - currentActivity = activity; + currentActivity = new WeakReference<>(activity); if (UtilsDevice.cutout == null) { UtilsDevice.getCutout(activity); } @@ -90,7 +111,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { UtilsDevice.getCutout(activity); } - currentActivity = activity; + currentActivity = new WeakReference<>(activity); // Move existing overlay to the new activity if (contentOverlay != null && !activity.isFinishing() && !activity.isDestroyed()) { @@ -123,7 +144,7 @@ void onActivityStopped(int updatedActivityCount) { void onActivityDestroyed(@NonNull Activity activity) { // Identity check guards against clearing a newer activity when the destroy callback // for an older activity arrives after onActivityStarted of the next one (rotation race). - if (currentActivity == activity) { + if (getCurrentActivity() == activity) { currentActivity = null; } // The overlay itself is intentionally kept alive across activity transitions. @@ -161,8 +182,9 @@ void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable call isCurrentlyInContentZone = true; isCurrentlyRetrying = false; - if (currentActivity != null && !currentActivity.isFinishing()) { - showContentOverlay(currentActivity, placementCoordinates); + Activity heldActivity = getCurrentActivity(); + if (heldActivity != null && !heldActivity.isFinishing()) { + showContentOverlay(heldActivity, placementCoordinates); } else { L.d("[ModuleContent] fetchContentsInternal, no active activity, buffering content"); pendingContentConfigs = placementCoordinates; @@ -299,13 +321,23 @@ private void showContentOverlay(@NonNull Activity activity, @NonNull Map customCrashSegments = null; @@ -47,12 +62,16 @@ public class ModuleCrash extends ModuleBase { recordAllThreads = config.crashes.recordAllThreadsWithCrash; - setCustomCrashSegmentsInternal(config.crashes.customCrashSegment); + //Copy first, for the same reason as ModuleViews' global segmentation: this truncates in place and the + //map belongs to the developer's config, which a second instance may also be built from. + setCustomCrashSegmentsInternal(config.crashes.customCrashSegment == null ? null : new LinkedHashMap<>(config.crashes.customCrashSegment)); metricOverride = config.metricOverride; crashesInterface = new Crashes(); - breadcrumbHelper = new BreadcrumbHelper(config.sdkInternalLimits.maxBreadcrumbCount, L); + //the limit this instance RESOLVED (developer config plus the server behaviour settings), not the raw + //developer value on the config - this was the last reader still bypassing sdkInternalLimits_ + breadcrumbHelper = new BreadcrumbHelper(cly.sdkInternalLimits_.maxBreadcrumbCount, L); assert breadcrumbHelper != null; } @@ -89,7 +108,9 @@ void checkForNativeCrashDumps(@NonNull Context context) { //record crash recordNativeException(dumpFile); - //delete dump file + //Always drop the dump, including when consent is missing: retaining it would need a + //cache with its own retention policy, and minidumps are raw process memory we do not + //want sitting on the device waiting for a consent that may never come. dumpFile.delete(); } } @@ -143,11 +164,11 @@ private CrashData prepareCrashData(@NonNull String error, final boolean handled, combinedSegmentationValues.putAll(customCrashSegments); } if (customSegmentation != null) { - UtilsInternalLimits.applySdkInternalLimitsToSegmentation(customSegmentation, _cly.config_.sdkInternalLimits, L, "[ModuleCrash] sendCrashReportToQueue"); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(customSegmentation, _cly.sdkInternalLimits_, L, "[ModuleCrash] sendCrashReportToQueue"); combinedSegmentationValues.putAll(customSegmentation); } - UtilsInternalLimits.truncateSegmentationValues(combinedSegmentationValues, _cly.config_.sdkInternalLimits.maxSegmentationValues, "[ModuleCrash] prepareCrashData", L); + UtilsInternalLimits.truncateSegmentationValues(combinedSegmentationValues, _cly.sdkInternalLimits_.maxSegmentationValues, "[ModuleCrash] prepareCrashData", L); return new CrashData(error, combinedSegmentationValues, breadcrumbHelper.getBreadcrumbs(), deviceInfo.getCrashMetrics(_cly.context_, isNativeCrash, metricOverride, L), !handled); } @@ -158,10 +179,10 @@ private String prepareStackTrace(Throwable e) { e.printStackTrace(pw); if (recordAllThreads) { - addAllThreadInformationToCrash(pw, _cly.config_.sdkInternalLimits); + addAllThreadInformationToCrash(pw, _cly.sdkInternalLimits_); } - String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(sw.toString(), _cly.config_.sdkInternalLimits.maxStackTraceLineLength, "[ModuleCrash] prepareStackTrace", L); + String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(sw.toString(), _cly.sdkInternalLimits_.maxStackTraceLineLength, "[ModuleCrash] prepareStackTrace", L); return truncatedStackTrace; } @@ -193,44 +214,98 @@ void setCustomCrashSegmentsInternal(@Nullable Map segments) { customSegments = segments; } - UtilsInternalLimits.applySdkInternalLimitsToSegmentation(customSegments, _cly.config_.sdkInternalLimits, L, "[ModuleCrash] setCustomCrashSegmentsInternal"); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(customSegments, _cly.sdkInternalLimits_, L, "[ModuleCrash] setCustomCrashSegmentsInternal"); customCrashSegments = customSegments; } void enableCrashReporting() { - if (unhandledCrashHandlerInstalled) { - //already installed, don't wrap the global handler again - return; + //read-then-set on the process-global handler chain: serialise it against any other instance doing + //the same, so no instance is silently dropped out of the chain + boolean installed = false; + synchronized (crashHandlerLock) { + if (!unhandledCrashHandlerInstalled && !crashHandlerDetached) { + //crashHandlerDetached means this module was already torn down; installing then would put a + //dead instance into the process-global chain with nothing left to unlink it + unhandledCrashHandlerInstalled = true; + //get default handler + final Thread.UncaughtExceptionHandler oldHandler = Thread.getDefaultUncaughtExceptionHandler(); + previousCrashHandler = oldHandler; + + installedCrashHandler = new CountlyCrashHandler(this, oldHandler); + + Thread.setDefaultUncaughtExceptionHandler(installedCrashHandler); + installed = true; + } } - L.d("[ModuleCrash] Enabling unhandled crash reporting"); - unhandledCrashHandlerInstalled = true; - //get default handler - final Thread.UncaughtExceptionHandler oldHandler = Thread.getDefaultUncaughtExceptionHandler(); - - Thread.UncaughtExceptionHandler handler = new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { - L.d("[ModuleCrash] Uncaught crash handler triggered"); - if (consentProvider.getConsent(Countly.CountlyFeatureNames.crashes) && configProvider.getAutomaticCrashReportingEnabled()) { + //log outside the lock: informListener runs the app's LogCallback, and no other SDK lock is held + //while calling into app code + if (installed) { + L.d("[ModuleCrash] Enabling unhandled crash reporting"); + } + } - String stackTrace = prepareStackTrace(e); - CrashData crashData = prepareCrashData(stackTrace, false, false, null); - if (!crashFilterCheck(crashData, false)) { - sendCrashReportToQueue(crashData, false); + /** + * The handler this instance installs into the process-global uncaught-exception chain. + *

+ * Static, and holds the module only weakly, because {@code halt()} can unlink from the chain only + * while this handler is still the process default. Once the host app (or another Countly instance) + * installs a handler on top, there is no way to remove a link from the middle of the chain, so this + * object stays there for the life of the process. A strong reference would pin the halted module, + * and through it the whole Countly instance, its context and its queues, forever. Delegation to the + * previous handler must keep working either way, so that link is held strongly. + */ + private static final class CountlyCrashHandler implements Thread.UncaughtExceptionHandler { + private final WeakReference moduleRef; + private final Thread.UncaughtExceptionHandler previous; + + CountlyCrashHandler(@NonNull ModuleCrash module, @Nullable Thread.UncaughtExceptionHandler previous) { + this.moduleRef = new WeakReference<>(module); + this.previous = previous; + } + + @Override + public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { + ModuleCrash module = moduleRef.get(); + //a halted (or already collected) instance only delegates, its queues are torn down + if (module != null && !module.crashHandlerDetached) { + //The record pipeline runs developer code (crash filters) and allocation-heavy metric + //collection on a thread that is already crashing. If any of it throws, the exception must + //not escape this handler: the runtime would swallow it, every handler further down the + //chain - other Countly instances and Android's own KillApplicationHandler - would never + //run, no crash dialog would show, and the process would be left alive with a dead thread. + try { + module.recordUnhandledCrash(e); + } catch (Throwable recordFailure) { + try { + module.L.e("[ModuleCrash] uncaughtException, failed to record the crash, delegating anyway [" + recordFailure + "]"); + } catch (Throwable ignored) { + //the logger itself may run app code (log listener); nothing may stop the delegation } } + } - //if there was another handler before - if (oldHandler != null) { - //notify it also - oldHandler.uncaughtException(t, e); - } + //if there was another handler before, notify it also + if (previous != null) { + previous.uncaughtException(t, e); } - }; + } + } - Thread.setDefaultUncaughtExceptionHandler(handler); + /** + * Records an unhandled crash on this instance. Kept off the handler itself so the handler can stay a + * static class with no strong link back to this module. + */ + private void recordUnhandledCrash(@NonNull Throwable e) { + L.d("[ModuleCrash] Uncaught crash handler triggered"); + if (consentProvider.getConsent(Countly.CountlyFeatureNames.crashes) && configProvider.getAutomaticCrashReportingEnabled()) { + String stackTrace = prepareStackTrace(e); + CrashData crashData = prepareCrashData(stackTrace, false, false, null); + if (!crashFilterCheck(crashData, false)) { + sendCrashReportToQueue(crashData, false); + } + } } /** @@ -261,12 +336,12 @@ boolean crashFilterCheck(@NonNull CrashData crashData, final boolean isNativeCra crashData.calculateChangedFields(); - UtilsInternalLimits.applyInternalLimitsToBreadcrumbs(crashData.getBreadcrumbs(), _cly.config_.sdkInternalLimits, L, "[ModuleCrash] sendCrashReportToQueue"); - UtilsInternalLimits.applySdkInternalLimitsToSegmentation(crashData.getCrashSegmentation(), _cly.config_.sdkInternalLimits, L, "[ModuleCrash] sendCrashReportToQueue"); + UtilsInternalLimits.applyInternalLimitsToBreadcrumbs(crashData.getBreadcrumbs(), _cly.sdkInternalLimits_, L, "[ModuleCrash] sendCrashReportToQueue"); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(crashData.getCrashSegmentation(), _cly.sdkInternalLimits_, L, "[ModuleCrash] sendCrashReportToQueue"); // Stack trace line limits must not be applied to native crashes: the "stack trace" of a // native crash is a single-line base64 dump, so per-line truncation would corrupt the dump. if (!isNativeCrash) { - String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(crashData.getStackTrace(), _cly.config_.sdkInternalLimits.maxStackTraceLineLength, "[ModuleCrash] sendCrashReportToQueue", L); + String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(crashData.getStackTrace(), _cly.sdkInternalLimits_.maxStackTraceLineLength, "[ModuleCrash] sendCrashReportToQueue", L); crashData.setStackTrace(truncatedStackTrace); } UtilsInternalLimits.removeUnsupportedDataTypes(crashData.getCrashSegmentation(), L); @@ -349,7 +424,7 @@ Countly addBreadcrumbInternal(@Nullable String breadcrumb) { return _cly; } - breadcrumbHelper.addBreadcrumb(breadcrumb, _cly.config_.sdkInternalLimits.maxValueSize, _cly.config_.sdkInternalLimits.maxBreadcrumbCount); + breadcrumbHelper.addBreadcrumb(breadcrumb, _cly.sdkInternalLimits_.maxValueSize, _cly.sdkInternalLimits_.maxBreadcrumbCount); return _cly; } @@ -375,8 +450,18 @@ void initFinished(@NonNull CountlyConfig config) { //check for previous native crash dumps if (config.crashes.checkForNativeCrashDumps) { - //flag so that this can be turned off during testing - _cly.moduleCrash.checkForNativeCrashDumps(config.context); + //sdk-native writes dumps to one fixed process-wide path, so only the storage owner may + //consume them - otherwise the first instance to init claims every dump under its app key. + if (_cly.storageNamespace_.isEmpty()) { + //flag so that this can be turned off during testing + //called directly: _cly.moduleCrash is this very module + checkForNativeCrashDumps(config.context); + } else { + //Warn, not debug: reading the folder is also what deletes it, so in an app that only ever + //initialises named instances nothing consumes the dumps and they accumulate on disk. + //Initialise the default instance as well if you use native crash reporting. + L.w("[ModuleCrash] initFinished, skipping the native crash dump check: the process-global dump folder belongs to the default instance, so native crashes are only reported (and the dumps only deleted) when the default instance is initialised"); + } } } @@ -398,7 +483,38 @@ void onSdkConfigurationChanged(@NonNull CountlyConfig config) { @Override void halt() { + //stop recording first, so a crash racing this teardown can not touch the dying queues. This runs + //before any check: Countly#tearDown reaches the module halts after the store and the connection + //queue are already gone, so leaving this instance recording for even a moment longer is exactly + //what the flag exists to prevent. + crashHandlerDetached = true; + + //Read installedCrashHandler under the same lock that installs it, so an install racing this teardown + //cannot slip between the null check and the unlink. Log outside the lock: informListener runs the + //app's LogCallback, and no other SDK lock is ever held while calling into app code. + String outcome = null; + synchronized (crashHandlerLock) { + if (installedCrashHandler != null) { + if (Thread.getDefaultUncaughtExceptionHandler() == installedCrashHandler) { + //still the process default, so restoring unlinks us entirely and makes the instance collectable + Thread.setDefaultUncaughtExceptionHandler(previousCrashHandler); + outcome = "[ModuleCrash] halt, restored the previously installed uncaught exception handler"; + } else { + //Another handler sits on top and there is no way to remove a link from the middle of the + //chain, so ours stays there for the life of the process, neutralised. It only holds this + //module weakly, so the halted instance is still collectable; it keeps delegating downstream. + outcome = "[ModuleCrash] halt, another uncaught exception handler was installed on top of this one, it stays in the chain but will no longer record crashes"; + } + installedCrashHandler = null; + previousCrashHandler = null; + unhandledCrashHandlerInstalled = false; + } + } + + if (outcome != null) { + L.d(outcome); + } } public class Crashes { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java index 66c166882..44cd6e354 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java @@ -94,30 +94,51 @@ void exitTemporaryIdMode(@NonNull String deviceId) { throw new IllegalStateException("init must be called before exitTemporaryIdMode"); } + //Sibling modules come through _cly and teardown nulls those fields. Each is read once into a local - + //reading the field twice could return null after the check - and a module that is already gone only + //costs its own follow-up step. Exiting temporary mode itself still happens, because the stored ID is + //what the developer asked to change. + ModuleConfiguration configurationModule = _cly.moduleConfiguration; + ModuleRemoteConfig remoteConfigModule = _cly.moduleRemoteConfig; + ModuleHealthCheck healthCheckModule = _cly.moduleHealthCheck; + //start by changing stored ID deviceIdInstance.changeToCustomId(deviceId); // trigger fetching if the temp id given on init - _cly.moduleConfiguration.fetchConfigFromServer(_cly.config_); + if (configurationModule != null) { + configurationModule.fetchConfigFromServer(_cly.config_); + } else { + L.w("[ModuleDeviceId] exitTemporaryIdMode, the configuration module is gone, not re-fetching the server config"); + } // Resume the content zone now that a real device ID is set again. The config re-fetch above // only notifies modules when a value changes, so an unchanged (still enabled) content-zone // value would otherwise leave the zone torn down after exiting temporary mode. This runs only // here (not on a generic device ID change), so a plain changeWithoutMerge does not re-arm it. - if (_cly.moduleContent != null) { - _cly.moduleContent.resumeContentZoneAfterTemporaryIdExit(); + ModuleContent contentModule = _cly.moduleContent; + if (contentModule != null) { + contentModule.resumeContentZoneAfterTemporaryIdExit(); } //update stored request for ID change to use this new ID replaceTempIDWithRealIDinRQ(deviceId); //update remote config_ values if automatic update is enabled - _cly.moduleRemoteConfig.RCAutomaticDownloadTrigger(false); + if (remoteConfigModule != null) { + remoteConfigModule.RCAutomaticDownloadTrigger(false); + } else { + L.w("[ModuleDeviceId] exitTemporaryIdMode, the remote config module is gone, not triggering a download"); + } _cly.requestQueue().attemptToSendStoredRequests(); // trigger sending if the temp id given on init - _cly.moduleHealthCheck.sendHealthCheck(); + if (healthCheckModule != null) { + healthCheckModule.sendHealthCheck(); + } else { + L.w("[ModuleDeviceId] exitTemporaryIdMode, the health check module is gone, not sending a health check"); + } } /** @@ -151,22 +172,48 @@ void changeDeviceIdWithoutMergeInternal(@NonNull String deviceId) { // we are either making a simple ID change or entering temporary mode // in both cases we act the same as the temporary ID requests will be updated with the final ID later + //Every sibling module is read once into a local - reading a field twice could return null after the + //check - and each step is skipped on its own. The device ID change itself still goes through, because + //that is what the developer asked for; only the follow-up work a missing module owned is dropped. + ModuleRequestQueue requestQueueModule = _cly.moduleRequestQueue; + ModuleUserProfile userProfileModule = _cly.moduleUserProfile; + ModuleRemoteConfig remoteConfigModule = _cly.moduleRemoteConfig; + ModuleSessions sessionsModule = _cly.moduleSessions; + ModuleConsent consentModule = _cly.moduleConsent; + ModuleRatings ratingsModule = _cly.moduleRatings; + //force flush events so that they are associated correctly - _cly.moduleRequestQueue.sendEventsIfNeeded(true); + if (requestQueueModule != null) { + requestQueueModule.sendEventsIfNeeded(true); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithoutMerge, the request queue module is gone, not flushing events"); + } //send user profile data because we are flushing the event queue - _cly.moduleUserProfile.saveInternal(); + if (userProfileModule != null) { + userProfileModule.saveInternal(); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithoutMerge, the user profile module is gone, not saving pending profile changes"); + } //update remote config_ values after id change if automatic update is enabled - _cly.moduleRemoteConfig.clearAndDownloadAfterIdChange(); + if (remoteConfigModule != null) { + remoteConfigModule.clearAndDownloadAfterIdChange(); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithoutMerge, the remote config module is gone, not clearing remote config"); + } - if (_cly.moduleSessions.automaticSessionTrackingEnabled()) { + if (sessionsModule != null && sessionsModule.automaticSessionTrackingEnabled()) { //if automatic session tracking is active, end the current session - _cly.moduleSessions.endSessionInternal(); // this will check consent + sessionsModule.endSessionInternal(); // this will check consent } //remove all consent - _cly.moduleConsent.removeConsentAllInternal(ModuleConsent.ConsentChangeSource.DeviceIDChangedNotMerged); + if (consentModule != null) { + consentModule.removeConsentAllInternal(ModuleConsent.ConsentChangeSource.DeviceIDChangedNotMerged); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithoutMerge, the consent module is gone, not removing consent"); + } if (deviceId.equals(ly.count.android.sdk.DeviceId.temporaryCountlyDeviceId)) { // entering temp ID mode @@ -177,7 +224,11 @@ void changeDeviceIdWithoutMergeInternal(@NonNull String deviceId) { } //clear automated star rating session values because now we have a new user - _cly.moduleRatings.clearAutomaticStarRatingSessionCountInternal(); + if (ratingsModule != null) { + ratingsModule.clearAutomaticStarRatingSessionCountInternal(); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithoutMerge, the ratings module is gone, not clearing the star rating session count"); + } _cly.notifyDeviceIdChange(true); } @@ -221,7 +272,12 @@ void changeDeviceIdWithMergeInternal(@NonNull String deviceId) { // in both cases we act the same as the temporary ID requests will be updated with the final ID later //update remote config_ values after id change if automatic update is enabled - _cly.moduleRemoteConfig.clearAndDownloadAfterIdChange(); + ModuleRemoteConfig remoteConfigModule = _cly.moduleRemoteConfig; + if (remoteConfigModule != null) { + remoteConfigModule.clearAndDownloadAfterIdChange(); + } else { + L.w("[ModuleDeviceId] changeDeviceIdWithMerge, the remote config module is gone, not clearing remote config"); + } requestQueueProvider.changeDeviceId(deviceId, deviceIdInstance.getCurrentId()); deviceIdInstance.changeToCustomId(deviceId); _cly.notifyDeviceIdChange(false); @@ -281,12 +337,15 @@ void halt() { @Override @NonNull public String getUUID() { String retrievedID; - SharedPreferences mPreferences = _cly.context_.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + // Namespace the legacy OpenUDID file the same way as the main store, so two instances do + // not share (and regenerate over) one another's generated UUID. Default instance keeps the + // legacy "openudid_prefs" file. + SharedPreferences mPreferences = _cly.context_.getSharedPreferences(CountlyStore.namespacedName(PREFS_NAME, _cly.storageNamespace_), Context.MODE_PRIVATE); //Try to get the stored UUID from local preferences retrievedID = mPreferences.getString(PREF_KEY, null); if (retrievedID == null) //Not found if temp storage { - Countly.sharedInstance().L.d("[ModuleDeviceId] getUUID, Generating UUID"); + L.d("[ModuleDeviceId] getUUID, Generating UUID"); retrievedID = UUID.randomUUID().toString(); final SharedPreferences.Editor e = mPreferences.edit(); @@ -294,7 +353,7 @@ void halt() { e.apply(); } - Countly.sharedInstance().L.d("[ModuleDeviceId] getUUID, retrievedID:[" + retrievedID + "]"); + L.d("[ModuleDeviceId] getUUID, retrievedID:[" + retrievedID + "]"); return retrievedID; } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index bc7c53356..b555a21cf 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -7,7 +7,9 @@ import ly.count.android.sdk.messaging.ModulePush; public class ModuleEvents extends ModuleBase implements EventProvider { - static final 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"; @@ -72,6 +74,30 @@ void checkCachedPushData(CountlyStore cs) { * @param instant * @param eventIdOverride */ + /** + * Every event flush goes through here. The request queue module is reached through _cly and teardown + * nulls that field, so a flush on a dying instance skips - leaving the event in the queue for the next + * init to pick up - instead of throwing. Snapshotted on each call rather than once per event, because the + * field can go null between a check and a use. + */ + private void sendEventsIfNeededWhenPossible(boolean sendEvents) { + ModuleRequestQueue requestQueueModule = _cly.moduleRequestQueue; + if (requestQueueModule != null) { + requestQueueModule.sendEventsIfNeeded(sendEvents); + } else { + L.w("[ModuleEvents] sendEventsIfNeededWhenPossible, the request queue module is gone, leaving the events queued"); + } + } + + private void sendEventsIfNeededWhenPossible(boolean sendEvents, boolean triggerRefreshContentZone) { + ModuleRequestQueue requestQueueModule = _cly.moduleRequestQueue; + if (requestQueueModule != null) { + requestQueueModule.sendEventsIfNeeded(sendEvents, triggerRefreshContentZone); + } else { + L.w("[ModuleEvents] sendEventsIfNeededWhenPossible, the request queue module is gone, leaving the events queued"); + } + } + public void recordEventInternal(@Nullable final String key, @Nullable Map segmentation, int count, final double sum, final double dur, UtilsTime.Instant instant, final String eventIdOverride) { //assert key != null; assert count >= 1; @@ -126,10 +152,17 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map currentActivity; ContentOverlayView feedbackOverlay; + private @Nullable Activity getCurrentActivity() { + return currentActivity != null ? currentActivity.get() : null; + } + ModuleFeedback(Countly cly, CountlyConfig config) { super(cly, config); L.v("[ModuleFeedback] Initialising"); @@ -58,7 +67,7 @@ public static class CountlyFeedbackWidget implements Serializable { @Override void onInitialActivitySeeded(@NonNull Activity activity) { L.d("[ModuleFeedback] onInitialActivitySeeded, activity: [" + activity.getClass().getSimpleName() + "]"); - currentActivity = activity; + currentActivity = new WeakReference<>(activity); } @Override @@ -67,7 +76,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { return; } - currentActivity = activity; + currentActivity = new WeakReference<>(activity); // Move existing feedback overlay to the new activity if (feedbackOverlay != null && !activity.isFinishing() && !activity.isDestroyed()) { @@ -91,7 +100,7 @@ void onActivityStopped(int updatedActivityCount) { void onActivityDestroyed(@NonNull Activity activity) { // Identity check guards against clearing a newer activity when the destroy callback // for an older activity arrives after onActivityStarted of the next one (rotation race). - if (currentActivity == activity) { + if (getCurrentActivity() == activity) { currentActivity = null; } // The overlay itself is intentionally kept alive across activity transitions. @@ -147,15 +156,19 @@ void getAvailableFeedbackWidgetsInternal(final RetrieveFeedbackWidgets devCallba L.d("[ModuleFeedback] Retrieved request: [" + checkResponse.toString() + "]"); - List feedbackEntries = parseFeedbackList(checkResponse); + List feedbackEntries = parseFeedbackList(checkResponse, L); devCallback.onFinished(feedbackEntries, null); } }, L); } - static List parseFeedbackList(JSONObject requestResponse) { - Countly.sharedInstance().L.d("[ModuleFeedback] calling 'parseFeedbackList'"); + /** + * @param L logger of the instance that requested this widget list, so widget ids, types and tags are + * reported to that instance's log listener rather than the default instance's + */ + static List parseFeedbackList(JSONObject requestResponse, @NonNull ModuleLog L) { + L.d("[ModuleFeedback] calling 'parseFeedbackList'"); List parsedRes = new ArrayList<>(); try { @@ -163,7 +176,7 @@ static List parseFeedbackList(JSONObject requestResponse) JSONArray jArray = requestResponse.optJSONArray("result"); if (jArray == null) { - Countly.sharedInstance().L.w("[ModuleFeedback] parseFeedbackList, response does not have a valid 'result' entry. No widgets retrieved."); + L.w("[ModuleFeedback] parseFeedbackList, response does not have a valid 'result' entry. No widgets retrieved."); return parsedRes; } @@ -179,7 +192,7 @@ static List parseFeedbackList(JSONObject requestResponse) JSONArray jTagArr = jObj.optJSONArray("tg"); if (jTagArr == null) { - Countly.sharedInstance().L.w("[ModuleFeedback] parseFeedbackList, no tags received"); + L.w("[ModuleFeedback] parseFeedbackList, no tags received"); } else { for (int in = 0; in < jTagArr.length(); in++) { valTagsArr.add(jTagArr.getString(in)); @@ -187,12 +200,12 @@ static List parseFeedbackList(JSONObject requestResponse) } if (valId.isEmpty()) { - Countly.sharedInstance().L.e("[ModuleFeedback] parseFeedbackList, retrieved invalid entry with null or empty widget id, dropping"); + L.e("[ModuleFeedback] parseFeedbackList, retrieved invalid entry with null or empty widget id, dropping"); continue; } if (valType.isEmpty()) { - Countly.sharedInstance().L.e("[ModuleFeedback] parseFeedbackList, retrieved invalid entry with null or empty widget type, dropping"); + L.e("[ModuleFeedback] parseFeedbackList, retrieved invalid entry with null or empty widget type, dropping"); continue; } @@ -204,7 +217,7 @@ static List parseFeedbackList(JSONObject requestResponse) } else if (valType.equals("rating")) { plannedType = FeedbackWidgetType.rating; } else { - Countly.sharedInstance().L.e("[ModuleFeedback] parseFeedbackList, retrieved unknown widget type, dropping"); + L.e("[ModuleFeedback] parseFeedbackList, retrieved unknown widget type, dropping"); continue; } @@ -217,12 +230,12 @@ static List parseFeedbackList(JSONObject requestResponse) parsedRes.add(se); } catch (Exception ex) { - Countly.sharedInstance().L.e("[ModuleFeedback] parseFeedbackList, failed to parse json, [" + ex.toString() + "]"); + L.e("[ModuleFeedback] parseFeedbackList, failed to parse json, [" + ex.toString() + "]"); } } } } catch (Exception ex) { - Countly.sharedInstance().L.e("[ModuleFeedback] parseFeedbackList, Encountered exception while parsing feedback list, [" + ex.toString() + "]"); + L.e("[ModuleFeedback] parseFeedbackList, Encountered exception while parsing feedback list, [" + ex.toString() + "]"); } return parsedRes; @@ -299,9 +312,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 @@ -371,7 +384,7 @@ private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInf webView.clearHistory(); webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); Utils.applyWebViewSecurityDefaults(webView.getSettings()); - ModuleRatings.FeedbackDialogWebViewClient webViewClient = new ModuleRatings.FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes); + ModuleRatings.FeedbackDialogWebViewClient webViewClient = new ModuleRatings.FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes, L); webView.setWebViewClient(webViewClient); webView.loadUrl(url); webView.requestFocus(); @@ -404,10 +417,11 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url } Activity activity = null; + Activity heldActivity = getCurrentActivity(); if (context instanceof Activity && !((Activity) context).isFinishing()) { activity = (Activity) context; - } else if (currentActivity != null && !currentActivity.isFinishing()) { - activity = currentActivity; + } else if (heldActivity != null && !heldActivity.isFinishing()) { + activity = heldActivity; } if (activity == null) { @@ -419,7 +433,8 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url } // Do not show feedback widget if content overlay is currently showing - if (_cly.moduleContent != null && _cly.moduleContent.contentOverlay != null) { + ModuleContent contentModule = _cly.moduleContent; + if (contentModule != null && contentModule.contentOverlay != null) { L.w("[ModuleFeedback] showFeedbackWidget_newActivity, content overlay is currently showing, skipping feedback widget"); if (devCallback != null) { devCallback.onFinished("Content overlay is currently showing"); @@ -496,6 +511,17 @@ 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"); + //every other skip in this method reports back, so a caller awaiting the callback is not left hanging + if (devCallback != null) { + devCallback.onFinished("A content or feedback overlay is already being shown"); + } + return; + } + // Clean up any existing feedback overlay if (feedbackOverlay != null) { feedbackOverlay.destroy(); @@ -504,6 +530,7 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url final Activity hostActivity = activity; feedbackOverlay = new ContentOverlayView( + _cly, hostActivity, pConfig, lConfig, @@ -593,9 +620,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 +633,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"); @@ -666,7 +693,7 @@ void reportFeedbackWidgetManuallyInternal(@Nullable CountlyFeedbackWidget widget if (entry.getValue() instanceof String) { // TODO, if applicable think about applying key and segmentation count limit for the widget result - String truncatedValue = UtilsInternalLimits.truncateValueSize(entry.getValue().toString(), _cly.config_.sdkInternalLimits.maxValueSize, L, "[ModuleFeedback] reportFeedbackWidgetManuallyInternal"); + String truncatedValue = UtilsInternalLimits.truncateValueSize(entry.getValue().toString(), _cly.sdkInternalLimits_.maxValueSize, L, "[ModuleFeedback] reportFeedbackWidgetManuallyInternal"); if (!truncatedValue.equals(entry.getValue())) { entry.setValue(truncatedValue); } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java index dc84ebb4b..18df58528 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java @@ -49,9 +49,9 @@ void sendHealthCheck() { return; } - // why _cly? because module health is created last. So device id provider - // call order to module device id is before module health check and device id provider is module device id - if (_cly.config_.deviceIdProvider.isTemporaryIdEnabled()) { + // this module is constructed before ModuleDeviceId, so its own deviceIdProvider is filled in by + // the provider wiring block in Countly#init rather than by the ModuleBase constructor + if (deviceIdProvider.isTemporaryIdEnabled()) { //temporary id mode enabled, abort L.d("[ModuleHealthCheck] sendHealthCheck, sending health info of the SDK to server is aborted, temporary device ID mode is set"); return; 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/ModuleRatings.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java index 79ccc71d5..6cde9ee75 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -75,8 +75,8 @@ void recordManualRatingInternal(String widgetId, int rating, String email, Strin L.d("[ModuleRatings] recordManualRatingInternal, given rating too high, defaulting to 5"); } - String truncatedEmail = UtilsInternalLimits.truncateValueSize(email, _cly.config_.sdkInternalLimits.maxValueSize, L, "[ModuleRatings] recordManualRatingInternal"); - String truncatedComment = UtilsInternalLimits.truncateValueSize(comment, _cly.config_.sdkInternalLimits.maxValueSize, L, "[ModuleRatings] recordManualRatingInternal"); + String truncatedEmail = UtilsInternalLimits.truncateValueSize(email, _cly.sdkInternalLimits_.maxValueSize, L, "[ModuleRatings] recordManualRatingInternal"); + String truncatedComment = UtilsInternalLimits.truncateValueSize(comment, _cly.sdkInternalLimits_.maxValueSize, L, "[ModuleRatings] recordManualRatingInternal"); Map segm = new HashMap<>(); segm.put("platform", "android"); @@ -102,7 +102,7 @@ void recordManualRatingInternal(String widgetId, int rating, String email, Strin * @param srp */ private void saveStarRatingPreferences(final StarRatingPreferences srp) { - storageProvider.setStarRatingPreferences(srp.toJSON().toString()); + storageProvider.setStarRatingPreferences(srp.toJSON(L).toString()); } /** @@ -114,7 +114,7 @@ private void saveStarRatingPreferences(final StarRatingPreferences srp) { * @param starRatingTextDismiss provided dismiss text */ void setStarRatingInitConfig(final int limit, final String starRatingTextTitle, final String starRatingTextMessage, final String starRatingTextDismiss) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); if (limit >= 0) { srp.sessionLimit = limit; @@ -141,13 +141,13 @@ void setStarRatingInitConfig(final int limit, final String starRatingTextTitle, * @param shouldShow */ void setShowDialogAutomatically(final boolean shouldShow) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); srp.automaticRatingShouldBeShown = shouldShow; saveStarRatingPreferences(srp); } boolean getIfStarRatingShouldBeShownAutomatically() { - StarRatingPreferences srp = loadStarRatingPreferences(_cly.countlyStore); + StarRatingPreferences srp = loadStarRatingPreferences(_cly.countlyStore, L); return srp.automaticRatingShouldBeShown; } @@ -159,7 +159,7 @@ boolean getIfStarRatingShouldBeShownAutomatically() { * @param disableAsking if set true, will not show star rating for every new app version */ void setStarRatingDisableAskingForEachAppVersion(final boolean disableAsking) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); srp.disabledAutomaticForNewVersions = disableAsking; saveStarRatingPreferences(srp); } @@ -171,7 +171,7 @@ void setStarRatingDisableAskingForEachAppVersion(final boolean disableAsking) { * @param starRatingCallback */ void registerAppSession(final Context context, final StarRatingCallback starRatingCallback) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); String currentAppVersion = deviceInfo.mp.getAppVersion(context); @@ -194,8 +194,8 @@ void registerAppSession(final Context context, final StarRatingCallback starRati /** * Returns the session limit set for automatic star rating */ - static int getAutomaticStarRatingSessionLimitInternal(final StorageProvider sp) { - StarRatingPreferences srp = loadStarRatingPreferences(sp); + static int getAutomaticStarRatingSessionLimitInternal(final StorageProvider sp, @NonNull ModuleLog L) { + StarRatingPreferences srp = loadStarRatingPreferences(sp, L); return srp.sessionLimit; } @@ -205,7 +205,7 @@ static int getAutomaticStarRatingSessionLimitInternal(final StorageProvider sp) * @return */ int getCurrentVersionsSessionCountInternal(final StorageProvider sp) { - StarRatingPreferences srp = loadStarRatingPreferences(sp); + StarRatingPreferences srp = loadStarRatingPreferences(sp, L); return srp.sessionAmount; } @@ -213,7 +213,7 @@ int getCurrentVersionsSessionCountInternal(final StorageProvider sp) { * Set the automatic star rating session count back to 0 */ void clearAutomaticStarRatingSessionCountInternal() { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); srp.sessionAmount = 0; saveStarRatingPreferences(srp); } @@ -224,7 +224,7 @@ void clearAutomaticStarRatingSessionCountInternal() { * @param isCancellable */ void setIfRatingDialogIsCancellableInternal(final boolean isCancellable) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); srp.isDialogCancellable = isCancellable; saveStarRatingPreferences(srp); } @@ -262,7 +262,7 @@ static class StarRatingPreferences { * * @return */ - JSONObject toJSON() { + JSONObject toJSON(@NonNull ModuleLog L) { final JSONObject json = new JSONObject(); try { @@ -278,7 +278,7 @@ JSONObject toJSON() { json.put(KEY_DIALOG_TEXT_MESSAGE, dialogTextMessage); json.put(KEY_DIALOG_TEXT_DISMISS, dialogTextDismiss); } catch (JSONException e) { - Countly.sharedInstance().L.w("Got exception converting an StarRatingPreferences to JSON", e); + L.w("Got exception converting an StarRatingPreferences to JSON", e); } return json; @@ -290,7 +290,7 @@ JSONObject toJSON() { * @param json * @return */ - static StarRatingPreferences fromJSON(final JSONObject json) { + static StarRatingPreferences fromJSON(final JSONObject json, @NonNull ModuleLog L) { StarRatingPreferences srp = new StarRatingPreferences(); @@ -317,7 +317,7 @@ static StarRatingPreferences fromJSON(final JSONObject json) { srp.dialogTextDismiss = json.getString(KEY_DIALOG_TEXT_DISMISS); } } catch (JSONException e) { - Countly.sharedInstance().L.w("Got exception converting JSON to a StarRatingPreferences", e); + L.w("Got exception converting JSON to a StarRatingPreferences", e); } } @@ -332,7 +332,7 @@ static StarRatingPreferences fromJSON(final JSONObject json) { * @param callback */ void showStarRatingInternal(final Context context, final StarRatingCallback callback) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); showStarRatingCustom(context, srp.dialogTextTitle, srp.dialogTextMessage, srp.dialogTextDismiss, srp.isDialogCancellable, callback); } @@ -342,7 +342,7 @@ void showStarRatingInternal(final Context context, final StarRatingCallback call * * @return */ - static StarRatingPreferences loadStarRatingPreferences(final StorageProvider sp) { + static StarRatingPreferences loadStarRatingPreferences(final StorageProvider sp, @NonNull ModuleLog L) { String srpString = sp.getStarRatingPreferences(); StarRatingPreferences srp; @@ -350,7 +350,7 @@ static StarRatingPreferences loadStarRatingPreferences(final StorageProvider sp) JSONObject srJSON; try { srJSON = new JSONObject(srpString); - srp = StarRatingPreferences.fromJSON(srJSON); + srp = StarRatingPreferences.fromJSON(srJSON, L); } catch (JSONException e) { e.printStackTrace(); srp = new StarRatingPreferences(); @@ -540,7 +540,7 @@ public void run() { webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); webView.getSettings().setJavaScriptEnabled(true); Utils.applyWebViewSecurityDefaults(webView.getSettings()); - webView.setWebViewClient(new FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes)); + webView.setWebViewClient(new FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes, L)); webView.loadUrl(ratingWidgetUrl); AlertDialog.Builder builder = new AlertDialog.Builder(activity); @@ -591,12 +591,13 @@ static class FeedbackDialogWebViewClient extends WebViewClient { // null -> default denylist; non-empty -> allow-list mode (sourced from config.content). private final Set allowedSchemes; - FeedbackDialogWebViewClient() { - this(null); - } + // Logger of the instance that opened this dialog. Blocked-scheme decisions are security + // relevant, so they must reach that instance's log listener, not the default instance's. + @NonNull private final ModuleLog L; - FeedbackDialogWebViewClient(Set allowedSchemes) { + FeedbackDialogWebViewClient(Set allowedSchemes, @NonNull ModuleLog L) { this.allowedSchemes = allowedSchemes; + this.L = L; } @Override @@ -616,7 +617,7 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request // are not dispatched to ACTION_VIEW from server content, honoring any configured // allow-list the same way the content overlay does. if (!Utils.isExternalSchemeAllowed(link.getScheme(), allowedSchemes)) { - Countly.sharedInstance().L.w("[FeedbackDialogWebViewClient] Blocked link with disallowed scheme: [" + link.getScheme() + "]"); + L.w("[FeedbackDialogWebViewClient] Blocked link with disallowed scheme: [" + link.getScheme() + "]"); return true; } Intent intent = new Intent(Intent.ACTION_VIEW, link); @@ -642,7 +643,7 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque private WebResourceResponse interceptScheme(Uri uri) { String scheme = uri == null ? null : uri.getScheme(); if (!Utils.isWebContentSchemeAllowed(scheme, allowedSchemes)) { - Countly.sharedInstance().L.v("[FeedbackDialogWebViewClient] Blocked sub-resource with disallowed scheme: [" + uri + "]"); + L.v("[FeedbackDialogWebViewClient] Blocked sub-resource with disallowed scheme: [" + uri + "]"); return Utils.blankWebResourceResponse(); } return null; @@ -652,7 +653,7 @@ private WebResourceResponse interceptScheme(Uri uri) { @Override void callbackOnActivityResumed(Activity activity) { if (showStarRatingDialogOnFirstActivity) { - StarRatingPreferences srp = loadStarRatingPreferences(storageProvider); + StarRatingPreferences srp = loadStarRatingPreferences(storageProvider, L); srp.isShownForCurrentVersion = true; srp.automaticHasBeenShown = true; @@ -786,7 +787,7 @@ public void clearAutomaticStarRatingSessionCount() { */ public int getAutomaticStarRatingSessionLimit() { synchronized (_cly) { - int sessionLimit = ModuleRatings.getAutomaticStarRatingSessionLimitInternal(_cly.countlyStore); + int sessionLimit = ModuleRatings.getAutomaticStarRatingSessionLimitInternal(_cly.countlyStore, L); L.i("[Ratings] Getting automatic star rating session limit: [" + sessionLimit + "]"); 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..0b508c8f1 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRemoteConfig.java @@ -112,7 +112,7 @@ void updateRemoteConfigValues(@Nullable final String[] keysOnly, @Nullable final } String error = null; - Map newRC = RemoteConfigHelper.DownloadedValuesIntoMap(checkResponse); + Map newRC = RemoteConfigHelper.DownloadedValuesIntoMap(checkResponse, L); try { boolean clearOldValues = keysExcept == null && keysOnly == null; @@ -329,7 +329,7 @@ void saveConfig(@NonNull RemoteConfigValueStore rcvs) { @NonNull RemoteConfigValueStore loadConfig() { String rcvsString = storageProvider.getRemoteConfigValues(); //noinspection UnnecessaryLocalVariable - RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcvsString, remoteConfigValuesShouldBeCached); + RemoteConfigValueStore rcvs = RemoteConfigValueStore.dataFromString(rcvsString, remoteConfigValuesShouldBeCached, L); return rcvs; } @@ -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/ModuleRequestQueue.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java index ee54c0d29..2d5eb0911 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java @@ -167,8 +167,13 @@ protected void sendEventsIfNeeded(boolean forceSendingEvents, boolean triggerRef if (triggerRefreshContentZone) { callback = new InternalRequestCallback() { @Override public void onRequestCompleted(String response, boolean success) { - if (success) { - _cly.moduleContent.refreshContentZoneInternal(false); + //This lands on the network thread after the HTTP round trip, so the instance may have been + //torn down in between - removeInstance's flush can record a journey-trigger view end and + //then null moduleContent microseconds later. ConnectionProcessor would swallow the NPE and + //abandon the request it had already delivered. + ModuleContent contentModule = _cly.moduleContent; + if (success && contentModule != null) { + contentModule.refreshContentZoneInternal(false); } } }; @@ -222,7 +227,10 @@ public void attemptToSendStoredRequestsInternal() { sendEventsIfNeeded(true); //save the user profile changes if any - _cly.moduleUserProfile.saveInternal(); + ModuleUserProfile userProfileModule = _cly.moduleUserProfile; + if (userProfileModule != null) { + userProfileModule.saveInternal(); + } //trigger the processing of the request queue requestQueueProvider.tick(); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java index 3439ab447..bef7e8126 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java @@ -56,16 +56,35 @@ void beginSessionInternal() { return; } + //Sibling modules are reached through _cly and teardown nulls those fields, so each one is read once + //into a local before it is used - reading the field twice could return null after the check. A module + //that is already gone only costs its own step; the session itself still begins, because a + //begin_session that never went out would leave a later end_session with nothing to close. + ModuleUserProfile userProfile = _cly.moduleUserProfile; + ModuleLocation location = _cly.moduleLocation; + ModuleViews views = _cly.moduleViews; + //prepare metrics String preparedMetrics = deviceInfo.getMetrics(_cly.context_, metricOverride, L); sessionRunning = true; prevSessionDurationStartTime_ = System.currentTimeMillis(); - _cly.moduleUserProfile.saveInternal(); + if (userProfile != null) { + userProfile.saveInternal(); + } else { + L.w("[ModuleSessions] beginSessionInternal, the user profile module is gone, not saving pending profile changes"); + } - requestQueueProvider.beginSession(_cly.moduleLocation.locationDisabled, _cly.moduleLocation.locationCountryCode, _cly.moduleLocation.locationCity, _cly.moduleLocation.locationGpsCoordinates, _cly.moduleLocation.locationIpAddress, preparedMetrics); + if (location != null) { + requestQueueProvider.beginSession(location.locationDisabled, location.locationCountryCode, location.locationCity, location.locationGpsCoordinates, location.locationIpAddress, preparedMetrics); + } else { + //ModuleLocation's own field defaults are exactly these, so this is "no location information" + //rather than a guess - prepareLocationData appends nothing for them + L.w("[ModuleSessions] beginSessionInternal, the location module is gone, beginning the session without location information"); + requestQueueProvider.beginSession(false, null, null, null, null, preparedMetrics); + } - if (_cly.moduleViews.trackOrientationChanges) { - _cly.moduleViews.updateOrientation(_cly.context_.getResources().getConfiguration().orientation, true); + if (views != null && views.trackOrientationChanges) { + views.updateOrientation(_cly.context_.getResources().getConfiguration().orientation, true); } } @@ -87,7 +106,12 @@ void updateSessionInternal() { } if (!_cly.disableUpdateSessionRequests_) { - _cly.moduleUserProfile.saveInternal(); + ModuleUserProfile userProfile = _cly.moduleUserProfile; + if (userProfile != null) { + userProfile.saveInternal(); + } else { + L.w("[ModuleSessions] updateSessionInternal, the user profile module is gone, not saving pending profile changes"); + } requestQueueProvider.updateSession(roundedSecondsSinceLastSessionDurationUpdate()); } @@ -110,14 +134,34 @@ void endSessionInternal(boolean checkConsent) { return; } - _cly.moduleRequestQueue.sendEventsIfNeeded(true); + //resetFirstView below is the frame that killed a CI run: the main thread was inside this method when + //a teardown on another thread nulled moduleViews, and the NPE escaped Activity.onStop. Each sibling is + //snapshotted and skipped on its own so that end_session still goes out when one of them is already + //gone - a session left open forever is worse than one that ends without its trailing bookkeeping. + ModuleRequestQueue requestQueue = _cly.moduleRequestQueue; + ModuleUserProfile userProfile = _cly.moduleUserProfile; + ModuleViews views = _cly.moduleViews; + + if (requestQueue != null) { + requestQueue.sendEventsIfNeeded(true); + } else { + L.w("[ModuleSessions] endSessionInternal, the request queue module is gone, not flushing events"); + } - _cly.moduleUserProfile.saveInternal(); + if (userProfile != null) { + userProfile.saveInternal(); + } else { + L.w("[ModuleSessions] endSessionInternal, the user profile module is gone, not saving pending profile changes"); + } requestQueueProvider.endSession(roundedSecondsSinceLastSessionDurationUpdate()); sessionRunning = false; - _cly.moduleViews.resetFirstView();//todo these scenarios need to be tested and validated + if (views != null) { + views.resetFirstView();//todo these scenarios need to be tested and validated + } else { + L.w("[ModuleSessions] endSessionInternal, the views module is gone, not resetting the first view flag"); + } } void endSessionInternal() { @@ -166,21 +210,25 @@ void onConsentChanged(@NonNull final List consentChangeDelta, final bool if (consentChangeDelta.contains(Countly.CountlyFeatureNames.sessions)) { if (newConsent) { //if consent was just given and automatic session tracking is active, start a session if we are in the foreground - if (automaticSessionTrackingEnabled() && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (automaticSessionTrackingEnabled() && _cly.lifeCycleAtleastStarted()) { beginSessionInternal(); } } else { L.d("[ModuleSessions] Ending session due to consent change"); - if (!_cly.isBeginSessionSent) { + ModuleLocation location = _cly.moduleLocation; + if (!_cly.isBeginSessionSent && location != null) { //if session consent was removed and first begins session was not sent //that means that we might not have sent the initially given location information - _cly.moduleLocation.sendCurrentLocationIfValid(); + location.sendCurrentLocationIfValid(); } if (sessionIsRunning()) { endSessionInternal(false); } else { - _cly.moduleViews.resetFirstView(); + ModuleViews views = _cly.moduleViews; + if (views != null) { + views.resetFirstView(); + } } } } @@ -188,7 +236,7 @@ void onConsentChanged(@NonNull final List consentChangeDelta, final bool @Override void initFinished(@NonNull CountlyConfig config) { - if (automaticSessionTrackingEnabled() && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (automaticSessionTrackingEnabled() && _cly.lifeCycleAtleastStarted()) { //start a session if we initialized in the foreground beginSessionInternal(); } @@ -202,7 +250,7 @@ void halt() { @Override void deviceIdChanged(boolean withoutMerge) { - if (automaticSessionTrackingEnabled() && withoutMerge && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (automaticSessionTrackingEnabled() && withoutMerge && _cly.lifeCycleAtleastStarted()) { L.d("[ModuleSessions] deviceIdChanged, automatic session control enabled and device id changed without merge, starting a new session"); beginSessionInternal(); } 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..92c7f33d7 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; @@ -149,7 +150,7 @@ protected JSONObject toJSON() { JSONObject ob; if (custom != null) { - UtilsInternalLimits.truncateSegmentationValues(custom, _cly.config_.sdkInternalLimits.maxSegmentationValues, "[ModuleUserProfile] toJSON", _cly.L); + UtilsInternalLimits.truncateSegmentationValues(custom, _cly.sdkInternalLimits_.maxSegmentationValues, "[ModuleUserProfile] toJSON", _cly.L); ob = new JSONObject(custom); } else { ob = new JSONObject(); @@ -226,9 +227,9 @@ void modifyCustomData(String key, Object value, String mod) { } Object valueAdded; - String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] modifyCustomData"); + String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.sdkInternalLimits_.maxKeyLength, _cly.L, "[ModuleUserProfile] modifyCustomData"); if (value instanceof String) { - valueAdded = UtilsInternalLimits.truncateValueSize((String) value, _cly.config_.sdkInternalLimits.maxValueSize, _cly.L, "[ModuleUserProfile] modifyCustomData"); + valueAdded = UtilsInternalLimits.truncateValueSize((String) value, _cly.sdkInternalLimits_.maxValueSize, _cly.L, "[ModuleUserProfile] modifyCustomData"); } else if (UtilsInternalLimits.isSupportedDataType(value)) { valueAdded = value; } else { @@ -290,9 +291,9 @@ void setPropertiesInternal(@NonNull Map data) { // limit to the picture path is applied when request is being made in the ConnectionProcessor if (value instanceof String) { if (key.equals(PICTURE_PATH_KEY) || key.equals(PICTURE_KEY)) { - value = UtilsInternalLimits.truncateValueSize(value.toString(), _cly.config_.sdkInternalLimits.maxValueSizePicture, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); + value = UtilsInternalLimits.truncateValueSize(value.toString(), _cly.sdkInternalLimits_.maxValueSizePicture, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); } else { - value = UtilsInternalLimits.truncateValueSize(value.toString(), _cly.config_.sdkInternalLimits.maxValueSize, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); + value = UtilsInternalLimits.truncateValueSize(value.toString(), _cly.sdkInternalLimits_.maxValueSize, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); } } @@ -312,7 +313,7 @@ void setPropertiesInternal(@NonNull Map data) { continue; } - String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); + String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.sdkInternalLimits_.maxKeyLength, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); if (UtilsInternalLimits.isSupportedDataType(value)) { dataCustomFields.put(truncatedKey, value); } else { @@ -336,8 +337,9 @@ void setPropertiesInternal(@NonNull Map data) { private void onUserPropertiesChanged(Map sourceMap) { applyUserPropertyCacheLimit(sourceMap); isSynced = false; - if (storageProvider.getEventQueueSize() > 0) { - _cly.moduleRequestQueue.sendEventsIfNeeded(true); + ModuleRequestQueue requestQueueModule = _cly.moduleRequestQueue; + if (requestQueueModule != null && storageProvider.getEventQueueSize() > 0) { + requestQueueModule.sendEventsIfNeeded(true); } } @@ -407,7 +409,12 @@ void saveInternal() { return; } - _cly.moduleRequestQueue.sendEventsIfNeeded(true); + //Only the event flush is skipped when the instance is being torn down - the user data itself is + //still sent, because this method is also what teardown calls to persist pending profile changes. + ModuleRequestQueue requestQueueModule = _cly.moduleRequestQueue; + if (requestQueueModule != null) { + requestQueueModule.sendEventsIfNeeded(true); + } requestQueueProvider.sendUserData(cachedUserData); clearInternal(); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java index ee7605882..852c05f38 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java @@ -7,6 +7,7 @@ import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -82,7 +83,11 @@ static class ViewData { config.viewIdProvider = this; safeViewIDGenerator = config.safeViewIDGenerator; - setGlobalViewSegmentationInternal(config.globalViewSegmentation); + //Copy first: setGlobalViewSegmentationInternal truncates keys/values and drops entries past the + //segmentation limit IN PLACE, and config.globalViewSegmentation is the developer's own map held by + //reference. Truncating it would apply THIS instance's resolved limits to the shared config, so a + //second instance built from the same config would only ever see the already-truncated entries. + setGlobalViewSegmentationInternal(config.globalViewSegmentation == null ? null : new LinkedHashMap<>(config.globalViewSegmentation)); autoTrackingActivityExceptions = config.automaticViewTrackingExceptions; trackOrientationChanges = config.trackOrientationChange; restartManualViews = !config.disableViewRestartForManualRecording; @@ -135,7 +140,7 @@ public void resetFirstView() { Map CreateViewEventSegmentation(@NonNull ViewData vd, boolean firstView, boolean visit, @NonNull Map customViewSegmentation) { Map viewSegmentation = new ConcurrentHashMap<>(customViewSegmentation); - String truncatedViewName = UtilsInternalLimits.truncateKeyLength(vd.viewName, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleViews] CreateViewEventSegmentation"); + String truncatedViewName = UtilsInternalLimits.truncateKeyLength(vd.viewName, _cly.sdkInternalLimits_.maxKeyLength, L, "[ModuleViews] CreateViewEventSegmentation"); viewSegmentation.put("name", truncatedViewName); if (visit) { viewSegmentation.put("visit", "1"); @@ -228,7 +233,11 @@ void autoCloseRequiredViews(boolean closeAllViews, @Nullable Map applyLimitsToViewSegmentation(customViewSegmentation, "startViewInternal", accumulatedEventSegm); - boolean firstViewInSession = firstView && _cly.moduleSessions.sessionIsRunning(); + //Read once into a local: teardown nulls moduleSessions, and this runs on the main thread via + //onActivityStarted. A missing sessions module means there is no session, which is the same answer + //sessionIsRunning() would give. + ModuleSessions sessionsModule = _cly.moduleSessions; + boolean firstViewInSession = firstView && sessionsModule != null && sessionsModule.sessionIsRunning(); Map viewSegmentation = CreateViewEventSegmentation(currentViewData, firstViewInSession, true, accumulatedEventSegm); @@ -325,7 +334,7 @@ void recordViewEndEvent(ViewData vd, @Nullable Map customViewSeg } applyLimitsToViewSegmentation(customViewSegmentation, "recordViewEndEvent", accumulatedEventSegm); - UtilsInternalLimits.truncateSegmentationValues(accumulatedEventSegm, _cly.config_.sdkInternalLimits.maxSegmentationValues, "[ModuleViews] recordViewEndEvent", L); + UtilsInternalLimits.truncateSegmentationValues(accumulatedEventSegm, _cly.sdkInternalLimits_.maxSegmentationValues, "[ModuleViews] recordViewEndEvent", L); long viewDurationSeconds = lastElapsedDurationSeconds; Map segments = CreateViewEventSegmentation(vd, false, false, accumulatedEventSegm); @@ -440,9 +449,9 @@ private void applyLimitsToViewSegmentation(@Nullable Map viewSeg assert function != null; UtilsInternalLimits.removeReservedKeysFromSegmentation(viewSegmentation, reservedSegmentationKeysViews, "[ModuleViews] " + function + ", ", L); - UtilsInternalLimits.applySdkInternalLimitsToSegmentation(viewSegmentation, _cly.config_.sdkInternalLimits, L, "[ModuleViews] " + function); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(viewSegmentation, _cly.sdkInternalLimits_, L, "[ModuleViews] " + function); source.putAll(viewSegmentation); - UtilsInternalLimits.truncateSegmentationValues(source, _cly.config_.sdkInternalLimits.maxSegmentationValues, "[ModuleViews] " + function, L); + UtilsInternalLimits.truncateSegmentationValues(source, _cly.sdkInternalLimits_.maxSegmentationValues, "[ModuleViews] " + function, L); } public void addSegmentationToViewWithNameInternal(@Nullable String viewName, @Nullable Map viewSegmentation) { 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 { diff --git a/sdk/src/main/java/ly/count/android/sdk/Utils.java b/sdk/src/main/java/ly/count/android/sdk/Utils.java index 6b37cb12c..518ae4090 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -275,11 +275,18 @@ public static boolean isAppInDebuggableMode(@NonNull Context context) { /** * Read stream into a byte array + *

+ * Kept for source compatibility - this is public API and nothing inside the SDK calls it any more. It uses + * a silent logger, so a read failure is not reported anywhere; prefer {@link #readStream(InputStream, ModuleLog)}. * * @param stream input to read * @return stream contents or {@code null} in case of error */ public static byte[] readStream(InputStream stream) { + return readStream(stream, new ModuleLog()); + } + + public static byte[] readStream(InputStream stream, @NonNull ModuleLog L) { if (stream == null) { return null; } @@ -293,7 +300,7 @@ public static byte[] readStream(InputStream stream) { } return bytes.toByteArray(); } catch (IOException e) { - Countly.sharedInstance().L.e("Couldn't read stream: " + e); + L.e("Couldn't read stream: " + e); return null; } finally { try { @@ -304,7 +311,7 @@ public static byte[] readStream(InputStream stream) { } } - static String inputStreamToString(InputStream stream) { + static String inputStreamToString(InputStream stream, @NonNull ModuleLog L) { BufferedReader br = new BufferedReader(new InputStreamReader(stream)); StringBuilder sbRes = new StringBuilder(); @@ -314,7 +321,7 @@ static String inputStreamToString(InputStream stream) { try { streamLine = br.readLine(); } catch (IOException e) { - Countly.sharedInstance().L.e("", e); + L.e("", e); break; } diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsNetworking.java b/sdk/src/main/java/ly/count/android/sdk/UtilsNetworking.java index e2b406a8f..2f610cba4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsNetworking.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsNetworking.java @@ -58,7 +58,7 @@ public class UtilsNetworking { return decodedResult; } - protected static @NonNull String sha256Hash(@NonNull String toHash) { + protected static @NonNull String sha256Hash(@NonNull String toHash, @NonNull ModuleLog L) { assert toHash != null; String hash; @@ -72,7 +72,7 @@ public class UtilsNetworking { hash = bytesToHex(bytes); } catch (Throwable e) { hash = ""; - Countly.sharedInstance().L.e("Cannot tamper-protect params", e); + L.e("Cannot tamper-protect params", e); } return hash; } diff --git a/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigHelper.java b/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigHelper.java index 517e7ba3d..9e97cf611 100644 --- a/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigHelper.java +++ b/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigHelper.java @@ -7,7 +7,6 @@ import java.util.Iterator; import java.util.List; import java.util.Map; -import ly.count.android.sdk.Countly; import ly.count.android.sdk.ExperimentInformation; import ly.count.android.sdk.ModuleLog; import ly.count.android.sdk.ModuleRemoteConfig; @@ -17,7 +16,11 @@ public class RemoteConfigHelper { - public static @NonNull Map DownloadedValuesIntoMap(@Nullable JSONObject jsonObject) { + /** + * @param L logger of the instance these downloaded values belong to, so a parse failure is reported + * to that instance's log listener instead of the default instance's + */ + public static @NonNull Map DownloadedValuesIntoMap(@Nullable JSONObject jsonObject, @NonNull ModuleLog L) { Map ret = new HashMap<>(); if (jsonObject == null) { @@ -31,7 +34,7 @@ public class RemoteConfigHelper { Object value = jsonObject.get(key); ret.put(key, new RCData(value, true)); } catch (Exception e) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] Failed merging new remote config values"); + L.e("[RemoteConfigValueStore] Failed merging new remote config values"); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigValueStore.java b/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigValueStore.java index 0adb83a9a..7e7e5a9a5 100644 --- a/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigValueStore.java +++ b/sdk/src/main/java/ly/count/android/sdk/internal/RemoteConfigValueStore.java @@ -5,7 +5,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; -import ly.count.android.sdk.Countly; +import ly.count.android.sdk.ModuleLog; import ly.count.android.sdk.RCData; import org.json.JSONException; import org.json.JSONObject; @@ -13,6 +13,9 @@ public class RemoteConfigValueStore { public JSONObject values; public boolean valuesCanBeCached; + // Logger of the owning Countly instance. Remote config values are customer data, so this store's + // diagnostics must reach that instance's log listener and not another instance's. + @NonNull private final ModuleLog L; public static final String keyValue = "v"; public static final String keyCacheFlag = "c"; public static final int cacheValCached = 0; @@ -43,7 +46,7 @@ public void cacheClearValues() { if (value == null) { Object badVal = values.opt(key); - Countly.sharedInstance().L.w("[RemoteConfigValueStore] cacheClearValues, stored entry was not a JSON object, key:[" + key + "] value:[" + badVal + "]"); + L.w("[RemoteConfigValueStore] cacheClearValues, stored entry was not a JSON object, key:[" + key + "] value:[" + badVal + "]"); continue; } @@ -51,7 +54,7 @@ public void cacheClearValues() { value.put(keyCacheFlag, cacheValCached); values.put(key, value); } catch (Exception e) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] cacheClearValues, Failed caching remote config values, " + e); + L.e("[RemoteConfigValueStore] cacheClearValues, Failed caching remote config values, " + e); } } } @@ -65,8 +68,7 @@ public void clearValues() { //======================================== public void mergeValues(@NonNull Map newValues, boolean fullUpdate) { - //Countly.sharedInstance().L.i("[RemoteConfigValueStore] mergeValues, stored values:" + values.toString() + "provided values:" + newValues); - Countly.sharedInstance().L.v("[RemoteConfigValueStore] mergeValues, stored values C:" + values.length() + "provided values C:" + newValues.size()); + L.v("[RemoteConfigValueStore] mergeValues, stored values C:" + values.length() + "provided values C:" + newValues.size()); if (fullUpdate) { clearValues(); @@ -81,19 +83,20 @@ public void mergeValues(@NonNull Map newValues, boolean fullUpda newObj.put(keyCacheFlag, cacheValFresh); values.put(key, newObj); } catch (Exception e) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] Failed merging remote config values"); + L.e("[RemoteConfigValueStore] Failed merging remote config values"); } } - Countly.sharedInstance().L.v("[RemoteConfigValueStore] merging done:" + values.toString()); + L.v("[RemoteConfigValueStore] merging done:" + values.toString()); } //======================================== // CONSTRUCTION //======================================== - private RemoteConfigValueStore(@NonNull JSONObject values, boolean valuesShouldBeCached) { + private RemoteConfigValueStore(@NonNull JSONObject values, boolean valuesShouldBeCached, @NonNull ModuleLog L) { this.values = values; this.valuesCanBeCached = valuesShouldBeCached; + this.L = L; } //======================================== @@ -111,7 +114,7 @@ private RemoteConfigValueStore(@NonNull JSONObject values, boolean valuesShouldB res.isCurrentUsersData = rcObj.getInt(keyCacheFlag) != cacheValCached; return res; } catch (Exception ex) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] Got JSON exception while calling 'getValue': " + ex.toString()); + L.e("[RemoteConfigValueStore] Got JSON exception while calling 'getValue': " + ex.toString()); } return res; } @@ -131,7 +134,7 @@ private RemoteConfigValueStore(@NonNull JSONObject values, boolean valuesShouldB int rcObjCache = rcObj.getInt(keyCacheFlag); ret.put(key, new RCData(rcObjVal, (rcObjCache != cacheValCached))); } catch (Exception ex) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] Got JSON exception while calling 'getAllValues': " + ex.toString()); + L.e("[RemoteConfigValueStore] Got JSON exception while calling 'getAllValues': " + ex.toString()); } } @@ -157,14 +160,14 @@ public Object getValueLegacy(@NonNull String key) { JSONObject jobj = values.optJSONObject(key); if (jobj == null) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] getAllValuesLegacy, inner object seems to be 'null', key:[" + key + "]"); + L.e("[RemoteConfigValueStore] getAllValuesLegacy, inner object seems to be 'null', key:[" + key + "]"); continue; } Object innerValue = jobj.opt(keyValue); if (innerValue == null) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] getAllValuesLegacy, inner value seems to be 'null', key:[" + key + "]"); + L.e("[RemoteConfigValueStore] getAllValuesLegacy, inner value seems to be 'null', key:[" + key + "]"); continue; } @@ -178,20 +181,23 @@ public Object getValueLegacy(@NonNull String key) { // SERIALIZATION, DESERIALIZATION //======================================== - public static RemoteConfigValueStore dataFromString(@Nullable String storageString, boolean valuesShouldBeCached) { + /** + * @param L logger of the instance that owns these values, so their diagnostics never reach another + * instance's log listener + */ + public static RemoteConfigValueStore dataFromString(@Nullable String storageString, boolean valuesShouldBeCached, @NonNull ModuleLog L) { if (storageString == null || storageString.isEmpty()) { - return new RemoteConfigValueStore(new JSONObject(), valuesShouldBeCached); + return new RemoteConfigValueStore(new JSONObject(), valuesShouldBeCached, L); } JSONObject values; try { values = new JSONObject(storageString); } catch (JSONException e) { - Countly.sharedInstance().L.e("[RemoteConfigValueStore] Couldn't decode RemoteConfigValueStore successfully: " + e.toString()); + L.e("[RemoteConfigValueStore] Couldn't decode RemoteConfigValueStore successfully: " + e.toString()); values = new JSONObject(); } - //Countly.sharedInstance().L.i("[RemoteConfigValueStore] serialization done, dataFromString:" + values.toString()); - return new RemoteConfigValueStore(values, valuesShouldBeCached); + return new RemoteConfigValueStore(values, valuesShouldBeCached, L); } public String dataToString() {