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
11 changes: 10 additions & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: Env.NAME,
description: `${Env.NAME} Resgrid Responder`,
owner: Env.EXPO_ACCOUNT_OWNER,
scheme: Env.SCHEME,
scheme: [Env.SCHEME, 'resgrid'],
slug: 'resgrid-responder',
version: Env.VERSION.toString(),
orientation: 'default',
Expand Down Expand Up @@ -55,6 +55,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
UIViewControllerBasedStatusBarAppearance: false,
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Responder to connect to bluetooth devices for PTT.',
NSMicrophoneUsageDescription: 'Allow Resgrid Responder to access the microphone for voice communication and push-to-talk functionality during emergency response.',
LSApplicationQueriesSchemes: ['resgrid'],
},
entitlements: {
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
Expand All @@ -76,6 +77,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
softwareKeyboardLayoutMode: 'pan',
package: Env.PACKAGE,
googleServicesFile: 'google-services.json',
intentFilters: [
{
action: 'VIEW',
data: [{ scheme: 'resgrid' }],
category: ['BROWSABLE', 'DEFAULT'],
},
],
permissions: [
'android.permission.WAKE_LOCK',
'android.permission.RECORD_AUDIO',
Expand Down Expand Up @@ -273,6 +281,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
],
'react-native-ble-manager',
'expo-secure-store',
'expo-web-browser',
'@livekit/react-native-expo-plugin',
'@config-plugins/react-native-webrtc',
'@config-plugins/react-native-callkeep',
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@
"expo-application": "~6.1.5",
"expo-asset": "~11.1.7",
"expo-audio": "~0.4.9",
"expo-auth-session": "~6.2.1",
"expo-av": "~15.1.7",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.7",
"expo-crypto": "~14.1.5",
"expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4",
"expo-document-picker": "~13.1.6",
Expand All @@ -126,6 +128,7 @@
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6",
"expo-web-browser": "~14.2.0",
"geojson": "~0.5.0",
"i18next": "~23.14.0",
"livekit-client": "^2.15.7",
Expand Down
9 changes: 6 additions & 3 deletions src/app/(app)/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { RefreshControl } from '@/components/ui/refresh-control';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { isSameDate } from '@/lib/utils';
import { isDateInRange } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { useCalendarStore } from '@/stores/calendar/store';

Expand Down Expand Up @@ -110,8 +110,11 @@ export default function CalendarScreen() {
if (!selectedDate) return [];

return selectedMonthItems.filter((item) => {
// Use Start field for consistent date comparison with .NET backend timezone-aware dates
return isSameDate(item.Start, selectedDate);
// Always use range-based filtering so that:
// 1. timed multi-day events (regardless of IsMultiDay flag) show on every covered day
// 2. all-day events use the exclusive-end convention
// 3. single-day events still work (start === end collapses to an exact match)
return isDateInRange(selectedDate, item.Start, item.End, item.IsAllDay);
});
};

Expand Down
25 changes: 25 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { registerGlobals } from '@livekit/react-native';
import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as Sentry from '@sentry/react-native';
import { isRunningInExpoGo } from 'expo';
import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import { Stack, useNavigationContainerRef } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as WebBrowser from 'expo-web-browser';
import React, { useEffect } from 'react';
import { LogBox, useColorScheme } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
Expand All @@ -24,6 +26,7 @@ import { LiveKitBottomSheet } from '@/components/livekit';
import { PushNotificationModal } from '@/components/push-notification/push-notification-modal';
import { ToastContainer } from '@/components/toast/toast-container';
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
import { handleSamlCallbackUrl } from '@/hooks/use-saml-login';
import { hydrateAuth, useAuth } from '@/lib/auth';
import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme';
Expand All @@ -33,6 +36,9 @@ import { loadBackgroundGeolocationState } from '@/lib/storage/background-geoloca
import { uuidv4 } from '@/lib/utils';
import { appInitializationService } from '@/services/app-initialization.service';

// Ensure OIDC / OAuth in-app browser sessions complete properly on iOS
WebBrowser.maybeCompleteAuthSession();

// Prevent the splash screen from auto-hiding before asset loading is complete.
//SplashScreen.preventAutoHideAsync();

Expand Down Expand Up @@ -106,6 +112,20 @@ function RootLayout() {

hydrateAuth();

// Handle SAML deep-link callbacks (cold start)
Linking.getInitialURL().then((url) => {
if (url && url.includes('saml_response')) {
handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML cold-start deep-link handler failed', context: { err } }));
}
});

// Handle SAML deep-link callbacks (warm start)
const samlSubscription = Linking.addEventListener('url', ({ url }) => {
if (url.includes('saml_response')) {
handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML warm-start deep-link handler failed', context: { err } }));
}
});

// Clear the badge count on app startup
Notifications.setBadgeCountAsync(0)
.then(() => {
Expand Down Expand Up @@ -162,6 +182,10 @@ function RootLayout() {
context: { error },
});
});

return () => {
samlSubscription.remove();
};
}, [ref]);

