Skip to content

Commit 42019ba

Browse files
authored
vDevice, the built-in browser & DevTools, and the extension surface (#59)
* vDevice: its own keyboard, its own permission prompt, real insets and a working app bar Four things a device needs before an app running on it behaves like an app running on a phone — and before an agent can drive one. **A keyboard of its own.** The device borrowed the phone's IME, which is JCode's chrome: `screencap` of the device did not show it, `uiautomator dump` did not list it, and `input tap` could not reach a key, so an agent could see a text field and had no way to answer it. `tools/vdevice-keyboard` is a real APK hosted as a container child. The seam is `View.onCreateInputConnection`, not `InputMethodManager` — `showSoftInput` returns false before it reaches a binder for a windowless hierarchy — so the keyboard is handed the same `InputConnection` a real IME gets and the editing is the platform's. Text arrives as text, accents and emoji included, instead of through `KeyCharacterMap`, which has no key for most of it. Adds `adb shell ime show|hide|toggle|status|list`. **A permission prompt on the device.** It was a Compose `AlertDialog` in the tab: out of `:guest` over the binder, onto the screen, and back with the answer. Both halves were in `:guest` the whole time, so the question never needed to leave — and leaving was what put it in the wrong window. `VirtualPermissionDialog` is a container child with declared ids, so it photographs and taps like everything else. `onPermissionRequest` and `permissionResult` are deleted. **Insets that describe this device.** A window that stops where the bar starts was enough for an app that lays out inside its window, and nothing at all for one that asked to draw behind it: edge-to-edge is a bargain, and the app was keeping its half alone. The container now substitutes its own insets at the root, so what a guest reads is the device's chrome rather than the phone's. The bar's height is accounted for exactly once — as a margin or as an inset, never both. **An app bar with words in it.** `onPostCreate` was never dispatched, and it is what sets `mTitleReady`; without it `onTitleChanged` never reaches the window, so a themed action bar was built and its title stayed empty. It is also where `AppCompatActivity` hands its delegate the same call. `tools/hardware-fixture` gains an action bar, an options menu and a WINDOW block reporting the insets as dispatched, so all of this is checkable at a glance. Its README's build recipe was missing the resource step. Verified on an Odin2: app bar renders with its title and overflow popup; the three status-bar modes give content heights 559 / 559 / 609, the bar's 50px counted once; the prompt answers to `input tap` and the callback arrives; the keyboard reads the field's EditorInfo (Web address, `/`, `.com`, Go) and types through its connection. * vDevice: paint the home screen after an app is stopped Stopping an app left the device showing its wallpaper *colour* and nothing else — no wallpaper, no status bar, no icons, not even the "No app installed" placeholder — until the tab was closed and reopened. Taps still launched the icons nobody could see, which is what gave it away: the launcher's data was there, and only the paint was missing. The tab rebuilds the surface view as a guest's screen goes, because a released child `SurfacePackage` otherwise leaves the app's last frame on a layer over everything the container draws. So the launcher is handed to a view created moments earlier, whose surface does not exist yet — normally nothing to worry about, because `surfaceCreated` paints again from the list the view is already holding. Except a `SurfaceView` creates its surface from an `OnPreDrawListener`, so it needs the view tree to *draw* once after it is attached — and an app stopping is the exact moment the screen goes still. Nothing animates, nothing invalidates, no traversal runs, and the callback that would have rescued it never comes. Measured: `surfaceCreated` had still not fired forty seconds later. So the draw is asked for rather than waited for. One `invalidate()` when the surface is not there yet; the surface arrives 17ms later and the existing callback paints it. Verified on an Odin2 over three consecutive launch/stop cycles. * vDevice: radios on the status bar, quick actions in the shade, and a shade you can pull anywhere Three things that turn a strip with notification icons on it into the device's status bar. **Radios on the bar.** Wi-Fi, Bluetooth and the mobile radio, on the right where a phone puts its system icons, drawn only when the device *has* that radio and has it switched on — the same two states a phone shows and the two an app is written to tell apart. This is the one thing the phone's own bar, sitting directly above, cannot say for this device: its Wi-Fi is not the phone's, and seeing that it is off is the answer to "why does this app think it is offline". **Quick actions in the shade.** One tile per radio the device was built with, switching it where the app is rather than three screens away in the device's Settings. Taking an app offline and watching what it does is one of the things this device is for, and until now it cost a round trip through Settings — by which time the app had usually decided. The tiles and the bar's icons are built from the same read, so the two cannot disagree. **A shade that pulls anywhere.** It had two holes, both of which made it a decoration on the screens that happened to be simple rather than the device's own shade: - A guest's dialog or popup is a *separate window*, so it took every touch on the screen the moment one was open, and `deviceUiUnder` did not ask about the status bar. It does now — the same argument that already put the keyboard and the permission prompt ahead of a guest's window. - A full-screen app made the bar `GONE`, and a view with no height receives no touches, so taking the strip away took the shade with it. The strip is `INVISIBLE` instead: nothing is drawn over an app that asked for the screen, but there is still an edge to pull from, deliberately narrower than the ordinary grab because those pixels are the app's. Pulling the shade brings the strip back with it, and closing it takes it away again. The shade is opaque now. A hair of an app showing through a 22dp strip is texture; the same hair through a full pane is a notification's title competing with whatever button the app has underneath — measured on the hardware fixture, whose pale full-width buttons ghosted straight through "No notifications". Verified on an Odin2: icons follow the switches; a tile toggles Wi-Fi and the bar's icon goes with it; the shade pulls down over an open overflow popup and over a full-screen app, and the app still gets its own touches underneath. * vDevice: close the shade when it stops being what the screen is about Everybody tries the same gesture on an open shade — a tap on the app behind it — and nothing happened. The status bar's view was only as tall as the two things it draws, so a press below them was never delivered to it at all: the container gave it straight to the guest, and the branch that would have closed the shade could not be reached. Anything a view is going to dismiss itself on has to be something the view is given, so the view is now the height of the screen. It costs nothing while the shade is shut. A press below the grab strip is refused, the container moves on to the app underneath, and the app never knows the view is there — checked by tapping the fixture's own button straight afterwards and getting the permission prompt. The dismissal is measured against the bottom of the *panel* rather than the grab strip. Those were the same edge while the view stopped at the shade, and are not now: against the strip, a tap on a notification's own card would have dismissed the thing being read. The other three ways a shade stops being what the screen is about, none of which involve a finger: - an activity starts on top of the one it was pulled over - the permission prompt goes up, and the app behind is blocked on an answer - nobody is looking at the device any more, so nothing on it has the focus — otherwise the shade is still hanging there on the way back, over an app it was never opened from Verified on an Odin2: a tap outside closes it and does *not* reach the button underneath; a tap on a quick-action tile still toggles its radio and leaves the shade open; the app's own touches and the device keyboard are unaffected. * Workbench: stop the header's path from ending in the title above it The header is two lines: what is open, and where it is. The second one ended with the same name the first had just said — "Device sandbox" over "Default Workspace / waverepo / Device sandbox" — so the line with the least room on it spent its last third repeating the line above, and did it in the place a long path gets ellipsised from. It is not only tabs. With nothing open the heading falls back to the project, and the trail ended on the project: "waverepo" over "Default Workspace / waverepo". Same repetition, same fix — drop the last step when it is the one already named above. Only the last one, and only when it matches. A project that happens to share its name with the tab open in it is still a real step on the way there, and taking it out would leave a path that goes somewhere else. * vDevice keyboard: the marked keys wear symbols, not drawings Shift, backspace and hide came out **blank**. Everything about them was right: the icon loaded as a 55x55 VectorDrawable, the bounds were sane (43x43 inside a 143x103 key), the alpha was 255, the canvas was hardware accelerated, the view was on screen and drawing its own background, and taps landed on it. Replacing the glyph with a plain opaque `drawRect` did not appear either, which is what turns "the icon is wrong" into "this canvas is not usable": `TextView.onDraw` clips the canvas to the space it lays text out in, and what a key with no text has is a clip with nothing in it. So the keys wear characters instead, which is how the rest of the device already does it — the terminal's extra-keys row writes its arrows as text. A key is a `TextView` either way, so a mark is now laid out, centred, scaled and tinted by the same code that already does all four for the letters, and `uiautomator dump` reports it as `text` beside the `content-desc` that says what it means. The font is the one JCode already carries for this. Those code points are the obvious ones and that is not the same as being present: phones ship *subsetted* symbol fonts, so U+21E7 and U+232B land as tofu on exactly the devices nobody tests on, and Noto Sans Symbols 2 is what already stops a TUI's glyphs coming out as boxes in the terminal. This app carries its own copy for the same reason it carries its own palette — it is a separate APK and cannot read the container's resources — and it is given only to the marked keys, so the letters stay on the system's own sans. U+2304 was the obvious pick for hide and draws as a thin lowercase "v", which is not a mark to put in a row of letter keys; U+23F7 is. The five icon drawables are gone with it. Verified on an Odin2: shift, caps lock, backspace and hide all draw; shift lights and locks with the right mark on it; typing and the action key still work. * Workbench: put the built-in browser's navigation in the Command Palette The browser had a toolbar and nothing else. That is fine while you are looking at it, and no use at all from anywhere else — which is where the palette is reached from, and the palette is the one surface in this app that does not care what is on screen. So "Open Browser" sits beside "Open Virtual Device" under Tools, and Back, Forward and Reload/Stop are their own Browser group. The same argument the editor commands are already here on: a command anybody can find beats a button only the right screen has. Each one is offered only when it would do something. Back and Forward register on `canGoBack`/`canGoForward`, so a Back with nowhere to go is never in the list — a command that answers a search and then does nothing is worse than not being offered. Reload wears the state the browser's own button does, and becomes Stop while a page is loading, because a page is either loading or it is not and the two are never both worth showing. All four need the browser tab to be the one in front, which is not a nicety: its controller only exists while its page is composed. `BuiltinBrowser` gained a no-URL `requestOpen()`. Everything that opened this browser until now arrived holding a URL — a preview, a link in a terminal — so "open the browser" and "go here" were the same action; the palette is the first caller with nothing to navigate to, and sending it somewhere would throw away the page already loaded. Verified on an Odin2: Open Browser opens the tab and leaves its page alone; with no history neither arrow is listed; after navigating, Back appears; running it goes back and Forward takes its place. * Workbench: a compact browser toolbar, and the options that had nowhere to live The bar was three default 48dp icon buttons over a field carrying 9dp of its own padding — a phone browser's chrome on a pane already sharing a screen with the editor, the tab strip and the workbench header. Nearly sixty density-independent pixels of frame around the page that is the whole point of the tab. It is about forty now: 30dp buttons, 17dp glyphs, and the field's padding brought down to match. The space that bought goes to a "more" button, and what is behind it is chosen for what a *built-in* browser is for. This one exists to look at a dev server running on the machine it is being written on, so the list is what that job asks for rather than what a phone browser ships: - **Request desktop site** — the one asked for. Composed from `WebSettings.getDefaultUserAgent` rather than written down, so the engine version in it is the version actually rendering the page; a made-up Chrome number is a lie a server can act on, and this browser exists to show what a server does. Kept in `BuiltinBrowser` because the WebView is destroyed whenever the tab is not in front, and a mode that reset itself every time you looked at something else is not a mode anybody can compare two layouts with. - **Reload without cache** — the one a dev server asks for by the hour. A preview still serving last build's bundle looks exactly like a change that did not work. - **Clear cookies and site data** — cookies, storage and cache, then a reload, because nothing about a cleared localStorage shows on a page that is still the one it was drawn from. - **Copy URL**, **Open in system browser**, **DevTools** — the ways out. The toggle names the action rather than the state ("Request desktop site" / "Request mobile site"): a row reading "Desktop site" with no tick beside it is a question about which of the two it is telling you. Verified on an Odin2: the page reports `Mozilla/5.0 (X11; Linux x86_64) ... Chrome/101.0.4951.61 Safari/537.36` with desktop mode on — no Android, no wv, no Mobile, and 101 is this device's real engine version rather than an invented one. * Workbench: a site indicator in the browser's address field, and the panel behind it A padlock before the URL, and a panel when it is pressed. The mark says what the *connection* is, and deliberately not what the site is: a padlock for https, an open one in the error colour for http, a file mark for file://, a globe for a tab with nothing in it. A favicon in that slot would be a picture the site supplied being used as evidence about the site, which browsers learned the hard way — a favicon of a padlock was a phishing kit's first move. The favicon is in the panel's heading instead, beside the host, where identifying is the whole of what it is being asked to do. The panel answers the questions a browser is actually asked about one site. What this connection is, in a sentence that says what it means rather than naming it. Who says so — issued to, issued by, expires, straight off the WebView's own certificate, which is what a padlock is shorthand for and which no amount of green icon substitutes for. And what the site has kept: cookies, site data, and one button that clears both for *this origin* — the overflow menu is where "forget every site" lives, and a login being debugged is not a reason to sign out of everything else. Site data is counted by asking the page, not `WebStorage.getOrigins`. That API only knows the quota-managed storage and answers "nothing" for the one storage anybody actually uses, so the row would have said None whatever the site had kept — a wrong answer to the question the panel was opened to ask. The clear button clears the same two the row counts, or it would be clearing something else. No "trackers blocked" row. Nothing here blocks any, and a row claiming otherwise is worse than no row. Verified on an Odin2: example.com shows secure with SSL Corporation / Oct 28 2026; duckduckgo.com shows its duck in the heading and *.duckduckgo.com from DigiCert; an http URL shows the open padlock in red with no certificate rows; two localStorage keys set from the console read back as "2 in localStorage" and return to None after the clear. * Workbench: stop the navigation drawer taking drags meant for the browser's page Scrolling a page slid the workbench's navigation drawer open over it. The drawer's swipe-to-open watches the whole content area, and a page is full of things that answer a sideways drag — a carousel, a row of tabs, a map, and plain scrolling, which is never perfectly vertical when it comes from a thumb. The drawer kept winning gestures that were never meant for it. The WebView now claims the gesture on ACTION_DOWN, which is what every other embedded surface in this app already does: the editor, the terminal, the markdown preview, both extension hosts and the virtual device's screen all say the same line. The browser was the one that did not. The listener returns false, so the page still handles its own events; this only settles who *else* may take them away. The cost is the same one those five accept — the drawer's edge swipe does not start on top of the page, and the drawer's own button is how it opens there. Verified on an Odin2 against duckduckgo.com/settings: a scroll with sideways drift scrolls the page and leaves the drawer shut, a deliberate left-to-right drag across the page leaves it shut too, and the header's button still opens it. * Workbench: make the built-in browser follow JCode's colour scheme It did not. Checked rather than assumed: with the device in night mode and the workbench painting dark, `matchMedia('(prefers-color-scheme:dark)').matches` answered **false**, and the WebView's background was hardcoded `Color.White`. So every site that themes itself was being told the user prefers light and obliged, and a dark workbench flashed a full-pane white rectangle on every navigation. The attribute that decides it is **`android:isLightTheme`**, read off the theme of the context the WebView was built with. Not the night bits of the configuration, which is the obvious guess and is wrong — measured, with the configuration forced to `UI_MODE_NIGHT_YES` and algorithmic darkening allowed the page still answered false, because this app's runtime theme has a Light parent and that is what the WebView was asking. Two one-line themes exist now for the WebView to be handed, and the runtime theme is left alone. It follows **JCode's** scheme rather than the phone's, which is the promise the rest of the app makes: the workbench's own theme setting can differ from the device's, and a browser inside a dark window should not be white because the phone happens to be in day mode. The answer comes from `MaterialTheme.colorScheme` — the palette actually painting the tab — so a theme bundle changes it too, not just the light/dark switch. Algorithmic darkening is allowed alongside it, so a site with no dark styles of its own is darkened rather than left glaring. Keyed on the theme, because a WebView reads this once at construction and there is no setter: switching theme rebuilds it and re-navigates to the same URL, which is a page that has to be re-rendered anyway. Verified on an Odin2: `prefers-color-scheme: dark` now answers true, and duckduckgo.com/settings — the page this was reported against — renders dark instead of white. * Workbench: make the browser's DevTools console readable Every line was the same shape: one paragraph of monospace wrapping flush to the left margin, with the level carried by nothing but a shade of text. A long error's second line looked exactly like the next entry, telling an error from a log meant comparing two colours against each other, and `source:line` was appended to the message so it wrapped into the middle of the prose it was annotating. A line now has a coloured rail, a one-character marker (✕ ! · › ‹), the message, and its origin on the right where an annotation belongs. Errors and warnings carry a faint tint of their own colour — tinted rather than ruled, because a divider on every row of a log this dense reads as a table, and the two lines worth finding are the ones that are not ordinary. Consecutive identical messages fold into one row with a ×N badge. That is the difference between a usable console and a wall: one load of an ordinary site put the same Permissions-Policy warning up three times, two wrapped lines each, and pushed everything worth reading off the top. Only *consecutive* ones, which is what a browser does and is the honest version — two identical errors either side of something else are two events, and folding them together would lose the ordering that makes a log worth reading. The list follows the newest line, which it did not: a log that has to be scrolled to see the thing that just happened is a log nobody looks at while it is happening. And a long press copies the message, which is the reason anybody reaches for a console line in the first place. Verified on an Odin2 against duckduckgo.com/settings: the repeated warning shows once with ×2, an input/result pair reads › then ‹, a page `console.log` gets the muted ·, and an uncaught ReferenceError shows ✕ with `settings:1` on the right. * Workbench: expandable console lines, and Sources and Application panes **Console lines summarise, then open.** The panel is a drawer on a phone, and the messages worth the most space were the ones taking all of it — one uncaught stack trace owned the whole pane and pushed the ten lines that led to it off the top. A row is one line until it is asked for more. The chevron appears only on rows that have more, decided by whether the collapsed text actually overflowed rather than by guessing at a length. **The origin is a link.** Reading "settings:1" and then going to find settings line 1 by hand is the step a console exists to remove, so it opens Sources at that line, highlighted. Console messages now carry the whole `sourceId` rather than the file name they were being truncated to, because a bare "settings" matches nothing. **Sources.** A WebView exposes no debugger protocol, so this asks the page: the document and inline scripts it reads outright, external files it has the page `fetch` — the same request the page already made, so usually a cache hit. Line numbers, and a jump target that lands on the line. Known bound, and it is the platform's: a cross-origin file served without CORS headers cannot be read back by the page that loaded it, and the pane says so rather than showing an empty file and letting you conclude the file was empty. **Application.** Local storage, session storage and cookies, each key removable on its own — the reason to open storage while debugging is usually to drop one key and try again, not to wipe the site and lose the session that took ten minutes to get into. The five panes scroll horizontally; five chips squeezed to fit a phone are five chips nobody can read. Markers are aligned to the first line of the message they mark, by giving them the message's line height instead of their own: a ✕ against a three-line error was sitting halfway down it. Verified on an Odin2 against duckduckgo.com/settings: a folded warning opens and closes; an uncaught ReferenceError's `settings:1` switches to Sources, opens the document and highlights line 1; the source list shows the document, external scripts and inline scripts; an external script fetches and renders with line numbers; Application lists the site's own localStorage key and removing it leaves the section empty. * Workbench: draw the DevTools pane switcher as a tab strip It was a row of rounded pills with gaps between them, sitting a few pixels above the terminal's tab strip and the editor's, both of which are flat, butt against each other, and mark the active one by lifting it to `surfaceVariant` off a `surface` rail. Two idioms for "pick one of these" in one window is one too many, and the pills were the odd one out. Same 36dp height, same 12dp padding, same label style as the strips it now sits beside. The row still scrolls, because five panes do not fit across a phone and squeezing them until none of them can be read is not the alternative anybody wants. * Workbench: put the console prompt at the end of the log, not on the panel floor A browser's console prompt is the last row of the log, and the reason is that the prompt and the answer to it are one conversation: what you typed, what came back, and the caret waiting for the follow-up all read down a single column. Pinned to the bottom of the panel it was a separate instrument, with the whole empty middle of a mostly-quiet console between a result and the place you would type the next thing. It is a list item now, so it sits under the last message and moves down as output arrives. The box around the field is gone with it — a filled, rounded input is a form control, and this is a line in a log that happens to take typing. It gets the same rail and the same "›" every other row has, because it is one of them. Following the output now scrolls to the prompt rather than to the last message, which is one row further up: the point of keeping up with a log is to end up looking at the place the next thing gets typed. Verified on an Odin2: with one warning on screen the caret sits directly beneath it; typing `2*21` leaves `› 2*21`, `‹ 42`, and the prompt below both. * Workbench: drop the console's level rails and its run button The rail was a third statement of a fact already made twice. The marker says what kind of line this is and the tint says it again for the two kinds worth noticing, so a coloured stripe down the left of *every* row was decoration on a surface whose whole job is to let two or three rows stand out from the rest — and it started at the panel's edge, which is where the eye goes first. The run button goes with it. The field submits on the keyboard's Go key, which is how a console has always been submitted and is where the thumb already is after typing; a play arrow floating at the end of an empty line was a second way to do the same thing, taking up the one place a long expression could have run to. Verified on an Odin2: `2*21` typed at the prompt and submitted with Go still answers 42, and the levels still read at a glance from their markers. * Workbench: console type follows the terminal's, and a narrower gutter **Size.** The console had a number of its own — 11.5sp, picked to fit. It reads `LocalTerminalFontSizeSetting` now, which is the same setting the terminal uses: both are monospace logs of a machine talking back, they are two tabs of the same drawer, and somebody who sized the terminal to something they can read on this screen has already answered the question for the console. Changing one changes both, which is the point — a pane that merely copied the terminal's *default* would drift apart from it the moment anybody touched the setting. Everything on the row is derived from that size rather than fixed alongside it: line height is a proportion of the type, so a console set to 18sp does not end up with its lines touching, and the count badge and the source link stay a step smaller than the message instead of staying 10sp while the message grows. **Gutter.** The marker column was 28dp off the front of every line — 8dp of padding around a 20dp box holding one character — on a panel a phone can spare about forty for. It is 18dp now, and the source link under a message follows it. * Workbench: flatten the right panel's tab strip too The last row of rounded pills in the workbench, and it sits directly above one of the flat strips — the terminal's own tabs are a few pixels below it, so the two ways of drawing "pick one of these" were visible in the same glance. Flat now, on the same terms as the editor's tabs, the terminal's and the DevTools pane switcher: tabs share edges instead of floating with gaps between them, the active one lifts to `surfaceVariant`, and it fills the strip's height rather than being a shape inside it. Icons and labels are unchanged, and Close stays pinned to the right where scrolling cannot reach it. `RightPanelChip` is `RightPanelTabItem`, because it is not a chip any more and a name that says otherwise is the kind that survives three refactors. * DevTools: a Network panel that sees the whole page, with payloads The Network pane listed fetch and XHR and nothing else, so an ordinary page produced an empty panel: the document, the scripts, the stylesheets and the images are loaded by the browser, not by page script, and there is no JS function in their path to wrap. Wikipedia showed zero rows. Resource Timing sees exactly those, so the shim now runs a PerformanceObserver alongside the fetch/XHR wrappers, with buffered:true so entries recorded before it installed are replayed rather than lost. The overlap between the two sources is settled on a timestamp taken when the wrappers go in: a fetch that started after that is the wrappers' to report, and would otherwise be drawn twice. What each source can answer differs, and the panel says so instead of pretending. Wrapped calls carry both sets of headers, the payload sent and the body returned -- bodies capped at 16KB, skipped for binary and oversized responses, and read from a clone so the page still gets its own. Timing rows carry URL, kind, size and duration; the status code and body are not exposed to a page for a resource it did not request, so the detail view explains the gap rather than drawing empty sections. Tapping a row opens it: General, request headers, payload, response headers, response, with JSON pretty-printed. A filter strip counts the rows by kind, which on a page with fifteen stylesheets is the difference between a list and a wall. The header's bare "Clear" link is now an overflow menu. Clearing was the only thing in reach and the one thing that cannot be undone, while "Preserve log" -- the setting you want set *before* the interesting request happens -- had nowhere to live. Off by default, as in Chrome, so each navigation starts clean. Copy-all joins them. ContextAction gained a checked state to draw the tick; a boolean mode needs one, where a two-way choice is still better said by naming the action. Details worth their lines: - Row names keep the query. A resource loader answers everything from one path, so stripping it made fifteen rows all read "load.php". - A zero transfer size is two different facts. With a body size it means the cache answered; without one it means a cross-origin server declined to say. The first says "cached", the second says nothing at all rather than "0 B", which reads as a measurement. - A navigation entry's duration runs to loadEventEnd, which is not set until the load handlers return -- so the document row reported 0ms. Verified on the Odin2 against Wikipedia: 26 rows across document, script, css and img; live search XHRs captured at 200 with their JSON responses. * DevTools: rebuild the Application pane around what a site actually keeps The pane knew about three things -- local storage, session storage and whatever document.cookie would admit to -- printed as one flat scroll with values clipped at two lines and a Refresh link at the bottom, past everything it refreshes. Most of what a modern site stores was simply absent from it. It now surveys eight, each collapsible and each stating its count in the header, so the whole shape of what an origin keeps reads in one screen: page origin and secure context, the two web storages, cookies, IndexedDB, cache storage, service workers, and the web app manifest. A quota bar sits at the top, because a site misbehaving over storage shows up as a number there long before you would think to count cache entries. Everything past the two storages is Promise-only, so the survey is delivered through the JCodeDevTools bridge rather than returned from the eval -- evaluateJavascript hands back what an expression evaluated to synchronously, which for an async function is nothing. Each block is guarded separately: caches and serviceWorker exist only in a secure context, and a page on plain http would otherwise take the whole report down with it. Cookies are now read from both places they exist. document.cookie is what the page can see; the WebView's own jar is what the server gets, and the difference between the two lists is exactly the HttpOnly cookies -- which on Wikipedia is four of seven, including the session cookie, none of which this pane could previously show at all. They are badged, and deleting one goes through the jar, the only side that can reach it. Tapping a row opens the value in full, pretty-printed, with copy and delete. That is the point of the rewrite: a two-line preview is enough to recognise a key and never enough to debug one. Wikipedia's module store is 948 KB of JSON that used to render as two clipped lines. The overflow menu introduced for Console and Network now covers this pane too -- Refresh, and Clear site data, which goes through WebStorage.deleteAllData() because no page-side API can empty databases the page never named. Also: formatBytes stopped at MB and reported a 131 GB quota as "134379.8 MB". Verified on the Odin2 against Wikipedia (7 cookies, 4 of them HttpOnly) and Excalidraw (3 IndexedDB databases with per-store record counts, a 59-entry workbox precache, an activated service worker, and the fetched manifest). * DevTools: colour page source, and make minified source readable Sources and Elements both drew plain monospace text. A page script is a source file, and JCode already knows how to colour one -- so they now go through the same three layers the editor uses, in the same order of authority: built-in Markdown and JSON, then an installed Dev Pack's rules for the language, then the generic tokenizer so nothing is left unlit. A Dev Pack that lights up a .js file in a tab now lights up the same file when it arrives from a web page instead of the workspace. On top of that, a language server's own classification when one is already running: semantic tokens for a document that is not a file and must not become one. It borrows a session the user's own work started, matched by extension, announces the text under a URI in a directory that does not exist, asks, and closes it again. Nothing is written and no server is started. Failure is the ordinary case -- no server, none running, none implementing semantic tokens, or one that declines a URI with no file behind it -- and every one of them is silent, because the tokenizer's colouring already stands on its own. A marker segment in the URI keeps any diagnostics the server volunteers out of the Issues pane, where they would name a file nobody can open. Elements was worse than uncoloured: outerHTML serialises to one line, so the whole document was a single row scrolling sideways forever. It is now one tag per line, indented by depth. Script, style and preformatted content passes through untouched -- re-wrapping a JavaScript body on its angle brackets produces something that reads like code and is not the code that ran. And the thing that makes Sources usable on a real site: every script a site serves is minified, so `{ }` in the header reformats one. Breaks only on structural punctuation and only outside strings, template literals and comments. Three problems the device found, all in the minified path: - `//` is not always a comment. In `url(https://…)` it is a scheme, and reading it as a comment swallowed the rest of the file -- which for a minified bundle is the whole file. - Compose measures a no-wrap line by laying out every glyph in it, so a 400 KB row was an ANR before anything else ran. Lines are clipped at 1000 characters for drawing; `{ }` is how the rest becomes visible. - Splitting a styled document per line with subSequence re-filters the whole span list each call -- tens of thousands of spans against tens of thousands of lines, and a hang. One pass over both, sorted, instead. All colouring, reformatting and the server round-trip run off the main thread, and the text is never waiting on any of it: plain first, then the tokenizer, then the server if it answers. Verified on the Odin2 against Excalidraw: inline scripts fully coloured, the DOM snapshot one tag per line, and a 400 KB minified stylesheet reformatted to ~15k coloured lines that scroll smoothly. The LSP layer is exercised only on its silent-fail path -- this device has no language server installed, so the positive path is unverified. * Web Engine: JCode's own engine as an optional split, browser rewired onto it The browser no longer renders on the device's system WebView — an engine the app can neither choose, upgrade, nor trust (the Odin2 ships Chromium 101, which drops dvh and renders Excalidraw as a black page). It now renders on JCode's own engine: GeckoView 153, built as the :webengine dynamic-feature split and delivered by the Web Engine marketplace extension (jcode.ext.webengine), so the base APK stays at its current size and only users who open web surfaces pay for the engine. A split rather than a class-loader plugin because a browser engine needs <service> entries for its child processes and manifests are fixed at install time; as a split of our own package its manifest merges properly, native libs land in the real lib dir, and classes join the app class loader on the next start. WebEngineInstaller commits the split via PackageInstaller MODE_INHERIT_EXISTING; the running process can't see new classes, so NeedsRestart is its success state. The seam (dev.jcode.webengine) keeps the base engine-agnostic: interfaces only, reflective lookup, and WebEnginePlaceholder — the install prompt every gated surface shows. Deliberately no system-WebView fallback. Optimization is structural: WebEngineHost.get() starts nothing (Gecko boots on the first createTab), one GeckoRuntime serves the process (a Gecko invariant anyway), and prefers-color-scheme follows the workbench theme at runtime instead of recreating the view. play-services-fido is excluded from the GeckoView dep — its manifest demands Play resources the app doesn't ship; costs passkeys, nothing else. Known regressions this milestone, stated rather than hidden: the DevTools panes were built on WebView plumbing (evaluateJavascript, injected shims, the system cookie jar) that doesn't exist against Gecko, so the panel shows an engine notice until the RDP client lands; the site-info panel loses its per-site cookie/storage counts for the same reason (they read the *system* WebView's jar, which is now simply the wrong store). Markdown preview and extension UI hosts stay on WebView until their engine-backed implementations exist — gating them now would break them with no replacement. Extension repo: github.com/blamspotdev/jcode-ext-webview (scaffolded). * Web Engine: boot Gecko from the split it actually lives in GeckoView assumes it was packaged in the base APK: GeckoThread's first arguments are `-greomni getPackageResourcePath()`, and with the engine in a feature split that path holds no omni.ja — Gecko segfaults at 0x30 in GeckoThread.run the moment the runtime boots. Appending our own -greomni via GeckoRuntimeSettings.arguments() loses; Gecko takes the first occurrence. (Both measured on the Odin2; the arg assembly read from the AAR's bytecode. In stock App Bundles only ABI/config splits exist and assets stay in the base, which is why this topology is off Gecko's map.) The path Gecko uses is asked of the Context it was created with, so the runtime now boots against a ContextWrapper whose getPackageResourcePath() answers with split_webengine.apk — and which returns itself from getApplicationContext() so the wrapper survives Gecko's own hop. Every Gecko payload lookup then lands where the payload actually is. Verified on the Odin2: Excalidraw fully renders and takes ink on GeckoView 153 inside JCode — the page the device's frozen system WebView 101 drew as black. Placeholder state (base installed without the split) and lazy boot (zero GeckoRuntime activity until a web surface opens) verified in the same session. * Web Engine: drop the GeckoView split — pivot to Chromium Reverts the two engine commits (e226bdc, 211264a) on the user's decision: the bundled engine should be Chromium, not Gecko. No maintained embeddable Chromium artifact exists (Chromium ships only as Chrome and as WebView providers; Crosswalk and WebLayer both died here), so "Chromium" today means the system WebView — which this revert restores in full: the WebView-backed browser, its DevTools panes (console shims, network capture, storage survey), site-info cookie counts, desktop-UA mode and the theme-following web context. The jcode-ext-webview extension stays as the home for a future from-source Chromium split (//android_webview built into the same :webengine seam this history carries); that build needs Linux infrastructure and a standing security-release cadence, and starts only if that cost is accepted. On devices whose ROM pins an old provider (the Odin2's frozen 101), the browser inherits that engine's limits again; the follow-up commits add engine-version surfacing plus an install-latest-WebView flow to onboarding and Settings, which is as far as any app can reach — the provider switch itself is refused by that ROM's one-entry allow-list. * Settings + onboarding: surface the web engine, offer the Play upgrade The engine behind the browser and web previews is the device's WebView provider — a system component the app can read but not choose. What an app CAN do is make its state visible and the fix reachable, so: - Settings → Web preview gains a "Web engine" card: current Chromium version and provider package, an Outdated flag below Chromium 110 (108 shipped dvh — the line under which modern sites go from dated to blank), a Play-Store install of the latest Android System WebView, and a shortcut to Developer options for the provider switch. The card says plainly that some devices lock the provider and JCode then stays on the ROM's engine. - Onboarding gains the same as a hint card, shown only when the engine is below that line: setup is the one moment the user is already granting things, and a provider switch made now saves a blank-page mystery later. Nothing blocks; JCode works either way. Verified on the Odin2: card reads "Chromium 101.0.4951.61 · Outdated · com.android.webview" with both actions; browser and the WebView DevTools panes confirmed live again after the GeckoView revert. * Browser: repair the zero-height viewport that blanked full-height sites Some WebViews (measured on the AYN Odin2 — Chromium 101, and still 150) lay a page out against a zero-height layout viewport: html,body{height:100%}, vh and dvh all resolve to 0 while innerHeight, clientHeight and position:fixed report the real size. A full-viewport app whose layout hangs off height:100% (excalidraw and most canvas SPAs) then collapses to a blank pane, and nothing looks wrong from the outside. Inject a guarded shim on page start/finish that pins <html> to innerHeight px so the 100% chain resolves, re-synced on resize. It arms only when the bug is detected (a 100vh probe measures 0 while innerHeight > 0), so healthy WebViews are untouched. Verified on-device: excalidraw renders, long pages still scroll. * Add keyboard vector drawable for device settings * Browser: nudge restored full-viewport pages to re-layout after the height pin VIEWPORT_FIX_JS pins <html> to innerHeight so height:100% apps (excalidraw) render on a WebView whose layout viewport resolves to 0. On a fresh navigation the pin lands before the app lays out, so it renders. On a cold-start restore the WebView is created at size 0 and the app lays out collapsed before the pin, then never recovers on its own (a manual reload fixes it). Fire a resize whenever the pinned height actually changes, so an app that already laid out recomputes; guarded on a real change so the dispatched resize can't re-enter a loop. Also extend the re-probe window to ~5s for SPAs that mount after first paint. * Browser: don't force the DevTools drawer open on a plain "Open Browser" Opening the built-in browser from the Command Palette popped the DevTools right-drawer open over it, because both the URL-carrying opens (previews, terminal/markdown links) and the plain no-URL open shared one revealSignal, and an effect revealed DevTools on any bump. Split it: a new devToolsRevealSignal is bumped only by requestOpen(url) (the opens where DevTools is part of the intent), and the reveal effect watches that. The palette's requestOpen() bumps only revealSignal, so it surfaces the browser tab without forcing DevTools open. The DevTools tab stays available to open by hand once the browser has been opened. * Browser: fix Excalidraw menus/modals collapsing under the vh-unit WebView bug The Odin2 WebView resolves every CSS viewport-length unit (vh/dvh/svh/lvh and vw kin) to ~0, while innerHeight, documentElement.clientHeight, percentage heights and position:fixed offsets all report the real size. The earlier html-pin fixed height:100% chains (the canvas) but not overlays sized in vh: Excalidraw runs in mobile mode here, where its menu and modals are viewport-height drawers, so they shrank to a sliver. Extend VIEWPORT_FIX_JS: expose the real 1vh/1vw as --jcode-vh/--jcode-vw pixel custom properties (kept in step on resize) and rewrite every vh/vw-family unit in the page's own CSS -- stylesheets, grouped @media/@supports rules, inline styles and custom properties -- to calc(var(--jcode-vh) * N), verified on-device to resolve where the bare unit gives 0. A MutationObserver catches stylesheets/nodes added on demand. Still arms only when a 100vh probe reads under half of innerHeight, so healthy WebViews are untouched. * Browser: stop force-darkening self-theming sites (Excalidraw light UI came out dark) isAlgorithmicDarkeningAllowed was tied to the workbench theme, so a dark JCode force-darkened every page's DOM. Force-dark cannot touch a <canvas>, so a self-theming app viewed in its own LIGHT theme rendered split: Excalidraw's white canvas under a darkened toolbar and menu. Disable it and let the page paint its own colours, which is what a mobile browser does and is correct for both light and dark sites. prefers-color-scheme still follows the workbench theme (via the themed context), so a site set to 'system' still matches JCode. * Browser: pin height/orientation @media to the real viewport (Excalidraw welcome screen) The same WebView bug that zeroes the vh unit also makes @media evaluate the viewport HEIGHT as 0 (width is fine): (max-height:Npx) always matches, (min-height:Npx) never does, and orientation reads landscape. Excalidraw gates its empty-canvas welcome screen behind a max-height query, so it was hidden as if the screen were 0px tall — blank where desktop shows the logo and hints. When the shim arms, re-evaluate each height/orientation media feature against the real innerHeight/innerWidth and rewrite the rule to a constant that matches (min-width:0px for true, min-width:99999px for false) — what the page would do on a real phone of this size. Done once per rule and deliberately NOT on resize: re-mutating mediaText on the resize a rotation fires wedged Excalidraw into a blank canvas until reload, and the visual-viewport pin already carries layout across a rotation. Trade-off: a rotated window keeps the media verdict from the orientation it loaded in. Verified on-device: welcome screen renders, and a full portrait<->landscape rotation no longer blanks the page. * Browser: recover from the WebView's intermittent black reload surface This device's WebView returns from a reload with a black, uncomposited content surface on a minority of loads. Measured: even a position:fixed probe fails to paint on those loads, so it is the GPU surface, not the page's layout — and it is pre-existing, reproducing on a minimal pin-only shim that predates the vh/@media work (so the CSSOM rewrite did not cause it). The pin already cuts it from ~100% (Excalidraw needs a definite html height or its height:100% chain collapses) to ~30%. Re-applying the pin and dispatching a resize on each post-arm interval tick nudges the page to re-render once it has mounted, forcing a fresh frame that unsticks the surface; removing those more than doubled the black rate in testing. Run them over a longer (~9s) window to catch a late-mounting SPA. The resize goes only through the media-free resize handler, so it cannot re-trigger the rotation blank. On-device this took the black-reload rate from ~30% to ~19%; it is a mitigation, not a cure — the residual is a hardware/WebView compositor fault a reload still clears. * Browser: make "Request desktop site" lay out at a desktop width, not just send the UA The toggle only swapped the user agent, so the layout viewport stayed the phone's — measured: with the desktop UA in place, innerWidth was still 468. A site that reads the agent got its desktop stylesheet applied to a 468px column and spilled off the right edge (Google's results lost two thirds of their width), and a responsive site honoured its own width=device-width and handed back the very mobile layout the toggle was asked to escape (GitHub, Stack Overflow, MDN were byte-identical either way). Neither is what "desktop site" means. Add the other half: rewrite (or add) the page's viewport meta to a fixed 1280px, re-asserted through a MutationObserver because frameworks rewrite the tag, and then zoom the WebView so that width fits the screen. The zoom is applied after load via zoomBy rather than declared, because setInitialScale and an injected initial-scale are both read off the first viewport tag the parser sees — the site's own — and were ignored on every page that ships one. Verified on-device: Google, GitHub, Stack Overflow and MDN all render their real desktop layouts, full width, nothing cut off. 1280 rather than 1024 because GitHub's marketing nav switches above 1024. * VSIX host: render OpenChamber in its touch layout, matched to the dark IDE OpenChamber's VS Code bundle hard-codes runtime.isVSCode = true and pins a desktop layout from that alone, ignoring the coarse pointer and ~469px width, so its dialogs came up as clipped desktop modals with keyboard hints instead of touch bottom sheets. It exposes no ?surface=touch opt-out, so intercept the write to its __OPENCHAMBER_RUNTIME_APIS__ global and clear the flag. The host transport is unaffected — it keys on acquireVsCodeApi, not this flag. Clearing the flag also drops OpenChamber's VS Code theme, leaving "system" mode to resolve via prefers-color-scheme (reported light here). Report dark for just that query so "system" tracks the dark IDE, passing every other media query through so the pointer/hover checks still yield the touch layout. * Revert "VSIX host: render OpenChamber in its touch layout, matched to the dark IDE" This reverts commit d575767. Per user feedback, JCode should not override a third-party VSIX extension's own layout decision. OpenChamber renders in its native (VS Code / desktop) layout again and reads the dark theme we already supply through the --vscode-* variables, so the paired prefers-color-scheme shim is unnecessary too. * Extensions: install & update VSIX extensions from custom GitHub sources Add "Extension Sources" — user-added GitHub repos whose releases publish a .vsix. JCode resolves each repo's newest .vsix release, installs or updates the extension from it, and shows "Update available" in the main Extensions list, the same flow as the built-in marketplace. This is how a VSIX extension like OpenChamber gets updated inside JCode, where the host owns extension updates and the extension's own in-app updater does not run (it's gated to desktop/web runtimes). - ProviderReleaseFetcher: GitHub releases API -> newest .vsix asset + version, mirroring UpdateChecker's transport and SemVer compare. Repo parse is case-insensitive (soft keyboards autocapitalize "github"). - ExtensionInstaller.installVsixFromUrl reuses the already origin-agnostic openStream(url) + installFromVsixBytes; MarketplaceEntry gains an optional vsixAssetUrl so a source-sourced install/update routes through it. - MainViewModel: persisted source list + installed-id -> source attribution (cleared on uninstall); source releases fold into marketplaceEntries so the existing marketStatus/isUpdateAvailable badge logic lights up with no UI change. - UI: a Sources button in the shared ManagerPanelHeader opens an Extension Sources editor page (add/remove repos, per-source latest release + Install/ Update), following the ExtensionPermissions page pattern. * Extension Sources: restyle with JCode's shared manager design kit The page was raw Material components (bulky OutlinedTextField, plain text lines, FilledTonalButtons) that read as a bolt-on. Rebuild it from the same kit the SDK/LSP/Extension managers use so it belongs to the Extensions surface: - Header matches ManagerPanelHeader (title + source count + refresh icon). - Each source is a surfaceVariant card with the installed extension's icon (ExtensionIcon), repo + name, and the shared ManagerStatusChip (the same Installed / Update available pill as the main list), then ManagerSummaryRow lines (Latest release / Installed / Asset) and CompactFilled/OutlinedButton actions. - Add-source row uses a compact bordered field styled like CompactSearchField (minus its search glyph) plus a CompactFilledButton, with a lock-icon trust note. No behavior change. * Extension Settings: header + per-extension icons, matching the manager kit Bring the Extension Settings page in line with the extensions list and the new Extension Sources page: a title + "N installed" header, and each collapsible card now leads with the extension's icon. ManagerSectionCard gains an optional `leading` slot (default null, so every other caller is unchanged) rendered before the title; ExtensionPermissionsPage passes the ExtensionIcon. No behavior change to the settings/permissions controls. * Extensions: adopt the App Settings look — primary-blue section headers Reorganize the Extension Sources and Extension Settings pages under the same primary-tinted section headers App Settings uses, so they read as first-class settings surfaces: - New shared ManagerGroupHeader (labelLarge, primary, semi-bold, optional trailing action), matching SettingsGroup's header. - Extension Settings: drop the page title; group installed extensions by type under "Apps" / "Language packs" / "Source control" / … headers, cards keep their icons and collapse. - Extension Sources: "Add a source" and "Sources" (refresh moves onto its header) sections instead of a floating title + card. No behavior change. * VSIX: surface contributes.configuration as extension settings A VS Code extension declares its user settings under contributes.configuration, but JCode dropped them on import — so an extension like OpenChamber showed only Activation/capabilities on the Extension Settings page, none of its own settings (apiUrl, opencodeBinary). - VsixPackage.parse translates contributes.configuration (object or array form) into ExtensionSetting[] — full dotted key kept verbatim, JSON-schema type mapped (boolean/number/enum/string), default + enum + NLS-resolved description carried over; toExtensionYaml emits them as the `settings:` block. That flows into InstalledExtension.settings, so the settings render on the page and the existing config.* extension API resolves them. - The VSIX host was activated with an empty configuration, so getConfiguration always returned fallbacks. It now seeds from config.all (defaults overlaid with the user's saved values), delivering the values to the extension. Verified on device: reinstalling OpenChamber from its source regenerates extension.yaml with the settings, and Api URL / Opencode Binary appear on its card. * Extension Sources: drop the redundant intro line The "Add a GitHub repo…" blurb duplicated what the section headers and the trust note already convey. The page now opens straight to "Add a source". * Extension Settings: richer cards with an activation-status pill The collapsed cards were sparse (icon + name + author) and, at low surface alpha, barely separated from the OLED background. Rebuild each as a defined card: - Rounded 12dp corners + a hairline border + slightly higher surface, so cards read as distinct objects on black. - Metadata subtitle (description · version · VSIX) instead of just the author. - An activation-status pill on the right — Auto-start (green) / On-demand (blue) / Manual (grey) with a status dot — reading LocalExtensionActivation, so the collapsed card shows on/off at a glance and fills the dead space. - A compact "N installed" count chip in the header (replacing the intro line). Expands to the same settings/activation/capabilities as before. * Extension Sources: match the defined-card styling Give the source cards the same treatment as the Extension Settings cards — rounded 12dp corners, a hairline border, slightly higher surface, and a 38dp icon — so the two Extensions pages read as one surface. * App Settings: match the defined-card styling Give SettingsCard (and WarningCard) the same rounded 12dp corners, hairline border, and slightly higher surface as the Extension Settings/Sources cards, so every settings-style surface reads the same. * Themes: replace built-ins with Catppuccin, Pierre Dark, Night Owl Swap Dracula → Pierre Dark and Midnight OLED → Night Owl, and make Pierre Dark the default. Palettes taken from the upstream themes: - Pierre Dark (github.com/pierrecomputer/theme): #0a0a0a canvas, #171717 panels, #fafafa text, #009fff accent with magenta/green highlights. - Night Owl (Sarah Drasner): #011627 navy canvas, #d6deeb text, #82aaff / #c792ea / #7fdbca accents. A previously-saved "midnight"/"dracula" selection falls back to the new default (Pierre Dark) via ThemeBundleRegistry.byId, so no migration is needed. * Tooltip: open under the control, after a short delay JcTooltip anchored above and opened instantly on hover/press. Now, globally: - a below-anchor PopupPositionProvider centers the label under the control (it flips above only when there's no room below), and - a 500ms hover/long-press delay keeps a passing cursor quiet. Gestures are driven here with an observe-only pointer pass (enableUserInput off), so the wrapped button still gets its own clicks; the one swallowed case is a touch long-press that already opened the label, so reading a tooltip never also taps the button. * Ext Dev: flat underline tab strips, browser-devtools style The Inspector/Validator/Log and extension-selector rows were filled pills. Make them flat tabs — muted labels with a primary underline hugging the active one, sitting flush above the strip's divider — matching a browser devtools tab strip. * Run panel: compact pill tabs for the Run/Build toggle Replace the full-width segmented bar with the shared ManagerFilterChip, so Run/Build reads as two compact pills instead of a heavy segmented control. * Ext Dev: use the DevTools filled-tab strip, not an underline Match the Console/Sources/Network strip in the DevTools panel: the active tab lifts to surfaceVariant off the surface rail, full-height and butted against its neighbours, instead of the flat underline. One tab idiom across the panels. * DevTools Network: show the HTTP status on browser-loaded resources Resource-timing rows (CSS, images, beacons the browser fetched itself) reported no status, so they read "—". Capture PerformanceResourceTiming.responseStatus (Chromium 109+) for resource and navigation entries; fetch/XHR already carry it. Cross-origin responses without Timing-Allow-Origin still read 0 → "—", which the browser genuinely won't disclose. Detail-pane copy updated to match. * Workspace header: folder icon for the Open folder button It was sharing the DatasetLinked (linked-boxes) glyph with Sources/ Destinations/Database, which reads as nothing like a folder next to its 'Open folder' tooltip. Give it a dedicated OpenFolder slot mapped to FolderOpen.
1 parent a16fc7c commit 42019ba

