diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index fffe8bc6dbd..7aa1d14eaa1 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -31,10 +31,9 @@ holds `private final UI ui`, and all `executeJs` calls go through `ui.getElement()` or `ui.getPage()`. Follow the `Page` / `History` pattern. Enforce single-instance creation in the constructor if needed. -If the facade hands out a stateful handle (e.g. -`Geolocation.watchPosition()` returning a `GeolocationWatcher`), make the -handle's constructor **package-private** so application code cannot bypass -the facade. +If a feature hands out a stateful handle (e.g. `Geolocation.watchPosition()` +returning a `GeolocationWatcher`), make the handle's constructor +**package-private** so application code cannot bypass the entry point. ### Keep internal mutators off user-facing classes diff --git a/flow-server/src/main/java/com/vaadin/flow/component/UI.java b/flow-server/src/main/java/com/vaadin/flow/component/UI.java index e458f9744d1..1ecdc2d7bf1 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/UI.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/UI.java @@ -32,7 +32,6 @@ import tools.jackson.databind.node.BaseJsonNode; import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.geolocation.Geolocation; import com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer; import com.vaadin.flow.component.internal.UIInternalUpdater; import com.vaadin.flow.component.internal.UIInternals; @@ -136,8 +135,6 @@ public class UI extends Component private final Page page; - private final Geolocation geolocation; - /* * Despite section 6 of RFC 4122, this particular use of UUID *is* adequate * for security capabilities. Type 4 UUIDs contain 122 bits of random data, @@ -168,7 +165,6 @@ protected UI(UIInternalUpdater internalsHandler) { Component.setElement(this, Element.get(getNode())); pushConfiguration = new PushConfigurationImpl(this); page = new Page(this); - geolocation = new Geolocation(this); } /** @@ -951,16 +947,6 @@ public Page getPage() { return page; } - /** - * Returns the {@link Geolocation} facade for this UI, used to read the end - * user's physical location from the browser. - * - * @return the Geolocation facade - */ - public Geolocation getGeolocation() { - return geolocation; - } - /** * Updates this UI to show the view corresponding to the given navigation * target. diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/BrowserGeolocationClient.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/BrowserGeolocationClient.java index 17f689cfa98..6d040e9fb95 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/BrowserGeolocationClient.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/BrowserGeolocationClient.java @@ -77,7 +77,7 @@ private record AvailabilityDetail( @Override public CompletableFuture get( - @Nullable GeolocationOptions options) { + GeolocationOptions options) { CompletableFuture future = new CompletableFuture<>(); ui.getElement() .executeJs("return window.Vaadin.Flow.geolocation.get($0)", @@ -98,8 +98,7 @@ public CompletableFuture get( } @Override - public WatchHandle startWatch(Component owner, - @Nullable GeolocationOptions options, + public WatchHandle startWatch(Component owner, GeolocationOptions options, SerializableConsumer onUpdate) { return new BrowserWatchHandle(owner, options, onUpdate); } @@ -154,8 +153,7 @@ private final class BrowserWatchHandle implements WatchHandle { private @Nullable DomListenerRegistration errorListener; private boolean active = true; - BrowserWatchHandle(Component owner, - @Nullable GeolocationOptions options, + BrowserWatchHandle(Component owner, GeolocationOptions options, SerializableConsumer onUpdate) { this.owner = owner; Element el = owner.getElement(); diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/Geolocation.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/Geolocation.java index 2aed99d38a4..a50606859c8 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/Geolocation.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/Geolocation.java @@ -26,51 +26,27 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.di.Lookup; import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.server.ErrorEvent; +import com.vaadin.flow.server.ErrorHandler; import com.vaadin.flow.server.VaadinContext; import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.signals.Signal; /** - * Facade for the browser's Geolocation API. Obtain via - * {@link UI#getGeolocation()}. - *

- * Every entry point on this class is asynchronous: calling it enqueues a - * request to the browser and returns immediately. The browser answers later - * (after the user responds to a permission prompt, after the operating system - * reports a position, or after a timeout), and Flow invokes the callback or - * updates the signal on the UI thread. - *

