feat(android): close behaviour gaps between the APK and the browser

Several behaviours diverged between the web build and the Capacitor
wrapper, none of them reproducible on a desktop browser.

- Declare VIBRATE. PlayerBoard calls navigator.vibrate() on every cell
  tap; a WebView app must hold the permission itself, where a browser
  holds it on the page's behalf. Taps were silent in the APK.
- Hold a screen wake lock while numbers remain to be called. Auto-call
  advances on a timer with no touch input, so a round could run 15
  minutes untouched — long enough for the display to sleep and the
  WebView to throttle the interval. Android drops the lock whenever the
  page hides, so wake-lock.js re-acquires on visibilitychange.
- Handle back through OnBackPressedCallback. Android 16 (targetSdk 36)
  no longer calls onBackPressed() nor dispatches KEYCODE_BACK. Each open
  overlay pushes one history entry, so "the WebView can go back" means
  "an overlay is open": back closes the bingo modal or settings sheet,
  and only at the root does it confirm before quitting. Browsers get the
  overlay behaviour too.
- Inset content by env(safe-area-inset-*) with viewport-fit=cover.
  targetSdk 36 forces edge-to-edge, so the WebView painted under the
  status bar and gesture nav. Also fixes the iOS PWA notch.
- Pin the WebView's textZoom to 100 and add a "Cỡ chữ bảng" setting.
  The player card is a fixed 9-column grid that clips at large system
  font scales. Pinning the zoom overrides an accessibility control, so
  the app now owns an equivalent one; the two belong together.
- Point the volume rocker at the media stream, so it works before the
  first clip plays.

strings.xml also gains the exit-dialog strings and picks up the
diacritics in app_name.
This commit is contained in:
tiennm99 committed 2026-08-14 15:20:32 +07:00
1 parent c6c839fd95
commit 3bb6b3d631
15 files changed
+790 -13

No files matched your search

