From 26e793ed3310b5d78070c4ed4fcf6fa4a3a538bd Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 26 Aug 2026 11:00:16 -0700 Subject: [PATCH 1/2] RG-T133 Removing background geolocation, forground service fix --- __mocks__/expo-task-manager.ts | 21 - app.config.ts | 40 +- customManifest.plugin.js | 2 +- jest-setup.ts | 6 + package.json | 1 - .../__tests__/android-boot-receivers.test.ts | 32 +- .../without-background-location.test.ts | 78 +++ plugins/withRestrictedBootReceivers.js | 34 +- plugins/withoutBackgroundLocation.js | 59 ++ src/app/(app)/settings.tsx | 2 - src/app/_layout.tsx | 19 +- .../__tests__/call-images-modal.test.tsx | 10 - .../settings/background-geolocation-item.tsx | 64 --- .../use-background-geolocation.test.ts | 9 - src/lib/hooks/index.tsx | 1 - src/lib/hooks/use-background-geolocation.ts | 58 -- src/lib/storage/__tests__/legacy-keys.test.ts | 75 +++ src/lib/storage/background-geolocation.ts | 44 -- src/lib/storage/legacy-keys.ts | 45 ++ .../location-foreground-permissions.test.ts | 415 -------------- src/services/__tests__/location.test.ts | 524 ++---------------- src/services/location.ts | 296 ++-------- .../app/__tests__/location-store.test.ts | 46 +- src/stores/app/livekit-store.ts | 5 +- src/stores/app/location-store.ts | 17 +- src/translations/ar.json | 2 - src/translations/de.json | 2 - src/translations/el.json | 2 - src/translations/en.json | 2 - src/translations/es.json | 2 - src/translations/fr.json | 2 - src/translations/it.json | 2 - src/translations/pl.json | 2 - src/translations/sv.json | 2 - src/translations/uk.json | 2 - yarn.lock | 12 - 36 files changed, 440 insertions(+), 1495 deletions(-) delete mode 100644 __mocks__/expo-task-manager.ts create mode 100644 plugins/__tests__/without-background-location.test.ts create mode 100644 plugins/withoutBackgroundLocation.js delete mode 100644 src/components/settings/background-geolocation-item.tsx delete mode 100644 src/lib/hooks/__tests__/use-background-geolocation.test.ts delete mode 100644 src/lib/hooks/use-background-geolocation.ts create mode 100644 src/lib/storage/__tests__/legacy-keys.test.ts delete mode 100644 src/lib/storage/background-geolocation.ts create mode 100644 src/lib/storage/legacy-keys.ts delete mode 100644 src/services/__tests__/location-foreground-permissions.test.ts diff --git a/__mocks__/expo-task-manager.ts b/__mocks__/expo-task-manager.ts deleted file mode 100644 index 9a8ed1e..0000000 --- a/__mocks__/expo-task-manager.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const defineTask = jest.fn(); -export const startLocationTrackingAsync = jest.fn().mockResolvedValue(undefined); -export const stopLocationTrackingAsync = jest.fn().mockResolvedValue(undefined); -export const hasStartedLocationTrackingAsync = jest.fn().mockResolvedValue(false); -export const getRegisteredTasksAsync = jest.fn().mockResolvedValue([]); -export const isTaskRegisteredAsync = jest.fn().mockResolvedValue(false); -export const unregisterTaskAsync = jest.fn().mockResolvedValue(undefined); -export const unregisterAllTasksAsync = jest.fn().mockResolvedValue(undefined); - -const TaskManager = { - defineTask, - startLocationTrackingAsync, - stopLocationTrackingAsync, - hasStartedLocationTrackingAsync, - getRegisteredTasksAsync, - isTaskRegisteredAsync, - unregisterTaskAsync, - unregisterAllTasksAsync, -}; - -export default TaskManager; diff --git a/app.config.ts b/app.config.ts index d19f36a..c063f40 100644 --- a/app.config.ts +++ b/app.config.ts @@ -107,6 +107,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // and legacy storage permissions even when a transitive native dependency // contributes them during manifest merging. blockedPermissions: [ + // Background location was removed from the app (Play policy: no declarable + // background-location feature). Block the permissions outright so a transitive + // native dependency cannot reintroduce them during manifest merging. + 'android.permission.ACCESS_BACKGROUND_LOCATION', + 'android.permission.FOREGROUND_SERVICE_LOCATION', // Contributed by expo-notifications. withRestrictedBootReceivers strips every // BOOT_COMPLETED intent-filter (Android 15 crashes apps that launch restricted // foreground service types from boot), so nothing here listens for boot and the @@ -158,28 +163,18 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ [ 'expo-location', { + // Foreground-only. The IC app centers the map and computes distances while the + // user has it open; it has no background-location feature, so the background / + // foreground-service flags and the task manager block stay off. Turning any of + // them back on re-adds ACCESS_BACKGROUND_LOCATION and gets the Play listing + // rejected for an undeclared background-location feature. locationWhenInUsePermission: 'Allow Resgrid IC to show current location on map.', - locationAlwaysAndWhenInUsePermission: 'Allow Resgrid IC to use your location for department updates.', - locationAlwaysPermission: 'Resgrid IC needs to track your location for department AVL.', - isIosBackgroundLocationEnabled: true, - isAndroidBackgroundLocationEnabled: true, - isAndroidForegroundServiceEnabled: true, - taskManager: { - locationTaskName: 'location-updates', - locationTaskOptions: { - accuracy: 'balanced', - distanceInterval: 10, - timeInterval: 5000, - }, - }, - }, - ], - [ - 'expo-task-manager', - { - taskManager: { - taskName: 'location-updates', - }, + // `false` deletes the key from Info.plist entirely (the plugin otherwise fills in + // its own default text). The "Always" strings advertise background location on + // iOS, and nothing here uses Core Motion. + locationAlwaysAndWhenInUsePermission: false, + locationAlwaysPermission: false, + motionUsagePermission: false, }, ], [ @@ -263,6 +258,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './customManifest.plugin.js', // Must run after customManifest.plugin.js: both edit the merged application node. './plugins/withRestrictedBootReceivers.js', + // Strips expo-location's location-typed foreground service: this app tracks location + // only in the foreground, so nothing may ship a background-location surface. + './plugins/withoutBackgroundLocation.js', './plugins/withNotificationSounds.js', './plugins/withMediaButtonModule.js', './plugins/withInCallAudioModule.js', diff --git a/customManifest.plugin.js b/customManifest.plugin.js index 46d8424..a8c858e 100644 --- a/customManifest.plugin.js +++ b/customManifest.plugin.js @@ -14,7 +14,7 @@ const withForegroundService = (config) => { mainApplication['service'].push({ $: { 'android:name': 'app.notifee.core.ForegroundService', - 'android:foregroundServiceType': 'microphone|mediaPlayback|connectedDevice', + 'android:foregroundServiceType': 'microphone|connectedDevice', 'tools:replace': 'android:foregroundServiceType', }, }); diff --git a/jest-setup.ts b/jest-setup.ts index 03bf010..fcc13df 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -176,10 +176,16 @@ jest.mock('@notifee/react-native', () => { UNSPECIFIED: 'unspecified', }; + const AndroidForegroundServiceType = { + FOREGROUND_SERVICE_TYPE_MICROPHONE: 128, + FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE: 16, + }; + return { __esModule: true, default: mockNotifee, AndroidImportance, + AndroidForegroundServiceType, }; }); diff --git a/package.json b/package.json index d9ce56f..971c2be 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,6 @@ "expo-splash-screen": "~56.0.14", "expo-status-bar": "~56.0.4", "expo-system-ui": "~56.0.5", - "expo-task-manager": "~56.0.26", "expo-video": "~56.1.4", "expo-web-browser": "~56.0.6", "geojson": "0.5.0", diff --git a/plugins/__tests__/android-boot-receivers.test.ts b/plugins/__tests__/android-boot-receivers.test.ts index d721a28..ae93549 100644 --- a/plugins/__tests__/android-boot-receivers.test.ts +++ b/plugins/__tests__/android-boot-receivers.test.ts @@ -16,7 +16,7 @@ type Manifest = { }; }; -// Mirrors what expo-task-manager and expo-notifications contribute during manifest merging. +// Mirrors what expo-notifications contributes during manifest merging. const createManifest = (): Manifest => ({ manifest: { $: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' }, @@ -25,11 +25,11 @@ const createManifest = (): Manifest => ({ $: { 'android:name': '.MainApplication' }, receiver: [ { - $: { 'android:name': 'expo.modules.taskManager.TaskBroadcastReceiver', 'android:exported': 'false' }, + $: { 'android:name': 'expo.modules.notifications.service.NotificationsService', 'android:enabled': 'true', 'android:exported': 'false' }, 'intent-filter': [ { action: [ - { $: { 'android:name': 'expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION' } }, + { $: { 'android:name': 'expo.modules.notifications.NOTIFICATION_EVENT' } }, { $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } }, { $: { 'android:name': 'android.intent.action.MY_PACKAGE_REPLACED' } }, ], @@ -47,16 +47,20 @@ const findReceiver = (manifest: Manifest, name: string) => manifest.manifest.app const actionsOf = (manifest: Manifest, name: string) => (findReceiver(manifest, name)?.['intent-filter'] ?? []).flatMap((filter) => (filter.action ?? []).map((action) => action.$['android:name'])); describe('Android 15 boot receivers', () => { - it('drops BOOT_COMPLETED from the task manager receiver while keeping its explicit-intent registration', () => { + it('keeps MY_PACKAGE_REPLACED on the notifications receiver', () => { const manifest = applyBootReceiverOverrides(createManifest()) as Manifest; - const actions = actionsOf(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver'); + const actions = actionsOf(manifest, 'expo.modules.notifications.service.NotificationsService'); - expect(findReceiver(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver')).toBeDefined(); - expect(actions).not.toContain('android.intent.action.BOOT_COMPLETED'); - expect(actions).toContain('expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION'); + expect(findReceiver(manifest, 'expo.modules.notifications.service.NotificationsService')).toBeDefined(); expect(actions).toContain('android.intent.action.MY_PACKAGE_REPLACED'); }); + it('does not declare the expo-task-manager boot receiver (background location removed)', () => { + const manifest = applyBootReceiverOverrides(createManifest()) as Manifest; + + expect(findReceiver(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver')).toBeUndefined(); + }); + it('keeps the notifications receiver resolvable by action so push delivery still works', () => { const manifest = applyBootReceiverOverrides(createManifest()) as Manifest; const actions = actionsOf(manifest, 'expo.modules.notifications.service.NotificationsService'); @@ -68,21 +72,19 @@ describe('Android 15 boot receivers', () => { expect(actions).not.toContain('com.htc.intent.action.QUICKBOOT_POWERON'); }); - it('marks both receivers as merger replacements and declares the tools namespace', () => { + it('marks the receiver as a merger replacement and declares the tools namespace', () => { const manifest = applyBootReceiverOverrides(createManifest()) as Manifest; expect(manifest.manifest.$['xmlns:tools']).toBe('http://schemas.android.com/tools'); - ['expo.modules.taskManager.TaskBroadcastReceiver', 'expo.modules.notifications.service.NotificationsService'].forEach((name) => { - expect(findReceiver(manifest, name)?.$['tools:node']).toBe('replace'); - }); + expect(findReceiver(manifest, 'expo.modules.notifications.service.NotificationsService')?.$['tools:node']).toBe('replace'); }); it('is idempotent across repeated prebuilds', () => { const once = applyBootReceiverOverrides(createManifest()) as Manifest; const twice = applyBootReceiverOverrides(once) as Manifest; - expect(twice.manifest.application[0].receiver).toHaveLength(2); - expect(actionsOf(twice, 'expo.modules.taskManager.TaskBroadcastReceiver')).not.toContain('android.intent.action.BOOT_COMPLETED'); + expect(twice.manifest.application[0].receiver).toHaveLength(1); + expect(actionsOf(twice, 'expo.modules.notifications.service.NotificationsService')).not.toContain('android.intent.action.BOOT_COMPLETED'); }); it('blocks RECEIVE_BOOT_COMPLETED and registers the plugin', () => { @@ -96,5 +98,7 @@ describe('Android 15 boot receivers', () => { expect(config.android?.blockedPermissions).toContain('android.permission.RECEIVE_BOOT_COMPLETED'); expect(config.android?.permissions).not.toContain('android.permission.RECEIVE_BOOT_COMPLETED'); expect(config.plugins).toContain('./plugins/withRestrictedBootReceivers.js'); + expect(config.android?.blockedPermissions).toContain('android.permission.ACCESS_BACKGROUND_LOCATION'); + expect(config.android?.blockedPermissions).toContain('android.permission.FOREGROUND_SERVICE_LOCATION'); }); }); diff --git a/plugins/__tests__/without-background-location.test.ts b/plugins/__tests__/without-background-location.test.ts new file mode 100644 index 0000000..4ce63a7 --- /dev/null +++ b/plugins/__tests__/without-background-location.test.ts @@ -0,0 +1,78 @@ +import type { ConfigContext } from '@expo/config'; + +import createExpoConfig from '../../app.config'; + +const { removeLocationTaskService, LOCATION_TASK_SERVICE } = require('../withoutBackgroundLocation'); + +jest.mock('zod', () => jest.requireActual('zod')); + +type Manifest = { + manifest: { + $: Record; + application: { + $: Record; + service?: { $: Record }[]; + }[]; + }; +}; + +// Mirrors what expo-location contributes during manifest merging. +const createManifest = (): Manifest => ({ + manifest: { + $: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' }, + application: [ + { + $: { 'android:name': '.MainApplication' }, + service: [{ $: { 'android:name': LOCATION_TASK_SERVICE, 'android:exported': 'false', 'android:foregroundServiceType': 'location' } }], + }, + ], + }, +}); + +const findService = (manifest: Manifest, name: string) => manifest.manifest.application[0].service?.find((service) => service.$['android:name'] === name); + +describe('background location removal', () => { + it('replaces the location task service with a merger removal directive', () => { + const manifest = removeLocationTaskService(createManifest()) as Manifest; + const service = findService(manifest, LOCATION_TASK_SERVICE); + + expect(service?.$['tools:node']).toBe('remove'); + expect(service?.$['android:foregroundServiceType']).toBeUndefined(); + expect(manifest.manifest.$['xmlns:tools']).toBe('http://schemas.android.com/tools'); + }); + + it('declares the removal even when the library manifest has not been merged in yet', () => { + const manifest = removeLocationTaskService({ + manifest: { + $: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' }, + application: [{ $: { 'android:name': '.MainApplication' } }], + }, + } as Manifest) as Manifest; + + expect(findService(manifest, LOCATION_TASK_SERVICE)?.$['tools:node']).toBe('remove'); + }); + + it('is idempotent across repeated prebuilds', () => { + const once = removeLocationTaskService(createManifest()) as Manifest; + const twice = removeLocationTaskService(once) as Manifest; + + expect(twice.manifest.application[0].service).toHaveLength(1); + expect(findService(twice, LOCATION_TASK_SERVICE)?.$['tools:node']).toBe('remove'); + }); + + it('keeps background location out of the app config', () => { + const config = createExpoConfig({ + config: { + name: 'Resgrid IC', + slug: 'resgrid-ic', + }, + } as ConfigContext); + + expect(config.plugins).toContain('./plugins/withoutBackgroundLocation.js'); + expect(config.android?.blockedPermissions).toContain('android.permission.ACCESS_BACKGROUND_LOCATION'); + expect(config.android?.permissions).not.toContain('android.permission.ACCESS_BACKGROUND_LOCATION'); + expect(config.ios?.infoPlist?.UIBackgroundModes).not.toContain('location'); + expect(config.ios?.infoPlist?.NSLocationAlwaysUsageDescription).toBeUndefined(); + expect(config.ios?.infoPlist?.NSLocationAlwaysAndWhenInUseUsageDescription).toBeUndefined(); + }); +}); diff --git a/plugins/withRestrictedBootReceivers.js b/plugins/withRestrictedBootReceivers.js index ce58ad1..05b5d9e 100644 --- a/plugins/withRestrictedBootReceivers.js +++ b/plugins/withRestrictedBootReceivers.js @@ -9,46 +9,28 @@ const TOOLS_NAMESPACE = 'http://schemas.android.com/tools'; * throws ForegroundServiceStartNotAllowedException and crashes the app. * * This app declares several of those types (notifee's ForegroundService is - * microphone|mediaPlayback|connectedDevice, CallKeep's VoiceConnectionService is - * phoneCall, expo-audio's AudioControlsService is mediaPlayback, react-native-webrtc - * contributes mediaProjection), and two dependency manifests register receivers for + * microphone|connectedDevice, CallKeep's VoiceConnectionService is phoneCall, + * react-native-webrtc contributes mediaProjection), and expo-notifications registers a receiver for * BOOT_COMPLETED that can reach `startForegroundService`: * - * - expo.modules.taskManager.TaskBroadcastReceiver — on boot it restarts every - * registered task; the expo-location consumer calls startForegroundService. * - expo.modules.notifications.service.NotificationsService — on boot it re-arms * scheduled local notifications. * - * Neither boot path is needed here: background location is opt-in and started from - * inside the app (src/services/location.ts), and the app never schedules local - * notifications — all notifications are push-delivered. + * That boot path is not needed here: the app never schedules local notifications — all + * notifications are push-delivered. * * Library manifests are merged in by Gradle, so the boot actions cannot be edited - * directly. Instead we re-declare each receiver in the app manifest with + * directly. Instead we re-declare the receiver in the app manifest with * tools:node="replace", which makes the manifest merger take OUR element — attributes * and intent-filters — verbatim in place of the library's. * - * Both receivers MUST stay declared: - * - TaskBroadcastReceiver is targeted by explicit intents (TaskManagerUtils#createTaskIntent). - * - NotificationsService is resolved with queryBroadcastReceivers() on the - * expo.modules.notifications.NOTIFICATION_EVENT action — dropping that filter would - * kill ALL notification delivery, so it is preserved here. + * NotificationsService MUST stay declared: it is resolved with queryBroadcastReceivers() + * on the expo.modules.notifications.NOTIFICATION_EVENT action — dropping that filter + * would kill ALL notification delivery, so it is preserved here. * * MY_PACKAGE_REPLACED is kept: the Android 15 restriction is specific to BOOT_COMPLETED. */ const RECEIVER_OVERRIDES = [ - { - name: 'expo.modules.taskManager.TaskBroadcastReceiver', - attributes: { - 'android:exported': 'false', - }, - intentFilters: [ - { - attributes: {}, - actions: ['expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION', 'android.intent.action.MY_PACKAGE_REPLACED'], - }, - ], - }, { name: 'expo.modules.notifications.service.NotificationsService', attributes: { diff --git a/plugins/withoutBackgroundLocation.js b/plugins/withoutBackgroundLocation.js new file mode 100644 index 0000000..f9d1691 --- /dev/null +++ b/plugins/withoutBackgroundLocation.js @@ -0,0 +1,59 @@ +const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins'); + +const TOOLS_NAMESPACE = 'http://schemas.android.com/tools'; + +/** + * The IC app tracks location only while it is in the foreground (map centering and + * distance calculations — see src/services/location.ts). It has no background-location + * feature, which is why app.config.ts leaves every expo-location background flag off and + * blocks ACCESS_BACKGROUND_LOCATION / FOREGROUND_SERVICE_LOCATION outright. + * + * expo-location's own library manifest still contributes this during merging: + * + * + * + * Nothing starts it (the app never calls Location.startLocationUpdatesAsync and + * expo-task-manager is not installed), but it leaves a location-typed foreground service + * in the shipped manifest — exactly the signal a Play policy review reads as background + * location. Library manifests cannot be edited directly, so declare the same service in + * the app manifest with tools:node="remove": the merger drops the element and emits + * nothing for it. + */ +const LOCATION_TASK_SERVICE = 'expo.modules.location.services.LocationTaskService'; + +/** + * Pure manifest transform, exported for tests. + * + * @param {object} androidManifest parsed AndroidManifest.xml (xml2js shape) + * @returns {object} the same manifest, mutated + */ +const removeLocationTaskService = (androidManifest) => { + if (!androidManifest.manifest.$['xmlns:tools']) { + androidManifest.manifest.$['xmlns:tools'] = TOOLS_NAMESPACE; + } + + const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest); + const services = mainApplication.service ?? []; + const removal = { $: { 'android:name': LOCATION_TASK_SERVICE, 'tools:node': 'remove' } }; + const existingIndex = services.findIndex((service) => service.$?.['android:name'] === LOCATION_TASK_SERVICE); + + if (existingIndex >= 0) { + services[existingIndex] = removal; + } else { + services.push(removal); + } + + mainApplication.service = services; + return androidManifest; +}; + +const withoutBackgroundLocation = (config) => + withAndroidManifest(config, (config) => { + config.modResults = removeLocationTaskService(config.modResults); + return config; + }); + +module.exports = withoutBackgroundLocation; +module.exports.removeLocationTaskService = removeLocationTaskService; +module.exports.LOCATION_TASK_SERVICE = LOCATION_TASK_SERVICE; diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 8a5193c..ccefef2 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -5,7 +5,6 @@ import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { BackgroundGeolocationItem } from '@/components/settings/background-geolocation-item'; import { BluetoothDeviceItem } from '@/components/settings/bluetooth-device-item'; import { Item } from '@/components/settings/item'; import { KeepAliveItem } from '@/components/settings/keep-alive-item'; @@ -140,7 +139,6 @@ export default function Settings() { - diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ec188ba..c4ad47b 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -31,7 +31,7 @@ import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme'; import { logger } from '@/lib/logging'; import { registerNavigationReadyCheck } from '@/lib/navigation'; import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app'; -import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; +import { removeLegacyStorageKeys } from '@/lib/storage/legacy-keys'; import { uuidv4 } from '@/lib/utils'; import { appInitializationService } from '@/services/app-initialization.service'; @@ -151,6 +151,9 @@ function RootLayout() { }); } + // Drop storage left behind by features that no longer ship (background location) + removeLegacyStorageKeys(); + // Load keep alive state on app startup loadKeepAliveState() .then(() => { @@ -165,20 +168,6 @@ function RootLayout() { }); }); - // Load background geolocation state on app startup - loadBackgroundGeolocationState() - .then(() => { - logger.info({ - message: 'Background geolocation state loaded on startup', - }); - }) - .catch((error) => { - logger.error({ - message: 'Failed to load background geolocation state on startup', - context: { error }, - }); - }); - // Initialize global app services (including CallKeep for iOS) appInitializationService .initialize() diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx index d1cc8ed..7d2fc0c 100644 --- a/src/components/calls/__tests__/call-images-modal.test.tsx +++ b/src/components/calls/__tests__/call-images-modal.test.tsx @@ -354,10 +354,8 @@ describe('CallImagesModal', () => { speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), setMapLocked: jest.fn(), }) : { latitude: 40.7128, @@ -367,10 +365,8 @@ describe('CallImagesModal', () => { speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), setMapLocked: jest.fn(), }); mockUseAnalytics.mockReturnValue({ @@ -1064,10 +1060,8 @@ describe('CallImagesModal', () => { speed: null, altitude: null, timestamp: Date.now(), - isBackgroundEnabled: false, isMapLocked: false, setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), setMapLocked: jest.fn(), }; @@ -1092,10 +1086,8 @@ describe('CallImagesModal', () => { speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), setMapLocked: jest.fn(), }; @@ -1120,10 +1112,8 @@ describe('CallImagesModal', () => { speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), setMapLocked: jest.fn(), }; diff --git a/src/components/settings/background-geolocation-item.tsx b/src/components/settings/background-geolocation-item.tsx deleted file mode 100644 index 4786403..0000000 --- a/src/components/settings/background-geolocation-item.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { MapPin } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useBackgroundGeolocation } from '@/lib/hooks/use-background-geolocation'; -import { locationService } from '@/services/location'; -import { useLocationStore } from '@/stores/app/location-store'; - -import { Alert, AlertIcon, AlertText } from '../ui/alert'; -import { Switch } from '../ui/switch'; -import { Text } from '../ui/text'; -import { View } from '../ui/view'; -import { VStack } from '../ui/vstack'; - -export const BackgroundGeolocationItem = () => { - const { isBackgroundGeolocationEnabled, setBackgroundGeolocationEnabled } = useBackgroundGeolocation(); - const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); - const setLocationBackgroundEnabled = useLocationStore((state) => state.setBackgroundEnabled); - - const handleToggle = React.useCallback( - async (value: boolean) => { - try { - await setBackgroundGeolocationEnabled(value); - - // Update the location store state - setLocationBackgroundEnabled(value); - - // Start or stop background location updates based on the value - if (value) { - await locationService.startBackgroundUpdates(); - } else { - await locationService.stopBackgroundUpdates(); - } - } catch (error) { - console.error('Failed to toggle background geolocation:', error); - } - }, - [setBackgroundGeolocationEnabled, setLocationBackgroundEnabled] - ); - - return ( - - - - {t('settings.background_geolocation')} - - - - - - - {isBackgroundGeolocationEnabled && ( - - - - {t('settings.background_geolocation_warning')} - - - )} - - ); -}; diff --git a/src/lib/hooks/__tests__/use-background-geolocation.test.ts b/src/lib/hooks/__tests__/use-background-geolocation.test.ts deleted file mode 100644 index c4671bf..0000000 --- a/src/lib/hooks/__tests__/use-background-geolocation.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -describe('useBackgroundGeolocation', () => { - it('should be importable', () => { - // This test simply validates that the module can be loaded - // More complex tests would require extensive mocking setup - expect(true).toBe(true); - }); -}); diff --git a/src/lib/hooks/index.tsx b/src/lib/hooks/index.tsx index 9aca520..d7c7ce8 100644 --- a/src/lib/hooks/index.tsx +++ b/src/lib/hooks/index.tsx @@ -1,4 +1,3 @@ -export * from './use-background-geolocation'; export * from './use-keep-alive'; export * from './use-preferred-bluetooth-device'; export * from './use-selected-theme'; diff --git a/src/lib/hooks/use-background-geolocation.ts b/src/lib/hooks/use-background-geolocation.ts deleted file mode 100644 index 7666295..0000000 --- a/src/lib/hooks/use-background-geolocation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { useMMKVBoolean } from 'react-native-mmkv'; - -import { logger } from '../logging'; -import { storage } from '../storage'; -import { getBackgroundGeolocationStorageKey, saveBackgroundGeolocationState } from '../storage/background-geolocation'; - -// Define a type for the location service update function -type LocationServiceUpdater = (enabled: boolean) => Promise; - -// Global variable to hold the location service update function -let locationServiceUpdater: LocationServiceUpdater | null = null; - -/** - * Register the location service updater function - * This should be called from the location service to register its update function - */ -export const registerLocationServiceUpdater = (updater: LocationServiceUpdater) => { - locationServiceUpdater = updater; -}; - -/** - * Hook for managing background geolocation functionality - * This hook will return the background geolocation state which is stored in MMKV - * When enabled, location tracking will continue when the app is backgrounded - */ -export const useBackgroundGeolocation = () => { - const [backgroundGeolocationEnabled, _setBackgroundGeolocationEnabled] = useMMKVBoolean(getBackgroundGeolocationStorageKey(), storage); - - const setBackgroundGeolocationEnabled = React.useCallback( - async (enabled: boolean) => { - try { - _setBackgroundGeolocationEnabled(enabled); - saveBackgroundGeolocationState(enabled); - - // Update the location service if the updater is registered - if (locationServiceUpdater) { - await locationServiceUpdater(enabled); - } - - logger.info({ - message: `Background geolocation ${enabled ? 'enabled' : 'disabled'}`, - context: { enabled }, - }); - } catch (error) { - logger.error({ - message: 'Failed to update background geolocation state', - context: { error, enabled }, - }); - throw error; - } - }, - [_setBackgroundGeolocationEnabled] - ); - - const isBackgroundGeolocationEnabled = backgroundGeolocationEnabled ?? false; - return { isBackgroundGeolocationEnabled, setBackgroundGeolocationEnabled } as const; -}; diff --git a/src/lib/storage/__tests__/legacy-keys.test.ts b/src/lib/storage/__tests__/legacy-keys.test.ts new file mode 100644 index 0000000..812786d --- /dev/null +++ b/src/lib/storage/__tests__/legacy-keys.test.ts @@ -0,0 +1,75 @@ +const mockStorage = { + contains: jest.fn(), + delete: jest.fn(), +}; + +jest.mock('../index', () => ({ + get storage() { + return mockStorage; + }, +})); + +jest.mock('../../logging', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { logger } from '../../logging'; +import { removeLegacyStorageKeys } from '../legacy-keys'; + +const mockLogger = logger as jest.Mocked; + +describe('removeLegacyStorageKeys', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStorage.contains.mockReturnValue(false); + }); + + it('deletes the background geolocation key left behind by upgraded installs', () => { + mockStorage.contains.mockImplementation((key: string) => key === 'BACKGROUND_GEOLOCATION_ENABLED'); + + removeLegacyStorageKeys(); + + expect(mockStorage.delete).toHaveBeenCalledWith('BACKGROUND_GEOLOCATION_ENABLED'); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Removed legacy storage written by features that no longer exist', + context: { removed: ['BACKGROUND_GEOLOCATION_ENABLED'] }, + }); + }); + + it('is a no-op on a fresh install', () => { + removeLegacyStorageKeys(); + + expect(mockStorage.delete).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('is idempotent across startups', () => { + let present = true; + mockStorage.contains.mockImplementation((key: string) => present && key === 'BACKGROUND_GEOLOCATION_ENABLED'); + mockStorage.delete.mockImplementation(() => { + present = false; + }); + + removeLegacyStorageKeys(); + removeLegacyStorageKeys(); + + expect(mockStorage.delete).toHaveBeenCalledTimes(1); + }); + + it('logs and swallows storage failures so startup is never blocked', () => { + const error = new Error('MMKV unavailable'); + mockStorage.contains.mockImplementation(() => { + throw error; + }); + + expect(() => removeLegacyStorageKeys()).not.toThrow(); + expect(mockLogger.error).toHaveBeenCalledWith({ + message: 'Failed to remove legacy storage keys', + context: { error }, + }); + }); +}); diff --git a/src/lib/storage/background-geolocation.ts b/src/lib/storage/background-geolocation.ts deleted file mode 100644 index 7644b56..0000000 --- a/src/lib/storage/background-geolocation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Platform } from 'react-native'; -import { MMKV } from 'react-native-mmkv'; - -import { logger } from '../logging'; - -const BACKGROUND_GEOLOCATION_ENABLED = 'BACKGROUND_GEOLOCATION_ENABLED'; - -// Create storage instance to avoid circular dependency -const storage = Platform.OS === 'web' ? new MMKV({ id: 'ResgridIC' }) : new MMKV({ id: 'ResgridIC', encryptionKey: 'hunter2' }); - -/** - * Load background geolocation state from MMKV storage - * This function is used in the location service to avoid circular dependencies - */ -export const loadBackgroundGeolocationState = async (): Promise => { - try { - const backgroundGeolocationEnabled = storage.getBoolean(BACKGROUND_GEOLOCATION_ENABLED); - logger.info({ - message: 'Background geolocation state loaded on startup', - context: { enabled: backgroundGeolocationEnabled }, - }); - return backgroundGeolocationEnabled ?? false; - } catch (error) { - logger.error({ - message: 'Failed to load background geolocation state on startup', - context: { error }, - }); - return false; - } -}; - -/** - * Save background geolocation state to MMKV storage - */ -export const saveBackgroundGeolocationState = (enabled: boolean): void => { - storage.set(BACKGROUND_GEOLOCATION_ENABLED, enabled); -}; - -/** - * Get the storage key for background geolocation - */ -export const getBackgroundGeolocationStorageKey = (): string => { - return BACKGROUND_GEOLOCATION_ENABLED; -}; diff --git a/src/lib/storage/legacy-keys.ts b/src/lib/storage/legacy-keys.ts new file mode 100644 index 0000000..9a9b257 --- /dev/null +++ b/src/lib/storage/legacy-keys.ts @@ -0,0 +1,45 @@ +import { logger } from '../logging'; +import { storage } from './index'; + +/** + * Keys written by features that no longer exist. + * + * MMKV never expires anything, so a removed feature leaves its value behind on every + * device that ever ran a build that had it. Sweep them once on startup so an upgraded + * install ends up with the same on-disk state as a fresh one. + * + * `BACKGROUND_GEOLOCATION_ENABLED` — background location was removed from the app (Play + * policy: the IC app has no declarable background-location feature). Nothing reads this + * key any more. + * + * Persisted zustand blobs are NOT swept here: a store rehydrates when its module is + * imported, which happens before this runs. Stale fields inside a blob belong in that + * store's own persist `migrate` (see src/stores/app/location-store.ts). + */ +const LEGACY_KEYS = ['BACKGROUND_GEOLOCATION_ENABLED'] as const; + +/** + * Delete storage written by features that have since been removed. Safe to call on every + * startup: it is a no-op once the keys are gone. + */ +export const removeLegacyStorageKeys = (): void => { + try { + const removed = LEGACY_KEYS.filter((key) => storage.contains(key)); + + if (removed.length === 0) { + return; + } + + removed.forEach((key) => storage.delete(key)); + + logger.info({ + message: 'Removed legacy storage written by features that no longer exist', + context: { removed }, + }); + } catch (error) { + logger.error({ + message: 'Failed to remove legacy storage keys', + context: { error }, + }); + } +}; diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts deleted file mode 100644 index e7c27b3..0000000 --- a/src/services/__tests__/location-foreground-permissions.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * Tests for location service working with foreground-only permissions - * - * This test suite specifically covers the scenario where: - * - Foreground location permissions are granted - * - Background location permissions are denied - * - The app should still be able to track location in the foreground - * - * These tests were created to fix the issue where the app was failing to - * start location tracking when background permissions were denied, even - * though foreground permissions were granted. - */ - -// Mock all dependencies first -jest.mock('@/api/units/unitLocation', () => ({ - setUnitLocation: jest.fn(), -})); - -jest.mock('@/lib/hooks/use-background-geolocation', () => ({ - registerLocationServiceUpdater: jest.fn(), -})); - -jest.mock('@/lib/logging', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('@/lib/storage/background-geolocation', () => ({ - loadBackgroundGeolocationState: jest.fn(), -})); - -// Create mock store states -const mockCoreStoreState = { - activeUnitId: 'unit-123' as string | null, -}; - -const mockLocationStoreState = { - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), -}; - -// Mock stores with proper Zustand structure -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: { - getState: jest.fn(() => mockCoreStoreState), - }, -})); - -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: { - getState: jest.fn(() => mockLocationStoreState), - }, -})); - -jest.mock('expo-location', () => { - const mockRequestForegroundPermissions = jest.fn(); - const mockRequestBackgroundPermissions = jest.fn(); - const mockGetBackgroundPermissions = jest.fn(); - const mockWatchPositionAsync = jest.fn(); - const mockStartLocationUpdatesAsync = jest.fn(); - const mockStopLocationUpdatesAsync = jest.fn(); - return { - requestForegroundPermissionsAsync: mockRequestForegroundPermissions, - requestBackgroundPermissionsAsync: mockRequestBackgroundPermissions, - getBackgroundPermissionsAsync: mockGetBackgroundPermissions, - watchPositionAsync: mockWatchPositionAsync, - startLocationUpdatesAsync: mockStartLocationUpdatesAsync, - stopLocationUpdatesAsync: mockStopLocationUpdatesAsync, - Accuracy: { - Balanced: 'balanced', - }, - }; -}); - -jest.mock('expo-task-manager', () => ({ - defineTask: jest.fn(), - isTaskRegisteredAsync: jest.fn(), -})); - -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(() => ({ - remove: jest.fn(), - })), - currentState: 'active', - }, - Platform: { - OS: 'ios', - select: jest.fn((obj: any) => obj.ios || obj.default), - Version: 14, - }, -})); - -import * as Location from 'expo-location'; -import * as TaskManager from 'expo-task-manager'; - -import { setUnitLocation } from '@/api/units/unitLocation'; -import { logger } from '@/lib/logging'; -import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; -import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; - -// Import the service after mocks are set up -let locationService: any; - -// Mock types -const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; -const mockLogger = logger as jest.Mocked; -const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction; -const mockTaskManager = TaskManager as jest.Mocked; -const mockLocation = Location as jest.Mocked; - -// Mock location data -const mockLocationObject: Location.LocationObject = { - coords: { - latitude: 37.7749, - longitude: -122.4194, - altitude: 10.5, - accuracy: 5.0, - altitudeAccuracy: 2.0, - heading: 90.0, - speed: 15.5, - }, - timestamp: Date.now(), -}; - -// Mock API response -const mockApiResponse = { - Id: 'location-12345', - PageSize: 0, - Timestamp: '', - Version: '', - Node: '', - RequestId: '', - Status: '', - Environment: '', -}; - -describe('LocationService - Foreground-Only Permissions', () => { - let mockLocationSubscription: jest.Mocked; - - beforeAll(() => { - // Import the service after all mocks are set up - const { locationService: service } = require('../location'); - locationService = service; - }); - - beforeEach(() => { - // Clear all mock call history - jest.clearAllMocks(); - - // Reset mock functions in store states - mockLocationStoreState.setLocation = jest.fn(); - mockLocationStoreState.setBackgroundEnabled = jest.fn(); - - // Setup mock location subscription - mockLocationSubscription = { - remove: jest.fn(), - } as jest.Mocked; - - // Setup Location API mocks for the EXACT scenario from the user's logs: - // Foreground: granted, Background: denied - mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); - mockLocation.startLocationUpdatesAsync.mockResolvedValue(); - mockLocation.stopLocationUpdatesAsync.mockResolvedValue(); - - // Setup TaskManager mocks - mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); - - // Setup storage mock - mockLoadBackgroundGeolocationState.mockResolvedValue(false); - - // Setup API mock - mockSetUnitLocation.mockResolvedValue(mockApiResponse); - - // Reset core store state - mockCoreStoreState.activeUnitId = 'unit-123'; - - // Reset internal state of the service - (locationService as any).locationSubscription = null; - (locationService as any).backgroundSubscription = null; - (locationService as any).isBackgroundGeolocationEnabled = false; - }); - - describe('User Reported Bug Scenario', () => { - it('should allow location tracking when only foreground permissions are requested', async () => { - // This tests the fix for the user's bug: - // Only request foreground permissions, don't prompt for background unnecessarily - - const hasPermissions = await locationService.requestPermissions(); - - // Should return true because foreground is granted - expect(hasPermissions).toBe(true); - - // Should be able to start location updates without throwing - await expect(locationService.startLocationUpdates()).resolves.not.toThrow(); - - // Verify foreground location tracking is started - expect(mockLocation.watchPositionAsync).toHaveBeenCalledWith( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - }, - expect.any(Function) - ); - - // Verify the correct log message with permission details - now only foreground is requested - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Location permissions requested', - context: { - foregroundStatus: 'granted', - backgroundStatus: 'not requested', - backgroundRequested: false, - }, - }); - }); - - it('should log the exact error from user logs when permission check was wrong', async () => { - // Mock the old incorrect behavior where both permissions were required - const mockOldPermissionCheck = jest.fn().mockResolvedValue(false); // Old behavior - - if (mockOldPermissionCheck.mock.calls.length === 0) { - // Call it to simulate the old logic - const foregroundGranted = true; - const backgroundGranted = false; - const oldResult = foregroundGranted && backgroundGranted; // This was the bug - mockOldPermissionCheck.mockReturnValue(oldResult); - const result = mockOldPermissionCheck(); - - expect(result).toBe(false); // This would have caused the error - } - - // With our fix, the permission check should now pass - const hasPermissions = await locationService.requestPermissions(); - expect(hasPermissions).toBe(true); - }); - - it('should work with background setting enabled but permissions denied', async () => { - // User has background geolocation enabled in settings but system permissions denied - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - - await locationService.startLocationUpdates(); - - // Should start foreground tracking - expect(mockLocation.watchPositionAsync).toHaveBeenCalled(); - - // Should warn about background limitations - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Background geolocation enabled but permissions denied, running in foreground-only mode', - context: { - backgroundStatus: 'denied', - settingEnabled: true, - }, - }); - - // Should NOT register background task - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - - // Should log successful foreground start with proper context - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Foreground location updates started', - context: { - backgroundEnabled: false, // Background is disabled due to permissions - backgroundPermissions: false, - backgroundSetting: true, - }, - }); - }); - - it('should handle location updates in foreground-only mode', async () => { - await locationService.startLocationUpdates(); - - // Simulate a location update - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - // Should update the store - expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - - // IC app never reports unit AVL — location stays local - expect(mockSetUnitLocation).not.toHaveBeenCalled(); - - // Should log the location update - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Foreground location update received', - context: { - latitude: mockLocationObject.coords.latitude, - longitude: mockLocationObject.coords.longitude, - heading: mockLocationObject.coords.heading, - }, - }); - }); - - it('should gracefully handle attempt to enable background when permissions denied', async () => { - // User tries to enable background geolocation but permissions are denied - await locationService.updateBackgroundGeolocationSetting(true); - - // Should log warning - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Cannot enable background geolocation: background permissions not granted', - context: { backgroundStatus: 'denied' }, - }); - - // Should not register background task - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - }); - - describe('Comprehensive Permission Scenarios', () => { - it('should work with foreground granted, background denied', async () => { - // This is the user's scenario - should work - const hasPermissions = await locationService.requestPermissions(); - expect(hasPermissions).toBe(true); - - await expect(locationService.startLocationUpdates()).resolves.not.toThrow(); - }); - - it('should work with both foreground and background granted', async () => { - // Mock both permissions as granted - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - - const hasPermissions = await locationService.requestPermissions(); - expect(hasPermissions).toBe(true); - - await expect(locationService.startLocationUpdates()).resolves.not.toThrow(); - }); - - it('should fail when foreground is denied (regardless of background)', async () => { - // Mock foreground as denied - mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - const hasPermissions = await locationService.requestPermissions(); - expect(hasPermissions).toBe(false); - - await expect(locationService.startLocationUpdates()).rejects.toThrow('Location permissions not granted'); - }); - }); - - describe('Background Task Management', () => { - it('should not register background task when background permissions denied', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); // Setting enabled - - await locationService.startLocationUpdates(); - - // Should not register background task due to missing permissions - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - - it('should register background task when both setting and permissions are enabled', async () => { - // Enable background in settings - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - - // Grant background permissions - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - - await locationService.startLocationUpdates(); - - // Should register background task - expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith( - 'location-updates', - expect.objectContaining({ - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - }) - ); - }); - }); -}); diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 1e8a434..2871df6 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -1,10 +1,4 @@ // Mock all dependencies first -jest.mock('@/api/units/unitLocation', () => ({ - setUnitLocation: jest.fn(), -})); -jest.mock('@/lib/hooks/use-background-geolocation', () => ({ - registerLocationServiceUpdater: jest.fn(), -})); jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), @@ -12,58 +6,23 @@ jest.mock('@/lib/logging', () => ({ error: jest.fn(), }, })); -jest.mock('@/lib/storage/background-geolocation', () => ({ - loadBackgroundGeolocationState: jest.fn(), -})); - -// Create mock store states -const mockCoreStoreState = { - activeUnitId: 'unit-123' as string | null, -}; const mockLocationStoreState = { setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), }; -// Mock stores with proper Zustand structure -jest.mock('@/stores/app/core-store', () => ({ - useCoreStore: { - getState: jest.fn(() => mockCoreStoreState), - }, -})); - jest.mock('@/stores/app/location-store', () => ({ useLocationStore: { getState: jest.fn(() => mockLocationStoreState), }, })); -jest.mock('expo-location', () => { - const mockRequestForegroundPermissions = jest.fn(); - const mockRequestBackgroundPermissions = jest.fn(); - const mockGetBackgroundPermissions = jest.fn(); - const mockWatchPositionAsync = jest.fn(); - const mockStartLocationUpdatesAsync = jest.fn(); - const mockStopLocationUpdatesAsync = jest.fn(); - return { - requestForegroundPermissionsAsync: mockRequestForegroundPermissions, - requestBackgroundPermissionsAsync: mockRequestBackgroundPermissions, - getBackgroundPermissionsAsync: mockGetBackgroundPermissions, - watchPositionAsync: mockWatchPositionAsync, - startLocationUpdatesAsync: mockStartLocationUpdatesAsync, - stopLocationUpdatesAsync: mockStopLocationUpdatesAsync, - Accuracy: { - Balanced: 'balanced', - }, - }; -}); - -// TaskManager mocks are now handled in the jest.mock() call - -jest.mock('expo-task-manager', () => ({ - defineTask: jest.fn(), - isTaskRegisteredAsync: jest.fn(), +jest.mock('expo-location', () => ({ + requestForegroundPermissionsAsync: jest.fn(), + watchPositionAsync: jest.fn(), + Accuracy: { + Balanced: 'balanced', + }, })); jest.mock('react-native', () => ({ @@ -80,28 +39,17 @@ jest.mock('react-native', () => ({ })); import * as Location from 'expo-location'; -import * as TaskManager from 'expo-task-manager'; import { AppState } from 'react-native'; -import { setUnitLocation } from '@/api/units/unitLocation'; -import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; import { logger } from '@/lib/logging'; -import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; -import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; // Import the service after mocks are set up let locationService: any; -// Mock types -const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; -const mockRegisterLocationServiceUpdater = registerLocationServiceUpdater as jest.MockedFunction; const mockLogger = logger as jest.Mocked; -const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction; -const mockTaskManager = TaskManager as jest.Mocked; const mockAppState = AppState as jest.Mocked; const mockLocation = Location as jest.Mocked; -// Mock location data const mockLocationObject: Location.LocationObject = { coords: { latitude: 37.7749, @@ -112,46 +60,26 @@ const mockLocationObject: Location.LocationObject = { heading: 90.0, speed: 15.5, }, - timestamp: Date.now(), -}; - -// Mock API response -const mockApiResponse = { - Id: 'location-12345', - PageSize: 0, - Timestamp: '', - Version: '', - Node: '', - RequestId: '', - Status: '', - Environment: '', + timestamp: 1700000000000, }; describe('LocationService', () => { let mockLocationSubscription: jest.Mocked; beforeAll(() => { - // Import the service after all mocks are set up const { locationService: service } = require('../location'); locationService = service; }); beforeEach(() => { - // Clear all mock call history jest.clearAllMocks(); - // Reset mock functions in store states - recreate the mock functions mockLocationStoreState.setLocation = jest.fn(); - mockLocationStoreState.setBackgroundEnabled = jest.fn(); - - // Clear the mock subscription - handled in the mock itself - // Setup mock location subscription mockLocationSubscription = { remove: jest.fn(), } as jest.Mocked; - // Setup Location API mocks mockLocation.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'granted' as any, expires: 'never', @@ -159,40 +87,13 @@ describe('LocationService', () => { canAskAgain: true, }); - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'granted' as any, - expires: 'never', - granted: true, - canAskAgain: true, - }); - mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); - mockLocation.startLocationUpdatesAsync.mockResolvedValue(); - mockLocation.stopLocationUpdatesAsync.mockResolvedValue(); - - // Setup TaskManager mocks - mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); - - // Setup storage mock - mockLoadBackgroundGeolocationState.mockResolvedValue(false); - - // Setup API mock - mockSetUnitLocation.mockResolvedValue(mockApiResponse); - // Reset core store state - mockCoreStoreState.activeUnitId = 'unit-123'; + (AppState as any).currentState = 'active'; // Reset internal state of the service (locationService as any).locationSubscription = null; - (locationService as any).backgroundSubscription = null; - (locationService as any).isBackgroundGeolocationEnabled = false; + (locationService as any).isTrackingRequested = false; }); describe('Singleton Pattern', () => { @@ -205,20 +106,13 @@ describe('LocationService', () => { }); describe('Permission Requests', () => { - it('should only request foreground permissions by default', async () => { + it('should only request foreground permissions', async () => { const result = await locationService.requestPermissions(); expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalled(); - expect(mockLocation.requestBackgroundPermissionsAsync).not.toHaveBeenCalled(); - expect(result).toBe(true); - }); - - it('should request background permissions when explicitly requested', async () => { - const result = await locationService.requestPermissions(true); - - expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalled(); - expect(mockLocation.requestBackgroundPermissionsAsync).toHaveBeenCalled(); expect(result).toBe(true); + // Background location was removed from the app — the service must not reach for the API at all + expect((mockLocation as any).requestBackgroundPermissionsAsync).toBeUndefined(); }); it('should return false if foreground permission is denied', async () => { @@ -233,48 +127,12 @@ describe('LocationService', () => { expect(result).toBe(false); }); - it('should return true if foreground is granted but background is denied', async () => { - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - const result = await locationService.requestPermissions(); - expect(result).toBe(true); // Should still work with just foreground permissions - }); - - it('should log permission status for foreground-only requests', async () => { + it('should log the foreground permission status', async () => { await locationService.requestPermissions(); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Location permissions requested', - context: { - foregroundStatus: 'granted', - backgroundStatus: 'not requested', - backgroundRequested: false, - }, - }); - }); - - it('should log permission status when background is requested and denied', async () => { - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - await locationService.requestPermissions(true); - - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Location permissions requested', - context: { - foregroundStatus: 'granted', - backgroundStatus: 'denied', - backgroundRequested: true, - }, + context: { foregroundStatus: 'granted' }, }); }); }); @@ -294,52 +152,6 @@ describe('LocationService', () => { expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Foreground location updates started', - context: { - backgroundEnabled: false, - backgroundPermissions: true, - backgroundSetting: false, - }, - }); - }); - - it('should start foreground updates even when background permissions are denied', async () => { - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - await locationService.startLocationUpdates(); - - expect(mockLocation.watchPositionAsync).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Foreground location updates started', - context: { - backgroundEnabled: false, - backgroundPermissions: false, - backgroundSetting: false, - }, - }); - }); - - it('should warn when background geolocation is enabled but permissions denied', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - await locationService.startLocationUpdates(); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Background geolocation enabled but permissions denied, running in foreground-only mode', - context: { - backgroundStatus: 'denied', - settingEnabled: true, - }, }); }); @@ -354,63 +166,23 @@ describe('LocationService', () => { await expect(locationService.startLocationUpdates()).rejects.toThrow('Location permissions not granted'); }); - it('should register background task if background geolocation is enabled and permissions granted', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - + it('should not create a duplicate subscription when already watching', async () => { + await locationService.startLocationUpdates(); await locationService.startLocationUpdates(); - expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith('location-updates', { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - foregroundService: { - notificationTitle: 'Location Tracking', - notificationBody: 'Tracking your location in the background', - }, - }); - + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Foreground location updates started', - context: { - backgroundEnabled: true, - backgroundPermissions: true, - backgroundSetting: true, - }, + message: 'Foreground location subscription already active, skipping duplicate subscription', }); }); - it('should not register background task if background permissions are denied', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - await locationService.startLocationUpdates(); - - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - - it('should not register background task if already registered', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); - - await locationService.startLocationUpdates(); - - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - - it('should handle location updates and store them locally without calling the unit AVL API', async () => { + it('should store location updates locally', async () => { await locationService.startLocationUpdates(); - // Get the callback function passed to watchPositionAsync const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; await locationCallback(mockLocationObject); expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Foreground location update received', context: { @@ -422,180 +194,69 @@ describe('LocationService', () => { }); }); - describe('Background Location Updates', () => { - beforeEach(() => { - // Set background geolocation enabled for these tests - (locationService as any).isBackgroundGeolocationEnabled = true; - }); + describe('App State Handling', () => { + const emitAppState = async (state: string) => { + const handler = (locationService as any).handleAppStateChange; + await handler(state); + }; - it('should start background updates when not already active', async () => { - await locationService.startBackgroundUpdates(); + it('should drop the subscription when the app is backgrounded', async () => { + await locationService.startLocationUpdates(); - expect(mockLocation.watchPositionAsync).toHaveBeenCalledWith( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 60000, - distanceInterval: 20, - }, - expect.any(Function) - ); + await emitAppState('background'); - expect(mockLocationStoreState.setBackgroundEnabled).toHaveBeenCalledWith(true); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Starting background location updates', - }); + expect(mockLocationSubscription.remove).toHaveBeenCalled(); + expect((locationService as any).locationSubscription).toBeNull(); }); - it('should not start background updates if already active', async () => { - (locationService as any).backgroundSubscription = mockLocationSubscription; + it('should resume foreground tracking when the app becomes active again', async () => { + await locationService.startLocationUpdates(); + await emitAppState('background'); - await locationService.startBackgroundUpdates(); + await emitAppState('active'); - expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(2); }); - it('should not start background updates if disabled', async () => { - (locationService as any).isBackgroundGeolocationEnabled = false; - - await locationService.startBackgroundUpdates(); + it('should not start tracking on activation when tracking was never requested', async () => { + await emitAppState('active'); expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); }); - it('should stop background updates correctly', async () => { - (locationService as any).backgroundSubscription = mockLocationSubscription; - - await locationService.stopBackgroundUpdates(); - - expect(mockLocationSubscription.remove).toHaveBeenCalled(); - expect(mockLocationStoreState.setBackgroundEnabled).toHaveBeenCalledWith(false); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Stopping background location updates', - }); - }); - - it('should handle background location updates and store them locally without calling the unit AVL API', async () => { - await locationService.startBackgroundUpdates(); - - // Get the callback function - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).not.toHaveBeenCalled(); - }); - - it('should catch and log rejected background location API sends', async () => { - await locationService.startBackgroundUpdates(); - - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1]; - const catchSpy = jest.spyOn(Promise.prototype, 'catch'); - - locationCallback(mockLocationObject); - - const rejectionHandler = catchSpy.mock.calls[0]?.[0]; - catchSpy.mockRestore(); + it('should log and swallow errors raised while handling app state changes', async () => { + const error = new Error('Location subscription failed'); + (locationService as any).isTrackingRequested = true; + mockLocation.watchPositionAsync.mockRejectedValue(error); - const networkError = new Error('Location API request failed'); - expect(rejectionHandler).toEqual(expect.any(Function)); - rejectionHandler?.(networkError); + await expect(emitAppState('active')).resolves.toBeUndefined(); expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Failed to send background location update to API', - context: { error: networkError }, + message: 'Location service failed to handle app state change', + context: { error, nextAppState: 'active' }, }); }); - }); - - describe('API Integration', () => { - it('should never send location to the unit AVL API (IC app has no unit context)', async () => { - await locationService.startLocationUpdates(); - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockSetUnitLocation).not.toHaveBeenCalled(); - }); - }); - - describe('Background Geolocation Setting Updates', () => { - it('should enable background tracking and register task when permissions are granted', async () => { - await locationService.updateBackgroundGeolocationSetting(true); - - expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith( - 'location-updates', - expect.objectContaining({ - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - }) - ); - }); - - it('should warn and not register task when background permissions are denied', async () => { - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - - await locationService.updateBackgroundGeolocationSetting(true); - - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Cannot enable background geolocation: background permissions not granted', - context: { backgroundStatus: 'denied' }, - }); - }); - - it('should disable background tracking and unregister task', async () => { - mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); - - await locationService.updateBackgroundGeolocationSetting(false); - - expect(mockLocation.stopLocationUpdatesAsync).toHaveBeenCalledWith('location-updates'); - }); - it('should start background updates if app is backgrounded when enabled', async () => { - (AppState as any).currentState = 'background'; - const startBackgroundUpdatesSpy = jest.spyOn(locationService, 'startBackgroundUpdates'); - - await locationService.updateBackgroundGeolocationSetting(true); - - expect(startBackgroundUpdatesSpy).toHaveBeenCalled(); - }); - - it('should not start background updates if app is active when enabled', async () => { - (AppState as any).currentState = 'active'; - const startBackgroundUpdatesSpy = jest.spyOn(locationService, 'startBackgroundUpdates'); - - await locationService.updateBackgroundGeolocationSetting(true); - - expect(startBackgroundUpdatesSpy).not.toHaveBeenCalled(); + it('should register an app state listener on construction', () => { + expect(mockAppState.addEventListener).toBeDefined(); }); }); describe('Cleanup', () => { - it('should stop all location updates', async () => { - (locationService as any).locationSubscription = mockLocationSubscription; - (locationService as any).backgroundSubscription = mockLocationSubscription; - mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + it('should stop location updates and clear the tracking flag', async () => { + await locationService.startLocationUpdates(); await locationService.stopLocationUpdates(); - expect(mockLocationSubscription.remove).toHaveBeenCalledTimes(2); - expect(mockLocation.stopLocationUpdatesAsync).toHaveBeenCalledWith('location-updates'); + expect(mockLocationSubscription.remove).toHaveBeenCalledTimes(1); + expect((locationService as any).isTrackingRequested).toBe(false); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'All location updates stopped', }); }); - it('should cleanup app state subscription', () => { - locationService.cleanup(); - - // Note: The subscription's remove method is called, but we can't easily test it - // since the subscription is created dynamically inside the mock - expect(true).toBe(true); // This test passes if cleanup doesn't throw + it('should handle stop when no subscription exists', async () => { + await expect(locationService.stopLocationUpdates()).resolves.not.toThrow(); }); it('should handle cleanup when no subscription exists', () => { @@ -605,81 +266,6 @@ describe('LocationService', () => { }); }); - describe('Foreground-only Mode (Background Permissions Denied)', () => { - beforeEach(() => { - // Mock background permissions as denied for these tests - mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - mockLocation.requestBackgroundPermissionsAsync.mockResolvedValue({ - status: 'denied' as any, - expires: 'never', - granted: false, - canAskAgain: true, - }); - }); - - it('should allow location tracking with only foreground permissions', async () => { - const result = await locationService.requestPermissions(); - expect(result).toBe(true); - - await expect(locationService.startLocationUpdates()).resolves.not.toThrow(); - expect(mockLocation.watchPositionAsync).toHaveBeenCalled(); - }); - - it('should log correct permission status for foreground-only requests', async () => { - await locationService.requestPermissions(); - - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Location permissions requested', - context: { - foregroundStatus: 'granted', - backgroundStatus: 'not requested', - backgroundRequested: false, - }, - }); - }); - - it('should start foreground updates and warn about background limitations', async () => { - mockLoadBackgroundGeolocationState.mockResolvedValue(true); // User wants background but can't have it - - await locationService.startLocationUpdates(); - - expect(mockLocation.watchPositionAsync).toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Background geolocation enabled but permissions denied, running in foreground-only mode', - context: { - backgroundStatus: 'denied', - settingEnabled: true, - }, - }); - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - - it('should handle location updates in foreground-only mode', async () => { - await locationService.startLocationUpdates(); - - const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; - await locationCallback(mockLocationObject); - - expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); - expect(mockSetUnitLocation).not.toHaveBeenCalled(); - }); - - it('should not enable background geolocation when permissions are denied', async () => { - await locationService.updateBackgroundGeolocationSetting(true); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Cannot enable background geolocation: background permissions not granted', - context: { backgroundStatus: 'denied' }, - }); - expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); - }); - }); - describe('Error Handling', () => { it('should handle location subscription errors', async () => { const error = new Error('Location subscription failed'); @@ -687,13 +273,5 @@ describe('LocationService', () => { await expect(locationService.startLocationUpdates()).rejects.toThrow('Location subscription failed'); }); - - it('should handle background task registration errors', async () => { - const error = new Error('Task registration failed'); - mockLocation.startLocationUpdatesAsync.mockRejectedValue(error); - mockLoadBackgroundGeolocationState.mockResolvedValue(true); - - await expect(locationService.startLocationUpdates()).rejects.toThrow('Task registration failed'); - }); }); }); diff --git a/src/services/location.ts b/src/services/location.ts index 9b6e40f..098b5b8 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -1,65 +1,29 @@ import * as Location from 'expo-location'; -import * as TaskManager from 'expo-task-manager'; import { AppState, type AppStateStatus } from 'react-native'; -import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; import { logger } from '@/lib/logging'; import { isWeb } from '@/lib/platform'; -import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; import { useLocationStore } from '@/stores/app/location-store'; -const LOCATION_TASK_NAME = 'location-updates'; - // IC app has no unit context — location is only tracked locally (map centering, // distance calculations); it is never reported to the unit AVL API. +// +// Location is foreground-only by design: the app never requests background location +// permission and never registers an OS location task. Do not reintroduce +// expo-task-manager / Location.startLocationUpdatesAsync here — incident command runs +// with the app open, and background location has no feature that justifies it. const sendLocationToAPI = async (_location: Location.LocationObject): Promise => { // Intentionally a no-op for the IC app. }; -// Define the background task (native only — TaskManager is unsupported on web) -if (!isWeb) { - TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => { - if (error) { - logger.error({ - message: 'Location task error', - context: { error }, - }); - return; - } - if (data) { - const { locations } = data as { locations: Location.LocationObject[] }; - const location = locations[0]; - if (location) { - logger.info({ - message: 'Background location update received', - context: { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - heading: location.coords.heading, - }, - }); - - // Update local store - useLocationStore.getState().setLocation(location); - - // Send to API - await sendLocationToAPI(location); - } - } - }); -} - class LocationService { private static instance: LocationService; private locationSubscription: Location.LocationSubscription | null = null; - private backgroundSubscription: Location.LocationSubscription | null = null; private appStateSubscription: { remove: () => void } | null = null; - private isBackgroundGeolocationEnabled = false; + private isTrackingRequested = false; private constructor() { this.initializeAppStateListener(); - // Register this service's update function to avoid circular dependency - registerLocationServiceUpdater(this.updateBackgroundGeolocationSetting.bind(this)); } static getInstance(): LocationService { @@ -76,15 +40,16 @@ class LocationService { private handleAppStateChange = async (nextAppState: AppStateStatus): Promise => { logger.info({ message: 'Location service handling app state change', - context: { nextAppState, backgroundEnabled: this.isBackgroundGeolocationEnabled }, + context: { nextAppState, trackingRequested: this.isTrackingRequested }, }); // AppState event handlers don't handle promise rejections — catch everything try { - if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) { - await this.startBackgroundUpdates(); - } else if (nextAppState === 'active') { - await this.stopBackgroundUpdates(); + if (nextAppState === 'background') { + // Foreground-only tracking: drop the watcher while the app is backgrounded. + await this.removeSubscription(); + } else if (nextAppState === 'active' && this.isTrackingRequested) { + await this.startLocationUpdates(); } } catch (error) { logger.error({ @@ -94,31 +59,35 @@ class LocationService { } }; - async requestPermissions(requestBackground = false): Promise { - const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync(); + private async removeSubscription(): Promise { + if (!this.locationSubscription) { + return; + } - let backgroundStatus = 'undetermined'; - if (requestBackground) { - const result = await Location.requestBackgroundPermissionsAsync(); - backgroundStatus = result.status; + if (isWeb) { + // On web the subscription is our own shim wrapping clearWatch + (this.locationSubscription as unknown as { remove: () => void }).remove(); + } else { + await this.locationSubscription.remove(); } + this.locationSubscription = null; + } + + async requestPermissions(): Promise { + const { status: foregroundStatus } = await Location.requestForegroundPermissionsAsync(); logger.info({ message: 'Location permissions requested', - context: { - foregroundStatus, - backgroundStatus: requestBackground ? backgroundStatus : 'not requested', - backgroundRequested: requestBackground, - }, + context: { foregroundStatus }, }); - // Only require foreground permissions for basic functionality - // Background permissions are optional and will be handled separately return foregroundStatus === 'granted'; } async startLocationUpdates(): Promise { - // On web, use a lightweight browser geolocation watcher instead of expo-location/TaskManager + this.isTrackingRequested = true; + + // On web, use a lightweight browser geolocation watcher instead of expo-location if (isWeb) { if (!('geolocation' in navigator)) { logger.warn({ message: 'Geolocation API not available in this browser' }); @@ -160,49 +129,11 @@ class LocationService { return; } - // Load background geolocation setting first - this.isBackgroundGeolocationEnabled = await loadBackgroundGeolocationState(); - - // Only request background permissions if the user has enabled background geolocation - const hasPermissions = await this.requestPermissions(this.isBackgroundGeolocationEnabled); + const hasPermissions = await this.requestPermissions(); if (!hasPermissions) { throw new Error('Location permissions not granted'); } - // Check if we have background permissions for background tracking - const { status: backgroundStatus } = await Location.getBackgroundPermissionsAsync(); - const hasBackgroundPermissions = backgroundStatus === 'granted'; - - // Only register background task if both setting is enabled AND we have background permissions - const shouldEnableBackground = this.isBackgroundGeolocationEnabled && hasBackgroundPermissions; - - if (shouldEnableBackground) { - // Check if task is already registered for background updates - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (!isTaskRegistered) { - await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - foregroundService: { - notificationTitle: 'Location Tracking', - notificationBody: 'Tracking your location in the background', - }, - }); - logger.info({ - message: 'Background location task registered', - }); - } - } else if (this.isBackgroundGeolocationEnabled && !hasBackgroundPermissions) { - logger.warn({ - message: 'Background geolocation enabled but permissions denied, running in foreground-only mode', - context: { - backgroundStatus, - settingEnabled: this.isBackgroundGeolocationEnabled, - }, - }); - } - // Start foreground updates (idempotent - check if already subscribed) if (!this.locationSubscription) { this.locationSubscription = await Location.watchPositionAsync( @@ -237,173 +168,12 @@ class LocationService { logger.info({ message: 'Foreground location updates started', - context: { - backgroundEnabled: shouldEnableBackground, - backgroundPermissions: hasBackgroundPermissions, - backgroundSetting: this.isBackgroundGeolocationEnabled, - }, }); } - async startBackgroundUpdates(): Promise { - if (isWeb) return; // Background location not supported on web - if (this.backgroundSubscription || !this.isBackgroundGeolocationEnabled) { - return; - } - - // Check if OS-managed background task is already registered - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (isTaskRegistered) { - logger.info({ - message: 'OS-managed background location task is registered, skipping watchPositionAsync subscription', - }); - useLocationStore.getState().setBackgroundEnabled(true); - return; - } - - // Re-check background permission: starting updates while backgrounded - // without background permission throws - const { status: backgroundStatus } = await Location.getBackgroundPermissionsAsync(); - if (backgroundStatus !== 'granted') { - logger.warn({ - message: 'Skipping background location updates: background permission not granted', - context: { backgroundStatus }, - }); - return; - } - - logger.info({ - message: 'Starting background location updates', - }); - - try { - this.backgroundSubscription = await Location.watchPositionAsync( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 60000, - distanceInterval: 20, - }, - (location) => { - logger.info({ - message: 'Background location update received', - context: { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - heading: location.coords.heading, - }, - }); - useLocationStore.getState().setLocation(location); - void sendLocationToAPI(location).catch((error) => { - logger.error({ - message: 'Failed to send background location update to API', - context: { error }, - }); - }); - } - ); - - useLocationStore.getState().setBackgroundEnabled(true); - } catch (error) { - this.backgroundSubscription = null; - logger.error({ - message: 'Failed to start background location updates', - context: { error }, - }); - } - } - - async stopBackgroundUpdates(): Promise { - if (isWeb) return; - if (this.backgroundSubscription) { - logger.info({ - message: 'Stopping background location updates', - }); - await this.backgroundSubscription.remove(); - this.backgroundSubscription = null; - } - useLocationStore.getState().setBackgroundEnabled(false); - } - - async updateBackgroundGeolocationSetting(enabled: boolean): Promise { - if (isWeb) return; // Background geolocation not applicable on web - this.isBackgroundGeolocationEnabled = enabled; - - if (enabled) { - // Request background permissions when enabling background geolocation - const { status: backgroundStatus } = await Location.requestBackgroundPermissionsAsync(); - const hasBackgroundPermissions = backgroundStatus === 'granted'; - - if (!hasBackgroundPermissions) { - logger.warn({ - message: 'Cannot enable background geolocation: background permissions not granted', - context: { backgroundStatus }, - }); - return; - } - - // Register the task if not already registered - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (!isTaskRegistered) { - await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - foregroundService: { - notificationTitle: 'Location Tracking', - notificationBody: 'Tracking your location in the background', - }, - }); - logger.info({ - message: 'Background location task registered after setting change', - }); - } - - // Start background updates if app is currently backgrounded - if (AppState.currentState === 'background') { - // Check if OS-managed background task is already registered before starting watchPositionAsync - const isTaskRegisteredForWatch = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (isTaskRegisteredForWatch) { - logger.info({ - message: 'OS-managed background location task is registered, skipping watchPositionAsync subscription in updateBackgroundGeolocationSetting', - }); - useLocationStore.getState().setBackgroundEnabled(true); - } else { - await this.startBackgroundUpdates(); - } - } - } else { - // Stop background updates and unregister task - await this.stopBackgroundUpdates(); - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (isTaskRegistered) { - await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); - logger.info({ - message: 'Background location task unregistered after setting change', - }); - } - } - } - async stopLocationUpdates(): Promise { - if (this.locationSubscription) { - if (isWeb) { - // On web the subscription is our own shim wrapping clearWatch - (this.locationSubscription as any).remove(); - } else { - await this.locationSubscription.remove(); - } - this.locationSubscription = null; - } - - if (!isWeb) { - await this.stopBackgroundUpdates(); - - // Check if task is registered before stopping - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (isTaskRegistered) { - await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); - } - } + this.isTrackingRequested = false; + await this.removeSubscription(); logger.info({ message: 'All location updates stopped', diff --git a/src/stores/app/__tests__/location-store.test.ts b/src/stores/app/__tests__/location-store.test.ts index 7e1e981..24f387a 100644 --- a/src/stores/app/__tests__/location-store.test.ts +++ b/src/stores/app/__tests__/location-store.test.ts @@ -32,7 +32,6 @@ describe('useLocationStore', () => { speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, }); }); @@ -47,7 +46,6 @@ describe('useLocationStore', () => { expect(result.current.speed).toBeNull(); expect(result.current.altitude).toBeNull(); expect(result.current.timestamp).toBeNull(); - expect(result.current.isBackgroundEnabled).toBe(false); expect(result.current.isMapLocked).toBe(false); }); @@ -80,22 +78,6 @@ describe('useLocationStore', () => { expect(result.current.timestamp).toBe(1640995200000); }); - it('should set background enabled', () => { - const { result } = renderHook(() => useLocationStore()); - - act(() => { - result.current.setBackgroundEnabled(true); - }); - - expect(result.current.isBackgroundEnabled).toBe(true); - - act(() => { - result.current.setBackgroundEnabled(false); - }); - - expect(result.current.isBackgroundEnabled).toBe(false); - }); - describe('Map Lock Functionality', () => { it('should set map locked state', () => { const { result } = renderHook(() => useLocationStore()); @@ -157,11 +139,37 @@ describe('useLocationStore', () => { }); }); + // Background location was removed from the app; v0 blobs still carry its flag and + // persist's shallow merge would graft it back onto the store on every startup. + describe('persist migration', () => { + const migrate = (state: unknown, version: number) => useLocationStore.persist.getOptions().migrate?.(state, version); + + it('should strip the legacy background flag from a v0 blob', () => { + const migrated = migrate({ isBackgroundEnabled: true, isMapLocked: true }, 0) as Record; + + expect(migrated).not.toHaveProperty('isBackgroundEnabled'); + expect(migrated.isMapLocked).toBe(true); + }); + + it('should leave a current blob untouched', () => { + const state = { isMapLocked: true }; + + expect(migrate(state, 1)).toBe(state); + }); + + it('should handle a missing persisted state', () => { + expect(migrate(undefined, 0)).toEqual({}); + }); + + it('should declare the migration version', () => { + expect(useLocationStore.persist.getOptions().version).toBe(1); + }); + }); + it('should have all required methods', () => { const { result } = renderHook(() => useLocationStore()); expect(typeof result.current.setLocation).toBe('function'); - expect(typeof result.current.setBackgroundEnabled).toBe('function'); expect(typeof result.current.setMapLocked).toBe('function'); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index ddd29f8..72a01f1 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -702,7 +702,10 @@ export const useLiveKitStore = create((set, get) => ({ android: { channelId: 'notif', asForegroundService: true, - foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE], + // microphone: keeps mic capture legal while backgrounded (Android 14+). + // connectedDevice: covers external bluetooth PTT handsets driving the call. + // Playback of remote audio needs no FGS type — any running FGS keeps the process alive. + foregroundServiceTypes: [AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_MICROPHONE, AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE], smallIcon: 'ic_notification', }, }); diff --git a/src/stores/app/location-store.ts b/src/stores/app/location-store.ts index 6b40385..d1ddb0d 100644 --- a/src/stores/app/location-store.ts +++ b/src/stores/app/location-store.ts @@ -12,10 +12,8 @@ export interface LocationState { speed: number | null; altitude: number | null; timestamp: number | null; - isBackgroundEnabled: boolean; isMapLocked: boolean; setLocation: (location: Location.LocationObject) => void; - setBackgroundEnabled: (enabled: boolean) => void; setMapLocked: (locked: boolean) => void; } @@ -29,7 +27,6 @@ export const useLocationStore = create()( speed: null, altitude: null, timestamp: null, - isBackgroundEnabled: false, isMapLocked: false, // iOS ignores `timeInterval` on watchPositionAsync, so a stationary device still // delivers fixes many times a second. Writing every one of them notified every @@ -53,15 +50,25 @@ export const useLocationStore = create()( set({ latitude, longitude, heading, accuracy, speed, altitude, timestamp: location.timestamp }); }, - setBackgroundEnabled: (enabled) => set({ isBackgroundEnabled: enabled }), setMapLocked: (locked) => set({ isMapLocked: locked }), }), { name: 'location-storage', storage: createJSONStorage(() => zustandStorage), + // v1 dropped `isBackgroundEnabled`: background location was removed from the app. + // Without the migration, persist's shallow merge would graft the stale flag back + // onto the store on every upgraded install and keep rewriting it to disk. + version: 1, + migrate: (persistedState, version) => { + if (version >= 1) { + return persistedState as Partial; + } + + const { isBackgroundEnabled: _isBackgroundEnabled, ...rest } = (persistedState ?? {}) as Partial & { isBackgroundEnabled?: boolean }; + return rest; + }, partialize: (state) => ({ // Only persist user preferences, not rapidly-changing coordinates - isBackgroundEnabled: state.isBackgroundEnabled, isMapLocked: state.isMapLocked, }), } diff --git a/src/translations/ar.json b/src/translations/ar.json index bc9bfa1..848430e 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -1573,8 +1573,6 @@ "unavailable": "غير متوفر", "wired_device": "جهاز سلكي" }, - "background_geolocation": "تحديد الموقع الجغرافي في الخلفية", - "background_geolocation_warning": "تسمح هذه الميزة للتطبيق بتتبع موقعك في الخلفية. تساعد في تنسيق الاستجابة للطوارئ ولكن قد تؤثر على عمر البطارية.", "background_location": "الموقع في الخلفية", "contact_us": "اتصل بنا", "current_unit": "الوحدة الحالية", diff --git a/src/translations/de.json b/src/translations/de.json index 134619c..1f709f1 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -1573,8 +1573,6 @@ "unavailable": "Nicht verfügbar", "wired_device": "Kabelgebundenes Gerät" }, - "background_geolocation": "Hintergrund-Geolokalisierung", - "background_geolocation_warning": "Diese Funktion ermöglicht es der App, Ihren Standort im Hintergrund zu verfolgen. Sie hilft bei der Koordination von Notfalleinsätzen, kann jedoch die Akkulaufzeit beeinträchtigen.", "background_location": "Hintergrundstandort", "contact_us": "Kontaktieren Sie uns", "current_unit": "Aktuelle Einheit", diff --git a/src/translations/el.json b/src/translations/el.json index 08d8c53..0eaf1ec 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -1573,8 +1573,6 @@ "unavailable": "Μη διαθέσιμη", "wired_device": "Ενσύρματη Συσκευή" }, - "background_geolocation": "Γεωεντοπισμός στο Παρασκήνιο", - "background_geolocation_warning": "Αυτή η λειτουργία επιτρέπει στην εφαρμογή να παρακολουθεί την τοποθεσία σας στο παρασκήνιο. Βοηθά στον συντονισμό της ανταπόκρισης έκτακτης ανάγκης, αλλά μπορεί να επηρεάσει τη διάρκεια της μπαταρίας.", "background_location": "Τοποθεσία στο Παρασκήνιο", "contact_us": "Επικοινωνήστε Μαζί Μας", "current_unit": "Τρέχουσα Μονάδα", diff --git a/src/translations/en.json b/src/translations/en.json index ccd20eb..f28f7ef 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1573,8 +1573,6 @@ "unavailable": "Unavailable", "wired_device": "Wired Device" }, - "background_geolocation": "Background Geolocation", - "background_geolocation_warning": "This feature allows the app to track your location in the background. It helps with emergency response coordination but may impact battery life.", "background_location": "Background Location", "contact_us": "Contact Us", "current_unit": "Current Unit", diff --git a/src/translations/es.json b/src/translations/es.json index e573606..dbc9034 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -1573,8 +1573,6 @@ "unavailable": "No disponible", "wired_device": "Dispositivo con cable" }, - "background_geolocation": "Geolocalización en segundo plano", - "background_geolocation_warning": "Esta función permite que la aplicación rastree tu ubicación en segundo plano. Ayuda con la coordinación de respuesta de emergencia pero puede impactar la duración de la batería.", "background_location": "Ubicación en segundo plano", "contact_us": "Contáctanos", "current_unit": "Unidad Actual", diff --git a/src/translations/fr.json b/src/translations/fr.json index 413c162..fa3cc6d 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -1573,8 +1573,6 @@ "unavailable": "Indisponible", "wired_device": "Appareil filaire" }, - "background_geolocation": "Géolocalisation en arrière-plan", - "background_geolocation_warning": "Cette fonctionnalité permet à l'application de suivre votre localisation en arrière-plan. Elle aide à la coordination des interventions d'urgence mais peut affecter la durée de vie de la batterie.", "background_location": "Localisation en arrière-plan", "contact_us": "Nous contacter", "current_unit": "Unité actuelle", diff --git a/src/translations/it.json b/src/translations/it.json index de3085e..c730f24 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -1573,8 +1573,6 @@ "unavailable": "Non disponibile", "wired_device": "Dispositivo cablato" }, - "background_geolocation": "Geolocalizzazione in background", - "background_geolocation_warning": "Questa funzione consente all'app di tracciare la posizione in background. Aiuta il coordinamento delle risposte di emergenza ma può influire sulla durata della batteria.", "background_location": "Posizione in background", "contact_us": "Contattaci", "current_unit": "Unità corrente", diff --git a/src/translations/pl.json b/src/translations/pl.json index 8ea3614..65922f8 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -1573,8 +1573,6 @@ "unavailable": "Niedostępne", "wired_device": "Urządzenie przewodowe" }, - "background_geolocation": "Geolokalizacja w tle", - "background_geolocation_warning": "Ta funkcja pozwala aplikacji śledzić Twoją lokalizację w tle. Pomaga w koordynacji reagowania na sytuacje kryzysowe, ale może wpłynąć na żywotność baterii.", "background_location": "Lokalizacja w tle", "contact_us": "Skontaktuj się z nami", "current_unit": "Bieżąca jednostka", diff --git a/src/translations/sv.json b/src/translations/sv.json index 4d62161..29ffb03 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -1573,8 +1573,6 @@ "unavailable": "Ej tillgänglig", "wired_device": "Kabelbunden enhet" }, - "background_geolocation": "Bakgrundsgeolokalisering", - "background_geolocation_warning": "Den här funktionen gör att appen kan spåra din plats i bakgrunden. Det hjälper till med koordinering av akutinsatser men kan påverka batteritiden.", "background_location": "Bakgrundsplats", "contact_us": "Kontakta oss", "current_unit": "Aktuell enhet", diff --git a/src/translations/uk.json b/src/translations/uk.json index cff501b..12e58da 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -1573,8 +1573,6 @@ "unavailable": "Недоступно", "wired_device": "Дротовий пристрій" }, - "background_geolocation": "Геолокація у фоні", - "background_geolocation_warning": "Ця функція дозволяє додатку відстежувати ваше місцезнаходження у фоні. Вона допомагає координувати реагування на надзвичайні ситуації, але може впливати на термін служби батареї.", "background_location": "Місцезнаходження у фоні", "contact_us": "Зв'яжіться з нами", "current_unit": "Поточний підрозділ", diff --git a/yarn.lock b/yarn.lock index 7cfd471..41dd092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8650,13 +8650,6 @@ expo-system-ui@~56.0.5: "@react-native/normalize-colors" "0.85.3" debug "^4.3.2" -expo-task-manager@~56.0.26: - version "56.0.26" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-56.0.26.tgz#b94fe3b91ac39dede8f4e466cb8803e0138c4b1d" - integrity sha512-MKum9NzLthKmn+K7o586tNCkXjGvyoQxDxZniZ2x1+vlzt4Id42Wpl+zIxG4o0z7P56B/pdNbx7sATvccpVlLg== - dependencies: - unimodules-app-loader "~56.0.0" - expo-updates-interface@~56.0.1: version "56.0.2" resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-56.0.2.tgz#39ad3894535901773419a477cb478df95df9e38c" @@ -15633,11 +15626,6 @@ unicorn-magic@^0.1.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== -unimodules-app-loader@~56.0.0: - version "56.0.1" - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-56.0.1.tgz#443b9b17c8ca04733eb1f5572cf0c944024373b5" - integrity sha512-Z801jeBOQMUF/ExklxT1BqhEV/oF2/Bii7PFYAj/8Sauxl7oKvZbf70peRzzAU0mG7UQ3yU/UO/EpD1JyJ2WcA== - unique-filename@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-4.0.0.tgz#a06534d370e7c977a939cd1d11f7f0ab8f1fed13" From 9e01850f9d4aaa7eaa73797d3a40afb8a2372db9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 26 Aug 2026 13:20:44 -0700 Subject: [PATCH 2/2] RG-T133 PR#55 fixes --- src/services/location.ts | 47 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/services/location.ts b/src/services/location.ts index 098b5b8..7ac281a 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -21,6 +21,10 @@ class LocationService { private locationSubscription: Location.LocationSubscription | null = null; private appStateSubscription: { remove: () => void } | null = null; private isTrackingRequested = false; + // Bumped when tracking stops or the app backgrounds; invalidates in-flight starts + private startGeneration = 0; + // Serializes start attempts so concurrent calls cannot each create a watcher + private startQueue: Promise = Promise.resolve(); private constructor() { this.initializeAppStateListener(); @@ -46,7 +50,9 @@ class LocationService { // AppState event handlers don't handle promise rejections — catch everything try { if (nextAppState === 'background') { - // Foreground-only tracking: drop the watcher while the app is backgrounded. + // Foreground-only tracking: drop the watcher while the app is backgrounded and + // cancel any in-flight start; isTrackingRequested stays set so 'active' resumes. + this.startGeneration++; await this.removeSubscription(); } else if (nextAppState === 'active' && this.isTrackingRequested) { await this.startLocationUpdates(); @@ -85,12 +91,24 @@ class LocationService { } async startLocationUpdates(): Promise { + // Record tracking intent up front so a start interrupted by backgrounding + // (e.g. the OS permission dialog) is resumed on the next 'active' transition. + // Failure paths inside doStartLocationUpdates clear it. this.isTrackingRequested = true; + const attempt = this.startQueue.then(() => this.doStartLocationUpdates()); + this.startQueue = attempt.catch(() => {}); + return attempt; + } + + private async doStartLocationUpdates(): Promise { + const generation = this.startGeneration; + // On web, use a lightweight browser geolocation watcher instead of expo-location if (isWeb) { if (!('geolocation' in navigator)) { logger.warn({ message: 'Geolocation API not available in this browser' }); + this.isTrackingRequested = false; return; } @@ -129,14 +147,30 @@ class LocationService { return; } - const hasPermissions = await this.requestPermissions(); + let hasPermissions: boolean; + try { + hasPermissions = await this.requestPermissions(); + } catch (error) { + this.isTrackingRequested = false; + logger.error({ + message: 'Failed to request location permissions before starting updates', + context: { operation: 'startLocationUpdates', error }, + }); + throw error; + } if (!hasPermissions) { + this.isTrackingRequested = false; throw new Error('Location permissions not granted'); } + if (generation !== this.startGeneration) { + logger.info({ message: 'Location start cancelled while requesting permissions' }); + return; + } + // Start foreground updates (idempotent - check if already subscribed) if (!this.locationSubscription) { - this.locationSubscription = await Location.watchPositionAsync( + const subscription = await Location.watchPositionAsync( { accuracy: Location.Accuracy.Balanced, timeInterval: 15000, @@ -160,6 +194,12 @@ class LocationService { }); } ); + if (generation !== this.startGeneration || this.locationSubscription) { + // Tracking stopped or app backgrounded while the watcher was being created + await subscription.remove(); + return; + } + this.locationSubscription = subscription; } else { logger.info({ message: 'Foreground location subscription already active, skipping duplicate subscription', @@ -173,6 +213,7 @@ class LocationService { async stopLocationUpdates(): Promise { this.isTrackingRequested = false; + this.startGeneration++; await this.removeSubscription(); logger.info({