From c90f8cb1fa08c50ad00b27d2b37917552c79cdb8 Mon Sep 17 00:00:00 2001 From: echo Date: Fri, 3 Apr 2026 00:13:22 +0200 Subject: [PATCH] geofence refinement and manual failsafe --- apps/admin/data/fitai.db | Bin 286720 -> 286720 bytes .../attendance/__tests__/attendance.test.ts | 2 + .../src/app/api/attendance/check-in/route.ts | 5 +- .../src/app/api/attendance/check-out/route.ts | 9 +- apps/admin/src/app/api/users/gym/route.ts | 70 +++++ apps/admin/src/lib/geofence.ts | 80 +++++ apps/mobile/app.json | 20 +- apps/mobile/package-lock.json | 20 ++ apps/mobile/package.json | 1 + apps/mobile/src/api/attendance.ts | 38 ++- apps/mobile/src/api/userGym.ts | 28 ++ apps/mobile/src/app/(auth)/onboarding.tsx | 4 + apps/mobile/src/app/(tabs)/index.tsx | 16 +- apps/mobile/src/app/(tabs)/profile.tsx | 7 + apps/mobile/src/app/_layout.tsx | 2 + .../src/hooks/useAutoWorkoutGeofence.ts | 68 ++++ apps/mobile/src/lib/background-auth-token.ts | 19 ++ .../src/services/autoWorkoutGeofence.ts | 296 ++++++++++++++++++ 18 files changed, 671 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/api/userGym.ts create mode 100644 apps/mobile/src/hooks/useAutoWorkoutGeofence.ts create mode 100644 apps/mobile/src/lib/background-auth-token.ts create mode 100644 apps/mobile/src/services/autoWorkoutGeofence.ts diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index ed674fcda62f5a0b011e60afd765da8bff9dbddd..e7a2c90d4895fb783ec12be9dd2753f251d236c5 100644 GIT binary patch delta 208 zcmZoTAlPs~aDp_W$3z)tMvsjNOZu6GxML=>Pf%v!j@hgzV8AWxAjBc>%fceez+#b; zVPv^h@X{#e=UDH|3rRaes6x(%|Z)4 z^6~L9%QM3D3T+l%|1N=(e**&>Zxa)5({?Ta#tL4(L`EK6K|x7hRu*=4M$YNGLaByX^zWZnXPY2Vo%@ye%R>^IDD$979k%fVQA)tMWJR=Y@0WtITE%Gcr4gk1E BKkonl delta 126 zcmZoTAlPs~aDp_W>qHr6M%RrAOZu4wx#vx0pP;;1QJ{o-^Ss$8G)Dyh?%$ll4oIa005B3CFTGC diff --git a/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts b/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts index 90cb6b6..268a492 100644 --- a/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts +++ b/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts @@ -38,6 +38,8 @@ jest.mock("@/lib/geofence", () => ({ accuracy: 10, })), validateGeofence: jest.fn(() => ({ ok: true })), + validateGeofenceWithFallback: jest.fn(() => ({ ok: true })), + validateCheckInGeofence: jest.fn(() => ({ ok: true })), })); const mockDb = { diff --git a/apps/admin/src/app/api/attendance/check-in/route.ts b/apps/admin/src/app/api/attendance/check-in/route.ts index 2e3fdff..0262786 100644 --- a/apps/admin/src/app/api/attendance/check-in/route.ts +++ b/apps/admin/src/app/api/attendance/check-in/route.ts @@ -5,7 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user"; import { getUserGymGeofence, parseUserLocation, - validateGeofence, + validateCheckInGeofence, } from "@/lib/geofence"; import log from "@/lib/logger"; @@ -27,6 +27,7 @@ export async function POST(req: NextRequest) { const body = await req.json().catch(() => ({})); const { type = "gym", notes } = body; + const fallbackRequested = Boolean(body.fallbackRequested); const gym = await getUserGymGeofence(userId); if (!gym) { @@ -37,7 +38,7 @@ export async function POST(req: NextRequest) { } const location = parseUserLocation(body.location); - const geofence = validateGeofence(gym, location); + const geofence = validateCheckInGeofence(gym, location, fallbackRequested); if (!geofence.ok) { return NextResponse.json( { error: geofence.error }, diff --git a/apps/admin/src/app/api/attendance/check-out/route.ts b/apps/admin/src/app/api/attendance/check-out/route.ts index d84438a..2082be9 100644 --- a/apps/admin/src/app/api/attendance/check-out/route.ts +++ b/apps/admin/src/app/api/attendance/check-out/route.ts @@ -4,7 +4,7 @@ import { getDatabase } from "@/lib/database"; import { getUserGymGeofence, parseUserLocation, - validateGeofence, + validateGeofenceWithFallback, } from "@/lib/geofence"; import log from "@/lib/logger"; @@ -21,6 +21,7 @@ export async function POST(req: Request) { } const body = await req.json().catch(() => ({})); + const fallbackRequested = Boolean(body.fallbackRequested); const gym = await getUserGymGeofence(userId); if (!gym) { @@ -31,7 +32,11 @@ export async function POST(req: Request) { } const location = parseUserLocation(body.location); - const geofence = validateGeofence(gym, location); + const geofence = validateGeofenceWithFallback( + gym, + location, + fallbackRequested, + ); if (!geofence.ok) { return NextResponse.json( { error: geofence.error }, diff --git a/apps/admin/src/app/api/users/gym/route.ts b/apps/admin/src/app/api/users/gym/route.ts index 3178423..e70e8c2 100644 --- a/apps/admin/src/app/api/users/gym/route.ts +++ b/apps/admin/src/app/api/users/gym/route.ts @@ -1,8 +1,78 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { db, users as usersTable, eq, sql } from "@fitai/database"; +import { ensureGymsGeofenceColumns } from "@/lib/geofence"; import log from "@/lib/logger"; +export async function GET() { + try { + const { userId } = await auth(); + if (!userId) return new NextResponse("Unauthorized", { status: 401 }); + + const user = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)) + .get(); + + if (!user) { + return new NextResponse("User not found", { status: 404 }); + } + + if (!user.gymId) { + return NextResponse.json({ gymId: null, gym: null }); + } + + await ensureGymsGeofenceColumns(); + + const rows = await db.all(sql` + SELECT + id, + name, + location, + latitude, + longitude, + geofence_radius_meters as geofenceRadiusMeters, + geofence_enabled as geofenceEnabled, + status + FROM gyms + WHERE id = ${user.gymId} + LIMIT 1 + `); + + const gym = rows?.[0] as + | { + id: string; + name: string; + location: string | null; + latitude: number | null; + longitude: number | null; + geofenceRadiusMeters: number | null; + geofenceEnabled: number | boolean | null; + status: "active" | "inactive"; + } + | undefined; + + if (!gym || gym.status !== "active") { + return NextResponse.json({ gymId: user.gymId, gym: null }); + } + + return NextResponse.json({ + gymId: user.gymId, + gym: { + ...gym, + geofenceEnabled: + typeof gym.geofenceEnabled === "boolean" + ? gym.geofenceEnabled + : Boolean(gym.geofenceEnabled), + }, + }); + } catch (error) { + log.error("Failed to fetch current user gym", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + /** * PATCH /api/users/gym * Body: { gymId: string | null } diff --git a/apps/admin/src/lib/geofence.ts b/apps/admin/src/lib/geofence.ts index 912e652..a3e7bdb 100644 --- a/apps/admin/src/lib/geofence.ts +++ b/apps/admin/src/lib/geofence.ts @@ -2,6 +2,7 @@ import { db, eq, sql, users } from "@fitai/database"; export const DEFAULT_GEOFENCE_RADIUS_METERS = 30; export const MAX_LOCATION_ACCURACY_METERS = 50; +export const MAX_FALLBACK_ACCURACY_MARGIN_METERS = 120; export interface UserLocation { latitude: number; @@ -169,6 +170,85 @@ export function validateGeofence( return { ok: true }; } +export function validateGeofenceWithFallback( + gym: GymGeofenceConfig, + location: UserLocation | null, + fallbackRequested: boolean, +): { ok: true } | { ok: false; status: number; error: string } { + const geofenceEnabled = gym.geofenceEnabled ?? true; + if (!geofenceEnabled) { + return { ok: true }; + } + + if (!location) { + return { + ok: false, + status: 400, + error: "Location is required for gym check-in/check-out", + }; + } + + if (gym.latitude === null || gym.longitude === null) { + return { + ok: false, + status: 400, + error: "Gym geofence is enabled but gym coordinates are not configured", + }; + } + + const radius = gym.geofenceRadiusMeters ?? DEFAULT_GEOFENCE_RADIUS_METERS; + const distanceMeters = haversineDistanceMeters( + gym.latitude, + gym.longitude, + location.latitude, + location.longitude, + ); + + if (location.accuracy <= MAX_LOCATION_ACCURACY_METERS) { + if (distanceMeters > radius) { + return { + ok: false, + status: 403, + error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, allowed ${Math.round(radius)}m).`, + }; + } + + return { ok: true }; + } + + if (!fallbackRequested) { + return { + ok: false, + status: 400, + error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`, + }; + } + + const fallbackMargin = Math.min( + location.accuracy, + MAX_FALLBACK_ACCURACY_MARGIN_METERS, + ); + const fallbackAllowedDistance = radius + fallbackMargin; + + if (distanceMeters > fallbackAllowedDistance) { + return { + ok: false, + status: 403, + error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, fallback allowed ${Math.round(fallbackAllowedDistance)}m).`, + }; + } + + return { ok: true }; +} + +export function validateCheckInGeofence( + gym: GymGeofenceConfig, + location: UserLocation | null, + fallbackRequested: boolean, +): { ok: true } | { ok: false; status: number; error: string } { + return validateGeofenceWithFallback(gym, location, fallbackRequested); +} + function haversineDistanceMeters( latitude1: number, longitude1: number, diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e0c5d69..e02a064 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -18,7 +18,12 @@ "NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.", "NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders.", "NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress.", - "NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out." + "NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses your location in the background to automatically start and end workouts when you enter or leave your gym geofence." + }, + "bundleIdentifier": "com.anonymous.fitai", + "config": { + "usesNonExemptEncryption": false } }, "android": { @@ -32,7 +37,8 @@ "android.permission.CAMERA", "android.permission.ACTIVITY_RECOGNITION", "android.permission.ACCESS_FINE_LOCATION", - "android.permission.ACCESS_COARSE_LOCATION" + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION" ], "package": "com.anonymous.fitai" }, @@ -43,7 +49,15 @@ "expo-router", "expo-font", "expo-barcode-scanner", - "expo-location", + [ + "expo-location", + { + "locationWhenInUsePermission": "Allow FitAI to use your location to verify gym check-ins and check-outs.", + "locationAlwaysAndWhenInUsePermission": "Allow FitAI to use your location in the background to automatically start and end workouts at your gym.", + "isIosBackgroundLocationEnabled": true, + "isAndroidBackgroundLocationEnabled": true + } + ], [ "expo-notifications", { diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 8d7fd54..2e5890f 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -35,6 +35,7 @@ "expo-secure-store": "~15.0.7", "expo-sensors": "~14.1.4", "expo-status-bar": "^3.0.8", + "expo-task-manager": "~14.0.8", "expo-web-browser": "^15.0.10", "react": "19.1.0", "react-dom": "^19.1.0", @@ -7650,6 +7651,19 @@ "react-native": "*" } }, + "node_modules/expo-task-manager": { + "version": "14.0.9", + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz", + "integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==", + "license": "MIT", + "dependencies": { + "unimodules-app-loader": "~6.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-web-browser": { "version": "15.0.10", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", @@ -13443,6 +13457,12 @@ "node": ">=4" } }, + "node_modules/unimodules-app-loader": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz", + "integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==", + "license": "MIT" + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 38156ca..f29a1a5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -38,6 +38,7 @@ "expo-location": "~19.0.7", "expo-notifications": "~0.32.0", "expo-router": "~6.0.14", + "expo-task-manager": "~14.0.8", "expo-secure-store": "~15.0.7", "expo-sensors": "~14.1.4", "expo-status-bar": "^3.0.8", diff --git a/apps/mobile/src/api/attendance.ts b/apps/mobile/src/api/attendance.ts index a1e4aea..cbe3ef7 100644 --- a/apps/mobile/src/api/attendance.ts +++ b/apps/mobile/src/api/attendance.ts @@ -1,5 +1,6 @@ import { apiClient } from "./client"; import { API_ENDPOINTS } from "../config/api"; +import { isAxiosError } from "axios"; export interface Attendance { id: string; @@ -23,7 +24,9 @@ export const attendanceApi = { }); return response.data; } catch (error) { - throw error; + throw new Error( + getAttendanceErrorMessage(error, "Failed to load attendance history."), + ); } }, @@ -31,30 +34,55 @@ export const attendanceApi = { type: string, token: string, location: AttendanceLocationPayload, + fallbackRequested = false, ): Promise => { try { await apiClient.post( API_ENDPOINTS.ATTENDANCE.CHECK_IN, - { type, location }, + { type, location, fallbackRequested }, { headers: { Authorization: `Bearer ${token}` } }, ); } catch (error) { - throw error; + throw new Error( + getAttendanceErrorMessage(error, "Failed to start workout."), + ); } }, checkOut: async ( token: string, location: AttendanceLocationPayload, + fallbackRequested = false, ): Promise => { try { await apiClient.post( API_ENDPOINTS.ATTENDANCE.CHECK_OUT, - { location }, + { location, fallbackRequested }, { headers: { Authorization: `Bearer ${token}` } }, ); } catch (error) { - throw error; + throw new Error( + getAttendanceErrorMessage(error, "Failed to end workout."), + ); } }, }; + +function getAttendanceErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const payload = error.response?.data; + + if (typeof payload === "string" && payload.trim()) { + return payload; + } + + if (payload && typeof payload === "object") { + const message = (payload as { error?: unknown }).error; + if (typeof message === "string" && message.trim()) { + return message; + } + } + } + + return fallback; +} diff --git a/apps/mobile/src/api/userGym.ts b/apps/mobile/src/api/userGym.ts new file mode 100644 index 0000000..7c23546 --- /dev/null +++ b/apps/mobile/src/api/userGym.ts @@ -0,0 +1,28 @@ +import { API_ENDPOINTS } from "../config/api"; +import { apiClient, withAuth } from "./client"; + +export interface UserGymGeofence { + id: string; + name: string; + location: string | null; + latitude: number | null; + longitude: number | null; + geofenceRadiusMeters: number | null; + geofenceEnabled: boolean; + status: "active" | "inactive"; +} + +export interface UserGymResponse { + gymId: string | null; + gym: UserGymGeofence | null; +} + +export const userGymApi = { + getCurrentGym: async (token: string | null): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.USERS.GYM, + withAuth(token), + ); + return response.data; + }, +}; diff --git a/apps/mobile/src/app/(auth)/onboarding.tsx b/apps/mobile/src/app/(auth)/onboarding.tsx index 1aa02fe..89c00df 100644 --- a/apps/mobile/src/app/(auth)/onboarding.tsx +++ b/apps/mobile/src/app/(auth)/onboarding.tsx @@ -17,6 +17,7 @@ import { useTheme } from "../../contexts/ThemeContext"; import { Input } from "../../components/Input"; import { MinimalButton } from "../../components/MinimalButton"; import { MinimalCard } from "../../components/MinimalCard"; +import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence"; import log from "../../utils/logger"; export default function OnboardingScreen() { @@ -81,6 +82,9 @@ export default function OnboardingScreen() { // selectedGymId: string gym id, or null to proceed without gym try { await gymsApi.updateUserGym(selectedGymId, token); + await syncAutoWorkoutGeofenceWithToken(token, { + requestPermissions: true, + }); } catch (e) { log.warn("Failed to update gym selection", { gymId: selectedGymId }); } diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 2902995..54462b5 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -30,6 +30,7 @@ import { ActivityRing } from "../../components/ActivityRing"; import { useMembership } from "../../hooks/useMembership"; import { attendanceApi, type Attendance } from "../../api/attendance"; import { useDailySteps } from "../../hooks/useDailySteps"; +import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence"; import { checkInsToActivities, completedGoalsToActivities, @@ -237,6 +238,10 @@ export default function HomeScreen() { return; } + await syncAutoWorkoutGeofenceWithToken(token, { + requestPermissions: true, + }); + const permission = await Location.requestForegroundPermissionsAsync(); if (permission.status !== "granted") { Alert.alert( @@ -256,10 +261,17 @@ export default function HomeScreen() { }; if (activeWorkoutSession) { - await attendanceApi.checkOut(token, locationPayload); + const fallbackRequested = locationPayload.accuracy > 50; + await attendanceApi.checkOut(token, locationPayload, fallbackRequested); Alert.alert("Workout logged", "Session ended successfully."); } else { - await attendanceApi.checkIn("gym", token, locationPayload); + const fallbackRequested = locationPayload.accuracy > 50; + await attendanceApi.checkIn( + "gym", + token, + locationPayload, + fallbackRequested, + ); Alert.alert("Workout started", "Session started successfully."); } diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index 8355624..e0ef120 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -21,6 +21,7 @@ import { IconContainer } from "../../components/IconContainer"; import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile"; import { gymsApi, type Gym } from "../../api/gyms"; import { useMembership } from "../../hooks/useMembership"; +import { syncAutoWorkoutGeofenceWithToken } from "../../services/autoWorkoutGeofence"; import log from "../../utils/logger"; export default function ProfileScreen() { @@ -115,6 +116,12 @@ export default function ProfileScreen() { "Success", selectedGymId ? "Gym selected successfully" : "Proceeding without gym", ); + + if (token) { + await syncAutoWorkoutGeofenceWithToken(token, { + requestPermissions: true, + }); + } } catch (err) { log.error("Failed to update gym selection", err); Alert.alert("Error", "Failed to update gym selection"); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2f5afc0..54394eb 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -13,6 +13,7 @@ import { RecommendationsProvider } from "../contexts/RecommendationsContext"; import { NotificationsProvider } from "../contexts/NotificationsContext"; import { MembershipProvider } from "../contexts/MembershipContext"; import { queryClient } from "../lib/query-client"; +import { useAutoWorkoutGeofence } from "../hooks/useAutoWorkoutGeofence"; import log from "../utils/logger"; // Wrapper to use notification permissions hook after ClerkLoaded @@ -22,6 +23,7 @@ function AppContent() { useNotificationPermissions, } = require("../hooks/useNotificationPermissions"); useNotificationPermissions(); + useAutoWorkoutGeofence(); return ( diff --git a/apps/mobile/src/hooks/useAutoWorkoutGeofence.ts b/apps/mobile/src/hooks/useAutoWorkoutGeofence.ts new file mode 100644 index 0000000..c43c6d0 --- /dev/null +++ b/apps/mobile/src/hooks/useAutoWorkoutGeofence.ts @@ -0,0 +1,68 @@ +import { useAuth } from "@clerk/clerk-expo"; +import { useCallback, useEffect } from "react"; +import { AppState } from "react-native"; +import { + disableAutoWorkoutGeofence, + syncAutoWorkoutGeofenceWithToken, +} from "../services/autoWorkoutGeofence"; +import log from "../utils/logger"; + +export function useAutoWorkoutGeofence() { + const { isSignedIn, getToken } = useAuth(); + + const syncGeofence = useCallback(async () => { + try { + if (!isSignedIn) { + await disableAutoWorkoutGeofence(); + return; + } + + const token = await getToken(); + if (!token) { + await disableAutoWorkoutGeofence(); + return; + } + + await syncAutoWorkoutGeofenceWithToken(token, { + requestPermissions: true, + }); + } catch (error) { + log.warn("Failed to sync auto workout geofence", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, [getToken, isSignedIn]); + + useEffect(() => { + void syncGeofence(); + }, [syncGeofence]); + + useEffect(() => { + const appStateSubscription = AppState.addEventListener( + "change", + (state) => { + if (state === "active") { + void syncGeofence(); + } + }, + ); + + const interval = setInterval( + () => { + void syncGeofence(); + }, + 5 * 60 * 1000, + ); + + return () => { + appStateSubscription.remove(); + clearInterval(interval); + }; + }, [syncGeofence]); + + useEffect(() => { + if (!isSignedIn) { + void disableAutoWorkoutGeofence(); + } + }, [isSignedIn]); +} diff --git a/apps/mobile/src/lib/background-auth-token.ts b/apps/mobile/src/lib/background-auth-token.ts new file mode 100644 index 0000000..39f656c --- /dev/null +++ b/apps/mobile/src/lib/background-auth-token.ts @@ -0,0 +1,19 @@ +import * as SecureStore from "expo-secure-store"; + +const BACKGROUND_AUTH_TOKEN_KEY = "fitai_background_auth_token"; + +export async function saveBackgroundAuthToken(token: string): Promise { + if (!looksLikeJwt(token)) { + return; + } + + await SecureStore.setItemAsync(BACKGROUND_AUTH_TOKEN_KEY, token); +} + +export async function getBackgroundAuthToken(): Promise { + return SecureStore.getItemAsync(BACKGROUND_AUTH_TOKEN_KEY); +} + +function looksLikeJwt(token: string): boolean { + return token.split(".").length === 3; +} diff --git a/apps/mobile/src/services/autoWorkoutGeofence.ts b/apps/mobile/src/services/autoWorkoutGeofence.ts new file mode 100644 index 0000000..e0ac8cb --- /dev/null +++ b/apps/mobile/src/services/autoWorkoutGeofence.ts @@ -0,0 +1,296 @@ +import * as Location from "expo-location"; +import * as TaskManager from "expo-task-manager"; +import { apiClient } from "../api/client"; +import { userGymApi } from "../api/userGym"; +import { API_ENDPOINTS } from "../config/api"; +import { saveBackgroundAuthToken } from "../lib/background-auth-token"; +import { getBackgroundAuthToken } from "../lib/background-auth-token"; +import log from "../utils/logger"; + +const AUTO_WORKOUT_GEOFENCE_TASK = "fitai-auto-workout-geofence"; +const DEFAULT_RADIUS_METERS = 30; +const MAX_ACCEPTABLE_ACCURACY_METERS = 50; +const ACTION_COOLDOWN_MS = 90_000; + +let lastActionAt = 0; + +interface GeofenceRegion { + identifier: string; + latitude: number; + longitude: number; + radius: number; + notifyOnEnter: boolean; + notifyOnExit: boolean; +} + +if (!TaskManager.isTaskDefined(AUTO_WORKOUT_GEOFENCE_TASK)) { + TaskManager.defineTask( + AUTO_WORKOUT_GEOFENCE_TASK, + async ({ data, error }: TaskManager.TaskManagerTaskBody) => { + if (error) { + log.error("Auto workout geofence task failed", error); + return; + } + + const event = data as + | { + eventType?: Location.GeofencingEventType; + region?: GeofenceRegion; + } + | undefined; + + const eventType = event?.eventType; + if ( + eventType !== Location.GeofencingEventType.Enter && + eventType !== Location.GeofencingEventType.Exit + ) { + return; + } + + const now = Date.now(); + if (now - lastActionAt < ACTION_COOLDOWN_MS) { + return; + } + lastActionAt = now; + + try { + const token = await getBackgroundAuthToken(); + if (!token) { + log.warn("Skipping geofence auto-workout due to missing auth token"); + return; + } + + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + + const accuracy = location.coords.accuracy ?? 999; + if (accuracy > MAX_ACCEPTABLE_ACCURACY_METERS) { + log.warn("Skipping geofence auto-workout due to poor GPS accuracy", { + accuracy, + }); + return; + } + + const headers = { Authorization: `Bearer ${token}` }; + const locationPayload = { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + accuracy, + }; + + const historyRes = await apiClient.get( + API_ENDPOINTS.ATTENDANCE.HISTORY, + { + headers, + }, + ); + const history = Array.isArray(historyRes.data) + ? (historyRes.data as Array<{ id: string; checkOutTime?: string }>) + : []; + const activeSession = history.find((item) => !item.checkOutTime); + + if (eventType === Location.GeofencingEventType.Enter) { + if (activeSession) { + return; + } + + await apiClient.post( + API_ENDPOINTS.ATTENDANCE.CHECK_IN, + { type: "gym", location: locationPayload }, + { headers }, + ); + log.info("Auto-started workout after geofence enter"); + return; + } + + if (!activeSession) { + return; + } + + await apiClient.post( + API_ENDPOINTS.ATTENDANCE.CHECK_OUT, + { + location: locationPayload, + fallbackRequested: accuracy > MAX_ACCEPTABLE_ACCURACY_METERS, + }, + { headers }, + ); + log.info("Auto-ended workout after geofence exit"); + } catch (taskError: unknown) { + if ( + isApiError(taskError, 400, "Already checked in") || + isApiError(taskError, 404, "No active check-in found") + ) { + return; + } + + log.error("Auto workout geofence action failed", taskError); + } + }, + ); +} + +export async function configureAutoWorkoutGeofence(params: { + latitude: number; + longitude: number; + radiusMeters?: number | null; +}): Promise { + const started = await Location.hasStartedGeofencingAsync( + AUTO_WORKOUT_GEOFENCE_TASK, + ); + + const region: GeofenceRegion = { + identifier: "user-gym", + latitude: params.latitude, + longitude: params.longitude, + radius: params.radiusMeters ?? DEFAULT_RADIUS_METERS, + notifyOnEnter: true, + notifyOnExit: true, + }; + + if (started) { + try { + await Location.stopGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK); + } catch (error) { + if (!isTaskNotFoundError(error)) { + throw error; + } + + log.warn("Geofence task was not found while stopping before restart", { + task: AUTO_WORKOUT_GEOFENCE_TASK, + }); + } + } + + await Location.startGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK, [region]); +} + +export async function disableAutoWorkoutGeofence(): Promise { + try { + const started = await Location.hasStartedGeofencingAsync( + AUTO_WORKOUT_GEOFENCE_TASK, + ); + + if (!started) { + return; + } + + await Location.stopGeofencingAsync(AUTO_WORKOUT_GEOFENCE_TASK); + } catch (error) { + if (!isTaskNotFoundError(error)) { + throw error; + } + + log.warn("Geofence task was not found while disabling", { + task: AUTO_WORKOUT_GEOFENCE_TASK, + }); + } +} + +export async function syncAutoWorkoutGeofenceWithToken( + token: string, + options?: { requestPermissions?: boolean }, +): Promise { + const shouldRequestPermissions = options?.requestPermissions ?? false; + + await saveBackgroundAuthToken(token); + + const foregroundPermission = await Location.getForegroundPermissionsAsync(); + const foregroundStatus = + foregroundPermission.status === "granted" + ? foregroundPermission.status + : shouldRequestPermissions + ? (await Location.requestForegroundPermissionsAsync()).status + : foregroundPermission.status; + + if (foregroundStatus !== "granted") { + log.info("Auto workout geofence disabled: foreground location not granted"); + await disableAutoWorkoutGeofence(); + return; + } + + const backgroundPermission = await Location.getBackgroundPermissionsAsync(); + const backgroundStatus = + backgroundPermission.status === "granted" + ? backgroundPermission.status + : shouldRequestPermissions + ? (await Location.requestBackgroundPermissionsAsync()).status + : backgroundPermission.status; + + if (backgroundStatus !== "granted") { + log.info("Auto workout geofence disabled: background location not granted"); + await disableAutoWorkoutGeofence(); + return; + } + + const currentGym = await userGymApi.getCurrentGym(token); + const gym = currentGym.gym; + + if ( + !gym || + gym.geofenceEnabled === false || + gym.latitude === null || + gym.longitude === null + ) { + log.info("Auto workout geofence disabled: missing/disabled gym geofence"); + await disableAutoWorkoutGeofence(); + return; + } + + await configureAutoWorkoutGeofence({ + latitude: gym.latitude, + longitude: gym.longitude, + radiusMeters: gym.geofenceRadiusMeters ?? DEFAULT_RADIUS_METERS, + }); +} + +function isApiError( + error: unknown, + status: number, + expectedMessage: string, +): boolean { + if (!error || typeof error !== "object") { + return false; + } + + const maybe = error as { + response?: { + status?: number; + data?: unknown; + }; + }; + + if (maybe.response?.status !== status) { + return false; + } + + const data = maybe.response.data; + if (typeof data === "string") { + return data === expectedMessage; + } + + if (data && typeof data === "object") { + const errorField = (data as { error?: unknown }).error; + if (typeof errorField === "string") { + return errorField.includes(expectedMessage); + } + } + + return false; +} + +function isTaskNotFoundError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + + const message = + "message" in error && typeof error.message === "string" + ? error.message + : String(error); + + return ( + message.includes("TaskNotFoundException") || message.includes("not found") + ); +}