fix: Qibla not detecting device compass on iOS and Android

Two real bugs, both explaining 'didn't detect compass':

1. iOS: DeviceOrientationEvent.requestPermission() was called *after*
   the async geolocation round-trip resolved. Safari's user-activation
   grant for sensor permissions expires almost immediately, so by the
   time a GPS fix comes back (often several seconds), the permission
   call silently fails with no prompt ever shown. Now requested
   synchronously inside the button's click handler, before geolocation
   starts.

2. Android: the heading was only accepted when e.absolute === true, but
   many Android browsers fire plain 'deviceorientation' with
   absolute:false even though alpha is a perfectly usable heading —
   compass detection silently never succeeded on those devices at all.
   Now accepts alpha regardless of the absolute flag, falling back to
   it when webkitCompassHeading (iOS) isn't present.

Also fixed a duplicate-listener bug: clicking 'Refresh location'
repeatedly re-registered deviceorientation listeners without removing
the previous ones, stacking duplicates on every retry.

Verified live: dispatched a synthetic non-absolute deviceorientation
event (the exact Android failure shape) against the deployed app —
compass detection now activates correctly where it previously stayed
silently stuck on the no-sensor fallback. Added as a permanent
regression assertion in e2e-khairat-lifestyle.cjs (16/16).
This commit is contained in:
2026-08-14 15:23:17 +08:00
parent 23f32872a5
commit 9cb0793347
2 changed files with 56 additions and 16 deletions
+16
View File
@@ -60,6 +60,22 @@ async function main() {
const distanceVisible = await page.locator('.distance-note', { hasText: 'km to Makkah' }).isVisible().catch(() => false); const distanceVisible = await page.locator('.distance-note', { hasText: 'km to Makkah' }).isVisible().catch(() => false);
record('Qibla: shows distance to Makkah', distanceVisible); record('Qibla: shows distance to Makkah', distanceVisible);
// Regression guard: many Android browsers fire plain 'deviceorientation'
// with absolute:false even though alpha is a usable heading — a prior
// version required absolute:true and silently never detected the compass
// on those devices. Simulate exactly that event shape.
const staticNoteBeforeSensor = await page.locator('.static-note').isVisible().catch(() => false);
record('Qibla: shows the no-sensor fallback note before any orientation event', staticNoteBeforeSensor);
await page.evaluate(() => {
const evt = new Event('deviceorientation');
Object.defineProperty(evt, 'alpha', { value: 90 });
Object.defineProperty(evt, 'absolute', { value: false });
window.dispatchEvent(evt);
});
await page.waitForTimeout(500);
const staticNoteAfterSensor = await page.locator('.static-note').isVisible().catch(() => false);
record('Qibla: accepts a non-absolute deviceorientation event as a valid compass reading (Android)', !staticNoteAfterSensor);
// ── Prayer Times ── // ── Prayer Times ──
await gotoTab(page, 'Prayer Times'); await gotoTab(page, 'Prayer Times');
await page.waitForTimeout(500); await page.waitForTimeout(500);
+40 -16
View File
@@ -33,34 +33,58 @@
distanceKm = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))); distanceKm = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
} }
let listenersAttached = false;
function handleOrientation(e) { function handleOrientation(e) {
const heading = e.webkitCompassHeading ?? (e.absolute && e.alpha != null ? 360 - e.alpha : null); let heading = e.webkitCompassHeading;
if (heading == null && e.alpha != null) {
// Prefer a true geomagnetic reading when the browser provides one
// (absolute: true), but don't require it — many Android browsers
// never set absolute:true even though alpha is still a usable
// heading, so requiring it strictly left the compass permanently
// "undetected" on those devices.
heading = 360 - e.alpha;
}
if (heading != null) { compassHeading = heading; compassSupported = true; } if (heading != null) { compassHeading = heading; compassSupported = true; }
} }
function attachOrientationListeners() {
if (listenersAttached) return;
listenersAttached = true;
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
async function findQibla() { async function findQibla() {
status = 'locating'; status = 'locating';
errorMsg = ''; errorMsg = '';
compassSupported = false;
compassHeading = null;
// Request device-orientation permission (iOS 13+) synchronously here,
// inside this click handler — Safari expires the user-activation grant
// almost immediately, so requesting it *after* the async geolocation
// round-trip (which can take several seconds) reliably fails silently
// with no prompt ever shown. Registering listeners for everyone else
// immediately too, so no early orientation events are missed while
// still waiting on the GPS fix.
if (typeof DeviceOrientationEvent !== 'undefined') {
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') attachOrientationListeners();
} catch { /* denied or unsupported — static bearing still shown */ }
} else {
attachOrientationListeners();
}
}
if (!navigator.geolocation) { if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.'; status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return; return;
} }
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
async (pos) => { (pos) => { computeQibla(pos.coords.latitude, pos.coords.longitude); status = 'ready'; },
computeQibla(pos.coords.latitude, pos.coords.longitude);
status = 'ready';
// Compass heading requires an explicit permission prompt on iOS 13+,
// which must be triggered by this same user gesture.
if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') window.addEventListener('deviceorientation', handleOrientation, true);
} catch { /* permission denied or unsupported — static bearing still shown */ }
} else if (typeof DeviceOrientationEvent !== 'undefined') {
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
},
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; }, (err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 } { enableHighAccuracy: true, timeout: 10000 }
); );