← Back to posts
Software DevBuilding in Public 6 min read

Capacitor app rejected by Google Play for ‘unresponsive UI’

Google Play rejected my Capacitor app for broken functionality. The WebView reported a fine pointer, so the joystick never appeared. The one-line fix.

Google Play rejected DimeTown, my top-down multiplayer browser game (a bit GTA2-style), on 28 September. The notice said “Broken Functionality” and listed one issue: “Unresponsive UI elements, such as buttons or icons”. It didn’t say which button. It gave me four screenshots and some help links.

The Android app is a Capacitor 8 shell that loads the live game, so every button was an ordinary web button. The real problem was that inside the Android WebView, the game decided it was running on a desktop. No joystick, no action buttons. You could open the phone and the map, but you couldn’t walk anywhere.

The fix: ask Capacitor before the media queries

My “is this a touch device?” check used only media queries. Now it asks Capacitor first:

import { Capacitor } from "@capacitor/core";

export const TOUCH =
  Capacitor.isNativePlatform() ||
  (matchMedia("(pointer: coarse)").matches &&
    !matchMedia("(any-pointer: fine)").matches);

Before, it was only the second half. Capacitor.isNativePlatform() is true inside the iOS and Android shells and false in a normal browser, so browsers behave exactly as they did.

If a rejection won’t say what’s broken, log what your WebView reports for the pointer queries before you touch anything else. The rest of this post is how I found it and how sure I am.

What does Google Play’s “Broken Functionality: unresponsive UI” rejection mean?

Google’s policy lists “apps that load, but are not responsive” as a common violation. My notice falls in that category and stops there. It doesn’t say which control.

What I did have was the reviewer’s four screenshots. All four show the desktop layout: a “Say something…” chat bar along the bottom with Enter and / key hints, and no joystick. In two of them the phone and the city map are open, so some taps clearly worked. With no keyboard, nothing on that screen could move the character.

So on Google’s device the game had picked desktop controls. What I can’t prove is which query misfired there, or that this is the control Google meant. The fix doesn’t depend on the first.

Why did the Android WebView match both pointer: coarse and any-pointer: fine?

Here’s what I measured. I added a temporary log line to the game, ran a debug build of the Android app against my local dev server on an emulator (API 36), and read it back from logcat. The line looked roughly like this:

console.info("TOUCH_AUDIT", JSON.stringify({
  native: Capacitor.isNativePlatform(),
  platform: Capacitor.getPlatform(),
  coarse: matchMedia("(pointer: coarse)").matches,
  fine: matchMedia("(any-pointer: fine)").matches,
  touchPoints: navigator.maxTouchPoints,
}));
adb logcat -d -s Capacitor/Console:I | grep TOUCH_AUDIT

It printed (trimmed):

{"native":true,"platform":"android","coarse":true,"fine":true,"touchPoints":5}

pointer asks about the primary device and any-pointer about any device, so a touchscreen with a fine pointer also available matches both. Why this WebView counted something as fine I haven’t tracked down; a virtual input device on the emulator is the obvious suspect. I only measured Android, on an emulator, not the reviewer’s device.

Coarse true plus fine true is also the exact combination my rule sends to the desktop layout, because a laptop with a touchscreen is meant to keep the mouse controls. To the only code that cared, the WebView looked like a laptop.

What the pointer media queries report on four kinds of device, which controls the old rule picked, and the Capacitor check that fixes the Android WebView row pointer:coarseany-pointer:fineold rule picks desktop browsermouse, keyboard false true mouse UI phone browserfinger only true false touch UI touchscreen laptopas my tests model it true true mouse UI by design Android WebViewmeasured, emulator true true mouse UI nojoystick same the fix Capacitor.isNativePlatform() touch UI
Rows 1 to 3: what the rule expects (row 3 as my tests model it). Row 4: what the emulator printed.

How do you detect a touch device in a Capacitor app?

Ask the shell first. Capacitor.getPlatform() returns "web", "ios" or "android", and the shell knows which it is. My web client imports @capacitor/core and runs in plain browsers too, where isNativePlatform() just returns false. The trade-off is that a native app now always gets touch controls, even with a mouse attached.

Don’t read any-pointer: fine as “this is a desktop”. It can be true next to a primary touchscreen. My rule needed it to be false before it showed touch controls, so any device that reported it lost the joystick. If you don’t care about touchscreen laptops, dropping the any-pointer: fine check would also have fixed my emulator, because pointer: coarse was true there. I kept it for browsers because I’d rather a laptop got the mouse layout.

Don’t swap in maxTouchPoints. The log says 5, which is what smartphones typically return. But touchscreen laptops report touch points too, so it can’t split the last two rows in the figure.

Keep an override. My game takes ?device=touch or ?device=desktop in the URL (or a localStorage key), and that beats everything. It’s how I check the touch layout from a laptop. A simplified version of the final check (the real one also reads localStorage):

const q = new URLSearchParams(location.search).get("device");
const forced = q === "touch" || q === "desktop" ? q : null;

export const TOUCH = forced
  ? forced === "touch"
  : Capacitor.isNativePlatform() ||
    (matchMedia("(pointer: coarse)").matches &&
      !matchMedia("(any-pointer: fine)").matches);

Pin it with a test. Stub Capacitor and matchMedia, and add the case you got burned by. This is trimmed from my real test:

import { afterEach, expect, it, vi } from "vitest";

const platform = vi.hoisted(() => ({ native: false }));
vi.mock("@capacitor/core", () => ({
  Capacitor: { isNativePlatform: () => platform.native },
}));

afterEach(() => {
  vi.unstubAllGlobals();
  vi.resetModules();
  platform.native = false;
});

async function layout(native: boolean, coarse: boolean, fine: boolean) {
  platform.native = native;
  vi.stubGlobal("matchMedia", (q: string) => ({
    matches: q === "(pointer: coarse)" ? coarse : fine,
  }));
  return import("../client/src/layout");
}

it(
  "keeps native touch controls when an Android device also reports a mouse",
  async () => {
    expect((await layout(true, true, true)).TOUCH).toBe(true);
  },
);

The fix came with seven layout tests. Run against the old code, 2 failed and 5 passed. Against the fix, all 7 pass.

None of my tests caught this: my Storybook tests pick the device explicitly, so nothing ever asked a real WebView. Same family as tests that pass while the code is broken.

Did that get the app through Google Play review?

It got published, with a caveat. Because the Android app is a shell around the live site, the fix went out as an ordinary web deploy on 3 October. Version 1.0.1 (version code 2) only bumped the version number so I had a new build to submit. If your shell loads a live URL, the fix reaches the old binary too, and the new build is just your way back into review.

I submitted it for production review that day, then added a Data safety correction, which restarted the review. Play Console shows the release as published on 4 October at 8:39 am, Brisbane time.

The caveat is that the reviewer saw more than the pointer fix. The same deploy fixed an action list whose tenth row never showed, a handbrake that stayed held after the app lost focus, and chat typing that kept the character walking. Three more game updates went out while the review ran. Google never named a control, so I can’t tell you which change did it. Rule the pointer check out first, but don’t count on it being the whole story.

// join 78 subscribers

Enjoyed that? There are more.

Hi! I'm Jonah and I have thoughts that I share — sometimes. Sign up to receive awesome content in your inbox every week, month, when I get around to it. We don't spam. That's yuck.

Double opt-in — check your inbox (or spam folder) to confirm.