@@ -3,6 +3,7 @@
<application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -38,6 +39,12 @@
<!-- No INTERNET permission: app is fully offline. All assets bundled in
APK; WebView serves from https://localhost which uses loopback. -->
<!-- Haptic feedback on cell taps. A WebView app must declare this itself
for navigator.vibrate() to work — Chrome holds the permission on the
browser's behalf, which is why the web build needs no equivalent.
Normal permission: no runtime prompt, no data-safety impact. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Mark hardware as optional so emulators (BlueStacks, NoxPlayer, AVDs)
don't get filtered out. BlueStacks reports `faketouch` instead of
`touchscreen` and lacks real telephony/GPS hardware. -->
@@ -1,5 +1,74 @@
package com.miti99.loto;
import android.media.AudioManager;
import android.os.Bundle;
import android.webkit.WebView;
import androidx.activity.OnBackPressedCallback;
import androidx.appcompat.app.AlertDialog;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
WebView webView = webView();
if (webView != null) {
// The player card is a fixed 9-column grid, so the system font
// scale (users routinely run 130-200%) clips two-digit numbers
// out of their cells. Pinning the zoom is only defensible
// because the app ships its own size control in Settings
// ("Cỡ chữ bảng") as the accessible replacement.
webView.getSettings().setTextZoom(100);
}
// Calling numbers aloud is the app's whole job, so the volume rocker
// should reach the media stream even before the first clip plays.
setVolumeControlStream(AudioManager.STREAM_MUSIC);
// Android 16 (targetSdk 36) no longer calls onBackPressed() and no
// longer dispatches KEYCODE_BACK, so back has to come through the
// dispatcher. Each open overlay pushes one history entry (see
// web/src/lib/overlay-history.js), which makes "the WebView can go
// back" mean exactly "an overlay is open".
getOnBackPressedDispatcher()
.addCallback(
this,
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
WebView wv = webView();
if (wv != null && wv.canGoBack()) {
wv.goBack();
return;
}
confirmExit();
}
}
);
}
/** Null-safe accessor — the bridge is built during super.onCreate(). */
private WebView webView() {
Bridge bridge = getBridge();
return bridge == null ? null : bridge.getWebView();
}
/**
* Auto-call runs untouched for minutes at a time, so a stray back press
* at the root would otherwise drop the caller out of a live round.
*/
private void confirmExit() {
// Explicit AppCompat dialog theme: the activity runs on
// AppTheme.NoActionBarLaunch (parent Theme.SplashScreen), which is
// not guaranteed to be AppCompat-derived at dialog time.
new AlertDialog.Builder(this, R.style.AppTheme_ExitDialog)
.setTitle(R.string.exit_title)
.setMessage(R.string.exit_message)
.setNegativeButton(R.string.exit_cancel, null)
.setPositiveButton(R.string.exit_confirm, (dialog, which) -> finish())
.show();
}
}
@@ -1,7 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Lo To</string>
<string name="title_activity_main">Lo To</string>
<string name="app_name">Lô tô</string>
<string name="title_activity_main">Lô tô</string>
<string name="package_name">com.miti99.loto</string>
<string name="custom_url_scheme">com.miti99.loto</string>
<!-- Exit confirmation, shown when back is pressed with no overlay open. -->
<string name="exit_title">Thoát Lô tô?</string>
<string name="exit_message">Ván đang chơi sẽ được lưu lại.</string>
<string name="exit_confirm">Thoát</string>
<string name="exit_cancel">Ở lại</string>
</resources>
@@ -17,6 +17,21 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<!-- API 31+ system splash. Colour follows light/dark via
values-night/colors.xml. -->
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<!-- Hand the activity a normal AppCompat theme once the splash is
done; Theme.SplashScreen is not meant to stay applied, and
AppCompat dialogs need an AppCompat-derived theme. -->
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
<!-- Compat path for API < 31; see drawable/splash.xml. -->
<item name="android:background">@drawable/splash</item>
</style>
<!-- Exit-confirmation dialog. Declared with an explicit AppCompat parent
because the activity theme (Theme.SplashScreen) is not guaranteed to
be AppCompat-derived, and AppCompat's AlertDialog requires one.
DayNight so the dialog follows the app's light/dark setting. -->
<style name="AppTheme.ExitDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert" />
</resources>
+36
View File
@@ -39,6 +39,42 @@ html {
}
body {
background-color: transparent;
/* Android 15+ (targetSdk 35+) forces edge-to-edge, so the WebView paints
under the status bar and gesture nav. Inset the content here rather than
on the page container: `html` carries the background colour, so padding
on `body` keeps the background full-bleed while content clears the bars.
`fixed inset-0` overlays are viewport-relative and deliberately
unaffected — their backdrops should still cover the whole screen.
Zero-width where there are no insets (desktop, older Android). */
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}
/* Board number sizes, scaled by the in-app "Cỡ chữ bảng" setting.
These replace Tailwind's text-* utilities on the grid cells: the wrapper
pins the WebView's textZoom to 100 (a 9-column fixed grid clips badly at
200% system font), so the app has to own the size control itself.
Player card and master board keep separate rungs — the player card has a
md: step the master board never had, and merging them would silently
enlarge the master board on desktop. */
.board-num {
font-size: calc(1.25rem * var(--board-text-scale, 1));
}
.master-num {
font-size: calc(1.25rem * var(--board-text-scale, 1));
}
@media (min-width: 640px) {
.board-num,
.master-num {
font-size: calc(1.5rem * var(--board-text-scale, 1));
}
}
@media (min-width: 768px) {
.board-num {
font-size: calc(1.875rem * var(--board-text-scale, 1));
}
}
/* Equal-width grid cells */
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="vi" class="h-full antialiased">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- viewport-fit=cover is required for env(safe-area-inset-*) to report
real values under Android's forced edge-to-edge and on notched iOS. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="format-detection" content="telephone=no" />
<title>Lô tô — Hội chợ TN1</title>
<meta
+17 -2
View File
@@ -42,6 +42,7 @@
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playNumber } from "$lib/voice.js";
import { setWakeLock } from "$lib/wake-lock.js";
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
let autoRunning = $state(false);
@@ -107,6 +108,20 @@
// toggled off mid-clip) so audio doesn't outlive the panel.
$effect(() => () => cancelPlayback());
// Hold the screen awake while numbers are still to be called. Auto-call
// advances on a timer with no touch input, so without this the display
// sleeps mid-round and the WebView throttles the interval.
//
// Keyed on `remaining` rather than `hasGame`: `hasGame` never returns to
// false once a round starts, which would pin the screen on for a finished
// board. Player-only mode never mounts this panel — those screens stay
// awake from the user's own cell taps. Android drops the lock whenever the
// page hides, so a backgrounded app holds nothing.
$effect(() => {
setWakeLock(masterState.remaining.length > 0);
return () => setWakeLock(false);
});
// Scroll the "Số vừa xổ" hero into view on each new draw so the host
// doesn't have to chase it on a phone. Gated by a user-interaction flag
// so reloading with persisted state doesn't yank the page on mount.
@@ -282,9 +297,9 @@
<!-- Token: cream inner with a sky-blue ring when called,
gray-ringed and dim when uncalled. -->
<div
class="flex items-center justify-center
class="master-num flex items-center justify-center
w-[82%] h-[82%] rounded-full
text-xl sm:text-2xl font-black tabular-nums
font-black tabular-nums
border-[3px] transition-all
{isCalled
? 'border-sky-600 dark:border-sky-400 bg-amber-50 dark:bg-amber-100 text-sky-700 dark:text-sky-700'
+12 -5
View File
@@ -13,6 +13,7 @@
saveManualUnticks,
} from "$lib/game-logic.js";
import { masterState } from "$lib/master-store.svelte.js";
import { pushOverlay } from "$lib/overlay-history.js";
import { applyMasterCalls } from "$lib/player-auto-cross.js";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
@@ -213,16 +214,23 @@
dismissToast();
});
// Bingo modal: window-level Escape so it works regardless of which
// element holds focus (the inline backdrop button rarely does).
// Bingo modal dismissal. Window-level Escape so it works regardless of
// which element holds focus (the inline backdrop button rarely does), plus
// a history sentinel so the Android back gesture closes the modal instead
// of leaving the app.
$effect(() => {
if (!showCongrats) return;
const dispose = pushOverlay(() => (showCongrats = false));
/** @param {KeyboardEvent} e */
const onKey = (e) => {
if (e.key === "Escape") showCongrats = false;
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
// No-op when back already popped the sentinel; pops it otherwise.
dispose();
};
});
// Auto-cross on master draws (only in "both" mode). Reads
@@ -448,9 +456,8 @@
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}{isWaitingCell ? ', đang chờ' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="tan-tan-num relative flex items-center justify-center
class="tan-tan-num board-num relative flex items-center justify-center
aspect-[3/4] sm:aspect-[3/5]
text-xl sm:text-2xl md:text-3xl
border border-slate-400/50 dark:border-slate-600/40
transition-all select-none cursor-pointer active:scale-90
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-rose-400
+55 -2
View File
@@ -1,6 +1,8 @@
<script>
import { VOICES } from "$lib/audio-manifest.js";
import { pushOverlay } from "$lib/overlay-history.js";
import {
BOARD_TEXT_SCALES,
resetSettings,
saveSettings,
settings,
@@ -29,6 +31,14 @@
["dark", "Tối"],
]);
// Parallel to BOARD_TEXT_SCALES.
const BOARD_TEXT_SCALE_LABELS = /** @type {const} */ ([
"Nhỏ",
"Vừa",
"Lớn",
"Rất lớn",
]);
const MODES = /** @type {const} */ ([
["player", "Người chơi"],
["master", "Quản trò"],
@@ -60,6 +70,12 @@
saveSettings();
}
/** @param {number} s */
function pickBoardTextScale(s) {
settings.boardTextScale = s;
saveSettings();
}
/** @param {"player"|"master"|"both"} m */
function pickMode(m) {
settings.mode = m;
@@ -109,15 +125,22 @@
// Escape-to-close while open. Window listener avoids relying on focus
// staying inside the modal — the dialog div may not receive keydown
// when the trigger button still holds focus on initial open.
// when the trigger button still holds focus on initial open. The history
// sentinel makes the Android back gesture close the sheet rather than
// leave the app.
$effect(() => {
if (!open) return;
const dispose = pushOverlay(close);
/** @param {KeyboardEvent} e */
const onKey = (e) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
// No-op when back already popped the sentinel; pops it otherwise.
dispose();
};
});
</script>
@@ -208,6 +231,36 @@
</div>
</fieldset>
<!-- Board number size. The Android wrapper pins the WebView's textZoom
so the system font setting can't clip the fixed 9-column grid;
this is the in-app replacement for that control. -->
<fieldset class="mb-5">
<legend
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
>
Cỡ chữ bảng
</legend>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">
Cỡ số trên bảng người chơi và bảng quản trò
</p>
<div class="grid grid-cols-4 gap-2">
{#each BOARD_TEXT_SCALES as scale, i (scale)}
{@const selected = settings.boardTextScale === scale}
<button
type="button"
aria-pressed={selected}
onclick={() => pickBoardTextScale(scale)}
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
{selected
? 'border-rose-600 dark:border-rose-400 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-300'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
>
{BOARD_TEXT_SCALE_LABELS[i]}
</button>
{/each}
</div>
</fieldset>
{#snippet switchRow(label, isOn, onToggle)}
<!-- Whole row is the switch — role/tabindex/click here, not on the
inner pill, so tapping the label text also toggles. -->
+92
View File
@@ -0,0 +1,92 @@
/**
* Makes the back gesture close the topmost overlay instead of leaving the app.
*
* Each open overlay pushes one sentinel history entry. A back press pops it,
* `popstate` fires, and we close the newest overlay. Only when nothing is left
* to pop does back reach the native layer, which then asks before exiting
* (see `MainActivity.handleOnBackPressed`).
*
* Browsers get the same behaviour for free — browser back closes the modal
* rather than leaving the page.
*
* SvelteKit interop: its client router gates its own `popstate` handler on
* `event.state?.[HISTORY_INDEX]`. Our sentinel state deliberately omits that
* key, so the router ignores these entries and no navigation is triggered.
* Don't spread `history.state` into the sentinel — that would reintroduce the
* key and hand our events to the router.
*
* @module lib/overlay-history
*/
/** @typedef {{ id: number, close: () => void }} OverlayEntry */
/** Open overlays, oldest first. Closing is LIFO. */
/** @type {OverlayEntry[]} */
const stack = [];
let seq = 0;
/**
* Number of `popstate` events to swallow because we caused them ourselves via
* `history.back()` in `dispose()`. Without this, closing by button or Escape
* would pop the sentinel AND re-run `close()`.
*/
let suppress = 0;
let listening = false;
function onPopState() {
if (suppress > 0) {
suppress--;
return;
}
const entry = stack.pop();
entry?.close();
}
/**
* Installed on first use and left in place for the page lifetime. Removing it
* when the stack empties would strand a pending suppressed `popstate`, leaking
* a `suppress` count into the next overlay and swallowing a real back press.
*/
function startListening() {
if (listening) return;
window.addEventListener("popstate", onPopState);
listening = true;
}
/** @param {OverlayEntry} entry */
function dispose(entry) {
const i = stack.indexOf(entry);
// Already gone — closed by a back press, which ran `close()` itself.
if (i === -1) return;
stack.splice(i, 1);
suppress++;
history.back();
}
/**
* Register an open overlay. Call on open; call the returned function when the
* overlay closes by any other route (button, Escape, backdrop tap) so the
* sentinel is popped and history stays balanced.
*
* Nested overlays close LIFO. Each overlay owns exactly one history entry, so
* disposing a non-top overlay still pops one entry and the counts stay level.
*
* @param {() => void} close — closes this overlay; must be idempotent-safe
* @returns {() => void} dispose
*/
export function pushOverlay(close) {
if (typeof window === "undefined") return () => {};
const entry = { id: ++seq, close };
stack.push(entry);
startListening();
history.pushState({ lotoOverlay: entry.id }, "");
return () => dispose(entry);
}
/** Test-only teardown so module state doesn't leak across cases. */
export function _resetOverlayHistoryForTest() {
stack.length = 0;
suppress = 0;
}
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetOverlayHistoryForTest,
pushOverlay,
} from "./overlay-history.js";
// happy-dom's History does not reliably emit popstate for back(), so we drive
// the event by hand. That also keeps these tests about our own bookkeeping
// rather than the polyfill's fidelity.
function firePopState() {
window.dispatchEvent(new Event("popstate"));
}
let back;
let pushState;
beforeEach(() => {
_resetOverlayHistoryForTest();
back = vi.spyOn(history, "back").mockImplementation(() => {});
pushState = vi.spyOn(history, "pushState").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("overlay-history", () => {
it("pushes one history entry per overlay", () => {
pushOverlay(() => {});
expect(pushState).toHaveBeenCalledTimes(1);
});
it("omits SvelteKit's history index so the router ignores the entry", () => {
pushOverlay(() => {});
const state = pushState.mock.calls[0][0];
// SvelteKit gates its popstate handler on this key; if we ever leak it,
// back presses would trigger router navigation instead of closing a modal.
expect(Object.keys(state)).toEqual(["lotoOverlay"]);
});
it("closes the overlay on back", () => {
const close = vi.fn();
pushOverlay(close);
firePopState();
expect(close).toHaveBeenCalledTimes(1);
});
it("programmatic close pops the sentinel without re-closing", () => {
const close = vi.fn();
const dispose = pushOverlay(close);
dispose();
expect(back).toHaveBeenCalledTimes(1);
// The back() above produces a popstate; it must be swallowed.
firePopState();
expect(close).not.toHaveBeenCalled();
});
it("closes nested overlays LIFO", () => {
const closeA = vi.fn();
const closeB = vi.fn();
pushOverlay(closeA);
pushOverlay(closeB);
firePopState();
expect(closeB).toHaveBeenCalledTimes(1);
expect(closeA).not.toHaveBeenCalled();
firePopState();
expect(closeA).toHaveBeenCalledTimes(1);
});
it("disposing a non-top overlay keeps the remaining one closable", () => {
const closeA = vi.fn();
const closeB = vi.fn();
const disposeA = pushOverlay(closeA);
pushOverlay(closeB);
disposeA();
expect(back).toHaveBeenCalledTimes(1);
firePopState(); // swallowed — caused by disposeA
expect(closeB).not.toHaveBeenCalled();
firePopState(); // real back press
expect(closeB).toHaveBeenCalledTimes(1);
expect(closeA).not.toHaveBeenCalled();
});
it("disposing twice is harmless", () => {
const close = vi.fn();
const dispose = pushOverlay(close);
dispose();
dispose();
expect(back).toHaveBeenCalledTimes(1);
});
it("a back press after the overlay already closed is a no-op", () => {
const close = vi.fn();
const dispose = pushOverlay(close);
dispose();
firePopState(); // swallowed
expect(() => firePopState()).not.toThrow();
expect(close).not.toHaveBeenCalled();
});
});
+29
View File
@@ -12,6 +12,13 @@ const STORAGE_KEY = "loto_settings";
const HEX6 = /^#[0-9a-fA-F]{6}$/;
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
const VALID_MODES = /** @type {const} */ (["player", "master", "both"]);
/**
* Board number sizes, as a multiplier on the responsive base size.
* The Android wrapper pins the WebView's textZoom to 100 so the system font
* setting can't break the fixed 9-column grid — this is the in-app
* replacement for that control, so the rungs need to reach genuinely large.
*/
export const BOARD_TEXT_SCALES = /** @type {const} */ ([0.9, 1, 1.15, 1.3]);
/** Hard cap on stored settings JSON to keep poisoned origins from
* stalling the UI on mount. Real settings serialize to ~200 bytes. */
const MAX_STORAGE_BYTES = 8_192;
@@ -35,6 +42,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
voiceWaitingNumber: false,
/** Active voice id; matches an entry in audio manifest. */
voice: DEFAULT_VOICE,
/** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
boardTextScale: 1,
});
export const settings = $state({ ...DEFAULT_SETTINGS });
@@ -70,6 +79,13 @@ function validSpeed(v) {
function validVoiceId(v) {
return typeof v === "string" && VOICE_IDS.has(v) ? v : null;
}
/** @param {unknown} v */
function validBoardTextScale(v) {
return typeof v === "number" &&
BOARD_TEXT_SCALES.includes(/** @type {any} */ (v))
? v
: null;
}
/* ------------------------------------------------------------------ */
/* DOM apply */
@@ -112,9 +128,18 @@ function applyTheme() {
}
}
function applyBoardTextScale() {
if (typeof document === "undefined") return;
document.documentElement.style.setProperty(
"--board-text-scale",
String(settings.boardTextScale),
);
}
function applyAll() {
applyEmptyCellColor();
applyTheme();
applyBoardTextScale();
}
/* ------------------------------------------------------------------ */
@@ -157,6 +182,9 @@ export function loadSettings() {
DEFAULT_SETTINGS.voiceWaitingNumber;
settings.voice =
validVoiceId(parsed.voice) ?? DEFAULT_SETTINGS.voice;
settings.boardTextScale =
validBoardTextScale(parsed.boardTextScale) ??
DEFAULT_SETTINGS.boardTextScale;
}
} catch {
/* private mode / quota / corrupt JSON — fall back to defaults */
@@ -183,5 +211,6 @@ export function resetSettings() {
settings.voiceEnabledPlayer = DEFAULT_SETTINGS.voiceEnabledPlayer;
settings.voiceWaitingNumber = DEFAULT_SETTINGS.voiceWaitingNumber;
settings.voice = DEFAULT_SETTINGS.voice;
settings.boardTextScale = DEFAULT_SETTINGS.boardTextScale;
saveSettings();
}
+60
View File
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
BOARD_TEXT_SCALES,
DEFAULT_SETTINGS,
loadSettings,
resetSettings,
@@ -52,10 +53,12 @@ beforeEach(() => {
"voiceEnabledPlayer",
"voiceWaitingNumber",
"voice",
"boardTextScale",
])) {
/** @type {any} */ (settings)[k] = /** @type {any} */ (DEFAULT_SETTINGS)[k];
}
document.documentElement.style.removeProperty("--empty-cell-bg");
document.documentElement.style.removeProperty("--board-text-scale");
document.documentElement.classList.remove("dark");
mockMatchMedia(false);
});
@@ -272,6 +275,7 @@ describe("settings-store — saveSettings", () => {
settings.voiceEnabledPlayer = true;
settings.voiceWaitingNumber = true;
settings.voice = "nam-minh";
settings.boardTextScale = 1.15;
saveSettings();
const raw = localStorage.getItem(KEY);
expect(raw).not.toBeNull();
@@ -285,6 +289,7 @@ describe("settings-store — saveSettings", () => {
voiceEnabledPlayer: true,
voiceWaitingNumber: true,
voice: "nam-minh",
boardTextScale: 1.15,
});
});
@@ -367,3 +372,58 @@ describe("settings-store — applyTheme via load/save", () => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});
describe("settings-store — boardTextScale", () => {
it("defaults to 1", () => {
expect(DEFAULT_SETTINGS.boardTextScale).toBe(1);
loadSettings();
expect(settings.boardTextScale).toBe(1);
});
it("applies the scale as a CSS custom property", () => {
settings.boardTextScale = 1.3;
saveSettings();
expect(
document.documentElement.style.getPropertyValue("--board-text-scale"),
).toBe("1.3");
});
it("round-trips every allowed rung through storage", () => {
for (const scale of BOARD_TEXT_SCALES) {
settings.boardTextScale = scale;
saveSettings();
settings.boardTextScale = 1;
loadSettings();
expect(settings.boardTextScale).toBe(scale);
}
});
it("falls back to the default for a value outside the allowlist", () => {
localStorage.setItem(KEY, JSON.stringify({ boardTextScale: 2.5 }));
loadSettings();
expect(settings.boardTextScale).toBe(1);
});
it("falls back to the default for a non-number", () => {
localStorage.setItem(KEY, JSON.stringify({ boardTextScale: "1.15" }));
loadSettings();
expect(settings.boardTextScale).toBe(1);
});
it("leaves other keys intact when boardTextScale is missing", () => {
localStorage.setItem(KEY, JSON.stringify({ theme: "dark" }));
loadSettings();
expect(settings.theme).toBe("dark");
expect(settings.boardTextScale).toBe(1);
});
it("reset restores the default", () => {
settings.boardTextScale = 1.3;
saveSettings();
resetSettings();
expect(settings.boardTextScale).toBe(1);
expect(
document.documentElement.style.getPropertyValue("--board-text-scale"),
).toBe("1");
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Screen wake lock for the caller's device.
*
* Auto-call advances on a timer with no touch input, so a full round (up to
* 90 numbers at 1–10s each) can run for 15 minutes without the user ever
* touching the screen. Without a lock the display sleeps, the WebView
* throttles its timers, and the round stalls.
*
* Android releases the lock whenever the page hides, so a one-shot
* `request()` is dead after the first backgrounding — returning to the app
* has to re-acquire. That re-acquire is why this module owns a
* `visibilitychange` listener instead of just handing back a sentinel.
*
* @module lib/wake-lock
*/
/** @type {any} — WakeLockSentinel; lib.dom in this project predates the type. */
let sentinel = null;
/** True while a caller wants the screen held awake. */
let wanted = false;
/**
* Guards the async gap inside `acquire()`. A `request()` that resolves after
* the caller flipped to `false` must release immediately rather than leak a
* lock the UI thinks it dropped. Same idea as `activeToken` in `voice.js`.
*/
let generation = 0;
/** Prevents two concurrent `acquire()` calls from each taking a sentinel. */
let acquiring = false;
let listening = false;
function supported() {
return (
typeof navigator !== "undefined" &&
typeof document !== "undefined" &&
!!(/** @type {any} */ (navigator).wakeLock)
);
}
function onVisibilityChange() {
if (document.visibilityState === "visible") void acquire();
}
function startListening() {
if (listening || !supported()) return;
document.addEventListener("visibilitychange", onVisibilityChange);
listening = true;
}
function stopListening() {
if (!listening) return;
document.removeEventListener("visibilitychange", onVisibilityChange);
listening = false;
}
async function acquire() {
if (!supported() || !wanted || sentinel || acquiring) return;
acquiring = true;
const token = generation;
try {
const next = await /** @type {any} */ (navigator).wakeLock.request(
"screen",
);
if (token !== generation || !wanted) {
// Turned off while we awaited — drop it rather than hold a lock
// nobody asked for.
void next.release?.();
return;
}
sentinel = next;
// The system drops the lock on its own (screen off, battery saver).
// Clear our handle so the next visibilitychange can re-acquire.
next.addEventListener?.("release", () => {
if (sentinel === next) sentinel = null;
});
} catch {
/* hidden page, battery saver, permissions policy — nothing to do */
} finally {
acquiring = false;
}
}
function releaseNow() {
const held = sentinel;
sentinel = null;
if (held) {
try {
void Promise.resolve(held.release?.()).catch(() => {});
} catch {
/* already released by the system */
}
}
}
/**
* Hold or drop the screen wake lock. Idempotent — safe to call from a
* reactive effect that re-runs on unrelated state changes.
*
* No-ops entirely where `navigator.wakeLock` is missing (older WebViews on
* minSdk 24 devices).
*
* @param {boolean} on
*/
export function setWakeLock(on) {
if (on === wanted) return;
wanted = on;
// Invalidate any in-flight acquire from the previous state.
generation++;
if (on) {
startListening();
void acquire();
} else {
stopListening();
releaseNow();
}
}
/** Test-only teardown so module state doesn't leak across cases. */
export function _resetWakeLockForTest() {
wanted = false;
generation++;
acquiring = false;
stopListening();
sentinel = null;
}
+148
View File
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { _resetWakeLockForTest, setWakeLock } from "./wake-lock.js";
class FakeSentinel {
constructor() {
this.released = false;
/** @type {Record<string, () => void>} */
this.listeners = {};
}
release() {
this.released = true;
this.listeners.release?.();
return Promise.resolve();
}
/** @param {string} type @param {() => void} fn */
addEventListener(type, fn) {
this.listeners[type] = fn;
}
}
/** @type {FakeSentinel[]} */
let sentinels = [];
/** @type {() => Promise<any>} */
let requestImpl;
let requestCount = 0;
/** @param {"visible" | "hidden"} state */
function setVisibility(state) {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
document.dispatchEvent(new Event("visibilitychange"));
}
function grantWakeLock() {
Object.defineProperty(navigator, "wakeLock", {
configurable: true,
value: {
request: () => {
requestCount++;
return requestImpl();
},
},
});
}
beforeEach(() => {
sentinels = [];
requestCount = 0;
requestImpl = () => {
const s = new FakeSentinel();
sentinels.push(s);
return Promise.resolve(s);
};
grantWakeLock();
setVisibility("visible");
});
afterEach(() => {
_resetWakeLockForTest();
Reflect.deleteProperty(navigator, "wakeLock");
});
describe("wake-lock", () => {
it("acquires a screen lock when turned on", async () => {
setWakeLock(true);
await Promise.resolve();
expect(requestCount).toBe(1);
expect(sentinels[0].released).toBe(false);
});
it("releases the lock when turned off", async () => {
setWakeLock(true);
await Promise.resolve();
setWakeLock(false);
expect(sentinels[0].released).toBe(true);
});
it("is idempotent — repeat calls do not stack locks", async () => {
setWakeLock(true);
setWakeLock(true);
await Promise.resolve();
setWakeLock(true);
await Promise.resolve();
expect(requestCount).toBe(1);
});
it("re-acquires after the system drops the lock and the app returns", async () => {
setWakeLock(true);
await Promise.resolve();
expect(requestCount).toBe(1);
// Android releases the lock on hide; the sentinel fires "release".
sentinels[0].release();
setVisibility("hidden");
setVisibility("visible");
await Promise.resolve();
expect(requestCount).toBe(2);
expect(sentinels[1].released).toBe(false);
});
it("does not re-acquire once turned off", async () => {
setWakeLock(true);
await Promise.resolve();
setWakeLock(false);
setVisibility("hidden");
setVisibility("visible");
await Promise.resolve();
expect(requestCount).toBe(1);
});
it("releases a lock that resolves after the caller turned it off", async () => {
/** @type {(s: FakeSentinel) => void} */
let resolveRequest = () => {};
requestImpl = () =>
new Promise((resolve) => {
resolveRequest = resolve;
});
setWakeLock(true);
setWakeLock(false);
const late = new FakeSentinel();
resolveRequest(late);
await Promise.resolve();
await Promise.resolve();
// Must not leak a lock the UI already considers dropped.
expect(late.released).toBe(true);
});
it("no-ops without navigator.wakeLock", () => {
Reflect.deleteProperty(navigator, "wakeLock");
expect(() => setWakeLock(true)).not.toThrow();
expect(() => setWakeLock(false)).not.toThrow();
});
it("swallows a rejected request", async () => {
requestImpl = () => Promise.reject(new Error("battery saver"));
setWakeLock(true);
await Promise.resolve();
await Promise.resolve();
expect(() => setWakeLock(false)).not.toThrow();
});
});