Skip to content

Commit 498c4c4

Browse files
Artur-mcollovati
andauthored
feat: add Geolocation API (#23527)
## Summary - Adds a per-UI `Geolocation` facade (`UI.getGeolocation()`) that wraps the browser's Geolocation API with a one-shot `get(...)` request, a reactive `track(Component)` session, and an `availabilitySignal()` reporting capability/permission state. - Results use sealed types designed for exhaustive pattern matching: `GeolocationOutcome` (`GeolocationPosition` | `GeolocationError`) for one-shot reads, and `GeolocationResult` (adds `GeolocationPending`) for the tracker's signal before the first fix arrives. - Availability is delivered synchronously — seeded in the bootstrap handshake and kept in sync via a `vaadin-geolocation-availability-change` DOM event — so application code can read it (or bind to it) without an extra round-trip. ## Details `Geolocation.get(callback)` and `Geolocation.get(options, callback)` enqueue a single position request; the callback receives a `GeolocationOutcome` on the UI thread. `Geolocation.track(Component)` / `track(Component, GeolocationOptions)` returns a `GeolocationTracker`: - `valueSignal()` — `Signal<GeolocationResult>` carrying `GeolocationPending` until the first fix, then successive `GeolocationPosition` / `GeolocationError` values. - `activeSignal()` — `Signal<Boolean>` for binding a start/stop button without tracking a separate flag. - `stop()` / `resume()` — cancel or resume the browser watch; the tracker handle is reusable and bound effects keep working. - The watch is cancelled automatically when the owning component detaches. Javadoc documents the cross-browser caveat that a watch goes silent if the user revokes and re-grants permission, and recommends `stop()` + `resume()` (or driving it off `availabilitySignal()`) as recovery. `GeolocationOptions` is a record with a serializable builder, `Duration` overloads, and validation that rejects negative `timeout` / `maximumAge`. `GeolocationError.errorCode()` returns a `GeolocationErrorCode` enum (`PERMISSION_DENIED`, `POSITION_UNAVAILABLE`, `TIMEOUT`, `UNKNOWN`) so switches are exhaustive without a `null` arm. `availabilitySignal()` yields a `GeolocationAvailability` enum (`GRANTED` / `DENIED` / `PROMPT` / `UNKNOWN` / `UNSUPPORTED`). Javadoc spells out the reliability caveats (Safari always reports `UNKNOWN`, Firefox does not always propagate settings changes, a small propagation delay on Chromium) and recommends `get(...)` in the callback for critical paths. The client bridge lives in `flow-client/src/main/frontend/Geolocation.ts`, loaded as a side-effect import from `Flow.ts` (the entry point Vite actually bundles). `collectBrowserDetails()` awaits `queryAvailability()` so the initial value is present in the first server request. k The package is `@NullMarked` (JSpecify) with `@Nullable` on the genuinely optional spots (`options`, boxed coordinate fields, internal wire records). Read-only signal wrappers are cached so callers observe stable identities. Accompanied by unit tests in `GeolocationTest` and an integration test (`GeolocationIT` + `GeolocationView`) covering granted/denied/error paths and the "no updates after stop()" invariant. --------- Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent f54f0e3 commit 498c4c4

20 files changed

Lines changed: 2460 additions & 4 deletions

File tree

flow-client/src/main/frontend/Flow.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ConnectionStateChangeListener,
55
type ConnectionStateStore
66
} from '@vaadin/common-frontend';
7+
import './Geolocation';
78

