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>