Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gga
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# PROVIDER="lmstudio:qwen2.5-coder-7b-instruct"
# PROVIDER="github:gpt-4o"
# PROVIDER="github:deepseek-r1"
PROVIDER="opencode"
PROVIDER="opencode:opencode/nemotron-3.5-lightning-free"

# File patterns to include in review (comma-separated)
# Default: * (all files)
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe('GET /config', () => {
const geofence = body.geofence as Record<string, unknown>;
expect(geofence).toHaveProperty('bypassGeofence');
expect(typeof geofence.bypassGeofence).toBe('boolean');
expect(geofence).toHaveProperty('bypassIosBrowser');
expect(typeof geofence.bypassIosBrowser).toBe('boolean');

const tripGeo = (geofence.trip as Record<string, unknown>) ?? {};
const trackGeo = (geofence.track as Record<string, unknown>) ?? {};
Expand All @@ -42,7 +44,24 @@ describe('GET /config', () => {
const appVersion = body.appVersion as Record<string, unknown>;
expect(typeof appVersion.minimumVersion).toBe('string');
expect(typeof appVersion.blockOlderVersions).toBe('boolean');
expect(typeof appVersion.blockOlderVersions).toBe('boolean');
});

it('honors BYPASS_GEOFENCE_IOS_BROWSER env override', async () => {
const resOff = await app.request(
'/config',
{},
{ ...BINDINGS, BYPASS_GEOFENCE_IOS_BROWSER: 'false' },
);
const bodyOff = (await resOff.json()) as { geofence: { bypassIosBrowser: boolean } };
expect(bodyOff.geofence.bypassIosBrowser).toBe(false);

const resOn = await app.request(
'/config',
{},
{ ...BINDINGS, BYPASS_GEOFENCE_IOS_BROWSER: 'true' },
);
const bodyOn = (await resOn.json()) as { geofence: { bypassIosBrowser: boolean } };
expect(bodyOn.geofence.bypassIosBrowser).toBe(true);
});

it('does not include grace period fields when env vars are absent', async () => {
Expand Down Expand Up @@ -73,6 +92,7 @@ describe('GET /config', () => {
trip: { radiusMeters: number; defaultMode: string };
track: { radiusMeters: number; defaultMode: string };
bypassGeofence: boolean;
bypassIosBrowser: boolean;
};
audio: { rewindOffsetMs: number };
feedback: { syncIntervalSec: number };
Expand All @@ -87,6 +107,7 @@ describe('GET /config', () => {
expect(body.geofence.track.radiusMeters).toBe(100000);
expect(body.geofence.track.defaultMode).toBe('formatDefaultRadius');
expect(body.geofence.bypassGeofence).toBe(false);
expect(body.geofence.bypassIosBrowser).toBe(true);
expect(body.audio.rewindOffsetMs).toBe(10000);
expect(body.feedback.syncIntervalSec).toBe(30);
expect(body.appVersion.minimumVersion).toBe('0.0.0');
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface Env {
BLOCK_OLDER_VERSIONS: string;
GRACE_PERIOD_START?: string;
GRACE_PERIOD_END?: string;
BYPASS_GEOFENCE_IOS_BROWSER?: string;
MP_ACCESS_TOKEN?: string;
MP_WEBHOOK_SECRET?: string;
DEFAULT_PAYMENT_PROVIDER?: string;
Expand All @@ -61,6 +62,7 @@ export interface Variables {
blockOlderVersions: boolean;
gracePeriodStart?: string;
gracePeriodEnd?: string;
bypassIosBrowser: boolean;
};
environment: string;
feedbackStore?: KVNamespace;
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/middleware/config-guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MiddlewareHandler } from 'hono';
import { DEFAULT_REMOTE_CONFIG } from '@sonora/shared';
import type { Env, Variables } from '../index';

export const configGuard = (): MiddlewareHandler<{
Expand All @@ -11,6 +12,10 @@ export const configGuard = (): MiddlewareHandler<{
blockOlderVersions: c.env?.BLOCK_OLDER_VERSIONS === 'true',
gracePeriodStart: c.env?.GRACE_PERIOD_START,
gracePeriodEnd: c.env?.GRACE_PERIOD_END,
bypassIosBrowser:
c.env?.BYPASS_GEOFENCE_IOS_BROWSER !== undefined
? c.env.BYPASS_GEOFENCE_IOS_BROWSER === 'true'
: DEFAULT_REMOTE_CONFIG.geofence.bypassIosBrowser,
});
await next();
};
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { configGuard } from '../middleware/config-guard';
const configRouter = new Hono<{ Bindings: Env; Variables: Variables }>();

configRouter.get('/', configGuard(), (c) => {
const { minimumVersion, blockOlderVersions, gracePeriodStart, gracePeriodEnd } = c.var.configEnv;
const { minimumVersion, blockOlderVersions, gracePeriodStart, gracePeriodEnd, bypassIosBrowser } =
c.var.configEnv;

const appVersion: RemoteConfigPayload['appVersion'] = {
minimumVersion,
Expand All @@ -23,6 +24,10 @@ configRouter.get('/', configGuard(), (c) => {

return success(c, {
...DEFAULT_REMOTE_CONFIG,
geofence: {
...DEFAULT_REMOTE_CONFIG.geofence,
bypassIosBrowser,
},
appVersion,
} satisfies RemoteConfigPayload);
});
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/config/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ export const APP_CONFIG = {
bypassGeofence:
process.env.EXPO_PUBLIC_BYPASS_GEOFENCE === 'true' ||
DEFAULT_REMOTE_CONFIG.geofence.bypassGeofence,
/**
* Build-time env override to bypass geofence restriction specifically on iOS web browsers.
* Default sourced from @sonora/shared — overrideable via GET /api/config.
*/
bypassIosBrowser:
process.env.EXPO_PUBLIC_BYPASS_GEOFENCE_IOS_BROWSER === 'true' ||
DEFAULT_REMOTE_CONFIG.geofence.bypassIosBrowser,
},
feedback: {
/**
Expand Down
121 changes: 121 additions & 0 deletions apps/mobile/src/hooks/__tests__/use-offline-geofence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react-native';
import { useOfflineGeofence, type ProximityClient } from '../use-offline-geofence';
import { useLocationStore } from '@/store/location-store';
import { useRemoteConfig } from '../use-remote-config';
import { isIosBrowser } from '@/utils/platform';

// Mock the Zustand store hook
jest.mock('@/store/location-store', () => ({
Expand All @@ -13,6 +14,10 @@ jest.mock('../use-remote-config', () => ({
useRemoteConfig: jest.fn(),
}));

jest.mock('@/utils/platform', () => ({
isIosBrowser: jest.fn(),
}));

describe('useOfflineGeofence hook', () => {
const targetCoords = { latitude: -31.979, longitude: -64.635 };

Expand All @@ -22,13 +27,15 @@ describe('useOfflineGeofence hook', () => {
trip: { radiusMeters: 50, defaultMode: 'formatDefaultRadius' },
track: { radiusMeters: 50, defaultMode: 'entityRadius' },
bypassGeofence: false,
bypassIosBrowser: true,
},
audio: { rewindOffsetMs: 10000 },
feedback: { syncIntervalSec: 30 },
};

beforeEach(() => {
jest.clearAllMocks();
(isIosBrowser as unknown as jest.Mock).mockReturnValue(false);
(useRemoteConfig as unknown as jest.Mock).mockReturnValue({
config: defaultConfig,
isLoading: false,
Expand All @@ -54,6 +61,21 @@ describe('useOfflineGeofence hook', () => {
expect(result.current.requiredRadiusMeters).toBe(50);
});

it('should handle null targetCoords gracefully', async () => {
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: { latitude: -31.979, longitude: -64.635 },
accuracy: 5,
status: 'ready',
errorMsg: null,
});

const { result } = await renderHook(() => useOfflineGeofence(null));

expect(result.current.isNearStart).toBe(false);
expect(result.current.distanceMeters).toBeNull();
expect(result.current.requiredRadiusMeters).toBe(50);
});

it('should handle location permission denial', async () => {
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: null,
Expand Down Expand Up @@ -402,4 +424,103 @@ describe('useOfflineGeofence hook', () => {
expect(result.current.isNearStart).toBe(false);
expect(result.current.distanceMeters).toBe(120);
});

it('handles authoritative online result with undefined optional fields', async () => {
const proximityClient: ProximityClient = {
check: jest.fn().mockResolvedValue({
ok: true,
}),
};
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: { latitude: -31.979, longitude: -64.635 },
accuracy: 5,
status: 'ready',
errorMsg: null,
});

const { result } = await renderHook(() =>
useOfflineGeofence(targetCoords, undefined, { proximityClient }),
);

await waitFor(() => expect(result.current.requiredRadiusMeters).toBe(0));
expect(result.current.isNearStart).toBe(false);
expect(result.current.distanceMeters).toBeNull();
});

describe('iOS browser geofence bypass', () => {
it('bypasses proximity check when running on iOS browser and bypassIosBrowser is true', async () => {
(isIosBrowser as unknown as jest.Mock).mockReturnValue(true);
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: { latitude: 0, longitude: 0 },
accuracy: 5,
status: 'ready',
errorMsg: null,
});

const { result } = await renderHook(() => useOfflineGeofence(targetCoords));
expect(result.current.isNearStart).toBe(true);
expect(result.current.distanceMeters).toBeGreaterThan(10000);
});

it('bypasses proximity check on iOS browser even when there is no GPS fix', async () => {
(isIosBrowser as unknown as jest.Mock).mockReturnValue(true);
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: null,
accuracy: null,
status: 'initializing',
errorMsg: null,
});

const { result } = await renderHook(() => useOfflineGeofence(targetCoords));
expect(result.current.isNearStart).toBe(true);
expect(result.current.distanceMeters).toBeNull();
});

it('enforces proximity check on iOS browser when bypassIosBrowser is set to false', async () => {
(isIosBrowser as unknown as jest.Mock).mockReturnValue(true);
(useRemoteConfig as unknown as jest.Mock).mockReturnValue({
config: {
...defaultConfig,
geofence: { ...defaultConfig.geofence, bypassIosBrowser: false },
},
isLoading: false,
error: null,
refetch: jest.fn(),
});
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: { latitude: 0, longitude: 0 },
accuracy: 5,
status: 'ready',
errorMsg: null,
});

const { result } = await renderHook(() => useOfflineGeofence(targetCoords));
expect(result.current.isNearStart).toBe(false);
});

it('overrides online proximity block when running on iOS browser with bypass active', async () => {
(isIosBrowser as unknown as jest.Mock).mockReturnValue(true);
const proximityClient: ProximityClient = {
check: jest.fn().mockResolvedValue({
ok: true,
canListen: false,
distanceMeters: 500,
effectiveRadiusMeters: 30,
}),
};
(useLocationStore as unknown as jest.Mock).mockReturnValue({
coords: { latitude: 0, longitude: 0 },
accuracy: 5,
status: 'ready',
errorMsg: null,
});

const { result } = await renderHook(() =>
useOfflineGeofence(targetCoords, undefined, { proximityClient }),
);

await waitFor(() => expect(proximityClient.check).toHaveBeenCalledTimes(1));
expect(result.current.isNearStart).toBe(true);
});
});
});
7 changes: 7 additions & 0 deletions apps/mobile/src/hooks/use-offline-geofence.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLocationStore } from '@/store/location-store';
import { logger } from '@/utils/logger';
import { isIosBrowser } from '@/utils/platform';
import { resolveProximity, type GeoMode, type UserExperienceFormat } from '@sonora/shared';
import { useEffect, useState } from 'react';
import { useRemoteConfig } from './use-remote-config';
Expand Down Expand Up @@ -126,6 +127,12 @@ export function useOfflineGeofence(
requiredRadiusMeters = onlineDecision.effectiveRadiusMeters ?? 0;
}

// Bypass proximity gating on iOS web browsers if configured (default: true)
const isIosWeb = isIosBrowser();
if (isIosWeb && config.geofence.bypassIosBrowser) {
isNearStart = true;
}

return {
isNearStart,
gpsAccuracy: accuracy,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/storage/__tests__/config-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('config-cache', () => {
trip: { radiusMeters: 100, defaultMode: 'formatDefaultRadius' as const },
track: { radiusMeters: 100, defaultMode: 'entityRadius' as const },
bypassGeofence: true,
bypassIosBrowser: true,
},
audio: { rewindOffsetMs: 15000 },
feedback: { syncIntervalSec: 60 },
Expand All @@ -42,6 +43,7 @@ describe('config-cache', () => {
trip: { radiusMeters: 75, defaultMode: 'formatDefaultRadius' as const },
track: { radiusMeters: 75, defaultMode: 'entityRadius' as const },
bypassGeofence: false,
bypassIosBrowser: true,
},
audio: { rewindOffsetMs: 5000 },
feedback: { syncIntervalSec: 120 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ describe('RemoteConfigStore', () => {
trip: { radiusMeters: 300, defaultMode: 'formatDefaultRadius' },
track: { radiusMeters: 300, defaultMode: 'formatDefaultRadius' },
bypassGeofence: true,
bypassIosBrowser: true,
},
audio: { rewindOffsetMs: 20000 },
feedback: { syncIntervalSec: 300 },
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/store/remote-config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const INITIAL_REMOTE_CONFIG: RemoteConfigPayload = {
geofence: {
...DEFAULT_REMOTE_CONFIG.geofence,
bypassGeofence: APP_CONFIG.geofence.bypassGeofence,
bypassIosBrowser: APP_CONFIG.geofence.bypassIosBrowser,
},
};

Expand Down
Loading
Loading