76 files changed

Lines changed: 8497 additions & 670 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/aidl/dev/jcode/vdevice/IGuestSession.aidl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,13 @@ interface IGuestSession {
4343
/** Pops the embedded back stack, or sends Back to the only activity. */
4444
oneway void back();
4545

46-
/** The answer to one onPermissionRequest, in the order it asked. */
47-
oneway void permissionResult(int requestId, in boolean[] granted);
46+
/**
47+
* `ime show|hide|toggle|status|list` against the device's own keyboard.
48+
*
49+
* Not oneway, because `status` is a question — and the keyboard is a real app hosted on the
50+
* device's screen, so what it answers is the same thing screencap and uiautomator see.
51+
*/
52+
Bundle ime(String command);
4853

4954
/** Force-stop: ends everything the named guest is hosting and drops it from the loader. */
5055
oneway void forceStop(String packageName);
Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
package dev.jcode.vdevice;
22

3-
/** Guest -> IDE notifications for one embedded session. */
3+
/**
4+
* Guest -> IDE notifications for one embedded session.
5+
*
6+
* Deliberately short. Anything the device can draw on its own screen belongs there rather than
7+
* here: the permission prompt used to come out over this interface for the IDE to compose, and a
8+
* dialog composed over the tab is one an agent can photograph and cannot tap. What is left is what
9+
* the IDE genuinely has to know — that there is no longer an app to show.
10+
*/
411
interface IGuestSessionCallback {
512
/** The guest's last activity finished, or the container tore the session down. */
613
oneway void onGuestFinished(String reason);
7-
8-
/**
9-
* The guest asked for permissions the device has not decided about, and the person at the
10-
* keyboard has to. The IDE puts them on the screen and answers with
11-
* IGuestSession.permissionResult under the same requestId; the guest is waiting on it, so an
12-
* answer that never comes is an app that never gets its callback.
13-
*/
14-
oneway void onPermissionRequest(int requestId, in String[] permissions, String packageName);
1514
}
0 Bytes
Binary file not shown.
549 KB
Binary file not shown.

app/src/main/java/dev/jcode/JCodeShell.kt

Lines changed: 125 additions & 33 deletions
Large diffs are not rendered by default.

app/src/main/java/dev/jcode/MainViewModel.kt

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import dev.jcode.feature.marketplace.ExtensionActivation
8282
import dev.jcode.feature.marketplace.ExtensionDeps
8383
import dev.jcode.feature.marketplace.ExtensionInstaller
8484
import dev.jcode.feature.marketplace.InstalledExtension
85+
import dev.jcode.feature.marketplace.isUpdateAvailable
8586
import dev.jcode.feature.marketplace.languageFor
8687
import dev.jcode.feature.marketplace.MarketplaceEntry
8788
import dev.jcode.feature.marketplace.MarketplaceServiceLocator
@@ -284,9 +285,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
284285
private val _installedExtensions = MutableStateFlow<List<InstalledExtension>>(emptyList())
285286
val installedExtensions: StateFlow<List<InstalledExtension>> = _installedExtensions.asStateFlow()
286287

287-
/** Extensions available in the remote marketplace index (populated on demand). */
288+
/** Extensions available in the remote marketplace index (populated on demand). The public,
289+
* UI-facing list is [marketplaceEntries] below — it folds in custom-source update entries and so
290+
* is declared after the source state it depends on. */
288291
private val _marketplaceEntries = MutableStateFlow<List<MarketplaceEntry>>(emptyList())
289-
val marketplaceEntries: StateFlow<List<MarketplaceEntry>> = _marketplaceEntries.asStateFlow()
290292

291293
/** True while a marketplace fetch / install is in flight. */
292294
private val _marketplaceBusy = MutableStateFlow(false)
@@ -455,9 +457,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
455457
.onFailure { _messages.tryEmit("Marketplace: ${it.message ?: "failed to load"}") }
456458
_marketplaceBusy.value = false
457459
}
460+
// Custom sources refresh alongside so their update badges stay current with the built-in index.
461+
refreshExtensionSources()
458462
}
459463