89
export interface FlowConfig {
910
imports?: () => Promise<any>;
@@ -420,13 +421,13 @@ export class Flow {
420421
return Promise.resolve(initial);
421422
}
422423

424+
const browserDetails = await this.collectBrowserDetails();
425+
423426
// send a request to the `JavaScriptBootstrapHandler`
424427
return new Promise((resolve, reject) => {
425428
const xhr = new XMLHttpRequest();
426429
const httpRequest = xhr as any;
427430

428-
// Collect browser details to send with init request as JSON
429-
const browserDetails = this.collectBrowserDetails();
430431
const browserDetailsParam = browserDetails
431432
? `&v-browserDetails=${encodeURIComponent(JSON.stringify(browserDetails))}`
432433
: '';
@@ -459,7 +460,7 @@ export class Flow {
459460
}
460461

461462
// Collects browser details parameters
462-
private collectBrowserDetails(): Record<string, string> {
463+
private async collectBrowserDetails(): Promise<Record<string, string>> {
463464
const params: Record<string, any> = {};
464465

465466
/* Screen height and width */
@@ -550,6 +551,14 @@ export class Flow {
550551
}
551552
params['v-tn'] = themeName;
552553

554+
/* Geolocation availability — guarded because tests may reset
555+
window.Vaadin between runs, removing the namespace that
556+
Geolocation.ts installs at import time. */
557+
const geolocation = ($wnd.Vaadin.Flow as any)?.geolocation;
558+
if (geolocation) {
559+
params['v-ga'] = await geolocation.queryAvailability();
560+
}
561+
553562
/* Stringify each value (they are parsed on the server side) */
554563
const stringParams: Record<string, string> = {};
555564
Object.keys(params).forEach((key) => {
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
type VaadinGeolocationAvailability = 'GRANTED' | 'DENIED' | 'PROMPT' | 'UNKNOWN' | 'UNSUPPORTED';
18+
19+
/**
20+
* Coordinates data sent to the server, matching the Java GeolocationCoordinates record.
21+
*/
22+
interface VaadinGeolocationCoordinates {
23+
latitude: number;
24+
longitude: number;
25+
accuracy: number;
26+
altitude: number | null;
27+
altitudeAccuracy: number | null;
28+
heading: number | null;
29+
speed: number | null;
30+
}
31+
32+
/**
33+
* Position data sent to the server, matching the Java GeolocationPosition record.
34+
*/
35+
interface VaadinGeolocationPosition {
36+
coords: VaadinGeolocationCoordinates;
37+
timestamp: number;
38+
}
39+
40+
/**
41+
* Error data sent to the server, matching the Java GeolocationError record.
42+
*/
43+
interface VaadinGeolocationError {
44+
code: number;
45+
message: string;
46+
}
47+
48+
/**
49+
* Result of a one-shot geolocation request. Contains either a position or an error,
50+
* plus the availability state updated by this response.
51+
*/
52+
interface VaadinGeolocationGetResult {
53+
position?: VaadinGeolocationPosition;
54+
error?: VaadinGeolocationError;
55+
availability: VaadinGeolocationAvailability;
56+
}
57+
58+
/**
59+
* Options for geolocation requests, matching the standard PositionOptions.
60+
*/
61+
interface VaadinGeolocationOptions {
62+
enableHighAccuracy?: boolean;
63+
timeout?: number;
64+
maximumAge?: number;
65+
}
66+
67+
function copyCoords(c: GeolocationCoordinates): VaadinGeolocationCoordinates {
68+
return {
69+
latitude: c.latitude,
70+
longitude: c.longitude,
71+
accuracy: c.accuracy,
72+
altitude: c.altitude,
73+
altitudeAccuracy: c.altitudeAccuracy,
74+
heading: c.heading,
75+
speed: c.speed
76+
};
77+
}
78+
79+
const watches = new Map<string, number>();
80+
81+
// The cached availability for the current page. Populated on first
82+
// queryAvailability() call, refreshed from each get()/watch() outcome, and
83+
// kept current by a permissionchange listener (where supported).
84+
let cachedAvailability: VaadinGeolocationAvailability | null = null;
85+
let permissionChangeListenerInstalled = false;
86+
87+
function publishAvailability(next: VaadinGeolocationAvailability): void {
88+
if (cachedAvailability === next) {
89+
return;
90+
}
91+
cachedAvailability = next;
92+
// Dispatch on document.body so the server-side Geolocation facade (listening
93+
// on the UI element, which is body) can update its cached value.
94+
document.body.dispatchEvent(
95+
new CustomEvent('vaadin-geolocation-availability-change', {
96+
detail: { availability: next }
97+
})
98+
);
99+
}
100+
101+
// Applies a single get()/watch() outcome to the cached availability and
102+
// returns the value to report in the response. Never overwrites
103+
// UNSUPPORTED, which is session-stable. TIMEOUT and POSITION_UNAVAILABLE
104+
// don't reveal the permission state, so the previous cached value is
105+
// returned unchanged.
106+
function getAndCacheAvailabilityFromResult(
107+
position: VaadinGeolocationPosition | undefined,
108+
error: VaadinGeolocationError | undefined
109+
): VaadinGeolocationAvailability {
110+
if (cachedAvailability !== 'UNSUPPORTED') {
111+
if (position) {
112+
publishAvailability('GRANTED');
113+
} else if (error?.code === 1) {
114+
publishAvailability('DENIED');
115+
}
116+
}
117+
return cachedAvailability ?? 'UNKNOWN';
118+
}
119+
120+
async function resolveAvailability(): Promise<VaadinGeolocationAvailability> {
121+
if (!window.isSecureContext) {
122+
return 'UNSUPPORTED';
123+
}
124+
// Chromium exposes document.featurePolicy; Firefox and Safari do not
125+
// expose any feature-policy introspection API, so the check is only
126+
// possible on Chromium. When absent, assume geolocation is allowed.
127+
const doc = document as any;
128+
if (doc.featurePolicy && typeof doc.featurePolicy.allowsFeature === 'function') {
129+
try {
130+
if (!doc.featurePolicy.allowsFeature('geolocation')) {
131+
return 'UNSUPPORTED';
132+
}
133+
} catch (_e) {
134+
// Ignore and assume allowed
135+
}
136+
}
137+
try {
138+
const status = await navigator.permissions.query({ name: 'geolocation' as PermissionName });
139+
if (!permissionChangeListenerInstalled) {
140+
permissionChangeListenerInstalled = true;
141+
status.addEventListener('change', () => {
142+
publishAvailability(stateToAvailability(status.state));
143+
});
144+
}
145+
return stateToAvailability(status.state);
146+
} catch (_e) {
147+
// Safari rejects the 'geolocation' permission name with a TypeError
148+
return 'UNKNOWN';
149+
}
150+
}
151+
152+
function stateToAvailability(state: string): VaadinGeolocationAvailability {
153+
switch (state) {
154+
case 'granted':
155+
return 'GRANTED';
156+
case 'denied':
157+
return 'DENIED';
158+
case 'prompt':
159+
return 'PROMPT';
160+
default:
161+
return 'UNKNOWN';
162+
}
163+
}
164+
165+
const $wnd = window as any;
166+
$wnd.Vaadin ??= {};
167+
$wnd.Vaadin.Flow ??= {};
168+
$wnd.Vaadin.Flow.geolocation = {
169+
get(options?: VaadinGeolocationOptions): Promise<VaadinGeolocationGetResult> {
170+
return new Promise((resolve) => {
171+
navigator.geolocation.getCurrentPosition(
172+
(p) => {
173+
const position = { coords: copyCoords(p.coords), timestamp: p.timestamp };
174+
resolve({ position, availability: getAndCacheAvailabilityFromResult(position, undefined) });
175+
},
176+
(e) => {
177+
const error = { code: e.code, message: e.message };
178+
resolve({ error, availability: getAndCacheAvailabilityFromResult(undefined, error) });
179+
},
180+
options || undefined
181+
);
182+
});
183+
},
184+
185+
watch(element: HTMLElement, options: VaadinGeolocationOptions | undefined, watchKey: string): void {
186+
if (watches.has(watchKey)) {
187+
navigator.geolocation.clearWatch(watches.get(watchKey)!);
188+
}
189+
watches.set(
190+
watchKey,
191+
navigator.geolocation.watchPosition(
192+
(p) => {
193+
const position = { coords: copyCoords(p.coords), timestamp: p.timestamp };
194+
getAndCacheAvailabilityFromResult(position, undefined);
195+
element.dispatchEvent(
196+
new CustomEvent('vaadin-geolocation-position', {
197+
detail: position
198+
})
199+
);
200+
},
201+
(e) => {
202+
const error = { code: e.code, message: e.message };
203+
getAndCacheAvailabilityFromResult(undefined, error);
204+
element.dispatchEvent(
205+
new CustomEvent('vaadin-geolocation-error', {
206+
detail: error
207+
})
208+
);
209+
},
210+
options || undefined
211+
)
212+
);
213+
},
214+
215+
clearWatch(watchKey: string): void {
216+
if (watches.has(watchKey)) {
217+
navigator.geolocation.clearWatch(watches.get(watchKey)!);
218+
watches.delete(watchKey);
219+
}
220+
},
221+
222+
async queryAvailability(): Promise<VaadinGeolocationAvailability> {
223+
const value = await resolveAvailability();
224+
// publish without dispatching a change event — there is no previous
225+
// cached value to compare against when cachedAvailability is null and
226+
// the bootstrap consumer just wants the initial answer.
227+
cachedAvailability = value;
228+
return value;
229+
}
230+
};
231+
232+
// Empty export to ensure TypeScript emits this as an ES module,
233+
// which is required for Vite to load it via import.
234+
export {};

flow-server/src/main/java/com/vaadin/flow/component/UI.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import tools.jackson.databind.node.BaseJsonNode;
3333

3434
import com.vaadin.flow.component.dependency.JsModule;
35+
import com.vaadin.flow.component.geolocation.Geolocation;
3536
import com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer;
3637
import com.vaadin.flow.component.internal.UIInternalUpdater;
3738
import com.vaadin.flow.component.internal.UIInternals;
@@ -134,6 +135,8 @@ public class UI extends Component
134135

135136
private final Page page = new Page(this);
136137

138+
private final Geolocation geolocation;
139+
137140
/*
138141
* Despite section 6 of RFC 4122, this particular use of UUID *is* adequate
139142
* for security capabilities. Type 4 UUIDs contain 122 bits of random data,
@@ -163,6 +166,7 @@ protected UI(UIInternalUpdater internalsHandler) {
163166
getNode().getFeature(ElementData.class).setTag("body");
164167
Component.setElement(this, Element.get(getNode()));
165168
pushConfiguration = new PushConfigurationImpl(this);
169+
geolocation = new Geolocation(this);
166170
}
167171

168172
/**
@@ -914,6 +918,16 @@ public Page getPage() {
914918
return page;
915919
}
916920

921+
/**
922+
* Returns the {@link Geolocation} facade for this UI, used to read the end
923+
* user's physical location from the browser.
924+
*
925+
* @return the Geolocation facade
926+
*/
927+
public Geolocation getGeolocation() {
928+
return geolocation;
929+
}
930+
917931
/**
918932
* Updates this UI to show the view corresponding to the given navigation
919933
* target.

0 commit comments

Comments
 (0)