Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bd40b8f
chore: prep infrastructure for live notifications (#669)
mahmoud-elmorabea Apr 24, 2026
6647369
chore: live notification rendering and routing (#670)
mahmoud-elmorabea Apr 24, 2026
084be76
chore: live notification demo activity (#671)
mahmoud-elmorabea Apr 28, 2026
3e3c193
chore: per-install installation id on every event (#689)
mahmoud-elmorabea May 19, 2026
92f266b
chore: Predefined live notification templates (#676)
mahmoud-elmorabea Jun 9, 2026
56acdff
test: cover live notification envelope and per-template rendering (#690)
mahmoud-elmorabea Jun 9, 2026
fe4a3ff
chore: align live notifications with iOS + address #676/#690 review f…
mahmoud-elmorabea Jun 11, 2026
16ec8fa
chore(live-notifications): backend lifecycle reporting + out-of-order…
mahmoud-elmorabea Jun 11, 2026
feced86
chore(live-notifications): local start API + custom types + app rende…
mahmoud-elmorabea Jun 11, 2026
a22cef3
chore(sample): expand live-notification demo — custom types, callback…
mahmoud-elmorabea Jun 15, 2026
7697d20
chore(live-notifications): iOS-parity SDK — contract & registration (…
mahmoud-elmorabea Jul 21, 2026
ac5535f
chore(live-notifications): iOS-parity SDK — branding, templates & ren…
mahmoud-elmorabea Jul 21, 2026
ef6c9d3
chore(live-notifications): java_layout sample for the 2 templates (#780)
mahmoud-elmorabea Jul 21, 2026
1f93559
fix(live-notifications): identify→start race + logout-during-render w…
mahmoud-elmorabea Jul 21, 2026
275ae56
feat(live-notifications): stop reporting locally-triggered updates (#…
mahmoud-elmorabea Jul 24, 2026
a0a68a5
fix(live-notifications): address review findings (#797)
mahmoud-elmorabea Jul 27, 2026
ac3302c
fix(live-notifications): address bugbot review findings
mahmoud-elmorabea Jul 28, 2026
c67973a
fix(live-notifications): cancel a server-delivered end whose render f…
mahmoud-elmorabea Jul 28, 2026
6058bc1
fix(live-notifications): terminal-state and custom-callback review fi…
mahmoud-elmorabea Jul 28, 2026
d308283
fix(live-notifications): close the dismissal-during-render window, re…
mahmoud-elmorabea Jul 29, 2026
fd69467
fix(live-notifications): mark dismissals terminal without a token, ga…
mahmoud-elmorabea Jul 29, 2026
c73d8cc
fix(live-notifications): recover the opt-in set in a cold process
mahmoud-elmorabea Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import io.customer.base.internal.InternalCustomerIOApi
*/
@InternalCustomerIOApi
interface DataPipeline {
/**
* Whether a user is currently identified. Updated synchronously on the caller's thread
* during identify()/clearIdentify(), so it is accurate the instant those calls return —
* a consumer may gate on it immediately after identify() without racing async propagation.
*/
val isUserIdentified: Boolean
fun track(name: String, properties: Map<String, Any?>)
}
24 changes: 23 additions & 1 deletion datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ class CustomerIO private constructor(
// Reset on clearIdentify so a subsequent identify of the same userId is not deduped.
private var lastIdentifiedUserIdThisSession: String? = null

// Caller-thread mirror of whether a user is currently identified, backing the synchronous
// [isUserIdentified]. analytics.userId() is updated asynchronously and lags a synchronous
// identify(), so a consumer that gates on it right after login (e.g. a live-notification
// start() on the next line) would otherwise read stale state and drop the event. Set on the
// caller's thread during identify()/clearIdentify() so it is accurate the instant they return.
// null = no identify/reset has happened this process yet, so fall back to the (possibly
// restored-from-persistence) analytics userId; true = identified; false = anonymous.
@Volatile
private var syncUserIdentified: Boolean? = null

init {
// Set analytics logger and debug logs based on SDK logger configuration
Analytics.debugLogsEnabled = logger.logLevel == CioLogLevel.DEBUG
Expand Down Expand Up @@ -296,6 +306,12 @@ class CustomerIO private constructor(
traits = traits,
serializationStrategy = serializationStrategy
)
// Reflect identity synchronously on the caller's thread so isUserIdentified is correct
// immediately, before analytics.userId() catches up (see syncUserIdentified). Must be set
// before publishUserChanged: the mirror takes precedence over the analytics fallback, so a
// subscriber that gates on isUserIdentified would otherwise read a stale `false` left by an
// earlier clearIdentify() even though analytics.userId() is already correct.
syncUserIdentified = true
// Publish for other modules. Must come after analytics.identify() so analytics.userId()
// returns the new userId for subscribers that gate on it (e.g. location resync).
publishUserChanged(userId)
Expand Down Expand Up @@ -344,6 +360,9 @@ class CustomerIO private constructor(

// Reset the dedup marker so a subsequent identify of the same userId is not deduped.
lastIdentifiedUserIdThisSession = null
// Reflect logout synchronously so isUserIdentified reads false immediately, before
// analytics.reset() propagates (see syncUserIdentified).
syncUserIdentified = false

logger.debug("deleting device token to remove device from user profile")

Expand Down Expand Up @@ -377,7 +396,10 @@ class CustomerIO private constructor(
get() = analytics.userId()

override val isUserIdentified: Boolean
get() = !analytics.userId().isNullOrEmpty()
// Prefer the caller-thread mirror so this is correct synchronously right after
// identify()/clearIdentify(); fall back to analytics for a session restored from
// persistence, where neither was called this process. See [syncUserIdentified].
get() = syncUserIdentified ?: !analytics.userId().isNullOrEmpty()

@Deprecated("Use setDeviceAttributes() function instead")
@set:JvmName("setDeviceAttributesDeprecated")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.customer.datapipelines

import io.customer.commontest.extensions.random
import io.customer.datapipelines.testutils.core.JUnitTest
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.junit.jupiter.api.Test

/**
* Direct coverage for the synchronous `CustomerIO.isUserIdentified` mirror at the layer that owns
* it. The messagingpush live-notification tests mock `isUserIdentified`, so they don't prove the
* contract itself: it must flip true on the caller thread the instant `identify()` returns (before
* the async `analytics.userId()` catches up), flip false the instant `clearIdentify()` returns, and
* still reflect a session restored from persistence where neither was called this process.
*/
class IsUserIdentifiedSyncTests : JUnitTest() {

@Test
fun isUserIdentified_isTrueSynchronouslyAfterIdentify() {
sdkInstance.isUserIdentified.shouldBeFalse()

sdkInstance.identify(String.random)

// No async wait: the mirror is set on the caller thread inside identify(), so a consumer
// gating on it immediately after login (e.g. a live-notification start()) is not dropped.
sdkInstance.isUserIdentified.shouldBeTrue()
}

@Test
fun isUserIdentified_isFalseSynchronouslyAfterClearIdentify() {
sdkInstance.identify(String.random)
sdkInstance.isUserIdentified.shouldBeTrue()

sdkInstance.clearIdentify()

sdkInstance.isUserIdentified.shouldBeFalse()
}

@Test
fun isUserIdentified_fallsBackToAnalyticsForRestoredSession() {
// Cold launch: no identify() this process (the mirror stays unset), but analytics restored a
// userId from persistence. isUserIdentified must reflect the restored user via the fallback.
analytics.identify(String.random)

sdkInstance.isUserIdentified.shouldBeTrue()
}
}
Loading
Loading