From d2ffa57d0deb452e795afd84e545e9e040b9fc5a Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Thu, 15 Oct 2026 12:34:24 +0200 Subject: [PATCH] fix(mobile): ignore a vibration refused by a phone without a haptics engine On a Redmi Note 9, every vibration rejected with "A haptics engine is not available on this device": the expo-haptics promises were started with `void` and no catch, so the rejection went unhandled and reached Sentry. Every call now goes through `vibrate`, which drops a rejection, and a synchronous throw of a missing native module, in silence. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/ui/haptics.test.ts | 87 ++++++++++++++++++++++++++++++ apps/mobile/src/ui/haptics.ts | 21 ++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/ui/haptics.test.ts diff --git a/apps/mobile/src/ui/haptics.test.ts b/apps/mobile/src/ui/haptics.test.ts new file mode 100644 index 00000000..9b2fea69 --- /dev/null +++ b/apps/mobile/src/ui/haptics.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { platform, calls, nativeFault, expoHaptics, sentry } = vi.hoisted(() => { + const calls: string[] = []; + const nativeFault = { message: undefined as string | undefined }; + // Plain functions, not `vi.fn`: a spy attaches its own handlers to a returned promise, which + // would hide the very unhandled rejection this test looks for. + const refuse = + (name: string) => + (arg?: string): Promise => { + calls.push(arg === undefined ? name : `${name}:${arg}`); + if (nativeFault.message) throw new Error(nativeFault.message); + return Promise.reject(new Error('A haptics engine is not available on this device')); + }; + return { + platform: { OS: 'android' as 'android' | 'ios' }, + calls, + nativeFault, + expoHaptics: { + performAndroidHapticsAsync: refuse('performAndroidHapticsAsync'), + selectionAsync: refuse('selectionAsync'), + impactAsync: refuse('impactAsync'), + notificationAsync: refuse('notificationAsync'), + AndroidHaptics: { Toggle_On: 'toggle-on', Confirm: 'confirm', Reject: 'reject' }, + ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' }, + NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' }, + }, + sentry: { captureException: vi.fn() }, + }; +}); + +vi.mock('react-native', () => ({ Platform: platform })); +vi.mock('expo-haptics', () => expoHaptics); +vi.mock('@sentry/react-native', () => sentry); + +const { haptic } = await import('./haptics'); + +/** Lets the rejected promises settle, so that an unhandled one would be reported. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('haptic on a phone without a haptics engine', () => { + const unhandled = vi.fn(); + + beforeEach(() => { + process.on('unhandledRejection', unhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', unhandled); + calls.length = 0; + nativeFault.message = undefined; + vi.clearAllMocks(); + }); + + it('drops the refusal of the engine on Android, silently', async () => { + platform.OS = 'android'; + expect(() => { + haptic('toastSuccess'); + }).not.toThrow(); + await settle(); + expect(calls).toEqual(['performAndroidHapticsAsync:confirm']); + expect(unhandled).not.toHaveBeenCalled(); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + + it('drops the refusal of every iOS method, silently', async () => { + platform.OS = 'ios'; + expect(() => { + haptic('favoriteOn'); + haptic('ratingStar'); + haptic('toastError'); + }).not.toThrow(); + await settle(); + expect(calls).toEqual(['impactAsync:light', 'selectionAsync', 'notificationAsync:error']); + expect(unhandled).not.toHaveBeenCalled(); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + + it('stays silent when the native module throws before any promise', () => { + platform.OS = 'android'; + nativeFault.message = 'Cannot find native module ExpoHaptics'; + expect(() => { + haptic('arrived'); + }).not.toThrow(); + expect(calls).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/ui/haptics.ts b/apps/mobile/src/ui/haptics.ts index 6926fe0f..2651418f 100644 --- a/apps/mobile/src/ui/haptics.ts +++ b/apps/mobile/src/ui/haptics.ts @@ -4,6 +4,19 @@ import { Platform } from 'react-native'; type HapticKey = keyof typeof motion.haptics; +/** + * Starts a vibration without waiting for it. A vibration is a garnish: a phone without a + * haptics engine (Redmi Note 9: « A haptics engine is not available on this device ») rejects + * it, and that refusal is dropped in silence, neither an error nor a Sentry report. + */ +function vibrate(start: () => Promise): void { + try { + start().catch(() => undefined); + } catch { + // A native module missing from the build throws before any promise: same silence. + } +} + /** * A vibration of Direction-Artistique › Mouvement (D-030), as `mappings.motion.haptics` names it * for each system: expo-haptics on iOS, `performAndroidHapticsAsync` on Android. Nothing else @@ -12,19 +25,19 @@ type HapticKey = keyof typeof motion.haptics; export function haptic(key: HapticKey): void { const feedback = motion.haptics[key]; if (Platform.OS === 'android') { - void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics[feedback.android]); + vibrate(() => Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics[feedback.android])); return; } const { ios } = feedback; switch (ios.method) { case 'selectionAsync': - void Haptics.selectionAsync(); + vibrate(() => Haptics.selectionAsync()); break; case 'impactAsync': - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle[ios.style]); + vibrate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle[ios.style])); break; case 'notificationAsync': - void Haptics.notificationAsync(Haptics.NotificationFeedbackType[ios.style]); + vibrate(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType[ios.style])); break; } }