diff --git a/.DS_Store b/.DS_Store index cdbef2be..fe6609dc 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/__mocks__/react-native-callkeep.ts b/__mocks__/react-native-callkeep.ts index 2103ecc5..e7c6e1f6 100644 --- a/__mocks__/react-native-callkeep.ts +++ b/__mocks__/react-native-callkeep.ts @@ -1,4 +1,4 @@ -export default { +const mockMethods = { setup: jest.fn().mockResolvedValue(undefined), startCall: jest.fn().mockResolvedValue(undefined), reportConnectingOutgoingCallWithUUID: jest.fn().mockResolvedValue(undefined), @@ -12,6 +12,17 @@ export default { backToForeground: jest.fn(), }; -export const AudioSessionCategoryOption = {}; -export const AudioSessionMode = {}; +export default mockMethods; + +export const AudioSessionCategoryOption = { + allowAirPlay: 1, + allowBluetooth: 2, + allowBluetoothA2DP: 4, + defaultToSpeaker: 8, +}; + +export const AudioSessionMode = { + voiceChat: 1, +}; + export const CONSTANTS = {}; diff --git a/app.config.ts b/app.config.ts index ac260125..a3f67bf4 100644 --- a/app.config.ts +++ b/app.config.ts @@ -47,8 +47,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ infoPlist: { UIBackgroundModes: ['remote-notification', 'audio', 'bluetooth-central', 'voip'], ITSAppUsesNonExemptEncryption: false, + UIViewControllerBasedStatusBarAppearance: false, NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Unit to connect to bluetooth devices for PTT.', }, + entitlements: { + ...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && { + 'com.apple.developer.usernotifications.critical-alerts': true, + 'com.apple.developer.usernotifications.time-sensitive': true, + }), + }, }, experiments: { typedRoutes: true, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 90c23e0a..945618d9 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -26,10 +26,10 @@ import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'; import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive'; import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme'; import { logger } from '@/lib/logging'; -import { getDeviceUuid } from '@/lib/storage/app'; -import { setDeviceUuid } from '@/lib/storage/app'; +import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; import { uuidv4 } from '@/lib/utils'; +import { appInitializationService } from '@/services/app-initialization.service'; export { ErrorBoundary } from 'expo-router'; export const navigationRef = createNavigationContainerRef(); @@ -141,6 +141,21 @@ function RootLayout() { context: { error }, }); }); + + // Initialize global app services (including CallKeep for iOS) + appInitializationService + .initialize() + .then(() => { + logger.info({ + message: 'Global app services initialized successfully', + }); + }) + .catch((error) => { + logger.error({ + message: 'Failed to initialize global app services', + context: { error }, + }); + }); }, [ref]); return ( diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 3e2102e5..edb87d60 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -42,7 +42,7 @@ export type LoginFormProps = { onServerUrlPress?: () => void; }; -export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => { +export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => { const { colorScheme } = useColorScheme(); const { t } = useTranslation(); const { diff --git a/src/components/ui/__tests__/focus-aware-status-bar.test.tsx b/src/components/ui/__tests__/focus-aware-status-bar.test.tsx new file mode 100644 index 00000000..bef04b5b --- /dev/null +++ b/src/components/ui/__tests__/focus-aware-status-bar.test.tsx @@ -0,0 +1,413 @@ +import { useIsFocused } from '@react-navigation/native'; +import * as NavigationBar from 'expo-navigation-bar'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { Platform, StatusBar } from 'react-native'; +import { render } from '@testing-library/react-native'; +import { SystemBars } from 'react-native-edge-to-edge'; + +import { FocusAwareStatusBar } from '../focus-aware-status-bar'; + +// Mock dependencies +jest.mock('@react-navigation/native'); +jest.mock('expo-navigation-bar'); +jest.mock('nativewind'); +jest.mock('react-native-edge-to-edge'); + +const mockUseIsFocused = useIsFocused as jest.MockedFunction; +const mockUseColorScheme = useColorScheme as jest.MockedFunction; +const mockSystemBars = SystemBars as jest.MockedFunction; +const mockNavigationBar = NavigationBar as jest.Mocked; + +// Mock StatusBar methods +const mockStatusBar = { + setBackgroundColor: jest.fn(), + setTranslucent: jest.fn(), + setHidden: jest.fn(), + setBarStyle: jest.fn(), +}; + +// Replace StatusBar with our mock +Object.defineProperty(StatusBar, 'setBackgroundColor', { + value: mockStatusBar.setBackgroundColor, + writable: true, +}); +Object.defineProperty(StatusBar, 'setTranslucent', { + value: mockStatusBar.setTranslucent, + writable: true, +}); +Object.defineProperty(StatusBar, 'setHidden', { + value: mockStatusBar.setHidden, + writable: true, +}); +Object.defineProperty(StatusBar, 'setBarStyle', { + value: mockStatusBar.setBarStyle, + writable: true, +}); + +describe('FocusAwareStatusBar', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); + mockUseColorScheme.mockReturnValue({ colorScheme: 'light' } as any); + mockNavigationBar.setVisibilityAsync.mockResolvedValue(); + mockSystemBars.mockReturnValue(null); + }); + + afterEach(() => { + // Reset Platform.OS to original value + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + describe('Platform: Android', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + }); + + it('should configure status bar and navigation bar on Android when not hidden', () => { + render(