Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,17 @@ jobs:
packages: platform-tools
- uses: gradle/actions/setup-gradle@v4
- name: Install Android compile SDK
run: sdkmanager 'platforms;android-37.0' 'build-tools;36.0.0'
run: sdkmanager 'platforms;android-37.0' 'build-tools;36.0.0' 'build-tools;37.0.0'
- name: Android store and actual Compose regressions
working-directory: platform/android
run: ./gradlew --no-daemon :lui:testDebugUnitTest
- name: Android Drive protocol, sample builds and scoped lint
working-directory: platform/android
run: >-
./gradlew --no-daemon
:drive:testDebugUnitTest
:drive-sample:assembleDebug :drive-sample:assembleRelease
:drive:lintDebug :drive-sample:lintDebug :lui:lintDebug

c-bridge-stress:
runs-on: ubuntu-latest
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ Per-platform backend builds and test recipes are documented under each
`platform/` directory (the Gradle wrapper under `platform/android` for
Android).

### Native Drive testing

[Drive](https://github.com/logseq/drive) keeps fast semantic/headless tests
separate from native input. Android has an optional, explicitly launched
[instrumentation host](platform/android/drive/README.md) for API 30+ debug
variants. It injects OS MotionEvent/KeyEvent input and uses a deliberately
selected test IME for Unicode/empty text replacement; it never emits LUI
semantic events as a fallback.

With the host running and an explicit `adb forward`, use a Drive version
that supports `drive --tcp 127.0.0.1:49377 --input native scenario.drive`.
The host, IME, permission and runner must remain outside release artifacts.
Its compile/protocol/lint checks do not establish real-device input behavior;
see the host guide for setup, cleanup, geometry and settling limits.

For iOS, a DEBUG-only, opt-in [read-only snapshot probe](platform/apple/DriveProbe.md)
connects to Drive's separate XCTest UI-testing runner. The probe does not inject
input. Apple compile checks cover the backend/probe and standalone runner;
Simulator/device input remains unverified. Build/install the integrating DEBUG
app and exercise its UI-testing target before relying on native interaction.

## Component schema

`schema/components.json` is the single source of truth for node kinds,
Expand Down
82 changes: 82 additions & 0 deletions docs/api-migrations.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,87 @@
# LUI API migration guide

## Unreleased: DEBUG iOS read-only Drive probe

DEBUG iOS builds add `LUIAppleBackend.debugDriveSnapshot()` and
`LUIDriveSnapshotProbe.startIfEnabled(backend:window:environment:)`. These APIs
are absent in Release and on other platforms. Retain the probe after the
single foreground key window is ready, and call `stop()` during host teardown.
Wrap host references in `#if DEBUG`:

```swift
#if DEBUG
driveProbe = try LUIDriveSnapshotProbe.startIfEnabled(
backend: backend, window: window
)
#endif
```

No listener opens unless `LUI_DRIVE_PROBE=1` is explicitly supplied. The probe
temporarily enables frame collection and restores its settings without
replacing existing callbacks. It binds only IPv4 loopback (default port 49378)
and accepts versioned read-only snapshot requests; it has no Drive action or
semantic event endpoint. This is debug access for trusted local processes,
not authentication against other local applications.

Native input belongs to the separate Drive XCTest UI-testing target, never
the app binary. Selector actions require explicit stable native accessibility
identifiers and do not fall back to labels, coordinates or semantic events.
Frame coordinates are window-local logical points, not screenshot pixels.
Existing production event behavior and transport contracts do not change.
See [`platform/apple/DriveProbe.md`](../platform/apple/DriveProbe.md) for a
complete host hookup, protocol and lifecycle example. Apple-SDK compilation
and Simulator/device input verification are separate integration gates.

## Unreleased: Optional Android native Drive host

Android renderer defaults and existing semantic integrations are unchanged.
`LuiBackend.inspect()` is a new opt-in, UI-thread-only read-only inspection
API. Close its `LuiInspection` handle to remove inspection semantics and
layout observers. Snapshots copy retained nodes and carry host-local
incarnation guards; native targeting requires attached, visible geometry.

For an API 30+ debuggable test variant, add
`debugImplementation(project(":drive"))`, register
`DriveTarget.attach(activity, backend)` after `setContent` behind an explicit
debug intent flag, and close the handle in `onDestroy`. Declare
`dev.lui.drive.DriveInstrumentation` only in the debug manifest. Keep the
optional library, INTERNET permission, runner and test IME out of release
artifacts. The sample supplies separate debug/release hooks.

```sh
# The debug app must already be installed; this clears its Activity task.
adb forward tcp:49377 tcp:49377
adb shell am instrument -w -e drive true \
-e activity your.debug.package/.MainActivity \
your.debug.package/dev.lui.drive.DriveInstrumentation
# In another terminal, with a Drive version supporting loopback TCP:
drive --tcp 127.0.0.1:49377 --input native scenario.drive
# After the run:
adb forward --remove tcp:49377
```

Native protocol v1 is unchanged: initial hello, full frames, then ops establish
readiness; fresh ops and full frames precede each correlated result. Wire
coordinates/deltas are content-viewport dp, converted to screen pixels at OS
MotionEvent/KeyEvent injection. No action falls back to LUI semantic events.

Text replacement uses a real InputConnection and requires deliberately
selecting the test-device IME; `type` is not advertised otherwise. The host
never enables/selects an IME itself. Preserve and restore the original IME,
or use the explicit `--test-device` sample launcher. Missing selection or
geometry, stale incarnations, disabled/hidden targets, detectable native
occlusion and unsupported input fail explicitly. Offscreen targets need
native scrolling; there is no selector auto-scroll.

Settling is bounded model/layout/draw/accessibility quiet, not application or
network idle, or proof of every render-thread animation. Accessibility cannot
prove the behavior of arbitrary invisible custom touch interceptors;
unmeasured modals/extensions and system UI need separate instrumentation.
Already running input cannot be rolled back. Compile/protocol/lint checks
are not device-input evidence. See
[`platform/android/drive/README.md`](../platform/android/drive/README.md)
for complete integration, input and cleanup limits.

## Unreleased: MyGo overlay theme foreground

MyGo dialogs, sheets and popovers now initialize their detached panel's text
Expand Down
1 change: 1 addition & 0 deletions platform/android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id("com.android.library") version "9.1.1" apply false
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false
id("app.cash.paparazzi") version "2.0.0-alpha04" apply false
}
29 changes: 29 additions & 0 deletions platform/android/drive-sample/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}

