Skip to content

Commit d909c4c

Browse files
committed
RU-T39 PR#176 fixes
1 parent bfdf643 commit d909c4c

File tree

6 files changed

+191
-79
lines changed

6 files changed

+191
-79
lines changed

.github/workflows/react-native-cicd.yml

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -320,34 +320,36 @@ jobs:
320320
if [ "${{ github.event_name }}" = "push" ]; then
321321
echo "Fetching PR body for merged commit..."
322322
323-
# Get the PR number associated with this merge commit
324-
PR_NUMBER=$(gh pr list \
325-
--state merged \
326-
--search "${{ github.sha }}" \
327-
--json number \
328-
--jq '.[0].number' 2>/dev/null || echo "")
323+
# First, try to find PR number from commit message (most reliable)
324+
PR_FROM_COMMIT=$(git log -1 --pretty=%B | grep -oE '#[0-9]+' | head -1 | tr -d '#' || echo "")
329325
330-
if [ -n "$PR_NUMBER" ]; then
331-
echo "Found merged PR #$PR_NUMBER"
332-
333-
# Fetch the PR body
334-
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body' 2>/dev/null || echo "")
326+
if [ -n "$PR_FROM_COMMIT" ]; then
327+
echo "Found PR #$PR_FROM_COMMIT from commit message"
328+
PR_BODY=$(gh pr view "$PR_FROM_COMMIT" --json body --jq '.body' 2>/dev/null || echo "")
335329
336330
if [ -n "$PR_BODY" ]; then
337331
NOTES="$(extract_release_notes "$PR_BODY")"
338332
fi
339333
else
340-
echo "No associated PR found, checking commit message..."
341-
# Try to find PR number from commit message
342-
PR_FROM_COMMIT=$(git log -1 --pretty=%B | grep -oE '#[0-9]+' | head -1 | tr -d '#' || echo "")
334+
echo "No PR reference in commit message, searching by commit SHA..."
335+
# Get PRs that contain this commit (using GitHub API to search by commit)
336+
PR_NUMBERS=$(gh api \
337+
"repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" \
338+
--jq '.[].number' 2>/dev/null || echo "")
343339
344-
if [ -n "$PR_FROM_COMMIT" ]; then
345-
echo "Found PR #$PR_FROM_COMMIT from commit message"
346-
PR_BODY=$(gh pr view "$PR_FROM_COMMIT" --json body --jq '.body' 2>/dev/null || echo "")
340+
if [ -n "$PR_NUMBERS" ]; then
341+
# Take the first PR found (most recently merged)
342+
PR_NUMBER=$(echo "$PR_NUMBERS" | head -n 1)
343+
echo "Found PR #$PR_NUMBER associated with commit"
344+
345+
# Fetch the PR body
346+
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body' 2>/dev/null || echo "")
347347
348348
if [ -n "$PR_BODY" ]; then
349349
NOTES="$(extract_release_notes "$PR_BODY")"
350350
fi
351+
else
352+
echo "No associated PR found for this commit"
351353
fi
352354
fi
353355
fi
@@ -376,11 +378,17 @@ jobs:
376378
env:
377379
GH_TOKEN: ${{ github.token }}
378380

379-
- name: Send Release Notes to Changerawr
380-
if: ${{ matrix.platform == 'android' && env.CHANGERAWR_API_URL != '' && env.CHANGERAWR_API_KEY != '' }}
381+
- name: 📝 Send Release Notes to Changerawr
382+
if: ${{ matrix.platform == 'android' }}
381383
run: |
382384
set -eo pipefail
383385
386+
# Check if required secrets are set
387+
if [ -z "$CHANGERAWR_API_URL" ] || [ -z "$CHANGERAWR_API_KEY" ]; then
388+
echo "⚠️ Changerawr API credentials not configured, skipping release notes submission"
389+
exit 0
390+
fi
391+
384392
# Read release notes
385393
RELEASE_NOTES=$(cat RELEASE_NOTES.md)
386394
VERSION="7.${{ github.run_number }}"