460464
fun installExtension(entry: MarketplaceEntry) {
465+
// A custom-source entry (synthesized from a provider's release) installs from its `.vsix` asset
466+
// URL, not the marketplace `.jext` path — route it through the single source-install flow.
467+
if (entry.vsixAssetUrl != null) {
468+
val sourceUrl = extensionSourceOfId.value[entry.id]
469+
if (sourceUrl != null) { installFromSource(sourceUrl); return }
470+
}
461471
viewModelScope.launch {
462472
val wasInstalled = _installedExtensions.value.any { it.id == entry.id }
463473
_marketplaceBusy.value = true
@@ -710,6 +720,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
710720

711721
fun uninstallExtension(id: String) {
712722
clearExtensionActivation(id)
723+
clearExtensionSource(id)
713724
viewModelScope.launch(Dispatchers.IO) {
714725
extensionInstaller.uninstall(id)
715726
refreshInstalledExtensions()
@@ -1918,6 +1929,189 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
19181929
}
19191930
}
19201931

1932+
// ----- Custom extension sources (user-added .vsix release repos) -------------------------------
1933+
// An "extension source" is a GitHub repo URL whose releases publish `.vsix` files. JCode resolves
1934+
// each repo's newest `.vsix` release and, for an extension installed from a source, folds an
1935+
// "Update available" entry into the same Extensions list as the built-in marketplace. Managed on
1936+
// the Extension Sources page (Extensions panel → Sources button). This is how a VSIX extension
1937+
// like OpenChamber gets updated inside JCode — the host owns updates, the extension doesn't.
1938+
1939+
private val extensionSourcesKey = stringPreferencesKey("extension_sources")
1940+
1941+
/** User-added source repo URLs (JSON array). */
1942+
val extensionSources: StateFlow<List<String>> = uiPreferences.data
1943+
.map { prefs -> parseStringList(prefs[extensionSourcesKey]) }
1944+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
1945+
1946+
// Attribution: installed extension id -> the source URL it came from, so an update check knows
1947+
// which repo to poll. JSON object; cleared on uninstall. Same shape as extensionActivations.
1948+
private val extensionSourceOfIdKey = stringPreferencesKey("extension_source_of_id")
1949+
1950+
val extensionSourceOfId: StateFlow<Map<String, String>> = uiPreferences.data
1951+
.map { prefs -> parseStringMap(prefs[extensionSourceOfIdKey]) }
1952+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
1953+
1954+
/** Newest `.vsix` release resolved per source URL (in-memory; refreshed from GitHub). Absent =
1955+
* not fetched yet or none found. */
1956+
private val _sourceReleases = MutableStateFlow<Map<String, ProviderRelease>>(emptyMap())
1957+
val sourceReleases: StateFlow<Map<String, ProviderRelease>> = _sourceReleases.asStateFlow()
1958+
1959+
private val _sourcesRefreshing = MutableStateFlow(false)
1960+
val sourcesRefreshing: StateFlow<Boolean> = _sourcesRefreshing.asStateFlow()
1961+
1962+
/**
1963+
* The extensions the Extensions panel lists: the built-in marketplace index, plus a synthesized
1964+
* entry for any extension installed from a custom source that now has a newer release. Keying the
1965+
* synthesized entry by the installed id makes the existing `marketStatus`/`isUpdateAvailable`
1966+
* badge logic light up with no UI change; its [MarketplaceEntry.vsixAssetUrl] routes an update
1967+
* install back to the source's release asset.
1968+
*/
1969+
val marketplaceEntries: StateFlow<List<MarketplaceEntry>> =
1970+
combine(_marketplaceEntries, _installedExtensions, extensionSourceOfId, _sourceReleases) {
1971+
market, installed, attribution, releases ->
1972+
val marketIds = market.mapTo(mutableSetOf()) { it.id }
1973+
val updates = installed.mapNotNull { ext ->
1974+
if (ext.id in marketIds) return@mapNotNull null // marketplace already lists it
1975+
val sourceUrl = attribution[ext.id] ?: return@mapNotNull null
1976+
val release = releases[sourceUrl] ?: return@mapNotNull null
1977+
if (!isUpdateAvailable(release.version, ext.version)) return@mapNotNull null
1978+
MarketplaceEntry(
1979+
id = ext.id, name = ext.name, author = ext.author, authors = ext.authors,
1980+
type = ext.type, category = null, subcategory = null,
1981+
version = release.version, jext = null,
1982+
description = ext.description, longDescription = ext.longDescription,
1983+
vsixAssetUrl = release.vsixAssetUrl,
1984+
)
1985+
}
1986+
market + updates
1987+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
1988+
1989+
private fun parseStringList(json: String?): List<String> {
1990+
if (json.isNullOrBlank()) return emptyList()
1991+
return runCatching {
1992+
val arr = org.json.JSONArray(json)
1993+
buildList { for (i in 0 until arr.length()) arr.optString(i).takeIf { it.isNotBlank() }?.let(::add) }
1994+
}.getOrDefault(emptyList())
1995+
}
1996+
1997+
/** Add a custom source (a GitHub repo URL whose releases publish `.vsix` files) and fetch it. */
1998+
fun addExtensionSource(url: String) {
1999+
val normalized = url.trim()
2000+
if (normalized.isBlank()) return
2001+
if (ProviderReleaseFetcher.parseRepo(normalized) == null) {
2002+
_messages.tryEmit("Not a recognizable GitHub repo URL")
2003+
return
2004+
}
2005+
viewModelScope.launch {
2006+
uiPreferences.edit { prefs ->
2007+
val current = parseStringList(prefs[extensionSourcesKey])
2008+
if (current.none { it.equals(normalized, ignoreCase = true) }) {
2009+
prefs[extensionSourcesKey] = org.json.JSONArray(current + normalized).toString()
2010+
}
2011+
}
2012+
fetchSourceRelease(normalized)
2013+
}
2014+
}
2015+
2016+
/** Remove a custom source. Any extension installed from it stays installed; its attribution is
2017+
* kept so re-adding the source still recognizes the installed copy. */
2018+
fun removeExtensionSource(url: String) {
2019+
viewModelScope.launch {
2020+
uiPreferences.edit { prefs ->
2021+
val current = parseStringList(prefs[extensionSourcesKey])
2022+
prefs[extensionSourcesKey] =
2023+
org.json.JSONArray(current.filterNot { it.equals(url, ignoreCase = true) }).toString()
2024+
}
2025+
_sourceReleases.value = _sourceReleases.value - url
2026+
}
2027+
}
2028+
2029+
/** Re-resolve the newest `.vsix` release for every configured source. */
2030+
fun refreshExtensionSources() {
2031+
viewModelScope.launch {
2032+
val urls = extensionSources.value
2033+
if (urls.isEmpty()) { _sourceReleases.value = emptyMap(); return@launch }
2034+
_sourcesRefreshing.value = true
2035+
try {
2036+
val resolved = urls.associateWith { runCatching { ProviderReleaseFetcher.latest(it) }.getOrNull() }
2037+
_sourceReleases.value = buildMap { resolved.forEach { (u, r) -> if (r != null) put(u, r) } }
2038+
} finally {
2039+
_sourcesRefreshing.value = false
2040+
}
2041+
}
2042+
}
2043+
2044+
private suspend fun fetchSourceRelease(url: String) {
2045+
_sourcesRefreshing.value = true
2046+
try {
2047+
runCatching { ProviderReleaseFetcher.latest(url) }.getOrNull()?.let {
2048+
_sourceReleases.value = _sourceReleases.value + (url to it)
2049+
}
2050+
} finally {
2051+
_sourcesRefreshing.value = false
2052+
}
2053+
}
2054+
2055+
/**
2056+
* Install (or update to) the newest `.vsix` release from [sourceUrl]. Records which source the
2057+
* resulting extension came from, so its update badge tracks the right repo afterward. Handles both
2058+
* a fresh install and an update, since a `.vsix` install swaps in place by id.
2059+
*/
2060+
fun installFromSource(sourceUrl: String) {
2061+
viewModelScope.launch {
2062+
val release = _sourceReleases.value[sourceUrl]
2063+
?: runCatching { ProviderReleaseFetcher.latest(sourceUrl) }.getOrNull()
2064+
if (release == null) { _messages.tryEmit("No .vsix release found for this source"); return@launch }
2065+
_marketplaceBusy.value = true
2066+
try {
2067+
extensionInstaller.installVsixFromUrl(release.vsixAssetUrl, BuildConfig.VERSION_NAME)
2068+
.onSuccess { result ->
2069+
recordExtensionSource(result.extension.id, sourceUrl)
2070+
_messages.tryEmit("Installed ${result.extension.name} ${result.manifest.version}")
2071+
markPendingReload(result.extension.id, result.extension.name)
2072+
}
2073+
.onFailure { _messages.tryEmit("Install failed: ${it.message ?: "error"}") }
2074+
} finally {
2075+
_marketplaceBusy.value = false
2076+
}
2077+
refreshInstalledExtensions()
2078+
}
2079+
}
2080+
2081+
private fun recordExtensionSource(extId: String, sourceUrl: String) {
2082+
viewModelScope.launch {
2083+
uiPreferences.edit { prefs ->
2084+
val obj = runCatching { JSONObject(prefs[extensionSourceOfIdKey] ?: "{}") }.getOrDefault(JSONObject())
2085+
obj.put(extId, sourceUrl)
2086+
prefs[extensionSourceOfIdKey] = obj.toString()
2087+
}
2088+
}
2089+
}
2090+
2091+
private fun clearExtensionSource(id: String) {
2092+
viewModelScope.launch {
2093+
uiPreferences.edit { prefs ->
2094+
val obj = runCatching { JSONObject(prefs[extensionSourceOfIdKey] ?: "{}") }.getOrDefault(JSONObject())
2095+
obj.remove(id)
2096+
prefs[extensionSourceOfIdKey] = obj.toString()
2097+
}
2098+
}
2099+
}
2100+
2101+
/** Open (or focus) the Extension Sources page and refresh each source's latest release. */
2102+
fun openExtensionSourcesPage() {
2103+
_bringEditorToFront.tryEmit(Unit)
2104+
val existing = _editorGroup.value.tabs.firstOrNull { it.pageKind == EditorPageKind.ExtensionSources }
2105+
if (existing != null) {
2106+
_editorGroup.value = _editorGroup.value.withActiveTabChanged(existing.id)
2107+
} else {
2108+
_editorGroup.value = _editorGroup.value.withTabAdded(
2109+
EditorTab.page(EXT_SOURCES_TAB_ID, "Extension Sources", EditorPageKind.ExtensionSources),
2110+
)
2111+
}
2112+
refreshExtensionSources()
2113+
}
2114+
19212115
val workspaceConfig = configService.workspaceConfig
19222116
val projectConfig = configService.projectConfig
19232117
val workspaceConfigError = configService.workspaceError
@@ -2060,6 +2254,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
20602254
* capped: a server answering a bare `.` returns every member in scope, which is more than a
20612255
* phone-sized popup can usefully show.
20622256
*/
2257+
/**
2258+
* Semantic tokens for a DevTools page source, from a server the workspace already has running.
2259+
*
2260+
* Nothing here is guaranteed and nothing is reported: it is a colouring improvement offered
2261+
* when the pieces happen to be in place. See
2262+
* [dev.jcode.lsp.LspController.detachedSemanticTokens].
2263+
*/
2264+
suspend fun devtoolsSemanticTokens(fileName: String, text: String): List<dev.jcode.lsp.SemanticToken> =
2265+
runCatching { lspController.detachedSemanticTokens(fileName, text) }.getOrDefault(emptyList())
2266+
20632267
suspend fun lspCompletions(
20642268
hostPath: String,
20652269
line: Int,
@@ -5455,6 +5659,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
54555659
const val EXT_APP_PREFIX = "jcode://ext-app/"
54565660
const val VSIX_PANEL_PREFIX = "jcode://vsix-panel/"
54575661
const val EXT_PERMISSIONS_TAB_ID = "jcode://ext-permissions"
5662+
const val EXT_SOURCES_TAB_ID = "jcode://ext-sources"
54585663
const val RUN_CONFIG_PREFIX = "jcode://run-config/"
54595664
const val BUILD_CONFIG_PREFIX = "jcode://build-config/"
54605665
/** Stable id of the single built-in browser editor tab (see [openBrowserPage]). */

0 commit comments

Comments
 (0)