From 1eb738483d3dc540efbf5cc70b382a4dd91562c8 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 02:30:29 +0530 Subject: [PATCH 1/3] fix(sonar): resolve new-code reliability issues from the 0.0.103 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud's quality gate flagged new_reliability_rating=D on the release. Fix the core-repo bugs: - activeDownloadPersistence: give .sort() an explicit string comparator (deterministic signature). - useVoiceDownloadItems / modelPreloader: callHook returns R|undefined, so a Promise never actually reached a boolean condition — await the optional chain (?./await undefined) instead so no Promise sits in a conditional (SonarJS no-misused-promises). - modelManager.test: downloadId is typed string; the mock returned a number and assertions compared against 42 — align every downloadId to '42' so the assertion types match at runtime. - Android delete(): use the Boolean return — log a warning on a failed best-effort cleanup, matching the existing pattern in WorkerDownload (both DownloadManagerModule + WorkerDownload). (SonarCloud #2 McpServerModal is in the pro/ submodule — separate pro PR.) --- __tests__/unit/services/modelManager.test.ts | 20 +++++++++---------- .../download/DownloadManagerModule.kt | 3 ++- .../offgridmobile/download/WorkerDownload.kt | 2 +- .../useVoiceDownloadItems.ts | 5 +++-- src/services/activeDownloadPersistence.ts | 2 +- src/services/modelPreloader.ts | 5 +++-- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/__tests__/unit/services/modelManager.test.ts b/__tests__/unit/services/modelManager.test.ts index 3cf81be69..fe60a588e 100644 --- a/__tests__/unit/services/modelManager.test.ts +++ b/__tests__/unit/services/modelManager.test.ts @@ -474,7 +474,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); // mmProjExists (no mmproj) mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -486,7 +486,7 @@ describe('ModelManager', () => { const result = await modelManager.downloadModelBackground('test/model', file); expect(mockedBackgroundDownloadService.startDownload).toHaveBeenCalled(); - expect(result.downloadId).toBe(42); + expect(result.downloadId).toBe('42'); }); it('sets up progress listener during start and complete/error via watchDownload', async () => { @@ -498,7 +498,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -510,9 +510,9 @@ describe('ModelManager', () => { const info = await modelManager.downloadModelBackground('test/model', file); modelManager.watchDownload(info.downloadId, jest.fn(), jest.fn()); - expect(mockedBackgroundDownloadService.onProgress).toHaveBeenCalledWith(42, expect.any(Function)); - expect(mockedBackgroundDownloadService.onComplete).toHaveBeenCalledWith(42, expect.any(Function)); - expect(mockedBackgroundDownloadService.onError).toHaveBeenCalledWith(42, expect.any(Function)); + expect(mockedBackgroundDownloadService.onProgress).toHaveBeenCalledWith('42', expect.any(Function)); + expect(mockedBackgroundDownloadService.onComplete).toHaveBeenCalledWith('42', expect.any(Function)); + expect(mockedBackgroundDownloadService.onError).toHaveBeenCalledWith('42', expect.any(Function)); }); it('calls metadata callback with download info', async () => { @@ -524,7 +524,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -538,7 +538,7 @@ describe('ModelManager', () => { await modelManager.downloadModelBackground('test/model', file); - expect(metadataCallback).toHaveBeenCalledWith(42, expect.objectContaining({ + expect(metadataCallback).toHaveBeenCalledWith('42', expect.objectContaining({ modelId: 'test/model', fileName: 'bg-model.gguf', })); @@ -562,7 +562,7 @@ describe('ModelManager', () => { mockedBackgroundDownloadService.startDownload .mockResolvedValueOnce({ - downloadId: 42, + downloadId: '42', fileName: 'vision.gguf', modelId: 'test/model', status: 'pending', @@ -1674,7 +1674,7 @@ describe('ModelManager', () => { mockedBackgroundDownloadService.startDownload .mockResolvedValueOnce({ - downloadId: 42, + downloadId: '42', fileName: 'bg-vision.gguf', modelId: 'test/model', status: 'pending', diff --git a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt index dc06ca102..1acfef654 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt @@ -9,6 +9,7 @@ import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import android.os.Environment +import android.util.Log import android.os.PowerManager import android.provider.Settings import androidx.lifecycle.Observer @@ -162,7 +163,7 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : if (download != null) { downloadDao.updateStatus(downloadId, DownloadStatus.CANCELLED, DownloadReason.USER_CANCELLED) val file = File(download.destination) - if (file.exists()) file.delete() + if (file.exists() && !file.delete()) Log.w("DownloadManagerModule", "Failed to delete cancelled download file: ${file.path}") } } WorkerDownload.cancel(reactApplicationContext, downloadId) diff --git a/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt b/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt index 26e3830fc..1f35b8253 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt @@ -275,7 +275,7 @@ class WorkerDownload( val current = downloadDao.getDownload(downloadId) ?: download return if (current.status == DownloadStatus.CANCELLED) { val partialFile = File(current.destination) - if (partialFile.exists()) partialFile.delete() + if (partialFile.exists() && !partialFile.delete()) Log.w(TAG, "Failed to delete partial file on cancel: ${partialFile.path}") Result.failure() } else { // System stopped the worker — retry silently, no JS state change. diff --git a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts index d157055dc..c2da0312c 100644 --- a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts +++ b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts @@ -85,8 +85,9 @@ async function deleteItem(item: DownloadItem): Promise { // Models screen keep showing the deleted model as present/active. await useWhisperStore.getState().deleteModelById(item.modelId); } else { - const pending = callHook>(HOOKS.downloadsDeleteVoiceModel, item.modelId); - if (pending) await pending.catch(() => {}); + // callHook returns undefined when the hook isn't registered (free build); await the promise + // only when it exists. `?.` keeps a Promise out of a boolean condition (SonarJS). + await callHook>(HOOKS.downloadsDeleteVoiceModel, item.modelId)?.catch(() => {}); } } diff --git a/src/services/activeDownloadPersistence.ts b/src/services/activeDownloadPersistence.ts index d57810126..766427da2 100644 --- a/src/services/activeDownloadPersistence.ts +++ b/src/services/activeDownloadPersistence.ts @@ -64,7 +64,7 @@ export function initActiveDownloadPersistence(): void { subscribed = true; useDownloadStore.subscribe((state) => { const active = serializeActiveDownloads(state.downloads); - const signature = active.map((e) => `${e.modelKey}:${e.status}`).sort().join('|'); + const signature = active.map((e) => `${e.modelKey}:${e.status}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join('|'); if (signature === lastSignature) return; lastSignature = signature; saveActiveDownloads(active).catch(() => { /* saveActiveDownloads already logs; never throws */ }); diff --git a/src/services/modelPreloader.ts b/src/services/modelPreloader.ts index aa03d844a..baf21c561 100644 --- a/src/services/modelPreloader.ts +++ b/src/services/modelPreloader.ts @@ -56,8 +56,9 @@ async function preloadText(): Promise { async function preloadTts(): Promise { // Pro implements the audio.preload hook (fits-gated + registers the engine); // no-op in free builds. - const pending = callHook>(HOOKS.audioPreload); - if (pending) await pending; + // callHook returns undefined in free builds (no audio.preload hook); awaiting undefined is a + // no-op, so this needs no Promise-in-condition check (SonarJS). + await callHook>(HOOKS.audioPreload); } async function preloadStt(): Promise { From 1e4f7cded2f5f5f0d536a43204923821b649710a Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 02:30:29 +0530 Subject: [PATCH 2/3] fix(release): promote lands Play production as a DRAFT (track_promote_release_status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The android promote lane set release_status: 'draft', but a track PROMOTION reads track_promote_release_status — which defaulted to 'completed'. So scripts/promote.sh sent 0.0.103 straight to Play review/100% rollout instead of parking as a draft for a human to confirm the rollout % (device 2026-07-16). Set track_promote_release_status: 'draft' so it actually pauses. --- fastlane/Fastfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0b497ff12..1c4068760 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -253,7 +253,11 @@ platform :android do skip_upload_changelogs: true, skip_upload_images: true, skip_upload_screenshots: true, - release_status: "draft" # human confirms rollout in Play Console + # A track PROMOTION reads track_promote_release_status, NOT release_status — the latter only + # applies to a fresh upload. Without this it defaulted to "completed", so the promotion went + # straight to review/100% rollout instead of parking as a draft (device 2026-07-16, 0.0.103). + release_status: "draft", + track_promote_release_status: "draft" # human confirms rollout in Play Console ) UI.success("Promoted internal build (versionCode #{version_code}) to Play production (draft - confirm rollout in console)") end From 2189a8c7378517fdf134733b90e91b970c7bd701 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 16 Jul 2026 11:10:45 +0530 Subject: [PATCH 3/3] chore(sonar): use the companion NAME constant for the delete-failure log tag (CodeRabbit nitpick) --- .../java/ai/offgridmobile/download/DownloadManagerModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt index 1acfef654..b807af328 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt @@ -163,7 +163,7 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : if (download != null) { downloadDao.updateStatus(downloadId, DownloadStatus.CANCELLED, DownloadReason.USER_CANCELLED) val file = File(download.destination) - if (file.exists() && !file.delete()) Log.w("DownloadManagerModule", "Failed to delete cancelled download file: ${file.path}") + if (file.exists() && !file.delete()) Log.w(NAME, "Failed to delete cancelled download file: ${file.path}") } } WorkerDownload.cancel(reactApplicationContext, downloadId)