Skip to content

Commit 0e56b5b

Browse files
authored
journeylitics: fix global sink handling + press scope unlock; add tests (#231)
1 parent c47351d commit 0e56b5b

File tree

6 files changed

+147
-14
lines changed

6 files changed

+147
-14
lines changed

compose-sdk/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ android {
6464
dependencies {
6565
api project(':sdk')
6666
implementation "androidx.compose.foundation:foundation:$compose_version"
67+
testImplementation 'junit:junit:4.13.2'
6768
}
6869

6970
project.afterEvaluate {

compose-sdk/src/main/java/com/hcaptcha/sdk/journeylitics/ComposeAnalytics.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,12 +369,16 @@ private class PressGestureScopeImpl(
369369

370370
fun cancel() {
371371
isCanceled = true
372-
mutex.unlock()
372+
if (mutex.isLocked) {
373+
mutex.unlock()
374+
}
373375
}
374376

375377
fun release() {
376378
isReleased = true
377-
mutex.unlock()
379+
if (mutex.isLocked) {
380+
mutex.unlock()
381+
}
378382
}
379383

380384
suspend fun reset() {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.hcaptcha.sdk.journeylitics
2+
3+
import androidx.compose.ui.unit.Density
4+
import org.junit.Test
5+
6+
class PressGestureScopeImplTest {
7+
private fun newInstance(): Any {
8+
val classNames = listOf(
9+
"com.hcaptcha.sdk.journeylitics.PressGestureScopeImpl",
10+
"com.hcaptcha.sdk.journeylitics.ComposeAnalyticsKt\$PressGestureScopeImpl"
11+
)
12+
var clazz: Class<*>? = null
13+
for (name in classNames) {
14+
try {
15+
clazz = Class.forName(name)
16+
break
17+
} catch (_: ClassNotFoundException) {
18+
// Try next name
19+
}
20+
}
21+
requireNotNull(clazz) { "PressGestureScopeImpl class not found" }
22+
23+
val ctor = clazz.getDeclaredConstructor(Density::class.java)
24+
ctor.isAccessible = true
25+
val density = object : Density {
26+
override val density: Float = 1f
27+
override val fontScale: Float = 1f
28+
}
29+
return ctor.newInstance(density)
30+
}
31+
32+
@Test
33+
fun cancelWithoutReset_doesNotThrow() {
34+
val instance = newInstance()
35+
val cancel = instance.javaClass.getDeclaredMethod("cancel")
36+
cancel.isAccessible = true
37+
cancel.invoke(instance)
38+
}
39+
40+
@Test
41+
fun releaseWithoutReset_doesNotThrow() {
42+
val instance = newInstance()
43+
val release = instance.javaClass.getDeclaredMethod("release")
44+
release.isAccessible = true
45+
release.invoke(instance)
46+
}
47+
}

sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,22 @@ void onFailure(final HCaptchaException exception) {
105105
}
106106
};
107107
try {
108-
// Initialize user journey tracking if enabled
109-
if (Boolean.TRUE.equals(inputConfig.getUserJourney()) && journeySink == null) {
110-
journeySink = new InMemorySink();
111-
final JLConfig jlConfig = new JLConfig(journeySink);
112-
Journeylitics.start(activity, jlConfig);
108+
// Initialize or disable user journey tracking if enabled/disabled
109+
if (Boolean.TRUE.equals(inputConfig.getUserJourney())) {
110+
if (journeySink == null) {
111+
journeySink = new InMemorySink();
112+
}
113+
if (Journeylitics.isStarted()) {
114+
Journeylitics.addSink(journeySink);
115+
} else {
116+
final JLConfig jlConfig = new JLConfig(journeySink);
117+
Journeylitics.start(activity, jlConfig);
118+
}
119+
} else if (journeySink != null) {
120+
if (Journeylitics.isStarted()) {
121+
Journeylitics.removeSink(journeySink);
122+
}
123+
journeySink = null;
113124
}
114125

115126
if (Boolean.TRUE.equals(inputConfig.getHideDialog())) {

sdk/src/main/java/com/hcaptcha/sdk/journeylitics/Journeylitics.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,18 @@ public static void start(Context context, JLConfig configuration) {
137137
sApp.registerActivityLifecycleCallbacks(LIFECYCLE_CALLBACKS);
138138
}
139139

140-
static void addSink(JLSink sink) {
140+
public static boolean isStarted() {
141+
return STARTED.get();
142+
}
143+
144+
public static void addSink(JLSink sink) {
145+
if (sink == null || SINKS.contains(sink)) {
146+
return;
147+
}
141148
SINKS.add(sink);
142149
}
143150

144-
static void removeSink(JLSink sink) {
151+
public static void removeSink(JLSink sink) {
145152
SINKS.remove(sink);
146153
}
147154

@@ -652,4 +659,3 @@ private static ListenerLookup<View.OnClickListener> getOnClickListener(View view
652659
}
653660
}
654661
}
655-

sdk/src/test/java/com/hcaptcha/sdk/journeylitics/JourneyliticsTest.java

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,47 @@
11
package com.hcaptcha.sdk.journeylitics;
22

3+
import android.app.Application;
4+
35
import com.fasterxml.jackson.databind.JsonNode;
46
import com.fasterxml.jackson.databind.ObjectMapper;
57
import org.junit.Assert;
68
import org.junit.Test;
9+
import org.mockito.Mockito;
710

11+
import java.lang.reflect.Field;
812
import java.util.AbstractMap;
913
import java.util.ArrayList;
1014
import java.util.HashMap;
1115
import java.util.List;
1216
import java.util.Map;
17+
import java.util.concurrent.atomic.AtomicBoolean;
1318

1419
public class JourneyliticsTest {
20+
private static final String VIEW_BUTTON = "Button";
1521
private final List<JLEvent> captured = new ArrayList<>();
1622

23+
private static void resetJourneyliticsState() throws Exception {
24+
final Field startedField = Journeylitics.class.getDeclaredField("STARTED");
25+
startedField.setAccessible(true);
26+
((AtomicBoolean) startedField.get(null)).set(false);
27+
28+
final Field appField = Journeylitics.class.getDeclaredField("sApp");
29+
appField.setAccessible(true);
30+
appField.set(null, null);
31+
32+
final Field configField = Journeylitics.class.getDeclaredField("sConfig");
33+
configField.setAccessible(true);
34+
configField.set(null, JLConfig.DEFAULT);
35+
36+
final Field sinksField = Journeylitics.class.getDeclaredField("SINKS");
37+
sinksField.setAccessible(true);
38+
((List<?>) sinksField.get(null)).clear();
39+
40+
final Field instrumentedField = Journeylitics.class.getDeclaredField("INSTRUMENTED");
41+
instrumentedField.setAccessible(true);
42+
((Map<?, ?>) instrumentedField.get(null)).clear();
43+
}
44+
1745
@Test
1846
public void sink_emits_event() {
1947
// This test verifies that the sink pipeline works correctly
@@ -27,14 +55,14 @@ public void emit(JLEvent event) {
2755
final Map<String, Object> meta = MetaMapHelper.createMetaMap(
2856
new AbstractMap.SimpleEntry<>(FieldKey.ID, "test-button")
2957
);
30-
sink.emit(new JLEvent(EventKind.click, "Button", new HashMap<>(meta)));
58+
sink.emit(new JLEvent(EventKind.click, VIEW_BUTTON, new HashMap<>(meta)));
3159
Assert.assertTrue(captured.size() == before + 1);
3260
}
3361

3462
@Test
3563
public void metadata_serializes_as_string() throws Exception {
3664
final ObjectMapper mapper = new ObjectMapper();
37-
final JLEvent event = new JLEvent(1234567890L, EventKind.click, "Button", "meta-string");
65+
final JLEvent event = new JLEvent(1234567890L, EventKind.click, VIEW_BUTTON, "meta-string");
3866
final JsonNode node = mapper.readTree(mapper.writeValueAsString(event));
3967
Assert.assertEquals("meta-string", node.get("m").asText());
4068
}
@@ -44,9 +72,45 @@ public void metadata_serializes_as_object() throws Exception {
4472
final ObjectMapper mapper = new ObjectMapper();
4573
final Map<String, Object> meta = new HashMap<>();
4674
meta.put("id", "submit-btn");
47-
final JLEvent event = new JLEvent(1234567890L, EventKind.click, "Button", meta);
75+
final JLEvent event = new JLEvent(1234567890L, EventKind.click, VIEW_BUTTON, meta);
4876
final JsonNode node = mapper.readTree(mapper.writeValueAsString(event));
4977
Assert.assertEquals("submit-btn", node.get("m").get("id").asText());
5078
}
51-
}
5279

80+
@Test
81+
public void addSink_afterStart_receivesEvents() throws Exception {
82+
resetJourneyliticsState();
83+
final Application app = Mockito.mock(Application.class);
84+
Mockito.when(app.getApplicationContext()).thenReturn(app);
85+
Journeylitics.start(app, new JLConfig());
86+
87+
final List<JLEvent> events = new ArrayList<>();
88+
final JLSink sink = events::add;
89+
Journeylitics.addSink(sink);
90+
91+
final Map<String, Object> meta = MetaMapHelper.createMetaMap(
92+
new AbstractMap.SimpleEntry<>(FieldKey.ID, "test-button")
93+
);
94+
Journeylitics.emit(EventKind.click, VIEW_BUTTON, meta);
95+
Assert.assertEquals(1, events.size());
96+
}
97+
98+
@Test
99+
public void removeSink_stopsEvents() throws Exception {
100+
resetJourneyliticsState();
101+
final Application app = Mockito.mock(Application.class);
102+
Mockito.when(app.getApplicationContext()).thenReturn(app);
103+
Journeylitics.start(app, new JLConfig());
104+
105+
final List<JLEvent> events = new ArrayList<>();
106+
final JLSink sink = events::add;
107+
Journeylitics.addSink(sink);
108+
109+
Journeylitics.emit(EventKind.click, VIEW_BUTTON, new HashMap<>());
110+
Assert.assertEquals(1, events.size());
111+
112+
Journeylitics.removeSink(sink);
113+
Journeylitics.emit(EventKind.click, VIEW_BUTTON, new HashMap<>());
114+
Assert.assertEquals(1, events.size());
115+
}
116+
}

0 commit comments

Comments
 (0)