Android overlay pal — Kotlin overlay service #23

Open
opened 2026-07-29 17:32:09 +00:00 by aiko · 3 comments
Owner

What to build

Decided: TYPE_APPLICATION_OVERLAY via a custom Kotlin Tauri plugin, hosting the webview in a foreground overlay service. The Bubbles API was rejected — its chrome cannot express the 34x52 parked tab or the 168px approval cuff, and the bare-avatar look is the point. This means a SYSTEM_ALERT_WINDOW permission gate, which card 2b already designs for.

Cards 2a, 2b, 9a, 9b. Two constraints drove the design:

  • The host app is usually light. The shadowed-bare-text trick from the desktop pal dies — every word lives inside a dark plate or the bar; nothing floats bare.
  • An overlay that cannot get out of the way is malware. So it parks and it flips.

2a — bottom-anchored pal, model beside a bubble stack, 44px minimum on every control. 反転 flips it to the opposite edge: the row reverses, the model mirrors (counter-mirror any text inside it), the bubble tails swap. Animate the flip; don't cut.

2b — the three states an overlay owes the user:

  • 収納 PARKED — collapses to a 34×52 edge tab that still shows the carrier, so you can tell she is listening without giving her your screen. Expands on the edge it parked on.
  • 許可 PERMISSION GATE — asked in her voice, and 今はしない is never a dead end ("通常のウィンドウで動作します").
  • 常駐 FOREGROUND SERVICE — Android requires a persistent notification. Treat it as a surface, not a tax: it carries 収納 / 常時受信 OFF / 停止, the one place to kill the mic or the pal from outside the app.

9 — approval on a parked tab. A parked tab must not steal the screen, but a suspended turn cannot sit silently. Approval is the only thing allowed to change the parked tab's size (an unprompted turn merely pulses it). It widens to a 168px cuff holding the ask and two 44px keys — without unparking her. Ignored for 30s, the cuff retracts to a glowing tab with a count. It never decides for you. 9b: the same two actions are answerable from the notification shade, with 差分を見る the only action that opens the app.

Design reference: docs/design/denpa-receiver/README.md (card ids 1a, 4a… index into Denpa Receiver.dc.html, openable in a browser).

Acceptance criteria

  • Overlay renders over other apps via the chosen mechanism, hosted by a foreground service
  • 反転 animates and mirrors correctly, including counter-mirrored text
  • Parked tab shows live carrier and expands on its parked edge
  • Permission denial falls back to a normal window
  • Foreground notification carries the three actions and can kill the mic
  • Approval cuff widens without unparking, retracts after 30s, and never auto-answers
  • Approval is answerable from the notification shade; answering anywhere dismisses everywhere
  • Verified on a real device

Blocked by


Reality audit

Governing rule: build to reality, Hermes leads. Not doing something is acceptable if it does not break core functionality. Breaking the design is acceptable. Building UI that does nothing is not.