src/components/calls/dispatch-selection-modal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native';
22
import { useColorScheme } from 'nativewind';
3-
import React, { useEffect, useMemo } from 'react';
3+
import React, { useEffect } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import { ScrollView, TouchableOpacity, View } from 'react-native';
66

@@ -27,7 +27,7 @@ export const DispatchSelectionModal: React.FC<DispatchSelectionModalProps> = ({
2727
const { data, selection, isLoading, error, searchQuery, fetchDispatchData, setSelection, toggleEveryone, toggleUser, toggleGroup, toggleRole, toggleUnit, setSearchQuery, clearSelection, getFilteredData } =
2828
useDispatchStore();
2929

30-
const filteredData = useMemo(() => getFilteredData(), [getFilteredData]);
30+
const filteredData = getFilteredData();
3131

3232
useEffect(() => {
3333
if (isVisible) {

src/services/__tests__/notification-sound.service.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,21 @@ jest.mock('@/lib/logging', () => ({
4646
}));
4747

4848
describe('NotificationSoundService', () => {
49-
beforeEach(() => {
49+
beforeEach(async () => {
5050
jest.clearAllMocks();
51+
// Clean up any previous initialization state
52+
await notificationSoundService.cleanup();
53+
});
54+
55+
afterEach(async () => {
56+
// Clean up after each test
57+
await notificationSoundService.cleanup();
5158
});
5259

5360
describe('initialization', () => {
54-
it('should initialize audio mode', async () => {
55-
// Mock is called in constructor, so need to check mocks
61+
it('should initialize audio mode when initialize() is called', async () => {
62+
// Initialization is lazy, so Audio.setAudioModeAsync should not be called until initialize() is called
63+
await notificationSoundService.initialize();
5664
expect(Audio.setAudioModeAsync).toHaveBeenCalled();
5765
});
5866

@@ -62,6 +70,18 @@ describe('NotificationSoundService', () => {
6270
// Verify sounds are created (called during initialization)
6371
expect(Audio.Sound.createAsync).toHaveBeenCalled();
6472
});
73+
74+
it('should not initialize multiple times concurrently', async () => {
75+
// Call initialize multiple times concurrently
76+
await Promise.all([
77+
notificationSoundService.initialize(),
78+
notificationSoundService.initialize(),
79+
notificationSoundService.initialize(),
80+
]);
81+
82+
// Should only initialize once
83+
expect(Audio.setAudioModeAsync).toHaveBeenCalledTimes(1);
84+
});
6585
});
6686

6787
describe('playNotificationSound', () => {

src/services/__tests__/push-notification.test.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ jest.mock('react-native', () => ({
2626
}));
2727

2828
// Mock expo-notifications
29+
const mockRemove = jest.fn();
30+
const mockAddNotificationReceivedListener = jest.fn().mockReturnValue({ remove: mockRemove });
31+
const mockAddNotificationResponseReceivedListener = jest.fn().mockReturnValue({ remove: mockRemove });
32+
2933
jest.mock('expo-notifications', () => ({
30-
addNotificationReceivedListener: jest.fn(),
31-
addNotificationResponseReceivedListener: jest.fn(),
34+
addNotificationReceivedListener: mockAddNotificationReceivedListener,
35+
addNotificationResponseReceivedListener: mockAddNotificationResponseReceivedListener,
3236
removeNotificationSubscription: jest.fn(),
3337
setNotificationHandler: jest.fn(),
3438
setNotificationChannelAsync: jest.fn(),
@@ -80,16 +84,15 @@ jest.mock('@/stores/security/store', () => ({
8084
},
8185
}));
8286

83-
// Mock the push notification service
84-
const mockNotificationHandler = jest.fn();
87+
// Mock Firebase messaging
88+
const mockFcmUnsubscribe = jest.fn();
89+
const mockOnMessage = jest.fn().mockReturnValue(mockFcmUnsubscribe);
8590

86-
jest.mock('../push-notification', () => ({
87-
pushNotificationService: {
88-
getInstance: jest.fn(() => ({
89-
handleNotificationReceived: mockNotificationHandler,
90-
})),
91-
},
92-
}));
91+
jest.mock('@react-native-firebase/messaging', () => {
92+
return jest.fn(() => ({
93+
onMessage: mockOnMessage,
94+
}));
95+
});
9396

9497
describe('Push Notification Service Integration', () => {
9598
const mockShowNotificationModal = jest.fn();
@@ -369,4 +372,65 @@ describe('Push Notification Service Integration', () => {
369372
expect(mockShowNotificationModal).not.toHaveBeenCalled();
370373
});
371374
});
375+
376+
describe('listener cleanup', () => {
377+
// Dynamically require the service to avoid mocking issues
378+
let PushNotificationService: any;
379+
let service: any;
380+
381+
beforeEach(() => {
382+
jest.clearAllMocks();
383+
jest.resetModules();
384+
385+
// Re-require the module to get fresh instance
386+
jest.unmock('../push-notification');
387+
const module = require('../push-notification');
388+
service = module.pushNotificationService;
389+
});
390+
391+
it('should store listener handles on initialization', async () => {
392+
// Initialize should add listeners
393+
await service.initialize();
394+
395+
// Verify listeners were registered
396+
expect(mockAddNotificationReceivedListener).toHaveBeenCalled();
397+
expect(mockAddNotificationResponseReceivedListener).toHaveBeenCalled();
398+
expect(mockOnMessage).toHaveBeenCalled();
399+
});
400+
401+
it('should properly cleanup all listeners', async () => {
402+
// Initialize first to set up listeners
403+
await service.initialize();
404+
405+
// Clear previous calls
406+
mockRemove.mockClear();
407+
mockFcmUnsubscribe.mockClear();
408+
409+
// Call cleanup
410+
service.cleanup();
411+
412+
// Verify all listeners were removed
413+
// Expo listeners should have .remove() called twice (once for each listener)
414+
expect(mockRemove).toHaveBeenCalledTimes(2);
415+
416+
// FCM unsubscribe should be called once
417+
expect(mockFcmUnsubscribe).toHaveBeenCalledTimes(1);
418+
});
419+
420+
it('should not error when cleanup is called without initialization', () => {
421+
// Should not throw when cleanup is called without initializing
422+
expect(() => service.cleanup()).not.toThrow();
423+
});
424+
425+
it('should not error when cleanup is called multiple times', async () => {
426+
await service.initialize();
427+
428+
// Should not throw when cleanup is called multiple times
429+
expect(() => {
430+
service.cleanup();
431+
service.cleanup();
432+
service.cleanup();
433+
}).not.toThrow();
434+
});
435+
});
372436
});

src/services/notification-sound.service.ts

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ class NotificationSoundService {
1212
private groupChatSound: Audio.Sound | null = null;
1313
private defaultSound: Audio.Sound | null = null;
1414
private isInitialized = false;
15+
private initializationPromise: Promise<void> | null = null;
1516

1617
private constructor() {
17-
this.initializeAudio();
18+
// Initialization will happen lazily on first use
1819
}
1920

2021
static getInstance(): NotificationSoundService {
@@ -29,10 +30,22 @@ class NotificationSoundService {
2930
}
3031

3132
private async initializeAudio(): Promise<void> {
33+
// If already initialized, return immediately
3234
if (this.isInitialized) {
3335
return;
3436
}
3537

38+
// If initialization is in progress, wait for it to complete
39+
if (this.initializationPromise) {
40+
return this.initializationPromise;
41+
}
42+
43+
// Start initialization and store the promise
44+
this.initializationPromise = this.performInitialization();
45+
await this.initializationPromise;
46+
}
47+
48+
private async performInitialization(): Promise<void> {
3649
try {
3750
// Configure audio mode
3851
await Audio.setAudioModeAsync({
@@ -60,6 +73,8 @@ class NotificationSoundService {
6073
message: 'Failed to initialize notification sound service',
6174
context: { error },
6275
});
76+
// Reset initialization promise on failure so it can be retried
77+
this.initializationPromise = null;
6378
}
6479
}
6580

@@ -83,40 +98,46 @@ class NotificationSoundService {
8398
}
8499
}
85100

86-
private async loadAudioFiles(): Promise<void> {
101+
/**
102+
* Helper method to load a sound asset from a require path
103+
* @param assetRequire - The required asset module
104+
* @param soundName - Name of the sound for logging purposes
105+
* @returns The created Audio.Sound or null on failure
106+
*/
107+
private async loadSound(assetRequire: number, soundName: string): Promise<Audio.Sound | null> {
87108
try {
88-
// Load call sound
89-
const callSoundAsset = Asset.fromModule(require('@assets/audio/newcall.wav'));
90-
await callSoundAsset.downloadAsync();
109+
const asset = Asset.fromModule(assetRequire);
110+
await asset.downloadAsync();
91111

92-
const { sound: callSound } = await Audio.Sound.createAsync({ uri: callSoundAsset.localUri || callSoundAsset.uri } as AVPlaybackSource, {
112+
const { sound } = await Audio.Sound.createAsync({ uri: asset.localUri || asset.uri } as AVPlaybackSource, {
93113
shouldPlay: false,
94114
isLooping: false,
95115
volume: 1.0,
96116
});
97-
this.callSound = callSound;
98-
99-
// Load message sound
100-
const messageSoundAsset = Asset.fromModule(require('@assets/audio/newmessage.wav'));
101-
await messageSoundAsset.downloadAsync();
102117

103-
const { sound: messageSound } = await Audio.Sound.createAsync({ uri: messageSoundAsset.localUri || messageSoundAsset.uri } as AVPlaybackSource, {
104-
shouldPlay: false,
105-
isLooping: false,
106-
volume: 1.0,
118+
return sound;
119+
} catch (error) {
120+
logger.error({
121+
message: `Failed to load sound: ${soundName}`,
122+
context: { error },
107123
});
108-
this.messageSound = messageSound;
124+
return null;
125+
}
126+
}
127+
128+
private async loadAudioFiles(): Promise<void> {
129+
try {
130+
// Load call sound
131+
this.callSound = await this.loadSound(require('@assets/audio/newcall.wav'), 'call');
132+
133+
// Load message sound
134+
this.messageSound = await this.loadSound(require('@assets/audio/newmessage.wav'), 'message');
109135

110136
// Load chat sound
111137
const chatSoundAsset = Asset.fromModule(require('@assets/audio/newchat.wav'));
112138
await chatSoundAsset.downloadAsync();
113139

114-
const { sound: chatSound } = await Audio.Sound.createAsync({ uri: chatSoundAsset.localUri || chatSoundAsset.uri } as AVPlaybackSource, {
115-
shouldPlay: false,
116-
isLooping: false,
117-
volume: 1.0,
118-
});
119-
this.chatSound = chatSound;
140+
this.chatSound = await this.loadSound(require('@assets/audio/newchat.wav'), 'chat');
120141

121142
// Group chat uses the same sound as regular chat
122143
const { sound: groupChatSound } = await Audio.Sound.createAsync({ uri: chatSoundAsset.localUri || chatSoundAsset.uri } as AVPlaybackSource, {
@@ -127,15 +148,7 @@ class NotificationSoundService {
127148
this.groupChatSound = groupChatSound;
128149

129150
// Load default notification sound
130-
const defaultSoundAsset = Asset.fromModule(require('@assets/audio/notification.wav'));
131-
await defaultSoundAsset.downloadAsync();
132-
133-
const { sound: defaultSound } = await Audio.Sound.createAsync({ uri: defaultSoundAsset.localUri || defaultSoundAsset.uri } as AVPlaybackSource, {
134-
shouldPlay: false,
135-
isLooping: false,
136-
volume: 1.0,
137-
});
138-
this.defaultSound = defaultSound;
151+
this.defaultSound = await this.loadSound(require('@assets/audio/notification.wav'), 'default');
139152

140153
logger.debug({
141154
message: 'Notification audio files loaded successfully',
@@ -235,6 +248,7 @@ class NotificationSoundService {
235248
}
236249

237250
this.isInitialized = false;
251+
this.initializationPromise = null;
238252

239253
logger.info({
240254
message: 'Notification sound service cleaned up',

0 commit comments

Comments
 (0)