Files
homepage/src/lib/js/gps.svelte.ts
T
Alexander a056618696 fix(fitness): request ACTIVITY_RECOGNITION for cadence
Android step detector silently returns no events on API 29+
when ACTIVITY_RECOGNITION is ungranted, so cadence was always
absent from recorded tracks. Declare the permission, request
it at GPS start, guard sensor registration and retry it from
MainActivity.onRequestPermissionsResult when the user grants
mid-session, and toast a hint if they deny.
2026-04-21 19:21:25 +02:00

336 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GPS tracking utility for Tauri Android shell.
* Uses native Android LocationForegroundService for GPS collection
* (survives screen-off), with JS polling to pull points into the UI.
* Falls back to a no-op tracker in the browser.
*/
export interface GpsPoint {
lat: number;
lng: number;
altitude?: number;
speed?: number;
cadence?: number; // steps per minute, from step detector sensor
timestamp: number;
}
export interface IntervalStep {
type?: 'step';
label: string;
durationType: 'distance' | 'time';
durationValue: number; // meters (distance) or seconds (time)
}
export interface IntervalGroup {
type: 'group';
repeat: number;
steps: IntervalStep[];
}
export type IntervalEntry = IntervalStep | IntervalGroup;
/** Expand groups (repeat × steps) into a flat step list for the native bridge. */
export function flattenIntervals(entries: IntervalEntry[]): IntervalStep[] {
const out: IntervalStep[] = [];
for (const e of entries) {
if ((e as IntervalGroup).type === 'group') {
const g = e as IntervalGroup;
for (let i = 0; i < g.repeat; i++) {
for (const s of g.steps) {
out.push({ label: s.label, durationType: s.durationType, durationValue: s.durationValue });
}
}
} else {
const s = e as IntervalStep;
out.push({ label: s.label, durationType: s.durationType, durationValue: s.durationValue });
}
}
return out;
}
export interface VoiceGuidanceConfig {
enabled: boolean;
triggerType: 'distance' | 'time';
triggerValue: number;
metrics: string[];
language: string;
voiceId?: string;
ttsVolume?: number; // 0.01.0, relative TTS volume
audioDuck?: boolean; // duck other audio during TTS
intervals?: IntervalStep[];
}
export interface IntervalState {
currentIndex: number;
totalSteps: number;
currentLabel: string;
progress: number; // 0.01.0
complete: boolean;
}
interface AndroidBridge {
startLocationService(ttsConfigJson: string, startPaused: boolean): void;
stopLocationService(): void;
getPoints(): string;
isTracking(): boolean;
getAvailableTtsVoices(): string;
hasTtsEngine(): boolean;
installTtsEngine(): void;
pauseTracking(): void;
resumeTracking(): void;
getIntervalState(): string;
hasActivityRecognitionPermission?: () => boolean;
}
function checkTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
function getAndroidBridge(): AndroidBridge | null {
if (typeof window !== 'undefined' && 'AndroidBridge' in window) {
return (window as any).AndroidBridge;
}
return null;
}
/** Haversine distance in km between two points */
function haversine(a: GpsPoint, b: GpsPoint): number {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h =
sinLat * sinLat +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
/** Compute total distance from a track */
export function trackDistance(track: GpsPoint[]): number {
let total = 0;
for (let i = 1; i < track.length; i++) {
total += haversine(track[i - 1], track[i]);
}
return total;
}
const POLL_INTERVAL_MS = 3000;
export function createGpsTracker() {
let track = $state<GpsPoint[]>([]);
let isTracking = $state(false);
let _pollTimer: ReturnType<typeof setInterval> | null = null;
const distance = $derived(trackDistance(track));
const currentSpeed = $derived(
track.length > 0 ? (track[track.length - 1].speed ?? 0) : 0
);
const currentPace = $derived.by(() => {
if (track.length < 2) return 0;
const a = track[track.length - 2];
const b = track[track.length - 1];
const d = haversine(a, b);
const dt = (b.timestamp - a.timestamp) / 60000;
if (d < 0.001) return 0;
return dt / d;
});
const latestPoint = $derived(
track.length > 0 ? track[track.length - 1] : null
);
let _debugMsg = $state('');
let _intervalState = $state<IntervalState | null>(null);
function pollPoints() {
const bridge = getAndroidBridge();
if (!bridge) return;
try {
const json = bridge.getPoints();
const points: GpsPoint[] = JSON.parse(json);
if (points.length > 0) {
track = [...track, ...points];
const last = points[points.length - 1];
_debugMsg = `pts:${track.length} lat:${last.lat.toFixed(4)} lng:${last.lng.toFixed(4)}`;
}
} catch (e) {
_debugMsg = `poll err: ${(e as Error)?.message ?? e}`;
}
// Poll interval state
try {
const stateJson = bridge.getIntervalState();
if (stateJson && stateJson !== '{}') {
_intervalState = JSON.parse(stateJson);
}
} catch { /* no interval active */ }
}
async function start(voiceGuidance?: VoiceGuidanceConfig, startPaused = false) {
_debugMsg = 'starting...';
if (!checkTauri() || isTracking) {
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
return false;
}
try {
// Still use the Tauri plugin to request permissions (it has the proper Android permission flow)
_debugMsg = 'importing plugin...';
const geo = await import('@tauri-apps/plugin-geolocation');
_debugMsg = 'checking perms...';
let perms = await geo.checkPermissions();
_debugMsg = `perms: ${JSON.stringify(perms)}`;
if (perms.location !== 'granted') {
_debugMsg = 'requesting perms...';
perms = await geo.requestPermissions(['location']);
_debugMsg = `after req: ${JSON.stringify(perms)}`;
}
if (perms.location !== 'granted') {
_debugMsg = `denied: ${JSON.stringify(perms)}`;
return false;
}
track = [];
isTracking = true;
// Start native Android foreground service — it does its own GPS tracking
const bridge = getAndroidBridge();
if (bridge) {
_debugMsg = 'starting native GPS service...';
const ttsConfig = JSON.stringify(voiceGuidance ?? {});
bridge.startLocationService(ttsConfig, startPaused);
// Poll the native side for collected points
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
_debugMsg = 'native GPS service started, polling...';
} else {
_debugMsg = 'no AndroidBridge — native tracking unavailable';
isTracking = false;
return false;
}
return true;
} catch (e) {
_debugMsg = `error: ${(e as Error)?.message ?? e}`;
isTracking = false;
return false;
}
}
async function stop(): Promise<GpsPoint[]> {
if (!isTracking) return [];
// Stop polling
if (_pollTimer !== null) {
clearInterval(_pollTimer);
_pollTimer = null;
}
// Drain any remaining points from native
pollPoints();
// Stop native service
const bridge = getAndroidBridge();
if (bridge) {
bridge.stopLocationService();
}
isTracking = false;
_intervalState = null;
const result = [...track];
return result;
}
function reset() {
track = [];
_intervalState = null;
}
function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> {
const bridge = getAndroidBridge();
if (!bridge) return [];
try {
return JSON.parse(bridge.getAvailableTtsVoices());
} catch {
return [];
}
}
function hasTtsEngine(): boolean {
const bridge = getAndroidBridge();
if (!bridge) return false;
return bridge.hasTtsEngine();
}
function installTtsEngine(): void {
const bridge = getAndroidBridge();
bridge?.installTtsEngine();
}
function pauseTracking(): void {
const bridge = getAndroidBridge();
bridge?.pauseTracking();
}
function resumeTracking(): void {
const bridge = getAndroidBridge();
bridge?.resumeTracking();
}
/** Request location permissions without starting the tracking service.
* Returns true if permissions were granted. */
async function ensurePermissions(): Promise<boolean> {
if (!checkTauri()) return false;
try {
const geo = await import('@tauri-apps/plugin-geolocation');
let perms = await geo.checkPermissions();
if (perms.location !== 'granted') {
perms = await geo.requestPermissions(['location']);
}
return perms.location === 'granted';
} catch {
return false;
}
}
function cadenceAvailable(): boolean {
const bridge = getAndroidBridge();
// No bridge (e.g. browser) or older build lacking the check: assume ok, don't nag.
if (!bridge || typeof bridge.hasActivityRecognitionPermission !== 'function') return true;
try { return bridge.hasActivityRecognitionPermission(); } catch { return true; }
}
return {
get track() { return track; },
get isTracking() { return isTracking; },
get distance() { return distance; },
get currentSpeed() { return currentSpeed; },
get currentPace() { return currentPace; },
get latestPoint() { return latestPoint; },
get available() { return checkTauri(); },
get debug() { return _debugMsg; },
get intervalState() { return _intervalState; },
cadenceAvailable,
start,
stop,
reset,
getAvailableTtsVoices,
hasTtsEngine,
installTtsEngine,
pauseTracking,
resumeTracking,
ensurePermissions
};
}
let _instance: ReturnType<typeof createGpsTracker> | null = null;
export function getGpsTracker() {
if (!_instance) {
_instance = createGpsTracker();
}
return _instance;
}