Skip to content

Commit cafc19d

Browse files
Merge pull request #312 from alichherawalla/whisper-download-fix
Whisper download fix
2 parents 1fdda0b + f401199 commit cafc19d

13 files changed

Lines changed: 111 additions & 475 deletions

File tree

__tests__/unit/services/whisperService.test.ts

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ import { initWhisper, AudioSessionIos } from 'whisper.rn';
99
import { Platform, PermissionsAndroid } from 'react-native';
1010
import RNFS from 'react-native-fs';
1111
import { whisperService, WHISPER_MODELS } from '../../../src/services/whisperService';
12+
import { backgroundDownloadService } from '../../../src/services/backgroundDownloadService';
1213

14+
jest.mock('../../../src/services/backgroundDownloadService', () => ({
15+
backgroundDownloadService: {
16+
isAvailable: jest.fn(() => true),
17+
downloadFileTo: jest.fn(),
18+
cancelDownload: jest.fn(() => Promise.resolve()),
19+
},
20+
}));
21+
22+
const mockedBDS = backgroundDownloadService as jest.Mocked<typeof backgroundDownloadService>;
1323
const mockedAudioSessionIos = AudioSessionIos as jest.Mocked<typeof AudioSessionIos>;
1424

1525
const mockedRNFS = RNFS as jest.Mocked<typeof RNFS>;
@@ -32,6 +42,15 @@ describe('WhisperService', () => {
3242
(whisperService as any).stopFn = null;
3343
(whisperService as any).isReleasingContext = false;
3444
(whisperService as any).transcriptionFullyStopped = Promise.resolve();
45+
(whisperService as any).activeDownloadId = null;
46+
// Default backgroundDownloadService mock
47+
mockedBDS.isAvailable.mockReturnValue(true);
48+
mockedBDS.downloadFileTo.mockReturnValue({
49+
downloadId: 0,
50+
downloadIdPromise: Promise.resolve(0),
51+
promise: Promise.resolve(),
52+
} as any);
53+
mockedBDS.cancelDownload.mockResolvedValue(undefined as any);
3554
// Re-establish default AudioSessionIos mock implementations
3655
// (previous tests may have set mockRejectedValue which clearAllMocks doesn't reset)
3756
mockedAudioSessionIos.setCategory.mockResolvedValue(undefined as any);
@@ -85,71 +104,71 @@ describe('WhisperService', () => {
85104
const result = await whisperService.downloadModel('tiny.en');
86105

87106
expect(result).toBe('/mock/documents/whisper-models/ggml-tiny.en.bin');
88-
expect(RNFS.downloadFile).not.toHaveBeenCalled();
107+
expect(mockedBDS.downloadFileTo).not.toHaveBeenCalled();
89108
});
90109

91-
it('downloads via RNFS when not present', async () => {
92-
// First exists check (ensureModelsDirExists) = true, second (destPath) = false,
93-
// third (validateModelFile) = true
110+
it('downloads via backgroundDownloadService when not present', async () => {
94111
mockedRNFS.exists
95-
.mockResolvedValueOnce(true) // dir exists
112+
.mockResolvedValueOnce(true) // dir exists
96113
.mockResolvedValueOnce(false) // model not yet downloaded
97-
.mockResolvedValueOnce(true); // validation: file exists after download
114+
.mockResolvedValueOnce(true); // validateModelFile: file exists
98115
mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any);
99116

100-
mockedRNFS.downloadFile.mockReturnValue({
101-
jobId: 1,
102-
promise: Promise.resolve({ statusCode: 200, bytesWritten: 75000000 }),
117+
mockedBDS.downloadFileTo.mockReturnValue({
118+
downloadId: 1,
119+
downloadIdPromise: Promise.resolve(1),
120+
promise: Promise.resolve(),
103121
} as any);
104122

105123
const result = await whisperService.downloadModel('tiny.en');
106124

107-
expect(RNFS.downloadFile).toHaveBeenCalled();
108-
const callArgs = (RNFS.downloadFile as jest.Mock).mock.calls[0][0];
109-
expect(callArgs.fromUrl).toBe(WHISPER_MODELS[0].url);
125+
expect(mockedBDS.downloadFileTo).toHaveBeenCalledWith(expect.objectContaining({
126+
params: expect.objectContaining({ url: WHISPER_MODELS[0].url }),
127+
destPath: '/mock/documents/whisper-models/ggml-tiny.en.bin',
128+
}));
110129
expect(result).toBe('/mock/documents/whisper-models/ggml-tiny.en.bin');
111130
});
112131

113132
it('calls progress callback', async () => {
114133
mockedRNFS.exists
115-
.mockResolvedValueOnce(true) // dir exists
134+
.mockResolvedValueOnce(true) // dir exists
116135
.mockResolvedValueOnce(false) // model doesn't exist
117-
.mockResolvedValueOnce(true); // validation: file exists after download
136+
.mockResolvedValueOnce(true); // validateModelFile: file exists
118137
mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any);
119138

120-
let capturedProgressFn: any;
121-
mockedRNFS.downloadFile.mockImplementation((opts: any) => {
122-
capturedProgressFn = opts.progress;
139+
let capturedOnProgress: ((b: number, t: number) => void) | undefined;
140+
mockedBDS.downloadFileTo.mockImplementation((opts: any) => {
141+
capturedOnProgress = opts.onProgress;
123142
return {
124-
jobId: 1,
125-
promise: Promise.resolve({ statusCode: 200, bytesWritten: 75000000 }),
143+
downloadId: 1,
144+
downloadIdPromise: Promise.resolve(1),
145+
promise: Promise.resolve(),
126146
} as any;
127147
});
128148

129149
const progressCb = jest.fn();
130150
await whisperService.downloadModel('tiny.en', progressCb);
131151

132-
// Simulate progress
133-
if (capturedProgressFn) {
134-
capturedProgressFn({ bytesWritten: 37500000, contentLength: 75000000 });
152+
if (capturedOnProgress) {
153+
capturedOnProgress(37500000, 75000000);
135154
expect(progressCb).toHaveBeenCalledWith(0.5);
136155
}
137156
});
138157

139-
it('cleans up on non-200 status', async () => {
158+
it('cleans up partial file and rethrows when download fails', async () => {
140159
mockedRNFS.exists
141-
.mockResolvedValueOnce(true)
142-
.mockResolvedValueOnce(false);
160+
.mockResolvedValueOnce(true) // dir exists
161+
.mockResolvedValueOnce(false); // model not yet downloaded
162+
mockedRNFS.unlink.mockResolvedValue(undefined as any);
143163

144-
mockedRNFS.downloadFile.mockReturnValue({
145-
jobId: 1,
146-
promise: Promise.resolve({ statusCode: 500, bytesWritten: 0 }),
164+
mockedBDS.downloadFileTo.mockReturnValue({
165+
downloadId: 1,
166+
downloadIdPromise: Promise.resolve(1),
167+
promise: Promise.reject(new Error('network_lost')),
147168
} as any);
148169

149-
mockedRNFS.unlink.mockResolvedValue(undefined as any);
150-
151-
await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('Download failed');
152-
expect(RNFS.unlink).toHaveBeenCalled();
170+
await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('network_lost');
171+
expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/whisper-models/ggml-tiny.en.bin');
153172
});
154173
});
155174

android/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,7 @@
2323
<!-- For silent companion file downloads (e.g. mmproj for vision models) -->
2424
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
2525

26-
<!-- Foreground service to prevent Android from throttling/pausing large downloads -->
27-
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
28-
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
29-
30-
<!-- To check and request battery optimization exemption for uninterrupted downloads -->
26+
<!-- To check and request battery optimization exemption for uninterrupted downloads -->
3127
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
3228

3329
<application
@@ -62,13 +58,7 @@
6258
</intent-filter>
6359
</activity>
6460

65-
<!-- Foreground service to keep downloads running at high priority -->
66-
<service
67-
android:name=".download.DownloadForegroundService"
68-
android:exported="false"
69-
android:foregroundServiceType="dataSync" />
70-
71-
<!-- Receiver for background download completion events -->
61+
<!-- Receiver for background download completion events -->
7262
<receiver
7363
android:name=".download.DownloadCompleteBroadcastReceiver"
7464
android:exported="true">

0 commit comments

Comments
 (0)