Skip to main content

leonard_native 0.6.1 copy "leonard_native: ^0.6.1" to clipboard
leonard_native: ^0.6.1 copied to clipboard

A pure-Dart Leonard contract extension that perceives and drives a native mobile app over the OS accessibility tree (via Appium/XCUITest): a stateful, self-watching extension projecting the a11y tree [...]

0.6.1 #

Stable promotion of 0.6.1-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-21), after the Pixel 7a and iPad mini live probes of both fixes passed. The prerelease entry below carries the details.

0.6.1-dev.1 #

Two fixes for backend-direct consumers of the Appium backends.

  • close() closes only a client the backend created (lenny#198). Since backendForPlatform(client:) forwards a caller's http.Client (#187), XcuiTestBackend, UiAutomator2Backend and Mac2Backend closed it on close(), so a caller that owns one client per session lost it after a best-effort close (for example after a failed connect), and its next request failed with Client is already closed. A caller-supplied client now stays open and stays the caller's to close; close() still deletes the session over it. A backend built without client creates its own and closes it, as before.
  • An empty find value is a miss, not a StateError (lenny#200). A find answering {"value": {}} made XcuiTestBackend and UiAutomator2Backend throw StateError out of resolve and isPresent, past callers that catch NativeException. It now reads as no element, as Mac2Backend already did: resolve falls through to its next tier and isPresent answers false.

0.6.0 #

Stable promotion of 0.6.0-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-20), after the iOS 27.0 simulator, Pixel 7a and iPad mini (iOS 26.6) live probes passed. The prerelease entry below carries the full list, including the breaking NativeBackend additions.

0.6.0-dev.1 #

Fills the gaps that kept a backend-direct iOS consumer on its own XCUITest transport (lenny#180 to #193). Verified live on an iOS 27.0 simulator (XCUITest driver 12.18.1) and a Pixel 7a (UiAutomator2 driver 8.2.2).

  • Breaking for implementers — NativeBackend gains methods and parameters. A class that implements NativeBackend, or overrides FakeNativeBackend.resolve or enterText, must add them. Callers are unaffected, except that Android's alert keys no longer throw (below).
    • alertText() returns the open system alert's text, or null when none is open, so a caller can choose between consent_accept and alert_dismiss instead of answering blind (#180). iOS and Android read GET /session/{id}/alert/text and map W3C no such alert to null; macOS throws a NativeException coded unsupported operation, because a macOS alert is an ordinary window in the snapshot.
    • activateApp(appId) brings an installed app to the foreground, launching it if needed, for returning to the app under test after an out-of-app hop (#182). It issues mobile: activateApp (iOS, Android) or macos: activateApp.
    • resolve(..., findTimeout:) sets each tier's find retry window, default NativeBackend.defaultFindTimeout (10 s). Duration.zero makes one find request per populated tier, for presence probes and callers that poll in their own loop (#183). Measured on a miss: 42 ms on iOS and 190 ms on Android, against about 10.2 s at the default.
  • XcuiTestBackend.attach sends appium:autoLaunch: false, as UiAutomator2Backend.attach already did, so attaching no longer activates or launches the app (#181). A backgrounded app now stays in the background; call activateApp to bring it forward. Migration: code that relied on attach bringing the app to the front calls activateApp(appId) after connect().
  • XcuiTestBackend.attach's running check now accepts a suspended background app (queryAppState 2), which is what a backgrounded app reports once attach stops activating it; only 0 (not installed) and 1 (not running) fail, and the error names both causes of a 1: the app was never started, or the Xcode 27 DeviceHub reboot killed it.
  • Transport failures no longer leak grid credentials (#186). A request that gets no HTTP response used to escape as package:http's ClientException, whose message carries the full request URI, so a cloud grid's user:key@ access key reached any log that printed it. All three backends now throw a NativeException coded NativeException.transportErrorCode with URL userinfo masked (https://***@host), and redact W3C error messages and quoted response bodies the same way. A transport failure is still not retried as a miss: resolve fails at once, as before.
  • A failed connect releases the session it opened (#185). When the attach check (or the iOS context switch) throws after session create, the backend now deletes the session and clears its state before rethrowing, so an Android session (newCommandTimeout: 0) no longer holds the device until Appium restarts, and a retried connect opens a fresh one. A transport failure on the attach probe now fails connect instead of passing as an unknown app state.
  • backendForPlatform(client:) forwards an http.Client to the backend it picks (#187); NativeLaunch and NativeAttach gain the client parameter both constructors already took.
  • More NativeBackend members for backend-direct consumers (#188 to #193), breaking for implementers like the ones above:
    • isPresent(selector): one live find per populated tier, no retry and no rect fallback, so a gone element cannot read as present. A zero-findTimeout resolve can: it still returns rect-center from the selector's rect or a cached label match (review of #183). The label tier is a live query on the platform's label attributes. Measured live on a miss: 116 ms on iOS, 145 ms on Android.
    • acceptAlertIfPresent() / dismissAlertIfPresent() answer whether an alert was handled instead of throwing on W3C no such alert (#192); NativeException.noSuchAlertCode names that code for the throwing form.
    • Android now answers system alerts: press('consent_accept') / press('alert_dismiss') and the if-present forms post the W3C alert endpoint, refusing a runtime permission dialog (where accept grants and dismiss denies) as dismiss_overlay does, and answering nothing when the source cannot be read. They used to throw as iOS-only. The press tool's description says so.
    • readAttribute(target, name) reads a resolved element's attribute in the driver's own names (#191).
    • rawSource() returns the platform source document unparsed (#190).
    • enterText(..., dismissKeyboard: false) leaves the keyboard up (#193).
  • NativeNode.parentId links each node to its nearest kept ancestor, so the filtered nodes rebuild into a tree (#189). In-memory only, like depth.
  • iOS and macOS nodes carry the disabled state for enabled="false", as Android nodes already did (#190). This reaches the model's observation.
  • sessionId on XcuiTestBackend, UiAutomator2Backend and Mac2Backend (#188).
  • FakeNativeBackend scripts isPresent (presence, defaulting to a match against the snapshot payload), the alert answers, attributes and rawSourcePayload, and records each enterText's dismissKeyboard.
  • FakeNativeBackend scripts alertText through alertTextPayload, records alertText and activateApp calls, and records each resolve's findTimeout in resolveFindTimeouts.

0.5.0 #

Stable promotion of 0.5.0-dev.1: the same content, published on the stable rung on Nico's promotion call (2026-10-15), after the Android (Pixel 7a), iOS 26.5 simulator and macOS live suites passed. The prerelease entries below carry the full list.

0.5.0-dev.1 #

  • Breaking — the mobile backends' constructors now mean the same lifecycle on iOS and Android (lenny#168). The unnamed constructor installs and launches app; .attach drives an already-running appId. Both tear off to the new shared types NativeLaunch and NativeAttach.
    • UiAutomator2Backend(udid:, app:) now installs and launches app (an .apk path or a cloud-grid id such as lt://APP123), with optional appPackage / appActivity. It previously attached to the foreground app and ignored app. Migration: code that relied on attach — anything driving beside flutter run — calls UiAutomator2Backend.attach(udid:, appId:) with the app's package.
    • UiAutomator2Backend.attach checks the app is running after session create (mobile: queryAppState) and throws a NativeException when it is not.
    • XcuiTestBackend.attach(bundleId:) is now XcuiTestBackend.attach(appId:), and the bundleId field is appId.
    • XcuiTestBackend's osVersion (default '26', never sent to Appium) is now platformVersion, matching Android: it is sent as appium:platformVersion when set. The keyboard "Done" handling reads the version Appium reports for the session, and treats an unreported version as iOS 26.
    • backendForPlatform takes app (launch) or appId (attach) — exactly one on a mobile platform — and appId replaces bundleId for darwin.
    • leonard_native_host takes --app (launch) or --app-id (attach); --bundle-id stays as an alias of --app-id.
  • Breaking — attach-critical capabilities are refused inside vendor option maps too. connect(extraCapabilities:) already refused app, appPackage, autoLaunch, noReset and the rest at the top level; it now also refuses them nested inside any *:options map (lt:options, bstack:options, …), reported as <vendor>:options.<key>. Nesting app there was never a supported way to install an app: use the launch constructor.
  • Fixed: every Appium request keeps the server URL's base path. A hub such as https://…@mobile-hub.lambdatest.com/wd/hub lost its /wd/hub prefix, so every request, including session creation, returned 404 (lenny#148, #151).
  • Added: Mac2Backend, a macOS backend over Appium mac2 that attaches to a running app by bundle id (--platform darwin) (#150).
  • Added: NativeNode carries the raw platform element type and its tree depth (#152).
  • Fixed: an iOS attach fails loudly when the XCUITest driver rebooted the simulator on session create (Xcode 27 DeviceHub with driver 11.x or older) instead of leaving the session on the home screen (lenny#161, #165).
  • Build cached native accessibility observations with canonical Genesis Component types. Appium polling and action refreshes remain outside build.
  • Require genesis_perception ^0.4.0, leonard_contract ^0.3.0-dev.1, and leonard_host ^0.2.4-dev.1.

0.4.1 #

  • Android UiAutomator2 checked, focused, disabled and obscured attributes now project onto the existing native node state field, so the Android and iOS state vocabularies agree. Actions and iOS behavior are unchanged.
  • Raise the leonard_contract floor to ^0.2.2. The declared ^0.2.0 floor was unsatisfiable — leonard_contract 0.2.0 requires genesis_perception ^0.1.3, which this package's own >=0.2.0 <0.4.0 range forbids — so a resolver silently held leonard_contract back to 0.2.1 instead of taking 0.2.2.
  • Raise the leonard_host floor to ^0.2.2 for the same reason.

0.4.0 #

  • Breaking — ObstructionResourceIdPolicy entry fields are ordered List<String> values instead of Set<String> values. Constructor parameters now accept any Iterable<String>, so existing set-literal construction remains source-compatible; consumers that assigned a public entry field to a Set<String> must read it as a List<String> or call .toSet(). Matching now honors policy order rather than XML node order. Android permission defaults prefer the legacy allow button, then foreground location, then one-time location; denial prefers ordinary denial over deny-and-don't-ask-again. Missing-button errors list every attempted entry.

0.3.1 #

  • Fixed: the resourceId tier can now resolve Chrome web-content fields (bare HTML ids). UiAutomator2's using=id locator only consults real view resource names (pkg:id/name); the resource-id you see on a web <input> is synthesized from its HTML id when the tree is serialized, so it was present in /source yet invisible to the live selector — and since resourceId is tier 1 and often the only populated tier, the miss consumed the full retry window and read as a device timeout rather than a wrong locator. On a using=id miss, a BARE value (no :id/) now falls through to //*[@resource-id='…'] over the serialized tree and reports via: 'resource-id-xpath'; a missed pkg:id/name is a genuine miss and does not fall through. Verified live against real Chrome on device (SM-M225FV) alongside an unchanged native-id resolution. Reported on https://github.com/memento-engineering/lenny/issues/51.

0.3.0 #

  • Breaking — NativeBackend.connect() takes extraCapabilities. The abstract contract is now Future<void> connect({Map<String, Object?> extraCapabilities = const {}}) on both UiAutomator2Backend and XcuiTestBackend: the map is merged over the orthogonal Appium defaults when a new session is created, and attach-critical capabilities are rejected with ArgumentError. Migration: an external NativeBackend implementer adds the named parameter (a delegating implementation only changes its signature); existing call sites are source-compatible.

  • Breaking — XcuiTestBackend gains attach-by-bundle-id and stops forcing appium:app. XcuiTestBackend.attach(udid: …, bundleId: …) attaches to an already-running app instead of always launching from an .app path; the default constructor keeps launch mode. Migration: code that relied on appium:app being unconditionally present must use launch mode explicitly.

  • NativeSelector gains a resourceId tier — on Android it resolves first (resource-id is the strongest handle the platform offers); on iOS, which has no analogue, the tier is skipped. Additive.

  • NativeSelector.flutterIdentifier('x') selects a Flutter Semantics(identifier:) widget through the Android channel. It expands to //*[@resource-id='x']/ancestor-or-self::*[@clickable="true"][1], which is correct on BOTH measured projection shapes — Pixel 7a / Android 16 puts the identifier on a non-clickable wrapper, an SM-M225FV / Android 13 projects it clickable-self; both captures are recorded in the README so neither gets hardcoded. Additive.

  • Android permission dialogs are a first-class obstruction. press('dismiss_overlay') REFUSES them, because Android Back denies the permission — denial persistently changes application behaviour, and granting was not requested either. Consumers state intent explicitly with the new permission_allow / permission_deny press keys. Behaviour change with additive API; proven against a live device (android_permission_dialog_proof.dart).

  • Docs — CORRECTION to 0.2.2's migration note, which was wrong for one consumer shape. 0.2.2 said, unqualified: "Remove consumer-owned Touch-To-Fill dismissal logic: it is redundant." That holds only for consumers driving the Leonard tool surface, where enter_text owns recovery. A consumer holding a NativeBackend directly gets only a throw: UiAutomator2Backend.enterText detects the obstruction and raises NativeException with NativeException.fieldObscuredCode, and never recovers — at that seam it holds an already-resolved NativeTarget and cannot re-resolve the handle that dismissal invalidates. Recovery lives in _EnterTextTool, the layer that holds the selector. If you deleted your dismissal on 0.2.2's advice and drive the backend directly, reinstate it — the recipe is now in the dartdoc on NativeException.fieldObscuredCode and in the README. Your reinstated version should be strictly better than what you deleted: branch on fieldObscuredCode rather than message text, and dismiss via press('dismiss_overlay'), which is positively gated inside the backend, so the destructive bare-back hazard is handled upstream instead of in each consumer's gating discipline. No runtime behaviour changed in this release. Reported on https://github.com/memento-engineering/lenny/issues/26.

  • NativeException.elementGoneAfterDismissalCode — a documented code for "the obstruction cleared, but the element is gone". The recovery recipe threw that outcome as a bare, code-less NativeException, which pushed consumers who had just been taught to branch on code back to string-matching the message. Additive; nothing existing changes.

  • Docs — the recovery recipe now preserves the original cause when dismissal fails. As first drafted, the recipe left press('dismiss_overlay') unwrapped, so a failed dismissal replaced the obstruction error. That diverged from _EnterTextTool, which deliberately keeps the original. It matters because dismiss_overlay is positively gated and throws no dismissible platform overlay is present when the overlay has already cleared — reachable as a race, since Chrome dismisses the sheet on its own timers and the sheet is once-per-page rather than once-per-field. The unwrapped form therefore reported broken-looking recovery instead of the real cause, precisely in the case where the cause is hardest to guess. overlay_recovery_recipe_test.dart now pins this alongside the step order. Reported on https://github.com/memento-engineering/lenny/pull/32.

  • Docs — NativeBackend.resolve documents its internal retry. The cost is per populated tier, not per call: a tier whose selector field is null is skipped outright, and each tier that does issue an element find carries its own ~10 s retry window. So an xpath-only selector that matches nothing costs ~10 s, while one carrying an a11y-id, a label and an xpath that all miss costs ~30 s inside a single call — the worst case. Tier 2 only issues a find when the cached snapshot already has a label match, and tier 4 (rect-center) makes no device round-trip at all. An outer retry loop multiplies against whichever figure applies — 20 attempts against a fully-missing selector is ~10 minutes, which presents as a hang rather than a failure. Callers polling for a condition should detect it directly rather than discover it through a resolve failure, and keep outer counts small.

0.2.2 #

  • Behaviour change — Chrome credential sheets auto-recover: enter_text now detects a positively identified Chrome bottom sheet after an obstructed write, dismisses it, re-resolves the invalidated element handle, and retries once. Remove consumer-owned Touch-To-Fill dismissal logic: it is redundant. Any transitional consumer dismissal must remain positively gated because a second bare Android back navigates the Custom Tab away. If detection or dismissal fails, the existing cause-bearing error remains the final result. The detection probe itself fails safe toward NOT acting: an unreadable or malformed /source reads as "nothing obstructing", so an obstructed write still falls through to the pre-existing click + mobile: type fallback rather than aborting on a transient read.

  • Fix (destructive): UiAutomator2Backend's keyboard dismiss no longer fires a bare POST /back. It now probes appium/device/is_keyboard_shown first and presses nothing when no soft keyboard is up. back is Android's dismiss gesture only while a keyboard holds focus; with none up it is plain navigation, and since setValue / mobile: type do not raise the soft keyboard, the old unconditional press navigated a Chrome Custom Tab AWAY at the end of every successful enterText — stranding the flow, because later field lookups then 404 against a page that is no longer showing. If the keyboard state cannot be read, nothing is pressed: a keyboard left up is recoverable, a spurious back is not. This brings Android in line with the iOS impl, which already probed for a Done key before clicking.

  • NativeNode gains resourceId, the Android resource-id (null on iOS, where XCUITest has no analogue — its identifier is already carried by a11yId). It is the only stable, non-localising way for a Dart consumer to identify a platform overlay: Chrome's Touch-To-Fill sheet is matched by touch_to_fill_sheet_title / bottom_sheet, while its title text, content-desc and per-row summary all localise, so string matching breaks on a non-English device. Deliberately NOT added to toRecord(): that record is the canonical cross-host schema and stays byte-identical to leonard_flutter's semantics fragment, which has no resource-id to emit. The model addresses nodes by identifier/label, so the wire shape is unchanged and no snapshot expectations move.

  • Fix: UiAutomator2Backend.enterText now types into Chrome web-content inputs. UiAutomator2 implements POST /element/{id}/value as AccessibilityNodeInfo.performAction(ACTION_SET_TEXT), which Chrome's web <input> nodes refuse with invalid element state — so any consumer driving an OAuth/Auth0 login through a Chrome Custom Tab was blocked at the first text field. On that error enterText falls back to click + mobile: type, which injects key events. (/keys is NOT usable as the fallback: for arbitrary text it shares the same ACTION_SET_TEXT handler and fails identically.)

  • The fallback verifies attribute/focused after its click and throws with the cause named rather than typing into the void. mobile: type reports HTTP 200 even when the element cannot receive input, and for a MASKED field an empty readback is indistinguishable from a correct write, so an obstructed field would otherwise surface as a silently empty password. The known obstruction is Chrome's Touch-To-Fill sheet, which both refuses ACTION_SET_TEXT and swallows injected keystrokes — while it is up, NO write path succeeds, so the sheet must be dismissed before enterText can work at all.

  • Behaviour change, relevant if you have failure-triggered recovery: an enterText that previously FAILED on a Chrome web input now succeeds, so recovery paths keyed on that failure will stop firing. Specifically, the click that focuses the field can raise Chrome's Touch-To-Fill "Use saved password?" sheet, and because the write now succeeds that sheet can survive into the next step and obscure a following submit tap. Dismiss it on its own signal rather than on an enterText error.

  • NativeException gains an optional code carrying the W3C WebDriver error code (invalid element state, no such element, ...) when the failure came from a remote error body; null for transport and decode failures. Both backends populate it. message is unchanged and still carries the code as a prefix, so anything reading the message is unaffected.

0.2.0 #

  • Breaking: the iOS AppiumBackend public API is renamed to XcuiTestBackend, and its platform: constructor parameter is removed.
  • Migration: replace AppiumBackend(..., platform: 'ios') with XcuiTestBackend(...).
  • Breaking: VM-service methods now use the unified ext.leonard.* namespace supplied by leonard_contract.

0.1.2 #

  • Android support: UiAutomator2Backend drives a native Android app over a local Appium server running the UiAutomator2 driver — the additive sibling of the iOS AppiumBackend. It shares the generic W3C transport (session, element find, the 4-tier resolve chain, pointer-action tap/swipe) and keeps every Android divergence inside the impl: a <hierarchy> /source parser, content-desc as the tier-1 identifier, resource-id-or-positional xpath synthesis, attribute/password-derived masking with attribute/text readback, and a press vocabulary where back is recognized and the iOS-only alert keys throw.
  • New backendForPlatform(...) — the one place that maps a platform string to a NativeBackend, so the wiring is testable without a device. An unrecognized platform throws rather than silently falling back to iOS (which would parse an Android tree with the XCUITest parser and observe nothing).
  • bin/leonard_native_host.dart selects through backendForPlatform, so --platform android now works; an unsupported platform is an exit-64 usage error rather than an uncaught throw.

0.1.1 #

  • The per-node record now surfaces the OS accessibility identifier under the canonical identifier key — the same stable, locale-proof addressing key as Flutter's Semantics(identifier:) — for cross-host record parity with the Flutter semantics fragment. Previously a11yId was selector-internal and never wired to the observation.

0.1.0 — initial native host #

  • Initial leonard_native package: NativeExtension (a stateful, self-watching LeonardExtension) projects the OS accessibility tree into a genesis_perception fragment and exposes tap / enter_text / press / swipe tools.
  • The NativeBackend seam keeps all device I/O behind a synchronous buildPerception(). AppiumBackend drives a local Appium server over W3C WebDriver against an iOS simulator (XCUITest); FakeNativeBackend is the shipped test impl.
  • Standalone VM-service host runner at bin/leonard_native_host.dart.
0
likes
130
points
661
downloads

Documentation

API reference

Publisher

verified publishermemento.engineering

Weekly Downloads

A pure-Dart Leonard contract extension that perceives and drives a native mobile app over the OS accessibility tree (via Appium/XCUITest): a stateful, self-watching extension projecting the a11y tree into a genesis_perception tree and exposing tap / enter_text / press / swipe tools.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

genesis_perception, http, leonard_contract, leonard_host, meta, xml

More

Packages that depend on leonard_native