android: native GPS tracking with foreground service for screen-off support
Move GPS collection from WebView JS (watchPosition) to native Android LocationForegroundService, which survives screen-off. JS polls native side for accumulated points. Also: auto-enable GPS for cardio exercises, filter saved track to workout duration only, fix live map batch updates, notification tap opens active workout, and fix build script for pnpm.
This commit is contained in:
@@ -32,7 +32,7 @@ ensure_keystore() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
echo ":: Building Android APK..."
|
echo ":: Building Android APK..."
|
||||||
npx tauri android build --apk
|
pnpm tauri android build --apk
|
||||||
|
|
||||||
ensure_keystore
|
ensure_keystore
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* GPS tracking utility for Tauri Android shell.
|
* GPS tracking utility for Tauri Android shell.
|
||||||
* Uses @tauri-apps/plugin-geolocation when running inside Tauri,
|
* Uses native Android LocationForegroundService for GPS collection
|
||||||
* falls back to a no-op tracker in the browser.
|
* (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 {
|
export interface GpsPoint {
|
||||||
@@ -12,10 +13,24 @@ export interface GpsPoint {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AndroidBridge {
|
||||||
|
startLocationService(): void;
|
||||||
|
stopLocationService(): void;
|
||||||
|
getPoints(): string;
|
||||||
|
isTracking(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function checkTauri(): boolean {
|
function checkTauri(): boolean {
|
||||||
return typeof window !== 'undefined' && '__TAURI__' in window;
|
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 */
|
/** Haversine distance in km between two points */
|
||||||
function haversine(a: GpsPoint, b: GpsPoint): number {
|
function haversine(a: GpsPoint, b: GpsPoint): number {
|
||||||
@@ -41,24 +56,25 @@ export function trackDistance(track: GpsPoint[]): number {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
export function createGpsTracker() {
|
export function createGpsTracker() {
|
||||||
let track = $state<GpsPoint[]>([]);
|
let track = $state<GpsPoint[]>([]);
|
||||||
let isTracking = $state(false);
|
let isTracking = $state(false);
|
||||||
let _watchId: number | null = null;
|
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const distance = $derived(trackDistance(track));
|
const distance = $derived(trackDistance(track));
|
||||||
const currentSpeed = $derived(
|
const currentSpeed = $derived(
|
||||||
track.length > 0 ? (track[track.length - 1].speed ?? 0) : 0
|
track.length > 0 ? (track[track.length - 1].speed ?? 0) : 0
|
||||||
);
|
);
|
||||||
// Pace from track points: use last two points for instantaneous pace
|
|
||||||
const currentPace = $derived.by(() => {
|
const currentPace = $derived.by(() => {
|
||||||
if (track.length < 2) return 0;
|
if (track.length < 2) return 0;
|
||||||
const a = track[track.length - 2];
|
const a = track[track.length - 2];
|
||||||
const b = track[track.length - 1];
|
const b = track[track.length - 1];
|
||||||
const d = haversine(a, b);
|
const d = haversine(a, b);
|
||||||
const dt = (b.timestamp - a.timestamp) / 60000; // minutes
|
const dt = (b.timestamp - a.timestamp) / 60000;
|
||||||
if (d < 0.001) return 0; // too close, skip
|
if (d < 0.001) return 0;
|
||||||
return dt / d; // min/km
|
return dt / d;
|
||||||
});
|
});
|
||||||
const latestPoint = $derived(
|
const latestPoint = $derived(
|
||||||
track.length > 0 ? track[track.length - 1] : null
|
track.length > 0 ? track[track.length - 1] : null
|
||||||
@@ -66,6 +82,22 @@ export function createGpsTracker() {
|
|||||||
|
|
||||||
let _debugMsg = $state('');
|
let _debugMsg = $state('');
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
_debugMsg = 'starting...';
|
_debugMsg = 'starting...';
|
||||||
if (!checkTauri() || isTracking) {
|
if (!checkTauri() || isTracking) {
|
||||||
@@ -74,6 +106,7 @@ export function createGpsTracker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Still use the Tauri plugin to request permissions (it has the proper Android permission flow)
|
||||||
_debugMsg = 'importing plugin...';
|
_debugMsg = 'importing plugin...';
|
||||||
const geo = await import('@tauri-apps/plugin-geolocation');
|
const geo = await import('@tauri-apps/plugin-geolocation');
|
||||||
_debugMsg = 'checking perms...';
|
_debugMsg = 'checking perms...';
|
||||||
@@ -92,28 +125,20 @@ export function createGpsTracker() {
|
|||||||
|
|
||||||
track = [];
|
track = [];
|
||||||
isTracking = true;
|
isTracking = true;
|
||||||
_debugMsg = 'calling watchPosition...';
|
|
||||||
|
|
||||||
_watchId = await geo.watchPosition(
|
// Start native Android foreground service — it does its own GPS tracking
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
|
const bridge = getAndroidBridge();
|
||||||
(pos, err) => {
|
if (bridge) {
|
||||||
if (err) {
|
_debugMsg = 'starting native GPS service...';
|
||||||
_debugMsg = `watch err: ${JSON.stringify(err)}`;
|
bridge.startLocationService();
|
||||||
return;
|
// Poll the native side for collected points
|
||||||
}
|
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
|
||||||
if (!pos) return;
|
_debugMsg = 'native GPS service started, polling...';
|
||||||
track = [...track, {
|
} else {
|
||||||
lat: pos.coords.latitude,
|
_debugMsg = 'no AndroidBridge — native tracking unavailable';
|
||||||
lng: pos.coords.longitude,
|
isTracking = false;
|
||||||
altitude: pos.coords.altitude ?? undefined,
|
return false;
|
||||||
speed: pos.coords.speed ?? undefined,
|
}
|
||||||
timestamp: pos.timestamp
|
|
||||||
}];
|
|
||||||
_debugMsg = `pts:${track.length} lat:${pos.coords.latitude.toFixed(4)} lng:${pos.coords.longitude.toFixed(4)}`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
_debugMsg = `watching (id=${_watchId})`;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -126,13 +151,20 @@ export function createGpsTracker() {
|
|||||||
async function stop(): Promise<GpsPoint[]> {
|
async function stop(): Promise<GpsPoint[]> {
|
||||||
if (!isTracking) return [];
|
if (!isTracking) return [];
|
||||||
|
|
||||||
try {
|
// Stop polling
|
||||||
if (_watchId !== null) {
|
if (_pollTimer !== null) {
|
||||||
const geo = await import('@tauri-apps/plugin-geolocation');
|
clearInterval(_pollTimer);
|
||||||
await geo.clearWatch(_watchId);
|
_pollTimer = null;
|
||||||
_watchId = null;
|
}
|
||||||
}
|
|
||||||
} catch {}
|
// Drain any remaining points from native
|
||||||
|
pollPoints();
|
||||||
|
|
||||||
|
// Stop native service
|
||||||
|
const bridge = getAndroidBridge();
|
||||||
|
if (bridge) {
|
||||||
|
bridge.stopLocationService();
|
||||||
|
}
|
||||||
|
|
||||||
isTracking = false;
|
isTracking = false;
|
||||||
const result = [...track];
|
const result = [...track];
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const len = gps.track.length;
|
const len = gps.track.length;
|
||||||
if (len > prevTrackLen && liveMap && gps.latestPoint) {
|
if (len > prevTrackLen && liveMap && gps.latestPoint) {
|
||||||
|
// Add all new points since last update (native polling delivers batches)
|
||||||
for (let i = prevTrackLen; i < len; i++) {
|
for (let i = prevTrackLen; i < len; i++) {
|
||||||
const p = gps.track[i];
|
const p = gps.track[i];
|
||||||
livePolyline.addLatLng([p.lat, p.lng]);
|
livePolyline.addLatLng([p.lat, p.lng]);
|
||||||
|
|||||||
Reference in New Issue
Block a user