Audited against the actual wire surface:

  • Hermes (hermes-agent 0.19.0, verified running): /health, /v1/models, /v1/runs, /v1/runs/{id}, /v1/runs/{id}/events, /v1/runs/{id}/stop, /v1/runs/{id}/approval
  • Run events: message.delta, tool.started, tool.completed, run.completed, run.cancelled, run.failed/run.errored, reasoning.available, approval.request (carries a per-request choices array and sets run status waiting_for_approval; the go-between discards it today — see #19)
  • Go-between → client: full-text, audio, control, error, set-model-and-conf, config-switched, config-files, background-files, history-list, history-data, new-history-created, history-deleted, user-input-transcription, backend-synth-complete, force-new-message, tool_call_status, interrupt-signal, heartbeat-ack, group-update
  • Client → go-between: text-input, mic-audio-data, mic-audio-end, raw-audio-data, ai-speak-signal, interrupt-signal, fetch-history-list, fetch-and-set-history, create-new-history, delete-history, fetch-configs, switch-config, fetch-backgrounds, audio-play-start, request-init-config, heartbeat

Approval cuff cut; overlay itself is real

The overlay, parking, 反転 flip, permission gate and foreground-service notification are all real — they are platform work, not wire work, and depend on nothing Hermes does or does not emit.

Cut from this issue: the entire Turn 9 approval cuff. The 168px widen, the two 44px keys, the 30-second retract, the notification-shade answer path. #19 is closed because no approval can be delivered, so the cuff has nothing to widen for. This issue''s blocker on #19 is removed.

Also deferred: the unprompted pulse on a parked tab. It depends on #22. The parked tab still shows the live carrier — that is real and is the more important half.

What remains is the honest core: an overlay that stays out of the way, parks to an edge, flips sides, asks for its permission in her voice, and can be killed from the notification.

## What to build **Decided: `TYPE_APPLICATION_OVERLAY` via a custom Kotlin Tauri plugin**, hosting the webview in a foreground overlay service. The Bubbles API was rejected — its chrome cannot express the 34x52 parked tab or the 168px approval cuff, and the bare-avatar look is the point. This means a `SYSTEM_ALERT_WINDOW` permission gate, which card 2b already designs for. Cards 2a, 2b, 9a, 9b. Two constraints drove the design: - **The host app is usually light.** The shadowed-bare-text trick from the desktop pal dies — every word lives inside a dark plate or the bar; **nothing floats bare**. - **An overlay that cannot get out of the way is malware.** So it **parks** and it **flips**. **2a** — bottom-anchored pal, model beside a bubble stack, 44px minimum on every control. **反転 flips it to the opposite edge**: the row reverses, the model mirrors (**counter-mirror any text inside it**), the bubble tails swap. Animate the flip; don't cut. **2b — the three states an overlay owes the user:** - **収納 PARKED** — collapses to a 34×52 edge tab that **still shows the carrier**, so you can tell she is listening without giving her your screen. Expands on the edge it parked on. - **許可 PERMISSION GATE** — asked in her voice, and 今はしない is never a dead end ("通常のウィンドウで動作します"). - **常駐 FOREGROUND SERVICE** — Android requires a persistent notification. **Treat it as a surface, not a tax:** it carries 収納 / 常時受信 OFF / 停止, the one place to kill the mic or the pal from outside the app. **9 — approval on a parked tab.** A parked tab must not steal the screen, but a suspended turn cannot sit silently. **Approval is the only thing allowed to change the parked tab's size** (an unprompted turn merely pulses it). It widens to a 168px cuff holding the ask and two 44px keys — **without unparking her**. Ignored for 30s, the cuff retracts to a glowing tab with a count. It never decides for you. **9b:** the same two actions are answerable from the notification shade, with 差分を見る the only action that opens the app. Design reference: `docs/design/denpa-receiver/README.md` (card ids `1a`, `4a`… index into `Denpa Receiver.dc.html`, openable in a browser). ## Acceptance criteria - [ ] Overlay renders over other apps via the chosen mechanism, hosted by a foreground service - [ ] 反転 animates and mirrors correctly, including counter-mirrored text - [ ] Parked tab shows live carrier and expands on its parked edge - [ ] Permission denial falls back to a normal window - [ ] Foreground notification carries the three actions and can kill the mic - [ ] Approval cuff widens without unparking, retracts after 30s, and never auto-answers - [ ] Approval is answerable from the notification shade; answering anywhere dismisses everywhere - [ ] Verified on a real device ## Blocked by - #12 --- ## Reality audit Governing rule: **build to reality, Hermes leads. Not doing something is acceptable if it does not break core functionality. Breaking the design is acceptable. Building UI that does nothing is not.** Audited against the actual wire surface: - **Hermes** (`hermes-agent 0.19.0`, verified running): `/health`, `/v1/models`, `/v1/runs`, `/v1/runs/{id}`, `/v1/runs/{id}/events`, `/v1/runs/{id}/stop`, `/v1/runs/{id}/approval` - **Run events**: `message.delta`, `tool.started`, `tool.completed`, `run.completed`, `run.cancelled`, `run.failed`/`run.errored`, `reasoning.available`, `approval.request` (carries a per-request `choices` array and sets run status `waiting_for_approval`; the go-between discards it today — see #19) - **Go-between → client**: `full-text`, `audio`, `control`, `error`, `set-model-and-conf`, `config-switched`, `config-files`, `background-files`, `history-list`, `history-data`, `new-history-created`, `history-deleted`, `user-input-transcription`, `backend-synth-complete`, `force-new-message`, `tool_call_status`, `interrupt-signal`, `heartbeat-ack`, `group-update` - **Client → go-between**: `text-input`, `mic-audio-data`, `mic-audio-end`, `raw-audio-data`, `ai-speak-signal`, `interrupt-signal`, `fetch-history-list`, `fetch-and-set-history`, `create-new-history`, `delete-history`, `fetch-configs`, `switch-config`, `fetch-backgrounds`, `audio-play-start`, `request-init-config`, `heartbeat` ### Approval cuff cut; overlay itself is real The overlay, parking, 反転 flip, permission gate and foreground-service notification are all real — they are platform work, not wire work, and depend on nothing Hermes does or does not emit. **Cut from this issue: the entire Turn 9 approval cuff.** The 168px widen, the two 44px keys, the 30-second retract, the notification-shade answer path. #19 is closed because no approval can be delivered, so the cuff has nothing to widen for. This issue''s blocker on #19 is removed. **Also deferred: the unprompted pulse on a parked tab.** It depends on #22. The parked tab still shows the live carrier — that is real and is the more important half. What remains is the honest core: an overlay that stays out of the way, parks to an edge, flips sides, asks for its permission in her voice, and can be killed from the notification.
Author
Owner

Branch: issue/denpa-23-android-overlay-pal (commit 37da8c3)

TYPE_APPLICATION_OVERLAY hosted by a foreground service, per the audited scope — the approval cuff and the unprompted pulse are out.

Kotlin (src-tauri/gen/android/app/src/main/java/works/aiko/ollvt/overlay/)

  • OverlayService — the 常駐 foreground service. It reparents the app's own webview into the overlay window rather than starting a second one, so 収納 and the pal stay one session (one websocket, one audio graph, one Live2D context). The window is cut to the pal's box (432×346dp at inset 24 / bottom 52), because an overlay window swallows every touch inside its bounds and a full-screen one would eat the host app's every tap.
  • OverlayPermissionSettings.canDrawOverlays + ACTION_MANAGE_OVERLAY_PERMISSION. SYSTEM_ALERT_WINDOW is a special permission with no request-and-callback, so the gate is a re-check on every resume.
  • OverlayPlugin — the five commands, and the 常駐 notification's actions dispatched onto the webview as a CustomEvent.
  • Manifest: the service with foregroundServiceType="specialUse" and its subtype property; strings.xml carries 収納 / 常時受信 OFF / 停止.

Rust src-tauri/src/overlay.rs — a plugin that holds the Android handle plus five app-level commands. Off Android they answer "no overlay here", which is the same fallback as a refused permission.

TypeScript (components/pal/, lib/tauri-overlay.ts) — overlay-plan.ts (2a/2b geometry, the 反転 rule, the 収納 state machine), overlay-permission.ts (the gate), overlay-pal.tsx (the surface and the 許可 card), overlay-pal-container.tsx (wired to the existing contexts; pet mode on Android renders it instead of the desktop pal). PalBar gained an overlay variant row rather than a second bar, and PalBubbles a flipped / onLightHost pair — no second pal vocabulary.

Testsnpm test: 259 passed in 26 files (main was 200 in 22). 59 new, covering the flip and its counter-mirror, the parked tab's geometry and edge, the reducer, and every way the permission can fail: refused, declined, revoked while running, addView refused after a positive check, Settings unopenable, and no plugin at all. Each of those degrades to a normal window.

What I could not verify. There is no device here. The overlay was never displayed: the reparenting, the flip's motion, the notification's three actions and the parked tab's live carrier are all unexercised. What I do have is that the Kotlin compiles — :app:compileUniversalDebugKotlin BUILD SUCCESSFUL with no warnings from the new files, and the manifest and resources merge — and that cargo check is clean on the desktop path. The Android cfg branch of the Rust was not compiled. So: the TypeScript half is covered, the Kotlin half is compiled but unrun. "Verified on a real device" stays unticked.

Deliberately left out. Drag-to-park has a reducer and a hit test but nothing feeding it coordinates — the overlay window is the pal's own box, so a drag has to move the window through the service. Tapping the live 左端に収納 / 右端に収納 hint parks her, as does the notification. The flip's row-order swap is a cut behind the animated mirror and slide, because flex-direction is not animatable. Four assumptions are in .denpa-work/QUESTIONS.md.

**Branch:** `issue/denpa-23-android-overlay-pal` (commit `37da8c3`) `TYPE_APPLICATION_OVERLAY` hosted by a foreground service, per the audited scope — the approval cuff and the unprompted pulse are out. **Kotlin** (`src-tauri/gen/android/app/src/main/java/works/aiko/ollvt/overlay/`) - `OverlayService` — the 常駐 foreground service. It reparents the app's **own** webview into the overlay window rather than starting a second one, so 収納 and the pal stay one session (one websocket, one audio graph, one Live2D context). The window is cut to the pal's box (432×346dp at inset 24 / bottom 52), because an overlay window swallows every touch inside its bounds and a full-screen one would eat the host app's every tap. - `OverlayPermission` — `Settings.canDrawOverlays` + `ACTION_MANAGE_OVERLAY_PERMISSION`. `SYSTEM_ALERT_WINDOW` is a special permission with no request-and-callback, so the gate is a re-check on every resume. - `OverlayPlugin` — the five commands, and the 常駐 notification's actions dispatched onto the webview as a `CustomEvent`. - Manifest: the service with `foregroundServiceType="specialUse"` and its subtype property; `strings.xml` carries 収納 / 常時受信 OFF / 停止. **Rust** `src-tauri/src/overlay.rs` — a plugin that holds the Android handle plus five app-level commands. Off Android they answer "no overlay here", which is the same fallback as a refused permission. **TypeScript** (`components/pal/`, `lib/tauri-overlay.ts`) — `overlay-plan.ts` (2a/2b geometry, the 反転 rule, the 収納 state machine), `overlay-permission.ts` (the gate), `overlay-pal.tsx` (the surface and the 許可 card), `overlay-pal-container.tsx` (wired to the existing contexts; pet mode on Android renders it instead of the desktop pal). `PalBar` gained an `overlay` variant row rather than a second bar, and `PalBubbles` a `flipped` / `onLightHost` pair — no second pal vocabulary. **Tests** — `npm test`: **259 passed in 26 files** (main was 200 in 22). 59 new, covering the flip and its counter-mirror, the parked tab's geometry and edge, the reducer, and every way the permission can fail: refused, declined, revoked while running, `addView` refused after a positive check, Settings unopenable, and no plugin at all. Each of those degrades to a normal window. **What I could not verify.** There is no device here. The overlay was never displayed: the reparenting, the flip's motion, the notification's three actions and the parked tab's live carrier are all unexercised. What I do have is that the Kotlin compiles — `:app:compileUniversalDebugKotlin` BUILD SUCCESSFUL with no warnings from the new files, and the manifest and resources merge — and that `cargo check` is clean on the desktop path. The Android `cfg` branch of the Rust was not compiled. So: the TypeScript half is covered, the Kotlin half is compiled but unrun. "Verified on a real device" stays unticked. **Deliberately left out.** Drag-to-park has a reducer and a hit test but nothing feeding it coordinates — the overlay window is the pal's own box, so a drag has to move the window through the service. Tapping the live 左端に収納 / 右端に収納 hint parks her, as does the notification. The flip's row-order swap is a cut behind the animated mirror and slide, because `flex-direction` is not animatable. Four assumptions are in `.denpa-work/QUESTIONS.md`.
Author
Owner

Branch: issue/denpa-23-android-overlay-pal (pushed, not merged, no PR, issue left open).

What changed

The Android pal now runs as a TYPE_APPLICATION_OVERLAY window instead of being confined to the app.

Kotlin, src-tauri/gen/android/.../works/aiko/ollvt/overlay/ — four new files: OverlayService.kt (the foreground service owning the WindowManager window, its layout params per form, and the 常駐 notification with 収納 / 常時受信 OFF / 停止), OverlayHost.kt (holds the Activity's WebView and its home parent so it can be reparented out and back), OverlayPermission.kt (SYSTEM_ALERT_WINDOW check plus the settings intent), OverlayPlugin.kt (the Tauri Android plugin shell). AndroidManifest.xml gains the service with its specialUse type and subtype property plus the overlay/FGS/POST_NOTIFICATIONS permissions; strings.xml gains the notification copy.

Rust — src-tauri/src/overlay.rs is a new in-crate plugin exposing five ordinary app commands (start, stop, set form, permission query, open settings), registered from lib.rs.

TypeScript — components/pal/overlay-plan.ts is the geometry and state table (window bands, edges, forms, reducer); overlay-permission.ts is the permission/service gate; overlay-pal.tsx + overlay-pal-container.tsx are the overlay shell; lib/tauri-overlay.ts is the command and event bridge. pal-bar.tsx and pal-bubbles.tsx gained overlay variants rather than being forked into second components. App.tsx routes the Android branch to the overlay container.

Tests

268 passing in 26 files, against a 200-in-22 baseline on main.

 RUN  v4.1.10 F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_04e365ce-0c5-2

 Test Files  26 passed (26)
      Tests  268 passed (268)
   Start at  06:56:55
   Duration  5.94s

What the new tests cover: the geometry table sums — window height is derived from model + gaps + bar + the 44px touch floor and a test asserts the sum, so the hand-synced Kotlin constant cannot drift from the DOM; the composer band's height sum; the form/edge/flip reducer transitions; the permission gate's state machine including the three markStopped paths; the command bridge's argument shapes (composerOpen in both directions) and the close-composer event routing; the overlay bar's 44px key floor read out of the plan table; the parked tab's accessible name; that <Live2D /> passed as children lands inside the mirrored model layer ahead of the bubbles; and that the overlay bar carries no drag affordance.

All twelve assertions added during the review-fix pass were confirmed red first by reverting only the seven source files to HEAD and re-running the four affected suites.

Deleted vs fixed: nothing was fixed — there were no pre-existing failures. Two test groups were deleted: the drag-release reducer tests and the parkEdgeFor hit-test tests. They went because the code they tested went (see below); they were tests asserting the behaviour of a subsystem whose only caller was those tests. No test was deleted or skipped to get to green.

npm test cannot resolve vitest in this install (node_modules/.bin is absent in the shared install), so every run used node node_modules/vitest/vitest.mjs run — same binary, same config.

Typecheck: main is already 585 errors. This branch adds none — verified by grepping both tsconfig passes for overlay, components/pal, App.tsx, tauri-overlay, no hits. Two new errors did appear in overlay-pal.tsx on a first pass and were fixed before commit.

Deliberately left out

Drag-to-park is gone, not deferred. The first pass shipped a drag-release reducer branch, a parkEdgeFor hit test and a live 「ドラッグで移動」 hint with nothing feeding them pointer coordinates. Moving the window needs updateViewLayout driven from a touch listener, which needs a device to tune. Rather than ship UI that does nothing, all of it was deleted — hint, constants, reducer branch, hit test — and data-tauri-drag-region plus the grab cursor are now gated to the desktop bar variant. Parking is by tapping the 収納 hint, or from the notification's 収納. This deviates from the design doc and is recorded in .denpa-work/QUESTIONS.md.

The flip does not animate row order. The model mirror and the edge slide animate; the bar's column swap is a cut, because flex-direction is not an animatable CSS property and animating it would mean absolutely positioning both columns.

Turn 9's approval cuff and the unprompted pulse were cut from the issue by its own reality audit and were not built.

A POST_NOTIFICATIONS refusal has no in-app surface. The permission is requested (so the operator gets the system dialog) and a refusal is logged, but 2a/2b has nowhere to say "your outside kill switch is missing". In QUESTIONS.md.

Could not be verified at all

The overlay was never displayed. adb devices is empty — no device, no emulator. Nothing about the running behaviour was exercised: reparenting the live WebView into the overlay window, the flip's motion, the parked tab's live carrier, startForeground succeeding under Android 14 specialUse, the notification's three actions reaching the frontend, the back-key handler, the composer band's IME resize, and whether the window band actually fits her on a real screen. "Verified on a real device" is unticked on the issue.

What there is evidence for: :app:compileUniversalDebugKotlin reported BUILD SUCCESSFUL with no warnings in the overlay package, and :app:processUniversalDebugManifest + :app:mergeUniversalDebugResources also succeeded, so the service declaration, the specialUse subtype property and the new strings are at least well-formed. That required copying the gitignored generated TauriActivity tree and the generated tauri.* gradle files in from the main checkout; those are not in the commit. cargo check --offline is clean on the desktop target, but the #[cfg(target_os = "android")] branch of overlay.rs — the register_android_plugin call and the run_mobile_plugin signatures — was compiled by nothing.

Honest summary: the TypeScript half is covered and green, the Kotlin half compiles and is unrun.

Also untested: the App.tsx wiring itself. The component-level contract (children land in the mirrored model layer) is tested; OverlayPalContainer's passthrough of <Live2D /> is not, because the container needs eight contexts to render.

Assumptions

  1. The overlay hosts the app's own WebView, reparented out of the Activity into the WindowManager, not a second WebView inside the service. A second webview would be a second client — its own websocket, audio graph and Live2D context — and the operator would be talking to two of her.
  2. The window is cut to the pal, never to the screen (376dp tall expanded, 547dp with the composer, 34x52 parked). An overlay window swallows every touch inside its bounds; CSS pointer-events cannot give them back and Android has no equivalent of the desktop pal's cursor-ignore. A full-screen overlay would eat the host app's every tap, which is "an overlay that cannot get out of the way is malware" taken literally. The React surface drops its own insets when hostedByWindowManager.
  3. The five commands are ordinary app commands, not plugin commands, and the notification's actions arrive as a DOM CustomEvent dispatched onto the webview by Kotlin. Tauri plugin commands are ACL-gated and an in-crate plugin has no permissions/ manifest to ship, so plugin:denpa-overlay|registerListener — what addPluginListener calls — would be denied. The plugin still exists, with no invoke_handler, purely to reach register_android_plugin.
  4. Foreground service type is specialUse with a subtype property. It is not media playback or a location fix, and microphone would throw at startForeground if RECORD_AUDIO were not yet granted. startForeground is wrapped so a refusal falls back to a normal window rather than crashing.
  5. The expanded window is focusable only while the composer is open; the parked tab never is. She can only own the back button or the keyboard when there is a text field to justify it.
  6. Home edge is left, matching card 1b, so 反転 is the deviation and the mirror. She always faces the middle of the screen, which makes the far edge the mirrored one; text inside the model layer is counter-mirrored.
  7. 常時受信 OFF kills the mic by reaching the frontend's VAD, so it cannot help if the webview's JS is wedged. 停止 tears the service down and the frontend then treats itself as a normal window.
  8. No second pal vocabulary: PalBar gained an overlay variant row and PalBubbles a flipped/onLightHost pair, following chrome-plan.ts's one-table-two-sizes rule. Key sizes moved from Chakra props to inline style so the 44px floor is readable by a test.

Review findings

A review pass raised 2 blockers, 9 major, 3 minor. All fifteen were addressed; nothing was skipped outright.

Both blockers were real and would have shipped broken behaviour:

  • 停止 did not stick. The permission gate's refresh() short-circuited on running and had no way to learn the service had died, so the next visibilitychange flipped the reducer back to shell:'overlay' with a dead service — the pal rendering at overlay geometry inside a full-screen normal window, unrecoverable for the life of the process. Fixed with a latching markStopped(), plus commit now notifying on any state change rather than only shell changes.
  • The overlay had no model. App.tsx still mounted the desktop 322x520 Live2D box on the Android branch while OverlayPal's 186x262 model slot sat empty — a clipped canvas over the chrome and a hole where she should be. Fixed by passing <Live2D /> through as children and dropping the desktop box on that branch.

Two findings took the reviewer's own alternative branch rather than its primary suggestion, and these are the two worth arguing about on merge:

  • Drag — deleted rather than wired, because a touch-driven updateViewLayout cannot be tuned without a device. Justification: the issue's own rule is that building UI which does nothing is not acceptable, and the alternative was leaving a dead subsystem with tests asserting nothing shipped.
  • POST_NOTIFICATIONS — requested and logged, but a refusal is not surfaced in the UI. Justification: 2a/2b has no surface for it, and the system dialog is the telling. If the operator dismisses it, the outside kill switch is silently absent — that is a known hole.

Both are in .denpa-work/QUESTIONS.md.

The rest, briefly: window height is now derived from its parts (it was 346dp against 376dp of content, clipping 30dp off her head, and nothing tested the sum); the composer got its own window band with SOFT_INPUT_ADJUST_RESIZE instead of being clipped invisibly, with the panel's geometry moved into COMPOSER_PANEL so band and DOM cannot drift; the parked tab's aria-label now says what it does (it read 「左端に収納」 while doing the opposite) and carries the carrier state in words rather than colour and motion alone; a failed register_android_plugin degrades to "no overlay here" instead of ?-ing out of plugin setup and taking Builder::run down with it; OverlayHost no longer leaks the Activity and its whole view tree past destruction, and restore() resolves its home parent before detaching so it cannot leave the app's only WebView attached to nothing; fallBack() no longer reparents that WebView twice on the graceful path; the three startService call sites reject instead of throwing an unhandleable Kotlin exception out of a Tauri command, and start uses ContextCompat.startForegroundService; back is consumed for a purpose instead of being silently taken from whatever app is underneath; the notification small icon is a monochrome drawable instead of a masked launcher mipmap; the why key stopped borrowing 反転's width; PalBubbles' width prop is now actually passed.

One process gap: the global CLAUDE.md asks for a caveman:cavecrew-reviewer pass and there was no re-review of the fix pass — no agent-spawning tool is available in this subagent. The fixes rest on the twelve confirmed-red assertions, not on a second reviewer.

Worktree note

The implementing worktree denpa-client/.claude/worktrees/wf_04e365ce-0c5-2 still exists and still holds this branch, so the main checkout refused to check it out; the main checkout is also sitting on a sibling's branch. All work happened in the existing worktree. Nothing in the main checkout was touched.

Branch: `issue/denpa-23-android-overlay-pal` (pushed, not merged, no PR, issue left open). ## What changed The Android pal now runs as a `TYPE_APPLICATION_OVERLAY` window instead of being confined to the app. Kotlin, `src-tauri/gen/android/.../works/aiko/ollvt/overlay/` — four new files: `OverlayService.kt` (the foreground service owning the WindowManager window, its layout params per form, and the 常駐 notification with 収納 / 常時受信 OFF / 停止), `OverlayHost.kt` (holds the Activity's WebView and its home parent so it can be reparented out and back), `OverlayPermission.kt` (SYSTEM_ALERT_WINDOW check plus the settings intent), `OverlayPlugin.kt` (the Tauri Android plugin shell). `AndroidManifest.xml` gains the service with its `specialUse` type and subtype property plus the overlay/FGS/POST_NOTIFICATIONS permissions; `strings.xml` gains the notification copy. Rust — `src-tauri/src/overlay.rs` is a new in-crate plugin exposing five ordinary app commands (start, stop, set form, permission query, open settings), registered from `lib.rs`. TypeScript — `components/pal/overlay-plan.ts` is the geometry and state table (window bands, edges, forms, reducer); `overlay-permission.ts` is the permission/service gate; `overlay-pal.tsx` + `overlay-pal-container.tsx` are the overlay shell; `lib/tauri-overlay.ts` is the command and event bridge. `pal-bar.tsx` and `pal-bubbles.tsx` gained overlay variants rather than being forked into second components. `App.tsx` routes the Android branch to the overlay container. ## Tests 268 passing in 26 files, against a 200-in-22 baseline on main. ``` RUN v4.1.10 F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_04e365ce-0c5-2 Test Files 26 passed (26) Tests 268 passed (268) Start at 06:56:55 Duration 5.94s ``` What the new tests cover: the geometry table sums — window height is derived from model + gaps + bar + the 44px touch floor and a test asserts the sum, so the hand-synced Kotlin constant cannot drift from the DOM; the composer band's height sum; the form/edge/flip reducer transitions; the permission gate's state machine including the three `markStopped` paths; the command bridge's argument shapes (`composerOpen` in both directions) and the `close-composer` event routing; the overlay bar's 44px key floor read out of the plan table; the parked tab's accessible name; that `<Live2D />` passed as children lands inside the mirrored model layer ahead of the bubbles; and that the overlay bar carries no drag affordance. All twelve assertions added during the review-fix pass were confirmed red first by reverting only the seven source files to HEAD and re-running the four affected suites. **Deleted vs fixed:** nothing was fixed — there were no pre-existing failures. Two test groups were deleted: the `drag-release` reducer tests and the `parkEdgeFor` hit-test tests. They went because the code they tested went (see below); they were tests asserting the behaviour of a subsystem whose only caller was those tests. No test was deleted or skipped to get to green. `npm test` cannot resolve vitest in this install (`node_modules/.bin` is absent in the shared install), so every run used `node node_modules/vitest/vitest.mjs run` — same binary, same config. Typecheck: main is already 585 errors. This branch adds none — verified by grepping both tsconfig passes for `overlay`, `components/pal`, `App.tsx`, `tauri-overlay`, no hits. Two new errors did appear in `overlay-pal.tsx` on a first pass and were fixed before commit. ## Deliberately left out **Drag-to-park is gone, not deferred.** The first pass shipped a `drag-release` reducer branch, a `parkEdgeFor` hit test and a live 「ドラッグで移動」 hint with nothing feeding them pointer coordinates. Moving the window needs `updateViewLayout` driven from a touch listener, which needs a device to tune. Rather than ship UI that does nothing, all of it was deleted — hint, constants, reducer branch, hit test — and `data-tauri-drag-region` plus the grab cursor are now gated to the desktop bar variant. Parking is by tapping the 収納 hint, or from the notification's 収納. This deviates from the design doc and is recorded in `.denpa-work/QUESTIONS.md`. **The flip does not animate row order.** The model mirror and the edge slide animate; the bar's column swap is a cut, because `flex-direction` is not an animatable CSS property and animating it would mean absolutely positioning both columns. **Turn 9's approval cuff and the unprompted pulse** were cut from the issue by its own reality audit and were not built. **A POST_NOTIFICATIONS refusal has no in-app surface.** The permission is requested (so the operator gets the system dialog) and a refusal is logged, but 2a/2b has nowhere to say "your outside kill switch is missing". In QUESTIONS.md. ## Could not be verified at all **The overlay was never displayed.** `adb devices` is empty — no device, no emulator. Nothing about the running behaviour was exercised: reparenting the live WebView into the overlay window, the flip's motion, the parked tab's live carrier, `startForeground` succeeding under Android 14 `specialUse`, the notification's three actions reaching the frontend, the back-key handler, the composer band's IME resize, and whether the window band actually fits her on a real screen. "Verified on a real device" is unticked on the issue. What there is evidence for: `:app:compileUniversalDebugKotlin` reported BUILD SUCCESSFUL with no warnings in the overlay package, and `:app:processUniversalDebugManifest` + `:app:mergeUniversalDebugResources` also succeeded, so the service declaration, the `specialUse` subtype property and the new strings are at least well-formed. That required copying the gitignored generated `TauriActivity` tree and the generated `tauri.*` gradle files in from the main checkout; those are not in the commit. `cargo check --offline` is clean on the desktop target, but the `#[cfg(target_os = "android")]` branch of `overlay.rs` — the `register_android_plugin` call and the `run_mobile_plugin` signatures — was compiled by nothing. Honest summary: the TypeScript half is covered and green, the Kotlin half compiles and is unrun. Also untested: the `App.tsx` wiring itself. The component-level contract (children land in the mirrored model layer) is tested; `OverlayPalContainer`'s passthrough of `<Live2D />` is not, because the container needs eight contexts to render. ## Assumptions 1. **The overlay hosts the app's own WebView**, reparented out of the Activity into the WindowManager, not a second WebView inside the service. A second webview would be a second client — its own websocket, audio graph and Live2D context — and the operator would be talking to two of her. 2. **The window is cut to the pal, never to the screen** (376dp tall expanded, 547dp with the composer, 34x52 parked). An overlay window swallows every touch inside its bounds; CSS `pointer-events` cannot give them back and Android has no equivalent of the desktop pal's cursor-ignore. A full-screen overlay would eat the host app's every tap, which is "an overlay that cannot get out of the way is malware" taken literally. The React surface drops its own insets when `hostedByWindowManager`. 3. **The five commands are ordinary app commands, not plugin commands**, and the notification's actions arrive as a DOM `CustomEvent` dispatched onto the webview by Kotlin. Tauri plugin commands are ACL-gated and an in-crate plugin has no `permissions/` manifest to ship, so `plugin:denpa-overlay|registerListener` — what `addPluginListener` calls — would be denied. The plugin still exists, with no invoke_handler, purely to reach `register_android_plugin`. 4. **Foreground service type is `specialUse`** with a subtype property. It is not media playback or a location fix, and `microphone` would throw at `startForeground` if RECORD_AUDIO were not yet granted. `startForeground` is wrapped so a refusal falls back to a normal window rather than crashing. 5. **The expanded window is focusable only while the composer is open**; the parked tab never is. She can only own the back button or the keyboard when there is a text field to justify it. 6. **Home edge is `left`**, matching card 1b, so 反転 is the deviation and the mirror. She always faces the middle of the screen, which makes the far edge the mirrored one; text inside the model layer is counter-mirrored. 7. **常時受信 OFF kills the mic by reaching the frontend's VAD**, so it cannot help if the webview's JS is wedged. 停止 tears the service down and the frontend then treats itself as a normal window. 8. **No second pal vocabulary**: PalBar gained an `overlay` variant row and PalBubbles a `flipped`/`onLightHost` pair, following `chrome-plan.ts`'s one-table-two-sizes rule. Key sizes moved from Chakra props to inline `style` so the 44px floor is readable by a test. ## Review findings A review pass raised 2 blockers, 9 major, 3 minor. All fifteen were addressed; **nothing was skipped outright**. Both blockers were real and would have shipped broken behaviour: - **停止 did not stick.** The permission gate's `refresh()` short-circuited on `running` and had no way to learn the service had died, so the next `visibilitychange` flipped the reducer back to `shell:'overlay'` with a dead service — the pal rendering at overlay geometry inside a full-screen normal window, unrecoverable for the life of the process. Fixed with a latching `markStopped()`, plus `commit` now notifying on any state change rather than only shell changes. - **The overlay had no model.** `App.tsx` still mounted the desktop 322x520 Live2D box on the Android branch while `OverlayPal`'s 186x262 model slot sat empty — a clipped canvas over the chrome and a hole where she should be. Fixed by passing `<Live2D />` through as children and dropping the desktop box on that branch. Two findings took the reviewer's own alternative branch rather than its primary suggestion, and these are the two worth arguing about on merge: - **Drag** — deleted rather than wired, because a touch-driven `updateViewLayout` cannot be tuned without a device. Justification: the issue's own rule is that building UI which does nothing is not acceptable, and the alternative was leaving a dead subsystem with tests asserting nothing shipped. - **POST_NOTIFICATIONS** — requested and logged, but a refusal is not surfaced in the UI. Justification: 2a/2b has no surface for it, and the system dialog is the telling. If the operator dismisses it, the outside kill switch is silently absent — that is a known hole. Both are in `.denpa-work/QUESTIONS.md`. The rest, briefly: window height is now derived from its parts (it was 346dp against 376dp of content, clipping 30dp off her head, and nothing tested the sum); the composer got its own window band with `SOFT_INPUT_ADJUST_RESIZE` instead of being clipped invisibly, with the panel's geometry moved into `COMPOSER_PANEL` so band and DOM cannot drift; the parked tab's `aria-label` now says what it does (it read 「左端に収納」 while doing the opposite) and carries the carrier state in words rather than colour and motion alone; a failed `register_android_plugin` degrades to "no overlay here" instead of `?`-ing out of plugin setup and taking `Builder::run` down with it; `OverlayHost` no longer leaks the Activity and its whole view tree past destruction, and `restore()` resolves its home parent before detaching so it cannot leave the app's only WebView attached to nothing; `fallBack()` no longer reparents that WebView twice on the graceful path; the three `startService` call sites reject instead of throwing an unhandleable Kotlin exception out of a Tauri command, and start uses `ContextCompat.startForegroundService`; back is consumed for a purpose instead of being silently taken from whatever app is underneath; the notification small icon is a monochrome drawable instead of a masked launcher mipmap; the `why` key stopped borrowing 反転's width; PalBubbles' `width` prop is now actually passed. One process gap: the global CLAUDE.md asks for a `caveman:cavecrew-reviewer` pass and there was no re-review of the fix pass — no agent-spawning tool is available in this subagent. The fixes rest on the twelve confirmed-red assertions, not on a second reviewer. ## Worktree note The implementing worktree `denpa-client/.claude/worktrees/wf_04e365ce-0c5-2` still exists and still holds this branch, so the main checkout refused to check it out; the main checkout is also sitting on a sibling's branch. All work happened in the existing worktree. Nothing in the main checkout was touched.
Author
Owner

Ruled out of v1 by #47 on the wayfinder map the conversation works (v1). Relabelled v2.

The deciding fact came from #44: SYSTEM_ALERT_WINDOW exempts background-start only and is not on the while-in-use exemption list, so this issue's foreground service cannot start or promote to a microphone-typed service from the background. It does not help #35's capture path — it competes with it. That removed the main argument for carrying the overlay into v1.

This issue now owns the disable, not a revert. The code stays merged at 19bf953; what changes is that nothing starts the service, and the reason is written where the next reader hits it rather than left as silence. #47 accepted the cost explicitly: shipped, never-device-verified code sitting in the tree unreachable is the same shape as denpa#27's listener with no producer, so being unreachable on purpose and legibly is the whole mitigation.

The 15 review findings stay open against it. They are not v1 work, and they are also not resolved — do not read "v2" as "fine".

**Ruled out of v1** by [#47](https://git.aiko.works/aiko/denpa/issues/47) on the wayfinder map [the conversation works (v1)](https://git.aiko.works/aiko/denpa/issues/41). Relabelled `v2`. The deciding fact came from [#44](https://git.aiko.works/aiko/denpa/issues/44): `SYSTEM_ALERT_WINDOW` exempts background-*start* only and is **not** on the while-in-use exemption list, so this issue's foreground service cannot start or promote to a `microphone`-typed service from the background. It does not help `#35`'s capture path — it competes with it. That removed the main argument for carrying the overlay into v1. **This issue now owns the disable**, not a revert. The code stays merged at `19bf953`; what changes is that nothing starts the service, and the reason is written where the next reader hits it rather than left as silence. `#47` accepted the cost explicitly: shipped, never-device-verified code sitting in the tree unreachable is the same shape as `denpa#27`'s listener with no producer, so being unreachable *on purpose and legibly* is the whole mitigation. The 15 review findings stay open against it. They are not v1 work, and they are also not resolved — do not read "v2" as "fine".
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#23
No description provided.