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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ expo-env.d.ts
.mcp.json
opencode.json
/.dual-graph-pro
.DS_Store
Binary file added assets/audio/modernavailabilityalert.wav
Binary file not shown.
Binary file added assets/audio/moderncalendar.wav
Binary file not shown.
Binary file added assets/audio/moderncallclosed.wav
Binary file not shown.
Binary file added assets/audio/moderncallemergency.wav
Binary file not shown.
Binary file added assets/audio/moderncallhigh.wav
Binary file not shown.
Binary file added assets/audio/moderncalllow.wav
Binary file not shown.
Binary file added assets/audio/moderncallmedium.wav
Binary file not shown.
Binary file added assets/audio/moderncallupdated.wav
Binary file not shown.
Binary file added assets/audio/modernchat.wav
Binary file not shown.
Binary file added assets/audio/modernmessage.wav
Binary file not shown.
Binary file added assets/audio/modernnotification.wav
Binary file not shown.
Binary file added assets/audio/modernpersonnelstatus.wav
Binary file not shown.
Binary file added assets/audio/modernresourceorder.wav
Binary file not shown.
Binary file added assets/audio/modernshift.wav
Binary file not shown.
Binary file added assets/audio/modernstaffing.wav
Binary file not shown.
Binary file added assets/audio/moderntraining.wav
Binary file not shown.
Binary file added assets/audio/moderntroublealert.wav
Binary file not shown.
Binary file added assets/audio/modernunitnotice.wav
Binary file not shown.
Binary file added assets/audio/modernunitstatus.wav
Binary file not shown.
Binary file added assets/audio/modernweatheralert.wav
Binary file not shown.
21 changes: 21 additions & 0 deletions plugins/withNotificationSounds.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ const soundFiles = [
'assets/audio/unitstatusupdated.wav',
'assets/audio/upcomingshift.wav',
'assets/audio/upcomingtraining.wav',
// Modern notification sound set
'assets/audio/modernnotification.wav',
'assets/audio/modernavailabilityalert.wav',
'assets/audio/moderncalendar.wav',
'assets/audio/moderncallclosed.wav',
'assets/audio/moderncallemergency.wav',
'assets/audio/moderncallhigh.wav',
'assets/audio/moderncalllow.wav',
'assets/audio/moderncallmedium.wav',
'assets/audio/moderncallupdated.wav',
'assets/audio/modernchat.wav',
'assets/audio/modernmessage.wav',
'assets/audio/modernpersonnelstatus.wav',
'assets/audio/modernresourceorder.wav',
'assets/audio/modernshift.wav',
'assets/audio/modernstaffing.wav',
'assets/audio/moderntraining.wav',
'assets/audio/moderntroublealert.wav',
'assets/audio/modernunitnotice.wav',
'assets/audio/modernunitstatus.wav',
'assets/audio/modernweatheralert.wav',
'assets/audio/custom/c1.wav',
'assets/audio/custom/c2.wav',
'assets/audio/custom/c3.wav',
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Item } from '@/components/settings/item';
import { KeepAliveItem } from '@/components/settings/keep-alive-item';
import { LanguageItem } from '@/components/settings/language-item';
import { LoginInfoBottomSheet } from '@/components/settings/login-info-bottom-sheet';
import { ModernNotificationSoundsItem } from '@/components/settings/modern-notification-sounds-item';
import { ServerUrlBottomSheet } from '@/components/settings/server-url-bottom-sheet';
import { ThemeItem } from '@/components/settings/theme-item';
import { ToggleItem } from '@/components/settings/toggle-item';
Expand Down Expand Up @@ -131,6 +132,7 @@ export default function Settings() {
<ThemeItem />
<LanguageItem />
<KeepAliveItem />
<ModernNotificationSoundsItem />
<BackgroundGeolocationItem />
<BluetoothDeviceItem />
</VStack>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';

import { ModernNotificationSoundsItem } from '../modern-notification-sounds-item';

// Mock the translation hook
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'settings.modern_notification_sounds': 'Modern Notification Sounds',
'settings.modern_notification_sounds_description': 'Use the new modern sound set for push notifications.',
};
return translations[key] || key;
},
}),
}));

