Android overlay pal — Kotlin overlay service #23
Labels
No labels
needs-info
needs-triage
ready-for-agent
ready-for-human
v2
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wayfinder:ticket
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
aiko/denpa#23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What to build
Decided:
TYPE_APPLICATION_OVERLAYvia 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 aSYSTEM_ALERT_WINDOWpermission gate, which card 2b already designs for.Cards 2a, 2b, 9a, 9b. Two constraints drove the design:
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:
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 ids1a,4a… index intoDenpa Receiver.dc.html, openable in a browser).Acceptance criteria
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-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}/approvalmessage.delta,tool.started,tool.completed,run.completed,run.cancelled,run.failed/run.errored,reasoning.available,approval.request(carries a per-requestchoicesarray and sets run statuswaiting_for_approval; the go-between discards it today — see #19)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-updatetext-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,heartbeatApproval 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.
Branch:
issue/denpa-23-android-overlay-pal(commit37da8c3)TYPE_APPLICATION_OVERLAYhosted 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_WINDOWis 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 aCustomEvent.foregroundServiceType="specialUse"and its subtype property;strings.xmlcarries 収納 / 常時受信 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).PalBargained anoverlayvariant row rather than a second bar, andPalBubblesaflipped/onLightHostpair — 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,addViewrefused 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:compileUniversalDebugKotlinBUILD SUCCESSFUL with no warnings from the new files, and the manifest and resources merge — and thatcargo checkis clean on the desktop path. The Androidcfgbranch 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-directionis not animatable. Four assumptions are in.denpa-work/QUESTIONS.md.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_OVERLAYwindow 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.xmlgains the service with itsspecialUsetype and subtype property plus the overlay/FGS/POST_NOTIFICATIONS permissions;strings.xmlgains the notification copy.Rust —
src-tauri/src/overlay.rsis a new in-crate plugin exposing five ordinary app commands (start, stop, set form, permission query, open settings), registered fromlib.rs.TypeScript —
components/pal/overlay-plan.tsis the geometry and state table (window bands, edges, forms, reducer);overlay-permission.tsis the permission/service gate;overlay-pal.tsx+overlay-pal-container.tsxare the overlay shell;lib/tauri-overlay.tsis the command and event bridge.pal-bar.tsxandpal-bubbles.tsxgained overlay variants rather than being forked into second components.App.tsxroutes the Android branch to the overlay container.Tests
268 passing in 26 files, against a 200-in-22 baseline on main.
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
markStoppedpaths; the command bridge's argument shapes (composerOpenin both directions) and theclose-composerevent 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-releasereducer tests and theparkEdgeForhit-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 testcannot resolve vitest in this install (node_modules/.binis absent in the shared install), so every run usednode 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 inoverlay-pal.tsxon a first pass and were fixed before commit.Deliberately left out
Drag-to-park is gone, not deferred. The first pass shipped a
drag-releasereducer branch, aparkEdgeForhit test and a live 「ドラッグで移動」 hint with nothing feeding them pointer coordinates. Moving the window needsupdateViewLayoutdriven 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 — anddata-tauri-drag-regionplus 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-directionis 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 devicesis 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,startForegroundsucceeding under Android 14specialUse, 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:compileUniversalDebugKotlinreported BUILD SUCCESSFUL with no warnings in the overlay package, and:app:processUniversalDebugManifest+:app:mergeUniversalDebugResourcesalso succeeded, so the service declaration, thespecialUsesubtype property and the new strings are at least well-formed. That required copying the gitignored generatedTauriActivitytree and the generatedtauri.*gradle files in from the main checkout; those are not in the commit.cargo check --offlineis clean on the desktop target, but the#[cfg(target_os = "android")]branch ofoverlay.rs— theregister_android_plugincall and therun_mobile_pluginsignatures — was compiled by nothing.Honest summary: the TypeScript half is covered and green, the Kotlin half compiles and is unrun.
Also untested: the
App.tsxwiring 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
pointer-eventscannot 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 whenhostedByWindowManager.CustomEventdispatched onto the webview by Kotlin. Tauri plugin commands are ACL-gated and an in-crate plugin has nopermissions/manifest to ship, soplugin:denpa-overlay|registerListener— whataddPluginListenercalls — would be denied. The plugin still exists, with no invoke_handler, purely to reachregister_android_plugin.specialUsewith a subtype property. It is not media playback or a location fix, andmicrophonewould throw atstartForegroundif RECORD_AUDIO were not yet granted.startForegroundis wrapped so a refusal falls back to a normal window rather than crashing.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.overlayvariant row and PalBubbles aflipped/onLightHostpair, followingchrome-plan.ts's one-table-two-sizes rule. Key sizes moved from Chakra props to inlinestyleso 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:
refresh()short-circuited onrunningand had no way to learn the service had died, so the nextvisibilitychangeflipped the reducer back toshell:'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 latchingmarkStopped(), pluscommitnow notifying on any state change rather than only shell changes.App.tsxstill mounted the desktop 322x520 Live2D box on the Android branch whileOverlayPal'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:
updateViewLayoutcannot 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.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_RESIZEinstead of being clipped invisibly, with the panel's geometry moved intoCOMPOSER_PANELso band and DOM cannot drift; the parked tab'saria-labelnow says what it does (it read 「左端に収納」 while doing the opposite) and carries the carrier state in words rather than colour and motion alone; a failedregister_android_plugindegrades to "no overlay here" instead of?-ing out of plugin setup and takingBuilder::rundown with it;OverlayHostno longer leaks the Activity and its whole view tree past destruction, andrestore()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 threestartServicecall sites reject instead of throwing an unhandleable Kotlin exception out of a Tauri command, and start usesContextCompat.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; thewhykey stopped borrowing 反転's width; PalBubbles'widthprop is now actually passed.One process gap: the global CLAUDE.md asks for a
caveman:cavecrew-reviewerpass 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-2still 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.Ruled out of v1 by #47 on the wayfinder map the conversation works (v1). Relabelled
v2.The deciding fact came from #44:
SYSTEM_ALERT_WINDOWexempts background-start only and is not on the while-in-use exemption list, so this issue's foreground service cannot start or promote to amicrophone-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.#47accepted the cost explicitly: shipped, never-device-verified code sitting in the tree unreachable is the same shape asdenpa#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".