diff --git a/.github/workflows/input-probe-build.yml b/.github/workflows/input-probe-build.yml index 7b78363..78a0bf7 100644 --- a/.github/workflows/input-probe-build.yml +++ b/.github/workflows/input-probe-build.yml @@ -3,14 +3,28 @@ name: Input probe build on: workflow_dispatch: push: + branches: + - main + - "agent/**" paths: - "android/input-probe/**" + - "schemas/input-probe-v1.schema.json" + - "tools/analyze_input_probe.py" - ".github/workflows/input-probe-build.yml" pull_request: paths: - "android/input-probe/**" + - "schemas/input-probe-v1.schema.json" + - "tools/analyze_input_probe.py" - ".github/workflows/input-probe-build.yml" +permissions: + contents: read + +concurrency: + group: input-probe-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -35,9 +49,34 @@ jobs: with: gradle-version: "9.4.1" - - name: Build debug APK + - name: Lint, test and build debug APK working-directory: android/input-probe - run: gradle :app:assembleDebug --stacktrace + shell: bash + run: | + set -o pipefail + gradle \ + :app:lintDebug \ + :app:testDebugUnitTest \ + :app:assembleDebug \ + --stacktrace \ + --warning-mode all \ + 2>&1 | tee ../../input-probe-gradle.log + + - name: Verify APK output + run: | + test -s android/input-probe/app/build/outputs/apk/debug/app-debug.apk + unzip -l android/input-probe/app/build/outputs/apk/debug/app-debug.apk | tee input-probe-apk-contents.txt + grep -q 'classes.dex' input-probe-apk-contents.txt + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: input-probe-reports + path: | + input-probe-gradle.log + android/input-probe/app/build/reports/ + input-probe-apk-contents.txt + if-no-files-found: warn - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/pre-engineering.yml b/.github/workflows/pre-engineering.yml new file mode 100644 index 0000000..93cc336 --- /dev/null +++ b/.github/workflows/pre-engineering.yml @@ -0,0 +1,59 @@ +name: Pre-engineering checks + +on: + push: + branches: + - main + - "agent/**" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pre-engineering-${{ github.ref }} + cancel-in-progress: true + +jobs: + host-checks: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Ensure Ninja is available + run: | + if ! command -v ninja >/dev/null 2>&1; then + python3 -m pip install ninja + fi + + - name: Validate manifests and candidate SHAs + run: | + bash scripts/dev lock-check + bash scripts/dev candidates-check + python3 - <<'PY' + import json, tomllib + from pathlib import Path + json.loads(Path('schemas/input-probe-v1.schema.json').read_text(encoding='utf-8')) + with Path('deps/android-target-dependencies.toml').open('rb') as handle: + manifest = tomllib.load(handle) + assert manifest['format_version'] == 1 + assert manifest['required_pkg_config']['packages'] + PY + + - name: Run Python analyzer tests + run: python3 -m unittest discover -s tools/tests -p 'test_*.py' -v + + - name: Build and test C++ tablet input core + run: bash scripts/dev core-test + + - name: Validate repository tracking policy + run: bash scripts/dev repo-check diff --git a/.github/workflows/source-candidates.yml b/.github/workflows/source-candidates.yml new file mode 100644 index 0000000..a662466 --- /dev/null +++ b/.github/workflows/source-candidates.yml @@ -0,0 +1,42 @@ +name: Source candidate verification + +on: + workflow_dispatch: + schedule: + - cron: "23 4 * * 1" + pull_request: + paths: + - sources.candidates.toml + - scripts/verify-source-candidates.py + - .github/workflows/source-candidates.yml + +permissions: + contents: read + +concurrency: + group: source-candidates-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Fetch immutable SHAs from official remotes + run: | + bash scripts/dev candidates-check --network \ + --json-out source-candidates-report.json + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: source-candidates-report + path: source-candidates-report.json + if-no-files-found: warn diff --git a/.github/workflows/upstream-contract-audit.yml b/.github/workflows/upstream-contract-audit.yml new file mode 100644 index 0000000..41b2ffa --- /dev/null +++ b/.github/workflows/upstream-contract-audit.yml @@ -0,0 +1,50 @@ +name: Upstream contract audit + +on: + workflow_dispatch: + schedule: + - cron: "41 4 * * 1" + pull_request: + paths: + - sources.candidates.toml + - scripts/materialize-source-candidates.py + - scripts/audit-upstream-contracts.py + - .github/workflows/upstream-contract-audit.yml + +permissions: + contents: read + +concurrency: + group: upstream-contract-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Materialize immutable upstream candidates + run: | + python3 scripts/materialize-source-candidates.py \ + --output .out/upstream \ + --json-out upstream-materialization.json + + - name: Audit architecture contracts + run: | + python3 scripts/audit-upstream-contracts.py \ + --workspace .out/upstream \ + --json-out upstream-contract-audit.json + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: upstream-contract-audit + path: | + upstream-materialization.json + upstream-contract-audit.json + if-no-files-found: warn diff --git a/README.md b/README.md index fbc621c..97f3f07 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## 当前状态 -本仓库处于 **P0:框架初始化**,并已加入 P1 输入探针工程。 +本仓库处于 **P0:前期工程基线**。P0 被拆成仓库/CI、输入事务核心、源码与依赖边界、可信输入采集四个子关卡;全部通过后才进入小米真机事件采集。 已定义的首个编辑闭环: @@ -47,11 +47,13 @@ SVG 文档模型与序列化 ## 文档 +- [前期工程评估](docs/pre-engineering-assessment.md) - [完整迁移方案](docs/migration-plan.zh-CN.md) - [开源项目参考评估](docs/open-source-reference-assessment.zh-CN.md) - [分阶段技术关卡](docs/stage-gates.md) - [Mac 操作基线](docs/build-macos.md) - [架构边界](docs/architecture.md) +- [输入核心契约](core/tablet-input/README.md) ## 从 Mac 开始 @@ -59,9 +61,10 @@ SVG 文档模型与序列化 git clone https://github.com/LightChainr/InkscapeAndroid.git cd InkscapeAndroid bash scripts/dev doctor +bash scripts/dev test ``` -`doctor` 只检查环境,不修改系统。 +`doctor` 只检查环境,不修改系统。`test` 运行锁文件、候选 SHA、Python analyzer、C++ input core 和仓库策略检查。 ## P1 输入探针 @@ -73,7 +76,28 @@ gradle :app:assembleDebug adb install -r app/build/outputs/apk/debug/app-debug.apk ``` -GitHub Actions 工作流 `Input probe build` 会使用 JDK 17、AGP 9.2.0 和 Gradle 9.4.1 构建 debug APK。探针日志写入应用私有目录的 `input-probe/events.jsonl`。 +探针采用有界后台写盘,队列溢出会产生显式 `drop` 记录。日志写入应用私有目录: + +```text +files/input-probe/events.jsonl +``` + +导出并验证: + +```sh +PACKAGE=io.github.lightchainr.inkscapeandroid.inputprobe +adb exec-out run-as "$PACKAGE" \ + cat files/input-probe/events.jsonl > events.jsonl +bash scripts/dev probe-analyze events.jsonl +``` + +存在 schema 错误或 dropped records 的日志不能作为完整事件序列证据。 + +## 研究版本 + +- `sources.candidates.toml`:可从官方远端取得的研究候选 SHA; +- `sources.lock.toml`:只有兼容构建和证据包完成后才能标记 `validated`; +- `deps/android-target-dependencies.toml`:目标 sysroot 的依赖分层和硬性约束。 ## 开发原则 diff --git a/android/input-probe/README.md b/android/input-probe/README.md index 8910371..455e50e 100644 --- a/android/input-probe/README.md +++ b/android/input-probe/README.md @@ -1,17 +1,93 @@ # Android Input Probe -P1 的独立 Gradle 应用。只记录 `MotionEvent`,不依赖 GTK 或 Inkscape。 +P1 的独立 Android 应用。它只记录 `MotionEvent`,不依赖 GTK 或 Inkscape。 -最低日志字段:event time、arrival time、action、pointer ID、tool type、source、坐标、pressure、tilt、orientation、distance、buttons、flags、history size、device ID。 +## 设计目标 -输出为 JSON Lines,并附设备型号、系统版本和应用 build ID。探针不得推断手掌或触控笔语义,原始事件和解释报告分开保存。 +探针用于回答三个问题: -必须覆盖: +1. 小米焦点触控笔向第三方应用实际暴露哪些标准 Android 字段; +2. pen、finger、palm 和 canceled pointer 的事件顺序是什么; +3. GTK adapter 后续必须保留哪些原始语义。 + +探针不负责推断“这是手掌”或“这是点击”。原始日志与解释报告必须分开保存。 + +## 数据路径 + +```text +MotionEvent callback + ↓ 复制 primitive snapshot +有界队列(8192 records) + ↓ +后台线程 JSON 编码与 UTF-8 JSONL 写盘 +``` + +若队列溢出,日志会写入 `recordType=drop`,并记录丢失数量。存在 drop 的日志不能用于采样率或完整序列结论。 + +数据契约:[`schemas/input-probe-v1.schema.json`](../../schemas/input-probe-v1.schema.json)。 + +每次启动写入一条 session 记录,包括: + +- 应用版本和 build type; +- 设备型号、Android/HyperOS fingerprint; +- 屏幕密度与刷新率; +- 已连接 input devices; +- 每个 input device 的 motion ranges。 + +每个 event sample 至少包括: + +- current/historical 标记; +- event time、capture monotonic time、capture uptime; +- action、action pointer、pointer ID/index/count; +- tool type、source、device ID、display ID; +- pressure、tilt、orientation、distance; +- size、touch/tool major/minor; +- button、meta、flags、classification; +- raw/local coordinates 和 precision。 + +`System.nanoTime()` 与 MotionEvent event time 不应直接相减,除非先确认时钟基准。分析器默认只对明确可比较的数据做统计。 + +## 构建 + +CI 使用 JDK 17、AGP 9.2.0、Gradle 9.4.1 和 Android 36 SDK: + +```sh +cd android/input-probe +gradle :app:assembleDebug --stacktrace +``` + +安装: + +```sh +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start \ + -n io.github.lightchainr.inkscapeandroid.inputprobe/.MainActivity +``` + +## 导出 + +```sh +PACKAGE=io.github.lightchainr.inkscapeandroid.inputprobe +adb exec-out run-as "$PACKAGE" \ + cat files/input-probe/events.jsonl > events.jsonl +``` + +验证: + +```sh +bash scripts/dev probe-analyze events.jsonl +``` + +## 必测场景 - 轻点、慢拖、快拖; - hover、压力和侧键; - 一指、两指、两指变一指; -- pen 与手掌同时接触; +- pen 先落下后放手掌; +- finger 先落下后落笔; +- 双指过程中落笔; - `ACTION_CANCEL`; - Android 13+ `FLAG_CANCELED`; -- 切后台、系统面板、锁屏和 Surface 变化。 +- 切后台、系统面板、锁屏和窗口失焦。 + +每个场景开始前记录操作说明和时间,原始 JSONL 不做人工编辑。 diff --git a/android/input-probe/app/build.gradle.kts b/android/input-probe/app/build.gradle.kts index 496f5ea..ad329c8 100644 --- a/android/input-probe/app/build.gradle.kts +++ b/android/input-probe/app/build.gradle.kts @@ -2,6 +2,8 @@ plugins { id("com.android.application") } +val sourceRevision = providers.environmentVariable("GITHUB_SHA").orElse("local-uncommitted").get() + android { namespace = "io.github.lightchainr.inkscapeandroid.inputprobe" compileSdk = 36 @@ -10,8 +12,13 @@ android { applicationId = "io.github.lightchainr.inkscapeandroid.inputprobe" minSdk = 31 targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0" + buildConfigField("String", "GIT_SHA", "\"$sourceRevision\"") + } + + buildFeatures { + buildConfig = true } buildTypes { @@ -28,3 +35,8 @@ android { targetCompatibility = JavaVersion.VERSION_17 } } + +dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") +} diff --git a/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriter.java b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriter.java new file mode 100644 index 0000000..0c0f371 --- /dev/null +++ b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriter.java @@ -0,0 +1,199 @@ +package io.github.lightchainr.inkscapeandroid.inputprobe; + +import org.json.JSONObject; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +final class AsyncJsonlWriter { + interface JsonRecord { + long sequence(); + String toJsonLine() throws Exception; + } + + private static final int AUTO_FLUSH_RECORDS = 256; + + private static final class FlushCommand { + final CountDownLatch done = new CountDownLatch(1); + } + + private static final class CloseCommand { + final CountDownLatch done = new CountDownLatch(1); + } + + private final String sessionId; + private final int capacity; + private final ArrayBlockingQueue queue; + private final AtomicLong droppedRecords = new AtomicLong(); + private final AtomicLong lastAttemptedSequence = new AtomicLong(); + private final AtomicReference failure = new AtomicReference<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final BufferedWriter writer; + private final Thread worker; + + AsyncJsonlWriter(File file, String sessionId, int capacity) throws IOException { + this.sessionId = sessionId; + this.capacity = capacity; + this.queue = new ArrayBlockingQueue<>(capacity); + this.writer = Files.newBufferedWriter( + file.toPath(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + this.worker = new Thread(this::runWorker, "input-probe-writer"); + this.worker.setDaemon(true); + this.worker.start(); + } + + boolean enqueue(JsonRecord record) { + if (closed.get()) { + return false; + } + lastAttemptedSequence.accumulateAndGet(record.sequence(), Math::max); + if (!queue.offer(record)) { + droppedRecords.incrementAndGet(); + return false; + } + return true; + } + + boolean enqueueJson(String jsonLine) { + return enqueue(new JsonRecord() { + @Override + public long sequence() { + return 0; + } + + @Override + public String toJsonLine() { + return jsonLine; + } + }); + } + + long pendingDropCount() { + return droppedRecords.get(); + } + + int queueSize() { + return queue.size(); + } + + String failureMessage() { + return failure.get(); + } + + void flush() { + if (closed.get()) { + return; + } + FlushCommand command = new FlushCommand(); + if (!offerControl(command)) { + return; + } + await(command.done); + } + + void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + CloseCommand command = new CloseCommand(); + if (offerControl(command)) { + await(command.done); + } else { + worker.interrupt(); + } + try { + worker.join(2000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + + private boolean offerControl(Object command) { + try { + return queue.offer(command, 2L, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + failure.compareAndSet(null, "interrupted while queueing writer control command"); + return false; + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(2L, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + + private void runWorker() { + int writtenSinceFlush = 0; + try { + boolean running = true; + while (running) { + Object item = queue.take(); + writtenSinceFlush += emitDropRecordIfNeeded(); + if (item instanceof JsonRecord record) { + writer.write(record.toJsonLine()); + writer.newLine(); + writtenSinceFlush++; + if (writtenSinceFlush >= AUTO_FLUSH_RECORDS) { + writer.flush(); + writtenSinceFlush = 0; + } + } else if (item instanceof FlushCommand flush) { + writer.flush(); + writtenSinceFlush = 0; + flush.done.countDown(); + } else if (item instanceof CloseCommand close) { + writer.flush(); + close.done.countDown(); + running = false; + } + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + failure.compareAndSet(null, "writer thread interrupted"); + } catch (Exception error) { + failure.compareAndSet(null, error.getClass().getSimpleName() + ": " + error.getMessage()); + } finally { + try { + emitDropRecordIfNeeded(); + writer.flush(); + writer.close(); + } catch (Exception error) { + failure.compareAndSet(null, error.getClass().getSimpleName() + ": " + error.getMessage()); + } + } + } + + private int emitDropRecordIfNeeded() throws Exception { + long count = droppedRecords.getAndSet(0L); + if (count == 0L) { + return 0; + } + JSONObject json = new JSONObject(); + json.put("schemaVersion", EventSample.SCHEMA_VERSION); + json.put("recordType", "drop"); + json.put("sessionId", sessionId); + json.put("afterSequence", lastAttemptedSequence.get()); + json.put("droppedRecords", count); + json.put("queueCapacity", capacity); + writer.write(json.toString()); + writer.newLine(); + return 1; + } +} diff --git a/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/EventSample.java b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/EventSample.java new file mode 100644 index 0000000..e169ae9 --- /dev/null +++ b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/EventSample.java @@ -0,0 +1,305 @@ +package io.github.lightchainr.inkscapeandroid.inputprobe; + +import android.os.Build; +import android.view.MotionEvent; + +import org.json.JSONObject; + +final class EventSample implements AsyncJsonlWriter.JsonRecord { + static final int SCHEMA_VERSION = 1; + + private final String sessionId; + private final long sequence; + private final String dispatchPath; + private final String sampleKind; + private final long captureMonotonicNanos; + private final long captureUptimeMillis; + private final long eventTimeMillis; + private final Long eventTimeNanos; + private final long downTimeMillis; + private final int actionMasked; + private final int actionIndex; + private final int actionButton; + private final int pointerCount; + private final int pointerIndex; + private final int pointerId; + private final boolean actionPointer; + private final int toolType; + private final int source; + private final int deviceId; + private final int displayId; + private final int buttonState; + private final int metaState; + private final int flags; + private final int edgeFlags; + private final int classification; + private final int historySize; + private final float x; + private final float y; + private final Float rawX; + private final Float rawY; + private final float pressure; + private final float orientation; + private final float tilt; + private final float distance; + private final float size; + private final float touchMajor; + private final float touchMinor; + private final float toolMajor; + private final float toolMinor; + private final float xPrecision; + private final float yPrecision; + + private EventSample( + String sessionId, + long sequence, + String dispatchPath, + String sampleKind, + long captureMonotonicNanos, + long captureUptimeMillis, + long eventTimeMillis, + Long eventTimeNanos, + long downTimeMillis, + int actionMasked, + int actionIndex, + int actionButton, + int pointerCount, + int pointerIndex, + int pointerId, + boolean actionPointer, + int toolType, + int source, + int deviceId, + int displayId, + int buttonState, + int metaState, + int flags, + int edgeFlags, + int classification, + int historySize, + float x, + float y, + Float rawX, + Float rawY, + float pressure, + float orientation, + float tilt, + float distance, + float size, + float touchMajor, + float touchMinor, + float toolMajor, + float toolMinor, + float xPrecision, + float yPrecision) { + this.sessionId = sessionId; + this.sequence = sequence; + this.dispatchPath = dispatchPath; + this.sampleKind = sampleKind; + this.captureMonotonicNanos = captureMonotonicNanos; + this.captureUptimeMillis = captureUptimeMillis; + this.eventTimeMillis = eventTimeMillis; + this.eventTimeNanos = eventTimeNanos; + this.downTimeMillis = downTimeMillis; + this.actionMasked = actionMasked; + this.actionIndex = actionIndex; + this.actionButton = actionButton; + this.pointerCount = pointerCount; + this.pointerIndex = pointerIndex; + this.pointerId = pointerId; + this.actionPointer = actionPointer; + this.toolType = toolType; + this.source = source; + this.deviceId = deviceId; + this.displayId = displayId; + this.buttonState = buttonState; + this.metaState = metaState; + this.flags = flags; + this.edgeFlags = edgeFlags; + this.classification = classification; + this.historySize = historySize; + this.x = x; + this.y = y; + this.rawX = rawX; + this.rawY = rawY; + this.pressure = pressure; + this.orientation = orientation; + this.tilt = tilt; + this.distance = distance; + this.size = size; + this.touchMajor = touchMajor; + this.touchMinor = touchMinor; + this.toolMajor = toolMajor; + this.toolMinor = toolMinor; + this.xPrecision = xPrecision; + this.yPrecision = yPrecision; + } + + static EventSample capture( + MotionEvent event, + String sessionId, + long sequence, + String dispatchPath, + long captureMonotonicNanos, + long captureUptimeMillis, + int displayId, + int pointerIndex, + int historyIndex) { + boolean historical = historyIndex >= 0; + int action = event.getActionMasked(); + int actionIndex = event.getActionIndex(); + boolean pointerSpecific = action == MotionEvent.ACTION_DOWN + || action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_POINTER_DOWN + || action == MotionEvent.ACTION_POINTER_UP; + + long eventTimeMillis = historical + ? event.getHistoricalEventTime(historyIndex) + : event.getEventTime(); + Long eventTimeNanos = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + eventTimeNanos = historical + ? event.getHistoricalEventTimeNanos(historyIndex) + : event.getEventTimeNanos(); + } + + float x; + float y; + Float rawX = null; + Float rawY = null; + float pressure; + float orientation; + float tilt; + float distance; + float size; + float touchMajor; + float touchMinor; + float toolMajor; + float toolMinor; + + if (historical) { + x = event.getHistoricalX(pointerIndex, historyIndex); + y = event.getHistoricalY(pointerIndex, historyIndex); + pressure = event.getHistoricalPressure(pointerIndex, historyIndex); + orientation = event.getHistoricalOrientation(pointerIndex, historyIndex); + tilt = event.getHistoricalAxisValue(MotionEvent.AXIS_TILT, pointerIndex, historyIndex); + distance = event.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex, historyIndex); + size = event.getHistoricalSize(pointerIndex, historyIndex); + touchMajor = event.getHistoricalTouchMajor(pointerIndex, historyIndex); + touchMinor = event.getHistoricalTouchMinor(pointerIndex, historyIndex); + toolMajor = event.getHistoricalToolMajor(pointerIndex, historyIndex); + toolMinor = event.getHistoricalToolMinor(pointerIndex, historyIndex); + } else { + x = event.getX(pointerIndex); + y = event.getY(pointerIndex); + rawX = event.getRawX(pointerIndex); + rawY = event.getRawY(pointerIndex); + pressure = event.getPressure(pointerIndex); + orientation = event.getOrientation(pointerIndex); + tilt = event.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex); + distance = event.getAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex); + size = event.getSize(pointerIndex); + touchMajor = event.getTouchMajor(pointerIndex); + touchMinor = event.getTouchMinor(pointerIndex); + toolMajor = event.getToolMajor(pointerIndex); + toolMinor = event.getToolMinor(pointerIndex); + } + + return new EventSample( + sessionId, + sequence, + dispatchPath, + historical ? "historical" : "current", + captureMonotonicNanos, + captureUptimeMillis, + eventTimeMillis, + eventTimeNanos, + event.getDownTime(), + action, + actionIndex, + event.getActionButton(), + event.getPointerCount(), + pointerIndex, + event.getPointerId(pointerIndex), + pointerSpecific && pointerIndex == actionIndex, + event.getToolType(pointerIndex), + event.getSource(), + event.getDeviceId(), + displayId, + event.getButtonState(), + event.getMetaState(), + event.getFlags(), + event.getEdgeFlags(), + event.getClassification(), + event.getHistorySize(), + x, + y, + rawX, + rawY, + pressure, + orientation, + tilt, + distance, + size, + touchMajor, + touchMinor, + toolMajor, + toolMinor, + event.getXPrecision(), + event.getYPrecision()); + } + + @Override + public long sequence() { + return sequence; + } + + @Override + public String toJsonLine() throws Exception { + JSONObject json = new JSONObject(); + json.put("schemaVersion", SCHEMA_VERSION); + json.put("recordType", "event"); + json.put("sessionId", sessionId); + json.put("sequence", sequence); + json.put("dispatchPath", dispatchPath); + json.put("sampleKind", sampleKind); + json.put("captureMonotonicNanos", captureMonotonicNanos); + json.put("captureUptimeMillis", captureUptimeMillis); + json.put("eventTimeMillis", eventTimeMillis); + json.put("eventTimeNanos", eventTimeNanos == null ? JSONObject.NULL : eventTimeNanos); + json.put("downTimeMillis", downTimeMillis); + json.put("actionMasked", actionMasked); + json.put("actionIndex", actionIndex); + json.put("actionButton", actionButton); + json.put("pointerCount", pointerCount); + json.put("pointerIndex", pointerIndex); + json.put("pointerId", pointerId); + json.put("isActionPointer", actionPointer); + json.put("toolType", toolType); + json.put("source", source); + json.put("deviceId", deviceId); + json.put("displayId", displayId); + json.put("buttonState", buttonState); + json.put("metaState", metaState); + json.put("flags", flags); + json.put("edgeFlags", edgeFlags); + json.put("classification", classification); + json.put("historySize", historySize); + json.put("x", x); + json.put("y", y); + json.put("rawX", rawX == null ? JSONObject.NULL : rawX); + json.put("rawY", rawY == null ? JSONObject.NULL : rawY); + json.put("pressure", pressure); + json.put("orientation", orientation); + json.put("tilt", tilt); + json.put("distance", distance); + json.put("size", size); + json.put("touchMajor", touchMajor); + json.put("touchMinor", touchMinor); + json.put("toolMajor", toolMajor); + json.put("toolMinor", toolMinor); + json.put("xPrecision", xPrecision); + json.put("yPrecision", yPrecision); + return json.toString(); + } +} diff --git a/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/MainActivity.java b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/MainActivity.java index 5bc176d..15d91cc 100644 --- a/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/MainActivity.java +++ b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/MainActivity.java @@ -4,19 +4,16 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; -import android.os.Build; import android.os.Bundle; +import android.os.SystemClock; import android.view.MotionEvent; import android.view.View; -import org.json.JSONObject; - -import java.io.BufferedWriter; import java.io.File; -import java.io.FileWriter; -import java.io.IOException; import java.util.ArrayDeque; import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; public final class MainActivity extends Activity { private InputProbeView probeView; @@ -46,14 +43,14 @@ protected void onDestroy() { private static final class InputProbeView extends View { private static final int MAX_VISIBLE_LINES = 18; - private static final int FLUSH_INTERVAL = 8; - private static final long NANOS_PER_MILLISECOND = 1_000_000L; + private static final int WRITER_QUEUE_CAPACITY = 8192; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final ArrayDeque recentLines = new ArrayDeque<>(); + private final AtomicLong sequence = new AtomicLong(); + private final String sessionId = UUID.randomUUID().toString(); private final File logFile; - private BufferedWriter writer; - private int recordsSinceFlush; + private AsyncJsonlWriter logWriter; InputProbeView(Activity activity) { super(activity); @@ -70,12 +67,14 @@ private static final class InputProbeView extends View { } logFile = new File(logDir, "events.jsonl"); try { - writer = new BufferedWriter(new FileWriter(logFile, true)); - } catch (IOException error) { + logWriter = new AsyncJsonlWriter(logFile, sessionId, WRITER_QUEUE_CAPACITY); + logWriter.enqueueJson(SessionMetadata.create(activity, sessionId)); + } catch (Exception error) { addVisibleLine("ERROR opening log: " + error.getMessage()); } + addVisibleLine("Session: " + sessionId); addVisibleLine("Log: " + logFile.getAbsolutePath()); - addVisibleLine("Touch, hover and stylus events are recorded as JSONL."); + addVisibleLine("Primitive snapshots are encoded and written on a background thread."); } @Override @@ -106,109 +105,72 @@ public boolean performClick() { } private void record(MotionEvent event, String dispatchPath) { - final long arrivalTimeNanos = System.nanoTime(); + final AsyncJsonlWriter writer = logWriter; + if (writer == null) { + return; + } + + final long captureMonotonicNanos = System.nanoTime(); + final long captureUptimeMillis = SystemClock.uptimeMillis(); + final int displayId = getDisplay() == null ? -1 : getDisplay().getDisplayId(); final int pointerCount = event.getPointerCount(); final int historySize = event.getHistorySize(); for (int historyIndex = 0; historyIndex < historySize; historyIndex++) { for (int pointerIndex = 0; pointerIndex < pointerCount; pointerIndex++) { - writeRecord(event, dispatchPath, arrivalTimeNanos, pointerIndex, historyIndex); + enqueueSample( + writer, + EventSample.capture( + event, + sessionId, + sequence.incrementAndGet(), + dispatchPath, + captureMonotonicNanos, + captureUptimeMillis, + displayId, + pointerIndex, + historyIndex)); } } for (int pointerIndex = 0; pointerIndex < pointerCount; pointerIndex++) { - writeRecord(event, dispatchPath, arrivalTimeNanos, pointerIndex, -1); + enqueueSample( + writer, + EventSample.capture( + event, + sessionId, + sequence.incrementAndGet(), + dispatchPath, + captureMonotonicNanos, + captureUptimeMillis, + displayId, + pointerIndex, + -1)); } int actionIndex = Math.min(event.getActionIndex(), pointerCount - 1); String summary = String.format( Locale.US, - "%s action=%s pointers=%d id=%d tool=%s p=%.3f flags=0x%X", + "%s action=%s pointers=%d id=%d tool=%s p=%.3f q=%d drop=%d", dispatchPath, actionName(event.getActionMasked()), pointerCount, event.getPointerId(actionIndex), toolName(event.getToolType(actionIndex)), event.getPressure(actionIndex), - event.getFlags()); + writer.queueSize(), + writer.pendingDropCount()); addVisibleLine(summary); - invalidate(); - } - - private void writeRecord( - MotionEvent event, - String dispatchPath, - long arrivalTimeNanos, - int pointerIndex, - int historyIndex) { - if (writer == null) { - return; - } - - final boolean historical = historyIndex >= 0; - try { - JSONObject json = new JSONObject(); - json.put("dispatchPath", dispatchPath); - json.put("historical", historical); - json.put("arrivalTimeNanos", arrivalTimeNanos); - json.put("eventTimeNanos", eventTimeNanos(event, historyIndex)); - json.put("actionMasked", event.getActionMasked()); - json.put("actionIndex", event.getActionIndex()); - json.put("pointerCount", event.getPointerCount()); - json.put("pointerIndex", pointerIndex); - json.put("pointerId", event.getPointerId(pointerIndex)); - json.put("toolType", event.getToolType(pointerIndex)); - json.put("source", event.getSource()); - json.put("deviceId", event.getDeviceId()); - json.put("buttonState", event.getButtonState()); - json.put("metaState", event.getMetaState()); - json.put("flags", event.getFlags()); - json.put("historySize", event.getHistorySize()); - - if (historical) { - json.put("x", event.getHistoricalX(pointerIndex, historyIndex)); - json.put("y", event.getHistoricalY(pointerIndex, historyIndex)); - json.put("pressure", event.getHistoricalPressure(pointerIndex, historyIndex)); - json.put("orientation", event.getHistoricalOrientation(pointerIndex, historyIndex)); - json.put( - "tilt", - event.getHistoricalAxisValue( - MotionEvent.AXIS_TILT, pointerIndex, historyIndex)); - json.put( - "distance", - event.getHistoricalAxisValue( - MotionEvent.AXIS_DISTANCE, pointerIndex, historyIndex)); - } else { - json.put("x", event.getX(pointerIndex)); - json.put("y", event.getY(pointerIndex)); - json.put("pressure", event.getPressure(pointerIndex)); - json.put("orientation", event.getOrientation(pointerIndex)); - json.put("tilt", event.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex)); - json.put( - "distance", - event.getAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex)); - } - - writer.write(json.toString()); - writer.newLine(); - recordsSinceFlush++; - if (recordsSinceFlush >= FLUSH_INTERVAL) { - flush(); - } - } catch (Exception error) { - addVisibleLine("ERROR recording event: " + error.getMessage()); + String writerFailure = writer.failureMessage(); + if (writerFailure != null) { + addVisibleLine("ERROR writer: " + writerFailure); } + invalidate(); } - private static long eventTimeNanos(MotionEvent event, int historyIndex) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return historyIndex >= 0 - ? event.getHistoricalEventTimeNanos(historyIndex) - : event.getEventTimeNanos(); + private void enqueueSample(AsyncJsonlWriter writer, EventSample sample) { + if (!writer.enqueue(sample) && writer.pendingDropCount() == 1L) { + addVisibleLine("WARN writer queue full; drop record will be emitted"); } - long eventTimeMillis = historyIndex >= 0 - ? event.getHistoricalEventTime(historyIndex) - : event.getEventTime(); - return eventTimeMillis * NANOS_PER_MILLISECOND; } private void addVisibleLine(String line) { @@ -219,28 +181,15 @@ private void addVisibleLine(String line) { } void flush() { - if (writer == null) { - return; - } - try { - writer.flush(); - recordsSinceFlush = 0; - } catch (IOException error) { - addVisibleLine("ERROR flushing log: " + error.getMessage()); + if (logWriter != null) { + logWriter.flush(); } } void close() { - if (writer == null) { - return; - } - try { - writer.flush(); - writer.close(); - } catch (IOException error) { - addVisibleLine("ERROR closing log: " + error.getMessage()); - } finally { - writer = null; + if (logWriter != null) { + logWriter.close(); + logWriter = null; } } diff --git a/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/SessionMetadata.java b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/SessionMetadata.java new file mode 100644 index 0000000..90f2246 --- /dev/null +++ b/android/input-probe/app/src/main/java/io/github/lightchainr/inkscapeandroid/inputprobe/SessionMetadata.java @@ -0,0 +1,103 @@ +package io.github.lightchainr.inkscapeandroid.inputprobe; + +import android.app.Activity; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.InputDevice; + +import org.json.JSONArray; +import org.json.JSONObject; + +final class SessionMetadata { + private SessionMetadata() {} + + static String create(Activity activity, String sessionId) throws Exception { + JSONObject root = new JSONObject(); + root.put("schemaVersion", EventSample.SCHEMA_VERSION); + root.put("recordType", "session"); + root.put("sessionId", sessionId); + root.put("createdWallTimeMillis", System.currentTimeMillis()); + root.put("app", app(activity)); + root.put("device", device()); + root.put("display", display(activity)); + root.put("inputDevices", inputDevices()); + return root.toString(); + } + + private static JSONObject app(Activity activity) throws Exception { + PackageInfo info = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0); + boolean debuggable = (activity.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + JSONObject json = new JSONObject(); + json.put("versionName", info.versionName == null ? "unknown" : info.versionName); + json.put("versionCode", info.getLongVersionCode()); + json.put("buildType", debuggable ? "debug" : "release"); + json.put("gitSha", BuildConfig.GIT_SHA); + return json; + } + + private static JSONObject device() throws Exception { + JSONObject json = new JSONObject(); + json.put("manufacturer", Build.MANUFACTURER); + json.put("brand", Build.BRAND); + json.put("model", Build.MODEL); + json.put("device", Build.DEVICE); + json.put("hardware", Build.HARDWARE); + json.put("sdkInt", Build.VERSION.SDK_INT); + json.put("release", Build.VERSION.RELEASE); + json.put("fingerprint", Build.FINGERPRINT); + return json; + } + + private static JSONObject display(Activity activity) throws Exception { + DisplayMetrics metrics = activity.getResources().getDisplayMetrics(); + Display currentDisplay = activity.getDisplay(); + JSONObject json = new JSONObject(); + json.put("widthPixels", metrics.widthPixels); + json.put("heightPixels", metrics.heightPixels); + json.put("density", metrics.density); + json.put("densityDpi", metrics.densityDpi); + json.put("scaledDensity", metrics.scaledDensity); + json.put("xdpi", metrics.xdpi); + json.put("ydpi", metrics.ydpi); + json.put("refreshRateHz", currentDisplay == null ? 0.0f : currentDisplay.getRefreshRate()); + return json; + } + + private static JSONArray inputDevices() throws Exception { + JSONArray devices = new JSONArray(); + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) { + continue; + } + JSONObject json = new JSONObject(); + json.put("id", id); + json.put("name", device.getName()); + json.put("descriptor", device.getDescriptor()); + json.put("sources", device.getSources()); + json.put("vendorId", device.getVendorId()); + json.put("productId", device.getProductId()); + json.put("external", device.isExternal()); + json.put("virtual", device.isVirtual()); + + JSONArray ranges = new JSONArray(); + for (InputDevice.MotionRange range : device.getMotionRanges()) { + JSONObject item = new JSONObject(); + item.put("axis", range.getAxis()); + item.put("source", range.getSource()); + item.put("min", range.getMin()); + item.put("max", range.getMax()); + item.put("flat", range.getFlat()); + item.put("fuzz", range.getFuzz()); + item.put("resolution", range.getResolution()); + ranges.put(item); + } + json.put("motionRanges", ranges); + devices.put(json); + } + return devices; + } +} diff --git a/android/input-probe/app/src/test/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriterTest.java b/android/input-probe/app/src/test/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriterTest.java new file mode 100644 index 0000000..9dc48a0 --- /dev/null +++ b/android/input-probe/app/src/test/java/io/github/lightchainr/inkscapeandroid/inputprobe/AsyncJsonlWriterTest.java @@ -0,0 +1,85 @@ +package io.github.lightchainr.inkscapeandroid.inputprobe; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public final class AsyncJsonlWriterTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void writesQueuedRecordsAndFlushes() throws Exception { + File file = temporaryFolder.newFile("events.jsonl"); + AsyncJsonlWriter writer = new AsyncJsonlWriter(file, "session-test", 8); + assertTrue(writer.enqueue(record(1, "{\"recordType\":\"test\",\"sequence\":1}"))); + assertTrue(writer.enqueue(record(2, "{\"recordType\":\"test\",\"sequence\":2}"))); + writer.flush(); + writer.close(); + + List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + assertTrue(lines.stream().anyMatch(line -> line.contains("\"sequence\":1"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("\"sequence\":2"))); + } + + @Test + public void emitsDropRecordWhenBoundedQueueOverflows() throws Exception { + File file = temporaryFolder.newFile("events.jsonl"); + AsyncJsonlWriter writer = new AsyncJsonlWriter(file, "session-test", 1); + + CountDownLatch serializationStarted = new CountDownLatch(1); + CountDownLatch releaseSerialization = new CountDownLatch(1); + assertTrue(writer.enqueue(new AsyncJsonlWriter.JsonRecord() { + @Override + public long sequence() { + return 1; + } + + @Override + public String toJsonLine() throws Exception { + serializationStarted.countDown(); + if (!releaseSerialization.await(2, TimeUnit.SECONDS)) { + throw new IllegalStateException("test did not release serialization"); + } + return "{\"recordType\":\"test\",\"sequence\":1}"; + } + })); + + assertTrue(serializationStarted.await(2, TimeUnit.SECONDS)); + assertTrue(writer.enqueue(record(2, "{\"recordType\":\"test\",\"sequence\":2}"))); + assertFalse(writer.enqueue(record(3, "{\"recordType\":\"test\",\"sequence\":3}"))); + + releaseSerialization.countDown(); + writer.flush(); + writer.close(); + + List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8); + assertTrue(lines.stream().anyMatch(line -> line.contains("\"recordType\":\"drop\""))); + assertTrue(lines.stream().anyMatch(line -> line.contains("\"droppedRecords\":1"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("\"queueCapacity\":1"))); + } + + private static AsyncJsonlWriter.JsonRecord record(long sequence, String json) { + return new AsyncJsonlWriter.JsonRecord() { + @Override + public long sequence() { + return sequence; + } + + @Override + public String toJsonLine() { + return json; + } + }; + } +} diff --git a/core/tablet-input/CMakeLists.txt b/core/tablet-input/CMakeLists.txt new file mode 100644 index 0000000..643026c --- /dev/null +++ b/core/tablet-input/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.24) +project(inkscape_android_tablet_input_core LANGUAGES CXX) + +add_library(tablet_input_core + src/tablet_input_core.cpp +) + +target_include_directories(tablet_input_core + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_compile_features(tablet_input_core PUBLIC cxx_std_20) + +if(MSVC) + target_compile_options(tablet_input_core PRIVATE /W4 /WX) +else() + target_compile_options(tablet_input_core PRIVATE -Wall -Wextra -Wpedantic -Werror) +endif() + +option(TABLET_INPUT_BUILD_TESTS "Build tablet input core tests" ON) +if(TABLET_INPUT_BUILD_TESTS) + enable_testing() + add_executable(tablet_input_core_tests + tests/tablet_input_core_test.cpp + ) + target_link_libraries(tablet_input_core_tests PRIVATE tablet_input_core) + target_compile_features(tablet_input_core_tests PRIVATE cxx_std_20) + if(MSVC) + target_compile_options(tablet_input_core_tests PRIVATE /W4 /WX) + else() + target_compile_options(tablet_input_core_tests PRIVATE -Wall -Wextra -Wpedantic -Werror) + endif() + add_test(NAME tablet_input_core_tests COMMAND tablet_input_core_tests) +endif() diff --git a/core/tablet-input/README.md b/core/tablet-input/README.md new file mode 100644 index 0000000..d458714 --- /dev/null +++ b/core/tablet-input/README.md @@ -0,0 +1,62 @@ +# Tablet Input Core + +该目录是 Android/GTK/Inkscape 之外的可执行输入规范。 + +它解决两个问题: + +1. 谁拥有当前画布输入; +2. 当前对象交互是正常完成还是取消回滚。 + +## 为什么单独存在 + +直接在 GTK controller 或 Inkscape Select Tool 中编写 owner 逻辑,会让平台事件、视口手势、对象事务和工具状态互相耦合。这里先用纯 C++20 固定不变量,后续 adapter 只能把 GDK 事件转换为本模块事件,不得重新发明状态规则。 + +## 输入所有权 + +```text +None + ├─ pen down ───────────────→ Pen + └─ first finger down ──────→ TouchPending + +TouchPending + ├─ second finger down ─────→ TouchTransform + └─ all fingers up ─────────→ None + +TouchTransform + └─ any tracked finger up ──→ BlockUntilAllTouchUp + +BlockUntilAllTouchUp + └─ all fingers up ─────────→ None +``` + +规则: + +- `Pen` 期间出现的 finger 只登记和消费,不能抢占; +- touch 已获得 owner 后出现的整个 pen sequence 都忽略; +- 双指结束后,剩余单指不能继续平移,也不能变成对象点击; +- lifecycle cancel 清空所有 pointer registry; +- pen cancel 产生 `AbortInteraction`,normal up 产生 `ForwardPenUp`。 + +## InteractionSession + +```text +Idle → Armed → Active → Committed + └────→ Aborted +``` + +点击可以从 `Armed` 直接 `Committed`;拖动先进入 `Active`。 + +不变量: + +- `complete()` 和 `abort()` 只允许一次成功; +- `Aborted` 后迟到的 release 不能 commit; +- terminal session 必须显式 reset 才能复用; +- adapter 负责把 `AbortInteraction` 连接到工具 abort 和最小 rollback。 + +## 构建 + +```sh +bash scripts/dev core-test +``` + +该测试在 Linux 和 macOS CI 都运行。接入 GTK Interaction Lab 时,应复用此库或保持行为一致的薄 adapter,并用相同事件序列做回归。 diff --git a/core/tablet-input/include/inkscape_android/tablet_input_core.hpp b/core/tablet-input/include/inkscape_android/tablet_input_core.hpp new file mode 100644 index 0000000..cac3cac --- /dev/null +++ b/core/tablet-input/include/inkscape_android/tablet_input_core.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include + +namespace inkscape_android::input { + +enum class DeviceKind { + Pen, + Eraser, + Finger, + Mouse, + Unknown, +}; + +enum class PointerPhase { + Down, + Move, + Up, + Cancel, +}; + +enum class InputOwner { + None, + Pen, + TouchPending, + TouchTransform, + BlockUntilAllTouchUp, +}; + +enum class RouterAction { + Consume, + ForwardPenDown, + ForwardPenMove, + ForwardPenUp, + BeginTouchTransform, + UpdateTouchTransform, + EndTouchTransform, + AbortInteraction, +}; + +struct PointerEvent { + PointerPhase phase = PointerPhase::Move; + DeviceKind device = DeviceKind::Unknown; + std::int32_t pointer_id = -1; + double x = 0.0; + double y = 0.0; +}; + +struct RouterResult { + InputOwner owner = InputOwner::None; + std::vector actions; + + [[nodiscard]] bool contains(RouterAction action) const; +}; + +class InputRouter { +public: + [[nodiscard]] RouterResult handle(PointerEvent const &event); + [[nodiscard]] RouterResult cancel_all(); + + [[nodiscard]] InputOwner owner() const noexcept { return owner_; } + [[nodiscard]] std::size_t active_touch_count() const noexcept { return touch_ids_.size(); } + [[nodiscard]] bool pen_interaction_active() const noexcept { return pen_id_.has_value(); } + +private: + [[nodiscard]] RouterResult handle_pen(PointerEvent const &event); + [[nodiscard]] RouterResult handle_touch(PointerEvent const &event); + [[nodiscard]] RouterResult result(std::vector actions) const; + + InputOwner owner_ = InputOwner::None; + std::optional pen_id_; + std::unordered_set ignored_pen_ids_; + std::unordered_set touch_ids_; +}; + +enum class InteractionState { + Idle, + Armed, + Active, + Committed, + Aborted, +}; + +class InteractionSession { +public: + [[nodiscard]] bool arm(); + [[nodiscard]] bool activate(); + [[nodiscard]] bool complete(); + [[nodiscard]] bool abort(); + [[nodiscard]] bool reset(); + + [[nodiscard]] InteractionState state() const noexcept { return state_; } + [[nodiscard]] bool terminal() const noexcept; + +private: + InteractionState state_ = InteractionState::Idle; +}; + +} // namespace inkscape_android::input diff --git a/core/tablet-input/src/tablet_input_core.cpp b/core/tablet-input/src/tablet_input_core.cpp new file mode 100644 index 0000000..0fca02a --- /dev/null +++ b/core/tablet-input/src/tablet_input_core.cpp @@ -0,0 +1,199 @@ +#include "inkscape_android/tablet_input_core.hpp" + +#include +#include + +namespace inkscape_android::input { + +bool RouterResult::contains(RouterAction action) const +{ + return std::find(actions.begin(), actions.end(), action) != actions.end(); +} + +RouterResult InputRouter::result(std::vector actions) const +{ + return RouterResult{owner_, std::move(actions)}; +} + +RouterResult InputRouter::handle(PointerEvent const &event) +{ + switch (event.device) { + case DeviceKind::Pen: + case DeviceKind::Eraser: + return handle_pen(event); + case DeviceKind::Finger: + return handle_touch(event); + case DeviceKind::Mouse: + case DeviceKind::Unknown: + return result({RouterAction::Consume}); + } + return result({RouterAction::Consume}); +} + +RouterResult InputRouter::handle_pen(PointerEvent const &event) +{ + switch (event.phase) { + case PointerPhase::Down: + if (owner_ == InputOwner::None) { + owner_ = InputOwner::Pen; + pen_id_ = event.pointer_id; + return result({RouterAction::ForwardPenDown}); + } + ignored_pen_ids_.insert(event.pointer_id); + return result({RouterAction::Consume}); + + case PointerPhase::Move: + if (owner_ == InputOwner::Pen && pen_id_ == event.pointer_id) { + return result({RouterAction::ForwardPenMove}); + } + return result({RouterAction::Consume}); + + case PointerPhase::Up: + if (ignored_pen_ids_.erase(event.pointer_id) != 0U) { + return result({RouterAction::Consume}); + } + if (owner_ == InputOwner::Pen && pen_id_ == event.pointer_id) { + pen_id_.reset(); + owner_ = touch_ids_.empty() ? InputOwner::None : InputOwner::BlockUntilAllTouchUp; + return result({RouterAction::ForwardPenUp}); + } + return result({RouterAction::Consume}); + + case PointerPhase::Cancel: + ignored_pen_ids_.erase(event.pointer_id); + if (owner_ == InputOwner::Pen && pen_id_ == event.pointer_id) { + pen_id_.reset(); + owner_ = touch_ids_.empty() ? InputOwner::None : InputOwner::BlockUntilAllTouchUp; + return result({RouterAction::AbortInteraction}); + } + return result({RouterAction::Consume}); + } + return result({RouterAction::Consume}); +} + +RouterResult InputRouter::handle_touch(PointerEvent const &event) +{ + switch (event.phase) { + case PointerPhase::Down: { + touch_ids_.insert(event.pointer_id); + if (owner_ == InputOwner::None) { + owner_ = InputOwner::TouchPending; + return result({RouterAction::Consume}); + } + if (owner_ == InputOwner::TouchPending && touch_ids_.size() == 2U) { + owner_ = InputOwner::TouchTransform; + return result({RouterAction::BeginTouchTransform}); + } + return result({RouterAction::Consume}); + } + + case PointerPhase::Move: + if (owner_ == InputOwner::TouchTransform && touch_ids_.contains(event.pointer_id)) { + return result({RouterAction::UpdateTouchTransform}); + } + return result({RouterAction::Consume}); + + case PointerPhase::Up: { + touch_ids_.erase(event.pointer_id); + if (owner_ == InputOwner::TouchPending) { + if (touch_ids_.empty()) { + owner_ = InputOwner::None; + } + return result({RouterAction::Consume}); + } + if (owner_ == InputOwner::TouchTransform) { + owner_ = touch_ids_.empty() ? InputOwner::None : InputOwner::BlockUntilAllTouchUp; + return result({RouterAction::EndTouchTransform}); + } + if (owner_ == InputOwner::BlockUntilAllTouchUp && touch_ids_.empty()) { + owner_ = InputOwner::None; + } + return result({RouterAction::Consume}); + } + + case PointerPhase::Cancel: { + touch_ids_.erase(event.pointer_id); + if (owner_ == InputOwner::TouchTransform) { + owner_ = touch_ids_.empty() ? InputOwner::None : InputOwner::BlockUntilAllTouchUp; + return result({RouterAction::EndTouchTransform}); + } + if ((owner_ == InputOwner::TouchPending || owner_ == InputOwner::BlockUntilAllTouchUp) && touch_ids_.empty()) { + owner_ = InputOwner::None; + } + return result({RouterAction::Consume}); + } + } + return result({RouterAction::Consume}); +} + +RouterResult InputRouter::cancel_all() +{ + std::vector actions; + if (owner_ == InputOwner::Pen && pen_id_.has_value()) { + actions.push_back(RouterAction::AbortInteraction); + } + if (owner_ == InputOwner::TouchTransform) { + actions.push_back(RouterAction::EndTouchTransform); + } + if (actions.empty()) { + actions.push_back(RouterAction::Consume); + } + + owner_ = InputOwner::None; + pen_id_.reset(); + ignored_pen_ids_.clear(); + touch_ids_.clear(); + return result(std::move(actions)); +} + +bool InteractionSession::arm() +{ + if (state_ != InteractionState::Idle) { + return false; + } + state_ = InteractionState::Armed; + return true; +} + +bool InteractionSession::activate() +{ + if (state_ != InteractionState::Armed) { + return false; + } + state_ = InteractionState::Active; + return true; +} + +bool InteractionSession::complete() +{ + if (state_ != InteractionState::Armed && state_ != InteractionState::Active) { + return false; + } + state_ = InteractionState::Committed; + return true; +} + +bool InteractionSession::abort() +{ + if (state_ != InteractionState::Armed && state_ != InteractionState::Active) { + return false; + } + state_ = InteractionState::Aborted; + return true; +} + +bool InteractionSession::reset() +{ + if (state_ == InteractionState::Armed || state_ == InteractionState::Active) { + return false; + } + state_ = InteractionState::Idle; + return true; +} + +bool InteractionSession::terminal() const noexcept +{ + return state_ == InteractionState::Committed || state_ == InteractionState::Aborted; +} + +} // namespace inkscape_android::input diff --git a/core/tablet-input/tests/tablet_input_core_test.cpp b/core/tablet-input/tests/tablet_input_core_test.cpp new file mode 100644 index 0000000..bc4a4ea --- /dev/null +++ b/core/tablet-input/tests/tablet_input_core_test.cpp @@ -0,0 +1,146 @@ +#include "inkscape_android/tablet_input_core.hpp" + +#include +#include +#include + +using namespace inkscape_android::input; + +namespace { + +int failures = 0; + +void expect(bool condition, std::string_view message) +{ + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +PointerEvent event(PointerPhase phase, DeviceKind device, int id) +{ + return PointerEvent{phase, device, id, 0.0, 0.0}; +} + +void test_pen_exclusivity() +{ + InputRouter router; + auto down = router.handle(event(PointerPhase::Down, DeviceKind::Pen, 10)); + expect(down.owner == InputOwner::Pen, "pen down owns the canvas"); + expect(down.contains(RouterAction::ForwardPenDown), "pen down is forwarded"); + + auto finger = router.handle(event(PointerPhase::Down, DeviceKind::Finger, 20)); + expect(finger.owner == InputOwner::Pen, "finger cannot steal pen ownership"); + expect(finger.contains(RouterAction::Consume), "finger is consumed during pen interaction"); + + auto move = router.handle(event(PointerPhase::Move, DeviceKind::Pen, 10)); + expect(move.contains(RouterAction::ForwardPenMove), "active pen move is forwarded"); + + auto up = router.handle(event(PointerPhase::Up, DeviceKind::Pen, 10)); + expect(up.contains(RouterAction::ForwardPenUp), "active pen up is forwarded"); + expect(up.owner == InputOwner::BlockUntilAllTouchUp, "remaining finger blocks ownership handoff"); + + auto finger_up = router.handle(event(PointerPhase::Up, DeviceKind::Finger, 20)); + expect(finger_up.owner == InputOwner::None, "all touch released returns to idle"); +} + +void test_two_finger_navigation() +{ + InputRouter router; + auto first = router.handle(event(PointerPhase::Down, DeviceKind::Finger, 1)); + expect(first.owner == InputOwner::TouchPending, "first finger only enters pending state"); + expect(first.contains(RouterAction::Consume), "first finger produces no canvas action"); + + auto second = router.handle(event(PointerPhase::Down, DeviceKind::Finger, 2)); + expect(second.owner == InputOwner::TouchTransform, "second finger begins transform"); + expect(second.contains(RouterAction::BeginTouchTransform), "two fingers emit transform begin"); + + auto move = router.handle(event(PointerPhase::Move, DeviceKind::Finger, 1)); + expect(move.contains(RouterAction::UpdateTouchTransform), "touch transform move is routed"); + + auto one_up = router.handle(event(PointerPhase::Up, DeviceKind::Finger, 1)); + expect(one_up.contains(RouterAction::EndTouchTransform), "first release ends the whole transform"); + expect(one_up.owner == InputOwner::BlockUntilAllTouchUp, "remaining finger is blocked"); + + auto remaining_move = router.handle(event(PointerPhase::Move, DeviceKind::Finger, 2)); + expect(remaining_move.contains(RouterAction::Consume), "remaining single finger cannot continue navigation"); + + auto remaining_up = router.handle(event(PointerPhase::Up, DeviceKind::Finger, 2)); + expect(remaining_up.owner == InputOwner::None, "touch sequence fully releases ownership"); +} + +void test_pen_during_touch_is_ignored() +{ + InputRouter router; + (void)router.handle(event(PointerPhase::Down, DeviceKind::Finger, 1)); + (void)router.handle(event(PointerPhase::Down, DeviceKind::Finger, 2)); + + auto pen_down = router.handle(event(PointerPhase::Down, DeviceKind::Pen, 9)); + expect(pen_down.owner == InputOwner::TouchTransform, "pen cannot steal an active touch transform"); + expect(pen_down.contains(RouterAction::Consume), "pen sequence is ignored while touch owns canvas"); + + auto pen_up = router.handle(event(PointerPhase::Up, DeviceKind::Pen, 9)); + expect(pen_up.contains(RouterAction::Consume), "ignored pen up is not forwarded"); +} + +void test_cancel_aborts_once() +{ + InputRouter router; + InteractionSession session; + expect(session.arm(), "session can arm from idle"); + expect(session.activate(), "session can become active"); + + (void)router.handle(event(PointerPhase::Down, DeviceKind::Pen, 4)); + auto cancel = router.handle(event(PointerPhase::Cancel, DeviceKind::Pen, 4)); + expect(cancel.contains(RouterAction::AbortInteraction), "pen cancel emits abort"); + expect(session.abort(), "first abort transitions session"); + expect(session.state() == InteractionState::Aborted, "session is aborted"); + + auto late_up = router.handle(event(PointerPhase::Up, DeviceKind::Pen, 4)); + expect(!late_up.contains(RouterAction::ForwardPenUp), "late up after cancel is not forwarded"); + expect(!session.complete(), "late release cannot commit an aborted session"); + expect(!session.abort(), "duplicate abort is ignored"); +} + +void test_lifecycle_cancel_all() +{ + InputRouter router; + (void)router.handle(event(PointerPhase::Down, DeviceKind::Pen, 5)); + auto result = router.cancel_all(); + expect(result.contains(RouterAction::AbortInteraction), "lifecycle cancel aborts pen interaction"); + expect(result.owner == InputOwner::None, "lifecycle cancel clears ownership"); + expect(router.active_touch_count() == 0U, "lifecycle cancel clears touch registry"); + expect(!router.pen_interaction_active(), "lifecycle cancel clears pen registry"); +} + +void test_session_terminal_rules() +{ + InteractionSession session; + expect(session.arm(), "arm succeeds"); + expect(session.complete(), "armed click can complete without drag activation"); + expect(session.terminal(), "committed session is terminal"); + expect(!session.abort(), "committed session cannot abort"); + expect(session.reset(), "terminal session can reset"); + expect(session.state() == InteractionState::Idle, "reset returns to idle"); + expect(!session.activate(), "idle session cannot activate without arm"); +} + +} // namespace + +int main() +{ + test_pen_exclusivity(); + test_two_finger_navigation(); + test_pen_during_touch_is_ignored(); + test_cancel_aborts_once(); + test_lifecycle_cancel_all(); + test_session_terminal_rules(); + + if (failures != 0) { + std::cerr << failures << " test assertion(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "tablet input core tests: OK\n"; + return EXIT_SUCCESS; +} diff --git a/deps/android-target-dependencies.toml b/deps/android-target-dependencies.toml new file mode 100644 index 0000000..a61e7aa --- /dev/null +++ b/deps/android-target-dependencies.toml @@ -0,0 +1,89 @@ +format_version = 1 +status = "research-baseline" +source_commit = "85e98c11b8eb9c6cdbd46df509f99f6100056166" +source_note = "Inspected through the facsimiles/inkscape mirror; verify the same SHA exists in official GitLab before validation." + +# Packages that must already exist in the Android target sysroot before Inkscape CMake configure. +# Inkscape's fallback ExternalProject builds for glibmm/gtkmm are not cross-compilation safe. +[required_pkg_config] +packages = [ + "gtk4 >= 4.14.0", + "gtkmm-4.0 >= 4.13.3", + "glibmm-2.68 >= 2.78.1", + "giomm-2.68", + "cairomm-1.16", + "pangomm-2.48", + "gdk-pixbuf-2.0", + "graphene-1.0", + "harfbuzz >= 2.6.5", + "pangocairo >= 1.44", + "pangoft2", + "fontconfig", + "gmodule-2.0", + "bdw-gc", + "lcms2", + "icu-uc", +] + +[required_cmake_or_find_modules] +packages = [ + "GSL", + "double-conversion", + "Iconv", + "Intl", + "JPEG (optional display support)", + "PNG", + "Potrace", + "Boost headers and stacktrace strategy", + "LibXml2", + "LibXslt", + "ZLIB", +] + +[recommended_internal] +packages = [ + "2Geom", + "Uemf", + "Depixelize", + "Autotrace", + "Adaptagrams", +] +notes = "Internal copies reduce target package count, but each must be checked for Android-specific build assumptions." + +[disabled_for_mvp] +cmake_options = [ + "WITH_POPPLER=OFF", + "WITH_CAPYPDF=OFF", + "WITH_IMAGE_MAGICK=OFF", + "WITH_GRAPHICS_MAGICK=OFF", + "WITH_LIBCDR=OFF", + "WITH_LIBVISIO=OFF", + "WITH_LIBWPG=OFF", + "WITH_LIBSPELLING=OFF", + "WITH_GSOURCEVIEW=OFF", + "WITH_GNU_READLINE=OFF", + "WITH_NLS=OFF", + "BUILD_TESTING=OFF", +] + +[host_tools] +packages = [ + "cmake >= 3.24", + "meson >= 1.9 for android_exe_type application host", + "ninja", + "pkg-config", + "python3", + "glib-compile-resources", + "glib-compile-schemas", + "glslc", + "mm-common host generators when building C++ bindings from Git", +] + +[hard_invariants] +items = [ + "No target compile or link flag may reference /opt/homebrew, /usr/local/Cellar, or macOS frameworks.", + "gtkmm and glibmm must be found before Inkscape configure; do not permit the native ExternalProject fallback.", + "All C++ target libraries must use the same NDK libc++_shared runtime.", + "The sysroot must be relocatable and contain no CI workspace absolute paths.", + "Only arm64-v8a is supported through D1.", +] diff --git a/docs/p1-device-test-protocol.md b/docs/p1-device-test-protocol.md new file mode 100644 index 0000000..699cee5 --- /dev/null +++ b/docs/p1-device-test-protocol.md @@ -0,0 +1,206 @@ +# P1 小米平板输入测试协议 + +目标设备:小米平板 7 Ultra + 小米焦点触控笔。 + +本协议用于产生可复核的原始输入证据。测试者不得根据屏幕表现修改 JSONL,也不得把“没有观察到事件”直接解释为设备不支持。 + +## 1. 测试前记录 + +每轮测试记录: + +```text +测试编号 +日期与时区 +测试者 +平板型号与序列标识(可匿名化) +HyperOS / Android 版本 +Build fingerprint +输入探针 APK SHA-256 +输入探针应用版本 +焦点触控笔型号/固件/电量(系统可见时) +屏幕刷新率设置 +横屏/竖屏 +是否连接键盘、鼠标、USB、充电器 +是否开启悬浮窗、分屏、游戏加速或省电模式 +``` + +建议: + +- 固定横屏; +- 使用 USB ADB; +- 关闭自动旋转、分屏和悬浮窗; +- 保持屏幕刷新率配置不变; +- 测试前重启探针应用; +- 不同时运行 Mi Canvas 或其他触控笔应用。 + +## 2. 获取 APK + +从 PR 的 `Input probe build` 工作流下载 `input-probe-debug` artifact。 + +记录 APK SHA-256: + +```sh +shasum -a 256 app-debug.apk +adb install -r app-debug.apk +``` + +## 3. 日志隔离 + +探针目前把多个 session 追加到同一个文件。每轮正式采集前清空旧日志: + +```sh +PACKAGE=io.github.lightchainr.inkscapeandroid.inputprobe +adb shell am force-stop "$PACKAGE" +adb shell run-as "$PACKAGE" rm -f files/input-probe/events.jsonl +adb shell am start -n "$PACKAGE/.MainActivity" +``` + +看到屏幕上的新 session UUID 后再开始动作。 + +## 4. 场景序列 + +每个场景开始前静止两秒,完成后静止两秒。操作记录模板见 `tests/input-fixtures/p1-session-notes-template.csv`。 + +### P1-01 笔悬停 + +1. 笔从屏幕外进入悬停范围; +2. 沿水平线慢速悬停; +3. 离开悬停范围; +4. 重复三次。 + +观察字段:hover enter/move/exit、toolType、distance、tilt、orientation、buttons。 + +### P1-02 笔轻点 + +在五个不同位置轻点,每次完全离开屏幕。 + +观察字段:DOWN/UP、pressure 最小值、pointer ID 复用、历史采样。 + +### P1-03 慢速直线拖动 + +笔接触后用约两秒横向拖动,再正常抬笔,重复三次。 + +观察字段:MOVE 间隔、historySize、pressure、tilt、坐标单调性。 + +### P1-04 快速拖动 + +执行短距离快速拖动,重复五次。 + +观察字段:historical samples、队列 drop、事件间隔尾部。 + +### P1-05 压力变化 + +保持位置基本不变,从轻压逐渐加压再减压。不得使用压力触发点击逻辑。 + +观察字段:pressure 范围、噪声、是否大于 1、是否有明显死区。 + +### P1-06 侧键与橡皮端 + +分别在悬停和接触状态按每个可用按钮;若有橡皮端则测试。 + +观察字段:buttonState、ACTION_BUTTON_PRESS/RELEASE、toolType=ERASER。 + +### P1-07 单指 + +单指点按、慢拖、长按后抬起。 + +观察字段:FINGER tool type、touch major/minor、classification、flags。 + +### P1-08 双指 + +两指同时落下,依次执行: + +- 同向平移; +- pinch in/out; +- 小角度旋转; +- 正常全部抬起。 + +观察字段:pointer IDs、POINTER_DOWN/UP action pointer、历史采样。 + +### P1-09 两指变一指 + +双指移动过程中先抬起一根,剩余一根继续移动一秒再抬起。 + +该场景用于验证后续 `BlockUntilAllTouchUp`,探针本身不做策略判断。 + +### P1-10 笔先落下,再放手掌 + +1. 笔开始慢速拖动; +2. 保持笔接触,把书写手掌自然放到屏幕; +3. 继续拖动; +4. 抬笔和手掌。 + +重复五次,改变手掌接触位置。 + +观察字段:新增 finger pointer、ACTION_CANCEL、FLAG_CANCELED、pointer up 顺序。 + +### P1-11 手指先落下,再落笔 + +一根手指保持接触,然后笔落下并移动,最后分别抬起。 + +用于确认混合 tool type 的事件分发和 pointer sequence。 + +### P1-12 双指期间落笔 + +双指移动时落笔、移动、抬笔,再结束双指。 + +用于确认笔是否进入同一 MotionEvent、独立 pointer source,或被系统抑制。 + +### P1-13 生命周期中断 + +笔拖动期间分别触发: + +- 下拉系统面板; +- Home/切后台; +- 锁屏; +- Activity 失去焦点。 + +每种中断单独测试并重新启动应用。 + +观察字段:最后一个 MotionEvent 是否为 CANCEL/UP、应用暂停前日志是否 flush。 + +### P1-14 长时间压力测试 + +连续快速笔移动与双指操作至少五分钟。 + +唯一目标:确认 `drop` 记录是否出现、文件是否持续可解析、应用是否卡死。 + +## 5. 导出证据 + +```sh +PACKAGE=io.github.lightchainr.inkscapeandroid.inputprobe +adb shell am force-stop "$PACKAGE" +adb exec-out run-as "$PACKAGE" \ + cat files/input-probe/events.jsonl > events.jsonl + +shasum -a 256 events.jsonl > events.jsonl.sha256 +python3 tools/analyze_input_probe.py events.jsonl \ + --strict \ + --json-out events.summary.json +``` + +同时保存: + +- `adb shell getprop > device-getprop.txt`; +- `adb shell dumpsys input > dumpsys-input.txt`; +- `adb shell dumpsys display > dumpsys-display.txt`; +- 场景记录 CSV; +- APK SHA-256; +- analyzer summary。 + +涉及隐私或设备序列号的字段可在设备报告中脱敏,但原始事件时间和输入字段不得修改。 + +## 6. P1 通过条件 + +必须满足: + +- JSONL schema/analyzer 无错误; +- `droppedRecords = 0`; +- 有且仅有本轮预期 session; +- 关键场景能根据时间和 action 序列定位; +- pen 和 finger 的 tool/source 行为有原始记录; +- cancel/palm 行为以“观察到/未观察到/不确定”描述,不猜测; +- 报告列出设备未暴露的字段和零值轴; +- 证据包有 SHA-256 和复现命令。 + +P1 通过不代表输入策略已经实现。只有 P3 GTK Interaction Lab 才验证 owner 和 abort 行为。 diff --git a/docs/pre-engineering-assessment.md b/docs/pre-engineering-assessment.md new file mode 100644 index 0000000..32842b2 --- /dev/null +++ b/docs/pre-engineering-assessment.md @@ -0,0 +1,213 @@ +# 前期工程评估 + +本文记录在进入 GTK Interaction Lab 和 Inkscape Android 集成前,已经核实的源码事实、工程约束和验证顺序。 + +## 1. 当前结论 + +项目不应立即修改 Inkscape 的选择工具。正确顺序是: + +```text +平台无关输入/事务核心 + ↓ +小米设备原始输入数据 + ↓ +GTK Android Interaction Lab + ↓ +Inkscape 启动、资源和只读 Canvas + ↓ +选择、移动、Undo、显式 Abort +``` + +在进入下一层之前,上一层必须有自动测试或真机日志证据。 + +## 2. 已核实的 Inkscape 源码事实 + +研究候选:`85e98c11b8eb9c6cdbd46df509f99f6100056166`。最终使用前必须确认该 SHA 可从 Inkscape 官方 GitLab 获取。 + +### 2.1 构建与语言 + +- CMake 最低版本为 3.24。 +- C++ 标准为 C++20。 +- 当前源码版本标识为 1.5-dev。 +- Inkscape 已经存在 `if(ANDROID)` 分支:桌面端创建 executable,Android 创建 library。 +- `inkscape_base` 已经包含 Document、Selection、SelTrans、DocumentUndo、Canvas/UI 等大部分核心源文件。 + +这意味着不需要从零发明一个“核心 SDK”,但需要处理以下边界: + +- `inkscape-main.cpp` 仍定义普通 `main()`;薄 Host 不能同时定义另一个冲突的 `main()`。 +- Android 路径需要将入口改成可调用函数,例如 `inkscape_android_run()`,或用一个不冲突的 wrapper target。 +- 当前启动代码设置 PATH、PYTHONPATH 和 XDG_DATA_DIRS;Android MVP 必须禁用扩展环境设置,并使用 asset/resource resolver。 + +### 2.2 C++ GTK 绑定是硬前置 + +当前 Inkscape 要求: + +- GTK 4.14+ +- gtkmm 4.13.3+ +- glibmm 2.78.1+ +- cairomm 1.16 +- pangomm 2.48 + +若系统找不到 glibmm/gtkmm,Inkscape 会用 CMake `ExternalProject` 下载并构建它们。该回退命令没有 Android cross-file,也依赖 host 端生成工具,不适用于本项目。 + +因此: + +> Android sysroot 必须在 Inkscape CMake configure 之前提供完整 gtkmm/glibmm;找不到时必须 fail fast,禁止进入上游 ExternalProject 回退。 + +对应机器检查:`scripts/check-inkscape-cross-preconditions.py`。 + +### 2.3 MVP 目标依赖面仍然较大 + +即使关闭 PDF、CDR、Visio、拼写检查和脚本扩展,基础 SVG 编辑仍依赖 GTK/gtkmm、Pango、HarfBuzz、Fontconfig、Graphene、Cairo、GLib/GIO、ICU、GSL、Boehm GC、LCMS2、double-conversion、LibXML2、LibXSLT、PNG、Potrace、Boost 和 2Geom 等。 + +依赖分层记录在 `deps/android-target-dependencies.toml`。P2 的主要工作不是“编译 Inkscape”,而是生成一个可重定位、无 host 污染、ABI 一致的目标 sysroot。 + +## 3. 已核实的 GTK Android 约束 + +研究候选:`939826ae53d9b3029270546d6f3a4fb7988a7f85`。 + +GTK Android 主线已经提供: + +- Android GDK backend 和 runtime; +- SurfaceView/GTK Surface 桥接; +- finger touch sequence; +- stylus、eraser、hover、pressure、distance 和 tilt; +- Android 文件选择器和 content URI GFile。 + +仍需补齐: + +1. Pointer-source stylus `ACTION_CANCEL` 当前会退化为 button release,不能表达“回滚”。 +2. Android 13+ `ACTION_POINTER_UP + FLAG_CANCELED` 需要保留为取消语义。 +3. Java glue 使用 `MotionEvent.obtainNoHistory()`,未来自由笔迹需要重新评估;基础对象选择和移动暂不依赖 history。 +4. 所有取消通知必须早于工具的正常 release 提交。 + +## 4. 输入探针的测量设计 + +现有探针 v0.1 可以验证事件是否出现,但在 UI 线程上构造 JSON 并同步写盘,会扰动高频事件和延迟测量。 + +探针 v1 必须采用: + +```text +MotionEvent callback + ↓ 只复制 primitive snapshot +有界内存队列 + ↓ +后台线程 JSON 编码和写盘 +``` + +必须记录: + +- session/build/device/display/input-device 元数据; +- current 与 historical sample; +- pointer ID、tool type、source、button、flags; +- event time 与 capture time,且明确时钟来源; +- queue drop 记录; +- schemaVersion 和严格递增 sequence。 + +数据契约:`schemas/input-probe-v1.schema.json`。 +离线检查:`tools/analyze_input_probe.py`。 + +重要限制:`System.nanoTime()` 和 MotionEvent event time 不应在未确认时钟基准前直接相减。日志同时记录 monotonic capture time 和 uptime-based time,分析器只使用明确可比较的字段。 + +## 5. 输入与事务核心 + +`core/tablet-input` 是平台无关的 C++20 参考核心,不依赖 GTK、Android 或 Inkscape。它提前锁定以下不变量: + +- 笔拥有对象编辑权时,finger 不能抢占; +- 第一根 finger 只进入 pending,不执行操作; +- 第二根 finger 才开始 viewport transform; +- 双指中任一 finger 离开即结束手势,剩余单指被阻塞; +- touch 已获得 owner 时,新 pen sequence 整体忽略; +- cancel 与 normal up 是不同动作; +- abort 后迟到的 up 不能提交; +- complete/abort 均只能成功一次。 + +该核心不是最终 GTK adapter,而是 GTK Interaction Lab 和 Inkscape adapter 的可执行规范。 + +## 6. 构建拓扑评估 + +### 权威路径 + +Linux x86_64 CI 负责: + +- GTK/Pixiewood 和目标依赖; +- 可重定位 arm64 sysroot; +- GTK Interaction Lab APK; +- 最终 Inkscape native/Host APK 的可重复构建。 + +Mac 负责: + +- 代码、补丁和文档操作; +- Android Studio; +- ADB/LLDB/Perfetto; +- 真机测试和日志采集; +- 可选的 NDK 增量编译加速。 + +Mac 本地完整 native 构建不是 P0/P1 的前置条件。 + +### 为什么不直接套 Pixiewood + +Pixiewood要求应用是 Meson `android_exe_type: 'application'` target,而 Inkscape 使用 CMake。它还包含 Linux host 假设。因此合理边界是: + +- Pixiewood/GTK CI 负责 runtime、sysroot 和 package template; +- Inkscape继续由 CMake构建; +- 薄 Meson Host 仅提供 GTK Android application entry,并调用不冲突的 Inkscape Android 入口。 + +## 7. 技术关卡 + +### Gate A:前期核心 + +必须同时通过: + +- C++ input core 在 Linux/macOS 编译并通过测试; +- source candidates 格式校验; +- dependency manifest 格式校验; +- input log analyzer 测试; +- input probe debug APK 编译通过。 + +### Gate B:设备数据 + +必须获得小米平板上的原始 JSONL,至少覆盖: + +- pen down/move/up; +- hover; +- 一指、双指和两指变一指; +- pen + palm; +- touch 后落笔; +- 应用暂停和系统面板中断; +- canceled pointer 或 ACTION_CANCEL(若设备产生)。 + +### Gate C:GTK Interaction Lab + +必须证明: + +- 同一个 input core 可由 GDK adapter 驱动; +- pen drag 可 complete; +- Android cancel 可 abort; +- cancel 后 late release 不提交; +- 双指只修改 viewport; +- 生命周期中断后 owner、grab 和 interaction 全部清空。 + +只有 Gate C 通过后,才允许修改 Inkscape Select Tool 的 Android 适配。 + +## 8. 当前不应实施 + +- 不创建 Kotlin SVG 文档模型; +- 不复制 Inkscape hit testing; +- 不实现节点编辑、文本编辑或压感绘制; +- 不绑定小米专有按钮; +- 不把 gtkmm 的本机构建回退当作 Android 方案; +- 不把候选 SHA 直接标记为 validated; +- 不在没有 drop 计数的数据上声称采样率或延迟结论。 + +## 9. 参考源码 + +- Inkscape CMake: https://gitlab.com/inkscape/inkscape +- GTK Android: https://gitlab.gnome.org/GNOME/gtk +- Pixiewood: https://github.com/sp1ritCS/gtk-android-builder +- Krita input manager: https://github.com/KDE/krita +- Butterfly viewport input: https://github.com/LinwoodDev/Butterfly +- Graphite select tool FSM: https://github.com/GraphiteEditor/Graphite +- SVG-Edit history recording: https://github.com/SVG-Edit/svgedit + +第三方项目默认仅作为架构参考;复制代码前必须单独检查许可证。 diff --git a/docs/stage-gates.md b/docs/stage-gates.md index d230386..c3f3457 100644 --- a/docs/stage-gates.md +++ b/docs/stage-gates.md @@ -2,12 +2,36 @@ 每个阶段只解决一个核心不确定性。前一阶段没有可复核证据,不进入下一阶段。 +## P0:前期工程基线 + +P0 不再作为单一“仓库初始化”任务处理,而是拆成四个必须全部通过的子关卡。 + +| 子关卡 | 单一目标 | 最小通过证据 | +|---|---|---| +| P0-A | 仓库、Mac 操作和 CI 可复现 | `doctor`、scaffold check、PR 证据链、禁止提交生成物 | +| P0-B | 锁定输入所有权与事务不变量 | C++20 input core 在 Linux/macOS 测试通过 | +| P0-C | 锁定研究源码与目标依赖边界 | 官方远端 SHA 可达报告、依赖 manifest、sysroot fail-fast 工具 | +| P0-D | 建立可信的原始输入采集 | versioned schema、后台有界写盘、drop 记录、analyzer 测试、可安装 probe APK | + +P0 完成标准: + +```text +Scaffold check PASS +Pre-engineering / Ubuntu PASS +Pre-engineering / macOS PASS +Source candidate official reachability PASS +Input probe lint/test/APK build PASS +``` + +P0 只证明“工程和测量工具可信”,不证明小米设备行为,也不证明 GTK/Inkscape 可移植。 + +## 主阶段 + | 阶段 | 单一目标 | 最小通过证据 | |---|---|---| -| P0 | 建立 Mac 操作环境和仓库框架 | `doctor` 输出、CI scaffold check、可安装空白或探针 APK | -| P1 | 确认小米平板与焦点笔的原始事件 | JSONL 日志、设备能力矩阵、cancel/palm 序列 | +| P1 | 确认小米平板与焦点笔的原始事件 | schema-valid JSONL、设备能力矩阵、cancel/palm 序列、drop=0 | | P2 | 建立 GTK Android 权威参考平台 | GTK demo APK、可重定位 arm64 sysroot、build manifest | -| P3 | 在 GTK Interaction Lab 验证输入与事务 | pen/touch 路由、显式 complete/abort、生命周期重置 | +| P3 | 在 GTK Interaction Lab 验证输入与事务 | 同一 input core 的 GDK adapter、pen/touch 路由、显式 complete/abort、生命周期重置 | | P4 | 启动薄 Host 与 Inkscape 主循环 | APK 进入主循环、资源路径可诊断、前后台不崩溃 | | P5 | 只读显示 Inkscape Canvas | 内置 SVG 正确显示、density/坐标/Surface 恢复通过 | | P6 | 仅选择对象 | pen 点击选择、空白取消、手指不能修改对象 | @@ -21,8 +45,11 @@ ## 阶段不变量 +- P0-D 通过前,不使用探针日志推导采样率或延迟。 - P1 前不推测厂商笔能力。 +- P2 的 `sources.lock.toml` 只有兼容构建通过后才能标记 `validated`。 - P3 前不接入 Inkscape。 +- P3 必须复用或行为等价于 `core/tablet-input`,不能在 GTK adapter 中另写一套 owner 规则。 - P6 只允许选择,禁止拖动对象。 - P7 只验证正常完成,不宣称取消可靠。 - P8 通过前,不增加双指导航和外部文件写回。 @@ -39,6 +66,17 @@ APK SHA-256 复现命令 关键日志或测试输出 已知限制 +对应 schema / lock / patch-series hash +``` + +P1 证据包还必须包含: + +```text +原始 events.jsonl +analyzer JSON summary +droppedRecords = 0 +场景操作记录 +输入设备 motion ranges ``` 阶段完成不是“看起来能用”,而是另一台干净环境可以根据证据重复验证。 diff --git a/schemas/input-probe-v1.schema.json b/schemas/input-probe-v1.schema.json new file mode 100644 index 0000000..9d226d2 --- /dev/null +++ b/schemas/input-probe-v1.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/LightChainr/InkscapeAndroid/schemas/input-probe-v1.schema.json", + "title": "Inkscape Android input probe JSONL record", + "oneOf": [ + { "$ref": "#/$defs/session" }, + { "$ref": "#/$defs/event" }, + { "$ref": "#/$defs/drop" } + ], + "$defs": { + "base": { + "type": "object", + "required": ["schemaVersion", "recordType", "sessionId"], + "properties": { + "schemaVersion": { "const": 1 }, + "recordType": { "type": "string" }, + "sessionId": { "type": "string", "minLength": 1 } + } + }, + "session": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "type": "object", + "required": ["recordType", "createdWallTimeMillis", "app", "device", "display", "inputDevices"], + "properties": { + "recordType": { "const": "session" }, + "createdWallTimeMillis": { "type": "integer", "minimum": 0 }, + "app": { + "type": "object", + "required": ["versionName", "versionCode", "buildType"], + "properties": { + "versionName": { "type": "string" }, + "versionCode": { "type": "integer" }, + "buildType": { "type": "string" }, + "gitSha": { "type": "string" } + }, + "additionalProperties": true + }, + "device": { + "type": "object", + "required": ["manufacturer", "model", "sdkInt", "release", "fingerprint"], + "additionalProperties": true + }, + "display": { + "type": "object", + "required": ["widthPixels", "heightPixels", "densityDpi", "refreshRateHz"], + "additionalProperties": true + }, + "inputDevices": { "type": "array", "items": { "type": "object" } } + }, + "additionalProperties": true + } + ] + }, + "event": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "type": "object", + "required": [ + "recordType", "sequence", "dispatchPath", "sampleKind", + "captureMonotonicNanos", "captureUptimeMillis", "eventTimeMillis", "eventTimeNanos", "downTimeMillis", + "actionMasked", "actionIndex", "actionButton", "pointerCount", "pointerIndex", "pointerId", "isActionPointer", + "toolType", "source", "deviceId", "displayId", "buttonState", "metaState", "flags", "edgeFlags", "classification", "historySize", + "x", "y", "rawX", "rawY", "pressure", "orientation", "tilt", "distance", "size", + "touchMajor", "touchMinor", "toolMajor", "toolMinor", "xPrecision", "yPrecision" + ], + "properties": { + "recordType": { "const": "event" }, + "sequence": { "type": "integer", "minimum": 0 }, + "dispatchPath": { "enum": ["touch", "hover", "generic"] }, + "sampleKind": { "enum": ["current", "historical"] }, + "captureMonotonicNanos": { "type": "integer", "minimum": 0 }, + "captureUptimeMillis": { "type": "integer", "minimum": 0 }, + "eventTimeMillis": { "type": "integer", "minimum": 0 }, + "eventTimeNanos": { "type": ["integer", "null"], "minimum": 0 }, + "downTimeMillis": { "type": "integer", "minimum": 0 }, + "actionMasked": { "type": "integer" }, + "actionIndex": { "type": "integer", "minimum": 0 }, + "actionButton": { "type": "integer" }, + "pointerCount": { "type": "integer", "minimum": 1 }, + "pointerIndex": { "type": "integer", "minimum": 0 }, + "pointerId": { "type": "integer", "minimum": 0 }, + "isActionPointer": { "type": "boolean" }, + "toolType": { "type": "integer" }, + "source": { "type": "integer" }, + "deviceId": { "type": "integer" }, + "displayId": { "type": "integer" }, + "buttonState": { "type": "integer" }, + "metaState": { "type": "integer" }, + "flags": { "type": "integer" }, + "edgeFlags": { "type": "integer" }, + "classification": { "type": "integer" }, + "historySize": { "type": "integer", "minimum": 0 }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "rawX": { "type": ["number", "null"] }, + "rawY": { "type": ["number", "null"] }, + "pressure": { "type": "number" }, + "orientation": { "type": "number" }, + "tilt": { "type": "number" }, + "distance": { "type": "number" }, + "size": { "type": "number" }, + "touchMajor": { "type": "number" }, + "touchMinor": { "type": "number" }, + "toolMajor": { "type": "number" }, + "toolMinor": { "type": "number" }, + "xPrecision": { "type": "number" }, + "yPrecision": { "type": "number" } + }, + "additionalProperties": true + } + ] + }, + "drop": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "type": "object", + "required": ["recordType", "afterSequence", "droppedRecords", "queueCapacity"], + "properties": { + "recordType": { "const": "drop" }, + "afterSequence": { "type": "integer", "minimum": 0 }, + "droppedRecords": { "type": "integer", "minimum": 1 }, + "queueCapacity": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": true + } + ] + } + } +} diff --git a/scripts/audit-upstream-contracts.py b/scripts/audit-upstream-contracts.py new file mode 100644 index 0000000..541c09c --- /dev/null +++ b/scripts/audit-upstream-contracts.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Audit the upstream source assumptions that the migration architecture depends on.""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class Check: + name: str + relative_path: str + needles: tuple[str, ...] + rationale: str + + +CHECKS: dict[str, tuple[Check, ...]] = { + "inkscape": ( + Check( + "cmake-and-cxx-baseline", + "CMakeLists.txt", + ("cmake_minimum_required(VERSION 3.24.0)", "set(CMAKE_CXX_STANDARD 20)"), + "The Android build overlay and host compiler matrix depend on the current CMake/C++ baseline.", + ), + Check( + "android-library-target", + "src/CMakeLists.txt", + ("if(NOT ANDROID)", "add_library(inkscape ${main_SRC})", "add_library(inkscape_base)"), + "Inkscape already changes its application target shape on Android; the port should extend this rather than rebuild a second core.", + ), + Check( + "gtk-binding-minimums", + "CMakeScripts/DefineDependsandFlags.cmake", + ("gtk4>=4.14.0", "gtkmm-4.0>=4.13.3", "glibmm-2.68>=2.78.1", "pangomm-2.48", "cairomm-1.16"), + "The Android sysroot must provide the same C and C++ GTK stack expected by Inkscape.", + ), + Check( + "unsafe-native-binding-fallback", + "CMakeScripts/DefineDependsandFlags.cmake", + ("ExternalProject_Add(glibmm", "ExternalProject_Add(gtkmm", "meson setup --libdir lib"), + "These native fallback builds do not carry the Android cross files and must be blocked by preflight.", + ), + Check( + "main-entry-and-desktop-environment", + "src/inkscape-main.cpp", + ("int main(int argc, char *argv[])", "set_extensions_env()", "PYTHONPATH", "XDG_DATA_DIRS"), + "A thin Android host needs a non-conflicting callable entry and an Android resource policy.", + ), + ), + "gtk": ( + Check( + "android-backend-options", + "meson.options", + ("option('android-backend'", "option('android-runtime'"), + "The primary architecture requires GTK's mainline Android backend and native runtime.", + ), + Check( + "touch-cancel-preserved", + "gdk/android/gdkandroidevents.c", + ("AMOTION_EVENT_ACTION_CANCEL", "GDK_TOUCH_CANCEL"), + "Finger touch sequences already expose explicit cancellation and should remain the reference path.", + ), + Check( + "stylus-cancel-release-gap", + "gdk/android/gdkandroidevents.c", + ("treat cancel like a", "button up event", "GDK does not", "provide a cancel mechanism"), + "The port needs a narrow stylus cancellation side channel or translator hook.", + ), + Check( + "history-currently-discarded", + "gdk/android/glue/java/org/gtk/android/ToplevelActivity.java", + ("MotionEvent.obtainNoHistory(event)",), + "Basic object editing can proceed without history, but future freehand input cannot assume it is preserved.", + ), + Check( + "android-document-picker", + "gtk/gtkfilechoosernativeandroid.c", + ("action_open_document", "action_create_document", "action_open_document_tree"), + "Open/Save Copy should first validate the existing GTK Android document picker bridge.", + ), + ), + "pixiewood": ( + Check( + "meson-application-contract", + "README.md", + ("android_exe_type: 'application'", "g_application_run"), + "Pixiewood cannot directly replace Inkscape's CMake build; a thin Meson host is required.", + ), + Check( + "linux-host-assumptions", + "pixiewood", + ("linux-x86_64", "nproc"), + "The authoritative dependency build should remain on Linux unless host portability is fixed upstream.", + ), + ), +} + + +def audit_source(root: Path, checks: tuple[Check, ...]) -> tuple[list[dict[str, Any]], list[str]]: + results: list[dict[str, Any]] = [] + errors: list[str] = [] + for check in checks: + path = root / check.relative_path + entry: dict[str, Any] = { + "name": check.name, + "path": str(path), + "rationale": check.rationale, + "needles": list(check.needles), + } + if not path.is_file(): + entry["passed"] = False + entry["missingFile"] = True + errors.append(f"{root.name}:{check.name}: missing {check.relative_path}") + results.append(entry) + continue + text = path.read_text(encoding="utf-8", errors="replace") + missing = [needle for needle in check.needles if needle not in text] + entry["missingNeedles"] = missing + entry["passed"] = not missing + if missing: + errors.append( + f"{root.name}:{check.name}: expected source contracts disappeared: {missing}" + ) + results.append(entry) + return results, errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", type=Path, default=Path(".out/upstream")) + parser.add_argument("--json-out", type=Path) + args = parser.parse_args(argv) + + report: dict[str, Any] = {"workspace": str(args.workspace), "sources": {}, "errors": []} + for source, checks in CHECKS.items(): + results, errors = audit_source(args.workspace / source, checks) + report["sources"][source] = results + report["errors"].extend(errors) + + encoded = json.dumps(report, indent=2, sort_keys=True) + print(encoded) + if args.json_out: + args.json_out.write_text(encoded + "\n", encoding="utf-8") + return 2 if report["errors"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bootstrap-macos.sh b/scripts/bootstrap-macos.sh index 95340c7..5fdc4ab 100644 --- a/scripts/bootstrap-macos.sh +++ b/scripts/bootstrap-macos.sh @@ -1,19 +1,35 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + cat <<'MSG' Recommended Mac host tools: xcode-select --install brew install git git-lfs cmake meson ninja pkg-config ccache python jq zstd openjdk@17 -Android packages (after installing Android command-line tools): +Set the Android SDK and JDK environment: + + export ANDROID_HOME="$HOME/Library/Android/sdk" + export ANDROID_SDK_ROOT="$ANDROID_HOME" + export JAVA_HOME="$(/usr/libexec/java_home -v 17)" + export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + +Install the repository package set: +MSG + +printf ' sdkmanager --sdk_root="$ANDROID_HOME"' +while IFS= read -r package; do + [[ -z "$package" || "$package" == \#* ]] && continue + printf ' \\\n %q' "$package" +done < "$ROOT/sdk-packages.lock" +printf '\n\n' + +cat <<'MSG' +Then accept Android SDK licenses: - sdkmanager --sdk_root="$HOME/Library/Android/sdk" \ - "platform-tools" \ - "platforms;android-36" \ - "build-tools;35.0.0" \ - "ndk;27.2.12479018" + sdkmanager --sdk_root="$ANDROID_HOME" --licenses This script prints commands only. It does not modify the machine automatically. MSG diff --git a/scripts/check-inkscape-cross-preconditions.py b/scripts/check-inkscape-cross-preconditions.py new file mode 100644 index 0000000..67c58cd --- /dev/null +++ b/scripts/check-inkscape-cross-preconditions.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Fail fast before configuring Inkscape for Android. + +This script intentionally checks the target pkg-config environment before CMake can +fall back to native ExternalProject builds of glibmm/gtkmm. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + +FORBIDDEN_HOST_PATHS = ( + "/opt/homebrew", + "/usr/local/Cellar", + "/System/Library/Frameworks", + "/Library/Frameworks", +) + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, capture_output=True, check=False) + + +def package_name(requirement: str) -> str: + return requirement.split()[0] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--manifest", + type=Path, + default=Path("deps/android-target-dependencies.toml"), + ) + parser.add_argument("--pkg-config", default=os.environ.get("PKG_CONFIG", "pkg-config")) + parser.add_argument("--strict-env", action="store_true") + parser.add_argument("--json-out", type=Path) + args = parser.parse_args(argv) + + with args.manifest.open("rb") as handle: + manifest = tomllib.load(handle) + + requirements: list[str] = manifest["required_pkg_config"]["packages"] + result: dict[str, Any] = { + "manifest": str(args.manifest), + "pkgConfig": args.pkg_config, + "environment": { + "PKG_CONFIG_SYSROOT_DIR": os.environ.get("PKG_CONFIG_SYSROOT_DIR", ""), + "PKG_CONFIG_LIBDIR": os.environ.get("PKG_CONFIG_LIBDIR", ""), + "PKG_CONFIG_PATH": os.environ.get("PKG_CONFIG_PATH", ""), + }, + "packages": {}, + "errors": [], + "warnings": [], + } + + if args.strict_env: + for variable in ("PKG_CONFIG_SYSROOT_DIR", "PKG_CONFIG_LIBDIR"): + if not result["environment"][variable]: + result["errors"].append(f"{variable} is not set") + if result["environment"]["PKG_CONFIG_PATH"]: + result["warnings"].append( + "PKG_CONFIG_PATH is set; target builds should normally rely on PKG_CONFIG_LIBDIR" + ) + + version = run([args.pkg_config, "--version"]) + if version.returncode != 0: + result["errors"].append( + f"cannot execute pkg-config wrapper: {version.stderr.strip() or version.stdout.strip()}" + ) + else: + result["pkgConfigVersion"] = version.stdout.strip() + + for requirement in requirements: + name = package_name(requirement) + exists = run([args.pkg_config, "--exists", requirement]) + entry: dict[str, Any] = {"requirement": requirement, "found": exists.returncode == 0} + if exists.returncode == 0: + modversion = run([args.pkg_config, "--modversion", name]) + entry["version"] = modversion.stdout.strip() if modversion.returncode == 0 else None + flags = run([args.pkg_config, "--cflags", "--libs", name]) + entry["flags"] = flags.stdout.strip() if flags.returncode == 0 else "" + contaminated = [path for path in FORBIDDEN_HOST_PATHS if path in entry["flags"]] + if contaminated: + entry["hostPathContamination"] = contaminated + result["errors"].append( + f"{name} contains host paths: {', '.join(contaminated)}" + ) + else: + diagnostic = (exists.stderr or exists.stdout).strip() + entry["diagnostic"] = diagnostic + result["errors"].append(f"missing target package: {requirement}") + result["packages"][name] = entry + + # Inspect the complete link surface too, because a dependency can inject a host path indirectly. + names = [package_name(item) for item in requirements] + aggregate = run([args.pkg_config, "--cflags", "--libs", *names]) + if aggregate.returncode == 0: + result["aggregateFlags"] = aggregate.stdout.strip() + for forbidden in FORBIDDEN_HOST_PATHS: + if forbidden in result["aggregateFlags"]: + result["errors"].append(f"aggregate flags contain host path: {forbidden}") + else: + result["aggregateDiagnostic"] = (aggregate.stderr or aggregate.stdout).strip() + + encoded = json.dumps(result, indent=2, sort_keys=True) + print(encoded) + if args.json_out: + args.json_out.write_text(encoded + "\n", encoding="utf-8") + + if result["errors"]: + print("Android target preflight failed", file=sys.stderr) + return 2 + print("Android target preflight passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/dev b/scripts/dev index 237b87d..b54ba72 100644 --- a/scripts/dev +++ b/scripts/dev @@ -2,20 +2,30 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$ROOT/.out" usage() { cat <<'USAGE' -Usage: bash scripts/dev +Usage: bash scripts/dev [args] Commands: - doctor Check Mac/Android development environment - plan Print the staged technical gates - lock-check Validate lock files without building - repo-check Check that generated/binary paths are not tracked + doctor Check Mac/Android development environment + plan Print the staged technical gates + lock-check Validate lock files without building + candidates-check [--network] Validate candidate SHAs; optionally fetch official remotes + core-test Build and test the platform-independent C++ input core + probe-analyze Validate and summarize an input probe log + target-preflight [args] Check Android sysroot pkg-config dependencies + repo-check Check that generated/binary paths are not tracked + test Run all host-side pre-engineering checks USAGE } cmd="${1:-}" +if [[ $# -gt 0 ]]; then + shift +fi + case "$cmd" in doctor) exec bash "$ROOT/scripts/doctor-macos.sh" @@ -37,6 +47,27 @@ if data["status"] == "validated": assert data[name]["commit"], f"missing validated commit: {name}" PY ;; + candidates-check) + exec python3 "$ROOT/scripts/verify-source-candidates.py" "$@" + ;; + core-test) + build="$OUT/core-tablet-input" + cmake -S "$ROOT/core/tablet-input" -B "$build" -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DTABLET_INPUT_BUILD_TESTS=ON + cmake --build "$build" + ctest --test-dir "$build" --output-on-failure + ;; + probe-analyze) + if [[ $# -ne 1 ]]; then + echo "probe-analyze requires one JSONL path" >&2 + exit 2 + fi + exec python3 "$ROOT/tools/analyze_input_probe.py" "$1" --strict + ;; + target-preflight) + exec python3 "$ROOT/scripts/check-inkscape-cross-preconditions.py" "$@" + ;; repo-check) bad="$(git -C "$ROOT" ls-files | grep -E '(^|/)(\.work|\.out|\.cache|build|_build|\.pixiewood)(/|$)|\.(apk|aab|so|a|dylib)$' || true)" if [[ -n "$bad" ]]; then @@ -46,6 +77,13 @@ PY fi echo "repository tracking policy: OK" ;; + test) + bash "$ROOT/scripts/dev" lock-check + bash "$ROOT/scripts/dev" candidates-check + python3 -m unittest discover -s "$ROOT/tools/tests" -p 'test_*.py' -v + bash "$ROOT/scripts/dev" core-test + bash "$ROOT/scripts/dev" repo-check + ;; *) usage exit 2 diff --git a/scripts/doctor-macos.sh b/scripts/doctor-macos.sh index 68bdc37..cc23d5e 100644 --- a/scripts/doctor-macos.sh +++ b/scripts/doctor-macos.sh @@ -1,60 +1,106 @@ #!/usr/bin/env bash set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" failures=0 warnings=0 +ok() { printf 'OK %-24s %s\n' "$1" "${2:-}"; } +warn() { printf 'WARN %-24s %s\n' "$1" "${2:-}"; warnings=$((warnings + 1)); } +miss() { printf 'MISS %-24s %s\n' "$1" "${2:-}"; failures=$((failures + 1)); } + check_cmd() { local cmd="$1" if command -v "$cmd" >/dev/null 2>&1; then - printf 'OK %-18s %s\n' "$cmd" "$(command -v "$cmd")" + ok "$cmd" "$(command -v "$cmd")" else - printf 'MISS %-18s\n' "$cmd" - failures=$((failures + 1)) + miss "$cmd" fi } printf 'Host: %s %s\n' "$(uname -s)" "$(uname -m)" -[[ "$(uname -s)" == "Darwin" ]] || { echo 'WARN This script is designed for macOS'; warnings=$((warnings + 1)); } +[[ "$(uname -s)" == "Darwin" ]] || warn "host OS" "script is designed for macOS" if xcode-select -p >/dev/null 2>&1; then - echo "OK Xcode CLI $(xcode-select -p)" + ok "Xcode CLI" "$(xcode-select -p)" else - echo "MISS Xcode CLI" - failures=$((failures + 1)) + miss "Xcode CLI" "run xcode-select --install" fi -for cmd in git python3 java adb cmake meson ninja pkg-config; do +for cmd in git python3 java adb sdkmanager cmake meson ninja pkg-config ccache jq zstd; do check_cmd "$cmd" done +if command -v java >/dev/null 2>&1; then + java_version="$(java -version 2>&1 | head -n 1)" + if [[ "$java_version" == *'"17.'* || "$java_version" == *'"17"'* ]]; then + ok "JDK major" "$java_version" + else + miss "JDK major" "expected JDK 17, got $java_version" + fi +fi + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" -NDK_VERSION="27.2.12479018" +NDK_VERSION="$(python3 - "$ROOT/sources.lock.toml" <<'PY' +import sys, tomllib +with open(sys.argv[1], 'rb') as handle: + print(tomllib.load(handle)['android']['ndk']) +PY +)" NDK_ROOT="${ANDROID_NDK_ROOT:-$ANDROID_HOME/ndk/$NDK_VERSION}" -CLANG="$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang++" +NDK_HOST_TAG="darwin-x86_64" +CLANG="$NDK_ROOT/toolchains/llvm/prebuilt/$NDK_HOST_TAG/bin/clang++" + +printf 'INFO %-24s %s\n' "ANDROID_HOME" "$ANDROID_HOME" +printf 'INFO %-24s %s\n' "ANDROID_NDK_ROOT" "$NDK_ROOT" -printf 'INFO ANDROID_HOME %s\n' "$ANDROID_HOME" -printf 'INFO ANDROID_NDK_ROOT %s\n' "$NDK_ROOT" +[[ -d "$ANDROID_HOME" ]] && ok "Android SDK root" "$ANDROID_HOME" || miss "Android SDK root" "$ANDROID_HOME" +[[ -x "$ANDROID_HOME/platform-tools/adb" ]] && ok "platform-tools" || miss "platform-tools" +[[ -f "$ANDROID_HOME/platforms/android-36/android.jar" ]] && ok "platform android-36" || miss "platform android-36" +[[ -x "$ANDROID_HOME/build-tools/36.0.0/aapt2" ]] && ok "build-tools 36.0.0" || miss "build-tools 36.0.0" +[[ -x "$ANDROID_HOME/build-tools/35.0.0/aapt2" ]] && ok "build-tools 35.0.0" || warn "build-tools 35.0.0" "needed only by current GTK/Pixiewood candidate" +[[ -x "$CLANG" ]] && ok "NDK clang++" "$CLANG" || miss "NDK clang++" "$CLANG" -if [[ -x "$CLANG" ]]; then - echo "OK NDK clang++ $CLANG" +if command -v gradle >/dev/null 2>&1; then + ok "gradle" "$(gradle --version 2>/dev/null | awk '/Gradle / {print $2; exit}')" else - echo "MISS NDK clang++ $CLANG" - failures=$((failures + 1)) + warn "gradle" "not on PATH; Android Studio or CI can still build the probe" fi if command -v adb >/dev/null 2>&1; then - echo '--- adb devices ---' - adb devices -l || true + device_count="$(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" {count++} END {print count+0}')" + if [[ "$device_count" -gt 0 ]]; then + ok "ADB authorized devices" "$device_count" + adb devices -l || true + else + warn "ADB authorized devices" "none connected" + fi fi for var in PKG_CONFIG_PATH CMAKE_PREFIX_PATH DYLD_LIBRARY_PATH; do value="${!var:-}" - if [[ "$value" == *'/opt/homebrew'* || "$value" == *'/usr/local/Cellar'* ]]; then - echo "WARN $var may contaminate Android target lookup: $value" - warnings=$((warnings + 1)) + if [[ "$value" == *'/opt/homebrew'* || "$value" == *'/usr/local/Cellar'* || "$value" == *'/System/Library/Frameworks'* ]]; then + warn "$var" "may contaminate Android target lookup: $value" fi done +if command -v df >/dev/null 2>&1; then + free_kb="$(df -Pk "$ROOT" | awk 'NR == 2 {print $4}')" + if [[ "$free_kb" =~ ^[0-9]+$ ]]; then + free_gb=$((free_kb / 1024 / 1024)) + if [[ "$free_gb" -ge 30 ]]; then + ok "free disk" "${free_gb} GiB" + else + warn "free disk" "${free_gb} GiB; native sysroot work should reserve at least 30 GiB" + fi + fi +fi + +if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + ok "repository" "$(git -C "$ROOT" rev-parse --show-toplevel)" +else + warn "repository" "script is not running from a Git checkout" +fi + printf 'Summary: failures=%d warnings=%d\n' "$failures" "$warnings" [[ "$failures" -eq 0 ]] diff --git a/scripts/materialize-source-candidates.py b/scripts/materialize-source-candidates.py new file mode 100644 index 0000000..42d4300 --- /dev/null +++ b/scripts/materialize-source-candidates.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Fetch immutable source candidates into a disposable workspace.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) + + +def materialize(name: str, source: dict[str, Any], output: Path) -> dict[str, Any]: + target = output / name + if target.exists(): + shutil.rmtree(target) + target.mkdir(parents=True) + + result: dict[str, Any] = { + "name": name, + "url": source["url"], + "requestedCommit": source["commit"], + "path": str(target), + } + + commands = [ + ["git", "init", "--quiet"], + ["git", "remote", "add", "origin", source["url"]], + ["git", "fetch", "--quiet", "--depth=1", "origin", source["commit"]], + ["git", "checkout", "--quiet", "--detach", "FETCH_HEAD"], + ] + for command in commands: + completed = run(command, cwd=target) + if completed.returncode != 0: + result["error"] = (completed.stderr or completed.stdout).strip() + result["failedCommand"] = command + return result + + resolved = run(["git", "rev-parse", "HEAD"], cwd=target) + if resolved.returncode != 0: + result["error"] = resolved.stderr.strip() + return result + result["resolvedCommit"] = resolved.stdout.strip() + result["ok"] = result["resolvedCommit"] == source["commit"] + if not result["ok"]: + result["error"] = "resolved commit does not match requested commit" + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, default=Path("sources.candidates.toml")) + parser.add_argument("--output", type=Path, default=Path(".out/upstream")) + parser.add_argument("--json-out", type=Path) + args = parser.parse_args(argv) + + with args.manifest.open("rb") as handle: + manifest = tomllib.load(handle) + + args.output.mkdir(parents=True, exist_ok=True) + report: dict[str, Any] = {"output": str(args.output), "sources": {}, "errors": []} + for name in ("inkscape", "gtk", "pixiewood"): + source = manifest[name] + entry = materialize(name, source, args.output) + report["sources"][name] = entry + if not entry.get("ok"): + report["errors"].append(f"failed to materialize {name}") + + encoded = json.dumps(report, indent=2, sort_keys=True) + print(encoded) + if args.json_out: + args.json_out.write_text(encoded + "\n", encoding="utf-8") + return 2 if report["errors"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-source-candidates.py b/scripts/verify-source-candidates.py new file mode 100644 index 0000000..2c4eef1 --- /dev/null +++ b/scripts/verify-source-candidates.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Validate immutable upstream candidate SHAs, optionally fetching them from their remotes.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path +from typing import Any + +SHA1 = re.compile(r"^[0-9a-f]{40}$") + + +def run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) + + +def verify_remote(name: str, source: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {"url": source["url"], "commit": source["commit"]} + with tempfile.TemporaryDirectory(prefix=f"source-{name}-") as directory: + work = Path(directory) + initialized = run(["git", "init", "--quiet"], cwd=work) + if initialized.returncode != 0: + result["error"] = initialized.stderr.strip() + return result + fetched = run( + ["git", "fetch", "--quiet", "--depth=1", source["url"], source["commit"]], + cwd=work, + ) + if fetched.returncode != 0: + result["error"] = fetched.stderr.strip() or fetched.stdout.strip() + return result + resolved = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], cwd=work) + if resolved.returncode != 0: + result["error"] = resolved.stderr.strip() + return result + result["resolvedCommit"] = resolved.stdout.strip() + result["reachable"] = result["resolvedCommit"] == source["commit"] + if not result["reachable"]: + result["error"] = "FETCH_HEAD did not resolve to requested commit" + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, default=Path("sources.candidates.toml")) + parser.add_argument("--network", action="store_true", help="fetch each SHA from its official remote") + parser.add_argument("--json-out", type=Path) + args = parser.parse_args(argv) + + with args.manifest.open("rb") as handle: + manifest = tomllib.load(handle) + + report: dict[str, Any] = { + "manifest": str(args.manifest), + "status": manifest.get("status"), + "network": args.network, + "sources": {}, + "errors": [], + } + + for name in ("inkscape", "gtk", "pixiewood"): + source = manifest.get(name) + if not isinstance(source, dict): + report["errors"].append(f"missing source table: {name}") + continue + url = source.get("url") + commit = source.get("commit") + if not isinstance(url, str) or not url.startswith("https://"): + report["errors"].append(f"invalid URL for {name}") + continue + if not isinstance(commit, str) or not SHA1.fullmatch(commit): + report["errors"].append(f"invalid immutable SHA for {name}") + continue + if args.network: + entry = verify_remote(name, source) + if not entry.get("reachable"): + report["errors"].append(f"{name} SHA is not reachable from official remote") + else: + entry = {"url": url, "commit": commit, "syntaxValid": True} + report["sources"][name] = entry + + encoded = json.dumps(report, indent=2, sort_keys=True) + print(encoded) + if args.json_out: + args.json_out.write_text(encoded + "\n", encoding="utf-8") + return 2 if report["errors"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdk-packages.lock b/sdk-packages.lock index 4636759..8ff0fdb 100644 --- a/sdk-packages.lock +++ b/sdk-packages.lock @@ -1,5 +1,8 @@ -# Baseline candidate. P2 must validate this exact set. +# Candidate package set. P2 must validate native/GTK compatibility before promotion. platform-tools platforms;android-36 +# Input probe / current Android app toolchain. +build-tools;36.0.0 +# GTK/Pixiewood research baseline; retained until package template validation. build-tools;35.0.0 ndk;27.2.12479018 diff --git a/sources.candidates.toml b/sources.candidates.toml new file mode 100644 index 0000000..f81f411 --- /dev/null +++ b/sources.candidates.toml @@ -0,0 +1,22 @@ +# These SHAs are research candidates, not the validated build lock. +# CI verifies reachability; P2 compatibility work decides whether they move into sources.lock.toml. +format_version = 1 +status = "candidate" +observed_at = "2026-07-16" + +[inkscape] +url = "https://gitlab.com/inkscape/inkscape.git" +inspection_mirror = "https://github.com/facsimiles/inkscape.git" +commit = "85e98c11b8eb9c6cdbd46df509f99f6100056166" +notes = "Inkscape 1.5-dev GTK4/C++20 source inspected through GitHub mirror; official GitLab reachability must pass CI." + +[gtk] +url = "https://gitlab.gnome.org/GNOME/gtk.git" +inspection_mirror = "https://github.com/GNOME/gtk.git" +commit = "939826ae53d9b3029270546d6f3a4fb7988a7f85" +notes = "Contains mainline Android backend/runtime inspected for event, stylus, file chooser, and content GFile behavior." + +[pixiewood] +url = "https://github.com/sp1ritCS/gtk-android-builder.git" +commit = "00b1862b6434a8778557dc3d3074f43f5fdead1b" +notes = "Current builder candidate with target SDK 36 era changes; Linux host assumptions remain." diff --git a/tests/input-fixtures/p1-session-notes-template.csv b/tests/input-fixtures/p1-session-notes-template.csv new file mode 100644 index 0000000..d3e3981 --- /dev/null +++ b/tests/input-fixtures/p1-session-notes-template.csv @@ -0,0 +1,18 @@ +scenario_id,session_id,start_local_time,end_local_time,orientation,refresh_rate_hz,pen_battery,connected_devices,action_description,observed_screen_behavior,interrupt_trigger,operator_notes +P1-01,,,,landscape,,,,pen hover enter/move/exit,,, +P1-02,,,,landscape,,,,five light pen taps,,, +P1-03,,,,landscape,,,,three slow horizontal pen drags,,, +P1-04,,,,landscape,,,,five fast pen drags,,, +P1-05,,,,landscape,,,,gradual pressure increase and decrease,,, +P1-06,,,,landscape,,,,stylus buttons and eraser tests,,, +P1-07,,,,landscape,,,,single finger tap drag and hold,,, +P1-08,,,,landscape,,,,two-finger pan pinch and rotate,,, +P1-09,,,,landscape,,,,two fingers then one remains,,, +P1-10,,,,landscape,,,,pen first then natural palm contact,,, +P1-11,,,,landscape,,,,finger first then pen,,, +P1-12,,,,landscape,,,,pen during two-finger movement,,, +P1-13-system-panel,,,,landscape,,,,pen drag interrupted by system panel,,,system panel, +P1-13-home,,,,landscape,,,,pen drag interrupted by Home,,,Home, +P1-13-lock,,,,landscape,,,,pen drag interrupted by lock,,,lock screen, +P1-13-focus,,,,landscape,,,,pen drag interrupted by focus loss,,,focus loss, +P1-14,,,,landscape,,,,five-minute mixed-input stress test,,, diff --git a/tools/analyze_input_probe.py b/tools/analyze_input_probe.py new file mode 100644 index 0000000..9afc1c8 --- /dev/null +++ b/tools/analyze_input_probe.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Validate and summarize input-probe JSONL logs using only the Python standard library.""" + +from __future__ import annotations + +import argparse +import collections +import json +import math +import statistics +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +SCHEMA_VERSION = 1 +ACTION_CANCEL = 3 +ACTION_POINTER_UP = 6 +FLAG_CANCELED = 0x20 + +BASE_REQUIRED = {"schemaVersion", "recordType", "sessionId"} +SESSION_REQUIRED = {"createdWallTimeMillis", "app", "device", "display", "inputDevices"} +EVENT_REQUIRED = { + "sequence", + "dispatchPath", + "sampleKind", + "captureMonotonicNanos", + "captureUptimeMillis", + "eventTimeMillis", + "eventTimeNanos", + "downTimeMillis", + "actionMasked", + "actionIndex", + "actionButton", + "pointerCount", + "pointerIndex", + "pointerId", + "isActionPointer", + "toolType", + "source", + "deviceId", + "displayId", + "buttonState", + "metaState", + "flags", + "edgeFlags", + "classification", + "historySize", + "x", + "y", + "rawX", + "rawY", + "pressure", + "orientation", + "tilt", + "distance", + "size", + "touchMajor", + "touchMinor", + "toolMajor", + "toolMinor", + "xPrecision", + "yPrecision", +} +DROP_REQUIRED = {"afterSequence", "droppedRecords", "queueCapacity"} + + +@dataclass +class Report: + source: str + line_count: int = 0 + record_counts: collections.Counter[str] = field(default_factory=collections.Counter) + action_counts: collections.Counter[int] = field(default_factory=collections.Counter) + tool_counts: collections.Counter[int] = field(default_factory=collections.Counter) + dispatch_counts: collections.Counter[str] = field(default_factory=collections.Counter) + flag_counts: collections.Counter[int] = field(default_factory=collections.Counter) + sessions: set[str] = field(default_factory=set) + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + dropped_records: int = 0 + cancel_records: int = 0 + canceled_pointer_records: int = 0 + pointer_up_with_other_flags: int = 0 + current_event_times: dict[tuple[str, int, int, int, str], list[int]] = field( + default_factory=lambda: collections.defaultdict(list) + ) + last_sequence: dict[str, int] = field(default_factory=dict) + + def add_record(self, line_number: int, record: dict[str, Any]) -> None: + self.line_count += 1 + missing_base = BASE_REQUIRED - record.keys() + if missing_base: + self.errors.append(f"line {line_number}: missing base fields {sorted(missing_base)}") + return + if record["schemaVersion"] != SCHEMA_VERSION: + self.errors.append( + f"line {line_number}: unsupported schemaVersion={record['schemaVersion']}" + ) + return + + record_type = record["recordType"] + session_id = record["sessionId"] + if not isinstance(session_id, str) or not session_id: + self.errors.append(f"line {line_number}: invalid sessionId") + return + self.sessions.add(session_id) + self.record_counts[record_type] += 1 + + if record_type == "session": + self._require(line_number, record, SESSION_REQUIRED) + elif record_type == "event": + if not self._require(line_number, record, EVENT_REQUIRED): + return + self._add_event(line_number, record) + elif record_type == "drop": + if self._require(line_number, record, DROP_REQUIRED): + dropped = record["droppedRecords"] + if isinstance(dropped, int) and dropped > 0: + self.dropped_records += dropped + else: + self.errors.append(f"line {line_number}: invalid droppedRecords") + else: + self.errors.append(f"line {line_number}: unknown recordType={record_type!r}") + + def _require(self, line_number: int, record: dict[str, Any], required: set[str]) -> bool: + missing = required - record.keys() + if missing: + self.errors.append(f"line {line_number}: missing fields {sorted(missing)}") + return False + return True + + def _add_event(self, line_number: int, record: dict[str, Any]) -> None: + sequence = record["sequence"] + session_id = record["sessionId"] + if not isinstance(sequence, int) or sequence < 0: + self.errors.append(f"line {line_number}: invalid sequence") + return + previous = self.last_sequence.get(session_id) + if previous is not None and sequence <= previous: + self.errors.append( + f"line {line_number}: sequence {sequence} is not greater than {previous}" + ) + self.last_sequence[session_id] = sequence + + sample_kind = record["sampleKind"] + if sample_kind not in {"current", "historical"}: + self.errors.append(f"line {line_number}: invalid sampleKind={sample_kind!r}") + + action = record["actionMasked"] + tool = record["toolType"] + flags = record["flags"] + dispatch = record["dispatchPath"] + self.action_counts[action] += 1 + self.tool_counts[tool] += 1 + self.flag_counts[flags] += 1 + self.dispatch_counts[dispatch] += 1 + + if action == ACTION_CANCEL: + self.cancel_records += 1 + if action in {ACTION_CANCEL, ACTION_POINTER_UP} and flags & FLAG_CANCELED: + self.canceled_pointer_records += 1 + elif action == ACTION_POINTER_UP and flags != 0: + self.pointer_up_with_other_flags += 1 + + pointer_count = record["pointerCount"] + pointer_index = record["pointerIndex"] + if not isinstance(pointer_count, int) or pointer_count < 1: + self.errors.append(f"line {line_number}: invalid pointerCount") + if not isinstance(pointer_index, int) or not 0 <= pointer_index < pointer_count: + self.errors.append(f"line {line_number}: pointerIndex outside pointerCount") + + numeric_fields = ( + "captureMonotonicNanos", + "captureUptimeMillis", + "eventTimeMillis", + "downTimeMillis", + "actionMasked", + "actionIndex", + "actionButton", + "pointerCount", + "pointerIndex", + "pointerId", + "toolType", + "source", + "deviceId", + "displayId", + "buttonState", + "metaState", + "flags", + "edgeFlags", + "classification", + "historySize", + "x", + "y", + "pressure", + "orientation", + "tilt", + "distance", + "size", + "touchMajor", + "touchMinor", + "toolMajor", + "toolMinor", + "xPrecision", + "yPrecision", + ) + for field_name in numeric_fields: + value = record[field_name] + if not isinstance(value, (int, float)) or not math.isfinite(value): + self.errors.append(f"line {line_number}: invalid numeric field {field_name}") + + nullable_numeric_fields = ("eventTimeNanos", "rawX", "rawY") + for field_name in nullable_numeric_fields: + value = record[field_name] + if value is not None and ( + not isinstance(value, (int, float)) or not math.isfinite(value) + ): + self.errors.append(f"line {line_number}: invalid nullable numeric field {field_name}") + + if not isinstance(record["isActionPointer"], bool): + self.errors.append(f"line {line_number}: isActionPointer must be boolean") + + if sample_kind == "current": + key = ( + session_id, + record["deviceId"], + record["pointerId"], + record["downTimeMillis"], + dispatch, + ) + self.current_event_times[key].append(record["eventTimeMillis"]) + + def interval_summary(self) -> dict[str, float | int | None]: + deltas: list[int] = [] + for times in self.current_event_times.values(): + for previous, current in zip(times, times[1:]): + if current > previous: + deltas.append(current - previous) + if not deltas: + return {"count": 0, "medianMs": None, "p95Ms": None, "maxMs": None} + ordered = sorted(deltas) + p95_index = min(len(ordered) - 1, math.ceil(len(ordered) * 0.95) - 1) + return { + "count": len(ordered), + "medianMs": statistics.median(ordered), + "p95Ms": ordered[p95_index], + "maxMs": ordered[-1], + } + + def as_dict(self) -> dict[str, Any]: + return { + "source": self.source, + "lineCount": self.line_count, + "sessions": sorted(self.sessions), + "recordCounts": dict(sorted(self.record_counts.items())), + "actionCounts": {str(k): v for k, v in sorted(self.action_counts.items())}, + "toolCounts": {str(k): v for k, v in sorted(self.tool_counts.items())}, + "dispatchCounts": dict(sorted(self.dispatch_counts.items())), + "flagCounts": {str(k): v for k, v in sorted(self.flag_counts.items())}, + "cancelRecords": self.cancel_records, + "canceledPointerRecords": self.canceled_pointer_records, + "pointerUpWithOtherFlags": self.pointer_up_with_other_flags, + "droppedRecords": self.dropped_records, + "sampleIntervals": self.interval_summary(), + "errors": self.errors, + "warnings": self.warnings, + } + + +def iter_jsonl(path: Path) -> Iterable[tuple[int, dict[str, Any]]]: + with path.open("r", encoding="utf-8") as handle: + for line_number, raw_line in enumerate(handle, start=1): + line = raw_line.strip() + if not line: + continue + try: + value = json.loads(line) + except json.JSONDecodeError as error: + yield line_number, {"__parse_error__": str(error)} + continue + if not isinstance(value, dict): + yield line_number, {"__parse_error__": "record is not a JSON object"} + continue + yield line_number, value + + +def analyze(path: Path) -> Report: + report = Report(str(path)) + for line_number, record in iter_jsonl(path): + parse_error = record.get("__parse_error__") + if parse_error is not None: + report.errors.append(f"line {line_number}: {parse_error}") + continue + report.add_record(line_number, record) + if report.record_counts["session"] == 0: + report.warnings.append("no session metadata record found") + if report.record_counts["event"] == 0: + report.warnings.append("no event records found") + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("log", type=Path) + parser.add_argument("--json-out", type=Path) + parser.add_argument("--strict", action="store_true", help="fail on schema errors or dropped records") + args = parser.parse_args(argv) + + report = analyze(args.log) + encoded = json.dumps(report.as_dict(), ensure_ascii=False, indent=2, sort_keys=True) + print(encoded) + if args.json_out: + args.json_out.write_text(encoded + "\n", encoding="utf-8") + + if report.errors: + return 2 + if args.strict and report.dropped_records: + return 3 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_analyze_input_probe.py b/tools/tests/test_analyze_input_probe.py new file mode 100644 index 0000000..a571d15 --- /dev/null +++ b/tools/tests/test_analyze_input_probe.py @@ -0,0 +1,140 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from tools.analyze_input_probe import analyze + + +class AnalyzeInputProbeTest(unittest.TestCase): + def write_records(self, records): + temp = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False) + with temp: + for record in records: + temp.write(json.dumps(record) + "\n") + return Path(temp.name) + + def session(self): + return { + "schemaVersion": 1, + "recordType": "session", + "sessionId": "session-a", + "createdWallTimeMillis": 1, + "app": {"versionName": "test", "versionCode": 1, "buildType": "debug"}, + "device": { + "manufacturer": "Xiaomi", + "model": "test", + "sdkInt": 36, + "release": "test", + "fingerprint": "test", + }, + "display": { + "widthPixels": 100, + "heightPixels": 100, + "densityDpi": 320, + "refreshRateHz": 120, + }, + "inputDevices": [], + } + + def event(self, sequence, event_time, action=2, flags=0, down_time=90): + return { + "schemaVersion": 1, + "recordType": "event", + "sessionId": "session-a", + "sequence": sequence, + "dispatchPath": "touch", + "sampleKind": "current", + "captureMonotonicNanos": event_time * 1_000_000, + "captureUptimeMillis": event_time, + "eventTimeMillis": event_time, + "eventTimeNanos": None, + "downTimeMillis": down_time, + "actionMasked": action, + "actionIndex": 0, + "actionButton": 0, + "pointerCount": 1, + "pointerIndex": 0, + "pointerId": 7, + "isActionPointer": True, + "toolType": 2, + "source": 0x4002, + "deviceId": 3, + "displayId": 0, + "buttonState": 0, + "metaState": 0, + "flags": flags, + "edgeFlags": 0, + "classification": 0, + "historySize": 0, + "x": 10.0, + "y": 20.0, + "rawX": 10.0, + "rawY": 20.0, + "pressure": 0.5, + "orientation": 0.0, + "tilt": 0.1, + "distance": 0.0, + "size": 0.2, + "touchMajor": 1.0, + "touchMinor": 1.0, + "toolMajor": 1.0, + "toolMinor": 1.0, + "xPrecision": 1.0, + "yPrecision": 1.0, + } + + def test_valid_log_summary(self): + path = self.write_records([self.session(), self.event(1, 100), self.event(2, 108)]) + report = analyze(path) + self.assertEqual(report.errors, []) + self.assertEqual(report.record_counts["event"], 2) + self.assertEqual(report.interval_summary()["medianMs"], 8) + + def test_non_monotonic_sequence_is_rejected(self): + path = self.write_records([self.session(), self.event(2, 100), self.event(2, 108)]) + report = analyze(path) + self.assertTrue(any("not greater" in error for error in report.errors)) + + def test_missing_field_is_rejected(self): + broken = self.event(1, 100) + del broken["pressure"] + path = self.write_records([self.session(), broken]) + report = analyze(path) + self.assertTrue(any("pressure" in error for error in report.errors)) + + def test_drop_records_are_counted(self): + drop = { + "schemaVersion": 1, + "recordType": "drop", + "sessionId": "session-a", + "afterSequence": 9, + "droppedRecords": 4, + "queueCapacity": 1024, + } + path = self.write_records([self.session(), drop]) + report = analyze(path) + self.assertEqual(report.dropped_records, 4) + + def test_flag_canceled_is_counted_separately(self): + path = self.write_records([self.session(), self.event(1, 100, action=6, flags=0x20)]) + report = analyze(path) + self.assertEqual(report.canceled_pointer_records, 1) + self.assertEqual(report.pointer_up_with_other_flags, 0) + + def test_pointer_id_reuse_does_not_create_false_interval(self): + path = self.write_records( + [ + self.session(), + self.event(1, 100, down_time=90), + self.event(2, 108, down_time=90), + self.event(3, 1000, down_time=990), + ] + ) + report = analyze(path) + self.assertEqual(report.interval_summary()["count"], 1) + self.assertEqual(report.interval_summary()["maxMs"], 8) + + +if __name__ == "__main__": + unittest.main()