android {
namespace = "dev.lui.drive.sample"
compileSdk { version = release(37) { minorApiLevel = 0 } }
defaultConfig {
applicationId = "dev.lui.drive.sample"
minSdk = 30
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

dependencies {
implementation(project(":lui"))
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.material3:material3:1.5.0-beta01")
debugImplementation(project(":drive"))
}
52 changes: 52 additions & 0 deletions platform/android/drive-sample/run-drive.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ ${1:-} != --test-device || $# != 2 ]]; then
echo "Usage: DRIVE_BIN=/path/to/drive $0 --test-device scenario.drive" >&2
echo "This explicitly selects a test IME on the connected test device; do not use a personal device." >&2
exit 2
fi

scenario=$2
port=49377
package=dev.lui.drive.sample
ime=$package/dev.lui.drive.DriveInputMethod
old_ime=$(adb shell settings get secure default_input_method | tr -d '\r')
[[ -n "$old_ime" && "$old_ime" != null ]] || { echo "Cannot preserve current IME; refusing setup" >&2; exit 1; }
printf 'Original IME (restore if the launcher is killed): %s\n' "$old_ime" >&2
enabled=false
if adb shell ime list -s | tr -d '\r' | grep -Fxq "$ime"; then enabled=true; fi
log=$(mktemp)
host_pid=
forwarded=false
cleanup() {
local status=$?
trap - EXIT INT TERM
adb shell ime set "$old_ime" >/dev/null || echo "Restore the original IME manually: $old_ime" >&2
if ! $enabled; then adb shell ime disable "$ime" >/dev/null || true; fi
adb shell am force-stop "$package" >/dev/null || true
if $forwarded; then adb forward --remove "tcp:$port" >/dev/null || true; fi
if [[ -n "$host_pid" ]]; then kill "$host_pid" 2>/dev/null || true; wait "$host_pid" || true; fi
cat "$log"
rm -f "$log"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
adb shell ime enable "$ime" >/dev/null
adb shell ime set "$ime" >/dev/null
adb forward --no-rebind "tcp:$port" "tcp:$port"
forwarded=true
adb shell am instrument -w -e drive true -e port "$port" \
-e activity "$package/.MainActivity" \
"$package/dev.lui.drive.DriveInstrumentation" >"$log" 2>&1 &
host_pid=$!
ready=false
for ((i=0; i<150; i++)); do
if grep -q 'listening on 127.0.0.1' "$log"; then ready=true; break; fi
if ! kill -0 "$host_pid" 2>/dev/null; then break; fi
sleep 0.1
done
$ready || { echo "Drive instrumentation did not become ready" >&2; exit 1; }
"${DRIVE_BIN:-drive}" --tcp "127.0.0.1:$port" --input native "$scenario"
4 changes: 4 additions & 0 deletions platform/android/drive-sample/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<instrumentation android:name="dev.lui.drive.DriveInstrumentation"
android:targetPackage="dev.lui.drive.sample" android:label="Explicit LUI Drive test host" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.lui.drive.sample

import android.app.Activity
import dev.lui.LuiBackend
import dev.lui.drive.DriveTarget

internal object DriveHook {
fun attach(activity: Activity, backend: LuiBackend): AutoCloseable? =
if (activity.intent.getBooleanExtra("luiDrive", false)) DriveTarget.attach(activity, backend) else null
}
10 changes: 10 additions & 0 deletions platform/android/drive-sample/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="LUI Drive" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package dev.lui.drive.sample

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import dev.lui.LuiBackend
import dev.lui.LuiEvent
import dev.lui.LuiNodeKind
import dev.lui.LuiPatchBatch
import dev.lui.LuiPatchOp
import dev.lui.LuiWireValue

class MainActivity : ComponentActivity() {
private lateinit var backend: LuiBackend
private var drive: AutoCloseable? = null
private var generation = 0
private var count = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
backend = LuiBackend(onEvent = { event ->
when (event) {
is LuiEvent.Press -> if (event.node == 3L) {
count++
apply(listOf(LuiPatchOp.SetProp(4, "text", LuiWireValue.Str("Count: $count"))))
}
is LuiEvent.TextChanged -> apply(listOf(LuiPatchOp.SetProp(event.node, "text", LuiWireValue.Str(event.text))))
else -> Unit
}
})
val nodes = listOf(1L to LuiNodeKind.root, 2L to LuiNodeKind.column,
3L to LuiNodeKind.button, 4L to LuiNodeKind.text, 5L to LuiNodeKind.textField,
6L to LuiNodeKind.button)
apply(buildList {
nodes.forEach { (id, kind) -> add(LuiPatchOp.CreateNode(id, kind)) }
add(LuiPatchOp.InsertChild(1, 2, 0))
listOf(3L, 4L, 5L, 6L).forEachIndexed { index, id -> add(LuiPatchOp.InsertChild(2, id, index)) }
add(LuiPatchOp.SetProp(2, "padding", LuiWireValue.IntValue(24)))
add(LuiPatchOp.SetProp(2, "gap", LuiWireValue.IntValue(16)))
add(LuiPatchOp.SetProp(3, "text", LuiWireValue.Str("Increment")))
add(LuiPatchOp.SetProp(3, "accessibility-identifier", LuiWireValue.Str("increment")))
add(LuiPatchOp.SetProp(4, "text", LuiWireValue.Str("Count: 0")))
add(LuiPatchOp.SetProp(5, "text", LuiWireValue.Str("initial")))
add(LuiPatchOp.SetProp(5, "accessibility-identifier", LuiWireValue.Str("editor")))
add(LuiPatchOp.SetProp(6, "text", LuiWireValue.Str("Disabled")))
add(LuiPatchOp.SetProp(6, "enabled", LuiWireValue.Bool(false)))
})
setContent { MaterialTheme { backend.Content() } }
drive = DriveHook.attach(this, backend)
}

private fun apply(ops: List<LuiPatchOp>) {
check(backend.applyBatch(LuiPatchBatch(++generation, ops))) { backend.lastError ?: "patch failed" }
}

override fun onDestroy() {
drive?.close()
super.onDestroy()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.lui.drive.sample

import android.app.Activity
import dev.lui.LuiBackend

internal object DriveHook {
fun attach(activity: Activity, backend: LuiBackend): AutoCloseable? = null
}
Loading
Loading