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' }} diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml new file mode 100644 index 00000000..000ad20b --- /dev/null +++ b/.github/workflows/stress.yml @@ -0,0 +1,162 @@ +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. +# 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**'] + 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' + 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). + statuses: write +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 + 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 + 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/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) ``` diff --git a/.specs/codebase/TESTING.md b/.specs/codebase/TESTING.md index f7671867..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. @@ -112,3 +124,22 @@ 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. +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 + 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/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/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt b/app/src/androidTest/java/com/keyflow/ime/ImeTestTimeouts.kt index 18d5ca8c..51a4bff1 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 @@ -33,22 +34,118 @@ 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 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") 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) } - throw AssertionError("KeyFlowKeyboardView não anexou") + if (KeyFlowKeyboardService.activeViewForTest == null) throw AssertionError("KeyFlowKeyboardView não anexou") + 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. + * + * 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. + * + * 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, 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) { + 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 + } + 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, 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 — 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() + 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() } +} + +/** 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/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 + } +} 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 b1f3f8ed..9471f750 100644 --- a/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt +++ b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardCanvasGridTest.kt @@ -48,10 +48,17 @@ 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) } userSettings.restore() device.pressHome() // KeyFlowController é singleton de processo (sobrevive ao fim deste teste, dentro do mesmo gate). @@ -67,8 +74,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 } } @@ -83,7 +90,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. */ @@ -99,7 +106,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" } } /** @@ -119,7 +126,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. */ @@ -181,21 +188,29 @@ 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) 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) } } + /** + * `-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() } + 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) } @@ -212,6 +227,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) } @@ -223,10 +239,18 @@ 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) { + // 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") } @@ -325,7 +349,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" } /** @@ -355,5 +413,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/KeyFlowKeyboardLanguageTest.kt b/app/src/androidTest/java/com/keyflow/ime/KeyFlowKeyboardLanguageTest.kt index 746b6f97..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!! @@ -128,6 +127,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/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 3c6ad895..51b5e8d4 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() @@ -190,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 @@ -206,6 +208,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() @@ -214,6 +217,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()) } @@ -233,6 +237,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() 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) { 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/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 5b5cfec8..80320f19 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,23 +293,23 @@ 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 { 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) @@ -289,12 +322,12 @@ 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() val view = KeyFlowKeyboardView( context = this, onKey = ::handleKey, @@ -341,7 +374,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 +508,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 -> 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..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 @@ -185,6 +193,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 +363,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, @@ -591,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) { 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++ } 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-report.py b/scripts/ci-stress-report.py new file mode 100755 index 00000000..68b740be --- /dev/null +++ b/scripts/ci-stress-report.py @@ -0,0 +1,91 @@ +#!/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") + # 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"): + 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 ({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..0cf7a6e7 --- /dev/null +++ b/scripts/ci-stress.sh @@ -0,0 +1,171 @@ +#!/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. +# +# 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 + +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 +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). +"$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 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 +# (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/^ *//') + # 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" | + 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 +} + +# 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 rm -f "$FAILURE_SHOTS/falha-*.png" >/dev/null 2>&1 || true + # 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" + 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" + # 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 +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 + [ -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. +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 ] 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())