return (
Expand All @@ -171,6 +195,7 @@ function RootLayout() {
<Stack.Screen name="call" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="login/index" options={{ headerShown: false }} />
<Stack.Screen name="login/sso" options={{ headerShown: true }} />
</Stack>
</Providers>
);
Expand Down
8 changes: 4 additions & 4 deletions src/app/login/__tests__/login-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('LoginForm Server URL Integration', () => {

// Check that the form renders properly - there should be multiple text elements
expect(screen.getAllByTestId('text').length).toBeGreaterThan(0);
expect(screen.getAllByTestId('button')).toHaveLength(2);
expect(screen.getAllByTestId('button')).toHaveLength(3);
});

it('should track analytics and show bottom sheet when server URL button is pressed', () => {
Expand Down Expand Up @@ -245,7 +245,7 @@ describe('LoginForm Server URL Integration', () => {

// Check that the loading button is rendered with spinner
expect(screen.getByTestId('button-spinner')).toBeTruthy();
expect(screen.getAllByTestId('button')).toHaveLength(2);
expect(screen.getAllByTestId('button')).toHaveLength(3);
});

it('should render with different prop combinations', () => {
Expand All @@ -257,7 +257,7 @@ describe('LoginForm Server URL Integration', () => {
);

// Check basic rendering
expect(screen.getAllByTestId('button')).toHaveLength(2);
expect(screen.getAllByTestId('button')).toHaveLength(3);

// Test with error
rerender(
Expand All @@ -268,6 +268,6 @@ describe('LoginForm Server URL Integration', () => {
);

// Should still render the basic structure
expect(screen.getAllByTestId('button')).toHaveLength(2);
expect(screen.getAllByTestId('button')).toHaveLength(3);
});
});
40 changes: 11 additions & 29 deletions src/app/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,32 +32,22 @@ export default function Login() {
}, [trackEvent])
);

// Handle successful authenticated state → navigate to app
useEffect(() => {
if (status === 'signedIn' && isAuthenticated) {
logger.info({
message: 'Login successful, redirecting to home',
});

// Track successful login
trackEvent('login_success', {
timestamp: new Date().toISOString(),
});

logger.info({ message: 'Login successful, redirecting to home' });
trackEvent('login_success', { timestamp: new Date().toISOString() });
router.push('/(app)');
}
}, [status, isAuthenticated, router, trackEvent]);

// Show error modal on login failure
useEffect(() => {
if (status === 'error') {
logger.error({
message: 'Login failed',
context: { error },
});
logger.error({ message: 'Login failed', context: { error } });

// Safe analytics: classify and truncate error before tracking
try {
const timestamp = new Date().toISOString();
// Treat error as string and classify based on content
const rawMessage = error ?? '';
let errorCode = 'unknown_error';
if (rawMessage.includes('TypeError')) {
Expand All @@ -67,31 +57,27 @@ export default function Login() {
} else if (rawMessage.toLowerCase().includes('auth')) {
errorCode = 'auth_error';
}
// Truncate message to 100 chars
const message = rawMessage.slice(0, 100);
trackEvent('login_failed', {
timestamp,
errorCode,
category: 'login_error',
message,
message: rawMessage.slice(0, 100),
});
} catch {
// Swallow analytics errors, log non-sensitive warning
logger.warn({ message: 'Failed to track login_failed event' });
}

setIsErrorModalVisible(true);
}
}, [status, error, trackEvent]);

const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
const onLocalLoginSubmit: LoginFormProps['onSubmit'] = async (data) => {
const usernameHash = data.username ? CryptoJS.HmacSHA256(data.username, Env.LOGGING_KEY || '').toString() : null;
logger.info({
message: 'Starting Login (button press)',
context: { hasUsername: Boolean(data.username), usernameHash },
});

// Track login attempt
try {
trackEvent('login_attempted', {
timestamp: new Date().toISOString(),
Expand All @@ -112,15 +98,11 @@ export default function Login() {
return (
<>
<FocusAwareStatusBar />
<LoginForm onSubmit={onSubmit} isLoading={status === 'loading'} {...(error ? { error } : {})} />

<Modal
isOpen={isErrorModalVisible}
onClose={() => {
setIsErrorModalVisible(false);
}}
size="full"
>
<LoginForm onSubmit={onLocalLoginSubmit} isLoading={status === 'loading'} onSsoPress={() => router.push('/login/sso')} {...(error ? { error } : {})} />

{/* Error modal */}
<Modal isOpen={isErrorModalVisible} onClose={() => setIsErrorModalVisible(false)} size="full">
<ModalBackdrop />
<ModalContent className="m-4 w-full max-w-3xl rounded-2xl">
<ModalHeader>
Expand Down
16 changes: 11 additions & 5 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ export type LoginFormProps = {
onSubmit?: SubmitHandler<FormType>;
isLoading?: boolean;
error?: string;
onSsoPress?: () => void;
};

export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined }: LoginFormProps) => {
export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onSsoPress }: LoginFormProps) => {
const { colorScheme } = useColorScheme();
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
Expand Down Expand Up @@ -158,10 +159,15 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
</Button>
)}

{/* Server URL Change Button */}
<Button className="mt-14 w-full" variant="outline" onPress={handleServerUrlPress}>
<ButtonText className="text-sm">{t('login.change_server_url')}</ButtonText>
</Button>
{/* Server URL and SSO Buttons */}
<View className="mt-14 w-full flex-row gap-2">
<Button className="flex-1" variant="outline" onPress={handleServerUrlPress}>
<ButtonText className="text-xs">{t('login.change_server_url')}</ButtonText>
</Button>
<Button className="flex-1" variant="outline" onPress={onSsoPress}>
<ButtonText className="text-xs">{t('login.sso.login_with_sso_button')}</ButtonText>
</Button>
</View>
</View>
</ScrollView>

Expand Down
Loading
Loading