// Control the platform per test (component is Android-only).
let mockIsAndroid = true;
jest.mock('@/lib/platform', () => ({
get isAndroid() {
return mockIsAndroid;
},
}));

// Control the preference hook.
const mockSetModernSoundsEnabled = jest.fn();
let mockIsModernSoundsEnabled = true;
jest.mock('@/lib/hooks/use-modern-notification-sounds', () => ({
useModernNotificationSounds: () => ({
isModernSoundsEnabled: mockIsModernSoundsEnabled,
setModernSoundsEnabled: mockSetModernSoundsEnabled,
}),
}));

describe('ModernNotificationSoundsItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsAndroid = true;
mockIsModernSoundsEnabled = true;
});

it('renders the label and description on Android', () => {
render(<ModernNotificationSoundsItem />);

expect(screen.getByText('Modern Notification Sounds')).toBeTruthy();
expect(screen.getByText('Use the new modern sound set for push notifications.')).toBeTruthy();
});

it('renders nothing on non-Android platforms', () => {
mockIsAndroid = false;

render(<ModernNotificationSoundsItem />);

expect(screen.queryByText('Modern Notification Sounds')).toBeNull();
});

it('reflects the enabled state on the switch', () => {
mockIsModernSoundsEnabled = true;

render(<ModernNotificationSoundsItem />);

expect(screen.getByRole('switch').props.value).toBe(true);
});

it('calls the setter when toggled off', () => {
mockIsModernSoundsEnabled = true;

render(<ModernNotificationSoundsItem />);

fireEvent(screen.getByRole('switch'), 'valueChange', false);

expect(mockSetModernSoundsEnabled).toHaveBeenCalledWith(false);
});
});
45 changes: 45 additions & 0 deletions src/components/settings/modern-notification-sounds-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';

import { useModernNotificationSounds } from '@/lib/hooks/use-modern-notification-sounds';
import { isAndroid } from '@/lib/platform';

import { Switch } from '../ui/switch';
import { Text } from '../ui/text';
import { View } from '../ui/view';
import { VStack } from '../ui/vstack';

export const ModernNotificationSoundsItem = () => {
const { isModernSoundsEnabled, setModernSoundsEnabled } = useModernNotificationSounds();
const { t } = useTranslation();
const { colorScheme } = useColorScheme();

const handleToggle = React.useCallback(
(value: boolean) => {
setModernSoundsEnabled(value);
},
[setModernSoundsEnabled]
);

// Notification channel sounds are an Android-only concept; hide on other platforms.
if (!isAndroid) {
return null;
}

return (
<VStack space="sm">
<View className="flex-1 flex-row items-center justify-between px-4 py-2">
<View className="flex-1 flex-row items-center pr-3">
<Text>{t('settings.modern_notification_sounds')}</Text>
</View>
<View className="flex-row items-center">
<Switch size="md" value={isModernSoundsEnabled} onValueChange={handleToggle} />
</View>
</View>
<View className="px-4">
<Text className={`text-xs ${colorScheme === 'dark' ? 'text-neutral-400' : 'text-neutral-500'}`}>{t('settings.modern_notification_sounds_description')}</Text>
</View>
</VStack>
);
};
33 changes: 33 additions & 0 deletions src/lib/hooks/use-modern-notification-sounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { Platform } from 'react-native';
import { useMMKVBoolean } from 'react-native-mmkv';

import { storage } from '@/lib/storage';
import { MODERN_NOTIFICATION_SOUNDS_ENABLED } from '@/lib/storage/notification-prefs';
import { pushNotificationService } from '@/services/push-notification';

