From d9e0890c784c5c136cdfd57eb42f8ab9660cbd9b Mon Sep 17 00:00:00 2001 From: Mahmoud Elshamy Date: Mon, 11 May 2026 19:16:19 +0400 Subject: [PATCH 1/6] Fix NPE race condition in updateDisplayCountAndDuration --- .../java/com/iterable/iterableapi/EmbeddedSessionManager.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt index cbedfe4fd..467fe3bbe 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt @@ -108,10 +108,11 @@ public class EmbeddedSessionManager { } private fun updateDisplayCountAndDuration(impressionData: EmbeddedImpressionData): EmbeddedImpressionData { - if (impressionData.start != null) { + val start = impressionData.start + if (start != null) { impressionData.displayCount = impressionData.displayCount.plus(1) impressionData.duration = - impressionData.duration.plus((Date().time - impressionData.start!!.time) / 1000.0) + impressionData.duration.plus((Date().time - start.time) / 1000.0) .toFloat() impressionData.start = null } From f8c6c006c3459b7b9d4c7dd07daf075af7142d54 Mon Sep 17 00:00:00 2001 From: Mahmoud Elshamy Date: Mon, 11 May 2026 19:24:33 +0400 Subject: [PATCH 2/6] Add @Volatile on start field and synchronized block for full thread safety --- .../iterableapi/EmbeddedImpressionData.kt | 2 +- .../iterableapi/EmbeddedSessionManager.kt | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt index e166fa2e9..a8947c052 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt @@ -9,7 +9,7 @@ data class EmbeddedImpressionData( val placementId: Long, var displayCount: Int = 0, var duration: Float = 0.0f, - var start: Date? = null + @Volatile var start: Date? = null ) { constructor( messageId: String, diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt index 467fe3bbe..b1271111c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt @@ -108,13 +108,15 @@ public class EmbeddedSessionManager { } private fun updateDisplayCountAndDuration(impressionData: EmbeddedImpressionData): EmbeddedImpressionData { - val start = impressionData.start - if (start != null) { - impressionData.displayCount = impressionData.displayCount.plus(1) - impressionData.duration = - impressionData.duration.plus((Date().time - start.time) / 1000.0) - .toFloat() - impressionData.start = null + synchronized(impressionData) { + val start = impressionData.start + if (start != null) { + impressionData.displayCount = impressionData.displayCount.plus(1) + impressionData.duration = + impressionData.duration.plus((Date().time - start.time) / 1000.0) + .toFloat() + impressionData.start = null + } } return impressionData } From 2679370e5805582adc807c6555db85d5f2d53efd Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 24 Aug 2026 10:19:17 +0100 Subject: [PATCH 3/6] [SDK-499] Synchronize EmbeddedSessionManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NPE fix on start closed the reported crash but left the rest of the class open to the same threading. Callers reach EmbeddedSessionManager from arbitrary threads (issue #1052 reports Dispatchers.Default), and impressions is a plain LinkedHashMap: startImpression writes to it while endSession iterates it and then reassigns the field, so concurrent use could also throw ConcurrentModificationException or lose entries. The session field had the same check-then-act shape as start — two threads could both pass isTracking() and each track a session. Guard every access to impressions, session, and the impression fields with one private lock. The per-impression synchronized block is now redundant and removed; @Volatile on start stays as visibility defence, but the class lock is the invariant. trackEmbeddedSession runs after the lock is released, since it calls back into IterableApi. endSession keeps its existing behaviour of doing nothing — not even resetting the session — when there are no impressions. Adds a test racing 8 threads over startImpression, pauseImpression, startSession and endSession, which reproduces the reported NullPointerException without this change, plus two tests pinning the endSession-with-no-impressions behaviour. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../iterableapi/EmbeddedSessionManager.kt | 105 ++++++++++-------- ...mbeddedSessionManagerThreadSafetyTest.java | 92 +++++++++++++++ 3 files changed, 154 insertions(+), 44 deletions(-) create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b7b1b79..a1ee9c87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed +- Fixed a `NullPointerException` in `EmbeddedSessionManager.updateDisplayCountAndDuration()` that could crash apps calling embedded session methods off the main thread. `EmbeddedSessionManager` is now internally synchronized, which also fixes concurrent modification of its impression map and duplicate session tracking when `endSession()` raced with itself. Thanks to [@Shamyyoun](https://github.com/Shamyyoun) for the report and initial fix. - Fixed a race in JWT auth refresh scheduling that could leave overlapping timers active and repeatedly call `IterableAuthHandler.onAuthTokenRequested()`. Refresh scheduling now has a single task owner, rejects stale or duplicate tasks, and logs each schedule, skip, fire, cancellation, and error with its refresh reason. ## [3.10.0] diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt index b1271111c..e591439b8 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt @@ -6,6 +6,10 @@ public class EmbeddedSessionManager { private val TAG = "EmbeddedSessionManager" + // Callers reach this class from arbitrary threads (see issue #1052), so every access to + // impressions, session, and the impression fields happens under this lock. + private val lock = Any() + private var impressions: MutableMap = mutableMapOf() var session: IterableEmbeddedSession = IterableEmbeddedSession( @@ -13,41 +17,47 @@ public class EmbeddedSessionManager { null, null ) + get() = synchronized(lock) { field } + set(value) = synchronized(lock) { field = value } fun isTracking(): Boolean { - return session.start != null + return synchronized(lock) { session.start != null } } fun startSession() { - if (isTracking()) { - IterableLogger.e(TAG, "Embedded session started twice") - return - } + synchronized(lock) { + if (isTracking()) { + IterableLogger.e(TAG, "Embedded session started twice") + return + } - session = IterableEmbeddedSession( - Date(), - null, - null - ) + session = IterableEmbeddedSession( + Date(), + null, + null + ) + } } fun endSession() { - if (!isTracking()) { - IterableLogger.e(TAG, "Embedded session ended without start") - return - } + val sessionToTrack = synchronized(lock) { + if (!isTracking()) { + IterableLogger.e(TAG, "Embedded session ended without start") + return + } + + if (impressions.isEmpty()) { + return + } - if(impressions.isNotEmpty()) { endAllImpressions() - val sessionToTrack = IterableEmbeddedSession( + val tracked = IterableEmbeddedSession( session.start, Date(), getImpressionList() ) - IterableApi.getInstance().trackEmbeddedSession(sessionToTrack) - //reset session for next session start session = IterableEmbeddedSession( null, @@ -56,34 +66,43 @@ public class EmbeddedSessionManager { ) impressions = mutableMapOf() + + tracked } + + // Tracking calls into IterableApi, so it runs after the lock is released. + IterableApi.getInstance().trackEmbeddedSession(sessionToTrack) } fun startImpression(messageId: String, placementId: Long) { - var impressionData: EmbeddedImpressionData? = impressions[messageId] + synchronized(lock) { + var impressionData: EmbeddedImpressionData? = impressions[messageId] - if (impressionData == null) { - impressionData = EmbeddedImpressionData(messageId, placementId) - impressions[messageId] = impressionData - } + if (impressionData == null) { + impressionData = EmbeddedImpressionData(messageId, placementId) + impressions[messageId] = impressionData + } - impressionData.start = Date() + impressionData.start = Date() + } } fun pauseImpression(messageId: String) { - val impressionData: EmbeddedImpressionData? = impressions[messageId] + synchronized(lock) { + val impressionData: EmbeddedImpressionData? = impressions[messageId] - if (impressionData == null) { - IterableLogger.e(TAG, "onMessageImpressionEnded: impressionData not found") - return - } + if (impressionData == null) { + IterableLogger.e(TAG, "onMessageImpressionEnded: impressionData not found") + return + } - if (impressionData.start == null) { - IterableLogger.e(TAG, "onMessageImpressionEnded: impressionStarted is null") - return - } + if (impressionData.start == null) { + IterableLogger.e(TAG, "onMessageImpressionEnded: impressionStarted is null") + return + } - updateDisplayCountAndDuration(impressionData) + updateDisplayCountAndDuration(impressionData) + } } private fun endAllImpressions() { @@ -108,16 +127,14 @@ public class EmbeddedSessionManager { } private fun updateDisplayCountAndDuration(impressionData: EmbeddedImpressionData): EmbeddedImpressionData { - synchronized(impressionData) { - val start = impressionData.start - if (start != null) { - impressionData.displayCount = impressionData.displayCount.plus(1) - impressionData.duration = - impressionData.duration.plus((Date().time - start.time) / 1000.0) - .toFloat() - impressionData.start = null - } + val start = impressionData.start + if (start != null) { + impressionData.displayCount = impressionData.displayCount.plus(1) + impressionData.duration = + impressionData.duration.plus((Date().time - start.time) / 1000.0) + .toFloat() + impressionData.start = null } return impressionData } -} \ No newline at end of file +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java new file mode 100644 index 000000000..7c33e5eb2 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java @@ -0,0 +1,92 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class EmbeddedSessionManagerThreadSafetyTest extends BaseTest { + + private EmbeddedSessionManager sessionManager; + + @Before + public void setUp() { + IterableApi.sharedInstance = new IterableApi(); + sessionManager = new EmbeddedSessionManager(); + } + + @Test + public void endSessionWithoutImpressionsLeavesSessionRunning() { + sessionManager.startSession(); + sessionManager.endSession(); + + assertTrue(sessionManager.isTracking()); + } + + @Test + public void endSessionWithImpressionsResetsSession() { + sessionManager.startSession(); + sessionManager.startImpression("message-1", 1L); + sessionManager.pauseImpression("message-1"); + sessionManager.endSession(); + + assertFalse(sessionManager.isTracking()); + } + + @Test + public void concurrentSessionAndImpressionUpdatesDoNotThrow() throws Exception { + final int threadCount = 8; + final int iterations = 2000; + final CountDownLatch startGate = new CountDownLatch(1); + final CountDownLatch finishGate = new CountDownLatch(threadCount); + final List failures = Collections.synchronizedList(new ArrayList()); + + sessionManager.startSession(); + + for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) { + final int role = threadIndex % 4; + new Thread(new Runnable() { + @Override + public void run() { + try { + startGate.await(); + for (int i = 0; i < iterations; i++) { + String messageId = "message-" + (i % 4); + switch (role) { + case 0: + sessionManager.startImpression(messageId, i % 3); + break; + case 1: + sessionManager.pauseImpression(messageId); + break; + case 2: + sessionManager.endSession(); + break; + default: + sessionManager.startSession(); + break; + } + } + } catch (Throwable throwable) { + failures.add(throwable); + } finally { + finishGate.countDown(); + } + } + }, "embedded-session-" + threadIndex).start(); + } + + startGate.countDown(); + + assertTrue("threads did not finish in time", finishGate.await(30, TimeUnit.SECONDS)); + assertEquals("concurrent access failed: " + failures, 0, failures.size()); + } +} From ec244148c4d24cb7e83042e2973bf4d1b9f02552 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 24 Aug 2026 11:55:08 +0100 Subject: [PATCH 4/6] Move CHANGELOG entry back under Unreleased after merging 3.10.1 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46aa3c21d..550304705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed +- Fixed a `NullPointerException` in `EmbeddedSessionManager.updateDisplayCountAndDuration()` that could crash apps calling embedded session methods off the main thread. `EmbeddedSessionManager` is now internally synchronized, which also fixes concurrent modification of its impression map and duplicate session tracking when `endSession()` raced with itself. Thanks to [@Shamyyoun](https://github.com/Shamyyoun) for the report and initial fix. ## [3.10.1] ### Fixed -- Fixed a `NullPointerException` in `EmbeddedSessionManager.updateDisplayCountAndDuration()` that could crash apps calling embedded session methods off the main thread. `EmbeddedSessionManager` is now internally synchronized, which also fixes concurrent modification of its impression map and duplicate session tracking when `endSession()` raced with itself. Thanks to [@Shamyyoun](https://github.com/Shamyyoun) for the report and initial fix. - Fixed a race in JWT auth refresh scheduling that could leave overlapping timers active and repeatedly call `IterableAuthHandler.onAuthTokenRequested()`. Refresh scheduling now has a single task owner, rejects stale or duplicate tasks, and logs each schedule, skip, fire, cancellation, and error with its refresh reason. ## [3.10.0] From 60f67b851208d23670bebb20dfb679ed0e12fefe Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 24 Aug 2026 13:22:47 +0100 Subject: [PATCH 5/6] [SDK-499] Test concurrent session tracking once --- ...mbeddedSessionManagerThreadSafetyTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java index 7c33e5eb2..4dcc1fd72 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import org.junit.Before; @@ -11,7 +12,11 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public class EmbeddedSessionManagerThreadSafetyTest extends BaseTest { @@ -41,6 +46,46 @@ public void endSessionWithImpressionsResetsSession() { assertFalse(sessionManager.isTracking()); } + @Test + public void concurrentEndSessionTracksSessionOnlyOnce() throws Exception { + BlockingRecordingIterableApi recordingApi = new BlockingRecordingIterableApi(); + IterableApi.sharedInstance = recordingApi; + + sessionManager.startSession(); + sessionManager.startImpression("message-1", 1L); + sessionManager.pauseImpression("message-1"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // Keep the first tracking call open while a second thread ends the same session. + Future firstEnd = executor.submit(sessionManager::endSession); + assertTrue( + "first endSession did not reach tracking", + recordingApi.awaitFirstTrack(5, TimeUnit.SECONDS) + ); + + // The session must already be cleared, even though its first tracking call is blocked. + // Ending it again must therefore return without tracking the same session twice. + Future secondEnd = executor.submit(sessionManager::endSession); + secondEnd.get(5, TimeUnit.SECONDS); + + recordingApi.allowFirstTrackToFinish(); + firstEnd.get(5, TimeUnit.SECONDS); + } finally { + recordingApi.allowFirstTrackToFinish(); + executor.shutdownNow(); + } + + List trackedSessions = recordingApi.getTrackedSessions(); + assertEquals("the active session should be tracked exactly once", 1, trackedSessions.size()); + + List impressions = trackedSessions.get(0).getImpressions(); + assertNotNull(impressions); + assertEquals(1, impressions.size()); + assertEquals("message-1", impressions.get(0).getMessageId()); + assertEquals(1, impressions.get(0).getDisplayCount()); + } + @Test public void concurrentSessionAndImpressionUpdatesDoNotThrow() throws Exception { final int threadCount = 8; @@ -89,4 +134,44 @@ public void run() { assertTrue("threads did not finish in time", finishGate.await(30, TimeUnit.SECONDS)); assertEquals("concurrent access failed: " + failures, 0, failures.size()); } + + private static class BlockingRecordingIterableApi extends IterableApi { + private final AtomicInteger trackCallCount = new AtomicInteger(); + private final List trackedSessions = + Collections.synchronizedList(new ArrayList()); + private final CountDownLatch firstTrackStarted = new CountDownLatch(1); + private final CountDownLatch allowFirstTrackToFinish = new CountDownLatch(1); + + @Override + public void trackEmbeddedSession(IterableEmbeddedSession session) { + int callNumber = trackCallCount.incrementAndGet(); + trackedSessions.add(session); + + if (callNumber == 1) { + firstTrackStarted.countDown(); + try { + if (!allowFirstTrackToFinish.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("first tracking call was not released"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting to finish tracking", exception); + } + } + } + + boolean awaitFirstTrack(long timeout, TimeUnit unit) throws InterruptedException { + return firstTrackStarted.await(timeout, unit); + } + + void allowFirstTrackToFinish() { + allowFirstTrackToFinish.countDown(); + } + + List getTrackedSessions() { + synchronized (trackedSessions) { + return new ArrayList<>(trackedSessions); + } + } + } } From d1a8172bf656ce333fae01ea681515835be89c15 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 25 Aug 2026 16:05:00 +0100 Subject: [PATCH 6/6] [SDK-499] Address review comments Drop the redundant @Volatile on EmbeddedImpressionData.start. Every read and write of it happens under the session manager's lock, so the annotation only suggested the field was safe on its own. Rename the three private helpers that touch impressions without locking to a Locked suffix, so the requirement is visible at each call site instead of only at the declaration. Note in the test that leaving the session open when there are no impressions is existing behavior being pinned, not intended behavior. Co-Authored-By: Claude Opus 5 --- .../iterableapi/EmbeddedImpressionData.kt | 2 +- .../iterableapi/EmbeddedSessionManager.kt | 16 +++++++++------- .../EmbeddedSessionManagerThreadSafetyTest.java | 2 ++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt index a8947c052..e166fa2e9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedImpressionData.kt @@ -9,7 +9,7 @@ data class EmbeddedImpressionData( val placementId: Long, var displayCount: Int = 0, var duration: Float = 0.0f, - @Volatile var start: Date? = null + var start: Date? = null ) { constructor( messageId: String, diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt index e591439b8..b6dc43491 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/EmbeddedSessionManager.kt @@ -50,12 +50,12 @@ public class EmbeddedSessionManager { return } - endAllImpressions() + endAllImpressionsLocked() val tracked = IterableEmbeddedSession( session.start, Date(), - getImpressionList() + getImpressionListLocked() ) //reset session for next session start @@ -101,17 +101,19 @@ public class EmbeddedSessionManager { return } - updateDisplayCountAndDuration(impressionData) + updateDisplayCountAndDurationLocked(impressionData) } } - private fun endAllImpressions() { + // The Locked suffix marks helpers that read or write impressions without taking the lock + // themselves: every caller must already hold it. + private fun endAllImpressionsLocked() { for (impressionData in impressions.values) { - updateDisplayCountAndDuration(impressionData) + updateDisplayCountAndDurationLocked(impressionData) } } - private fun getImpressionList(): List? { + private fun getImpressionListLocked(): List? { val impressionList: MutableList = ArrayList() for (impressionData in impressions.values) { impressionList.add( @@ -126,7 +128,7 @@ public class EmbeddedSessionManager { return impressionList } - private fun updateDisplayCountAndDuration(impressionData: EmbeddedImpressionData): EmbeddedImpressionData { + private fun updateDisplayCountAndDurationLocked(impressionData: EmbeddedImpressionData): EmbeddedImpressionData { val start = impressionData.start if (start != null) { impressionData.displayCount = impressionData.displayCount.plus(1) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java index 4dcc1fd72..97907a409 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/EmbeddedSessionManagerThreadSafetyTest.java @@ -28,6 +28,8 @@ public void setUp() { sessionManager = new EmbeddedSessionManager(); } + // Pins existing behavior, not intended behavior: with no impressions, endSession() returns + // early and leaves the session open. Flip this assertion when SDK-701 is fixed. @Test public void endSessionWithoutImpressionsLeavesSessionRunning() { sessionManager.startSession();