diff --git a/.changeset/empty-bugs-end.md b/.changeset/empty-bugs-end.md new file mode 100644 index 0000000000..45d6087bb6 --- /dev/null +++ b/.changeset/empty-bugs-end.md @@ -0,0 +1,5 @@ +--- +'@reown/appkit-controllers': patch +--- + +fix: improve isMobile() to exclude touchscreen desktops using any-pointer:fine media query diff --git a/packages/controllers/src/utils/CoreHelperUtil.ts b/packages/controllers/src/utils/CoreHelperUtil.ts index bd588bff10..2982a864f0 100644 --- a/packages/controllers/src/utils/CoreHelperUtil.ts +++ b/packages/controllers/src/utils/CoreHelperUtil.ts @@ -27,12 +27,25 @@ export const CoreHelperUtil = { isMobile() { if (this.isClient()) { - return Boolean( - (window?.matchMedia && - typeof window.matchMedia === 'function' && - window.matchMedia('(pointer:coarse)')?.matches) || - /Android|webOS|iPhone|iPad|iPod|BlackBerry|Opera Mini/u.test(navigator.userAgent) - ) + // Known mobile UA strings - definitive match, check first + if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|Opera Mini/u.test(navigator.userAgent)) { + return true + } + + /* + * Pointer:coarse matches touchscreens, but also touchscreen desktops/laptops. + * If a fine pointer (trackpad/mouse) is also available via any-pointer:fine, + * this is a multi-input desktop device - not mobile. + * Ref: https://css-tricks.com/interaction-media-features-and-their-potential-for-incorrect-assumptions/ + */ + const isCoarsePointer = window.matchMedia?.('(pointer:coarse)')?.matches ?? false + const hasAnyFinePointer = window.matchMedia?.('(any-pointer:fine)')?.matches ?? false + + if (isCoarsePointer && hasAnyFinePointer) { + return false + } + + return isCoarsePointer } return false diff --git a/packages/controllers/tests/utils/CoreHelperUtil.test.ts b/packages/controllers/tests/utils/CoreHelperUtil.test.ts index b2bab30577..fbed13c8fd 100644 --- a/packages/controllers/tests/utils/CoreHelperUtil.test.ts +++ b/packages/controllers/tests/utils/CoreHelperUtil.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CoreHelperUtil } from '../../src/utils/CoreHelperUtil.js' @@ -124,6 +124,80 @@ describe('CoreHelperUtil', () => { expect(CoreHelperUtil.isCaipAddress(caipAddress)).toEqual(expected) }) + describe('isMobile', () => { + function mockMatchMedia(results: Record) { + vi.spyOn(window, 'matchMedia').mockImplementation( + (query: string) => + ({ + matches: results[query] ?? false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + }) as MediaQueryList + ) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return true for iPhone user agent', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' + ) + + expect(CoreHelperUtil.isMobile()).toBe(true) + }) + + it('should return true for Android user agent', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + ) + + expect(CoreHelperUtil.isMobile()).toBe(true) + }) + + it('should return false for non-touch desktop', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + mockMatchMedia({ + '(pointer:coarse)': false, + '(any-pointer:fine)': true + }) + + expect(CoreHelperUtil.isMobile()).toBe(false) + }) + + it('should return false for touchscreen desktop/laptop', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + mockMatchMedia({ + '(pointer:coarse)': true, + '(any-pointer:fine)': true + }) + + expect(CoreHelperUtil.isMobile()).toBe(false) + }) + + it('should return true for coarse-only device without mobile UA', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (X11; CrOS armv7l 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36' + ) + mockMatchMedia({ + '(pointer:coarse)': true, + '(any-pointer:fine)': false + }) + + expect(CoreHelperUtil.isMobile()).toBe(true) + }) + }) + describe('formatNativeUrl', () => { const wcUri = 'wc:1234@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=key'