From 0b015157a2296d0d61363f8907df1734406d98ed Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 10:50:01 -0300 Subject: [PATCH 01/23] =?UTF-8?q?ci:=20esteira=20de=20instabilidade=20?= =?UTF-8?q?=E2=80=94=2016=20emuladores=20repetem=20em=20la=C3=A7o=20os=20t?= =?UTF-8?q?estes=20que=20falham=20"=C3=A0s=20vezes"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os testes que ainda dão "instável" no gate (CanvasGrid holdingAKey/rollover, SpellChecker backspace/sugestão, joystick da BarsTest, OverlayServiceCancelTest, PackOrbBudgetTest) falham uma vez a cada tantas rodadas e nunca no emulador local. Workflow Stress: os APKs são montados uma vez; 16 emuladores iguais ao do gate repetem as classes por ~40 min, reinstalando o app a cada volta; metade com o QEMU num cgroup limitado a 2 núcleos (perto do runner privado de 2 vCPU). Cada volta com falha guarda saída bruta, trecho do logcat e tela; o relatório soma falhas/execuções por teste e condição de CPU e fica vermelho quando acha algo. Roda por push em ci/esteira-lenta… (o código daquele commit) ou, na main, por Run workflow. Não bloqueia PR. --- .github/workflows/stress.yml | 151 +++++++++++++++++++++++++++++++++++ .specs/codebase/TESTING.md | 16 ++++ scripts/ci-stress-report.py | 88 ++++++++++++++++++++ scripts/ci-stress.sh | 126 +++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 .github/workflows/stress.yml create mode 100755 scripts/ci-stress-report.py create mode 100755 scripts/ci-stress.sh diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml new file mode 100644 index 00000000..eb87a894 --- /dev/null +++ b/.github/workflows/stress.yml @@ -0,0 +1,151 @@ +name: Stress +# Esteira de instabilidade: repete em laço, em vários emuladores ao mesmo tempo, os testes que já falharam +# "às vezes" no gate, até a falha aparecer com evidência (scripts/ci-stress.sh). Não bloqueia PR e não faz +# parte da regra da main — é ferramenta de caça. +# +# Rodar: +# - push numa branch `ci/esteira-lenta…`: testa o código DAQUELE commit — serve também para conferir uma +# correção sob carga antes de mesclar (`git push origin HEAD:ci/esteira-lenta`); +# - com este arquivo na main: Actions → Stress → Run workflow, escolhendo classes e minutos. +# +# Metade dos emuladores roda no runner como ele é; a outra metade com o QEMU limitado a 2 núcleos, perto do +# runner privado (2 vCPU) onde as falhas apareciam mais — no repositório público o runner tem 4. +on: + push: + branches: ['ci/esteira-lenta**'] + workflow_dispatch: + inputs: + classes: + description: Classes ou Classe#metodo do AndroidJUnitRunner, separadas por vírgula + default: com.keyflow.ime.KeyFlowKeyboardCanvasGridTest,com.keyflow.ime.KeyFlowKeyboardSpellCheckerTest,com.keyflow.ime.KeyFlowKeyboardBarsTest,com.keyflow.overlay.OverlayServiceCancelTest,com.keyflow.skins.PackOrbBudgetTest + minutes: + description: Minutos de laço em cada emulador (até 45) + default: '40' +permissions: + contents: read +concurrency: + group: stress-${{ github.ref }} + cancel-in-progress: true +env: + # Os testes que deram "instável" ou falharam sem causa no gate entre 2026-10-07 e 2026-10-10. + STRESS_CLASSES: ${{ inputs.classes || 'com.keyflow.ime.KeyFlowKeyboardCanvasGridTest,com.keyflow.ime.KeyFlowKeyboardSpellCheckerTest,com.keyflow.ime.KeyFlowKeyboardBarsTest,com.keyflow.overlay.OverlayServiceCancelTest,com.keyflow.skins.PackOrbBudgetTest' }} + STRESS_MINUTES: ${{ inputs.minutes || '40' }} +jobs: + build: + name: APKs + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # O mesmo preparo do job `unit` do checks.yml (ver os comentários de lá), sem os testes de unidade. + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + - name: JDK path and debug keystore + run: | + sudo ln -sfn "$JAVA_HOME" /usr/lib/jvm/java-17-openjdk + mkdir -p ~/.config/.android + keytool -genkeypair -keystore ~/.config/.android/debug.keystore -storepass android \ + -keypass android -alias androiddebugkey -dname "CN=Android Debug,O=Android,C=US" \ + -keyalg RSA -keysize 2048 -validity 10000 + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: sherpa-onnx AAR version + id: sherpa + run: | + version=$(sed -n 's/^val sherpaOnnxVersion = "\(.*\)"$/\1/p' app/build.gradle.kts) + sha=$(sed -n 's/^val sherpaOnnxSha256 = "\(.*\)"$/\1/p' app/build.gradle.kts) + test -n "$version" && test -n "$sha" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: app/libs/sherpa-onnx-${{ steps.sherpa.outputs.version }}.aar + key: sherpa-onnx-${{ steps.sherpa.outputs.version }}-${{ steps.sherpa.outputs.sha }} + - name: APKs + run: ./gradlew --no-daemon assembleDebug assembleDebugAndroidTest + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: apks + path: | + app/build/outputs/apk/debug/app-debug.apk + app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + stress: + name: Stress ${{ matrix.worker }} (CPU ${{ matrix.cpu && format('{0}%', matrix.cpu) || 'livre' }}) + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + worker: [1, 2, 3, 4, 5, 6, 7, 8] + cpu: ['', '200'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/.ghcup /opt/ghc /opt/hostedtoolcache/CodeQL \ + /usr/share/swift /usr/local/share/boost /usr/local/share/powershell /usr/local/julia* + df -h / + - name: KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + test -c /dev/kvm + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: apks + path: app/build/outputs/apk + # O mesmo emulador do gate (checks.yml: versão fixa, swangle, Pixel 6, 2 núcleos, 4 GB). + - name: Stress loop on the emulator + timeout-minutes: 65 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: 34 + emulator-build: 15917651 + target: google_apis + arch: x86_64 + profile: pixel_6 + cores: 2 + ram-size: 4096M + disk-size: 6G + emulator-options: -no-window -gpu swangle_indirect -no-snapshot -no-boot-anim -camera-back none + disable-animations: true + script: scripts/ci-stress.sh "$STRESS_CLASSES" "$STRESS_MINUTES" "${{ matrix.cpu }}" + - name: Stress evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stress-evidence-${{ matrix.worker }}-cpu${{ matrix.cpu || 'livre' }} + path: app/build/stress-evidence/ + if-no-files-found: warn + retention-days: 14 + + report: + name: Stress report + needs: stress + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: stress-evidence-* + path: stress + - name: Failures across all emulators + run: python3 scripts/ci-stress-report.py stress diff --git a/.specs/codebase/TESTING.md b/.specs/codebase/TESTING.md index f7671867..30dfa6dc 100644 --- a/.specs/codebase/TESTING.md +++ b/.specs/codebase/TESTING.md @@ -112,3 +112,19 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env - Artefatos: `unit-evidence` (relatórios JUnit) e `device-evidence-` (saída bruta do `am instrument` e da repetição, `passed.txt`/`failed.txt`, logcat da rodada, `host.txt`, PNGs capturados), 14 dias. - O device físico (A32) e o emulador Android 17 seguem sendo gates manuais **[YAN]**. + +### Esteira de instabilidade — `.github/workflows/stress.yml` + +Caça ao teste que falha "às vezes": 16 emuladores (o mesmo do gate) repetem as mesmas classes em laço por +~40 min, reinstalando o app a cada volta como o gate faz (`scripts/ci-stress.sh`). Metade roda no runner como +ele é; a outra metade com o QEMU num cgroup limitado a 2 núcleos, perto do runner privado de 2 vCPU onde as +falhas apareciam mais. O job `Stress report` (`scripts/ci-stress-report.py`) soma tudo: falhas/execuções por +teste e condição de CPU, a 1ª linha do erro e onde está a evidência (artefato `stress-evidence--cpu…` → +volta: saída bruta, trecho do logcat, tela). Fica vermelho quando acha alguma falha. Não bloqueia PR. + +- Rodar com o código de um commit qualquer: `git push origin HEAD:ci/esteira-lenta` (serve também para + conferir uma correção sob repetição antes de mesclar). Com o arquivo na `main`: Actions → Stress → Run + workflow, com as classes e os minutos. +- Por que no CI e não em laço local: ~15 emuladores ao mesmo tempo (~10× mais voltas por hora), a própria + máquina onde as falhas apareceram, e o computador do Yan livre. O emulador lento local + (`systemd-run --user --scope -p CPUQuota=300%`) serve para reproduzir uma falha já achada. diff --git a/scripts/ci-stress-report.py b/scripts/ci-stress-report.py new file mode 100755 index 00000000..7a69871a --- /dev/null +++ b/scripts/ci-stress-report.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Soma as voltas da esteira de instabilidade (scripts/ci-stress.sh) de todos os emuladores. + +Uso (.github/workflows/stress.yml, job do relatório): + + python3 scripts/ci-stress-report.py + +Para cada teste que falhou ao menos uma vez: quantas execuções, quantas falhas, em que condição de CPU e +onde está a evidência (artefato/volta). Escreve no resumo do job e sai com 1 se algum teste falhou — a +rodada fica vermelha quando a esteira achou alguma coisa. +""" +import os +import pathlib +import sys +from collections import defaultdict + + +def read_lines(path): + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] if path.exists() else [] + + +def first_error(summary, name): + """A 1ª linha da pilha que o ci-instrument-summary.py imprime logo abaixo do ✗ do teste.""" + lines = summary.read_text(encoding="utf-8").splitlines() if summary.exists() else [] + for i, line in enumerate(lines): + if line.lstrip().startswith("✗ ") and f" {name} " in f"{line} ": + return lines[i + 1].strip() if i + 1 < len(lines) else "" + return "" + + +def main(): + root = pathlib.Path(sys.argv[1]) + runs = defaultdict(lambda: defaultdict(int)) # teste → condição → execuções + fails = defaultdict(lambda: defaultdict(int)) # teste → condição → falhas + where = defaultdict(list) # teste → [(artefato, volta, 1ª linha do erro)] + workers = [] + for artifact in sorted(p for p in root.iterdir() if p.is_dir()): + meta = read_lines(artifact / "meta.txt") + cpu = next((m.split(": ", 1)[1] for m in meta if m.startswith("cpu do emulador")), "?") + workers.append((artifact.name, meta)) + for round_dir in sorted(artifact.glob("volta-*")): + for name in read_lines(round_dir / "passed.txt"): + runs[name][cpu] += 1 + for name in read_lines(round_dir / "failed.txt"): + runs[name][cpu] += 1 + fails[name][cpu] += 1 + where[name].append((artifact.name, round_dir.name, first_error(round_dir / "summary.txt", name))) + + total_runs = sum(sum(c.values()) for c in runs.values()) + conditions = sorted({cpu for c in runs.values() for cpu in c}) + out = ["## Esteira de instabilidade", ""] + out.append(f"{len(workers)} emuladores, {len(runs)} testes, {total_runs} execuções no total.") + out.append("") + if fails: + out.append("| Teste | " + " | ".join(f"falhas / execuções (CPU {c})" for c in conditions) + " |") + out.append("|---|" + "---|" * len(conditions)) + for name in sorted(fails, key=lambda n: -sum(fails[n].values())): + cells = [f"{fails[name][c]} / {runs[name][c]}" for c in conditions] + out.append(f"| `{name.split('.')[-1]}` | " + " | ".join(cells) + " |") + out.append("") + out.append("Evidência (artefato → volta: 1ª linha do erro):") + out.append("") + for name in sorted(fails, key=lambda n: -sum(fails[n].values())): + out.append(f"- `{name.split('.')[-1]}`") + for artifact, round_name, error in where[name][:10]: + out.append(f" - {artifact} → {round_name}: {error}") + else: + out.append("Nenhuma falha.") + out.append("") + out.append("| Emulador | Voltas, CPU e quadros |") + out.append("|---|---|") + for name, meta in workers: + out.append(f"| {name} | {'; '.join(meta) or 'sem meta.txt (o emulador não chegou ao fim)'} |") + + text = "\n".join(out) + "\n" + print(text) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as f: + f.write(text) + for name in fails: + count = sum(fails[name].values()) + print(f"::warning title={name.split('.')[-1]}::falhou {count} de {sum(runs[name].values())} execuções na esteira") + return 1 if fails or not workers else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh new file mode 100755 index 00000000..5c113672 --- /dev/null +++ b/scripts/ci-stress.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Esteira de instabilidade (.github/workflows/stress.yml): roda com o emulador já de pé, dentro do +# android-emulator-runner, e repete as mesmas classes em laço até acabar o tempo — para pegar, com +# evidência, o teste que falha uma vez a cada tantas rodadas do gate. Vários emuladores rodam isto ao +# mesmo tempo; scripts/ci-stress-report.py soma as voltas de todos. +# +# ci-stress.sh [] +# +# lista do `-e class` do AndroidJUnitRunner (Classe ou Classe#metodo, separados por vírgula). +# teto de CPU só do QEMU, num cgroup próprio: 200 = 2 núcleos, perto do runner privado onde as +# falhas apareciam mais. Vazio = o runner como ele é. +# +# Cada volta reinstala o app, como o gate faz antes da suíte: as falhas que sobraram aparecem logo depois +# de instalar (o holdingAKey… da CanvasGridTest é sempre o 1º teste de teclado do processo). Toda volta +# grava passed.txt/failed.txt; a que falha guarda também a saída bruta, o trecho do logcat e a tela. +# Sai com 0 mesmo com teste falhando — achar falha é o produto da esteira, e quem acusa é o relatório. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +CLASSES="${1:?uso: $0 []}" +MINUTES="${2:?uso: $0 []}" +CPU="${3:-}" +OUT=app/build/stress-evidence +ADB="${ANDROID_HOME:+$ANDROID_HOME/platform-tools/}adb" +APP_APK=app/build/outputs/apk/debug/app-debug.apk +TEST_APK=app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +RUNNER=com.keyflow.test/androidx.test.runner.AndroidJUnitRunner +mkdir -p "$OUT" + +# Sem diálogo de "app não está respondendo" segurando o foco (a mesma lição do ci-device-gate.sh). +"$ADB" shell settings put global hide_error_dialogs 1 || true + +qemu_pid() { + # O maior entre os candidatos: o crashpad do emulador também leva "qemu-system" na linha de comando. + for p in $(pgrep -f qemu-system); do echo "$(ps -o rss= -p "$p") $p"; done | sort -n | tail -1 | awk '{print $2}' +} + +# Só o QEMU fica no teto; o adb, o logcat e o runner seguem livres. É a receita do emulador lento local +# (systemd-run --scope -p CPUQuota=…), que reproduziu a falha do seletor que só dava no CI (2026-10-10). +limit_cpu() { + local cg=/sys/fs/cgroup/keyflow-emulator pid + pid=$(qemu_pid) + [ -n "$pid" ] || { echo "::error::QEMU não encontrado para limitar a CPU"; return 1; } + grep -qw cpu /sys/fs/cgroup/cgroup.subtree_control || + echo +cpu | sudo tee /sys/fs/cgroup/cgroup.subtree_control >/dev/null || return 1 + sudo mkdir -p "$cg" || return 1 + echo "$((CPU * 1000)) 100000" | sudo tee "$cg/cpu.max" >/dev/null || return 1 + echo "$pid" | sudo tee "$cg/cgroup.procs" >/dev/null || return 1 + echo "QEMU $pid: cgroup $(cut -d: -f3 "/proc/$pid/cgroup"), cpu.max $(cat "$cg/cpu.max")" +} + +# Depois do `adb install` o app fica `run-from-apk`; o compile grava a verificação (ci-device-gate.sh). +install_app() { + "$ADB" install -r -t "$APP_APK" >/dev/null || return 1 + "$ADB" shell cmd package compile -m speed -f com.keyflow >/dev/null +} + +# Mediana e p90 do tempo de quadro do processo de teste: é o número que compara este emulador com o +# do gate e com o local (a calibração da memória do projeto). +frame_stats() { + python3 - "$OUT/logcat-full.txt" <<'EOF' +import re, statistics, sys +lines = open(sys.argv[1], encoding="utf-8", errors="replace").read().splitlines() +pids = {l.split()[2] for l in lines if " TestRunner: " in l and len(l.split()) > 2} +v = sorted(float(m.group(1)) for l in lines + if len(l.split()) > 5 and l.split()[2] in pids + for m in [re.search(r"app_time_stats: avg=([0-9.]+)ms", l)] if m) +print(f"quadros do app: {len(v)} amostras, mediana {statistics.median(v):.0f} ms, p90 {v[int(len(v) * .9)]:.0f} ms" + if v else "quadros do app: sem amostras") +EOF +} + +"$ADB" logcat -c || true +"$ADB" logcat -v threadtime > "$OUT/logcat-full.txt" 2>&1 & +LOGCAT_PID=$! + +if ! "$ADB" install -r -t "$TEST_APK" >/dev/null; then + echo "::error::o APK de teste não instalou" + exit 1 +fi +"$ADB" shell cmd package compile -m speed -f com.keyflow.test >/dev/null +if [ -n "$CPU" ] && ! limit_cpu; then + echo "::error::não consegui limitar a CPU do emulador a $CPU%" + exit 1 +fi + +deadline=$((SECONDS + MINUTES * 60)) +i=0 +failed_rounds=0 +while [ "$SECONDS" -lt "$deadline" ]; do + i=$((i + 1)) + dir="$OUT/$(printf 'volta-%03d' "$i")" + mkdir -p "$dir" + from=$(wc -l < "$OUT/logcat-full.txt") + start=$SECONDS + if ! install_app; then + echo "::error::volta $i: o app não instalou" + break + fi + "$ADB" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS >/dev/null 2>&1 || true + "$ADB" shell am instrument -w -r -e class "$CLASSES" -e motionFrames false "$RUNNER" > "$dir/raw.txt" + # --retry-follows: sem anotação por volta (seriam centenas); o relatório anota no fim. + env -u GITHUB_STEP_SUMMARY python3 scripts/ci-instrument-summary.py --label "volta $i" --retry-follows \ + --passed-out "$dir/passed.txt" --failed-out "$dir/failed.txt" < "$dir/raw.txt" > "$dir/summary.txt" + rc=$? + score=$(grep "^volta $i: " "$dir/summary.txt" | cut -d: -f2-) + if [ "$rc" -eq 0 ]; then + rm -f "$dir/raw.txt" + echo "volta $i ($((SECONDS - start)) s):$score" + else + failed_rounds=$((failed_rounds + 1)) + sed -n "$((from + 1)),\$p" "$OUT/logcat-full.txt" > "$dir/logcat.txt" + "$ADB" exec-out screencap -p > "$dir/tela.png" 2>/dev/null || rm -f "$dir/tela.png" + echo "volta $i ($((SECONDS - start)) s):$score — FALHOU: $(paste -sd' ' "$dir/failed.txt")" + fi +done + +kill "$LOGCAT_PID" 2>/dev/null || true +{ + echo "voltas: $i (com falha: $failed_rounds)" + if [ -n "$CPU" ]; then echo "cpu do emulador: $CPU%"; else echo "cpu do emulador: livre"; fi + frame_stats +} | tee "$OUT/meta.txt" +# O logcat inteiro tem centenas de milhares de linhas; as voltas com falha já guardaram o seu trecho. +rm -f "$OUT/logcat-full.txt" +[ "$i" -gt 0 ] && [ -s "$OUT/volta-001/passed.txt" -o -s "$OUT/volta-001/failed.txt" ] From 79858f036c7fc1ce344b5a18f3174d7f34ca9039 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 10:50:46 -0300 Subject: [PATCH 02/23] =?UTF-8?q?docs(ci):=20o=20cancelamento=20de=20rodad?= =?UTF-8?q?a=20antiga=20n=C3=A3o=20=C3=A9=20mais=20por=20minutos=20?= =?UTF-8?q?=E2=80=94=20o=20reposit=C3=B3rio=20=C3=A9=20p=C3=BAblico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index dfec5673..738dccb9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,8 +11,9 @@ permissions: # Os jobs do emulador baixam os APKs desta rodada com ela ainda em curso (`gh run download`) e # consultam o estado do job de unitários — ver scripts/ci-device-gate.sh. actions: read -# Um push novo no mesmo PR cancela a rodada anterior: o job de device leva dezenas de minutos e o -# repositório é privado (minutos contam). +# Um push novo no mesmo PR cancela a rodada anterior: ela testa um commit que já ficou para trás, e os +# runners que ela ocupa contam no limite de 20 jobs simultâneos do plano (o repositório é público desde +# 2026-10-10: os minutos não contam mais). concurrency: group: checks-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} From 48a78000ddfbaa967f87aad5df7b0b04ea332b2c Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 10:55:15 -0300 Subject: [PATCH 03/23] =?UTF-8?q?ci(esteira):=20cada=20emulador=20para=20n?= =?UTF-8?q?a=201=C2=AA=20volta=20com=20falha=20=E2=80=94=20o=20job=20termi?= =?UTF-8?q?na=20e=20a=20evid=C3=AAncia=20sai=20na=20hora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/stress.yml | 2 ++ .specs/codebase/TESTING.md | 2 ++ scripts/ci-stress.sh | 9 +++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index eb87a894..5434ecb7 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -10,6 +10,8 @@ name: Stress # # Metade dos emuladores roda no runner como ele é; a outra metade com o QEMU limitado a 2 núcleos, perto do # runner privado (2 vCPU) onde as falhas apareciam mais — no repositório público o runner tem 4. +# Cada emulador para na 1ª volta com falha: o job fica vermelho na hora, com a evidência já no artefato +# (log e artefato só saem de job terminado), e os demais seguem caçando. on: push: branches: ['ci/esteira-lenta**'] diff --git a/.specs/codebase/TESTING.md b/.specs/codebase/TESTING.md index 30dfa6dc..a5de541b 100644 --- a/.specs/codebase/TESTING.md +++ b/.specs/codebase/TESTING.md @@ -121,6 +121,8 @@ ele é; a outra metade com o QEMU num cgroup limitado a 2 núcleos, perto do run falhas apareciam mais. O job `Stress report` (`scripts/ci-stress-report.py`) soma tudo: falhas/execuções por teste e condição de CPU, a 1ª linha do erro e onde está a evidência (artefato `stress-evidence--cpu…` → volta: saída bruta, trecho do logcat, tela). Fica vermelho quando acha alguma falha. Não bloqueia PR. +Cada emulador para na 1ª volta com falha — o GitHub só libera log e artefato de job terminado, então é +assim que a falha aparece na hora; `STRESS_KEEP_GOING=1` no script segue até o fim (taxa de falha). - Rodar com o código de um commit qualquer: `git push origin HEAD:ci/esteira-lenta` (serve também para conferir uma correção sob repetição antes de mesclar). Com o arquivo na `main`: Actions → Stress → Run diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index 5c113672..4711e82e 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -13,7 +13,10 @@ # Cada volta reinstala o app, como o gate faz antes da suíte: as falhas que sobraram aparecem logo depois # de instalar (o holdingAKey… da CanvasGridTest é sempre o 1º teste de teclado do processo). Toda volta # grava passed.txt/failed.txt; a que falha guarda também a saída bruta, o trecho do logcat e a tela. -# Sai com 0 mesmo com teste falhando — achar falha é o produto da esteira, e quem acusa é o relatório. +# +# Para na 1ª volta com falha e sai com 1: o GitHub só libera log e artefato de job terminado, então é +# assim que a falha aparece na hora, com evidência, enquanto os outros emuladores seguem caçando. +# STRESS_KEEP_GOING=1 segue até o fim do tempo (para medir a taxa de falha) e sai com 0. set -uo pipefail cd "$(dirname "$0")/.." || exit 1 @@ -112,6 +115,7 @@ while [ "$SECONDS" -lt "$deadline" ]; do sed -n "$((from + 1)),\$p" "$OUT/logcat-full.txt" > "$dir/logcat.txt" "$ADB" exec-out screencap -p > "$dir/tela.png" 2>/dev/null || rm -f "$dir/tela.png" echo "volta $i ($((SECONDS - start)) s):$score — FALHOU: $(paste -sd' ' "$dir/failed.txt")" + [ "${STRESS_KEEP_GOING:-0}" = 1 ] || break fi done @@ -123,4 +127,5 @@ kill "$LOGCAT_PID" 2>/dev/null || true } | tee "$OUT/meta.txt" # O logcat inteiro tem centenas de milhares de linhas; as voltas com falha já guardaram o seu trecho. rm -f "$OUT/logcat-full.txt" -[ "$i" -gt 0 ] && [ -s "$OUT/volta-001/passed.txt" -o -s "$OUT/volta-001/failed.txt" ] +[ "$i" -gt 0 ] && [ -s "$OUT/volta-001/passed.txt" -o -s "$OUT/volta-001/failed.txt" ] || exit 1 +[ "$failed_rounds" -eq 0 ] || [ "${STRESS_KEEP_GOING:-0}" = 1 ] || exit 1 From 2c26e08e3a975f9cbfe0bc1a2e969ee3516664ec Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 10:57:38 -0300 Subject: [PATCH 04/23] ci(esteira): os emuladores rodam o tempo todo e avisam cada falha na hora por um status no commit --- .github/workflows/stress.yml | 9 +++++++-- .specs/codebase/TESTING.md | 5 +++-- scripts/ci-stress.sh | 24 +++++++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index 5434ecb7..08e96be6 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -10,8 +10,8 @@ name: Stress # # Metade dos emuladores roda no runner como ele é; a outra metade com o QEMU limitado a 2 núcleos, perto do # runner privado (2 vCPU) onde as falhas apareciam mais — no repositório público o runner tem 4. -# Cada emulador para na 1ª volta com falha: o job fica vermelho na hora, com a evidência já no artefato -# (log e artefato só saem de job terminado), e os demais seguem caçando. +# Cada emulador roda o tempo todo e junta todas as falhas; uma volta com falha publica na hora um status +# `esteira/…` no commit (log e artefato só saem de job terminado) — é o aviso antecipado. on: push: branches: ['ci/esteira-lenta**'] @@ -25,6 +25,8 @@ on: default: '40' permissions: contents: read + # Os status `esteira//volta-N` do aviso antecipado (scripts/ci-stress.sh). + statuses: write concurrency: group: stress-${{ github.ref }} cancel-in-progress: true @@ -114,6 +116,9 @@ jobs: # O mesmo emulador do gate (checks.yml: versão fixa, swangle, Pixel 6, 2 núcleos, 4 GB). - name: Stress loop on the emulator timeout-minutes: 65 + env: + GH_TOKEN: ${{ github.token }} + STRESS_WORKER: ${{ matrix.worker }}-cpu${{ matrix.cpu || 'livre' }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 34 diff --git a/.specs/codebase/TESTING.md b/.specs/codebase/TESTING.md index a5de541b..e1be5696 100644 --- a/.specs/codebase/TESTING.md +++ b/.specs/codebase/TESTING.md @@ -121,8 +121,9 @@ ele é; a outra metade com o QEMU num cgroup limitado a 2 núcleos, perto do run falhas apareciam mais. O job `Stress report` (`scripts/ci-stress-report.py`) soma tudo: falhas/execuções por teste e condição de CPU, a 1ª linha do erro e onde está a evidência (artefato `stress-evidence--cpu…` → volta: saída bruta, trecho do logcat, tela). Fica vermelho quando acha alguma falha. Não bloqueia PR. -Cada emulador para na 1ª volta com falha — o GitHub só libera log e artefato de job terminado, então é -assim que a falha aparece na hora; `STRESS_KEEP_GOING=1` no script segue até o fim (taxa de falha). +Cada emulador roda o tempo todo (uma falha frequente não tira ninguém da caça às raras); como o GitHub só +libera log e artefato de job terminado, cada volta com falha publica na hora um status `esteira// +volta-N` no commit, com o teste e a 1ª linha do erro — o aviso antecipado. A evidência completa sai no fim. - Rodar com o código de um commit qualquer: `git push origin HEAD:ci/esteira-lenta` (serve também para conferir uma correção sob repetição antes de mesclar). Com o arquivo na `main`: Actions → Stress → Run diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index 4711e82e..ccd107f4 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -14,9 +14,10 @@ # de instalar (o holdingAKey… da CanvasGridTest é sempre o 1º teste de teclado do processo). Toda volta # grava passed.txt/failed.txt; a que falha guarda também a saída bruta, o trecho do logcat e a tela. # -# Para na 1ª volta com falha e sai com 1: o GitHub só libera log e artefato de job terminado, então é -# assim que a falha aparece na hora, com evidência, enquanto os outros emuladores seguem caçando. -# STRESS_KEEP_GOING=1 segue até o fim do tempo (para medir a taxa de falha) e sai com 0. +# Roda o tempo todo, mesmo depois de uma falha: uma falha frequente não pode tirar o emulador da caça às +# raras. Como o GitHub só libera log e artefato de job terminado, cada volta com falha publica na hora um +# status no commit (`esteira//volta-N`: o teste e a 1ª linha do erro) — é o aviso antecipado; +# a evidência completa sobe no fim. Sai com 1 se alguma volta falhou. set -uo pipefail cd "$(dirname "$0")/.." || exit 1 @@ -58,6 +59,19 @@ install_app() { "$ADB" shell cmd package compile -m speed -f com.keyflow >/dev/null } +# O aviso antecipado: um status no commit, que a API mostra com o job ainda rodando. Fora do Actions +# (sem GITHUB_SHA) não faz nada. +announce_failure() { + local dir="$1" round="$2" test error + [ -n "${GITHUB_SHA:-}" ] && [ -n "${GH_TOKEN:-}" ] || return 0 + test=$(head -1 "$dir/failed.txt" | sed 's/^.*\.//') + error=$(grep -A1 "^ ✗ " "$dir/summary.txt" | sed -n 2p | sed 's/^ *//') + gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" -f state=failure \ + -f context="esteira/${STRESS_WORKER:-?}/volta-$round" \ + -f description="$(printf '%s: %s' "${test:-processo caiu}" "$error" | cut -c1-140)" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null || true +} + # Mediana e p90 do tempo de quadro do processo de teste: é o número que compara este emulador com o # do gate e com o local (a calibração da memória do projeto). frame_stats() { @@ -115,7 +129,7 @@ while [ "$SECONDS" -lt "$deadline" ]; do sed -n "$((from + 1)),\$p" "$OUT/logcat-full.txt" > "$dir/logcat.txt" "$ADB" exec-out screencap -p > "$dir/tela.png" 2>/dev/null || rm -f "$dir/tela.png" echo "volta $i ($((SECONDS - start)) s):$score — FALHOU: $(paste -sd' ' "$dir/failed.txt")" - [ "${STRESS_KEEP_GOING:-0}" = 1 ] || break + announce_failure "$dir" "$i" fi done @@ -128,4 +142,4 @@ kill "$LOGCAT_PID" 2>/dev/null || true # O logcat inteiro tem centenas de milhares de linhas; as voltas com falha já guardaram o seu trecho. rm -f "$OUT/logcat-full.txt" [ "$i" -gt 0 ] && [ -s "$OUT/volta-001/passed.txt" -o -s "$OUT/volta-001/failed.txt" ] || exit 1 -[ "$failed_rounds" -eq 0 ] || [ "${STRESS_KEEP_GOING:-0}" = 1 ] || exit 1 +[ "$failed_rounds" -eq 0 ] From 4eed57c8e113f6cb97af48f2eaaa7106d941409c Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 11:17:30 -0300 Subject: [PATCH 05/23] =?UTF-8?q?docs(ordem):=20V1-0=20feita=20=E2=80=94?= =?UTF-8?q?=20PR=20#7=20mesclada=20(4cd4ee4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .specs/DEVELOPMENT-ORDER.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.specs/DEVELOPMENT-ORDER.md b/.specs/DEVELOPMENT-ORDER.md index ade9c05e..fadad194 100644 --- a/.specs/DEVELOPMENT-ORDER.md +++ b/.specs/DEVELOPMENT-ORDER.md @@ -89,8 +89,9 @@ Groq, endpoint próprio) ou os modelos locais, offline. ### Tasks da v1 (em ordem; uma por vez, gate verde) -- **V1-0** — Mesclar a PR #7 (skins até 120 fps, mascote, provedores Anthropic/Google, agente fase 1) - com o CI verde. +- ~~**V1-0** — Mesclar a PR #7 (skins até 120 fps, mascote, provedores Anthropic/Google, agente fase 1) + com o CI verde.~~ **Feito em 2026-10-10** (merge `4cd4ee4`, CI #35 verde sem repetição). No caminho: repo + público, regra "main protegida", gate que repete 1× o que falha, esteira de instabilidade (TESTING.md). - **V1-1** — "Em breve" e fim das cascas inertes (listas acima). Instrumentado + goldens das rotas. - **V1-2** — Higiene do release, pendências herdadas do trabalho do Gemini: logs que imprimem o texto ditado (`KeyFlowController`, `OverlayCoordinator` — `take(30)`) só no debug; leitura dos segredos fora @@ -389,7 +390,7 @@ texto literal da doc da OpenAI). Ordem: RSN-T0 → T1 → … → T19 (uma por v ``` feito: Q1..Q11 → SB → GT → NM → RSN → SKP → AGT fase 1 (faltam só manuais [YAN]) -agora: V1-0 (PR #7) → V1-1..V1-8 → v1 na Play Store +agora: V1-1..V1-8 → v1 na Play Store (V1-0, PR #7, mesclada em 2026-10-10) em paralelo: API-T13 (staging) → [conta Play + service account] → v1.1 = MZ-T1..T15 + API-T14 depois: VC (visão) · TS (temas, spec a revisar) · AGT fases 2/3 · Fala/Fluxos/Computador (sem spec) ``` From c099b533d96e9621d48e95acd3cbdb4085691bf3 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 11:52:23 -0300 Subject: [PATCH 06/23] =?UTF-8?q?test(teclado):=20a=20CanvasGridTest=20par?= =?UTF-8?q?a=20o=20servi=C3=A7o=20no=20fim=20=E2=80=94=20a=20grava=C3=A7?= =?UTF-8?q?=C3=A3o=20do=20teste=20do=20mic=20seguia=20por=20baixo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canvasGridDoesNotDisturbTheMicButton inicia uma gravação de verdade e o tearDown só zerava o estado exibido. A gravação continuava no OverlayService: na esteira de instabilidade, o KeyFlowKeyboardBarsTest seguinte achava "● 2:20" no lugar do "🎙" em 134 de 134 voltas, e o OverlayServiceCancelTest começava com o serviço já gravando (o ACTION_RECORD virava "parar e enviar"). No gate o teste seguinte do mesmo shard herdava a mesma gravação. --- .../java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index b1f3f8ed..feb5d278 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -52,6 +52,9 @@ class KeyFlowKeyboardCanvasGridTest { @After fun tearDown() { + // canvasGridDoesNotDisturbTheMicButton deixa uma gravação de verdade no serviço: sem parar, ela seguia por baixo e o + // próximo teste de mic encontrava "● 2:20" no lugar do "🎙" (esteira de instabilidade, 134/134 voltas, 2026-10-10). + runCatching { com.keyflow.overlay.OverlayService.stop(context) } userSettings.restore() device.pressHome() // KeyFlowController é singleton de processo (sobrevive ao fim deste teste, dentro do mesmo gate). From bbf49497f23cae2dddc9298594cee8f9baff0500 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 11:52:23 -0300 Subject: [PATCH 07/23] =?UTF-8?q?ci(esteira):=20o=20aviso=20tira=20o=20Uni?= =?UTF-8?q?code=20de=204=20bytes=20e=20corta=20por=20caractere=20=E2=80=94?= =?UTF-8?q?=20o=20GitHub=20recusava=20a=20descri=C3=A7=C3=A3o=20com=20emoj?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci-stress.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index ccd107f4..af7419ed 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -66,9 +66,11 @@ announce_failure() { [ -n "${GITHUB_SHA:-}" ] && [ -n "${GH_TOKEN:-}" ] || return 0 test=$(head -1 "$dir/failed.txt" | sed 's/^.*\.//') error=$(grep -A1 "^ ✗ " "$dir/summary.txt" | sed -n 2p | sed 's/^ *//') + # A descrição aceita até 140 caracteres e recusa Unicode de 4 bytes (o "🎙" de um erro derrubava o aviso). gh api -X POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" -f state=failure \ -f context="esteira/${STRESS_WORKER:-?}/volta-$round" \ - -f description="$(printf '%s: %s' "${test:-processo caiu}" "$error" | cut -c1-140)" \ + -f description="$(printf '%s: %s' "${test:-processo caiu}" "$error" | + python3 -c 'import sys; print("".join(c for c in sys.stdin.read() if ord(c) <= 0xFFFF).strip()[:140])')" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null || true } From 33b9d182f7607d2639349908ea32b2517923e1e1 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 11:56:00 -0300 Subject: [PATCH 08/23] =?UTF-8?q?fix(testes):=20o=201=C2=BA=20teste=20de?= =?UTF-8?q?=20teclado=20espera=20o=20sistema=20registrar=20o=20KeyFlow=20r?= =?UTF-8?q?einstalado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira de instabilidade (38057718808): logo depois do `adb install -r`, o sistema reconstrói a lista de teclados em segundo plano e recusa `ime enable` e `ime set` do KeyFlow ("unrecognized IME ID"). No emulador lento isso passou de 75 s; o 1º teste de teclado da volta esperou 30 s por um teclado que não tinha como subir (holdingAKey… "KeyFlowKeyboardView não anexou"). O awaitKeyboardAttached repetia só o `ime set` — sem o `enable` aceito, nunca voltava — e um toque no campo recriado derrubava a espera (StaleObjectException). O gate e a esteira esperam `ime list -a -s` listar o KeyFlow depois de instalar (até 180 s, com o tempo no log); o awaitKeyboardAttached repete `enable` + `set` e tolera o campo recriado. --- .../java/com/keyflow/ime/ImeTestTimeouts.kt | 9 ++++++++- scripts/ci-device-gate.sh | 17 +++++++++++++++++ scripts/ci-stress.sh | 17 +++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt index 18d5ca8c..d3a11da7 100644 --- a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt +++ b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt @@ -33,6 +33,12 @@ private const val KEYFLOW_IME = "com.keyflow/.ime.KeyFlowKeyboardService" * a frio e, no fim da inicialização, regrava `default_input_method` com o valor que leu antes. O * `ime set` deste teste caiu 2 ms antes dessa gravação, o sistema voltou para o Samsung e nenhum toque * no campo trazia o KeyFlow de volta. + * + * O `ime enable` também é repetido: logo depois de (re)instalar o app, o sistema ainda não registrou o + * teclado e recusa os dois comandos ("unrecognized IME ID") — no emulador lento da esteira de + * instabilidade isso durou mais de 75 s (2026-10-10). Só o `ime set` de novo não bastava: sem o + * `enable` aceito, o KeyFlow nunca virava o teclado. O toque é tolerante ao campo recriado pelo + * relançamento (StaleObjectException derrubava a espera inteira). */ internal fun awaitKeyboardAttached(device: UiDevice, relaunch: () -> Unit) { val deadline = System.currentTimeMillis() + IME_COLD_ATTACH_TIMEOUT_MS @@ -41,11 +47,12 @@ internal fun awaitKeyboardAttached(device: UiDevice, relaunch: () -> Unit) { if (KeyFlowKeyboardService.activeViewForTest != null) return if (System.currentTimeMillis() >= nextNudge) { if (device.executeShellCommand("settings get secure default_input_method").trim() != KEYFLOW_IME) { + device.executeShellCommand("ime enable $KEYFLOW_IME") device.executeShellCommand("ime set $KEYFLOW_IME") } relaunch() device.wait(Until.hasObject(By.desc("ime_probe_field")), IME_ATTACH_NUDGE_MS) - device.findObject(By.desc("ime_probe_field"))?.click() + runCatching { device.findObject(By.desc("ime_probe_field"))?.click() } nextNudge = System.currentTimeMillis() + IME_ATTACH_NUDGE_MS } Thread.sleep(50) diff --git a/scripts/ci-device-gate.sh b/scripts/ci-device-gate.sh index ea11389d..078835ef 100755 --- a/scripts/ci-device-gate.sh +++ b/scripts/ci-device-gate.sh @@ -29,6 +29,7 @@ APK_DIR=app/build/outputs/apk APP_APK="$APK_DIR/debug/app-debug.apk" TEST_APK="$APK_DIR/androidTest/debug/app-debug-androidTest.apk" RUNNER=com.keyflow.test/androidx.test.runner.AndroidJUnitRunner +KEYFLOW_IME=com.keyflow/.ime.KeyFlowKeyboardService # O que passou na 1ª tentativa, lido pelo processo do app na repetição (notTestFile). PASSED_ON_DEVICE=/data/local/tmp/keyflow-passed.txt # Nome do job que publica os APKs — tem que bater com o `name:` dele em checks.yml. @@ -121,6 +122,21 @@ fetch_apks() { # Depois do `adb install` o app fica `run-from-apk`: sem a verificação gravada, cada abertura a frio # verifica as classes de novo. O `compile` grava (APK debuggable para em `verify`, sem AOT, e basta): # medido no emulador local, a GalleryActivity abre em 1,95 s contra 2,7 s. +# Depois de (re)instalar, o sistema reconstrói a lista de teclados em segundo plano; até lá, `ime enable` e +# `ime set` do KeyFlow são recusados ("unrecognized IME ID") e o 1º teste de teclado espera um teclado que +# não tem como subir. No emulador lento da esteira de instabilidade isso passou de 75 s (2026-10-10). +wait_ime_registered() { + local start=$SECONDS + until "$ADB" shell ime list -a -s 2>/dev/null | tr -d '\r' | grep -qx "$KEYFLOW_IME"; do + if [ $((SECONDS - start)) -ge 180 ]; then + echo "::error::o sistema não registrou o teclado do KeyFlow em 180 s" + return 1 + fi + sleep 2 + done + echo "teclado do KeyFlow registrado pelo sistema em $((SECONDS - start)) s" +} + install_apks() { local apk pkg for apk in "$@"; do @@ -130,6 +146,7 @@ install_apks() { "$ADB" shell pm path "$pkg" >/dev/null 2>&1 || continue "$ADB" shell cmd package compile -m speed -f "$pkg" || return 1 done + wait_ime_registered } # localModelE2E: liga o ponta a ponta do modelo local (baixa o Whisper base, ~160 MB, e transcreve). diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index af7419ed..ba581c1f 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -29,6 +29,7 @@ ADB="${ANDROID_HOME:+$ANDROID_HOME/platform-tools/}adb" APP_APK=app/build/outputs/apk/debug/app-debug.apk TEST_APK=app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk RUNNER=com.keyflow.test/androidx.test.runner.AndroidJUnitRunner +KEYFLOW_IME=com.keyflow/.ime.KeyFlowKeyboardService mkdir -p "$OUT" # Sem diálogo de "app não está respondendo" segurando o foco (a mesma lição do ci-device-gate.sh). @@ -53,10 +54,26 @@ limit_cpu() { echo "QEMU $pid: cgroup $(cut -d: -f3 "/proc/$pid/cgroup"), cpu.max $(cat "$cg/cpu.max")" } +# Depois de (re)instalar, o sistema reconstrói a lista de teclados em segundo plano; até lá, `ime enable` e +# `ime set` do KeyFlow são recusados ("unrecognized IME ID") e o 1º teste de teclado espera um teclado que +# não tem como subir. No emulador lento da esteira de instabilidade isso passou de 75 s (2026-10-10). +wait_ime_registered() { + local start=$SECONDS + until "$ADB" shell ime list -a -s 2>/dev/null | tr -d '\r' | grep -qx "$KEYFLOW_IME"; do + if [ $((SECONDS - start)) -ge 180 ]; then + echo "::error::o sistema não registrou o teclado do KeyFlow em 180 s" + return 1 + fi + sleep 2 + done + echo "teclado do KeyFlow registrado pelo sistema em $((SECONDS - start)) s" +} + # Depois do `adb install` o app fica `run-from-apk`; o compile grava a verificação (ci-device-gate.sh). install_app() { "$ADB" install -r -t "$APP_APK" >/dev/null || return 1 "$ADB" shell cmd package compile -m speed -f com.keyflow >/dev/null + wait_ime_registered } # O aviso antecipado: um status no commit, que a API mostra com o job ainda rodando. Fora do Actions From b5e52ce466f5bc5205e5229abdbd540d762bfe91 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 11:57:56 -0300 Subject: [PATCH 09/23] =?UTF-8?q?test(teclado):=20a=20falha=20de=20digita?= =?UTF-8?q?=C3=A7=C3=A3o=20na=20grade=20diz=20se=20o=20toque=20chegou=20ao?= =?UTF-8?q?=20teclado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Na esteira (38057718808), typingCommits e slidingToAnotherKey falharam com o teclado já anexado e registrado: o toque no "q" não chegou ao campo, e o logcat não separa "o toque caiu na janela do probe, por baixo" de "a grade recebeu e a tecla se perdeu depois". A grade conta os eventos de toque e guarda o último (ação e posição, sem custo de texto por evento); as mensagens dos testes de digitação trazem isso, o texto do campo e a janela do IME vista pelo sistema de entrada. A leitura do campo tolera o nó trocado (StaleObjectException). --- .../ime/KeyFlowKeyboardCanvasGridTest.kt | 18 ++++++++++++------ .../keyflow/ime/keyboard/KeyboardGridView.kt | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index feb5d278..d771edd2 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -70,8 +70,8 @@ class KeyFlowKeyboardCanvasGridTest { tapKey(grid!!, "q") - waitUntil("o campo não recebeu \"q\" digitado pela grade") { - device.findObject(By.desc("ime_probe_field"))?.text?.contains("q") == true + waitUntil({ "o campo não recebeu \"q\" digitado pela grade (${typingState(grid!!, "q")})" }) { + fieldText()?.contains("q") == true } } @@ -86,7 +86,7 @@ class KeyFlowKeyboardCanvasGridTest { inject(MotionEvent.ACTION_DOWN, q.centerX, q.centerY, down) inject(MotionEvent.ACTION_UP, q.right + q.width / 10, q.centerY, down) - waitUntil("o \"q\" tocado se perdeu com o dedo escorregando para o \"w\"") { fieldText() == "q" } + waitUntil({ "o \"q\" tocado se perdeu com o dedo escorregando para o \"w\" (${typingState(grid, "q")})" }) { fieldText() == "q" } } /** Arrastar até outra tecla antes de soltar digita a tecla onde o dedo saiu. */ @@ -102,7 +102,7 @@ class KeyFlowKeyboardCanvasGridTest { inject(MotionEvent.ACTION_MOVE, e.centerX, e.centerY, down) inject(MotionEvent.ACTION_UP, e.centerX, e.centerY, down) - waitUntil("arrastar do \"q\" até o \"e\" deveria digitar \"e\"") { fieldText() == "e" } + waitUntil({ "arrastar do \"q\" até o \"e\" deveria digitar \"e\" (${typingState(grid, "e")})" }) { fieldText() == "e" } } /** @@ -122,7 +122,7 @@ class KeyFlowKeyboardCanvasGridTest { injectPointers(pointerAction(MotionEvent.ACTION_POINTER_UP, 1), down, listOf(q.centerX to q.centerY, p.centerX to p.centerY)) injectPointers(MotionEvent.ACTION_UP, down, listOf(q.centerX to q.centerY)) - waitUntil("dois dedos sobrepostos deveriam digitar \"qp\", na ordem do toque") { fieldText() == "qp" } + waitUntil({ "dois dedos sobrepostos deveriam digitar \"qp\", na ordem do toque (${typingState(grid, "p")})" }) { fieldText() == "qp" } } /** Segurar uma tecla com acentos abre a tira com a 1ª variante já escolhida; soltar sem mexer insere ela. */ @@ -226,7 +226,13 @@ class KeyFlowKeyboardCanvasGridTest { ) } - private fun fieldText(): String? = device.findObject(By.desc("ime_probe_field"))?.text + // O nó do campo pode ser trocado entre achar e ler (StaleObjectException derrubava o teste): lê de novo na próxima volta. + private fun fieldText(): String? = runCatching { device.findObject(By.desc("ime_probe_field"))?.text }.getOrNull() + + /** Para a mensagem de falha de digitação: o estado do teclado, o texto do campo e se a grade chegou a ver o toque. */ + private fun typingState(grid: com.keyflow.ime.keyboard.KeyboardGridView, label: String): String = + "campo=\"${fieldText()}\", a grade viu ${grid.touchesSeenForTest} eventos de toque, o último ${grid.lastTouchForTest}; " + + keyboardState(grid, keyOnScreen(grid, label)) /** Acentos existem em pt (não em en): fixa pt durante [block] e devolve o idioma que o aparelho tinha. */ private fun withPortugueseKeyboard(block: () -> Unit) { diff --git a/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt b/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt index 956ee277..971c3374 100644 --- a/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt +++ b/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt @@ -185,6 +185,19 @@ class KeyboardGridView( /** Largura de um chip da tira aberta, só para o instrumentado andar o dedo um chip para o lado. */ val accentChipWidthForTest: Float? get() = accentStrips.values.firstOrNull()?.chipWidth + /** + * Quantos eventos de toque a grade recebeu e o último (ação e posição na tela), só para o instrumentado dizer, quando + * uma tecla não chega ao campo, se o toque nem chegou ao teclado (caiu na janela de baixo) ou chegou e se perdeu depois. + */ + var touchesSeenForTest: Int = 0 + private set + val lastTouchForTest: String + get() = if (lastTouchAction < 0) "nenhum" else + "${MotionEvent.actionToString(lastTouchAction)} em (${lastTouchRawX.toInt()}, ${lastTouchRawY.toInt()})" + private var lastTouchAction = -1 + private var lastTouchRawX = 0f + private var lastTouchRawY = 0f + /** * Altura de fileira: o ideal ([IDEAL_ROW_HEIGHT_DP]) até o limite de [keyboardHeightFraction] da altura da tela pro * teclado inteiro — sem isto, a grade mantinha a MESMA altura absoluta em paisagem (bem mais baixa), @@ -342,6 +355,10 @@ class KeyboardGridView( @Suppress("ClickableViewAccessibility") // a11y própria chega na KC-T8 (ExploreByTouchHelper) override fun onTouchEvent(event: MotionEvent): Boolean { + touchesSeenForTest++ + lastTouchAction = event.actionMasked + lastTouchRawX = event.rawX + lastTouchRawY = event.rawY when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { // 1º dedo de um gesto novo: sobra de um gesto que não terminou (UP perdido) é descartada, From 97be265eb71bcb601364b412d4aee7a727cb30e9 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 13:05:49 -0300 Subject: [PATCH 10/23] test(teclado): a falha da grade traz a janela do IME vista pelo gerenciador de janelas e a tela MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira 38061331651: nas 5 falhas da tira de acentos o sistema de entrada via a janela do teclado em [0,1739][1080,2526] — 126 px (a barra de navegação com o teclado aberto) abaixo do normal [0,1613][1080,2400] — enquanto a view achava que estava no lugar; o toque calculado pela view caía 53 px acima da grade. Para separar "janela desenhada no lugar errado" de "só a área de toque errada", a mensagem traz agora a raiz da view (posição e tamanho), o que o gerenciador de janelas guardou (altura pedida, frame, estado de desenho, leash de insets) e uma captura da tela na hora da falha, que a esteira traz para a evidência. Conferido com uma falha provisória no emulador local (a 1ª versão com Regex quebrava no ICU do Android). --- .../ime/KeyFlowKeyboardCanvasGridTest.kt | 36 ++++++++++++++++++- scripts/ci-stress.sh | 4 +++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index d771edd2..25defcc6 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -334,7 +334,41 @@ class KeyFlowKeyboardCanvasGridTest { return "toque em (${key.centerX}, ${key.centerY}); grade em (${loc[0]}, ${loc[1]}) ${grid.width}x${grid.height}, " + "visível=${grid.isShown}, mesma view ativa=${view?.canvasGridForTest === grid}, expandido=${view?.expandedForTest}, " + "camada=${grid.currentLayerIdForTest}, fileiras=${grid.placedRowsForTest.size}, idioma salvo=$language; " + - "janela do IME para o sistema de entrada: ${imeInputWindow()}" + "janela do IME para o sistema de entrada: ${imeInputWindow()}; " + + "raiz da view: ${rootViewState()}; gerenciador de janelas: ${imeWindowManagerState()}; tela: ${screenshotOnFailure()}" + } + + /** Onde a própria view acha que a janela do teclado está — comparar com o que o sistema de entrada vê. */ + private fun rootViewState(): String { + val root = KeyFlowKeyboardService.activeViewForTest?.rootView ?: return "sem view" + val loc = IntArray(2).also { root.getLocationOnScreen(it) } + return "(${loc[0]}, ${loc[1]}) ${root.width}x${root.height}" + } + + /** + * A janela do IME para o gerenciador de janelas: altura pedida, frame e se ainda está presa numa animação de entrada + * (`insets_animation`). Esteira de instabilidade, 2026-10-10: na falha, o sistema de entrada via a janela 126 px abaixo + * de onde a view achava que estava ([0,1739][1080,2526] × grade em 1686), com o pedido de mostrar o teclado repetido e a + * animação cancelada logo antes. + */ + private fun imeWindowManagerState(): String { + val lines = shell("dumpsys window windows").lines() + // Sem Regex com chaves: o ICU do Android recusa o "}" solto (PatternSyntaxException derrubava o diagnóstico). + val isWindowHeader = { line: String -> line.trim().startsWith("Window #") && line.contains(" Window{") } + val start = lines.indexOfFirst { isWindowHeader(it) && it.contains("InputMethod}") } + if (start < 0) return "não listada" + val block = lines.drop(start + 1).takeWhile { !isWindowHeader(it) } + val requested = block.firstOrNull { it.trim().startsWith("Requested ") }?.trim()?.substringBefore(" mLayoutSeq") + val frames = block.firstOrNull { it.trim().startsWith("Frames:") }?.let { Regex("frame=\\S+ last=\\S+").find(it)?.value } + val draw = block.firstOrNull { it.contains("mDrawState=") }?.let { Regex("mDrawState=\\S+").find(it)?.value } + val leash = if (block.any { it.contains("insets_animation") }) "com leash de insets" else "sem leash de insets" + return "$requested $frames $draw $leash" + } + + /** Captura da tela na hora da falha, nos arquivos do app — a esteira (scripts/ci-stress.sh) a traz para a evidência. */ + private fun screenshotOnFailure(): String { + val file = java.io.File(context.getExternalFilesDir(null), "falha-${System.currentTimeMillis()}.png") + return if (runCatching { device.takeScreenshot(file) }.getOrDefault(false)) file.name else "falhou" } /** diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index ba581c1f..8588df33 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -30,6 +30,7 @@ APP_APK=app/build/outputs/apk/debug/app-debug.apk TEST_APK=app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk RUNNER=com.keyflow.test/androidx.test.runner.AndroidJUnitRunner KEYFLOW_IME=com.keyflow/.ime.KeyFlowKeyboardService +FAILURE_SHOTS=/sdcard/Android/data/com.keyflow/files mkdir -p "$OUT" # Sem diálogo de "app não está respondendo" segurando o foco (a mesma lição do ci-device-gate.sh). @@ -134,6 +135,7 @@ while [ "$SECONDS" -lt "$deadline" ]; do break fi "$ADB" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS >/dev/null 2>&1 || true + "$ADB" shell rm -f "$FAILURE_SHOTS/falha-*.png" >/dev/null 2>&1 || true "$ADB" shell am instrument -w -r -e class "$CLASSES" -e motionFrames false "$RUNNER" > "$dir/raw.txt" # --retry-follows: sem anotação por volta (seriam centenas); o relatório anota no fim. env -u GITHUB_STEP_SUMMARY python3 scripts/ci-instrument-summary.py --label "volta $i" --retry-follows \ @@ -147,6 +149,8 @@ while [ "$SECONDS" -lt "$deadline" ]; do failed_rounds=$((failed_rounds + 1)) sed -n "$((from + 1)),\$p" "$OUT/logcat-full.txt" > "$dir/logcat.txt" "$ADB" exec-out screencap -p > "$dir/tela.png" 2>/dev/null || rm -f "$dir/tela.png" + # As capturas que os próprios testes tiram na hora da falha (KeyFlowKeyboardCanvasGridTest.screenshotOnFailure). + "$ADB" pull "$FAILURE_SHOTS/." "$dir/" >/dev/null 2>&1 || true echo "volta $i ($((SECONDS - start)) s):$score — FALHOU: $(paste -sd' ' "$dir/failed.txt")" announce_failure "$dir" "$i" fi From 731b13a86be33b75609c1f0d563fe07a7554eb28 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 14:02:22 -0300 Subject: [PATCH 11/23] =?UTF-8?q?test(teclado):=20toque=20injetado=20devag?= =?UTF-8?q?ar=20n=C3=A3o=20vira=20"segurar"=20nos=20testes=20que=20s=C3=B3?= =?UTF-8?q?=20tocam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira 38066247962: typingCommits digitou "1" no lugar de "q" e o rollover "q0" no lugar de "qp", com a grade vendo o toque no lugar certo. No emulador lento a injeção do DOWN → UP passa dos 250 ms da tira de acentos, o toque vira "segurar" e soltar no lugar insere a 1ª variante (o número da tecla). accentHoldMsForTest alonga o segurar nos testes que só tocam (CanvasGridTest, SpellCheckerTest); os testes da tira de acentos seguem com os 250 ms do produto. --- .../com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt | 7 +++++++ .../com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt | 3 +++ .../java/com/keyflow/ime/keyboard/KeyboardGridView.kt | 10 +++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index 25defcc6..5c1aa726 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -48,10 +48,14 @@ class KeyFlowKeyboardCanvasGridTest { userSettings.capture() // A grade só entra na hierarquia (e só é medida) com o teclado expandido. userSettings.forceExpanded() + // Toque que demora a ser injetado no emulador lento não pode virar "segurar" (o "q" digitava "1"). Os testes da tira de + // acentos voltam para os 250 ms do produto — ver withPortugueseKeyboard. + com.keyflow.ime.keyboard.accentHoldMsForTest = TAP_ONLY_HOLD_MS } @After fun tearDown() { + com.keyflow.ime.keyboard.accentHoldMsForTest = null // canvasGridDoesNotDisturbTheMicButton deixa uma gravação de verdade no serviço: sem parar, ela seguia por baixo e o // próximo teste de mic encontrava "● 2:20" no lugar do "🎙" (esteira de instabilidade, 134/134 voltas, 2026-10-10). runCatching { com.keyflow.overlay.OverlayService.stop(context) } @@ -236,6 +240,8 @@ class KeyFlowKeyboardCanvasGridTest { /** Acentos existem em pt (não em en): fixa pt durante [block] e devolve o idioma que o aparelho tinha. */ private fun withPortugueseKeyboard(block: () -> Unit) { + // Os testes da tira de acentos são os que testam o segurar: tempo do produto. + com.keyflow.ime.keyboard.accentHoldMsForTest = null val repo = SettingsRepository(context) val original = runBlocking { repo.keyboardLanguageOnce() } runBlocking { repo.saveKeyboardLanguageOnce("pt") } @@ -398,5 +404,6 @@ class KeyFlowKeyboardCanvasGridTest { private companion object { const val IME = "com.keyflow/.ime.KeyFlowKeyboardService" const val TIMEOUT = 5000L + const val TAP_ONLY_HOLD_MS = 10_000L } } diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt index 3c6ad895..13ee8d73 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt @@ -55,10 +55,13 @@ class KeyFlowKeyboardSpellCheckerTest { userSettings.capture() // A grade só entra na hierarquia (e só é medida) com o teclado expandido. userSettings.forceExpanded() + // Aqui só se toca: um toque lento de injetar no emulador do CI não pode abrir a tira de acentos. + com.keyflow.ime.keyboard.accentHoldMsForTest = 10_000L } @After fun tearDown() { + com.keyflow.ime.keyboard.accentHoldMsForTest = null userSettings.restore() device.pressHome() KeyFlowController.clearInserter() diff --git a/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt b/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt index 971c3374..2d95e619 100644 --- a/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt +++ b/app/src/main/java/com/keyflow/ime/keyboard/KeyboardGridView.kt @@ -36,6 +36,14 @@ import com.keyflow.ime.keyboard.layout.PlacedKey import com.keyflow.ime.keyboard.layout.PlacedRow import com.keyflow.ime.keyboard.layout.RepeatSchedule +/** + * Quanto segurar até a tira de acentos abrir, no lugar dos [KeyboardGridView] 250 ms — só para o instrumentado. No emulador + * lento do CI a própria injeção de um toque (DOWN → UP) passa de 250 ms: o toque virava "segurar" e o "q" digitava "1" + * (esteira de instabilidade, 2026-10-10). Os testes que só tocam alongam; os que testam o segurar deixam `null` (250 ms). + */ +@Volatile +var accentHoldMsForTest: Long? = null + /** * A grade de teclas (QWERTY/`?123`) desenhada numa única `View` em `Canvas` (RND-01). O rendering é * **função pura** do [KeyboardGridState]/[KeyboardLayout] — nada de `View` por tecla. Mic, action row e @@ -608,7 +616,7 @@ class KeyboardGridView( if (pressedKeys[pointerId] === placed) openAccentStrip(pointerId, placed) } accentTimers[pointerId] = runnable - accentHandler.postDelayed(runnable, ACCENT_HOLD_MS) + accentHandler.postDelayed(runnable, accentHoldMsForTest ?: ACCENT_HOLD_MS) } private fun cancelAccentTimer(pointerId: Int) { From 1f9ad86160a00f31636703315218cf0df7d1e14a Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 14:06:05 -0300 Subject: [PATCH 12/23] =?UTF-8?q?test(teclado):=20probe=20opcional=20num?= =?UTF-8?q?=20processo=20separado=20do=20teclado,=20e=20a=20esteira=20comp?= =?UTF-8?q?ara=20condi=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira 38066247962: as capturas na hora da falha mostraram o teclado desenhado fora do lugar — 126 px abaixo, com a fileira do espaço cortada, ou no topo da tela —, com o gerenciador de janelas e a view guardando a posição certa: quem errou foi a camada da animação de entrada. Essa camada é controlada pela tela que recebe o teclado, e o ImeProbeActivity roda no processo do próprio KeyFlow; no uso real são processos diferentes. ImeProbeRemoteActivity é o mesmo probe em `:probe`, escolhido na CanvasGridTest por `-e probeProcess remoto` (o padrão segue o de hoje, sem mudar o gate). A esteira aceita argumentos extras do teste (input instrument_args / STRESS_INSTRUMENT_ARGS) e o relatório separa as falhas por condição (CPU + argumentos). Conferido no emulador local: os 7 testes da CanvasGridTest passam com o campo no processo com.keyflow:probe. --- .github/workflows/stress.yml | 4 ++++ .../com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt | 10 +++++++++- app/src/debug/AndroidManifest.xml | 6 ++++++ app/src/debug/java/com/keyflow/ui/ImeProbeActivity.kt | 2 +- .../java/com/keyflow/ui/ImeProbeRemoteActivity.kt | 10 ++++++++++ scripts/ci-stress-report.py | 5 ++++- scripts/ci-stress.sh | 5 ++++- 7 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 app/src/debug/java/com/keyflow/ui/ImeProbeRemoteActivity.kt diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index 08e96be6..000ad20b 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -23,6 +23,9 @@ on: minutes: description: Minutos de laço em cada emulador (até 45) default: '40' + instrument_args: + description: Argumentos extras do teste, separados por espaço (ex. -e probeProcess remoto) + default: '' permissions: contents: read # Os status `esteira//volta-N` do aviso antecipado (scripts/ci-stress.sh). @@ -119,6 +122,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} STRESS_WORKER: ${{ matrix.worker }}-cpu${{ matrix.cpu || 'livre' }} + STRESS_INSTRUMENT_ARGS: ${{ inputs.instrument_args }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 34 diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index 5c1aa726..56366755 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -188,7 +188,7 @@ class KeyFlowKeyboardCanvasGridTest { shell("ime set $IME") val intent = Intent() - .setClassName(context.packageName, "com.keyflow.ui.ImeProbeActivity") + .setClassName(context.packageName, probeActivity()) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) context.startActivity(intent) @@ -200,6 +200,14 @@ class KeyFlowKeyboardCanvasGridTest { awaitKeyboardAttached(device) { context.startActivity(intent) } } + /** + * `-e probeProcess remoto` troca o campo pelo [com.keyflow.ui.ImeProbeRemoteActivity], num processo separado do teclado, + * como num app de verdade — o experimento da esteira sobre a animação de entrada do teclado fora do lugar. + */ + private fun probeActivity(): String = + if (InstrumentationRegistry.getArguments().getString("probeProcess") == "remoto") "com.keyflow.ui.ImeProbeRemoteActivity" + else "com.keyflow.ui.ImeProbeActivity" + /** Acha a tecla [label] na camada de letras via geometria já computada e injeta um toque real nela. */ private fun tapKey(grid: com.keyflow.ime.keyboard.KeyboardGridView, label: String) { waitUntil("grade Canvas ainda sem geometria medida") { grid.placedRowsForTest.isNotEmpty() } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 9556c225..b7c3c0bf 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -17,5 +17,11 @@ + + + diff --git a/app/src/debug/java/com/keyflow/ui/ImeProbeActivity.kt b/app/src/debug/java/com/keyflow/ui/ImeProbeActivity.kt index 3a0f0a03..1c45a886 100644 --- a/app/src/debug/java/com/keyflow/ui/ImeProbeActivity.kt +++ b/app/src/debug/java/com/keyflow/ui/ImeProbeActivity.kt @@ -17,7 +17,7 @@ import android.widget.FrameLayout * Activity debug com um único `EditText` focado — só para o teste de superfícies de sistema (T25) * capturar o IME real sobre um campo. Exportada para `am start` via shell; não tem launcher. */ -class ImeProbeActivity : Activity() { +open class ImeProbeActivity : Activity() { private var field: EditText? = null diff --git a/app/src/debug/java/com/keyflow/ui/ImeProbeRemoteActivity.kt b/app/src/debug/java/com/keyflow/ui/ImeProbeRemoteActivity.kt new file mode 100644 index 00000000..9f97db3f --- /dev/null +++ b/app/src/debug/java/com/keyflow/ui/ImeProbeRemoteActivity.kt @@ -0,0 +1,10 @@ +package com.keyflow.ui + +/** + * O mesmo probe num processo só dele (`:probe`, no AndroidManifest do debug) — como um app de verdade, com o teclado e a + * tela onde se digita em processos diferentes. O [ImeProbeActivity] roda no processo do próprio KeyFlow, e é lá que a + * esteira de instabilidade (2026-10-10) viu a camada da animação de entrada do teclado ficar fora do lugar (126 px abaixo, + * ou no topo da tela) no emulador lento. Este probe diz se isso é efeito de dividir o processo com o teclado. + * Escolhido pelo argumento `-e probeProcess remoto` na KeyFlowKeyboardCanvasGridTest. + */ +class ImeProbeRemoteActivity : ImeProbeActivity() diff --git a/scripts/ci-stress-report.py b/scripts/ci-stress-report.py index 7a69871a..68b740be 100755 --- a/scripts/ci-stress-report.py +++ b/scripts/ci-stress-report.py @@ -36,7 +36,10 @@ def main(): workers = [] for artifact in sorted(p for p in root.iterdir() if p.is_dir()): meta = read_lines(artifact / "meta.txt") + # A condição de cada emulador: a CPU e, se houver, os argumentos extras do teste (ex.: -e probeProcess remoto). cpu = next((m.split(": ", 1)[1] for m in meta if m.startswith("cpu do emulador")), "?") + extra = next((m.split(": ", 1)[1] for m in meta if m.startswith("argumentos")), "") + cpu = f"{cpu} {extra}".strip() workers.append((artifact.name, meta)) for round_dir in sorted(artifact.glob("volta-*")): for name in read_lines(round_dir / "passed.txt"): @@ -52,7 +55,7 @@ def main(): out.append(f"{len(workers)} emuladores, {len(runs)} testes, {total_runs} execuções no total.") out.append("") if fails: - out.append("| Teste | " + " | ".join(f"falhas / execuções (CPU {c})" for c in conditions) + " |") + out.append("| Teste | " + " | ".join(f"falhas / execuções ({c})" for c in conditions) + " |") out.append("|---|" + "---|" * len(conditions)) for name in sorted(fails, key=lambda n: -sum(fails[n].values())): cells = [f"{fails[name][c]} / {runs[name][c]}" for c in conditions] diff --git a/scripts/ci-stress.sh b/scripts/ci-stress.sh index 8588df33..0cf7a6e7 100755 --- a/scripts/ci-stress.sh +++ b/scripts/ci-stress.sh @@ -136,7 +136,9 @@ while [ "$SECONDS" -lt "$deadline" ]; do fi "$ADB" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS >/dev/null 2>&1 || true "$ADB" shell rm -f "$FAILURE_SHOTS/falha-*.png" >/dev/null 2>&1 || true - "$ADB" shell am instrument -w -r -e class "$CLASSES" -e motionFrames false "$RUNNER" > "$dir/raw.txt" + # STRESS_INSTRUMENT_ARGS: argumentos extras do teste, separados por espaço (ex.: "-e probeProcess remoto"). + # shellcheck disable=SC2086 + "$ADB" shell am instrument -w -r -e class "$CLASSES" -e motionFrames false ${STRESS_INSTRUMENT_ARGS:-} "$RUNNER" > "$dir/raw.txt" # --retry-follows: sem anotação por volta (seriam centenas); o relatório anota no fim. env -u GITHUB_STEP_SUMMARY python3 scripts/ci-instrument-summary.py --label "volta $i" --retry-follows \ --passed-out "$dir/passed.txt" --failed-out "$dir/failed.txt" < "$dir/raw.txt" > "$dir/summary.txt" @@ -160,6 +162,7 @@ kill "$LOGCAT_PID" 2>/dev/null || true { echo "voltas: $i (com falha: $failed_rounds)" if [ -n "$CPU" ]; then echo "cpu do emulador: $CPU%"; else echo "cpu do emulador: livre"; fi + [ -z "${STRESS_INSTRUMENT_ARGS:-}" ] || echo "argumentos: $STRESS_INSTRUMENT_ARGS" frame_stats } | tee "$OUT/meta.txt" # O logcat inteiro tem centenas de milhares de linhas; as voltas com falha já guardaram o seu trecho. From 11b453d5fba1c84d6cde2c3e63aaf24a37e345e1 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 15:11:35 -0300 Subject: [PATCH 13/23] =?UTF-8?q?test(teclado):=20antes=20de=20tocar,=20es?= =?UTF-8?q?pera=20a=20janela=20do=20teclado=20estar=20onde=20a=20view=20es?= =?UTF-8?q?t=C3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira (38066247962 e 38070354174): sob carga, a janela do teclado ficou para o sistema de entrada 126 px abaixo, no topo da tela ou com a geometria do 1º desenho, enquanto a view e o gerenciador de janelas guardavam a posição certa — por um instante (campo em outro processo) ou por segundos (no mesmo processo). O toque calculado pela view caía fora da grade. awaitImeWindowSettled espera o frame da janela do IME no `dumpsys input` bater com a raiz da view em duas leituras seguidas (até 10 s; senão falha dizendo os dois). Roda no fim do awaitKeyboardAttached (todas as classes de teclado) e antes de cada cálculo de posição de tecla na CanvasGridTest, SpellCheckerTest e LanguageTest. A espera às cegas de 5 s continua por ora: sai num passo à parte, quando a esteira mostrar que esta basta. 17/17 no emulador local. --- .../java/com/keyflow/ime/ImeTestTimeouts.kt | 55 ++++++++++++++++++- .../ime/KeyFlowKeyboardCanvasGridTest.kt | 2 + .../ime/KeyFlowKeyboardLanguageTest.kt | 1 + .../ime/KeyFlowKeyboardSpellCheckerTest.kt | 3 + 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt index d3a11da7..ecd59b4a 100644 --- a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt +++ b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt @@ -1,5 +1,6 @@ package com.keyflow.ime +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until @@ -44,7 +45,7 @@ internal fun awaitKeyboardAttached(device: UiDevice, relaunch: () -> Unit) { val deadline = System.currentTimeMillis() + IME_COLD_ATTACH_TIMEOUT_MS var nextNudge = System.currentTimeMillis() + IME_ATTACH_NUDGE_MS while (System.currentTimeMillis() < deadline) { - if (KeyFlowKeyboardService.activeViewForTest != null) return + if (KeyFlowKeyboardService.activeViewForTest != null) break if (System.currentTimeMillis() >= nextNudge) { if (device.executeShellCommand("settings get secure default_input_method").trim() != KEYFLOW_IME) { device.executeShellCommand("ime enable $KEYFLOW_IME") @@ -57,5 +58,55 @@ internal fun awaitKeyboardAttached(device: UiDevice, relaunch: () -> Unit) { } Thread.sleep(50) } - throw AssertionError("KeyFlowKeyboardView não anexou") + if (KeyFlowKeyboardService.activeViewForTest == null) throw AssertionError("KeyFlowKeyboardView não anexou") + awaitImeWindowSettled(device) } + +/** Prazo para a janela do teclado assentar onde a view está ([awaitImeWindowSettled]). */ +internal const val IME_SETTLE_TIMEOUT_MS = 10_000L + +/** + * Espera a janela do teclado ficar, para o sistema de entrada, exatamente onde a própria view acha que está — em duas + * leituras seguidas. Chamar antes de calcular um toque pela posição da view. + * + * Esteira de instabilidade (2026-10-10): no emulador sob carga a camada da animação de entrada do teclado chegou a ficar + * 126 px abaixo (a barra de navegação que o teclado desenha) ou no topo da tela, com o gerenciador de janelas e a view + * guardando a posição certa — por um instante (campo em outro processo) ou por segundos (campo no processo do teclado). + * O toque calculado pela view caía fora da grade: na barra, no mic, no campo. A captura na hora da falha mostrou o + * teclado desenhado fora do lugar, não só a área de toque. + */ +internal fun awaitImeWindowSettled(device: UiDevice, timeoutMs: Long = IME_SETTLE_TIMEOUT_MS) { + val deadline = System.currentTimeMillis() + timeoutMs + var stable = 0 + var last = "" + while (System.currentTimeMillis() < deadline) { + val expected = keyboardWindowOnScreen() + val seen = imeInputFrame(device) + last = "sistema de entrada=$seen, view=$expected" + if (expected != null && seen == expected) { + if (++stable >= 2) return + } else { + stable = 0 + } + Thread.sleep(100) + } + throw AssertionError("a janela do teclado não assentou onde a view está em ${timeoutMs / 1000} s ($last)") +} + +/** A janela do teclado como a view a vê (raiz da hierarquia), no formato do `dumpsys input`: `[l,t][r,b]`. */ +private fun keyboardWindowOnScreen(): String? { + var frame: String? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val root = KeyFlowKeyboardService.activeViewForTest?.rootView ?: return@runOnMainSync + if (!root.isShown || root.width == 0) return@runOnMainSync + val loc = IntArray(2).also { root.getLocationOnScreen(it) } + frame = "[${loc[0]},${loc[1]}][${loc[0] + root.width},${loc[1] + root.height}]" + } + return frame +} + +/** O frame da janela do IME para o InputDispatcher (`dumpsys input`), ou `null` se ela não está listada. */ +internal fun imeInputFrame(device: UiDevice): String? = + device.executeShellCommand("dumpsys input").lineSequence() + .firstOrNull { it.contains(" InputMethod'") && it.contains("frame=") } + ?.let { Regex("frame=(\\[[^]]*]\\[[^]]*])").find(it)?.groupValues?.get(1) } diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index 56366755..ae01540b 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -211,6 +211,7 @@ class KeyFlowKeyboardCanvasGridTest { /** Acha a tecla [label] na camada de letras via geometria já computada e injeta um toque real nela. */ private fun tapKey(grid: com.keyflow.ime.keyboard.KeyboardGridView, label: String) { waitUntil("grade Canvas ainda sem geometria medida") { grid.placedRowsForTest.isNotEmpty() } + awaitImeWindowSettled(device) val placed = grid.placedRowsForTest.flatMap { it.keys } .first { (it.keyDef.action as? CanvasKeyAction.Char)?.text == label } val loc = IntArray(2).also { grid.getLocationOnScreen(it) } @@ -227,6 +228,7 @@ class KeyFlowKeyboardCanvasGridTest { private fun keyOnScreen(grid: com.keyflow.ime.keyboard.KeyboardGridView, label: String): KeyOnScreen { waitUntil("grade Canvas ainda sem geometria medida") { grid.placedRowsForTest.isNotEmpty() } + awaitImeWindowSettled(device) val placed = grid.placedRowsForTest.flatMap { it.keys } .first { (it.keyDef.action as? CanvasKeyAction.Char)?.text == label } val loc = IntArray(2).also { grid.getLocationOnScreen(it) } diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt index 746b6f97..6c7e415a 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt @@ -128,6 +128,7 @@ class KeyFlowKeyboardLanguageTest { /** Toca a tecla globo (KeyAction.Language) na geometria real da grade. */ private fun tapGlobe(grid: KeyboardGridView) { val placed = grid.placedRowsForTest.flatMap { it.keys }.first { it.keyDef.action == CanvasKeyAction.Language } + awaitImeWindowSettled(device) val loc = IntArray(2).also { grid.getLocationOnScreen(it) } val x = (loc[0] + (placed.left + placed.right) / 2).toInt() val y = (loc[1] + (placed.top + placed.bottom) / 2).toInt() diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt index 13ee8d73..2209bfb1 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt @@ -209,6 +209,7 @@ class KeyFlowKeyboardSpellCheckerTest { waitUntil("grade Canvas ainda sem geometria medida") { grid.placedRowsForTest.isNotEmpty() } val placed = grid.placedRowsForTest.flatMap { it.keys } .first { (it.keyDef.action as? CanvasKeyAction.Char)?.text == label } + awaitImeWindowSettled(device) val loc = IntArray(2).also { grid.getLocationOnScreen(it) } val x = (loc[0] + (placed.left + placed.right) / 2).toInt() val y = (loc[1] + (placed.top + placed.bottom) / 2).toInt() @@ -217,6 +218,7 @@ class KeyFlowKeyboardSpellCheckerTest { private fun tapAction(grid: KeyboardGridView, action: CanvasKeyAction) { val placed = grid.placedRowsForTest.flatMap { it.keys }.first { it.keyDef.action == action } + awaitImeWindowSettled(device) val loc = IntArray(2).also { grid.getLocationOnScreen(it) } tapScreen((loc[0] + (placed.left + placed.right) / 2).toInt(), (loc[1] + (placed.top + placed.bottom) / 2).toInt()) } @@ -236,6 +238,7 @@ class KeyFlowKeyboardSpellCheckerTest { private fun tapBackspaceOnGrid(grid: KeyboardGridView) { val placed = grid.placedRowsForTest.flatMap { it.keys } .first { it.keyDef.action == CanvasKeyAction.Backspace } + awaitImeWindowSettled(device) val loc = IntArray(2).also { grid.getLocationOnScreen(it) } val x = (loc[0] + (placed.left + placed.right) / 2).toInt() val y = (loc[1] + (placed.top + placed.bottom) / 2).toInt() From fc33f7df263ee6e60f23d0487403f8ddf23cd45a Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 15:11:35 -0300 Subject: [PATCH 14/23] =?UTF-8?q?test(teclado):=20medi=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20onde=20a=20janela=20do=20teclado=20fica=20ao=20abrir,=20para?= =?UTF-8?q?=20comparar=20KeyFlow=20e=20Gboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mede, durante cada abertura do campo, o frame da janela do IME no sistema de entrada contra o do gerenciador de janelas — vale para qualquer teclado. É o controle da esteira: se o Gboard, no mesmo emulador e campo, também fica fora do lugar, é do Android/emulador; se só o KeyFlow, é algo nosso (ex.: o 1º desenho demorando mais que o prazo de sincronização do Android). Só roda com `-e placementProbe true` (o gate pula). No emulador local, 3 aberturas cada: o KeyFlow ficou 2 leituras seguidas com a camada do tamanho do 1º desenho ([0,1613][840,1739]); o Gboard, 1 leitura isolada. --- .../com/keyflow/ime/ImeWindowPlacementTest.kt | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 app/src/androidTest/java/com/keyflow/ime/ImeWindowPlacementTest.kt diff --git a/app/src/androidTest/java/com/keyflow/ime/ImeWindowPlacementTest.kt b/app/src/androidTest/java/com/keyflow/ime/ImeWindowPlacementTest.kt new file mode 100644 index 00000000..4b116dfa --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/ime/ImeWindowPlacementTest.kt @@ -0,0 +1,149 @@ +package com.keyflow.ime + +import android.content.Context +import android.content.Intent +import android.os.ParcelFileDescriptor +import android.os.SystemClock +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Medição, não verificação de comportamento: abre o campo do probe várias vezes e, durante cada abertura, compara onde o + * sistema de entrada vê a janela do teclado (`dumpsys input`) com onde o gerenciador de janelas a pôs (`dumpsys window`). + * Vale para qualquer teclado — é o controle com o Gboard da esteira de instabilidade. + * + * Esteira, 2026-10-10: no emulador sob carga a janela do KeyFlow chegou a ficar 126 px abaixo ou no topo da tela, com o + * gerenciador de janelas guardando a posição certa (as capturas mostraram o teclado desenhado fora do lugar). Este teste diz + * se o Gboard, no mesmo emulador e no mesmo campo, faz o mesmo — do Android/emulador — ou se é coisa do KeyFlow. + * + * Só roda com `-e placementProbe true` (a esteira passa; o gate não). `-e placementIme gboard|keyflow` escolhe o teclado e + * `-e probeProcess remoto|local` o campo (ver KeyFlowKeyboardCanvasGridTest.probeActivity). + */ +@RunWith(AndroidJUnit4::class) +class ImeWindowPlacementTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context get() = instrumentation.targetContext + private val device: UiDevice get() = UiDevice.getInstance(instrumentation) + private val args get() = InstrumentationRegistry.getArguments() + private var previousIme: String? = null + + @Before + fun onlyWhenAsked() { + assumeTrue("medição da esteira: só com -e placementProbe true", args.getString("placementProbe") == "true") + previousIme = shell("settings get secure default_input_method").trim() + } + + @After + fun restoreIme() { + previousIme?.takeIf { it.isNotEmpty() && it != "null" }?.let { shell("ime set $it") } + device.pressHome() + } + + @Test + fun theImeWindowIsWhereTheWindowManagerPutIt() { + val ime = if (args.getString("placementIme") == "gboard") GBOARD else KEYFLOW + selectIme(ime) + val probe = if (args.getString("probeProcess") == "remoto") "com.keyflow.ui.ImeProbeRemoteActivity" + else "com.keyflow.ui.ImeProbeActivity" + val openings = args.getString("placementOpenings")?.toIntOrNull() ?: OPENINGS + + var samples = 0 + var displacedSamples = 0 + val displacedOpenings = mutableListOf() + repeat(openings) { opening -> + device.pressHome() + SystemClock.sleep(1_500) + context.startActivity( + Intent().setClassName(context.packageName, probe) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), + ) + device.wait(Until.hasObject(By.desc("ime_probe_field")), 5_000) + runCatching { device.findObject(By.desc("ime_probe_field"))?.click() } + + // Lê durante a abertura; fora do lugar = visível na tela e diferente do gerenciador de janelas, em 2 leituras + // seguidas (a janela entrando por baixo da tela é o normal antes de aparecer e não conta). + var streak = 0 + val seen = mutableListOf() + val end = SystemClock.uptimeMillis() + SAMPLE_MS + while (SystemClock.uptimeMillis() < end) { + val input = imeInputFrame(device) + val wm = windowManagerImeFrame() + if (input == null || wm == null) { + SystemClock.sleep(50) + continue + } + samples++ + val inputTop = top(input) + if (input != wm && inputTop != null && inputTop < device.displayHeight) { + displacedSamples++ + seen += "entrada=$input wm=$wm" + if (++streak == 2) displacedOpenings += "abertura ${opening + 1}: ${seen.takeLast(2).joinToString(" → ")}" + } else { + streak = 0 + } + } + Log.i(TAG, "abertura ${opening + 1}/$openings ($ime): ${seen.size} leituras fora do lugar") + } + + Log.i(TAG, "$ime: $displacedSamples de $samples leituras fora do lugar; aberturas deslocadas: $displacedOpenings") + assertEquals( + "janela do teclado fora do lugar ($ime, ${args.getString("probeProcess") ?: "local"}): " + + "${displacedOpenings.size} de $openings aberturas, $displacedSamples de $samples leituras — $displacedOpenings", + 0, + displacedOpenings.size, + ) + } + + private fun selectIme(ime: String) { + val deadline = SystemClock.uptimeMillis() + 60_000 + while (SystemClock.uptimeMillis() < deadline) { + shell("ime enable $ime") + shell("ime set $ime") + if (shell("settings get secure default_input_method").trim() == ime) return + SystemClock.sleep(1_000) + } + throw AssertionError("não consegui selecionar o teclado $ime") + } + + /** O frame da janela do IME para o gerenciador de janelas, se ela estiver visível. Sem Regex com chaves (ICU). */ + private fun windowManagerImeFrame(): String? { + val lines = shell("dumpsys window windows").lines() + val isHeader = { line: String -> line.trim().startsWith("Window #") && line.contains(" Window{") } + val start = lines.indexOfFirst { isHeader(it) && it.contains("InputMethod}") } + if (start < 0) return null + val block = lines.drop(start + 1).takeWhile { !isHeader(it) } + if (block.none { it.trim() == "isVisible=true" }) return null + val frames = block.firstOrNull { it.trim().startsWith("Frames:") } ?: return null + return Regex("frame=(\\[[^]]*]\\[[^]]*])").find(frames)?.groupValues?.get(1) + } + + private fun top(frame: String): Int? = frame.substringAfter(",").substringBefore("]").toIntOrNull() + + private fun shell(command: String): String { + val fd: ParcelFileDescriptor = instrumentation.uiAutomation.executeShellCommand(command) + try { + return String(java.io.FileInputStream(fd.fileDescriptor).readBytes()) + } finally { + fd.close() + } + } + + private companion object { + const val TAG = "KeyFlow.ImePlacement" + const val KEYFLOW = "com.keyflow/.ime.KeyFlowKeyboardService" + const val GBOARD = "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME" + const val OPENINGS = 8 + const val SAMPLE_MS = 4_000L + } +} From cbebd2b23c3a0b626bba61e6aeb28e663ebc45fb Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 17:03:15 -0300 Subject: [PATCH 15/23] =?UTF-8?q?chore(teclado):=20diagn=C3=B3stico=20da?= =?UTF-8?q?=20abertura=20no=20log=20=E2=80=94=201=C2=BA=20quadro,=20altura?= =?UTF-8?q?=20da=20janela=20e=20recria=C3=A7=C3=B5es=20com=20motivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira de instabilidade (2026-10-10): a janela do KeyFlow ficava fora do lugar ao abrir, e o Gboard não. O log do teclado passa a dizer o tempo do onCreateInputView (leituras de config × montar a view), do onStartInputView até o 1º quadro (e o tamanho da janela nele), cada mudança de altura da janela e cada recriação da view com o motivo. Foi o que mostrou, no emulador lento, a janela nascendo no tamanho de Dialog (840×126) ou na altura da tela (2272) e o 1º quadro passando de 1 s. Nada de dado do usuário. --- .../com/keyflow/ime/KeyFlowKeyboardService.kt | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt b/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt index 5b5cfec8..e51c2edd 100644 --- a/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt +++ b/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt @@ -3,6 +3,7 @@ package com.keyflow.ime import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.text.InputType import android.util.Log import android.view.KeyEvent @@ -170,7 +171,7 @@ class KeyFlowKeyboardService : InputMethodService() { .distinctUntilChanged() .collect { (bars, grids) -> val changed = bars != appliedBarsConfig || grids != appliedGrids - if (keyboardView != null && changed) setInputView(onCreateInputView()) + if (keyboardView != null && changed) recreateInputView("barras/grades mudaram") } } // Tema/tamanho/idioma ao vivo (pedido do Yan, 2026-07-03): antes só aplicava ao reabrir o @@ -186,18 +187,50 @@ class KeyFlowKeyboardService : InputMethodService() { ) { dark, scale, lang, numberRow, skin -> LiveKeyboardConfig(dark, scale, lang, numberRow, skin) } .distinctUntilChanged() .collect { config -> - if (keyboardView != null && config != appliedLiveConfig) setInputView(onCreateInputView()) + if (keyboardView != null && config != appliedLiveConfig) recreateInputView("tema/tamanho/idioma/skin mudou") } } // Pacote de skin instalado, reimportado ou removido (SKP-IMP-05/STO-04): se o que está na tela é um pacote, reabre. O primeiro // valor do fluxo (o de agora) não conta. serviceScope.launch { SkinPackStore.changes.drop(1).collect { - if (keyboardView != null && SkinIds.isPack(appliedLiveConfig?.skin)) setInputView(onCreateInputView()) + if (keyboardView != null && SkinIds.isPack(appliedLiveConfig?.skin)) recreateInputView("pacote de skin mudou") } } } + /** + * Recria a view do teclado (config mudou ao vivo). Com o teclado aberto, a janela troca de conteúdo e pode mudar de tamanho: + * o log diz quando e por quê — a esteira de instabilidade (2026-10-10) viu a janela ficar fora do lugar ao abrir. + */ + private fun recreateInputView(reason: String) { + Log.d(TAG, "recriando a view do teclado: $reason (visível=${keyboardView?.isShown == true})") + setInputView(onCreateInputView()) + } + + /** + * Diagnóstico da abertura: quanto tempo do [onStartInputView] até o 1º quadro entregue, e cada mudança de tamanho da janela. + * Esteira de instabilidade (2026-10-10): sob carga a janela do KeyFlow ficava fora do lugar para a tela e o toque (126 px + * abaixo, no topo, ou com a região tocável vazia) e o Gboard não — com o Android desistindo de esperar o 1º desenho + * ("SurfaceSyncGroup: Failed to receive transaction ready in 1000ms"). + */ + private fun traceFirstFrame(from: String) { + val view = keyboardView ?: return + val started = SystemClock.uptimeMillis() + val observer = view.viewTreeObserver + if (!observer.isAlive) return + observer.registerFrameCommitCallback { + val root = view.rootView + Log.d(TAG, "1º quadro depois de $from: ${SystemClock.uptimeMillis() - started} ms (janela ${root.width}x${root.height})") + } + } + + private val windowSizeLogger = View.OnLayoutChangeListener { v, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (bottom - top != oldBottom - oldTop) { + Log.d(TAG, "janela do teclado mudou de altura: ${oldBottom - oldTop} → ${bottom - top} (largura ${v.width})") + } + } + override fun onWindowShown() { super.onWindowShown() KeyFlowController.setKeyboardVisible(true) @@ -260,7 +293,7 @@ class KeyFlowKeyboardService : InputMethodService() { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) Log.d(TAG, "onConfigurationChanged orientation=${newConfig.orientation} keyboardView=$keyboardView") - if (keyboardView != null) setInputView(onCreateInputView()) + if (keyboardView != null) recreateInputView("configuração do aparelho mudou") } override fun onCreateInputView(): View { @@ -295,6 +328,7 @@ class KeyFlowKeyboardService : InputMethodService() { val vibration = runBlocking { repo.vibrationIntensityOnce() } appliedBarsConfig = barsConfig appliedGrids = grids + val readsDoneNs = System.nanoTime() val view = KeyFlowKeyboardView( context = this, onKey = ::handleKey, @@ -341,7 +375,15 @@ class KeyFlowKeyboardService : InputMethodService() { // estava (ex.) pausada quando o teclado abriu, a view nasceria dessincronizada (ex.: trocar de // teclado por atalho com o overlay pausado). view.updateMicState(KeyFlowController.buttonState.value) - Log.d(TAG, "onCreateInputView: ${(System.nanoTime() - createStartedNs) / 1_000_000} ms (skin=$skin)") + Log.d( + TAG, + "onCreateInputView: ${(System.nanoTime() - createStartedNs) / 1_000_000} ms " + + "(leituras de config ${(readsDoneNs - createStartedNs) / 1_000_000} ms, view ${(System.nanoTime() - readsDoneNs) / 1_000_000} ms, skin=$skin)", + ) + window.window?.decorView?.let { decor -> + decor.removeOnLayoutChangeListener(windowSizeLogger) + decor.addOnLayoutChangeListener(windowSizeLogger) + } return view } @@ -467,8 +509,11 @@ class KeyFlowKeyboardService : InputMethodService() { } override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { + val startedNs = System.nanoTime() super.onStartInputView(info, restarting) Log.d(TAG, "onStartInputView restarting=$restarting") + traceFirstFrame("onStartInputView") + mainHandler.post { Log.d(TAG, "onStartInputView: ${(System.nanoTime() - startedNs) / 1_000_000} ms até a thread principal soltar") } // Teclado aberto = modo ativo: com origem Local, o modelo já fica carregado para o mic (NAT-05). LocalTranscriber.get(this).sync(voiceSource) KeyFlowController.registerInserter { text -> From aca4d5da542bc319d226be7df98b8dd2c7a5b69c Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 17:03:16 -0300 Subject: [PATCH 16/23] perf(teclado): uma leitura do DataStore ao montar o teclado, no lugar de oito MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O onCreateInputView roda na thread principal bem quando o Android vai mostrar o teclado, e fazia oito runBlocking avulsos (tema, tamanho, idioma, fileira de números, skin, barras, grades, vibração) — oito idas e voltas entre threads, 224–420 ms no emulador lento. Com o 1º quadro passando do prazo de 1 s, o Android mostrava a janela ainda em transição (esteira de instabilidade, 2026-10-10). keyboardSnapshotOnce lê tudo de uma vez, com os mesmos padrões: 61 ms no mesmo emulador. Leitura fresca (não um cache), para uma config gravada logo antes de abrir o teclado valer já na 1ª montagem. SettingsRepositoryTest: 29/29, com os dois testes novos do retrato. --- .../keyflow/data/SettingsRepositoryTest.kt | 47 +++++++++++++++++++ .../com/keyflow/data/SettingsRepository.kt | 31 ++++++++++++ .../com/keyflow/ime/KeyFlowKeyboardService.kt | 29 ++++++------ 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/data/SettingsRepositoryTest.kt b/app/src/androidTest/java/com/keyflow/data/SettingsRepositoryTest.kt index dc1317bf..da7f5f52 100644 --- a/app/src/androidTest/java/com/keyflow/data/SettingsRepositoryTest.kt +++ b/app/src/androidTest/java/com/keyflow/data/SettingsRepositoryTest.kt @@ -453,4 +453,51 @@ class SettingsRepositoryTest { assertEquals(saved, repository.overlayPositionFlow().first()) } + + /** + * O teclado lê tudo de UMA vez ao ser criado (`onCreateInputView`, thread principal): eram oito idas ao DataStore, até + * 400 ms no emulador lento (esteira de instabilidade, 2026-10-10). O retrato tem que dizer exatamente o que as leituras + * avulsas dizem — gravado e no padrão. + */ + @Test + fun keyboardSnapshotMatchesTheSingleReads() = runTest { + val repository = newRepository() + repository.saveSettings( + Settings(themeDark = true, keyboardScalePercent = 85, keyboardNumberRowEnabled = true, keyboardSkin = "glitch", vibrationIntensity = 30), + ) + repository.saveKeyboardLanguageOnce("en") + + val snapshot = repository.keyboardSnapshotOnce() + + assertEquals(repository.themeDarkOnce(), snapshot.dark) + assertEquals(repository.keyboardScalePercentOnce(), snapshot.scalePercent) + assertEquals(repository.keyboardLanguageOnce(), snapshot.language) + assertEquals(repository.keyboardNumberRowEnabledOnce(), snapshot.numberRowEnabled) + assertEquals(repository.keyboardSkinOnce(), snapshot.skin) + assertEquals(repository.barsConfigOnce(), snapshot.bars) + assertEquals(repository.shortcutGridsOnce(), snapshot.grids) + assertEquals(repository.vibrationIntensityOnce(), snapshot.vibration) + assertEquals(KeyboardSnapshot(true, 85, "en", true, "glitch", snapshot.bars, snapshot.grids, 30), snapshot) + } + + @Test + fun keyboardSnapshotUsesTheSameDefaultsAsTheSingleReads() = runTest { + val repository = newRepository() + + val snapshot = repository.keyboardSnapshotOnce() + + assertEquals( + KeyboardSnapshot( + dark = repository.themeDarkOnce(), + scalePercent = repository.keyboardScalePercentOnce(), + language = repository.keyboardLanguageOnce(), + numberRowEnabled = repository.keyboardNumberRowEnabledOnce(), + skin = repository.keyboardSkinOnce(), + bars = repository.barsConfigOnce(), + grids = repository.shortcutGridsOnce(), + vibration = repository.vibrationIntensityOnce(), + ), + snapshot, + ) + } } diff --git a/app/src/main/java/com/keyflow/data/SettingsRepository.kt b/app/src/main/java/com/keyflow/data/SettingsRepository.kt index a6022193..89417fcf 100644 --- a/app/src/main/java/com/keyflow/data/SettingsRepository.kt +++ b/app/src/main/java/com/keyflow/data/SettingsRepository.kt @@ -139,6 +139,25 @@ class SettingsRepository( suspend fun shortcutGridsOnce(): List = ShortcutCodec.decodeGrids(dataStore.data.first()[SHORTCUT_GRIDS_JSON]) + /** + * Tudo o que o IME lê para montar o teclado, de UMA leitura do DataStore. O `onCreateInputView` roda na thread principal + * bem quando o Android vai mostrar o teclado: eram oito leituras avulsas (oito idas e voltas entre threads), até 400 ms no + * emulador lento — e o 1º quadro do teclado passava do prazo de 1 s que o Android dá para a janela aparecer (esteira de + * instabilidade, 2026-10-10). Mesmos padrões das leituras avulsas ([themeDarkOnce] e companhia). + */ + suspend fun keyboardSnapshotOnce(): KeyboardSnapshot = dataStore.data.first().let { prefs -> + KeyboardSnapshot( + dark = prefs[THEME_DARK] ?: false, + scalePercent = prefs[KEYBOARD_SCALE_PERCENT] ?: 100, + language = prefs[KEYBOARD_LANGUAGE] ?: "pt", + numberRowEnabled = prefs[KEYBOARD_NUMBER_ROW_ENABLED] ?: false, + skin = prefs[KEYBOARD_SKIN] ?: "system", + bars = ShortcutCodec.decodeBars(prefs[BARS_CONFIG_JSON]), + grids = ShortcutCodec.decodeGrids(prefs[SHORTCUT_GRIDS_JSON]), + vibration = prefs[VIBRATION_INTENSITY] ?: (if (prefs[HAPTIC_FEEDBACK] == false) 0 else 60), + ) + } + /** Observação ao vivo (BAR-07): o service recria a view do teclado quando a config muda. */ fun barsConfigFlow(): Flow = dataStore.data.map { ShortcutCodec.decodeBars(it[BARS_CONFIG_JSON]) } @@ -699,3 +718,15 @@ class SettingsRepository( } } } + +/** O que o teclado precisa para ser montado — ver [SettingsRepository.keyboardSnapshotOnce]. */ +data class KeyboardSnapshot( + val dark: Boolean, + val scalePercent: Int, + val language: String, + val numberRowEnabled: Boolean, + val skin: String, + val bars: BarsConfig, + val grids: List, + val vibration: Int, +) diff --git a/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt b/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt index e51c2edd..80320f19 100644 --- a/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt +++ b/app/src/main/java/com/keyflow/ime/KeyFlowKeyboardService.kt @@ -298,18 +298,18 @@ class KeyFlowKeyboardService : InputMethodService() { override fun onCreateInputView(): View { val createStartedNs = System.nanoTime() - val repo = SettingsRepository(this@KeyFlowKeyboardService) - val dark = runBlocking { repo.themeDarkOnce() } - // Tamanho do teclado (pedido do Yan, 2026-07-02): mesma leitura leve do tema — só DataStore, sem - // inicializar o EncryptedSharedPreferences (cripto cara) no caminho crítico do IME. - val scalePercent = runBlocking { repo.keyboardScalePercentOnce() } + // Tema, tamanho (2026-07-02), idioma do app (2026-07-02, não o subtype do sistema), fileira de números (NUMROW-01), + // skin, barras e grades (SB-T5) e vibração: UMA leitura do DataStore, sem o EncryptedSharedPreferences (cripto cara) — + // aqui é o caminho crítico do IME, na thread principal, bem quando o Android vai mostrar o teclado. Oito leituras + // avulsas custavam até 400 ms no emulador lento e empurravam o 1º quadro para além do prazo de 1 s da janela + // (esteira de instabilidade, 2026-10-10). + val snapshot = runBlocking { SettingsRepository(this@KeyFlowKeyboardService).keyboardSnapshotOnce() } + val dark = snapshot.dark + val scalePercent = snapshot.scalePercent val scale = scalePercent / 100f - // Idioma gerenciado pelo app (pedido do Yan, 2026-07-02) — não pelo subtype do sistema, mesma - // leitura leve do tema/tamanho. - val language = runBlocking { repo.keyboardLanguageOnce() } - // Fileira de números (NUMROW-01, pedido do Yan, 2026-07-03) — mesma leitura leve do tema/tamanho. - val numberRowEnabled = runBlocking { repo.keyboardNumberRowEnabledOnce() } - val skin = runBlocking { repo.keyboardSkinOnce() } + val language = snapshot.language + val numberRowEnabled = snapshot.numberRowEnabled + val skin = snapshot.skin // Skin de pacote (SKP-KBD): abre o pacote escolhido. Sumiu ou não carrega → `null`, e `KeyboardSkin.of` já cai no Sistema. val packKeyboard = openPackKeyboard(skin) appliedLiveConfig = LiveKeyboardConfig(dark, scalePercent, language, numberRowEnabled, skin) @@ -322,10 +322,9 @@ class KeyFlowKeyboardService : InputMethodService() { suggesterReadyForTest = loaded != null } } - // Barras Canvas (SB-T5): leitura leve como o tema — só DataStore no caminho crítico do IME. - val barsConfig = runBlocking { repo.barsConfigOnce() } - val grids = runBlocking { repo.shortcutGridsOnce() } - val vibration = runBlocking { repo.vibrationIntensityOnce() } + val barsConfig = snapshot.bars + val grids = snapshot.grids + val vibration = snapshot.vibration appliedBarsConfig = barsConfig appliedGrids = grids val readsDoneNs = System.nanoTime() From 83bd0a809d2f912b94028c2f1bfc93a6a821e7c2 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 17:03:16 -0300 Subject: [PATCH 17/23] =?UTF-8?q?test(teclado):=20medi=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20fora=20da=20posi=C3=A7=C3=A3o=20da=20janela=20do=20teclado,?= =?UTF-8?q?=20tamb=C3=A9m=20a=20frio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ime-placement.py faz pelo adb o que o ImeWindowPlacementTest faz por dentro: lá a medição rodava no processo com.keyflow — o mesmo do teclado do KeyFlow e do UiAutomator — e competia com a thread principal do KeyFlow e não com a do Gboard. --cold encerra o processo do teclado antes de cada abertura (a comparação justa). No emulador lento, a frio: KeyFlow 6 de 8 aberturas com a janela em transição visível (região tocável vazia, tamanho de Dialog), Gboard 1 de 8. --- scripts/ime-placement.py | 99 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 scripts/ime-placement.py diff --git a/scripts/ime-placement.py b/scripts/ime-placement.py new file mode 100644 index 00000000..2e370a66 --- /dev/null +++ b/scripts/ime-placement.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Mede de FORA do app onde a janela do teclado fica ao abrir — sem nada de teste no processo do KeyFlow. + + python3 scripts/ime-placement.py --ime keyflow|gboard [--probe remoto|local] [--openings 12] [--seconds 4] [--cold] + +Mesma comparação do ImeWindowPlacementTest (sistema de entrada × gerenciador de janelas, `dumpsys`), mas pelo adb, do +computador. Lá a medição rodava dentro do processo com.keyflow — o mesmo do teclado do KeyFlow e do UiAutomator —, e +competia com a thread principal do KeyFlow e não com a do Gboard (esteira de instabilidade, 2026-10-10). + +Abertura deslocada = 2 leituras seguidas com a janela visível na tela e diferente do gerenciador de janelas. Sai com 1 se +alguma abertura deslocou. Usa o aparelho do ANDROID_SERIAL. + +--cold encerra o processo do teclado antes de cada abertura: toda abertura começa a frio, como a 1ª depois de instalar +ou de o Android matar o processo. É a comparação justa — sem isso o Gboard está sempre quente e o KeyFlow, recém-instalado. +""" +import argparse +import re +import subprocess +import sys +import time + +PACKAGES = {"keyflow": "com.keyflow", "gboard": "com.google.android.inputmethod.latin"} +IMES = { + "keyflow": "com.keyflow/.ime.KeyFlowKeyboardService", + "gboard": "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME", +} +PROBES = {"remoto": "com.keyflow/.ui.ImeProbeRemoteActivity", "local": "com.keyflow/.ui.ImeProbeActivity"} +FRAME = re.compile(r"frame=(\[[^\]]*\]\[[^\]]*\])") +# Uma chamada ao aparelho por leitura: a linha da janela do IME no InputDispatcher e o bloco dela no WindowManager. +SAMPLE = ( + "dumpsys input | grep -m1 \" InputMethod'\"; echo ---; " + "dumpsys window windows | grep -A30 'InputMethod}:' | grep -E 'Frames:|isVisible='" +) + + +def adb(*args, check=False): + return subprocess.run(["adb", "shell", *args], capture_output=True, text=True, check=check).stdout + + +def sample(): + out = adb(SAMPLE) + input_part, _, wm_part = out.partition("---") + seen = FRAME.search(input_part) + visible = "isVisible=true" in wm_part + frames = [line for line in wm_part.splitlines() if "Frames:" in line] + wm = FRAME.search(frames[0]) if frames else None + return (seen.group(1) if seen else None), (wm.group(1) if wm and visible else None) + + +def top(frame): + return int(frame.split(",")[1].split("]")[0]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--ime", choices=IMES, required=True) + parser.add_argument("--probe", choices=PROBES, default="remoto") + parser.add_argument("--openings", type=int, default=12) + parser.add_argument("--seconds", type=float, default=4.0) + parser.add_argument("--cold", action="store_true") + args = parser.parse_args() + ime = IMES[args.ime] + height = int(adb("wm", "size").strip().split("x")[-1]) + + adb("ime", "enable", ime) + adb("ime", "set", ime) + displaced, total = [], 0 + for opening in range(1, args.openings + 1): + adb("input", "keyevent", "KEYCODE_HOME") + time.sleep(1.5) + if args.cold: + # O probe é do pacote do KeyFlow: com o KeyFlow, encerrar o pacote leva o probe junto (ele reabre abaixo). + adb("am", "force-stop", PACKAGES[args.ime]) + if args.ime == "keyflow": + adb("ime", "set", ime) # force-stop tira o KeyFlow de teclado ativo + time.sleep(1.0) + adb("am", "start", "-n", PROBES[args.probe], "-f", "0x10008000") + time.sleep(2.0) + adb("input", "tap", "540", "283") + streak, bad, end = 0, [], time.time() + args.seconds + while time.time() < end: + seen, wm = sample() + if not seen or not wm: + continue + total += 1 + if seen != wm and top(seen) < height: + bad.append(f"entrada={seen} wm={wm}") + streak += 1 + if streak == 2: + displaced.append(f"abertura {opening}: {bad[-1]}") + else: + streak = 0 + print(f"abertura {opening}/{args.openings}: {len(bad)} leituras fora do lugar", flush=True) + print(f"{args.ime} ({args.probe}{', a frio' if args.cold else ''}): {len(displaced)} de {args.openings} aberturas deslocadas, {total} leituras — {displaced}") + return 1 if displaced else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 6589c68cca7acc37f5c651875c69debc63f78b25 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 17:13:42 -0300 Subject: [PATCH 18/23] =?UTF-8?q?test(teclado):=20na=20abertura,=20se=20a?= =?UTF-8?q?=20janela=20n=C3=A3o=20assenta,=20o=20teste=20fecha=20e=20reabr?= =?UTF-8?q?e=20o=20teclado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cada `am instrument` reinicia o processo do app, e o executor de testes e o UiAutomator rodam no mesmo processo do teclado do KeyFlow: o 1º teste de teclado pega a abertura a frio com a thread principal atrasada, o 1º quadro passa do prazo de 1 s do Android e a janela fica com a geometria da transição — no processo do teclado, presa por mais de 10 s (esteira 38074785127: 10 de 12 falhas eram "não assentou"). Medido de fora (scripts/ime-placement.py), o mesmo teclado quente fica no lugar. Na abertura (awaitKeyboardAttached), sem assentar em 5 s o teste fecha o teclado (só se ele estiver de pé) e toca no campo de novo, até 3 vezes, com um log a cada reabertura; no meio do teste não reabre. Emulador lento: 14/14 duas vezes, a 1ª rodada com 1 reabertura no teste a frio. --- .../java/com/keyflow/ime/ImeTestTimeouts.kt | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt index ecd59b4a..49c5966b 100644 --- a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt +++ b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt @@ -59,12 +59,16 @@ internal fun awaitKeyboardAttached(device: UiDevice, relaunch: () -> Unit) { Thread.sleep(50) } if (KeyFlowKeyboardService.activeViewForTest == null) throw AssertionError("KeyFlowKeyboardView não anexou") - awaitImeWindowSettled(device) + awaitImeWindowSettled(device, timeoutMs = IME_OPEN_SETTLE_TIMEOUT_MS, reopenAfterMs = IME_REOPEN_AFTER_MS) } /** Prazo para a janela do teclado assentar onde a view está ([awaitImeWindowSettled]). */ internal const val IME_SETTLE_TIMEOUT_MS = 10_000L +/** Na abertura (o 1º teste do processo pega o teclado a frio): até 3 reaberturas, uma a cada [IME_REOPEN_AFTER_MS]. */ +private const val IME_OPEN_SETTLE_TIMEOUT_MS = 20_000L +private const val IME_REOPEN_AFTER_MS = 5_000L + /** * Espera a janela do teclado ficar, para o sistema de entrada, exatamente onde a própria view acha que está — em duas * leituras seguidas. Chamar antes de calcular um toque pela posição da view. @@ -74,9 +78,18 @@ internal const val IME_SETTLE_TIMEOUT_MS = 10_000L * guardando a posição certa — por um instante (campo em outro processo) ou por segundos (campo no processo do teclado). * O toque calculado pela view caía fora da grade: na barra, no mic, no campo. A captura na hora da falha mostrou o * teclado desenhado fora do lugar, não só a área de toque. + * + * Com [reopenAfterMs] (só na abertura, no [awaitKeyboardAttached]): sem assentar nesse tempo, fecha e reabre o teclado e + * espera de novo. A causa medida é a abertura a frio — cada `am instrument` reinicia o processo, e o executor de testes e o + * UiAutomator rodam no mesmo processo do teclado do KeyFlow, atrasando a thread principal dele: o 1º quadro passa do prazo + * de 1 s do Android e a janela fica com a geometria da transição (tamanho de Dialog, altura da tela, 126 px abaixo). No + * processo do teclado essa geometria chegou a ficar presa por mais de 10 s; uma abertura nova, já quente, se acerta. No meio + * de um teste não reabre: fechar o teclado mexeria no estado do teste (composição, seletor), e aí a falha é a resposta. */ -internal fun awaitImeWindowSettled(device: UiDevice, timeoutMs: Long = IME_SETTLE_TIMEOUT_MS) { +internal fun awaitImeWindowSettled(device: UiDevice, timeoutMs: Long = IME_SETTLE_TIMEOUT_MS, reopenAfterMs: Long? = null) { val deadline = System.currentTimeMillis() + timeoutMs + var reopenAt = reopenAfterMs?.let { System.currentTimeMillis() + it } + var reopened = 0 var stable = 0 var last = "" while (System.currentTimeMillis() < deadline) { @@ -88,9 +101,28 @@ internal fun awaitImeWindowSettled(device: UiDevice, timeoutMs: Long = IME_SETTL } else { stable = 0 } + if (reopenAt != null && reopenAfterMs != null && System.currentTimeMillis() >= reopenAt && reopened < MAX_REOPENS) { + android.util.Log.i("KeyFlow.ImeTest", "janela do teclado fora do lugar ($last): reabrindo o teclado") + reopenKeyboard(device) + reopened++ + reopenAt = System.currentTimeMillis() + reopenAfterMs + continue + } Thread.sleep(100) } - throw AssertionError("a janela do teclado não assentou onde a view está em ${timeoutMs / 1000} s ($last)") + throw AssertionError( + "a janela do teclado não assentou onde a view está em ${timeoutMs / 1000} s, reabrindo $reopened vez(es) ($last)", + ) +} + +private const val MAX_REOPENS = 3 + +/** Fecha o teclado (só se ele estiver de pé: o "voltar" com o teclado fechado fecharia o campo) e toca no campo de novo. */ +private fun reopenKeyboard(device: UiDevice) { + if (imeInputFrame(device) != null) device.pressBack() + Thread.sleep(500) + device.wait(Until.hasObject(By.desc("ime_probe_field")), IME_ATTACH_NUDGE_MS) + runCatching { device.findObject(By.desc("ime_probe_field"))?.click() } } /** A janela do teclado como a view a vê (raiz da hierarquia), no formato do `dumpsys input`: `[l,t][r,b]`. */ From 21668d2ad6d9e9b6d5891f5fbd7e48bd1549e468 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 18:12:55 -0300 Subject: [PATCH 19/23] =?UTF-8?q?test(overlay):=20os=20testes=20do=20cance?= =?UTF-8?q?lamento=20agem=20com=20a=20janela=20de=20desfazer=20em=2030=20s?= =?UTF-8?q?=20=E2=80=94=20e=20dizem=20o=20estado=20do=20fim=20da=20espera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira 38082896996: commitCancel falhou 1 de 54 voltas (CPU limitada) — o teste dava 2 s ao "concluir" contra a janela de 3 s, e só parar e soltar o gravador passou disso. No emulador local, a frio, targetChange… falhou: o teste levou 19 s e a janela fechou antes do "desfazer" (esperava PAUSED, achou IDLE). Como no seletor do teclado, a classe usa OverlayService.cancelWindowMsForTest = 30 s; cancelExpiresAndDiscardsAudio, que testa a janela fechando, volta aos 3 s. O "concluir" ganha 15 s e tem que encerrar bem antes dos 30. A mensagem de espera era montada no começo (dizia o estado de antes e o array como endereço); agora é no fim, com os estados por nome. 5/5 duas vezes a partir de processo novo. --- .../overlay/OverlayServiceCancelTest.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/overlay/OverlayServiceCancelTest.kt b/app/src/androidTest/java/com/keyflow/overlay/OverlayServiceCancelTest.kt index 4b1fd1d9..14b49a3f 100644 --- a/app/src/androidTest/java/com/keyflow/overlay/OverlayServiceCancelTest.kt +++ b/app/src/androidTest/java/com/keyflow/overlay/OverlayServiceCancelTest.kt @@ -42,6 +42,11 @@ class OverlayServiceCancelTest { fun setUp() { shell("pm grant ${context.packageName} ${Manifest.permission.RECORD_AUDIO}") shell("appops set ${context.packageName} SYSTEM_ALERT_WINDOW allow") + // Os testes desfazem/concluem/olham o cancelamento DENTRO da janela de desfazer: com os 3 s do produto, um processo + // recém-criado ou um aparelho lento deixava a janela fechar antes (o "desfazer" achava IDLE; esteira de + // instabilidade e emulador local, 2026-10-10). 30 s aqui; cancelExpiresAndDiscardsAudio, que testa a janela + // fechando, volta aos 3 s. + OverlayService.cancelWindowMsForTest = 30_000L // Sobe o serviço já em RECORDING: ACTION_RECORD chama ensureForeground() (startForeground // imediato, sem warm-up extra) e coordinator.onTap() (ocioso → grava, MIN_RECORDING_MS à parte). OverlayService.start(context, OverlayService.actionIntent(context, OverlayService.ACTION_RECORD)) @@ -50,6 +55,7 @@ class OverlayServiceCancelTest { @After fun tearDown() { + OverlayService.cancelWindowMsForTest = null OverlayService.stop(context) } @@ -78,6 +84,7 @@ class OverlayServiceCancelTest { @Test fun cancelExpiresAndDiscardsAudio() { + OverlayService.cancelWindowMsForTest = null // a janela do produto, 3 s: é ela que este teste vê fechar sendAction(RecordingNotification.ACTION_CANCEL) waitUntilState(ButtonState.CANCELLING) @@ -87,15 +94,18 @@ class OverlayServiceCancelTest { @Test fun commitCancelEndsImmediatelyWithoutWaitingWindow() { + // Com a janela em 30 s (setUp), o "concluir" tem que encerrar muito antes dela. Com os 3 s do produto o teste dava 2 s + // ao "concluir" — e no emulador lento só parar e soltar o gravador passou disso (esteira de instabilidade, + // 2026-10-10). Assim "concluir funciona" (sai em segundos) e "concluir ignorado" (30 s em CANCELLING) não se confundem. sendAction(RecordingNotification.ACTION_CANCEL) waitUntilState(ButtonState.CANCELLING) val start = System.currentTimeMillis() - // CANCEL-05: toque na orbe durante CANCELLING conclui o descarte JÁ (sem esperar os 3s). + // CANCEL-05: toque na orbe durante CANCELLING conclui o descarte JÁ (sem esperar a janela). sendAction(OverlayService.ACTION_COMMIT_CANCEL) - waitUntilState(ButtonState.IDLE_NO_FIELD, ButtonState.IDLE_READY, timeoutMs = 2000) + waitUntilState(ButtonState.IDLE_NO_FIELD, ButtonState.IDLE_READY, timeoutMs = 15_000) val elapsed = System.currentTimeMillis() - start - assertTrue("commit deveria ser bem mais rápido que a janela de 3s (foi ${elapsed}ms)", elapsed < 2500) + assertTrue("commit deveria encerrar bem antes da janela de 30 s (foi ${elapsed}ms)", elapsed < 15_000) } @Test @@ -122,18 +132,19 @@ class OverlayServiceCancelTest { } private fun waitUntilState(vararg expected: ButtonState, timeoutMs: Long = TIMEOUT) { - waitUntil("esperando $expected, estado atual: ${KeyFlowController.buttonState.value}", timeoutMs) { + // A mensagem é montada no fim da espera: antes ela dizia o estado do começo (e o array como endereço). + waitUntil({ "esperando ${expected.joinToString(" ou ")}, estado atual: ${KeyFlowController.buttonState.value}" }, timeoutMs) { KeyFlowController.buttonState.value in expected } } - private fun waitUntil(message: String, timeoutMs: Long = TIMEOUT, condition: () -> Boolean) { + private fun waitUntil(message: () -> String, timeoutMs: Long = TIMEOUT, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { if (condition()) return Thread.sleep(50) } - throw AssertionError(message) + throw AssertionError(message()) } private fun shell(command: String) { From 3546d8f5680d21f70461785e363de8501d1723ad Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 18:19:01 -0300 Subject: [PATCH 20/23] =?UTF-8?q?fix(skins):=20o=20quadro=20que=20termina?= =?UTF-8?q?=20de=20renderizar=20depois=20do=20close()=20n=C3=A3o=20fica=20?= =?UTF-8?q?=C3=B3rf=C3=A3o=20no=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O work() conferia `closed` e só depois guardava o quadro: se o close() caísse entre os dois, o quadro entrava no cache global de uma orbe já fechada e ficava lá até ser despejado. Achado pelo PackOrbBudgetTest na esteira de instabilidade (2026-10-10): o teste começava com exatamente um quadro de 156 px (97.344 bytes) de outra orbe no cache, que sumia no meio dele ("expected 97344 but was 0"). Conferir `closed` de novo depois do put cobre todas as ordens, porque o close() marca `closed` antes de limpar. PackOrbBudgetTest 20/20 três vezes. --- app/src/main/java/com/keyflow/skins/PackOrb.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/keyflow/skins/PackOrb.kt b/app/src/main/java/com/keyflow/skins/PackOrb.kt index a5adb15d..3e01696c 100644 --- a/app/src/main/java/com/keyflow/skins/PackOrb.kt +++ b/app/src/main/java/com/keyflow/skins/PackOrb.kt @@ -263,6 +263,13 @@ class PackOrb private constructor(private val pack: SkinPack) { val bitmap = render(key, pack.readSvg(file)) ?: return if (closed) return cache.put(key, bitmap) + // O close() pode ter caído entre a checagem acima e o put: o quadro entrava no cache de uma orbe já fechada e ficava + // órfão (um quadro de 156 px, 97 KB, achado pelo PackOrbBudgetTest na esteira de instabilidade, 2026-10-10). Como o + // close() marca `closed` antes de limpar o cache, conferir de novo depois de guardar cobre todas as ordens. + if (closed) { + cache.removeWhere { it == key } + return + } versionState.intValue++ } From b8a305c3f7bf2d338de0619181708c98a88aa77e Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 18:28:59 -0300 Subject: [PATCH 21/23] =?UTF-8?q?test(teclado):=20sai=20a=20espera=20?= =?UTF-8?q?=C3=A0s=20cegas=20de=205=20s=20pelo=20"=F0=9F=8E=99"=20?= =?UTF-8?q?=E2=80=94=20a=20espera=20de=20assentamento=20faz=20o=20mesmo=20?= =?UTF-8?q?papel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As seis classes esperavam 5 s por um "🎙" que nunca aparece (o mic é desenhado pela BarView); era o tempo para a entrada do teclado assentar, e tirá-lo sem troca rendeu 4 falhas em 2 rodadas do CI (508f953). O comentário pedia trocar só por uma condição que esperasse o mesmo assentamento: é o que o awaitKeyboardAttached faz agora (janela do IME onde a view está, reabrindo o teclado se não assenta). Uns 5 s a menos por teste de teclado. 25/25 no emulador local; a esteira valida sob carga. --- .../java/com/keyflow/ime/KeyFlowKeyboardAccessibilityTest.kt | 5 ++--- .../java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt | 5 ++--- .../java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt | 5 ++--- .../java/com/keyflow/ime/KeyFlowKeyboardNumericFieldTest.kt | 5 ++--- .../java/com/keyflow/ime/KeyFlowKeyboardSelectorTest.kt | 5 ++--- .../java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt | 5 ++--- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardAccessibilityTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardAccessibilityTest.kt index cf9fe55d..8a4a995d 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardAccessibilityTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardAccessibilityTest.kt @@ -100,9 +100,8 @@ class KeyFlowKeyboardAccessibilityTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } waitUntil("grade Canvas não montou") { KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest != null } val grid = KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest!! diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt index ae01540b..9471f750 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -194,9 +194,8 @@ class KeyFlowKeyboardCanvasGridTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } } diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt index 6c7e415a..50f5fb14 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt @@ -115,9 +115,8 @@ class KeyFlowKeyboardLanguageTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } waitUntil("grade Canvas não montou") { KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest != null } val grid = KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest!! diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardNumericFieldTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardNumericFieldTest.kt index 9da33203..f06ecd67 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardNumericFieldTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardNumericFieldTest.kt @@ -104,9 +104,8 @@ class KeyFlowKeyboardNumericFieldTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } waitUntil("grade Canvas ainda sem geometria medida") { KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest?.placedRowsForTest?.isNotEmpty() == true diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSelectorTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSelectorTest.kt index 7859b219..ee907e7b 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSelectorTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSelectorTest.kt @@ -73,9 +73,8 @@ class KeyFlowKeyboardSelectorTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } } diff --git a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt index 2209bfb1..51b5e8d4 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardSpellCheckerTest.kt @@ -193,9 +193,8 @@ class KeyFlowKeyboardSpellCheckerTest { device.wait(Until.hasObject(By.desc("ime_probe_field")), TIMEOUT) device.findObject(By.desc("ime_probe_field"))?.click() - // Nunca acha o "🎙" (o mic é desenhado pela BarView): na prática são 5 s para a entrada do teclado assentar. Tirá-los - // rendeu 4 falhas de teclado em 2 rodadas do CI (2026-10-10). Trocar só por uma condição que espere o mesmo assentamento. - device.wait(Until.hasObject(By.text("🎙")), TIMEOUT) + // O awaitKeyboardAttached espera a janela do teclado assentar onde a view está (e reabre o teclado se ela não + // assenta): substitui os 5 s às cegas do "🎙" que nunca aparecia (o mic é desenhado pela BarView). awaitKeyboardAttached(device) { context.startActivity(intent) } waitUntil("grade Canvas não montou") { KeyFlowKeyboardService.activeViewForTest!!.canvasGridForTest != null } // Montada não basta: a janela do IME precisa estar VISÍVEL, senão o toque cai em janela alheia From cd4adb55ae5c8953b3b3ac18ca78a817e25653c7 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 19:19:53 -0300 Subject: [PATCH 22/23] =?UTF-8?q?test(teclado):=20a=20reabertura=20s=C3=B3?= =?UTF-8?q?=20toca=20no=20campo=20com=20o=20teclado=20fora=20da=20tela?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esteira 38087774045 (1 falha em 7.304 execuções): o teclado ficou preso no topo da tela ([120,128][1200,915]), a espera de assentamento mandou "voltar" e tocou no campo em (540, 283) — mas o teclado ainda cobria o campo, e o toque virou a tecla "r": o campo terminou "rooo" no lugar de "o". Agora, depois do "voltar", a reabertura espera o teclado sumir do sistema de entrada (até 3 s) e só então toca; se ele não sai, não toca e a próxima volta da espera tenta de novo. --- .../java/com/keyflow/ime/ImeTestTimeouts.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt index 49c5966b..51a4bff1 100644 --- a/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt +++ b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt @@ -117,10 +117,17 @@ internal fun awaitImeWindowSettled(device: UiDevice, timeoutMs: Long = IME_SETTL private const val MAX_REOPENS = 3 -/** Fecha o teclado (só se ele estiver de pé: o "voltar" com o teclado fechado fecharia o campo) e toca no campo de novo. */ +/** + * Fecha o teclado (só se ele estiver de pé: o "voltar" com o teclado fechado fecharia o campo) e toca no campo de novo — mas + * só com o teclado já fora da tela. Esteira 38087774045: preso no topo ([120,128][1200,915]), o teclado ainda cobria o campo + * quando o toque de reabrir chegou, e o toque em (540, 283) virou a tecla "r" ("rooo" no lugar de "o"). Se ele não sai em + * 3 s, não toca: a próxima volta da espera tenta de novo. + */ private fun reopenKeyboard(device: UiDevice) { if (imeInputFrame(device) != null) device.pressBack() - Thread.sleep(500) + val goneBy = System.currentTimeMillis() + 3_000 + while (imeInputFrame(device) != null && System.currentTimeMillis() < goneBy) Thread.sleep(100) + if (imeInputFrame(device) != null) return device.wait(Until.hasObject(By.desc("ime_probe_field")), IME_ATTACH_NUDGE_MS) runCatching { device.findObject(By.desc("ime_probe_field"))?.click() } } From ef26350bd136a180bb1820d7874d30438ccc3018 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Sat, 10 Oct 2026 19:20:09 -0300 Subject: [PATCH 23/23] =?UTF-8?q?docs(testes):=20conven=C3=A7=C3=B5es=20no?= =?UTF-8?q?vas=20dos=20testes=20de=20teclado=20e=20da=20medi=C3=A7=C3=A3o?= =?UTF-8?q?=20da=20janela=20do=20IME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .specs/codebase/TESTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.specs/codebase/TESTING.md b/.specs/codebase/TESTING.md index e1be5696..04566eee 100644 --- a/.specs/codebase/TESTING.md +++ b/.specs/codebase/TESTING.md @@ -62,6 +62,18 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env | `Unit tests and build` | `testDebugUnitTest assembleDebug assembleDebugAndroidTest`; publica os APKs (artefato `apks`, 1 dia) | `quick` + `build` | | `Device (API 34, i/6)` | 6 emuladores Pixel 6 · API 34 · x86_64 **com KVM** → `scripts/ci-device-gate.sh all 6`: 1/6 da suíte (`numShards`/`shardIndex` do AndroidJUnitRunner, por `am instrument` direto), app reinstalado do zero e 1/6 das rotas da galeria, claro e escuro (`ROUTE_SHARD`); depois `diff-screens.sh` do mesmo recorte, fora do emulador | `full` + `screenshot` | +- Teste de teclado não espera às cegas: o `awaitKeyboardAttached` (ImeTestTimeouts.kt) espera a janela do IME estar, no + `dumpsys input`, onde a raiz da view está (2 leituras seguidas) e, se não assenta em 5 s na abertura, fecha e reabre o + teclado (até 3×, só tocando no campo com o teclado fora da tela). Antes de calcular a posição de uma tecla, chame + `awaitImeWindowSettled` (sem reabrir). Motivo medido na esteira (2026-10-10): cada `am instrument` reinicia o processo, e o + executor de testes e o UiAutomator rodam no processo do teclado do KeyFlow — a abertura a frio passa do prazo de 1 s do + Android e a janela fica em transição (tamanho de Dialog, altura da tela, no topo, 126 px abaixo). +- Toque injetado em emulador lento pode passar dos 250 ms da tira de acentos e virar "segurar": teste que só toca usa + `accentHoldMsForTest` (os da tira de acentos, não). +- `scripts/ime-placement.py --ime keyflow|gboard [--cold]` mede de FORA do app (adb) onde a janela do teclado fica ao abrir + — sem teste no processo. A frio, no emulador lento: KeyFlow 6/8 aberturas em transição, Gboard 1/8 (desempenho da + abertura a frio é tarefa aberta). +- Teste que inicia gravação de verdade para o `OverlayService` no `@After` (a gravação vazava para o teste seguinte). - Os 6 jobs de device sobem **junto** com o de unitários (sem `needs`): preparam o runner e ligam o emulador enquanto ele compila, e então baixam o artefato `apks` da própria rodada (`gh run download`). Não há Gradle no runner do emulador. Se o job de unitários falhar, eles param logo.