Skip to content

Commit 43337a9

Browse files
committed
fix(app): stop capture before closing and break push token recursion
1 parent eb12c62 commit 43337a9

5 files changed

Lines changed: 66 additions & 10 deletions

File tree

‎app/scripts/test-phone-audio-diagnostics.cjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const writes = [];
2828
const socketSourcePath = path.join(__dirname, '../src/protocol/audioV2Socket.ts');
2929
const serverControls = {
3030
hello: { event: { case: 'hello', value: {} } },
31+
captureStopped: { event: { case: 'captureStopped', value: {} } },
3132
captureStarted: {
3233
event: {
3334
case: 'captureStarted',
@@ -127,6 +128,15 @@ async function declaredUplinkDuration(frameDurationMs) {
127128
});
128129
transport.receive('captureStarted');
129130
await starting;
131+
let stopped = false;
132+
const stopping = socket.stopCapture().then(() => { stopped = true; });
133+
await Promise.resolve();
134+
assert.equal(stopped, false, 'stop must await the server acknowledgement');
135+
assert.equal(transport.sent.at(-1).event.case, 'stopCapture');
136+
transport.receive('captureStopped');
137+
await stopping;
138+
assert.equal(socket.activeBinding, null, 'acknowledged stop must release the capture');
139+
socket.close();
130140
return transport.sent.map(control => (
131141
control.event.value.audioSpec ?? control.event.value.supportedUplink?.[0]
132142
)).filter(Boolean).map(spec => spec.frameDuration.nanos);

‎app/scripts/test-phone-audio-streaming.cjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const socketFrameDurations = [];
3838
const diagnostics = [];
3939
let backendStops = 0;
4040
let socketCloses = 0;
41+
let duringBackendStop = () => {};
4142

4243
class MockAudioV2Socket {
4344
constructor(options) {
@@ -66,6 +67,7 @@ class MockAudioV2Socket {
6667

6768
async stopCapture() {
6869
backendStops += 1;
70+
await duringBackendStop();
6971
this.activeBinding = null;
7072
}
7173

@@ -165,7 +167,19 @@ const { useAudioStreamer } = loadTypeScript(sourcePath, {
165167
assert.deepEqual(Array.from(sentPackets[0].opus), [1, 2, 3]);
166168
assert.deepEqual(diagnostics, [['sent', 3], ['accepted', 0]]);
167169

170+
duringBackendStop = async () => {
171+
const before = sentPackets.length;
172+
streamer.sendFrame('phone', {
173+
captureEpoch: 7,
174+
capturedAtMs: 1_780_000_000_040,
175+
monotonicTimestampMs: now + 40,
176+
opus: new Uint8Array([4]),
177+
});
178+
assert.equal(sentPackets.length, before, 'queued native callbacks must not send after stop');
179+
assert.equal(nativeStops, 1, 'microphone must stop before waiting for the backend');
180+
};
168181
await streamer.stopStreaming();
182+
duringBackendStop = () => {};
169183
assert.equal(backendStops, 1, 'the capture has one stop owner');
170184
assert.equal(nativeStops, 1, 'stopping the stream also stops the native phone session');
171185
assert.equal(socketCloses, 1);
@@ -180,6 +194,28 @@ const { useAudioStreamer } = loadTypeScript(sourcePath, {
180194
assert.equal(beginCaptureCalls[1].deliveryClass, DeliveryClass.LIVE);
181195
await wearableStreamer.stopStreaming();
182196

197+
for (const failureAt of ['native', 'backend']) {
198+
const failingStreamer = useAudioStreamer();
199+
const expected = new Error(`${failureAt} stop failed`);
200+
await failingStreamer.startStreaming('wss://chronicle.invalid/ws/audio', {
201+
kind: 'phone',
202+
...phoneVoice,
203+
stopCapture: async () => {
204+
if (failureAt === 'native') throw expected;
205+
},
206+
});
207+
const closesBefore = socketCloses;
208+
duringBackendStop = async () => { throw expected; };
209+
await assert.rejects(failingStreamer.stopStreaming(), error => error === expected);
210+
assert.equal(socketCloses, closesBefore + 1, 'stop failure must still close the transport');
211+
const packetsBefore = sentPackets.length;
212+
failingStreamer.sendFrame('phone', {
213+
captureEpoch: 7, capturedAtMs: 1_780_000_000_060,
214+
monotonicTimestampMs: now + 60, opus: new Uint8Array([5]),
215+
});
216+
assert.equal(sentPackets.length, packetsBefore, 'failed stop must leave capture inactive');
217+
}
218+
183219
console.log('phone audio streaming tests passed');
184220
})().catch(error => {
185221
console.error(error);

‎app/scripts/test-push-notifications.cjs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,19 @@ const requests = [];
3131
const opened = [];
3232
const alerts = [];
3333
let pushTokenListener = null;
34+
let nativeTokenFetches = 0;
3435
const notifications = {
3536
PermissionStatus: { DENIED: 'denied' },
3637
AndroidImportance: { HIGH: 4, DEFAULT: 3 },
3738
getPermissionsAsync: async () => permission,
3839
requestPermissionsAsync: async () => permission,
39-
getExpoPushTokenAsync: async ({ projectId }) => {
40+
getExpoPushTokenAsync: async ({ projectId, devicePushToken }) => {
4041
assert.equal(projectId, 'project-one');
42+
// Expo requests a native token if none is supplied; iOS emits the listener
43+
// again when that request completes. Bound the buggy loop in this fixture.
44+
if (!devicePushToken && pushTokenListener && ++nativeTokenFetches < 5) {
45+
pushTokenListener({ type: 'ios', data: 'rotated' });
46+
}
4147
return { data: 'ExpoPushToken[abcdefghijklmnopqrstuvwxyz]' };
4248
},
4349
setNotificationHandler() {},
@@ -100,8 +106,9 @@ const push = loadTypeScript(sourcePath, {
100106
await push.refreshPushRegistration('wss://chronicle/ws/audio');
101107
assert.equal(requests.length, 2, 'authenticated launch refreshes the registration');
102108
const stopTokenListener = push.listenForPushTokenChanges('wss://chronicle/ws/audio');
103-
pushTokenListener({ type: 'expo', data: 'rotated' });
109+
pushTokenListener({ type: 'ios', data: 'rotated' });
104110
await new Promise(resolve => setImmediate(resolve));
111+
assert.equal(nativeTokenFetches, 0, 'rotation must use the supplied native token without requesting another');
105112
assert.equal(requests.length, 3, 'native token changes refresh the Expo registration');
106113
stopTokenListener();
107114

‎app/src/hooks/useAudioStreamer.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,16 @@ export const useAudioStreamer = (): UseAudioStreamer => {
142142
heartbeatRef.current = null;
143143
const socket = socketRef.current;
144144
try {
145+
if (sourceRef.current?.kind === 'phone') {
146+
await sourceRef.current.stopCapture();
147+
}
145148
await socket?.stopCapture();
146149
} finally {
147150
socket?.close();
148151
socketRef.current = null;
149152
playbackSubscriptionRef.current?.remove();
150153
playbackSubscriptionRef.current = null;
151154
playbackRef.current = null;
152-
if (sourceRef.current?.kind === 'phone') {
153-
await sourceRef.current.stopCapture();
154-
}
155155
sourceRef.current = null;
156156
setIsStreaming(false);
157157
setIsConnecting(false);
@@ -297,7 +297,7 @@ export const useAudioStreamer = (): UseAudioStreamer => {
297297
source: AudioStreamSource['kind'],
298298
frame: CapturedOpusFrame,
299299
) => {
300-
if (!frame.opus.length) return;
300+
if (stoppedRef.current || !frame.opus.length) return;
301301
const activeSource = sourceRef.current;
302302
const socket = socketRef.current;
303303
if (!socket?.activeBinding || activeSource?.kind !== source) return;

‎app/src/services/pushNotifications.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ export const configureNotificationPresentation = async (): Promise<void> => {
4545
}
4646
};
4747

48-
const registerToken = async (backendUrl: string): Promise<void> => {
48+
const registerToken = async (
49+
backendUrl: string,
50+
devicePushToken?: Notifications.DevicePushToken,
51+
): Promise<void> => {
4952
if (!projectId) throw new Error('This build has no EAS project ID.');
50-
const token = await Notifications.getExpoPushTokenAsync({ projectId });
53+
const token = await Notifications.getExpoPushTokenAsync({ projectId, devicePushToken });
5154
const installationId = await getOrCreateInstallationId();
5255
const response = await fetchAuthed(
5356
`${deriveBaseUrl(backendUrl)}/api/notifications/devices/${encodeURIComponent(installationId)}`,
@@ -123,8 +126,8 @@ export const startNotificationTapHandling = async (): Promise<() => void> => {
123126
/** Native-token rotation means the Expo token must be fetched and registered again. */
124127
export const listenForPushTokenChanges = (backendUrl: string): (() => void) => {
125128
if (Platform.OS === 'web') return () => {};
126-
const subscription = Notifications.addPushTokenListener(() => {
127-
void refreshPushRegistration(backendUrl).catch(error => {
129+
const subscription = Notifications.addPushTokenListener(devicePushToken => {
130+
void registerToken(backendUrl, devicePushToken).catch(error => {
128131
console.warn('[Notifications] token refresh failed:', error);
129132
});
130133
});

0 commit comments

Comments
 (0)