diff --git a/package-lock.json b/package-lock.json index b449c06d..76b4a170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1563,6 +1563,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -1578,6 +1579,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -1593,6 +1595,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1608,6 +1611,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1623,6 +1627,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1638,6 +1643,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1653,6 +1659,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1668,6 +1675,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1683,6 +1691,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1698,6 +1707,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1713,6 +1723,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1728,6 +1739,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1743,6 +1755,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1758,6 +1771,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1773,6 +1787,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1788,6 +1803,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1803,6 +1819,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1818,6 +1835,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1833,6 +1851,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1848,6 +1867,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1863,6 +1883,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1878,6 +1899,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1893,6 +1915,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -1908,6 +1931,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1923,6 +1947,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1938,6 +1963,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2343,6 +2369,20 @@ "node": ">=10" } }, + "node_modules/@expo/image-utils/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@expo/json-file": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", @@ -18633,6 +18673,22 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tar": { "version": "7.5.16", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", @@ -21159,156 +21215,182 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -21617,6 +21699,13 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "optional": true, + "peer": true } } }, @@ -32823,6 +32912,13 @@ "requires": { "lilconfig": "^3.1.1" } + }, + "yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "optional": true, + "peer": true } } }, diff --git a/src/__tests__/services/locationService.test.ts b/src/__tests__/services/locationService.test.ts new file mode 100644 index 00000000..cbf8d808 --- /dev/null +++ b/src/__tests__/services/locationService.test.ts @@ -0,0 +1,141 @@ +import * as Location from 'expo-location'; +import { AppState } from 'react-native'; + +import { FeatureStatus, FeatureType } from '../../services/featureCapabilities'; +import { locationService } from '../../services/locationService'; +import { useDegradationStore } from '../../store/degradationStore'; +import { useLocationStore } from '../../store/locationStore'; + +jest.mock( + 'expo-location', + () => ({ + __esModule: true, + requestForegroundPermissionsAsync: jest.fn(), + getForegroundPermissionsAsync: jest.fn(), + getCurrentPositionAsync: jest.fn(), + reverseGeocodeAsync: jest.fn(), + Accuracy: { Balanced: 3, Highest: 6 }, + PermissionStatus: { GRANTED: 'granted', DENIED: 'denied', UNDETERMINED: 'undetermined' }, + }), + { virtual: true } +); + +const mockGetPermissions = Location.getForegroundPermissionsAsync as jest.Mock; +const mockRequestPermissions = Location.requestForegroundPermissionsAsync as jest.Mock; + +function getAppStateChangeHandler(): ((state: string) => void) | undefined { + const calls = (AppState.addEventListener as jest.Mock).mock.calls; + const changeCall = calls.find(([event]: [string]) => event === 'change'); + return changeCall ? changeCall[1] : undefined; +} + +describe('LocationService — permission revoke detection', () => { + beforeEach(() => { + jest.clearAllMocks(); + locationService.reset(); + useLocationStore.getState().clearLocation(); + useDegradationStore.getState().setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + }); + + it('does nothing on AppState active when permission was never granted', async () => { + mockGetPermissions.mockResolvedValue({ status: 'denied' }); + + const handler = getAppStateChangeHandler(); + expect(handler).toBeUndefined(); + }); + + it('starts watcher after permission is granted', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + + const granted = await locationService.requestPermission(); + expect(granted).toBe(true); + + const handler = getAppStateChangeHandler(); + expect(handler).toBeDefined(); + expect(useLocationStore.getState().permissionGranted).toBe(true); + }); + + it('does not register duplicate watchers on repeated grant', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + + await locationService.requestPermission(); + await locationService.requestPermission(); + + const changeCalls = (AppState.addEventListener as jest.Mock).mock.calls.filter( + ([event]: [string]) => event === 'change' + ); + expect(changeCalls.length).toBe(1); + }); + + it('clears cached coordinates and updates stores when permission revoked mid-session', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + mockGetPermissions.mockResolvedValue({ status: 'granted' }); + + await locationService.requestPermission(); + + // Simulate that a GPS fix was obtained previously + (locationService as any).cachedLocation = { + latitude: 37.7749, + longitude: -122.4194, + source: 'gps', + obtainedAt: new Date().toISOString(), + }; + useLocationStore.getState().setCoordinates({ + latitude: 37.7749, + longitude: -122.4194, + }); + + expect(useLocationStore.getState().coordinates).toEqual({ + latitude: 37.7749, + longitude: -122.4194, + }); + expect(useLocationStore.getState().permissionGranted).toBe(true); + expect(locationService.getCachedLocation()).not.toBeNull(); + expect(useDegradationStore.getState().featureStatuses[FeatureType.LOCATION]).toBe( + FeatureStatus.AVAILABLE + ); + + // Revoke permission and trigger AppState active + mockGetPermissions.mockResolvedValue({ status: 'denied' }); + + const handler = getAppStateChangeHandler(); + expect(handler).toBeDefined(); + await handler!('active'); + + // — Assertions — + // 1. Cached location is cleared + expect(locationService.getCachedLocation()).toBeNull(); + + // 2. LocationStore coordinates cleared + expect(useLocationStore.getState().coordinates).toBeNull(); + expect(useLocationStore.getState().permissionGranted).toBe(false); + + // 3. DegradationStore updated + expect(useDegradationStore.getState().featureStatuses[FeatureType.LOCATION]).toBe( + FeatureStatus.PERMISSION_DENIED + ); + + // 4. Watcher was stopped (calling cleanup again is safe) + expect(() => locationService.cleanup()).not.toThrow(); + }); + + it('stops watcher within one event loop cycle of detecting revoke', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + mockGetPermissions.mockResolvedValue({ status: 'granted' }); + + await locationService.requestPermission(); + + expect((locationService as any).appStateSubscription).not.toBeNull(); + expect((locationService as any).isWatchingPermissionChanges).toBe(true); + + mockGetPermissions.mockResolvedValue({ status: 'denied' }); + + const handler = getAppStateChangeHandler(); + expect(handler).toBeDefined(); + await handler!('active'); + + // The subscription should have been removed + expect((locationService as any).appStateSubscription).toBeNull(); + expect((locationService as any).isWatchingPermissionChanges).toBe(false); + }); +}); diff --git a/src/services/locationService.ts b/src/services/locationService.ts index 0ff4e67e..647358ec 100644 --- a/src/services/locationService.ts +++ b/src/services/locationService.ts @@ -8,12 +8,19 @@ * * Gracefully degrades to manual entry if GPS unavailable * No errors thrown - always falls back to manual entry + * + * Permission revoke detection: + * - Listens to AppState changes to detect OS-level permission revocation + * - On revoke: stops watcher, clears coordinates, notifies store */ import * as Location from 'expo-location'; +import { AppState, AppStateStatus } from 'react-native'; + +import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities'; import { useDegradationStore } from '../store/degradationStore'; +import { useLocationStore } from '../store/locationStore'; import { appLogger } from '../utils/logger'; -import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities'; export enum LocationSourceType { GPS = 'gps', @@ -35,6 +42,8 @@ class LocationService { private static instance: LocationService; private cachedLocation: LocationData | null = null; private locationPermissionStatus: Location.PermissionStatus | null = null; + private appStateSubscription: { remove: () => void } | null = null; + private isWatchingPermissionChanges: boolean = false; private constructor() {} @@ -55,15 +64,19 @@ class LocationService { if (status === 'granted') { featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + useDegradationStore + .getState() + .setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + useLocationStore.getState().setPermissionGranted(true); + this.startWatchingPermissionChanges(); appLogger.infoSync('[LocationService] Location permission granted'); return true; } else { featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); - degradationStore.addNotification({ + useDegradationStore + .getState() + .setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + useDegradationStore.getState().addNotification({ feature: FeatureType.LOCATION, status: FeatureStatus.DEGRADED, message: 'Location permission denied. You can manually enter your location instead.', @@ -72,7 +85,10 @@ class LocationService { return false; } } catch (error) { - appLogger.errorSync('[LocationService] Error requesting permission', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[LocationService] Error requesting permission', + error instanceof Error ? error : new Error(String(error)) + ); return false; } } @@ -86,7 +102,10 @@ class LocationService { this.locationPermissionStatus = status; return status === 'granted'; } catch (error) { - appLogger.errorSync('[LocationService] Error checking permission', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[LocationService] Error checking permission', + error instanceof Error ? error : new Error(String(error)) + ); return false; } } @@ -98,12 +117,14 @@ class LocationService { public async getCurrentLocation(): Promise { try { // Check permission - const hasPermission = this.locationPermissionStatus === 'granted' || await this.checkPermission(); + const hasPermission = + this.locationPermissionStatus === 'granted' || (await this.checkPermission()); if (!hasPermission) { appLogger.infoSync('[LocationService] Location permission not granted - GPS unavailable'); featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + useDegradationStore + .getState() + .setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); return null; } @@ -135,17 +156,29 @@ class LocationService { locationData.address = parts.join(', '); } } catch (geocodeError) { - appLogger.infoSync('[LocationService] Reverse geocoding failed (non-fatal)', geocodeError instanceof Error ? geocodeError : new Error(String(geocodeError))); + appLogger.infoSync( + '[LocationService] Reverse geocoding failed (non-fatal)', + geocodeError instanceof Error ? geocodeError : new Error(String(geocodeError)) + ); // Continue with GPS coordinates even if geocoding fails } // Cache the location this.cachedLocation = locationData; + // Store coordinates in locationStore for downstream consumers + if (locationData.latitude !== undefined && locationData.longitude !== undefined) { + useLocationStore.getState().setCoordinates({ + latitude: locationData.latitude, + longitude: locationData.longitude, + }); + } + // Update feature status featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + useDegradationStore + .getState() + .setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); appLogger.infoSync('[LocationService] GPS location obtained successfully', { lat: locationData.latitude, @@ -154,13 +187,15 @@ class LocationService { return locationData; } catch (error) { - appLogger.errorSync('[LocationService] Error getting current location', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[LocationService] Error getting current location', + error instanceof Error ? error : new Error(String(error)) + ); // Feature degraded but not unavailable - return cached location if available featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); - degradationStore.addNotification({ + useDegradationStore.getState().setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + useDegradationStore.getState().addNotification({ feature: FeatureType.LOCATION, status: FeatureStatus.DEGRADED, message: 'Could not access your current location. Please enter your location manually.', @@ -183,8 +218,7 @@ class LocationService { this.cachedLocation = locationData; featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); + useDegradationStore.getState().setFeatureStatus(FeatureType.LOCATION, FeatureStatus.AVAILABLE); appLogger.infoSync('[LocationService] Manual location set', { address }); return locationData; @@ -204,6 +238,77 @@ class LocationService { this.cachedLocation = null; } + /** + * Start watching for OS-level permission changes via AppState. + * When the app becomes active, re-checks permission status and cleans up + * if permission was revoked while in background. + */ + public startWatchingPermissionChanges(): void { + if (this.isWatchingPermissionChanges) return; + this.isWatchingPermissionChanges = true; + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); + appLogger.infoSync('[LocationService] Started watching permission changes via AppState'); + } + + private handleAppStateChange = async (nextAppState: AppStateStatus): void => { + if (nextAppState !== 'active') return; + + const previousStatus = this.locationPermissionStatus; + const hasPermission = await this.checkPermission(); + + if (previousStatus === 'granted' && !hasPermission) { + this.onPermissionRevoked(); + } + }; + + private onPermissionRevoked(): void { + // Stop watching — permission is gone + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + this.isWatchingPermissionChanges = false; + + // Clear cached location so stale data is never served + this.clearCachedLocation(); + + // Clear locationStore coordinates and mark permission as denied + useLocationStore.getState().clearLocation(); + + // Update degradation store + useDegradationStore + .getState() + .setFeatureStatus(FeatureType.LOCATION, FeatureStatus.PERMISSION_DENIED); + useDegradationStore.getState().addNotification({ + feature: FeatureType.LOCATION, + status: FeatureStatus.PERMISSION_DENIED, + message: + 'Location permission was revoked while the app was running. Please enable it in Settings or enter your location manually.', + }); + + appLogger.warnSync( + '[LocationService] Location permission revoked — watcher stopped, coordinates cleared' + ); + } + + /** + * Clean up AppState subscription. Called during service teardown. + */ + public cleanup(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + this.isWatchingPermissionChanges = false; + } + + /** @internal Reset internal state for testing purposes. */ + public reset(): void { + this.cleanup(); + this.cachedLocation = null; + this.locationPermissionStatus = null; + } + /** * Get location with fallback chain: GPS -> Cached -> Manual Entry Required * Never throws - always returns a valid response @@ -235,8 +340,7 @@ class LocationService { // Return null - manual entry required appLogger.infoSync('[LocationService] No location available - manual entry required'); featureCapabilities.getFeatureInfo(FeatureType.LOCATION); - const degradationStore = useDegradationStore(); - degradationStore.setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); + useDegradationStore.getState().setFeatureStatus(FeatureType.LOCATION, FeatureStatus.DEGRADED); return null; } diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts index 84ff226d..12a59bc8 100644 --- a/src/store/degradationStore.ts +++ b/src/store/degradationStore.ts @@ -14,7 +14,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import { FeatureStatus, FeatureType } from './featureCapabilities'; + +import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; export interface DegradationNotification { id: string; @@ -85,11 +86,12 @@ export const useDegradationStore = create()( // Feature status actions setFeatureStatus: (feature, status) => - set((state) => { + set(state => { const newDegraded = new Set(state.degradedFeatures); - const isDegraded = status === FeatureStatus.PERMISSION_DENIED || - status === FeatureStatus.HARDWARE_UNAVAILABLE || - status === FeatureStatus.UNAVAILABLE; + const isDegraded = + status === FeatureStatus.PERMISSION_DENIED || + status === FeatureStatus.HARDWARE_UNAVAILABLE || + status === FeatureStatus.UNAVAILABLE; if (isDegraded) { newDegraded.add(feature); @@ -108,9 +110,11 @@ export const useDegradationStore = create()( isFeatureDegraded: (feature: FeatureType): boolean => { const status = get().featureStatuses[feature]; - return status === FeatureStatus.PERMISSION_DENIED || - status === FeatureStatus.HARDWARE_UNAVAILABLE || - status === FeatureStatus.UNAVAILABLE; + return ( + status === FeatureStatus.PERMISSION_DENIED || + status === FeatureStatus.HARDWARE_UNAVAILABLE || + status === FeatureStatus.UNAVAILABLE + ); }, getDegradedFeatures: (): FeatureType[] => { @@ -132,7 +136,7 @@ export const useDegradationStore = create()( showedAt: new Date().toISOString(), }; - set((state) => ({ + set(state => ({ notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 })); @@ -140,8 +144,8 @@ export const useDegradationStore = create()( }, dismissNotification: (notificationId: string, action?: string) => { - set((state) => ({ - notifications: state.notifications.map((n) => + set(state => ({ + notifications: state.notifications.map(n => n.id === notificationId ? { ...n, dismissedAt: new Date().toISOString(), actionTaken: action } : n @@ -154,24 +158,24 @@ export const useDegradationStore = create()( }, getUnreadNotifications: (): DegradationNotification[] => { - return get().notifications.filter((n) => !n.dismissedAt); + return get().notifications.filter(n => !n.dismissedAt); }, // Preference actions setShowDegradationBanners: (show: boolean) => { - set((state) => ({ + set(state => ({ preferences: { ...state.preferences, showDegradationBanners: show }, })); }, setAutoDismissAlerts: (autoDismiss: boolean) => { - set((state) => ({ + set(state => ({ preferences: { ...state.preferences, autoDismissDegradationAlerts: autoDismiss }, })); }, setRemindPermissionRetry: (remind: boolean) => { - set((state) => ({ + set(state => ({ preferences: { ...state.preferences, remindPermissionRetry: remind }, })); }, @@ -179,7 +183,7 @@ export const useDegradationStore = create()( { name: 'degradation-store', storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ + partialize: state => ({ preferences: state.preferences, notifications: state.notifications, featureStatuses: state.featureStatuses, diff --git a/src/store/locationStore.ts b/src/store/locationStore.ts new file mode 100644 index 00000000..01f9b242 --- /dev/null +++ b/src/store/locationStore.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; + +interface Coordinates { + latitude: number; + longitude: number; +} + +interface LocationState { + coordinates: Coordinates | null; + permissionGranted: boolean; + setCoordinates: (coords: Coordinates) => void; + setPermissionGranted: (granted: boolean) => void; + clearLocation: () => void; +} + +export const useLocationStore = create(set => ({ + coordinates: null, + permissionGranted: false, + setCoordinates: coordinates => set({ coordinates }), + setPermissionGranted: permissionGranted => set({ permissionGranted }), + clearLocation: () => set({ coordinates: null, permissionGranted: false }), +}));