Skip to content

Commit 96d8802

Browse files
authored
feat(geolocation): add addPositionListener to GeolocationTracker (#24268)
Lets callers subscribe to tracking updates with plain callbacks instead of subscribing to valueSignal(), mirroring the W3C watchPosition(success, error) pair. Listeners persist across stop()/resume() cycles and never see the GeolocationPending state.
1 parent 81e9281 commit 96d8802

2 files changed

Lines changed: 183 additions & 1 deletion

File tree

flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationTracker.java

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616
package com.vaadin.flow.component.geolocation;
1717

1818
import java.io.Serializable;
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.Objects;
1922

2023
import org.jspecify.annotations.Nullable;
2124

2225
import com.vaadin.flow.component.Component;
26+
import com.vaadin.flow.function.SerializableConsumer;
2327
import com.vaadin.flow.shared.Registration;
2428
import com.vaadin.flow.signals.Signal;
2529
import com.vaadin.flow.signals.local.ValueSignal;
@@ -54,6 +58,9 @@ public class GeolocationTracker implements Serializable {
5458
private final Signal<Boolean> activeSignalReadOnly = activeSignal
5559
.asReadonly();
5660

61+
private final List<SerializableConsumer<GeolocationPosition>> positionListeners = new ArrayList<>();
62+
private final List<SerializableConsumer<GeolocationError>> errorListeners = new ArrayList<>();
63+
5764
private final Component owner;
5865
private final @Nullable GeolocationOptions options;
5966
private final GeolocationClient client;
@@ -111,6 +118,43 @@ public Signal<Boolean> activeSignal() {
111118
return activeSignalReadOnly;
112119
}
113120

121+
/**
122+
* Adds a listener pair that is notified on every reading the browser
123+
* reports. The listener-based equivalent of subscribing to
124+
* {@link #valueSignal()} for callers that prefer plain callbacks over
125+
* signals.
126+
* <p>
127+
* On every successful reading {@code onSuccess} is invoked with the
128+
* {@link GeolocationPosition}. If the browser reports an error instead
129+
* {@code onError} is invoked with the {@link GeolocationError}. The initial
130+
* {@link GeolocationPending} state is never delivered to listeners — they
131+
* only see real outcomes, mirroring the W3C
132+
* {@code watchPosition(success, error)} pair.
133+
* <p>
134+
* Listeners survive {@link #stop()} / {@link #resume()} cycles; remove them
135+
* via {@link Registration#remove()} on the returned registration. Both
136+
* callbacks are invoked on the UI thread.
137+
*
138+
* @param onSuccess
139+
* invoked with each successful position reading; not
140+
* {@code null}
141+
* @param onError
142+
* invoked when the browser reports an error; not {@code null}
143+
* @return a registration that removes both listeners when called
144+
*/
145+
public Registration addPositionListener(
146+
SerializableConsumer<GeolocationPosition> onSuccess,
147+
SerializableConsumer<GeolocationError> onError) {
148+
Objects.requireNonNull(onSuccess, "onSuccess listener cannot be null");
149+
Objects.requireNonNull(onError, "onError listener cannot be null");
150+
positionListeners.add(onSuccess);
151+
errorListeners.add(onError);
152+
return () -> {
153+
positionListeners.remove(onSuccess);
154+
errorListeners.remove(onError);
155+
};
156+
}
157+
114158
/**
115159
* Starts, or resumes, the underlying browser watch.
116160
* <p>
@@ -129,10 +173,33 @@ public void resume() {
129173
activeSignal.set(Boolean.TRUE);
130174
valueSignal.set(new GeolocationPending());
131175

132-
handle = client.startWatch(owner, options, valueSignal::set);
176+
handle = client.startWatch(owner, options, this::handleResult);
133177
detachRegistration = owner.addDetachListener(e -> stop());
134178
}
135179

180+
private void handleResult(GeolocationResult result) {
181+
valueSignal.set(result);
182+
switch (result) {
183+
case GeolocationPosition position -> {
184+
for (SerializableConsumer<GeolocationPosition> listener : new ArrayList<>(
185+
positionListeners)) {
186+
listener.accept(position);
187+
}
188+
}
189+
case GeolocationError error -> {
190+
for (SerializableConsumer<GeolocationError> listener : new ArrayList<>(
191+
errorListeners)) {
192+
listener.accept(error);
193+
}
194+
}
195+
case GeolocationPending pending -> {
196+
// Intentionally not dispatched to listeners — Pending is the
197+
// initial state set by resume(), not an outcome the W3C
198+
// watchPosition(success, error) pair would fire.
199+
}
200+
}
201+
}
202+
136203
/**
137204
* Cancels the underlying browser watch and tears down the server-side
138205
* listeners.

flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationTest.java

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.vaadin.flow.dom.Element;
3333
import com.vaadin.flow.internal.JacksonUtils;
3434
import com.vaadin.flow.internal.nodefeature.ElementListenerMap;
35+
import com.vaadin.flow.shared.Registration;
3536
import com.vaadin.tests.util.MockUI;
3637

3738
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -497,6 +498,120 @@ void active_signalReflectsResumeAndStop() {
497498
assertTrue(tracker.activeSignal().peek());
498499
}
499500

501+
// --- addPositionListener() tests ---
502+
503+
@Test
504+
void addPositionListener_dispatchesPositionAndError() {
505+
TestComponent component = new TestComponent();
506+
ui.add(component);
507+
508+
GeolocationTracker tracker = ui.getGeolocation().track(component);
509+
List<GeolocationPosition> positions = new ArrayList<>();
510+
List<GeolocationError> errors = new ArrayList<>();
511+
tracker.addPositionListener(positions::add, errors::add);
512+
513+
ObjectNode posData = JacksonUtils.createObjectNode();
514+
ObjectNode posDetail = JacksonUtils.createObjectNode();
515+
ObjectNode coords = JacksonUtils.createObjectNode();
516+
coords.put("latitude", 60.1699);
517+
coords.put("longitude", 24.9384);
518+
coords.put("accuracy", 10.0);
519+
posDetail.set("coords", coords);
520+
posDetail.put("timestamp", 1700000000000L);
521+
posData.set("event.detail", posDetail);
522+
fireEvent(component.getElement(), "vaadin-geolocation-position",
523+
posData);
524+
525+
ObjectNode errData = JacksonUtils.createObjectNode();
526+
ObjectNode errDetail = JacksonUtils.createObjectNode();
527+
errDetail.put("code", GeolocationErrorCode.TIMEOUT.code());
528+
errDetail.put("message", "Timeout");
529+
errData.set("event.detail", errDetail);
530+
fireEvent(component.getElement(), "vaadin-geolocation-error", errData);
531+
532+
assertEquals(1, positions.size());
533+
assertEquals(60.1699, positions.get(0).coords().latitude());
534+
assertEquals(1, errors.size());
535+
assertEquals(GeolocationErrorCode.TIMEOUT, errors.get(0).errorCode());
536+
}
537+
538+
@Test
539+
void addPositionListener_registrationStopsDelivery() {
540+
TestComponent component = new TestComponent();
541+
ui.add(component);
542+
543+
GeolocationTracker tracker = ui.getGeolocation().track(component);
544+
List<GeolocationPosition> positions = new ArrayList<>();
545+
Registration registration = tracker.addPositionListener(positions::add,
546+
err -> {
547+
});
548+
549+
registration.remove();
550+
551+
ObjectNode posData = JacksonUtils.createObjectNode();
552+
ObjectNode posDetail = JacksonUtils.createObjectNode();
553+
ObjectNode coords = JacksonUtils.createObjectNode();
554+
coords.put("latitude", 60.0);
555+
coords.put("longitude", 25.0);
556+
coords.put("accuracy", 10.0);
557+
posDetail.set("coords", coords);
558+
posDetail.put("timestamp", 1700000000000L);
559+
posData.set("event.detail", posDetail);
560+
fireEvent(component.getElement(), "vaadin-geolocation-position",
561+
posData);
562+
563+
assertTrue(positions.isEmpty(),
564+
"removed listener must not receive subsequent updates");
565+
}
566+
567+
@Test
568+
void addPositionListener_survivesStopResume() {
569+
TestComponent component = new TestComponent();
570+
ui.add(component);
571+
572+
GeolocationTracker tracker = ui.getGeolocation().track(component);
573+
List<GeolocationPosition> positions = new ArrayList<>();
574+
tracker.addPositionListener(positions::add, err -> {
575+
});
576+
577+
tracker.stop();
578+
tracker.resume();
579+
580+
ObjectNode posData = JacksonUtils.createObjectNode();
581+
ObjectNode posDetail = JacksonUtils.createObjectNode();
582+
ObjectNode coords = JacksonUtils.createObjectNode();
583+
coords.put("latitude", 60.0);
584+
coords.put("longitude", 25.0);
585+
coords.put("accuracy", 10.0);
586+
posDetail.set("coords", coords);
587+
posDetail.put("timestamp", 1700000000000L);
588+
posData.set("event.detail", posDetail);
589+
fireEvent(component.getElement(), "vaadin-geolocation-position",
590+
posData);
591+
592+
assertEquals(1, positions.size());
593+
assertEquals(60.0, positions.get(0).coords().latitude());
594+
}
595+
596+
@Test
597+
void addPositionListener_pendingIsSilent() {
598+
TestComponent component = new TestComponent();
599+
ui.add(component);
600+
601+
GeolocationTracker tracker = ui.getGeolocation().track(component);
602+
List<GeolocationPosition> positions = new ArrayList<>();
603+
List<GeolocationError> errors = new ArrayList<>();
604+
tracker.addPositionListener(positions::add, errors::add);
605+
606+
// resume() resets the signal to Pending — listeners must stay silent
607+
// since they only see real outcomes.
608+
tracker.stop();
609+
tracker.resume();
610+
611+
assertTrue(positions.isEmpty());
612+
assertTrue(errors.isEmpty());
613+
}
614+
500615
// --- availability() / availability-change listener tests ---
501616

502617
@Test

0 commit comments

Comments
 (0)