/**
* Android-only hook for the "use modern notification sounds" preference.
*
* Defaults to enabled (modern sounds) and is persisted in MMKV alongside the
* other app settings. When toggled on Android, the notification channels are
* recreated so the new sound takes effect — a channel's sound is immutable
* after it is created, so it must be deleted and recreated to change it.
*/
export const useModernNotificationSounds = () => {
const [enabled, _setEnabled] = useMMKVBoolean(MODERN_NOTIFICATION_SOUNDS_ENABLED, storage);

const setModernSoundsEnabled = React.useCallback(
async (value: boolean) => {
_setEnabled(value);
if (Platform.OS === 'android') {
await pushNotificationService.refreshAndroidNotificationChannels();
}
},
[_setEnabled]
);
Comment on lines +20 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled promise rejection from pushNotificationService.refreshAndroidNotificationChannels() at line 24 risks leaving the app in an inconsistent state if _setEnabled(value) at line 22 already updated the MMKV preference. Wrap the awaited operations in a try/catch block with structured error logging to match the patterns used in sibling hooks like useKeepAlive and useBackgroundGeolocation.

Kody rule violation: Handle async operations with proper error handling

const setModernSoundsEnabled = React.useCallback(
  async (value: boolean) => {
    try {
      _setEnabled(value);
      if (Platform.OS === 'android') {
        await pushNotificationService.refreshAndroidNotificationChannels();
      }
    } catch (error) {
      logger.error({
        message: 'Failed to update modern notification sounds',
        context: { error, value },
      });
    }
  },
  [_setEnabled]
);
Prompt for LLM

File src/lib/hooks/use-modern-notification-sounds.ts:

Line 20 to 28:

Violates rule 'Handle async operations with proper error handling': the `await pushNotificationService.refreshAndroidNotificationChannels()` at line 24 is not wrapped in try/catch or .catch, leaving a potential unhandled promise rejection. The team's sibling hooks (useKeepAlive and useBackgroundGeolocation) both guard their awaited operations in try/catch with structured error logging. If refreshAndroidNotificationChannels rejects, the MMKV preference has already been flipped (_setEnabled(value) at line 22) but the Android channels will not be updated, leaving the app in an inconsistent state with no error surfaced.

Suggested Code:

  const setModernSoundsEnabled = React.useCallback(
    async (value: boolean) => {
      try {
        _setEnabled(value);
        if (Platform.OS === 'android') {
          await pushNotificationService.refreshAndroidNotificationChannels();
        }
      } catch (error) {
        logger.error({
          message: 'Failed to update modern notification sounds',
          context: { error, value },
        });
      }
    },
    [_setEnabled]
  );

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


// Default ON when the user has not set a preference.
const isModernSoundsEnabled = enabled ?? true;
return { isModernSoundsEnabled, setModernSoundsEnabled } as const;
};
36 changes: 36 additions & 0 deletions src/lib/storage/notification-prefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { storage } from '@/lib/storage';

/**
* MMKV key for the Android-only "use modern notification sounds" preference.
* Defaults to enabled (modern sounds) when the user has not set a preference.
*/
export const MODERN_NOTIFICATION_SOUNDS_ENABLED = 'MODERN_NOTIFICATION_SOUNDS_ENABLED';

/**
* MMKV key tracking which sound set the Android notification channels were last
* created with. Android notification channel sound is immutable after creation,
* so this marker lets us detect when channels need to be deleted and recreated.
*/
const NOTIFICATION_SOUND_MODE_APPLIED = 'NOTIFICATION_SOUND_MODE_APPLIED';

export type NotificationSoundMode = 'modern' | 'classic';

/**
* Whether modern notification sounds are enabled. Defaults to true (modern is
* the default) when the user has not made a choice.
*/
export const getModernNotificationSoundsEnabled = (): boolean => storage.getBoolean(MODERN_NOTIFICATION_SOUNDS_ENABLED) ?? true;

/**
* The sound mode the Android channels were last created with, or undefined if
* they have never been created (fresh install or app upgrade).
*/
export const getAppliedNotificationSoundMode = (): NotificationSoundMode | undefined => {
const mode = storage.getString(NOTIFICATION_SOUND_MODE_APPLIED);
return mode === 'modern' || mode === 'classic' ? mode : undefined;
};

/** Persist the sound mode the Android channels were created with. */
export const setAppliedNotificationSoundMode = (mode: NotificationSoundMode): void => {
storage.set(NOTIFICATION_SOUND_MODE_APPLIED, mode);
};
93 changes: 93 additions & 0 deletions src/services/__tests__/push-notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,21 @@ jest.mock('expo-notifications', () => ({
AndroidNotificationVisibility: { PUBLIC: 1 },
}));

// Mock the modern-sounds preference module (controls Android channel sounds)
const mockGetModernNotificationSoundsEnabled = jest.fn((): boolean => true);
const mockGetAppliedNotificationSoundMode = jest.fn((): string | undefined => undefined);
const mockSetAppliedNotificationSoundMode = jest.fn();