- * Two usage modes: - *

    - *
  • {@link #getPosition(SerializableConsumer, SerializableConsumer)} — - * one-shot position request. Use this when the application only needs to know - * the user's location at a single moment (e.g. on a button click). Takes a pair - * of callbacks — one for a successful {@link GeolocationPosition}, one for a - * {@link GeolocationError} — mirroring the W3C - * {@code getCurrentPosition(success, error)} pair and matching - * {@link GeolocationWatcher#addPositionListener - * GeolocationWatcher.addPositionListener}. An overload accepts a trailing - * {@link GeolocationOptions} for accuracy / timeout / cache-age tuning.
  • - *
  • {@link #watchPosition(Component)} — continuous watching that keeps the - * server updated as the user moves. Returns a {@link GeolocationWatcher} whose - * {@link GeolocationWatcher#valueSignal() valueSignal()} is a reactive signal - * of {@link GeolocationResult}. The browser watch is automatically cancelled - * when the owning component detaches; use {@link GeolocationWatcher#stop()} to - * cancel it sooner and {@link GeolocationWatcher#resume()} to resume.
  • - *
- * Availability check: + * Browser geolocation API for Flow applications. Two entry points: *
    - *
  • {@link #availabilityHintSignal()} — best-effort hint about whether the - * feature is usable and what permission state the origin has.
  • + *
  • {@link #getPosition(SerializableConsumer, SerializableConsumer)} — read + * the user's location once.
  • + *
  • {@link #watchPosition(Component)} — keep receiving updates as the user + * moves; returns a {@link GeolocationWatcher} handle that exposes both a + * listener API and a reactive {@link Signal}.
  • *
- * - *

- * Permission prompts. The first time the application asks for a - * location, the browser shows its own permission dialog. The dialog is - * controlled by the browser, not by Flow — Flow cannot style it, suppress it, - * or detect when it is shown. If the user denies the prompt the callback - * receives a {@link GeolocationError} whose {@link GeolocationError#errorCode() - * errorCode} is {@link GeolocationErrorCode#PERMISSION_DENIED}. + * Every call is asynchronous and returns immediately; the browser answers later + * and Flow invokes the supplied consumers on the UI thread. The first call + * shows the browser's permission dialog if no decision has been recorded yet; + * if the user denies, the error consumer receives a {@link GeolocationError} + * with code {@link GeolocationErrorCode#PERMISSION_DENIED}. * *

* One-shot example: @@ -78,208 +54,201 @@ *

  * Button locate = new Button("Use my location");
  * locate.addClickListener(
- *         e -> e.getUI().getGeolocation().getPosition(
+ *         e -> Geolocation.getPosition(
  *                 pos -> showNearest(pos.coords().latitude(),
  *                         pos.coords().longitude()),
  *                 err -> showManualEntry()));
  * 
* *

- * Watching example: + * Continuous tracking with a listener (data-flow style): * *

- * GeolocationWatcher watcher = UI.getCurrent().getGeolocation()
- *         .watchPosition(this);
- * Signal.effect(this, () -> {
- *     switch (watcher.valueSignal().get()) {
- *     case GeolocationPending p -> {
- *         // waiting for first reading
- *     }
- *     case GeolocationPosition pos ->
+ * GeolocationWatcher watcher = Geolocation.watchPosition(this);
+ * watcher.addPositionListener(pos -> repository.save(pos), err -> LOGGER
+ *         .warn("location error: {} ({})", err.errorCode(), err.debugInfo()));
+ * 
+ * + *

+ * Continuous tracking with a signal (reactive UI style): + * + *

+ * GeolocationWatcher watcher = Geolocation.watchPosition(this);
+ * Signal<GeolocationResult> signal = watcher.positionSignal();
+ *
+ * status.bindText(signal.map(result -> switch (result) {
+ * case GeolocationPending p -> "Waiting for first reading…";
+ * case GeolocationError err -> "Could not locate you.";
+ * default -> "";
+ * }));
+ *
+ * Signal.effect(map, () -> {
+ *     if (signal.get() instanceof GeolocationPosition pos) {
  *         map.setCenter(new Coordinate(pos.coords().longitude(),
  *                 pos.coords().latitude()));
- *     case GeolocationError err -> showError(err.message());
  *     }
  * });
  * 
*/ -public class Geolocation implements Serializable { +public final class Geolocation implements Serializable { private static final Logger LOGGER = LoggerFactory .getLogger(Geolocation.class); - private final UI ui; - private final Signal availabilityReadOnly; + private static final GeolocationOptions DEFAULT_OPTIONS = new GeolocationOptions( + null, null, null); - private GeolocationClient client; + private Geolocation() { + // utility class + } /** - * Creates a new Geolocation facade bound to the given UI. - *

- * Framework-only. Application code obtains the instance via - * {@link UI#getGeolocation()} and should not instantiate this class - * directly — attempting to create a second instance for a UI that already - * has one throws. - *

- * The underlying {@link GeolocationClient} is resolved through - * {@link Lookup}: if a {@link GeolocationClientFactory} is registered, its - * {@link GeolocationClientFactory#create(UI)} produces the client. - * Otherwise the built-in browser-backed client is used. + * Requests the user's current position once, using the current UI. * - * @param ui - * the UI this facade belongs to + * @param onSuccess + * invoked when the browser reports a position, never + * {@code null} + * @param onError + * invoked when the browser reports an error, never {@code null} + * @throws NullPointerException + * if either consumer is {@code null} * @throws IllegalStateException - * if the UI already has a Geolocation facade + * if there is no current UI */ - public Geolocation(UI ui) { - if (ui.getGeolocation() != null) { - throw new IllegalStateException( - "A Geolocation facade has already been created for this " - + "UI. Use UI.getGeolocation() to obtain it."); - } - this.ui = ui; - this.availabilityReadOnly = ui.getInternals() - .getGeolocationAvailabilitySignal().asReadonly(); - this.client = resolveClient(ui); - wireClient(this.client); - } - - private static GeolocationClient resolveClient(UI ui) { - GeolocationClientFactory factory = lookupFactory(ui); - if (factory != null) { - return factory.create(ui); - } - GeolocationAvailability seed = ui.getInternals() - .getGeolocationAvailabilitySignal().peek(); - if (seed == null) { - seed = GeolocationAvailability.UNKNOWN; - } - return new BrowserGeolocationClient(ui, seed); + public static void getPosition( + SerializableConsumer onSuccess, + SerializableConsumer onError) { + getPosition(onSuccess, onError, DEFAULT_OPTIONS, + UI.getCurrentOrThrow()); } - private static @Nullable GeolocationClientFactory lookupFactory(UI ui) { - VaadinService service = VaadinService.getCurrent(); - if (service == null && ui.getSession() != null) { - service = ui.getSession().getService(); - } - if (service == null) { - return null; - } - VaadinContext context = service.getContext(); - if (context == null) { - return null; - } - Lookup lookup = context.getAttribute(Lookup.class); - if (lookup == null) { - return null; - } - return lookup.lookup(GeolocationClientFactory.class); + /** + * Requests the user's current position once, using the current UI, with + * tuning options. See {@link GeolocationOptions} for the available + * settings. + * + * @param onSuccess + * invoked when the browser reports a position, never + * {@code null} + * @param onError + * invoked when the browser reports an error, never {@code null} + * @param options + * accuracy / timeout / cache-age tuning, never {@code null}; + * pass an empty instance via + * {@code GeolocationOptions.builder().build()} to use browser + * defaults + * @throws NullPointerException + * if any argument is {@code null} + * @throws IllegalStateException + * if there is no current UI + */ + public static void getPosition( + SerializableConsumer onSuccess, + SerializableConsumer onError, + GeolocationOptions options) { + getPosition(onSuccess, onError, options, UI.getCurrentOrThrow()); } /** - * Requests the user's current position once. On a successful reading - * {@code onSuccess} is invoked with the {@link GeolocationPosition}; if the - * browser reports an error instead {@code onError} is invoked with the - * {@link GeolocationError}. The pair mirrors the W3C - * {@code getCurrentPosition(success, error)} signature and matches - * {@link GeolocationWatcher#addPositionListener - * GeolocationWatcher.addPositionListener}, so callers can share the same - * handler shape between one-shot and watch APIs. - *

- * The call returns immediately. The browser may show a permission dialog on - * the first call; after the user responds, exactly one of the callbacks is - * invoked on the UI thread. + * Requests the user's current position once on the given UI. Use this + * overload from background threads or anywhere {@link UI#getCurrent()} is + * unreliable. * * @param onSuccess - * invoked with the position on a successful reading; not + * invoked when the browser reports a position, never * {@code null} * @param onError - * invoked with the error if the browser reports one; not - * {@code null} + * invoked when the browser reports an error, never {@code null} + * @param ui + * the UI to dispatch the request through, never {@code null} + * @throws NullPointerException + * if any argument is {@code null} */ - public void getPosition(SerializableConsumer onSuccess, - SerializableConsumer onError) { - getPosition(onSuccess, onError, null); + public static void getPosition( + SerializableConsumer onSuccess, + SerializableConsumer onError, UI ui) { + getPosition(onSuccess, onError, DEFAULT_OPTIONS, ui); } /** - * Requests the user's current position once with tuning options. Use this - * to trade accuracy for battery/speed or to accept a recent cached reading. - * See {@link GeolocationOptions} for the available settings. - *

- * The call returns immediately. The browser may show a permission dialog on - * the first call; after the user responds, exactly one of the callbacks is - * invoked on the UI thread. + * Requests the user's current position once on the given UI with tuning + * options. Use this overload from background threads or anywhere + * {@link UI#getCurrent()} is unreliable. * * @param onSuccess - * invoked with the position on a successful reading; not + * invoked when the browser reports a position, never * {@code null} * @param onError - * invoked with the error if the browser reports one; not - * {@code null} + * invoked when the browser reports an error, never {@code null} * @param options - * accuracy / timeout / cache-age tuning, or {@code null} to use - * the browser defaults + * accuracy / timeout / cache-age tuning, never {@code null}; + * pass an empty instance via + * {@code GeolocationOptions.builder().build()} to use browser + * defaults + * @param ui + * the UI to dispatch the request through, never {@code null} + * @throws NullPointerException + * if any argument is {@code null} */ - public void getPosition(SerializableConsumer onSuccess, + public static void getPosition( + SerializableConsumer onSuccess, SerializableConsumer onError, - @Nullable GeolocationOptions options) { - Objects.requireNonNull(onSuccess, "onSuccess callback cannot be null"); - Objects.requireNonNull(onError, "onError callback cannot be null"); - client.get(options).whenComplete((outcome, error) -> { + GeolocationOptions options, UI ui) { + Objects.requireNonNull(onSuccess, "onSuccess must not be null"); + Objects.requireNonNull(onError, "onError must not be null"); + Objects.requireNonNull(options, "options must not be null"); + Objects.requireNonNull(ui, "ui must not be null"); + client(ui).get(options).whenComplete((outcome, error) -> { if (error != null) { LOGGER.debug("Geolocation getPosition() failed", error); - onError.accept(new GeolocationError( - GeolocationErrorCode.UNKNOWN.code(), - "Client-side geolocation bridge failure")); + deliverSafely(ui, + () -> onError.accept(new GeolocationError( + GeolocationErrorCode.UNKNOWN.code(), + "Client-side geolocation bridge failure"))); return; } - switch (outcome) { - case GeolocationPosition position -> onSuccess.accept(position); - case GeolocationError outcomeError -> onError.accept(outcomeError); + if (outcome instanceof GeolocationPosition position) { + deliverSafely(ui, () -> onSuccess.accept(position)); + } else if (outcome instanceof GeolocationError errorOutcome) { + deliverSafely(ui, () -> onError.accept(errorOutcome)); } }); } /** * Starts continuously watching the user's position, tied to the owner - * component's lifecycle. + * component's lifecycle. The browser reports new positions as the device + * moves; consume them via + * {@link GeolocationWatcher#addPositionListener(SerializableConsumer, SerializableConsumer)} + * for callbacks or {@link GeolocationWatcher#positionSignal()} for a + * reactive signal. The watch is cancelled automatically when {@code owner} + * detaches; call {@link GeolocationWatcher#stop()} to cancel sooner and + * {@link GeolocationWatcher#resume()} to resume. *

- * The browser reports new positions whenever it detects movement. Each - * report is delivered to the returned watcher's - * {@link GeolocationWatcher#valueSignal() valueSignal()} signal on the UI - * thread. The initial value is {@link GeolocationPending} until the first - * reading arrives, then transitions to {@link GeolocationPosition} (updated - * on every subsequent reading) or {@link GeolocationError}. - *

- * The underlying browser watch is automatically cancelled when - * {@code owner} detaches, so the application does not need to write cleanup - * code for navigation. For cancelling while the view is still attached - * (e.g. a "Stop watching" button), call {@link GeolocationWatcher#stop()} - * on the returned watcher. + * The watch starts as soon as {@code owner} is attached to a UI, so this + * method is safe to call from a view constructor: if the component is + * already attached the watch starts immediately, otherwise it starts on + * first attach. *

* Permission-revoke caveat. If the user revokes geolocation * permission while a watch is active and then grants it again, the browser - * silently stops delivering position updates to the existing watch — this - * is the W3C Geolocation API's documented behavior across browsers, not a - * Flow-specific limitation. To recover after a revoke/regrant cycle, call + * silently stops delivering updates to the existing watch — this is the W3C + * Geolocation API's documented behavior across browsers, not a + * Flow-specific limitation. Recover by calling * {@link GeolocationWatcher#stop()} followed by - * {@link GeolocationWatcher#resume()}, which installs a fresh browser - * watch. Applications that want this to happen automatically can subscribe - * to {@link #availabilityHintSignal()} with - * {@code Signal.effect(owner, ...)} and trigger the stop/resume when the - * availability transitions back to {@link GeolocationAvailability#GRANTED - * GRANTED}. + * {@link GeolocationWatcher#resume()} to install a fresh browser watch; + * subscribing to {@link #availabilityHintSignal()} can drive that + * automatically. * * @param owner - * the component that owns this watching session; detaching the - * component automatically stops the watch - * @return a watcher whose {@link GeolocationWatcher#valueSignal()} reports - * progress and whose {@link GeolocationWatcher#stop()} cancels the - * watch + * the component whose detach cancels the watch; the watch + * activates on the owner's first attach + * @return a watcher exposing the position stream and a stop/resume handle + * @throws NullPointerException + * if {@code owner} is {@code null} */ - public GeolocationWatcher watchPosition(Component owner) { - return watchPosition(owner, null); + public static GeolocationWatcher watchPosition(Component owner) { + return watchPosition(owner, DEFAULT_OPTIONS); } /** @@ -290,24 +259,28 @@ public GeolocationWatcher watchPosition(Component owner) { * {@link GeolocationOptions} for the available settings. * * @param owner - * the component that owns this watching session; detaching the - * component automatically stops the watch + * the component whose detach cancels the watch; the watch + * activates on the owner's first attach * @param options - * accuracy / timeout / cache-age tuning, or {@code null} to use - * the browser defaults - * @return a watcher whose {@link GeolocationWatcher#valueSignal()} reports - * progress and whose {@link GeolocationWatcher#stop()} cancels the - * watch + * accuracy / timeout / cache-age tuning, never {@code null}; + * pass an empty instance via + * {@code GeolocationOptions.builder().build()} to use browser + * defaults + * @return a watcher exposing the position stream and a stop/resume handle + * @throws NullPointerException + * if either argument is {@code null} */ - public GeolocationWatcher watchPosition(Component owner, - @Nullable GeolocationOptions options) { - return new GeolocationWatcher(owner, options, client); + public static GeolocationWatcher watchPosition(Component owner, + GeolocationOptions options) { + Objects.requireNonNull(owner, "owner must not be null"); + Objects.requireNonNull(options, "options must not be null"); + return new GeolocationWatcher(owner, options); } /** * Returns a read-only signal hinting at whether geolocation is usable for - * this UI. Useful for pre-rendering decisions like hiding a "Locate me" - * button on insecure contexts or auto-fetching on return visits when + * the current UI. Useful for pre-rendering decisions like hiding a "Locate + * me" button on insecure contexts or auto-fetching on return visits when * permission is already granted. *

* The value is best-effort and can be briefly stale: @@ -326,21 +299,95 @@ public GeolocationWatcher watchPosition(Component owner, * outcome. * * @return the availability hint signal + * @throws IllegalStateException + * if there is no current UI */ - public Signal availabilityHintSignal() { - return availabilityReadOnly; + public static Signal availabilityHintSignal() { + return availabilityHintSignal(UI.getCurrentOrThrow()); + } + + /** + * Returns a read-only signal hinting at whether geolocation is usable for + * the given UI. Same semantics as {@link #availabilityHintSignal()}; use + * this overload from background threads or anywhere {@link UI#getCurrent()} + * is unreliable. + * + * @param ui + * the UI to read the hint from, never {@code null} + * @return the availability hint signal + * @throws NullPointerException + * if {@code ui} is {@code null} + */ + public static Signal availabilityHintSignal( + UI ui) { + Objects.requireNonNull(ui, "ui must not be null"); + // Ensure a client is installed so the signal is wired to the browser. + client(ui); + return ui.getInternals().getGeolocationAvailabilitySignal() + .asReadonly(); + } + + /** + * Runs {@code callback} and routes any {@link RuntimeException} it throws + * to the session's {@link ErrorHandler}, matching the behavior of other + * Flow listener APIs. Rethrows when no error handler is reachable (e.g. + * during session teardown) so the exception is not silently lost. + */ + static void deliverSafely(UI ui, Runnable callback) { + try { + callback.run(); + } catch (RuntimeException e) { + VaadinSession session = ui.getSession(); + ErrorHandler handler = session == null ? null + : session.getErrorHandler(); + if (handler != null) { + handler.error(new ErrorEvent(e)); + } else { + throw e; + } + } + } + + static GeolocationClient client(UI ui) { + GeolocationClient existing = ui.getInternals().getGeolocationClient(); + if (existing != null) { + return existing; + } + GeolocationClient client = resolveClient(ui); + ui.getInternals().setGeolocationClient(client); + return client; } - void setClient(GeolocationClient client) { - this.client.close(); - this.client = client; - wireClient(client); + private static GeolocationClient resolveClient(UI ui) { + GeolocationClientFactory factory = lookupFactory(ui); + if (factory != null) { + return factory.create(ui); + } + GeolocationAvailability seed = ui.getInternals() + .getGeolocationAvailabilitySignal().peek(); + if (seed == null) { + seed = GeolocationAvailability.UNKNOWN; + } + return new BrowserGeolocationClient(ui, seed); } - private void wireClient(GeolocationClient client) { - ui.getInternals() - .setGeolocationAvailability(client.currentAvailability()); - client.subscribeAvailability( - next -> ui.getInternals().setGeolocationAvailability(next)); + private static @Nullable GeolocationClientFactory lookupFactory(UI ui) { + VaadinService service = VaadinService.getCurrent(); + if (service == null && ui.getSession() != null) { + service = ui.getSession().getService(); + } + if (service == null) { + return null; + } + VaadinContext context = service.getContext(); + if (context == null) { + return null; + } + Lookup lookup = context.getAttribute(Lookup.class); + if (lookup == null) { + return null; + } + return lookup.lookup(GeolocationClientFactory.class); } + } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClient.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClient.java index 8ec4081f852..1ec8d526912 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClient.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClient.java @@ -19,24 +19,18 @@ import java.util.concurrent.CompletableFuture; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; import com.vaadin.flow.component.Component; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.shared.Registration; /** - * Port between the {@link Geolocation} facade and whatever delivers actual - * position data — the browser in production, an in-memory test driver in unit - * tests. - *

- * Framework internal. Application code does not implement this interface - * directly. Replacement clients are installed at facade construction time by - * registering a {@link GeolocationClientFactory} through Vaadin's - * {@link com.vaadin.flow.di.Lookup Lookup}; {@link Geolocation} then hands the - * resulting client every {@link #get}, {@link #startWatch} and - * {@link #subscribeAvailability} call. When no factory is registered, - * {@code Geolocation} uses the built-in browser-backed client. + * Framework-internal port between the {@link Geolocation} static API and + * whatever delivers actual position data — the browser in production, an + * in-memory driver in browserless tests. Application code does not interact + * with this interface; it is exposed so external test drivers can replace the + * production client via + * {@link com.vaadin.flow.component.internal.UIInternals#setGeolocationClient(GeolocationClient)}. *

* Threading: all callbacks on this interface (the future returned by * {@link #get}, the {@code onUpdate} consumer passed to {@link #startWatch}, @@ -51,11 +45,11 @@ public interface GeolocationClient extends Serializable { * has an answer (a position or an error). * * @param options - * tuning options, or {@code null} for browser defaults + * tuning options, never {@code null}; pass an empty + * {@link GeolocationOptions} to use browser defaults * @return a future that completes with the outcome on the UI thread */ - CompletableFuture get( - @Nullable GeolocationOptions options); + CompletableFuture get(GeolocationOptions options); /** * Starts a watch session bound to {@code owner}. Position and error pushes @@ -66,13 +60,13 @@ CompletableFuture get( * the component that owns this watch; detaching the component * does not auto-stop the watch — the caller is responsible * @param options - * tuning options, or {@code null} for browser defaults + * tuning options, never {@code null}; pass an empty + * {@link GeolocationOptions} to use browser defaults * @param onUpdate * consumer invoked on the UI thread for every push * @return a handle for stopping the watch */ - WatchHandle startWatch(Component owner, - @Nullable GeolocationOptions options, + WatchHandle startWatch(Component owner, GeolocationOptions options, SerializableConsumer onUpdate); /** @@ -96,17 +90,17 @@ Registration subscribeAvailability( GeolocationAvailability currentAvailability(); /** - * Releases any resources held by this client. Called on UI detach. - * Idempotent: calling more than once is a no-op. After {@code close()}, the - * behavior of {@link #get} and {@link #startWatch} is undefined and the - * facade must not call them. + * Releases any resources held by this client. Called when one client is + * being replaced by another (e.g. when a test driver is installed) and on + * UI detach. Idempotent: calling more than once is a no-op. After + * {@code close()}, the behavior of {@link #get} and {@link #startWatch} is + * undefined and callers must not invoke them. */ void close(); /** - * Handle to a watcher watch session. The handle is alive while the - * underlying watch is active; calling {@link #stop()} idempotently tears it - * down. + * Handle to a watch session. The handle is alive while the underlying watch + * is active; calling {@link #stop()} idempotently tears it down. */ interface WatchHandle extends Serializable { /** diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClientFactory.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClientFactory.java index 6d919338c7d..0de778c8b51 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClientFactory.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationClientFactory.java @@ -25,7 +25,7 @@ /** * Framework internal. Factory SPI that produces * {@link GeolocationClient} instances per {@link UI}, resolved via - * {@link Lookup} when a {@link Geolocation} facade is constructed. When a + * {@link Lookup} the first time {@link Geolocation} is used for a UI. When a * factory is registered the resulting client replaces the built-in * browser-backed client for every {@code UI} in the application; when none is, * {@code Geolocation} uses the browser-backed client. @@ -41,7 +41,7 @@ public interface GeolocationClientFactory extends Serializable { /** * Creates a {@link GeolocationClient} for the given UI. Called once per UI, - * the first time {@link UI#getGeolocation()} is invoked. + * the first time a {@link Geolocation} entry point is invoked for that UI. * * @param ui * the UI for which the client is created diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationError.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationError.java index 8b53e053626..ff97fd8083f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationError.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationError.java @@ -15,25 +15,27 @@ */ package com.vaadin.flow.component.geolocation; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * A failed location reading: the request did not produce a * {@link GeolocationPosition}. *

- * This is one of the three possible values of a - * {@link GeolocationWatcher#valueSignal()} signal, and the value passed to the - * error callback of {@link Geolocation#getPosition Geolocation.getPosition}. - * Typical application code switches on {@link #errorCode()} to react to the - * specific reason: + * Delivered to the error consumer of + * {@link Geolocation#getPosition(com.vaadin.flow.function.SerializableConsumer, com.vaadin.flow.function.SerializableConsumer)} + * and held by the {@link GeolocationWatcher#positionSignal()} signal. Typical + * application code switches on {@link #errorCode()} to react to the specific + * reason: * *

- * ui.getGeolocation().getPosition(pos -> showNearest(pos), err -> {
+ * Geolocation.getPosition(pos -> showOnMap(pos), err -> {
  *     switch (err.errorCode()) {
  *     case PERMISSION_DENIED ->
  *         showExplanation("Location is blocked for this site.");
  *     case POSITION_UNAVAILABLE ->
  *         showRetry("Could not determine your location.");
  *     case TIMEOUT -> showRetry("Location request took too long.");
- *     case UNKNOWN -> showGenericError("Could not read your location.");
+ *     case UNKNOWN -> showGenericError();
  *     }
  * });
  * 
@@ -45,13 +47,16 @@ * the raw numeric error code as reported by the browser. * Applications should usually call {@link #errorCode()} instead of * comparing this directly - * @param message - * a human-readable message from the browser. Mainly useful for - * logging — the wording is not standardised and should not be shown - * to end users as-is + * @param debugInfo + * a free-form description of the failure as reported by the browser. + * Useful for log lines and bug reports — the wording is not + * standardised across browsers and must not be shown to end users + * as-is */ public record GeolocationError(int code, - String message) implements GeolocationOutcome { + @JsonProperty("message") String debugInfo) + implements + GeolocationOutcome { /** * Returns the error reason as a typed enum suitable for exhaustive diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOptions.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOptions.java index e974b093284..f7b77a78d2d 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOptions.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOptions.java @@ -28,7 +28,10 @@ *

* Every field is optional. A {@code null} field means "let the browser decide": * high accuracy defaults to {@code false}, timeout defaults to no timeout at - * all, and cached readings are never accepted unless explicitly allowed. + * all, and cached readings are never accepted unless explicitly allowed. An + * instance with no fields set ({@code GeolocationOptions.builder().build()}) + * therefore represents the browser defaults; the {@code Geolocation} overloads + * that take no {@code options} argument use the same instance internally. *

* Hand-written code should use {@link #builder()} rather than the canonical * constructor: the builder labels each setting at the call site and accepts diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOutcome.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOutcome.java index 9cd151f9fcd..8ef98b69d69 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOutcome.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationOutcome.java @@ -16,17 +16,15 @@ package com.vaadin.flow.component.geolocation; /** - * The actual answer to a geolocation request — either a successful reading or - * an error. Narrower than {@link GeolocationResult}: the "waiting for first - * reading" {@link GeolocationPending} state is excluded because a one-shot - * request never produces it. + * Internal narrowing of {@link GeolocationResult} to the two states that the + * client port resolves to: {@link GeolocationPosition} or + * {@link GeolocationError}. Excludes the "waiting for first reading" + * {@link GeolocationPending} state, which is only meaningful for an active + * {@link GeolocationWatcher} signal. *

- * Used as the result type of the internal {@link GeolocationClient#get} future, - * where the sum-type encoding keeps Pending out of the contract. Application - * code rarely references this type directly: {@link Geolocation#getPosition - * Geolocation.getPosition} delivers the position or the error through separate - * callbacks. + * Package-private; the public API exposes successes and errors as separate + * consumers instead. */ -public sealed interface GeolocationOutcome extends GeolocationResult +sealed interface GeolocationOutcome extends GeolocationResult permits GeolocationPosition, GeolocationError { } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPending.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPending.java index 99fba8bc047..28cc5a39840 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPending.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPending.java @@ -16,11 +16,10 @@ package com.vaadin.flow.component.geolocation; /** - * The initial state of a newly started watching session, held by - * {@link GeolocationWatcher#valueSignal()} until the browser reports its first - * position or error. One-shot {@link Geolocation#getPosition} requests never - * produce this value — they deliver a position or an error through separate - * callbacks. + * The initial state of a newly started watch session, held by + * {@link GeolocationWatcher#positionSignal()} until the browser reports its + * first position or error. One-shot {@link Geolocation#getPosition} callers + * never observe this value. */ public record GeolocationPending() implements GeolocationResult { } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPosition.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPosition.java index 4b37454a624..90fbeebceda 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPosition.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationPosition.java @@ -21,9 +21,9 @@ * A successful location reading: the coordinates the browser reported and the * moment in time they were taken. *

- * This is one of the three possible values of a - * {@link GeolocationWatcher#valueSignal()} signal, and the value passed to the - * success callback of {@link Geolocation#getPosition Geolocation.getPosition}. + * Delivered to the success consumer of + * {@link Geolocation#getPosition(com.vaadin.flow.function.SerializableConsumer, com.vaadin.flow.function.SerializableConsumer)} + * and held by the {@link GeolocationWatcher#positionSignal()} signal. * * @param coords * the latitude/longitude and related fields; see diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationResult.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationResult.java index 4f78d46afd3..1376d99ad86 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationResult.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationResult.java @@ -18,30 +18,22 @@ import java.io.Serializable; /** - * Anything a watcher can currently hold — a successful reading, an error, or - * the initial "waiting for first reading" state. - *

- * Held by the signal exposed by {@link GeolocationWatcher#valueSignal()}. A - * {@code GeolocationResult} is always exactly one of three things: + * The value held by {@link GeolocationWatcher#positionSignal()} — a successful + * reading, an error, or the initial "waiting for first reading" state. Always + * exactly one of: *

    - *
  • {@link GeolocationPending} — the initial state of a newly started - * watcher, before the browser has reported anything.
  • + *
  • {@link GeolocationPending} — initial state, before the browser has + * reported anything.
  • *
  • {@link GeolocationPosition} — a successful reading.
  • *
  • {@link GeolocationError} — the browser reported an error.
  • *
- * One-shot {@link Geolocation#getPosition} requests never produce - * {@link GeolocationPending}; they deliver the position and the error through - * separate callbacks instead. - *

- * The sealed hierarchy is designed for exhaustive pattern matching. A - * {@code switch} covering the three permitted variants is guaranteed complete - * at compile time. + * The sealed hierarchy supports exhaustive pattern matching: * *

- * switch (watcher.valueSignal().get()) {
+ * switch (watcher.positionSignal().get()) {
  * case GeolocationPending p -> showSpinner();
  * case GeolocationPosition pos -> map.setCenter(pos.coords());
- * case GeolocationError err -> showError(err.message());
+ * case GeolocationError err -> showError(err.errorCode());
  * }
  * 
*/ diff --git a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationWatcher.java b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationWatcher.java index 8965f4c71e0..5e59a97611f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationWatcher.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/geolocation/GeolocationWatcher.java @@ -23,34 +23,42 @@ import org.jspecify.annotations.Nullable; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.shared.Registration; import com.vaadin.flow.signals.Signal; import com.vaadin.flow.signals.local.ValueSignal; /** - * A handle to a geolocation watching session, returned by - * {@link Geolocation#watchPosition(Component)} / - * {@link Geolocation#watchPosition(Component, GeolocationOptions)}. + * A handle to a geolocation watch session, returned by + * {@link Geolocation#watchPosition(Component)} and its overload. *

- * Exposes the latest {@link GeolocationResult} as a reactive signal via - * {@link #valueSignal()}, and lets the application cancel watching via - * {@link #stop()} or resume it via {@link #resume()}. The underlying browser - * watch is also cancelled automatically when the owning component detaches, so - * most applications never need to call {@code stop()} explicitly — it is - * provided for "Stop watching" buttons and similar mid-view cancellation. + * Two ways to consume the stream of readings: + *

    + *
  • {@link #addPositionListener(SerializableConsumer, SerializableConsumer)} + * — non-reactive callback pair. Convenient when the destination is a service, + * repository, or anything that is not a UI component (e.g. a sports tracker + * that writes points to a database).
  • + *
  • {@link #positionSignal()} — reactive signal of {@link GeolocationResult}. + * Convenient when binding component state to the position via + * {@code Signal.effect} or {@code component.bindText(...)}.
  • + *
+ * The underlying browser watch is cancelled automatically when the owning + * component detaches; call {@link #stop()} to cancel sooner and + * {@link #resume()} to restart on the same handle. Bindings and listeners + * survive stop/resume cycles. *

- * A watcher is reusable: after {@link #stop()} you can call {@link #resume()} - * to resume watching on the same handle, and any effects or bindings subscribed - * to {@link #valueSignal()} continue to work. Bind a toggle button's state to - * {@link #activeSignal()} to let the UI react to start/stop without tracking - * your own flag. + * The watch starts as soon as the owning component is attached to a UI: if the + * component is already attached when the watcher is created the watch starts + * immediately, otherwise it starts on first attach. Calling {@link #stop()} + * before the first attach cancels the pending activation; the watcher can still + * be activated later by calling {@link #resume()} on an attached owner. */ public class GeolocationWatcher implements Serializable { - private final ValueSignal valueSignal = new ValueSignal<>( + private final ValueSignal positionSignal = new ValueSignal<>( new GeolocationPending()); - private final Signal valueSignalReadOnly = valueSignal + private final Signal positionSignalReadOnly = positionSignal .asReadonly(); private final ValueSignal activeSignal = new ValueSignal<>( @@ -58,163 +66,133 @@ public class GeolocationWatcher implements Serializable { private final Signal activeSignalReadOnly = activeSignal .asReadonly(); - private final List> positionListeners = new ArrayList<>(); - private final List> errorListeners = new ArrayList<>(); + private final List listeners = new ArrayList<>(); private final Component owner; - private final @Nullable GeolocationOptions options; - private final GeolocationClient client; + private final GeolocationOptions options; private GeolocationClient.@Nullable WatchHandle handle; private @Nullable Registration detachRegistration; + private @Nullable Registration pendingAttachActivation; - GeolocationWatcher(Component owner, @Nullable GeolocationOptions options, - GeolocationClient client) { + GeolocationWatcher(Component owner, GeolocationOptions options) { this.owner = owner; this.options = options; - this.client = client; - resume(); + if (owner.getUI().isPresent()) { + resume(); + } else { + pendingAttachActivation = owner.addAttachListener(e -> { + cancelPendingAttachActivation(); + resume(); + }); + } } /** - * Returns a read-only signal that holds the most recent watching result. - *

- * Combine with {@code Signal.effect(owner, ...)} or an attach listener to - * run code whenever the value changes — the effect re-runs automatically on - * every update and no manual event-listener bookkeeping is required. Inside - * an effect or another reactive context, call {@code valueSignal().get()} - * to read the current value and subscribe to further updates; outside a - * reactive context, call {@code valueSignal().peek()} to read a snapshot - * without subscribing. + * Returns a read-only signal that holds the most recent reading. *

- * The signal starts as {@link GeolocationPending} until the first reading - * arrives, then transitions to {@link GeolocationPosition} on every - * successful reading, or {@link GeolocationError} on failure. After - * {@link #stop()} (or after the owner detaches), the last value remains - * readable but the signal stops receiving updates. Calling - * {@link #resume()} resumes updates; the signal is reset to - * {@link GeolocationPending} on resume. + * Starts as {@link GeolocationPending} until the browser reports its first + * value, then transitions to {@link GeolocationPosition} on every + * successful reading or {@link GeolocationError} on failure. After + * {@link #stop()} the signal stops receiving updates but its last value + * stays readable; {@link #resume()} resets the value to + * {@link GeolocationPending} and resumes updates. Subscribers stay attached + * across stop/resume cycles. * - * @return a read-only signal reporting the latest result + * @return a read-only signal reporting the latest reading */ - public Signal valueSignal() { - return valueSignalReadOnly; + public Signal positionSignal() { + return positionSignalReadOnly; } /** - * Returns a read-only signal that indicates whether the watcher is - * currently receiving updates. Flips to {@code true} on {@link #resume()} - * and to {@code false} on {@link #stop()} (or when the owner detaches). - *

- * Subscribe with {@code Signal.effect(owner, ...)} to bind a toggle - * button's label/state to the watcher without tracking a separate flag. - * Inside a reactive context, call {@code activeSignal().get()} to - * subscribe; outside a reactive context, call {@code activeSignal().peek()} - * for a snapshot. + * Returns a read-only signal indicating whether the watcher is currently + * receiving updates. Flips to {@code true} on {@link #resume()} and to + * {@code false} on {@link #stop()} (or when the owner detaches). Useful for + * binding a "Stop tracking" toggle's state without tracking a separate + * flag. * - * @return a read-only signal reporting whether watching is active + * @return a read-only signal reporting whether the watch is active */ public Signal activeSignal() { return activeSignalReadOnly; } /** - * Adds a listener pair that is notified on every reading the browser - * reports. The listener-based equivalent of subscribing to - * {@link #valueSignal()} for callers that prefer plain callbacks over - * signals. + * Subscribes to position and error pushes from the watch. *

- * On every successful reading {@code onSuccess} is invoked with the - * {@link GeolocationPosition}. If the browser reports an error instead - * {@code onError} is invoked with the {@link GeolocationError}. The initial - * {@link GeolocationPending} state is never delivered to listeners — they - * only see real outcomes, mirroring the W3C - * {@code watchPosition(success, error)} pair. + * {@code onPosition} fires for every {@link GeolocationPosition} the + * browser reports. {@code onError} fires for every {@link GeolocationError} + * the browser reports. Neither fires for the initial + * {@link GeolocationPending} state. Listeners stay attached across + * {@link #stop()}/{@link #resume()} cycles; remove them through the + * returned {@link Registration}. *

- * Listeners survive {@link #stop()} / {@link #resume()} cycles; remove them - * via {@link Registration#remove()} on the returned registration. Both - * callbacks are invoked on the UI thread. + * Both consumers are required and must be non-null. To opt out of either + * notification, pass {@code pos -> {}} or {@code err -> {}} explicitly. * - * @param onSuccess - * invoked with each successful position reading; not - * {@code null} + * @param onPosition + * invoked on every successful reading, never {@code null} * @param onError - * invoked when the browser reports an error; not {@code null} - * @return a registration that removes both listeners when called + * invoked on every error reading, never {@code null} + * @return a registration that removes both listeners when removed + * @throws NullPointerException + * if either consumer is {@code null} */ public Registration addPositionListener( - SerializableConsumer onSuccess, + SerializableConsumer onPosition, SerializableConsumer onError) { - Objects.requireNonNull(onSuccess, "onSuccess listener cannot be null"); - Objects.requireNonNull(onError, "onError listener cannot be null"); - positionListeners.add(onSuccess); - errorListeners.add(onError); - return () -> { - positionListeners.remove(onSuccess); - errorListeners.remove(onError); - }; + Objects.requireNonNull(onPosition, "onPosition must not be null"); + Objects.requireNonNull(onError, "onError must not be null"); + PositionListener listener = new PositionListener(onPosition, onError); + listeners.add(listener); + return () -> listeners.remove(listener); } /** * Starts, or resumes, the underlying browser watch. *

- * Called automatically from the constructor so that a freshly created - * watcher is immediately active. Call again after {@link #stop()} to resume - * watching on the same handle — any effects or bindings subscribed to - * {@link #valueSignal()} stay attached and start receiving new updates. - *

- * The signal is reset to {@link GeolocationPending} on every resume. - * Calling {@code resume()} on an already-running watcher is a no-op. + * Called automatically when the owner is attached to a UI: immediately if + * it is already attached when the watcher is created, otherwise on first + * attach. Call again after {@link #stop()} to resume on the same handle — + * bindings and listeners stay attached and start receiving updates again. + * The signal resets to {@link GeolocationPending} on every resume. Calling + * {@code resume()} on an already-running watcher is a no-op. + * + * @throws IllegalStateException + * if the owner is not attached to a UI */ public void resume() { if (activeSignal.peek()) { return; } + UI ui = owner.getUI() + .orElseThrow(() -> new IllegalStateException( + "Owner component must be attached to a UI before " + + "resume() is called")); + GeolocationClient client = Geolocation.client(ui); activeSignal.set(Boolean.TRUE); - valueSignal.set(new GeolocationPending()); + positionSignal.set(new GeolocationPending()); - handle = client.startWatch(owner, options, this::handleResult); + handle = client.startWatch(owner, options, this::dispatch); detachRegistration = owner.addDetachListener(e -> stop()); } - private void handleResult(GeolocationResult result) { - valueSignal.set(result); - switch (result) { - case GeolocationPosition position -> { - for (SerializableConsumer listener : new ArrayList<>( - positionListeners)) { - listener.accept(position); - } - } - case GeolocationError error -> { - for (SerializableConsumer listener : new ArrayList<>( - errorListeners)) { - listener.accept(error); - } - } - case GeolocationPending pending -> { - // Intentionally not dispatched to listeners — Pending is the - // initial state set by resume(), not an outcome the W3C - // watchPosition(success, error) pair would fire. - } - } - } - /** * Cancels the underlying browser watch and tears down the server-side - * listeners. + * subscriptions. *

- * The browser stops reporting position updates and {@link #valueSignal()} - * stops changing. The last value remains readable. This is the way to end - * watching from application code (e.g. a "Stop" button) — leaving the view - * automatically calls this method, so there is no need to call it from a - * detach listener. + * The browser stops reporting updates and {@link #positionSignal()} stops + * changing. The last value remains readable. Detaching the owning component + * calls this automatically, so most applications never need to call it from + * a detach listener. *

- * Idempotent and always safe: calling it twice, or calling it on a watcher - * whose owner has already detached, does nothing extra. After - * {@code stop()} the watcher can be resumed with {@link #resume()}. + * Idempotent: calling it twice, or after the owner has already detached, + * does nothing extra. After {@code stop()} the watcher can be resumed with + * {@link #resume()}. */ public void stop() { + cancelPendingAttachActivation(); if (!activeSignal.peek()) { return; } @@ -229,14 +207,48 @@ public void stop() { } } + private void cancelPendingAttachActivation() { + if (pendingAttachActivation != null) { + pendingAttachActivation.remove(); + pendingAttachActivation = null; + } + } + /** * Returns the active watch handle, or {@code null} if the watcher is not - * currently active. + * currently active. Framework-internal seam used by external test drivers + * to reach the underlying watch. * - * @return the active watch handle, or {@code null} if the watcher has been - * stopped or auto-cancelled + * @return the active watch handle, or {@code null} if stopped */ GeolocationClient.@Nullable WatchHandle handle() { return handle; } + + private void dispatch(GeolocationResult result) { + positionSignal.set(result); + if (listeners.isEmpty()) { + return; + } + UI ui = owner.getUI().orElseThrow(); + List snapshot = new ArrayList<>(listeners); + if (result instanceof GeolocationPosition position) { + for (PositionListener listener : snapshot) { + Geolocation.deliverSafely(ui, + () -> listener.onPosition.accept(position)); + } + } else if (result instanceof GeolocationError error) { + for (PositionListener listener : snapshot) { + Geolocation.deliverSafely(ui, + () -> listener.onError.accept(error)); + } + } + } + + private record PositionListener( + SerializableConsumer onPosition, + SerializableConsumer onError) + implements + Serializable { + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java index b29877d4bd0..d54bdd12e98 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java @@ -48,6 +48,7 @@ import com.vaadin.flow.component.dependency.JavaScript; import com.vaadin.flow.component.dependency.StyleSheet; import com.vaadin.flow.component.geolocation.GeolocationAvailability; +import com.vaadin.flow.component.geolocation.GeolocationClient; import com.vaadin.flow.component.internal.ComponentMetaData.DependencyInfo; import com.vaadin.flow.component.page.ExtendedClientDetails; import com.vaadin.flow.component.page.Page; @@ -245,6 +246,10 @@ public List getParameters() { private final ValueSignal geolocationAvailabilitySignal = new ValueSignal<>( GeolocationAvailability.UNKNOWN); + private GeolocationClient geolocationClient; + + private Registration geolocationClientAvailabilityRegistration; + private ArrayDeque modalComponentStack; private Element wrapperElement; @@ -1464,6 +1469,43 @@ public void setGeolocationAvailability( this.geolocationAvailabilitySignal.set(availability); } + /** + * Returns the geolocation client currently bound to this UI, or + * {@code null} if none has been installed yet. Framework-internal: + * application code resolves the client through the static + * {@link com.vaadin.flow.component.geolocation.Geolocation} API, which + * lazily installs a default client on first use. + * + * @return the installed geolocation client, or {@code null} + */ + public GeolocationClient getGeolocationClient() { + return geolocationClient; + } + + /** + * Installs the given geolocation client on this UI, replacing any previous + * one. The previous client is closed; the availability signal is seeded + * with the new client's current availability and updated whenever the + * client reports a change. Framework-internal entry point used by the + * default {@code Geolocation} bootstrap and by external test drivers that + * substitute a browserless client. + * + * @param client + * the client to install, never {@code null} + */ + public void setGeolocationClient(GeolocationClient client) { + if (geolocationClient != null) { + geolocationClient.close(); + } + if (geolocationClientAvailabilityRegistration != null) { + geolocationClientAvailabilityRegistration.remove(); + } + geolocationClient = client; + setGeolocationAvailability(client.currentAvailability()); + geolocationClientAvailabilityRegistration = client + .subscribeAvailability(this::setGeolocationAvailability); + } + /** * Check if we have a modal component defined for the UI. * diff --git a/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationClientSeamTest.java b/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationClientSeamTest.java index 11eef640495..4728433b2fe 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationClientSeamTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationClientSeamTest.java @@ -27,7 +27,6 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.UI; import com.vaadin.flow.di.Lookup; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.server.VaadinService; @@ -44,9 +43,10 @@ /** * Tests the {@link GeolocationClient} seam — both the public * {@link GeolocationClientFactory} extension point that external test drivers - * and native bridges register through {@link Lookup}, and the package-private - * {@link Geolocation#setClient(GeolocationClient)} replacement that - * flow-server's own tests use. + * and native bridges register through {@link Lookup}, and the + * {@link com.vaadin.flow.component.internal.UIInternals#setGeolocationClient(GeolocationClient)} + * direct-install seam used by flow-server's own tests and by the + * {@link GeolocationWatcher#handle()} accessor. */ class GeolocationClientSeamTest { @@ -62,54 +62,54 @@ private static class TestComponent extends Component { } @Test - void lookupFactory_resolvedAtConstruction_clientReceivesGetCalls() { + void lookupFactory_resolvedOnFirstUse_clientReceivesGetCalls() { FakeClient fake = new FakeClient(); VaadinService service = VaadinService.getCurrent(); Lookup lookup = service.getContext().getAttribute(Lookup.class); Mockito.when(lookup.lookup(GeolocationClientFactory.class)) .thenReturn(unused -> fake); - UI freshUi = new MockUI(); - freshUi.getGeolocation().getPosition(pos -> { + MockUI freshUi = new MockUI(); + Geolocation.getPosition(pos -> { }, err -> { - }); + }, freshUi); assertEquals(1, fake.getCalls.size(), - "factory-produced client should receive get() calls"); + "factory-produced client should receive getPosition() calls"); } @Test - void setClient_routesGetThroughInstalledClient() { + void setGeolocationClient_routesGetThroughInstalledClient() { FakeClient fake = new FakeClient(); - ui.getGeolocation().setClient(fake); + ui.getInternals().setGeolocationClient(fake); - ui.getGeolocation().getPosition(pos -> { + Geolocation.getPosition(pos -> { }, err -> { - }); + }, ui); assertEquals(1, fake.getCalls.size(), - "get() should route through the installed client"); + "getPosition() should route through the installed client"); } @Test - void setClient_closesPreviousClient() { + void setGeolocationClient_closesPreviousClient() { FakeClient first = new FakeClient(); FakeClient second = new FakeClient(); - ui.getGeolocation().setClient(first); - ui.getGeolocation().setClient(second); + ui.getInternals().setGeolocationClient(first); + ui.getInternals().setGeolocationClient(second); assertTrue(first.closed, - "previous client should be closed when setClient replaces it"); + "previous client should be closed when setGeolocationClient replaces it"); } @Test void watchPosition_handleComesFromCurrentClient() { FakeClient fake = new FakeClient(); - ui.getGeolocation().setClient(fake); + ui.getInternals().setGeolocationClient(fake); TestComponent owner = new TestComponent(); ui.add(owner); - GeolocationWatcher watcher = ui.getGeolocation().watchPosition(owner); + GeolocationWatcher watcher = Geolocation.watchPosition(owner); GeolocationClient.WatchHandle handle = watcher.handle(); assertNotNull(handle, "watcher should expose its watch handle"); @@ -120,11 +120,11 @@ void watchPosition_handleComesFromCurrentClient() { @Test void watchPosition_handleIsNullAfterStop() { FakeClient fake = new FakeClient(); - ui.getGeolocation().setClient(fake); + ui.getInternals().setGeolocationClient(fake); TestComponent owner = new TestComponent(); ui.add(owner); - GeolocationWatcher watcher = ui.getGeolocation().watchPosition(owner); + GeolocationWatcher watcher = Geolocation.watchPosition(owner); watcher.stop(); assertNull(watcher.handle(), @@ -137,19 +137,19 @@ void getPosition_onErrorReceivesUnknownErrorWhenClientFutureFailsExceptionally() fake.nextGetResult = CompletableFuture .failedFuture(new RuntimeException( "Client-side geolocation.get failed: boom")); - ui.getGeolocation().setClient(fake); + ui.getInternals().setGeolocationClient(fake); AtomicReference<@Nullable GeolocationPosition> position = new AtomicReference<>(); - AtomicReference<@Nullable GeolocationError> error = new AtomicReference<>(); - ui.getGeolocation().getPosition(position::set, error::set); + AtomicReference<@Nullable GeolocationError> received = new AtomicReference<>(); + Geolocation.getPosition(position::set, received::set, ui); - GeolocationError err = error.get(); + GeolocationError err = received.get(); assertNotNull(err, "onError must fire even when the JS bridge fails"); assertNull(position.get(), "onSuccess must stay silent when the bridge fails"); assertEquals(GeolocationErrorCode.UNKNOWN, err.errorCode(), "error code should be UNKNOWN for client-bridge failures"); - assertFalse(err.message().contains("boom"), + assertFalse(err.debugInfo().contains("boom"), "synthesized message must not leak the wrapped exception text;" + " the cause is logged at DEBUG instead"); } @@ -159,7 +159,7 @@ void getPosition_onErrorReceivesUnknownErrorWhenClientFutureFailsExceptionally() * WatchHandle from startWatch. */ private static class FakeClient implements GeolocationClient { - final List<@Nullable GeolocationOptions> getCalls = new ArrayList<>(); + final List getCalls = new ArrayList<>(); boolean closed; GeolocationClient.@Nullable WatchHandle lastWatchHandle; @Nullable @@ -167,7 +167,7 @@ private static class FakeClient implements GeolocationClient { @Override public CompletableFuture get( - @Nullable GeolocationOptions options) { + GeolocationOptions options) { getCalls.add(options); CompletableFuture result = nextGetResult; return result != null ? result : new CompletableFuture<>(); @@ -175,7 +175,7 @@ public CompletableFuture get( @Override public WatchHandle startWatch(Component owner, - @Nullable GeolocationOptions options, + GeolocationOptions options, SerializableConsumer onUpdate) { lastWatchHandle = new FakeWatchHandle(); return lastWatchHandle; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationTest.java b/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationTest.java index 9f8f4f9f18f..1ccb04ed1fb 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/geolocation/GeolocationTest.java @@ -23,6 +23,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import tools.jackson.databind.node.ObjectNode; import com.vaadin.flow.component.Component; @@ -32,6 +33,7 @@ import com.vaadin.flow.dom.Element; import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.server.ErrorHandler; import com.vaadin.flow.shared.Registration; import com.vaadin.tests.util.MockUI; @@ -41,7 +43,6 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -116,7 +117,7 @@ void geolocationError_jacksonRoundTrip() { GeolocationError.class); assertEquals(error.code(), result.code()); - assertEquals(error.message(), result.message()); + assertEquals(error.debugInfo(), result.debugInfo()); } @Test @@ -135,30 +136,13 @@ void geolocationError_errorCode_returnsUnknownForUnrecognisedCode() { new GeolocationError(99, "future code").errorCode()); } - // --- UI.getGeolocation() tests --- - - @Test - void getGeolocation_returnsSameInstanceOnRepeatedCalls() { - assertSame(ui.getGeolocation(), ui.getGeolocation()); - } - - @Test - void newGeolocation_throwsWhenAlreadyCreated() { - // UI.getGeolocation() is populated by the UI constructor, so a - // second direct construction must be rejected. - assertThrows(IllegalStateException.class, () -> new Geolocation(ui)); - } - // --- getPosition() tests --- @Test void getPosition_executesPromiseJs() { - TestComponent component = new TestComponent(); - ui.add(component); - - ui.getGeolocation().getPosition(pos -> { + Geolocation.getPosition(pos -> { }, err -> { - }); + }, ui); List invocations = ui .dumpPendingJsInvocations(); @@ -167,48 +151,56 @@ void getPosition_executesPromiseJs() { } @Test - void getPosition_onSuccessReceivesPositionAndOnErrorIsSilent() { - TestComponent component = new TestComponent(); - ui.add(component); - + void getPosition_callbackReceivesPosition() { List positions = new ArrayList<>(); List errors = new ArrayList<>(); - ui.getGeolocation().getPosition(positions::add, errors::add); + Geolocation.getPosition(positions::add, errors::add, ui); resolvePromise(ui, resultJson(position(60.1699, 24.9384, 10.0), null, "GRANTED")); assertEquals(1, positions.size()); + assertTrue(errors.isEmpty()); assertEquals(60.1699, positions.get(0).coords().latitude()); - assertTrue(errors.isEmpty(), - "onError must not fire for a successful reading"); } @Test - void getPosition_onErrorReceivesErrorAndOnSuccessIsSilent() { - TestComponent component = new TestComponent(); - ui.add(component); - + void getPosition_callbackReceivesError() { List positions = new ArrayList<>(); List errors = new ArrayList<>(); - ui.getGeolocation().getPosition(positions::add, errors::add); + Geolocation.getPosition(positions::add, errors::add, ui); resolvePromise(ui, resultJson(null, error(1, "denied"), "DENIED")); assertEquals(1, errors.size()); + assertTrue(positions.isEmpty()); assertEquals(1, errors.get(0).code()); - assertTrue(positions.isEmpty(), - "onSuccess must not fire when the browser reports an error"); } @Test - void getPosition_updatesAvailabilityFromResponse() { - TestComponent component = new TestComponent(); - ui.add(component); + void getPosition_callbackException_routesToErrorHandler() { + List caught = new ArrayList<>(); + ErrorHandler handler = event -> caught.add(event.getThrowable()); + Mockito.when(ui.getSession().getErrorHandler()).thenReturn(handler); - ui.getGeolocation().getPosition(pos -> { + Geolocation.getPosition(pos -> { + throw new RuntimeException("boom"); }, err -> { - }); + }, ui); + + resolvePromise(ui, + resultJson(position(60.0, 25.0, 10.0), null, "GRANTED")); + + assertEquals(1, caught.size()); + assertEquals("boom", caught.get(0).getMessage()); + } + + @Test + void getPosition_updatesAvailabilityFromResponse() { + Geolocation.getPosition(pos -> { + }, err -> { + }, ui); + resolvePromise(ui, resultJson(position(60.0, 25.0, 10.0), null, "GRANTED")); @@ -223,13 +215,12 @@ void watchPosition_registersListenersAndExecutesWatchJs() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); assertNotNull(watcher); - assertNotNull(watcher.valueSignal()); + assertNotNull(watcher.positionSignal()); assertInstanceOf(GeolocationPending.class, - watcher.valueSignal().peek()); + watcher.positionSignal().peek()); List invocations = ui .dumpPendingJsInvocations(); @@ -238,32 +229,22 @@ void watchPosition_registersListenersAndExecutesWatchJs() { } @Test - void watchPosition_signalSurfacesUnknownErrorWhenWatchExecuteJsFails() { + void watchPosition_unattachedOwner_activatesOnFirstAttach() { TestComponent component = new TestComponent(); - ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); - PendingJavaScriptInvocation watchInvocation = ui - .dumpPendingJsInvocations().stream() - .filter(inv -> inv.getInvocation().getExpression() - .contains("geolocation.watch")) - .reduce((a, b) -> b).orElseThrow(); - watchInvocation.completeExceptionally( - JacksonUtils.createNode("module not loaded")); - - GeolocationResult value = watcher.valueSignal().peek(); - GeolocationError err = assertInstanceOf(GeolocationError.class, value, - "watch executeJs failure must surface as a GeolocationError" - + " on the watcher's valueSignal"); - assertEquals(GeolocationErrorCode.UNKNOWN, err.errorCode()); - assertFalse(err.message().contains("module not loaded"), - "synthesized message must not leak the wrapped client text;" - + " the cause is logged at DEBUG instead"); + assertNull(watcher.handle(), + "watch must not start before owner is attached"); + assertFalse(watcher.activeSignal().peek(), + "watcher must not be active before owner is attached"); + + ui.add(component); + + assertNotNull(watcher.handle(), + "watch should start when owner attaches"); assertTrue(watcher.activeSignal().peek(), - "activeSignal stays true on infra failure — its contract" - + " is tied to resume()/stop(), not data flow"); + "watcher should be active after owner attaches"); } @Test @@ -271,38 +252,16 @@ void watchPosition_signalUpdatesOnPositionEvent() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); - - ObjectNode eventData = JacksonUtils.createObjectNode(); - ObjectNode detail = JacksonUtils.createObjectNode(); - ObjectNode coords = JacksonUtils.createObjectNode(); - coords.put("latitude", 60.1699); - coords.put("longitude", 24.9384); - coords.put("accuracy", 10.0); - coords.put("altitude", 25.5); - coords.put("altitudeAccuracy", 5.0); - coords.put("heading", 90.0); - coords.put("speed", 1.5); - detail.set("coords", coords); - detail.put("timestamp", 1700000000000L); - eventData.set("event.detail", detail); + GeolocationWatcher watcher = Geolocation.watchPosition(component); - fireEvent(component.getElement(), "vaadin-geolocation-position", - eventData); + firePosition(component, 60.1699, 24.9384); assertInstanceOf(GeolocationPosition.class, - watcher.valueSignal().peek()); - GeolocationPosition pos = (GeolocationPosition) watcher.valueSignal() + watcher.positionSignal().peek()); + GeolocationPosition pos = (GeolocationPosition) watcher.positionSignal() .peek(); assertEquals(60.1699, pos.coords().latitude()); assertEquals(24.9384, pos.coords().longitude()); - assertEquals(10.0, pos.coords().accuracy()); - assertEquals(25.5, pos.coords().altitude()); - assertEquals(5.0, pos.coords().altitudeAccuracy()); - assertEquals(90.0, pos.coords().heading()); - assertEquals(1.5, pos.coords().speed()); - assertEquals(1700000000000L, pos.timestamp()); } @Test @@ -310,57 +269,18 @@ void watchPosition_signalUpdatesOnErrorEvent() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); - ObjectNode eventData = JacksonUtils.createObjectNode(); - ObjectNode detail = JacksonUtils.createObjectNode(); - detail.put("code", GeolocationErrorCode.PERMISSION_DENIED.code()); - detail.put("message", "User denied geolocation"); - eventData.set("event.detail", detail); - - fireEvent(component.getElement(), "vaadin-geolocation-error", - eventData); + fireError(component, GeolocationErrorCode.PERMISSION_DENIED.code(), + "User denied geolocation"); - assertInstanceOf(GeolocationError.class, watcher.valueSignal().peek()); - GeolocationError error = (GeolocationError) watcher.valueSignal() + assertInstanceOf(GeolocationError.class, + watcher.positionSignal().peek()); + GeolocationError error = (GeolocationError) watcher.positionSignal() .peek(); assertEquals(GeolocationErrorCode.PERMISSION_DENIED.code(), error.code()); - assertEquals("User denied geolocation", error.message()); - } - - @Test - void watchPosition_stateTransitionsFromErrorToPosition() { - TestComponent component = new TestComponent(); - ui.add(component); - - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); - - ObjectNode errEventData = JacksonUtils.createObjectNode(); - ObjectNode errDetail = JacksonUtils.createObjectNode(); - errDetail.put("code", GeolocationErrorCode.TIMEOUT.code()); - errDetail.put("message", "Timeout"); - errEventData.set("event.detail", errDetail); - fireEvent(component.getElement(), "vaadin-geolocation-error", - errEventData); - assertInstanceOf(GeolocationError.class, watcher.valueSignal().peek()); - - ObjectNode posEventData = JacksonUtils.createObjectNode(); - ObjectNode posDetail = JacksonUtils.createObjectNode(); - ObjectNode coords = JacksonUtils.createObjectNode(); - coords.put("latitude", 60.1699); - coords.put("longitude", 24.9384); - coords.put("accuracy", 10.0); - posDetail.set("coords", coords); - posDetail.put("timestamp", 1700000000000L); - posEventData.set("event.detail", posDetail); - fireEvent(component.getElement(), "vaadin-geolocation-position", - posEventData); - - assertInstanceOf(GeolocationPosition.class, - watcher.valueSignal().peek()); + assertEquals("User denied geolocation", error.debugInfo()); } @Test @@ -368,22 +288,18 @@ void watchPosition_autoStopsOnDetach() { TestComponent component = new TestComponent(); ui.add(component); - ui.getGeolocation().watchPosition(component); + Geolocation.watchPosition(component); ElementListenerMap listenerMap = component.getElement().getNode() .getFeature(ElementListenerMap.class); assertFalse(listenerMap.getExpressions("vaadin-geolocation-position") .isEmpty()); - assertFalse(listenerMap.getExpressions("vaadin-geolocation-error") - .isEmpty()); ui.dumpPendingJsInvocations(); ui.remove(component); assertTrue(listenerMap.getExpressions("vaadin-geolocation-position") .isEmpty()); - assertTrue(listenerMap.getExpressions("vaadin-geolocation-error") - .isEmpty()); List invocations = ui .dumpPendingJsInvocations(); @@ -396,8 +312,7 @@ void stop_removesListenersAndQueuesClearWatch() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); ElementListenerMap listenerMap = component.getElement().getNode() .getFeature(ElementListenerMap.class); @@ -409,8 +324,6 @@ void stop_removesListenersAndQueuesClearWatch() { assertTrue(listenerMap.getExpressions("vaadin-geolocation-position") .isEmpty()); - assertTrue(listenerMap.getExpressions("vaadin-geolocation-error") - .isEmpty()); List invocations = ui .dumpPendingJsInvocations(); @@ -423,8 +336,7 @@ void stop_isIdempotent() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); ui.dumpPendingJsInvocations(); watcher.stop(); @@ -437,74 +349,35 @@ void stop_isIdempotent() { .getExpression().contains("geolocation.clearWatch"))); } - @Test - void stop_afterDetach_isNoOp() { - TestComponent component = new TestComponent(); - ui.add(component); - - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); - ui.remove(component); - ui.dumpPendingJsInvocations(); - - assertDoesNotThrow(watcher::stop); - List invocations = ui - .dumpPendingJsInvocations(); - assertTrue(invocations.stream().noneMatch(inv -> inv.getInvocation() - .getExpression().contains("geolocation.clearWatch"))); - } - @Test void resume_restartsAfterStop() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); - ElementListenerMap listenerMap = component.getElement().getNode() - .getFeature(ElementListenerMap.class); - + GeolocationWatcher watcher = Geolocation.watchPosition(component); watcher.stop(); - assertTrue(listenerMap.getExpressions("vaadin-geolocation-position") - .isEmpty()); - ui.dumpPendingJsInvocations(); + watcher.resume(); + ElementListenerMap listenerMap = component.getElement().getNode() + .getFeature(ElementListenerMap.class); assertFalse(listenerMap.getExpressions("vaadin-geolocation-position") .isEmpty()); assertInstanceOf(GeolocationPending.class, - watcher.valueSignal().peek()); + watcher.positionSignal().peek()); List invocations = ui .dumpPendingJsInvocations(); assertTrue(invocations.stream().anyMatch(inv -> inv.getInvocation() .getExpression().contains("geolocation.watch"))); } - @Test - void resume_isNoOpWhenActive() { - TestComponent component = new TestComponent(); - ui.add(component); - - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); - ui.dumpPendingJsInvocations(); - - watcher.resume(); - - List invocations = ui - .dumpPendingJsInvocations(); - assertTrue(invocations.stream().noneMatch(inv -> inv.getInvocation() - .getExpression().contains("geolocation.watch"))); - } - @Test void active_signalReflectsResumeAndStop() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); assertTrue(watcher.activeSignal().peek()); watcher.stop(); @@ -517,149 +390,170 @@ void active_signalReflectsResumeAndStop() { // --- addPositionListener() tests --- @Test - void addPositionListener_dispatchesPositionAndError() { + void addPositionListener_firesSuccessOnPositionEvent() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); List positions = new ArrayList<>(); List errors = new ArrayList<>(); watcher.addPositionListener(positions::add, errors::add); - ObjectNode posData = JacksonUtils.createObjectNode(); - ObjectNode posDetail = JacksonUtils.createObjectNode(); - ObjectNode coords = JacksonUtils.createObjectNode(); - coords.put("latitude", 60.1699); - coords.put("longitude", 24.9384); - coords.put("accuracy", 10.0); - posDetail.set("coords", coords); - posDetail.put("timestamp", 1700000000000L); - posData.set("event.detail", posDetail); - fireEvent(component.getElement(), "vaadin-geolocation-position", - posData); - - ObjectNode errData = JacksonUtils.createObjectNode(); - ObjectNode errDetail = JacksonUtils.createObjectNode(); - errDetail.put("code", GeolocationErrorCode.TIMEOUT.code()); - errDetail.put("message", "Timeout"); - errData.set("event.detail", errDetail); - fireEvent(component.getElement(), "vaadin-geolocation-error", errData); + firePosition(component, 60.1699, 24.9384); assertEquals(1, positions.size()); assertEquals(60.1699, positions.get(0).coords().latitude()); + assertTrue(errors.isEmpty()); + } + + @Test + void addPositionListener_firesErrorOnErrorEvent() { + TestComponent component = new TestComponent(); + ui.add(component); + + GeolocationWatcher watcher = Geolocation.watchPosition(component); + List positions = new ArrayList<>(); + List errors = new ArrayList<>(); + watcher.addPositionListener(positions::add, errors::add); + + fireError(component, GeolocationErrorCode.TIMEOUT.code(), "Timeout"); + + assertTrue(positions.isEmpty()); assertEquals(1, errors.size()); - assertEquals(GeolocationErrorCode.TIMEOUT, errors.get(0).errorCode()); + assertEquals(GeolocationErrorCode.TIMEOUT.code(), errors.get(0).code()); + } + + @Test + void addPositionListener_listenerException_routesToErrorHandlerAndContinues() { + TestComponent component = new TestComponent(); + ui.add(component); + List caught = new ArrayList<>(); + ErrorHandler handler = event -> caught.add(event.getThrowable()); + Mockito.when(ui.getSession().getErrorHandler()).thenReturn(handler); + + GeolocationWatcher watcher = Geolocation.watchPosition(component); + List later = new ArrayList<>(); + watcher.addPositionListener(pos -> { + throw new RuntimeException("boom"); + }, err -> { + }); + watcher.addPositionListener(later::add, err -> { + }); + + firePosition(component, 60.1699, 24.9384); + + assertEquals(1, caught.size()); + assertEquals("boom", caught.get(0).getMessage()); + assertEquals(1, later.size(), + "later listener must still receive the reading after an earlier listener throws"); } @Test - void addPositionListener_registrationStopsDelivery() { + void addPositionListener_doesNotFireOnPendingState() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); List positions = new ArrayList<>(); - Registration registration = watcher.addPositionListener(positions::add, - err -> { - }); + List errors = new ArrayList<>(); + watcher.addPositionListener(positions::add, errors::add); - registration.remove(); + // resume() resets to Pending; listeners must not fire. + watcher.stop(); + watcher.resume(); - ObjectNode posData = JacksonUtils.createObjectNode(); - ObjectNode posDetail = JacksonUtils.createObjectNode(); - ObjectNode coords = JacksonUtils.createObjectNode(); - coords.put("latitude", 60.0); - coords.put("longitude", 25.0); - coords.put("accuracy", 10.0); - posDetail.set("coords", coords); - posDetail.put("timestamp", 1700000000000L); - posData.set("event.detail", posDetail); - fireEvent(component.getElement(), "vaadin-geolocation-position", - posData); + assertTrue(positions.isEmpty()); + assertTrue(errors.isEmpty()); + } + + @Test + void addPositionListener_registrationRemoveStopsBothConsumers() { + TestComponent component = new TestComponent(); + ui.add(component); + + GeolocationWatcher watcher = Geolocation.watchPosition(component); + List positions = new ArrayList<>(); + List errors = new ArrayList<>(); + Registration reg = watcher.addPositionListener(positions::add, + errors::add); + + reg.remove(); - assertTrue(positions.isEmpty(), - "removed listener must not receive subsequent updates"); + firePosition(component, 60.0, 25.0); + fireError(component, 1, "denied"); + + assertTrue(positions.isEmpty()); + assertTrue(errors.isEmpty()); } @Test - void addPositionListener_survivesStopResume() { + void addPositionListener_survivesStopAndResume() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); List positions = new ArrayList<>(); watcher.addPositionListener(positions::add, err -> { }); watcher.stop(); watcher.resume(); - - ObjectNode posData = JacksonUtils.createObjectNode(); - ObjectNode posDetail = JacksonUtils.createObjectNode(); - ObjectNode coords = JacksonUtils.createObjectNode(); - coords.put("latitude", 60.0); - coords.put("longitude", 25.0); - coords.put("accuracy", 10.0); - posDetail.set("coords", coords); - posDetail.put("timestamp", 1700000000000L); - posData.set("event.detail", posDetail); - fireEvent(component.getElement(), "vaadin-geolocation-position", - posData); + firePosition(component, 60.0, 25.0); assertEquals(1, positions.size()); - assertEquals(60.0, positions.get(0).coords().latitude()); } @Test - void addPositionListener_pendingIsSilent() { + void addPositionListener_signalAndListenersBothFire() { TestComponent component = new TestComponent(); ui.add(component); - GeolocationWatcher watcher = ui.getGeolocation() - .watchPosition(component); + GeolocationWatcher watcher = Geolocation.watchPosition(component); List positions = new ArrayList<>(); - List errors = new ArrayList<>(); - watcher.addPositionListener(positions::add, errors::add); + watcher.addPositionListener(positions::add, err -> { + }); - // resume() resets the signal to Pending — listeners must stay silent - // since they only see real outcomes. - watcher.stop(); - watcher.resume(); + firePosition(component, 60.0, 25.0); - assertTrue(positions.isEmpty()); - assertTrue(errors.isEmpty()); + assertEquals(1, positions.size()); + assertInstanceOf(GeolocationPosition.class, + watcher.positionSignal().peek()); } - // --- availability() / availability-change listener tests --- + // --- availabilityHintSignal() tests --- @Test void availability_unknownBeforeAnyReport() { assertEquals(GeolocationAvailability.UNKNOWN, - ui.getGeolocation().availabilityHintSignal().peek()); + Geolocation.availabilityHintSignal(ui).peek()); } @Test void availability_reflectsUIInternalsSignal() { + // Resolve the signal first so the client is installed. + Geolocation.availabilityHintSignal(ui); ui.getInternals() .setGeolocationAvailability(GeolocationAvailability.GRANTED); assertEquals(GeolocationAvailability.GRANTED, - ui.getGeolocation().availabilityHintSignal().peek()); + Geolocation.availabilityHintSignal(ui).peek()); } @Test - void availabilityChangeListener_isRegisteredFromConstructor() { - ElementListenerMap listenerMap = ui.getElement().getNode() - .getFeature(ElementListenerMap.class); - assertFalse(listenerMap - .getExpressions("vaadin-geolocation-availability-change") - .isEmpty()); + void availabilityHintSignal_installsClientLazily() { + assertNull(ui.getInternals().getGeolocationClient()); + + Geolocation.availabilityHintSignal(ui); + + assertNotNull(ui.getInternals().getGeolocationClient()); } @Test void availabilityChangeListener_updatesCachedValue() { + // Trigger lazy installation so the availability-change listener is + // registered on the UI element. + Geolocation.availabilityHintSignal(ui); + ObjectNode eventData = JacksonUtils.createObjectNode(); ObjectNode detail = JacksonUtils.createObjectNode(); detail.put("availability", "DENIED"); @@ -674,6 +568,7 @@ void availabilityChangeListener_updatesCachedValue() { @Test void availabilityChangeListener_ignoresUnknownValue() { + Geolocation.availabilityHintSignal(ui); ui.getInternals() .setGeolocationAvailability(GeolocationAvailability.GRANTED); @@ -748,6 +643,32 @@ private static void fireEvent(Element element, String eventType, listenerMap.fireEvent(new DomEvent(element, eventType, eventData)); } + private static void firePosition(Component component, double lat, + double lon) { + ObjectNode eventData = JacksonUtils.createObjectNode(); + ObjectNode detail = JacksonUtils.createObjectNode(); + ObjectNode coords = JacksonUtils.createObjectNode(); + coords.put("latitude", lat); + coords.put("longitude", lon); + coords.put("accuracy", 10.0); + detail.set("coords", coords); + detail.put("timestamp", 1700000000000L); + eventData.set("event.detail", detail); + fireEvent(component.getElement(), "vaadin-geolocation-position", + eventData); + } + + private static void fireError(Component component, int code, + String message) { + ObjectNode eventData = JacksonUtils.createObjectNode(); + ObjectNode detail = JacksonUtils.createObjectNode(); + detail.put("code", code); + detail.put("message", message); + eventData.set("event.detail", detail); + fireEvent(component.getElement(), "vaadin-geolocation-error", + eventData); + } + /** * Resolves the most recent pending geolocation.get() executeJs call by * delivering {@code resultJson} to its {@code then(Class, ...)} callback, diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/GeolocationView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/GeolocationView.java index a0e394093c1..5261c9bbb28 100644 --- a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/GeolocationView.java +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/GeolocationView.java @@ -16,6 +16,7 @@ package com.vaadin.flow.uitest.ui; import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.geolocation.Geolocation; import com.vaadin.flow.component.geolocation.GeolocationOptions; import com.vaadin.flow.component.geolocation.GeolocationPosition; import com.vaadin.flow.component.geolocation.GeolocationWatcher; @@ -27,8 +28,10 @@ @Route(value = "com.vaadin.flow.uitest.ui.GeolocationView", layout = ViewTestLayout.class) public class GeolocationView extends AbstractDivView { - private GeolocationWatcher watcher; - private int trackUpdateCount; + private GeolocationWatcher signalWatcher; + private GeolocationWatcher listenerWatcher; + private int signalUpdateCount; + private int listenerUpdateCount; @Override protected void onShow() { @@ -105,48 +108,47 @@ protected void onShow() { """); NativeButton getButton = createButton("Get Position", "getButton", - e -> e.getUI().getGeolocation().getPosition(pos -> { + e -> Geolocation.getPosition(pos -> { Div out = new Div(); out.setId("getResult"); out.setText("lat=" + pos.coords().latitude() + ", lon=" + pos.coords().longitude()); add(out); - }, error -> { + }, err -> { Div out = new Div(); out.setId("getResult"); - out.setText( - "error=" + error.code() + ":" + error.message()); + out.setText("error=" + err.code() + ":" + err.debugInfo()); add(out); })); // Uses the mock's "maximumAge == 9999 → error" trigger to exercise // the error branch. NativeButton getErrorButton = createButton("Get Position (error)", - "getErrorButton", - e -> e.getUI().getGeolocation().getPosition(pos -> { + "getErrorButton", e -> Geolocation.getPosition(pos -> { Div out = new Div(); out.setId("getErrorResult"); out.setText("unexpected position: " + pos.coords()); add(out); - }, error -> { + }, err -> { Div out = new Div(); out.setId("getErrorResult"); - out.setText("error=" + error.errorCode() + ":" - + error.message()); + out.setText( + "error=" + err.errorCode() + ":" + err.debugInfo()); add(out); }, new GeolocationOptions(null, null, 9999))); - NativeButton trackButton = createButton("Track Position", "trackButton", - e -> { - watcher = e.getUI().getGeolocation().watchPosition(this); - trackUpdateCount = 0; + NativeButton trackButton = createButton("Track Position (signal)", + "trackButton", e -> { + signalWatcher = Geolocation.watchPosition(this); + signalUpdateCount = 0; getElement().addEventListener("vaadin-geolocation-position", ev -> { - var value = watcher.valueSignal().peek(); - if (value instanceof GeolocationPosition pos) { - trackUpdateCount++; + if (signalWatcher.positionSignal() + .peek() instanceof GeolocationPosition pos) { + signalUpdateCount++; Div out = new Div(); - out.setId("trackResult" + trackUpdateCount); + out.setId( + "trackResult" + signalUpdateCount); out.setText("lat=" + pos.coords().latitude() + ", lon=" + pos.coords().longitude()); @@ -157,16 +159,49 @@ protected void onShow() { NativeButton stopButton = createButton("Stop tracking", "stopButton", e -> { - if (watcher != null) { - watcher.stop(); + if (signalWatcher != null) { + signalWatcher.stop(); Div out = new Div(); out.setId("stopResult"); - out.setText("stopped after " + trackUpdateCount + out.setText("stopped after " + signalUpdateCount + + " updates"); + add(out); + } + }); + + NativeButton listenerButton = createButton("Track Position (listener)", + "listenerButton", e -> { + listenerWatcher = Geolocation.watchPosition(this); + listenerUpdateCount = 0; + listenerWatcher.addPositionListener(pos -> { + listenerUpdateCount++; + Div out = new Div(); + out.setId("listenerResult" + listenerUpdateCount); + out.setText("lat=" + pos.coords().latitude() + ", lon=" + + pos.coords().longitude()); + add(out); + }, err -> { + Div out = new Div(); + out.setId("listenerError"); + out.setText("error=" + err.errorCode() + ":" + + err.debugInfo()); + add(out); + }); + }); + + NativeButton stopListenerButton = createButton( + "Stop tracking (listener)", "stopListenerButton", e -> { + if (listenerWatcher != null) { + listenerWatcher.stop(); + Div out = new Div(); + out.setId("stopListenerResult"); + out.setText("stopped after " + listenerUpdateCount + " updates"); add(out); } }); - add(getButton, getErrorButton, trackButton, stopButton); + add(getButton, getErrorButton, trackButton, stopButton, listenerButton, + stopListenerButton); } } diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/GeolocationIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/GeolocationIT.java index 9f78a9f521c..727243c5e49 100644 --- a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/GeolocationIT.java +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/GeolocationIT.java @@ -46,6 +46,29 @@ public void get_returnsError() { result.getText().startsWith("error=PERMISSION_DENIED:")); } + @Test + public void listener_receivesPositionUpdatesUntilStopped() { + open(); + findElement(By.id("listenerButton")).click(); + + waitUntil(d -> findElement(By.id("listenerResult2"))); + Assert.assertEquals("lat=60.1699, lon=24.9384", + findElement(By.id("listenerResult1")).getText()); + + findElement(By.id("stopListenerButton")).click(); + waitUntil(d -> findElement(By.id("stopListenerResult"))); + sleep(100); + int countAtStop = findElements(By.cssSelector("[id^='listenerResult']")) + .size(); + + sleep(1000); + int countAfterWait = findElements( + By.cssSelector("[id^='listenerResult']")).size(); + Assert.assertEquals( + "No new listenerResult divs should appear after stop()", + countAtStop, countAfterWait); + } + @Test public void track_receivesPositionUpdatesUntilStopped() { open();