jest.mock('@/lib/storage/notification-prefs', () => ({
getModernNotificationSoundsEnabled: mockGetModernNotificationSoundsEnabled,
getAppliedNotificationSoundMode: mockGetAppliedNotificationSoundMode,
setAppliedNotificationSoundMode: mockSetAppliedNotificationSoundMode,
}));

// Mock Notifee (channels, categories, foreground/background events, check-in)
const mockNotifeeForegroundUnsubscribe = jest.fn();
const mockCreateChannel = jest.fn(() => Promise.resolve());
const mockDeleteChannel = jest.fn(() => Promise.resolve());
const mockSetNotificationCategories = jest.fn(() => Promise.resolve());
const mockNotifeeRequestPermission = jest.fn(() =>
Promise.resolve({
Expand All @@ -112,6 +124,7 @@ jest.mock('@notifee/react-native', () => ({
__esModule: true,
default: {
createChannel: mockCreateChannel,
deleteChannel: mockDeleteChannel,
setNotificationCategories: mockSetNotificationCategories,
requestPermission: mockNotifeeRequestPermission,
displayNotification: mockDisplayNotification,
Expand Down Expand Up @@ -554,5 +567,85 @@ describe('Push Notification Service Integration', () => {
// Total: 32 channels
expect(mockCreateChannel).toHaveBeenCalledTimes(32);
});

it('should use modern sounds for the standard channels by default', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(true);

await pushNotificationService.initialize();

expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'calls', sound: 'modernnotification' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'moderncallemergency' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '1', sound: 'moderncallhigh' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '2', sound: 'moderncallmedium' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '3', sound: 'moderncalllow' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'notif', sound: 'modernnotification' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'message', sound: 'modernmessage' }));
});

it('should use classic sounds when modern sounds are disabled', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(false);

await pushNotificationService.initialize();

expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'calls', sound: 'notification' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'callemergency' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'notif', sound: 'notification' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'message', sound: 'newmessage' }));
// Never falls back to a modern sound when disabled.
expect(mockCreateChannel).not.toHaveBeenCalledWith(expect.objectContaining({ sound: 'moderncallemergency' }));
});

it('should leave custom call channels (c1-c25) unaffected by the setting', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(true);

await pushNotificationService.initialize();

expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1', sound: 'c1' }));
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'c25', sound: 'c25' }));
});

it('should delete the standard sound channels before recreating them when the mode changes', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(true);
mockGetAppliedNotificationSoundMode.mockReturnValue('classic');

await pushNotificationService.initialize();

// The 7 standard sound channels are deleted so they can be recreated with the new sound.
expect(mockDeleteChannel).toHaveBeenCalledTimes(7);
expect(mockDeleteChannel).toHaveBeenCalledWith('0');
expect(mockDeleteChannel).toHaveBeenCalledWith('notif');
expect(mockDeleteChannel).not.toHaveBeenCalledWith('c1');
});

it('should not delete channels when the sound mode is unchanged', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(true);
mockGetAppliedNotificationSoundMode.mockReturnValue('modern');

await pushNotificationService.initialize();

expect(mockDeleteChannel).not.toHaveBeenCalled();
expect(mockCreateChannel).toHaveBeenCalledTimes(32);
});

it('should persist the applied sound mode after setup', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(true);
mockGetAppliedNotificationSoundMode.mockReturnValue('modern');

await pushNotificationService.initialize();

expect(mockSetAppliedNotificationSoundMode).toHaveBeenCalledWith('modern');
});

it('refreshAndroidNotificationChannels recreates channels with the current setting', async () => {
mockGetModernNotificationSoundsEnabled.mockReturnValue(false);
mockGetAppliedNotificationSoundMode.mockReturnValue('modern');

await pushNotificationService.refreshAndroidNotificationChannels();

// Mode changed modern -> classic, so the standard channels are deleted and recreated.
expect(mockDeleteChannel).toHaveBeenCalledTimes(7);
expect(mockCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ id: '0', sound: 'callemergency' }));
expect(mockSetAppliedNotificationSoundMode).toHaveBeenCalledWith('classic');
});
});
});
Loading
Loading