diff --git a/.specs/features/computer-terminal/spec.md b/.specs/features/computer-terminal/spec.md index c8a25dc2..3a505555 100644 --- a/.specs/features/computer-terminal/spec.md +++ b/.specs/features/computer-terminal/spec.md @@ -386,11 +386,11 @@ Nenhuma. Q1–Q13 foram respondidas em 29/10 (D12–D24). Q8, Q9, Q10, Q11 e Q13 | CMP-02 | P0 | CMP-T1 | Verified — termlib 0.3.14/fork e cbssh 0.6.6; 126 componentes com POMs/hashes/licenças e 4/4 catracas; build/lint, 2.709 JVM e 20 instrumentados verdes; limites e riscos na [lição](../../project/licoes/2026-10-30-computador-a.md) | | CMP-03 | P0 | CMP-T1, CMP-T3 | Verified — JNI do CI com SHA fixado; 3 ABIs no APK, ELF/ZIP 16 KB e hashes conferidos, 6/6 catracas; [lição](../../project/licoes/2026-10-30-computador-a.md) | | CMP-04 | P0 | CMP-T4 | Verified (2026-10-30) | -| CMP-05 | P1-A | CMP-T5, CMP-T11, CMP-T15 | Parcial — modelo/codec (CMP-T5) verificados; faltam T11 e T15 | +| CMP-05 | P1-A | CMP-T5, CMP-T11, CMP-T15 | Parcial — modelo/codec (T5), persistência (T11) e gravação pelo modal (`EdicaoDeHost`, T15) verificados; faltam a aba ligada na tela (T17) | | CMP-06 | P1-A | CMP-T5, CMP-T11 | Parcial — migração pura (CMP-T5) verificada; falta T11 | -| CMP-07 | P1-A | CMP-T11, CMP-T15 | Pending | -| CMP-08 | P1-A | CMP-T9, CMP-T15 | Pending | -| CMP-09 | P1-A | CMP-T5, CMP-T15 | Parcial — regra `podeMarcar` (CMP-T5) verificada; falta T15 | +| CMP-07 | P1-A | CMP-T11, CMP-T15 | Verificada (T11 e T15: exclusão com confirmação, aviso de nova impressão ao mudar endereço/porta) | +| CMP-08 | P1-A | CMP-T9, CMP-T15 | Verificada (T9 + `TestadorDeConexao` contra o `SshFixtureServer`; modal mostra impressão, método e latência; sem `delay`) | +| CMP-09 | P1-A | CMP-T5, CMP-T15 | Revisão #96: disponibilidade real de credencial, chave vazia recusada e segredo confirmado antes da permissão (`RegraAgenteTest`, `ValidadorHostTest`, `GravadorDeHostsTest`); aviso/confirmar mantidos. Novos instrumentados compilados e pendentes por ANR do SystemUI | | CMP-10 | P1-B | CMP-T7, CMP-T8, CMP-T16 | Parcial — geração no Keystore/envelope (CMP-T8) implementada; falta T16 | | CMP-11 | P1-B | CMP-T2, CMP-T8, CMP-T16 | Parcial — sonda e envelope (CMP-T8); falta T2 e T16 | | CMP-12 | P1-B | CMP-T7, CMP-T16 | Parcial — importação (CMP-T7) verificada; falta T16 | @@ -404,20 +404,20 @@ Nenhuma. Q1–Q13 foram respondidas em 29/10 (D12–D24). Q8, Q9, Q10, Q11 e Q13 | CMP-20 | P1-C | CMP-T9, CMP-T16 | Pending | | CMP-21 | P1-C | CMP-T9, CMP-T16 | Pending | | CMP-22 | P1-C | CMP-T10, CMP-T20 | Parcial — reconexão, backoff, biometria e buffer (CMP-T10) verificados; falta a tela | -| CMP-23 | P1-C | CMP-T9, CMP-T13 | Pending | -| CMP-24 | P1-D | CMP-T13, CMP-T17 | Pending | -| CMP-25 | P1-D | CMP-T13 | Pending | -| CMP-26 | P1-D | CMP-T14 | Pending | -| CMP-27 | P1-D | CMP-T14 | Pending | -| CMP-28 | P1-D | CMP-T13 | Pending | -| CMP-29 | P1-D | CMP-T10, CMP-T13 | Parcial — limites 8/3 e ociosidade do agente (CMP-T10) verificados | -| CMP-30 | P1-D | CMP-T13 | Pending | -| CMP-31 | P1-D | CMP-T14, CMP-T17 | Pending | +| CMP-23 | P1-C | CMP-T9, CMP-T13 | Verificada (`window-change` com colunas, linhas e pixels ao girar, `TerminalActivityTest`) | +| CMP-24 | P1-D | CMP-T13, CMP-T17 | Parcial — `TerminalActivity`, abas, barra de estado e Voltar sem fechar sessões verificados (T13); falta a lista de sessões na aba (T17) | +| CMP-25 | P1-D | CMP-T13 | Verificada, salvo mouse (fica para a Fase 2/T27) e tamanho de fonte persistido (termlib sem retorno) | +| CMP-26 | P1-D | CMP-T14 | Mapeamento verificado; revisão #96: FIFO única com texto/IME e `KeyboardOrderingTest` 3/3, mutação revertida. Novos casos de ordem no IME pendentes por ANR do SystemUI antes da instalação | +| CMP-27 | P1-D | CMP-T14 | Verificada com o KeyFlow IME como teclado atual via `InputConnection`; Gboard e toque real: T21 (manual) | +| CMP-28 | P1-D | CMP-T13 | Verificada (colar e ditado com quebra de linha pedem confirmação; `ESC[201~` removido até não sobrar) | +| CMP-29 | P1-D | CMP-T10, CMP-T13 | Verificada (9ª sessão mostra o motivo; `onTrimMemory` não perde o texto) | +| CMP-30 | P1-D | CMP-T13 | Sem `FLAG_SECURE` verificado; fonte JetBrains Mono Nerd mantida pelo Yan. Revisão #96: copyrights e avisos completos dos 14 conjuntos, Nerd Fonts e base, com OFL/MIT/Apache-2.0/CC BY 4.0/Unlicense e aviso Bitstream da origem. Tamanho da fonte ainda não persiste | +| CMP-31 | P1-D | CMP-T14, CMP-T17 | Parcial — alvos de 48 dp e `contentDescription` nas abas e teclas extras, estado por texto (T13/T14); falta T17 | | CMP-32 | P1-E | CMP-T12, CMP-T20 | Parcial — serviço `specialUse`, notificação só com contagem (CMP-T12) verificados | | CMP-33 | P1-E | CMP-T12, CMP-T19 | Parcial — manifesto, `property` e checklist da Play (CMP-T12); declaração no Console é [YAN] | | CMP-34 | P1-E | CMP-T10, CMP-T12 | Parcial — serviço sobe com a 1ª sessão e cai com a última (CMP-T12); falta a tela | -| CMP-35 | P1-F | CMP-T13 | Pending | -| CMP-36 | P1-F | CMP-T10, CMP-T13 | Parcial — buffer só em memória (CMP-T10) verificado; falta o emulador (T13) | +| CMP-35 | P1-F | CMP-T13 | Verificada; revisão #96: números normalizados, C1/S8C1T, C0/DEL, CAN/SUB e limites sem vazamento (`DivergenciasDoParserTest`, fuzz diferencial com o parser C em `ParserLibvtermTest`, mais gravação hostil/fragmentação e `LigacaoEmuladorTest`) | +| CMP-36 | P1-F | CMP-T10, CMP-T13 | Verificada (texto e scrollback só em memória; emulador no registro do processo) | | CMP-37 | P2-G | CMP-T22 | Pending | | CMP-38 | P2-G | CMP-T23 | Pending | | CMP-39 | P2-G | CMP-T23 | Pending | @@ -440,7 +440,7 @@ Nenhuma. Q1–Q13 foram respondidas em 29/10 (D12–D24). Q8, Q9, Q10, Q11 e Q13 | CMP-56 | P3-H | CMP-T32, CMP-T35 | Pending | | CMP-57 | P3-H | CMP-T19, CMP-T33, CMP-T36 | Pending | | CMP-58 | P3-H | CMP-T31, CMP-T35 | Pending | -| CMP-59 | P2-I | CMP-T15, CMP-T17 | Pending | +| CMP-59 | P2-I | CMP-T15, CMP-T17 | Parcial — seção Hosts pronta (T15), Chaves e Sessões são avisos até T16/T17 | | CMP-60 | P2-I | CMP-T17 | Pending | | CMP-61 | P2-I | CMP-T15, CMP-T16, CMP-T33 | Pending | | CMP-62 | P2-I | CMP-T5, CMP-T31 | Parcial — regra de arquitetura `computador/` (CMP-T5) ativa; falta T31 | diff --git a/.specs/features/computer-terminal/tasks.md b/.specs/features/computer-terminal/tasks.md index 0ac30bff..f0baa94f 100644 --- a/.specs/features/computer-terminal/tasks.md +++ b/.specs/features/computer-terminal/tasks.md @@ -231,7 +231,7 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( o `PLAYSTORE_CHECKLIST.md` tem o texto da justificativa; com a opção desligada (padrão) o teste não vê `WakeLock`; com ela ligada, há `PARTIAL_WAKE_LOCK` enquanto a sessão do usuário está aberta e o painel mostra o aviso de bateria. O `ManifestSessoesTest` segue conferindo `specialUse`, a permissão do serviço e a `property`. - **Tests**: unit + instrumentado. **Gate**: `quick` + `pool instrumentar com.keyflow.computador.app.SessoesServiceTest`. **Commit**: `feat(computador): serviço em primeiro plano para manter as sessões`. -### CMP-T13 — O terminal na tela: `TerminalActivity`, emulador, abas e segurança das sequências — **Status: pendente** +### CMP-T13 — O terminal na tela: `TerminalActivity`, emulador, abas e segurança das sequências — **Status: concluída em 2026-10-31 (ver a lição computador-d: imagens ficam Off até a Fase 2; sem persistir o tamanho da fonte)** - **What**: `TerminalActivity` (própria, `exported=false`, sem anúncios, `adjustResize`, borda a borda) e `TerminalScreen` (Compose) com o `Terminal` do termlib; `LigacaoEmulador` (bytes do cbssh → `writeInput`; `onKeyboardInput` → `escrever`; `onResize` → `resizeTerminal` com pixels), abas de sessão (`[Terminal 1][Agente 2 ●][+]`), barra de estado da conexão (Conectando… / Reconectando… / Caiu), zoom por pinça, seleção, colar (`pasteText`), links OSC 8 com confirmação; **segurança**: respostas do emulador limitadas à lista de permitidos (CMP-35), OSC 52 e título tratados, colar com várias linhas pede confirmação sem bracketed paste e remove `ESC[201~`. A activity **não** pede `FLAG_SECURE` e não há opção que ligue (D17); um teste falha se pedir. Fonte com glifos Nerd (D16): licença conferida, OFL aceitável; se não for OFL nem compatível com Apache-2.0, **não entra** e a task registra a monoespaçada do sistema. Scrollback conforme a CMP-T1 (hoje 1 000 linhas no termlib). - **Where**: `computador/app/{TerminalActivity,LigacaoEmulador,FiltroDeRespostas}.kt`, `ui/terminal/{TerminalScreen,AbasDeSessao,BarraDeEstado}.kt`, fonte em `res/font/` com a licença em `app/licenses/` (ou a monoespaçada do sistema, se a licença não servir); testes `LigacaoEmuladorTest`, `FiltroDeRespostasTest`, `SequenciasHostisTest` (JVM com a gravação "hostil"), `TerminalScreenTest` (instrumentado). @@ -241,7 +241,9 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( `Back` sai da activity **sem** fechar as sessões; fechar a aba fecha a sessão; 9ª sessão mostra o motivo; `onTrimMemory` com a activity invisível não perde o scrollback de texto; um teste falha se a activity pedir `FLAG_SECURE`; a fonte embutida tem glifos Nerd e licença OFL ou compatível com Apache-2.0, ou a lição registra a monoespaçada do sistema. - **Tests**: unit + instrumentado. **Gate**: `quick` + `pool instrumentar com.keyflow.ui.terminal.TerminalScreenTest com.keyflow.computador.app.LigacaoEmuladorTest`. **Commit**: `feat(computador): terminal na tela com abas e proteção contra sequências do servidor`. -### CMP-T14 — Entrada: KeyFlow IME, Gboard, teclado físico, teclas extras e ditado — **Status: pendente** +**Correção da revisão #96 (2026-10-31):** CMP-35 usa tokenização compatível com o parser C fixado, normaliza números, bloqueia S8C1T e filtra respostas C1. Strings são retidas até a decisão; limite de 1 MiB descarta até o final/cancelamento, sem liberar prefixos. Regressões e fuzz contra o libvterm real: 25/25; mutação liberando S8C1T detectada e revertida. Fonte mantida por decisão do Yan, com copyrights e licenças completos em `OFL_JetBrainsMonoNerdFont.txt` (14 conjuntos de glifos e Nerd Fonts/base). Mouse SGR da tela continua pendente em CMP-25/T27. + +### CMP-T14 — Entrada: KeyFlow IME, Gboard, teclado físico, teclas extras e ditado — **Status: concluída em 2026-10-31 (Gboard e toque real no KeyFlow IME ficam para a T21 manual)** - **What**: teclas extras (Esc, Tab, Ctrl e Alt "pegajosos", setas, `|`, `~`, `/`, `-`, F1–F12 numa segunda linha) em Compose acima do teclado; mapeamento de teclado físico (Ctrl+letra, Alt, Home/End, PageUp/Down, F-keys); o campo de entrada do terminal conversa com o **KeyFlow IME** sem duplicar caractere nem deixar composição pendurada (`TYPE_NULL`; ver "bug do Termius" no Bloco 1 do `DEVELOPMENT-ORDER.md`), e o **ditado da orbe** insere o texto transcrito como entrada (nunca acrescenta Enter; quebra de linha ditada segue a regra de colar do CMP-28). - **Where**: `ui/terminal/{TeclasExtras,MapaTecladoFisico}.kt`, `computador/app/EntradaDoTerminal.kt`, ajustes mínimos em `ime/KeyFlowKeyboardService.kt` **só se a T1 provar defeito** (registrar `SPEC_DEVIATION`); testes `MapaTecladoFisicoTest`, `TeclasExtrasTest`, `EntradaTerminalImeTest` (instrumentado com o KeyFlow IME). @@ -251,9 +253,11 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( com o Gboard (se instalado no emulador) o mesmo resultado; alvos ≥ 48 dp; `contentDescription` em toda tecla extra. - **Tests**: unit + instrumentado. **Gate**: `quick` + `pool instrumentar com.keyflow.computador.app.EntradaTerminalImeTest`. **Commit**: `feat(computador): teclas extras, teclado físico e ditado no terminal`. +**Correção da revisão #96 (2026-10-31):** teclas mapeadas usam `dispatchKeyboardInput` na mesma FIFO do texto/IME, sem atraso de 40 ms. Os três unitários da fila real passaram; mutação emitindo fora da fila derrubou 3/3 e foi revertida. `EntradaTerminalImeTest` agora inclui Esc+i, Backspace+letra e texto+Enter rápidos. Instrumentados pendentes: a verificação prévia detectou ANR ativo do SystemUI e a execução foi suspensa, sem instalar ou reiniciar para contornar. + ### Fase 1D — Telas -### CMP-T15 — Painel Hosts: lista, modal, "Testar conexão" real e "pode ser usado pelo agente" — **Status: pendente** +### CMP-T15 — Painel Hosts: lista, modal, "Testar conexão" real e "pode ser usado pelo agente" — **Status: concluída em 2026-10-31** - **What**: `ComputadorPanel` reescrito com segmentado **Hosts · Chaves · Sessões** (`ComputadorHosts`): cartões de host (nome, `usuário@host:porta`, tipo de auth, chip "Agente: permitido/não", estado da sessão), "+ Novo host", modal do host (campos, escolha da chave/senha, interruptor "Pode ser usado pelo agente" com o aviso e a confirmação do CMP-09, "Testar conexão" **real** pela T9, impressão digital conhecida, esquecer servidor, excluir com confirmação). Remove o conteúdo antigo (segmentado SSH/Local, o `delay(600)`, a chave colada num campo). Local **some** da interface. A aba ainda **não** sai do "Em breve" (T17). - **Where**: `ui/config/{ComputadorPanel,ComputadorHosts,ComputadorHostModal}.kt`, `i18n/{en,es}/Computador.kt`, registro em `TranslationsEn/Es`; testes `ComputadorHostsTest`. @@ -263,6 +267,8 @@ para permitir essa verificação sem reescrever a branch; detalhes na [lição]( desligar fecha sessões do agente (fake); excluir host pede confirmação; `TranslationsTest` verde; sem `delay` simulado no código (teste de arquitetura: `ComputadorPanel` não importa `kotlinx.coroutines.delay`). - **Tests**: instrumentado (Compose + servidor de teste). **Gate**: `quick` + `pool instrumentar com.keyflow.ui.config.ComputadorHostsTest`. **Commit**: `feat(computador): painel de hosts com teste de conexão real`. +**Correção da revisão #96 (2026-10-31):** chave vazia/indisponível é recusada; a regra do agente recebe disponibilidade conferida no cofre/registro. `GravadorDeHosts` confirma o segredo antes de publicar a preferência/permissão e restaura a senha anterior se a configuração falhar. O modal desabilita Chave sem chaves e consulta apenas a existência da senha. Unitários de hosts/codec e traduções verdes, mutação de `lembrarSenha` sem credencial detectada/revertida. Os quatro novos casos de `ComputadorHostsTest` compilaram; execução pendente por ANR prévio do SystemUI. + ### CMP-T16 — Painel Chaves e os diálogos de confiança, senha e biometria — **Status: pendente** - **What**: `ComputadorChaves` (lista com tipo, impressão, proteção — "Protegida pelo hardware do aparelho" / "Protegida pelo Keystore (por software)" / "Guardada cifrada no cofre do aparelho" —, biometria, hosts que usam), "Gerar chave" **pergunta o tipo** (Ed25519 ou ECDSA P-256, D13), "Importar" (arquivo pelo seletor do sistema ou colar; passphrase em diálogo), "Copiar chave pública", ligar/desligar biometria com o aviso da nova digital e a janela de 5 minutos (D14), renomear, excluir; diálogos `ConfirmarServidor` (Nova: host:porta, algoritmo, `SHA256:…`, botões **Confiar e continuar** / **Cancelar**), `ServidorMudou` (alerta, impressão antiga × nova, **Cancelar** em destaque, **Substituir e continuar** em segundo passo), `PedirSenha`, `CodigoDeVerificacao` (keyboard-interactive) e a mensagem de cada `CausaSsh`. diff --git a/.specs/project/licoes/2026-10-31-computador-d-correcao.md b/.specs/project/licoes/2026-10-31-computador-d-correcao.md new file mode 100644 index 00000000..2632f2c9 --- /dev/null +++ b/.specs/project/licoes/2026-10-31-computador-d-correcao.md @@ -0,0 +1,39 @@ +# 2026-10-31 — Correções da revisão do Computador D (#96) + +## CMP-35: a gramática vem do parser, não da representação textual + +`ParserVt` acompanha o `parser.c` fixado: C0/DEL não terminam CSI/DCS, CAN/SUB cancelam e ESC dentro de string só é elevado a C1 quando é ST. Prefixos nunca são entregues antes da decisão. Números decimais perdem zeros à esquerda; argumentos fora da faixa e sequências acima de 1 MiB são descartados até o final/cancelamento, sem mudar para passagem livre. O limite retém no máximo 1 MiB por sequência; imagens seguem Off nesta fase, e a integração de imagens deverá respeitar esse orçamento ou fornecer sua própria fronteira segura. + +Os controles do teclado continuam sendo teclas: Ctrl+E e Alt+Ctrl+X/Z precisam conservar seus bytes. A direção do fluxo distingue o ENQ do servidor dos controles produzidos pelo teclado; isso não tenta identificar a origem de callbacks nativos. A regressão adicional confere todos os controles com/sem Alt, Alt+Esc e Alt+Unicode; a matriz de `MapaTecladoFisicoTest` confere cada combinação mapeada. O lote final do filtro e do mapa passou em 42/42 testes. Bytes inválidos após ESC também são ignorados na decisão, como no C; o orçamento não é reiniciado ao reconhecer um introduzidor depois deles. + +O filtro de respostas reconhece C1 bruto sem confundi-lo com continuação UTF-8. O servidor não pode ligar S8C1T; a saída ainda aplica a lista fechada mesmo se receber respostas C1. Títulos cancelados não são publicados. Remover a sequência inteira evita deixar o emulador preso em uma string cujo ESC foi consumido. + +`ParserLibvtermTest` compila o C original com o compilador do host dentro dos unitários do pool (sem NDK/JNI), consulta os callbacks do parser e compara 500 fluxos fragmentados e 300 fluxos de bytes arbitrários: consultas proibidas ausentes, DECCKM igual e texto posterior visível. Testes do filtro: 25/25. A mutação que liberou S8C1T derrubou três testes (incluindo o fuzz); revertida e 25/25 novamente. + +Mouse SGR na tela continua para CMP-25/T27; esta correção não o conecta. + +## CMP-26/27: ordem depende de uma fila, não de um atraso + +O texto do Compose passa por `QueuedTerminal` e pela `TerminalDispatcher`; as teclas do app agora entram na mesma FIFO com `dispatchKeyboardInput`. O callback continua único e aplica o filtro antes do consumidor SSH. O atraso de 40 ms saiu. Três testes JVM retêm o worker e conferem Esc+i, Backspace+letra e texto+Enter sem relógio: 3/3; mutação emitindo fora da fila: 3/3 vermelhos, revertida, 3/3 verdes. + +Os nove instrumentados de entrada foram preparados (inclusive o componente correto do IME), mas não executados. Antes da instalação, a verificação sob Gradle → emulador encontrou `Application Not Responding: com.android.systemui`, com lastanr `executing service ...SystemUIAuxiliaryDumpService`. Conforme a tarefa, os instrumentados foram suspensos; sem fechar diálogo, esconder erro, reiniciar ou mudar o AVD. A compilação dos APKs passou; após as correções restantes, a compilação final também confere os testes novos. + +## CMP-09: preferência de lembrar não é existência de credencial + +`RegraAgente` recebe um fato de disponibilidade e recusa por padrão; `Chave("")` é inválida. O codec valida estrutura e mantém a intenção nos metadados; a gravação consulta o cofre/registro. `DadosDaEdicaoDeHost` permite testar a persistência usada por `AcoesDeHostReais`: segredo confirmado antes de publicar a marca, senha anterior preservada na edição e restaurada se a configuração falhar. Falha silenciosa ou exceção do cofre não publica host/permissão. Não se grava `lembrarSenha=true` sem senha disponível. + +Depois da validação, a fase segredo → confirmação do DataStore → limpeza é protegida contra cancelamento do modal. Um teste reproduziu o ator que confirma a configuração antes de entregar uma resposta cancelada: desfazer apenas a senha nessa janela deixava permissão sem credencial. O teste ficou vermelho, a fase foi protegida com `NonCancellable` e os nove testes do gravador passaram. + +O modal consulta só a existência da senha (IO), acompanha a presença do rascunho para habilitar a marca e desabilita Chave quando não há chaves. Estado de tela após IO volta a Main. Os testes de persistência/regra/validação/codec e traduções passaram; a mutação que ignorou a disponibilidade da senha derrubou `lembrarNaoEProvaDeSegredoGuardado`, foi revertida e a rodada final passou. Os instrumentados novos compilaram; a execução continua suspensa pelo ANR observado antes da instalação. + +## CMP-30: avisos de todos os conjuntos incorporados + +A decisão do Yan mantém JetBrains Mono Nerd Font Mono 3.5.1. A OFL da JetBrains Mono 2.304 foi complementada com o LICENSE completo do Nerd Fonts (Ryan L McIntyre, 2014), os avisos MIT de Devicons, Font Awesome Extension, Octicons, Seti, Powerline/Extra e IEC, os textos OFL de Pomicons/Weather/Font Awesome, CC BY 4.0 de Codicons/ícones Font Awesome e Apache-2.0 de Material Design. O aviso do extraglyphs/Hack conserva também a origem Bitstream. Espaços finais/fins de linha foram normalizados sem alterar as palavras das licenças. + +O inventário de `src/glyphs/README.md` na tag 3.5.1 traz Pomicons como OFL 1.1 com RFN. Font Logos 1.3.0 possui Unlicense no upstream; a tabela do Nerd Fonts ainda o chama de “unlicensed”. O texto Unlicense e o copyright 2014–2026 de Lukas W presente na fonte foram preservados. README e arquivo de avisos apontam as fontes exatas; fontes sem data publicada não receberam datas inventadas. + +O asset permaneceu inalterado, SHA-256 `f2a5ea6cfab397445ffab00c0370927b66d61e560a05db5db271b42006381c1a`. A conferência verificou 16 atribuições (base, Nerd Fonts e 14 conjuntos) e os textos integrais; nenhum teste foi acrescentado para comparar texto de licença com ele mesmo. + +## Condição redundante e mouse + +`TerminalScreen` verifica só a ligação nula: uma ligação não nula já implica sessão ativa. O compilador apontava a segunda condição como sempre falsa. Mouse SGR na tela permanece pendente em CMP-25/T27. diff --git a/.specs/project/licoes/2026-10-31-computador-d.md b/.specs/project/licoes/2026-10-31-computador-d.md new file mode 100644 index 00000000..80f9fbc9 --- /dev/null +++ b/.specs/project/licoes/2026-10-31-computador-d.md @@ -0,0 +1,40 @@ +# 2026-10-31 — Computador D: terminal na tela, entrada e painel de hosts (CMP-T13..T15) + +Branch `equipe/computador-d` (base: `equipe/computador-c` + `origin/main` com #89 e #92). Mais os P3 da revisão do #92 num commit à parte. + +## O que entrou +- **T13** `computador/app/{TerminalActivity,LigacaoEmulador,EmuladoresDeSessao,ComputadorApp,FiltroDeRespostas,...}` e `ui/terminal/{TerminalScreen,AbasDeSessao,BarraDeEstado}`: + o emulador mora num registro do processo (girar ou voltar não perde a tela), os bytes do servidor passam por `FiltroDaSaidaDoServidor` e a saída do emulador por `FiltroDeRespostas`. + Fonte **JetBrains Mono Nerd Font Mono Regular** (OFL 1.1, `assets/fonts`, licença em `app/licenses`). A bancada `TerminalSpikeActivity` e o `TerminalSpikeTest` saíram. +- **T14** `MapaTecladoFisico` (xterm, DECCKM lido do próprio fluxo do servidor), `DeduplicadorDeTeclas` (o `aa`), `ModificadoresPegajosos`, `GuardaDeLinhas` (ditado com quebra de linha vira confirmação) e `TeclasExtras`. +- **T15** `ComputadorPanel` reescrito (Hosts · Chaves · Sessões), `ComputadorHosts`, `ComputadorHostModal`, `EdicaoDeHost` (regra pura), `TestadorDeConexao` (confiança efêmera: testar nunca passa a confiar) e `AcoesDeHostReais`. + +## Lições +- **Origem das respostas**: a flag de origem não serve (T1). O filtro olha **o que é**: lista fechada de teclas e respostas na saída; na entrada consome ENQ, DECRQSS, `CSI 20/21 t`, `OSC 21` e `OSC 52 ?`. + O risco novo é a **junção**: remover o meio de uma sequência pode emendar as pontas numa sequência proibida (`ESC[2` + ENQ + `1t`). Regra: byte que não pertence ao CSI/OSC/DCS em curso **descarta** o trecho segurado (nunca o emite). O teste por sorteio achou 3 furos (ESC dentro de DCS, ENQ dentro de string, link com caractere de formato bidi). +- **Colar**: remover `ESC[201~` em uma passada deixa `ESC ESC[201~[201~` virar a marca de novo; a remoção repete até não sobrar. +- **Ordem**: o termlib processa texto de vários caracteres numa thread própria e posta o callback depois; tecla mapeada pelo app (Enter, Backspace) que vá direto para a fila passa à frente. `LigacaoEmulador.enviar` posta com 40 ms de atraso, igual para todas. **Limite conhecido**: entradas dos dois caminhos no mesmo instante podem trocar de ordem; uma pessoa digitando não chega perto. +- **kitty `a=q`** só responde com imagens ligadas; na T13 elas ficam `Off` (a política por host é da Fase 2), então o instrumentado não exige o `OK`. +- `ConfigModal` já rola o conteúdo: `Column(verticalScroll)` dentro dele estoura por altura infinita. O `Column` do modal junta (merge) os filhos: testes de texto com `testTag` usam `useUnmergedTree = true`. +- `createComposeRule` depois de `ActivityScenario` singleTask no mesmo processo falhou ("No compose hierarchies"); os testes de tela e de activity ficaram em classes separadas e a intent leva `CLEAR_TASK`. +- Strings do núcleo (`CausaSsh`, `MotivoHost`, `MotivoAgente`, `MotivoRecusa`) viram texto por `when` exaustivo com `tr("…")` literal em `TextosComputador.kt` (o `TranslationsTest` só enxerga literais). + +## SPEC_DEVIATION / pendente +- **Gboard e toque no KeyFlow IME de verdade**: o instrumentado usa o `InputConnection` real do campo com o KeyFlow IME como teclado atual (`ime set`); o toque nas teclas e o Gboard ficam para a T21. +- **SSH real nos instrumentados**: o `SshFixtureServer` é JVM e o `pool instrumentar` não sobe o fixture; os instrumentados usam `ConexaoFalsa`. O SSH real (T9) e o `TestadorDeConexao` rodam contra o fixture nos unitários. +- **Tamanho da fonte por pinça não persiste** (o termlib não devolve o valor) e **o título da aba** vem do nosso filtro (o termlib 0.3.14 não o publica). +- **"Permitir neste host" do OSC 52** não existe (padrão negar; só "permitir desta vez"): exigiria campo novo no `Host`. +- **Imagens** (kitty, iTerm2, sixel) seguem desligadas no emulador até a Fase 2. **Mouse**: não ligado. +- **Licença da fonte**: o arquivo é OFL, mas o README do Nerd Fonts lista um conjunto de ícones ("Font Logos") como "unlicensed". Decisão do Yan se isso basta; trocar por uma subfonte sem esse conjunto é possível. +- `InteracaoDoApp` recusa tudo enquanto não houver tela (T16): nenhuma tela do app abre sessão sozinha ainda (a aba continua "Em breve"). +- A aba **não** sai do "Em breve" (T17); `ComputadorPanel` só é exercitado pelos testes. + +## Mutações (uma por task, vermelho confirmado) +T13 `FiltroDeRespostas` deixando passar `OSC 52` (`SequenciasHostisTest`); T14 `Ctrl+letra` sem o `+1` (`MapaTecladoFisicoTest`); T15 `EdicaoDeHost` sem a `RegraAgente` (`EdicaoDeHostTest`). Todas revertidas. + +## Gates +Unitários, `lintDebug` e `assembleDebugAndroidTest` verdes. Instrumentados: `TerminalScreenTest` 3/3, `TerminalActivityTest` 4/4 (inclui girar a tela → `window-change`), `LigacaoEmuladorTest` 10/10, `ComputadorHostsTest` 9/9, `ChavesAndroidTest` (inclui o P3 do cofre que lança na gravação) verde. +O emulador chegou a ficar com a tela apagada numa rodada ("No compose hierarchies" em tudo): não mexi no SystemUI nem no keyguard, e a rodada seguinte passou. + +## P3 do #92 +`invalidar()` protegido (marca em memória se o cofre falhar), `observarPrimeiraSessao` inscreve antes de conferir as vivas (`onSubscription`), contrato dos coletores no `GerenciadorDeSessoes`, `ipVersion` do builder no `ConexaoCbssh` e relógio de 45 s que pausa enquanto o usuário decide. diff --git a/app/licenses/OFL_JetBrainsMonoNerdFont.txt b/app/licenses/OFL_JetBrainsMonoNerdFont.txt new file mode 100644 index 00000000..d9c93d8b --- /dev/null +++ b/app/licenses/OFL_JetBrainsMonoNerdFont.txt @@ -0,0 +1,1549 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +============================================================ +Nerd Fonts e conjuntos de ícones incorporados — avisos adicionais +============================================================ + +Asset: JetBrainsMonoNerdFontMono-Regular.ttf, Nerd Fonts 3.5.1, base JetBrains Mono 2.304. +O KeyFlow distribui o asset recebido sem alterações. O Nerd Fonts incorporou e remapeou os glifos +para a variante Mono; as atribuições abaixo identificam os projetos de origem e suas licenças. +Não há endosso dos autores, fabricantes ou donos das marcas representadas pelos ícones. + +Inventário e versões: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/README.md +README do release: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/readme.md + +A tabela do Nerd Fonts chama Font Logos de "unlicensed"; o LICENSE do upstream v1.3.0 contém +Unlicense (domínio público). Esse texto e o copyright presente na fonte são preservados abaixo. +Pomicons é OFL 1.1 com RFN. O aviso do Hack conserva também a licença Bitstream da origem. +Os textos recebidos são reproduzidos completos, inclusive os avisos adicionais e os RFNs. + + +------------------------------------------------------------ +Nerd Fonts 3.5.1 — Ryan L McIntyre; fonte modificada OFL e fontes do projeto MIT +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/LICENSE +------------------------------------------------------------ + +# Nerd Fonts Licensing + +There are various sources used under various licenses: + +* Nerd Fonts source fonts, patched fonts, and folders with explict OFL SIL files are licensed under SIL OPEN FONT LICENSE Version 1.1 (see below). +* Nerd Fonts original source code files (such as `.sh`, `.py`, `font-patcher` and others) are licensed under the MIT License (MIT) (see below). +* Many other licenses are present in this project for even more detailed breakdown see: [License Audit](https://github.com/ryanoasis/nerd-fonts/blob/-/license-audit.md). + +## Source files not in folders containing an explicit license are using the MIT License (MIT) + +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Various Fonts, Patched Fonts, SVGs, Glyph Fonts, and any files in a folder with explicit SIL OFL 1.1 License + +Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + + +------------------------------------------------------------ +Devicons 2.17.0.custom — Copyright (c) 2015 konpa — MIT +Fonte: https://github.com/devicons/devicon/blob/v2.17.0/LICENSE +------------------------------------------------------------ + +The MIT License (MIT) + +Copyright (c) 2015 konpa + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +------------------------------------------------------------ +Font Awesome Extension 0.0.3 — Copyright (c) 2017 André Luiz Gava — MIT +Fonte: https://github.com/AndreLZGava/font-awesome-extension/blob/master/LICENCE +------------------------------------------------------------ + +MIT License + +Copyright (c) 2017 André Luiz Gava + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +------------------------------------------------------------ +Octicons 18.3.0 — Copyright (c) 2023 GitHub Inc. — MIT +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/octicons/LICENSE +------------------------------------------------------------ + +MIT License + +Copyright (c) 2023 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +------------------------------------------------------------ +Seti UI 0.8.1 — Copyright (c) 2014 Jesse Weed — MIT; glifos originais do Nerd Fonts sob MIT +Fonte: https://github.com/jesseweed/seti-ui/blob/v0.8.1/LICENSE.md +------------------------------------------------------------ + +Copyright (c) 2014 Jesse Weed + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +------------------------------------------------------------ +Powerline Symbols — Copyright 2013 Kim Silkebækken and other contributors — MIT +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/powerline-symbols/LICENSE.txt +------------------------------------------------------------ + +Copyright 2013 Kim Silkebækken and other contributors +https://github.com/powerline/powerline + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +------------------------------------------------------------ +Powerline Extra 1.200 — Copyright (c) 2016 Ryan L McIntyre — MIT +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/powerline-extra/LICENSE +------------------------------------------------------------ + +MIT License + +Copyright (c) 2016 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +------------------------------------------------------------ +IEC Power Symbols — Copyright (c) 2013 Joe Loughry — MIT +Fonte: https://github.com/jloughry/Unicode/blob/master/LICENSE.txt +------------------------------------------------------------ + +The MIT License (MIT) + +Copyright (c) 2013 Joe Loughry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +------------------------------------------------------------ +Extraglyphs do Hack 3.003 — Source Foundry Authors / Bitstream Inc. — MIT e aviso Bitstream preservados +Fonte: https://github.com/source-foundry/Hack/blob/v3.003/LICENSE.md +------------------------------------------------------------ + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" + +### MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + + + +------------------------------------------------------------ +Pomicons 1.001 — Copyright (c) 2021 Gabriele Lana; Reserved Font Name Pomicons — OFL 1.1 +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/pomicons/LICENSE +------------------------------------------------------------ + +Copyright (c) 2021, Gabriele Lana gabriele.lana@gmail.com +with Reserved Font Name Pomicons. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + + +------------------------------------------------------------ +Weather Icons 2.0.10 — Erik Flowers — OFL 1.1; cabeçalho recebido preservado, sem preencher datas ausentes +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/weather-icons/OFL.txt +------------------------------------------------------------ + +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (), +with Reserved Font Name . +Copyright (c) , (). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + + +------------------------------------------------------------ +Font Awesome Free 6.5.1 — Fonticons, Inc.; copyright e RFN recebidos abaixo — fontes OFL 1.1 e ícones SVG/JS CC BY 4.0 +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/font-awesome/LICENSE.txt +------------------------------------------------------------ + +Fonticons, Inc. (https://fontawesome.com) + +-------------------------------------------------------------------------------- + +Font Awesome Free License + +Font Awesome Free is free, open source, and GPL friendly. You can use it for +commercial projects, open source projects, or really almost whatever you want. +Full Font Awesome Free license: https://fontawesome.com/license/free. + +-------------------------------------------------------------------------------- + +# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) + +The Font Awesome Free download is licensed under a Creative Commons +Attribution 4.0 International License and applies to all icons packaged +as SVG and JS file types. + +-------------------------------------------------------------------------------- + +# Fonts: SIL OFL 1.1 License + +In the Font Awesome Free download, the SIL OFL license applies to all icons +packaged as web and desktop font files. + +Copyright (c) 2022 Fonticons, Inc. (https://fontawesome.com) +with Reserved Font Name: "Font Awesome". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +-------------------------------------------------------------------------------- + +# Code: MIT License (https://opensource.org/licenses/MIT) + +In the Font Awesome Free download, the MIT license applies to all non-font and +non-icon files. + +Copyright 2022 Fonticons, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +# Attribution + +Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font +Awesome Free files already contain embedded comments with sufficient +attribution, so you shouldn't need to do anything additional when using these +files normally. + +We've kept attribution comments terse, so we ask that you do not actively work +to remove them from files, especially code. They're a great way for folks to +learn about Font Awesome. + +-------------------------------------------------------------------------------- + +# Brand Icons + +All brand icons are trademarks of their respective owners. The use of these +trademarks does not indicate endorsement of the trademark holder by Font +Awesome, nor vice versa. **Please do not use brand logos for any purpose except +to represent the company, product, or service to which they refer.** + + + +------------------------------------------------------------ +Codicons 0.0.45 — Microsoft Corporation e contribuidores — CC BY 4.0; o texto também acompanha a atribuição dos ícones Font Awesome +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/codicons/LICENSE.txt +------------------------------------------------------------ + +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + + +------------------------------------------------------------ +Material Design Icons — Pictogrammers / Templarian, fonte de 06/10/2022 — Apache-2.0 +Fonte: https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/materialdesign/LICENSE +------------------------------------------------------------ + +Pictogrammers Free License +-------------------------- + +This icon collection is released as free, open source, and GPL friendly by +the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it +for commercial projects, open source projects, or anything really. + +# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +Some of the icons are redistributed under the Apache 2.0 license. All other +icons are either redistributed under their respective licenses or are +distributed under the Apache 2.0 license. + +# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) +All web and desktop fonts are distributed under the Apache 2.0 license. Web +and desktop fonts contain some icons that are redistributed under the Apache +2.0 license. All other icons are either redistributed under their respective +licenses or are distributed under the Apache 2.0 license. + +# Code: MIT (https://opensource.org/licenses/MIT) +The MIT license applies to all non-font and non-icon files. + + + +------------------------------------------------------------ +Texto completo Apache-2.0 aplicável ao Material Design Icons +Fonte: https://www.apache.org/licenses/LICENSE-2.0.txt +------------------------------------------------------------ + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +------------------------------------------------------------ +Font Logos 1.3.0 — Copyright (c) 2014-2026, Lukas W (metadado da fonte) — Unlicense +Fonte: https://github.com/lukas-w/font-logos/blob/v1.3.0/LICENSE +------------------------------------------------------------ + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/app/licenses/README.md b/app/licenses/README.md index 96747bfa..eb5d9d24 100644 --- a/app/licenses/README.md +++ b/app/licenses/README.md @@ -4,6 +4,7 @@ |---|---|---| | Space Grotesk (fonte) | SIL OFL 1.1 | `OFL_SpaceGrotesk.txt` | | Space Mono (fonte) | SIL OFL 1.1 | `OFL_SpaceMono.txt` | +| JetBrains Mono Nerd Font Mono Regular v3.5.1 (fonte do terminal; JetBrains Mono 2.304 + ícones Nerd Fonts) | OFL 1.1; conjuntos incorporados MIT, Apache-2.0, CC BY 4.0, OFL e Unlicense; aviso Bitstream da origem Hack preservado | `OFL_JetBrainsMonoNerdFont.txt` (copyright e textos completos de cada conjunto) | | sherpa-onnx 1.13.8 (AAR recompilado sem TTS, reconhecimento de voz no aparelho) | Apache-2.0 | `Apache-2.0_sherpa-onnx.txt` | | ONNX Runtime (`libonnxruntime.so`, dentro do AAR do sherpa-onnx) | MIT | `MIT_onnxruntime.txt` | | Whisper base/small (OpenAI) — modelos baixados sob demanda | MIT (código e pesos, repositório oficial) | `MIT_whisper.txt` | @@ -112,3 +113,34 @@ Os avisos recebidos nos AARs/JARs estão em `computador/`, sem remover copyright Aviso embutido adicional: o cbssh conserva trechos Bouncy Castle sob MIT (`MIT_bouncycastle-cbssh.txt`). Textos completos das licenças não Apache também em `MIT_kaitai.txt`, `ISC_jbcrypt.txt`, `BSL-1.0_kstatemachine.txt` e `BSD-3-Clause_protobuf.txt`. + +## Fonte do terminal — Nerd Fonts 3.5.1 (revisão #96) + +A fonte permanece por decisão do Yan. O asset é o original do release, sem alterações pelo KeyFlow. +As versões dos glifos vêm do [inventário do Nerd Fonts 3.5.1](https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/src/glyphs/README.md); +o [README](https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/readme.md) e o +[LICENSE](https://github.com/ryanoasis/nerd-fonts/blob/v3.5.1/LICENSE) registram o trabalho de Ryan L McIntyre (copyright 2014, MIT/OFL). +`OFL_JetBrainsMonoNerdFont.txt` preserva a OFL da base e reúne os copyrights, RFNs e textos completos abaixo. + +| Conjunto | Autor/atribuição | Licença dos glifos/fontes e avisos preservados | +| --- | --- | --- | +| JetBrains Mono 2.304 | The JetBrains Mono Project Authors (2020) | OFL 1.1 | +| Nerd Fonts 3.5.1 / glifos originais | Ryan L McIntyre (2014) | OFL 1.1 / MIT | +| Codicons 0.0.45 | Microsoft Corporation e contribuidores | CC BY 4.0 | +| Devicons 2.17.0.custom | konpa (2015) / projeto Devicon | MIT | +| Extraglyphs (Hack 3.003) | Source Foundry Authors (2018), Bitstream Inc. (2003) | MIT; aviso Bitstream da origem preservado | +| Font Awesome Free 6.5.1 | Fonticons, Inc. (copyright/RFN do aviso recebido) | OFL 1.1 nas fontes; CC BY 4.0 nos ícones SVG/JS | +| Font Awesome Extension 0.0.3 | André Luiz Gava (2017) | MIT | +| Font Logos 1.3.0 | Lukas W (2014–2026, metadado da fonte) | Unlicense | +| Material Design Icons (06/10/2022) | Pictogrammers / Templarian | Apache-2.0 (texto completo e aviso recebidos) | +| Octicons 18.3.0 | GitHub Inc. (2023) | MIT | +| Seti UI 0.8.1 | Jesse Weed (2014) | MIT | +| Pomicons 1.001 | Gabriele Lana (2021), RFN Pomicons | OFL 1.1 | +| Powerline Symbols 1.000 | Kim Silkebækken e contribuidores (2013) | MIT | +| Powerline Extra 1.200 | Ryan L McIntyre (2016) | MIT | +| IEC Power Symbols | Joe Loughry (2013) | MIT | +| Weather Icons 2.0.10 | Erik Flowers | OFL 1.1, texto original preservado sem inventar datas | + +Font Logos é rotulado como “unlicensed” na tabela do Nerd Fonts; o [LICENSE do upstream v1.3.0](https://github.com/lukas-w/font-logos/blob/v1.3.0/LICENSE) +contém Unlicense. Os glifos foram incorporados/remapeados pelo Nerd Fonts para a variante Mono; o KeyFlow não alterou a fonte. +As marcas representadas pertencem aos respectivos titulares e não indicam endosso. diff --git a/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt index f74c8ead..fbc07b2d 100644 --- a/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt +++ b/app/src/androidTest/java/com/keyflow/computador/app/ChavesAndroidTest.kt @@ -390,6 +390,27 @@ class ChavesAndroidTest { assertEquals("sem prompt", promptsAntes, aut.prompts) } + @Test fun cofreQueLancaNaGravacaoDaMarcaBloqueiaSemExcecao() { + val d = DetectorFalso() + val aut = AutenticadorFalso() + val p = provedor(autenticador = aut, aplicarBio = false, detector = d) + val chave = gerar(p, TipoDeChave.ECDSA_P256, bio = true) + val quebrado = object : CofreDeSegredos by cofre { + override fun gravar(chave: String, valor: String) { if (chave.startsWith("ssh_inv.")) throw SecurityException("cofre cheio") else cofre.gravar(chave, valor) } + } + val q = ProvedorDeChavesAndroid(quebrado, aut, RelogioFalso(), aplicarBiometriaNoKeystore = false, detector = d) + d.invalida = true // outra digital cadastrada + val a = q.assinador(chave) + // `assinar` descobre a invalidação, a gravação da marca falha, e mesmo assim: null, sem exceção, e a chave segue bloqueada. + assertNull(runBlocking { a.assinar(dados) }) + assertTrue(a.invalidada) + assertEquals(EstadoChave.INVALIDADA, q.estado(chave)) + assertNull(runBlocking { a.assinar(dados) }) + // `estado()` também não deixa a exceção escapar. + val e = ProvedorDeChavesAndroid(quebrado, aut, RelogioFalso(), aplicarBiometriaNoKeystore = false, detector = d) + assertEquals(EstadoChave.INVALIDADA, e.estado(chave)) + } + @Test fun excluirApagaASentinela() { val d = DetectorFalso() val p = provedor(aplicarBio = false, detector = d) diff --git a/app/src/androidTest/java/com/keyflow/computador/app/ConexaoFalsa.kt b/app/src/androidTest/java/com/keyflow/computador/app/ConexaoFalsa.kt new file mode 100644 index 00000000..bbd19325 --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/ConexaoFalsa.kt @@ -0,0 +1,52 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DependenciasDeSessao +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.EventoConexao +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.computador.ssh.ResultadoConexao +import com.keyflow.computador.ssh.ResultadoTeste +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow + +/** Conexão de mentira dos instrumentados: abre na hora, devolve o que o teste empurrar em [servidor] e anota tudo que o app escreve. */ +class ConexaoFalsa : ConexaoSsh { + private val ev = MutableSharedFlow(replay = 4) + override val eventos: SharedFlow = ev + private val canal = Channel(Channel.UNLIMITED) + override val saida: Flow = canal.receiveAsFlow() + val escritos = CopyOnWriteArrayList() + val tamanhos = CopyOnWriteArrayList() + @Volatile var ptyInicial: PtyInfo? = null + @Volatile var fechada = false + + override suspend fun conectar(destino: DestinoSsh, pty: PtyInfo): ResultadoConexao { ptyInicial = pty; return ResultadoConexao.Aberta } + override suspend fun testar(destino: DestinoSsh): ResultadoTeste = error("não usado") + override suspend fun escrever(bytes: ByteArray) { escritos += bytes } + override suspend fun redimensionar(pty: PtyInfo) { tamanhos += pty } + override suspend fun fechar() { fechada = true; canal.close() } + + /** O que o "servidor" manda para a tela. */ + fun servidor(bytes: ByteArray) { canal.trySend(bytes) } + + fun tudoQueFoiEscrito(): ByteArray = escritos.fold(ByteArray(0)) { a, b -> a + b } +} + +/** As dependências da sessão com [ConexaoFalsa]: [conexoes] guarda cada uma criada, na ordem. */ +class DependenciasFalsas : DependenciasDeSessao { + val conexoes = CopyOnWriteArrayList() + override fun novaConexao(): ConexaoSsh = ConexaoFalsa().also { conexoes += it } + override suspend fun destino(host: Host, dono: DonoDaSessao): DestinoSsh = DestinoSsh(host) + override fun precisaDoUsuario(host: Host) = false + override val rede: StateFlow = MutableStateFlow(true) + override fun agoraMs() = System.currentTimeMillis() +} diff --git a/app/src/androidTest/java/com/keyflow/computador/app/EntradaTerminalImeTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/EntradaTerminalImeTest.kt new file mode 100644 index 00000000..6f2e393e --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/EntradaTerminalImeTest.kt @@ -0,0 +1,173 @@ +package com.keyflow.computador.app + +import android.content.Context +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.compose.ui.graphics.Color +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.EstadoSessao +import com.keyflow.computador.sessao.ResultadoAbertura +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * CMP-27 e CMP-31 com o KeyFlow IME como teclado atual: o `InputConnection` real do campo do terminal recebe o que um teclado de tela + * entrega, e o servidor (conexão falsa) vê **uma** vez cada tecla. Não substitui o toque no teclado de verdade (T21, manual). + */ +@RunWith(AndroidJUnit4::class) +class EntradaTerminalImeTest { + private val instr = InstrumentationRegistry.getInstrumentation() + private val ctx: Context get() = instr.targetContext + private val host = Host(id = "h1", nome = "dev-server", endereco = "dev.example.org", usuario = "yan", autenticacao = AutenticacaoHost.Senha) + private val deps = DependenciasFalsas() + private var imeAnterior: String? = null + private val imeKeyFlow get() = "${ctx.packageName}/com.keyflow.ime.KeyFlowKeyboardService" + + private fun shell(cmd: String): String = instr.uiAutomation.executeShellCommand(cmd).use { f -> java.io.FileInputStream(f.fileDescriptor).bufferedReader().readText() } + + @Before fun ativarOKeyFlowIme() { + imeAnterior = shell("settings get secure default_input_method").trim().takeIf { it.isNotEmpty() && it != "null" } + shell("ime enable $imeKeyFlow") + shell("ime set $imeKeyFlow") + assertEquals(imeKeyFlow, shell("settings get secure default_input_method").trim()) + } + + @After fun restaurarIme() { imeAnterior?.let { shell("ime set $it") } } + + private fun campo(v: View): View? { + if (v.javaClass.simpleName == "ImeInputView") return v + if (v is ViewGroup) repeat(v.childCount) { campo(v.getChildAt(it))?.let { c -> return c } } + return null + } + + private fun esperar(ms: Long = 10_000, c: () -> Boolean) { + val fim = System.currentTimeMillis() + ms + while (!c()) { if (System.currentTimeMillis() > fim) throw AssertionError("tempo esgotado"); instr.waitForIdleSync(); Thread.sleep(25) } + } + + private fun hex(s: String) = s.toByteArray(Charsets.UTF_8).joinToString(" ") { "%02x".format(it) } + + private fun escrito(): String = String(deps.conexoes.first().tudoQueFoiEscrito(), Charsets.UTF_8) + + private fun comTerminal(bloco: (ComputadorApp, View) -> T): T { + val app = ComputadorApp.paraTeste(ctx, deps) + val s = (app.abrirSessao(host, DonoDaSessao.USUARIO, Color.White, Color.Black) as ResultadoAbertura.Aberta).sessao + esperar { app.gerenciador.resumos.value.any { it.estado == EstadoSessao.Aberta } } + val intent = TerminalActivity.intent(ctx, sessaoId = s.id).addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK) + ActivityScenario.launch(intent).use { cenario -> + var view: View? = null + esperar { cenario.onActivity { a -> view = campo(a.window.decorView) }; view != null } + return try { bloco(app, view!!) } finally { kotlinx.coroutines.runBlocking { app.gerenciador.fecharTodas() } } + } + } + + private fun conexao(view: View, aoCriar: (android.view.inputmethod.InputConnection) -> Unit) { + instr.runOnMainSync { + view.requestFocus() + val ic = view.onCreateInputConnection(EditorInfo()) + assertNotNull(ic) + aoCriar(ic!!) + } + instr.waitForIdleSync() + } + + @Test fun digitarLsLaEEnterChegaExatamenteUmaVez() = comTerminal { _, view -> + val enter = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER) + val soltar = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER) + conexao(view) { ic -> + ic.commitText("ls -la", 1) + // O mesmo evento por dois caminhos (o `aa` da T1): só um chega. + ic.sendKeyEvent(enter); view.dispatchKeyEvent(enter) + ic.sendKeyEvent(soltar); view.dispatchKeyEvent(soltar) + } + esperar { escrito().contains("\r") } + Thread.sleep(300) + assertEquals(hex("ls -la\r"), hex(escrito())) + } + + private fun tecla(view: View, ic: android.view.inputmethod.InputConnection, codigo: Int) { + for (acao in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + val evento = KeyEvent(acao, codigo) + ic.sendKeyEvent(evento); view.dispatchKeyEvent(evento) + } + } + + @Test fun escEImeNaMesmaChamadaNaoInvertemAOrdem() = comTerminal { _, view -> + conexao(view) { ic -> tecla(view, ic, KeyEvent.KEYCODE_ESCAPE); ic.commitText("i", 1) } + esperar { escrito().length >= 2 } + assertEquals(hex("\u001bi"), hex(escrito())) + } + + @Test fun escEILetrasFisicasNaoInvertemAOrdem() = comTerminal { _, view -> + conexao(view) { _ -> + for (codigo in listOf(KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_I)) { + view.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, codigo)) + view.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, codigo)) + } + } + esperar { escrito().length >= 2 } + assertEquals(hex("\u001bi"), hex(escrito())) + } + + @Test fun backspaceETextoRapidosNaoInvertemAOrdem() = comTerminal { _, view -> + conexao(view) { ic -> tecla(view, ic, KeyEvent.KEYCODE_DEL); ic.commitText("a", 1) } + esperar { escrito().length >= 2 } + assertEquals(hex("\u007fa"), hex(escrito())) + } + + @Test fun enterEsperaTodoOTextoEnfileiradoSemAtrasoArbitrario() = comTerminal { _, view -> + val texto = "x".repeat(10_000) + conexao(view) { ic -> ic.commitText(texto, 1); tecla(view, ic, KeyEvent.KEYCODE_ENTER) } + esperar { escrito().endsWith("\r") } + assertEquals(texto+"\r", escrito()) + } + + @Test fun acentosChegamEmUtf8EBackspaceApagaUmCaractere() = comTerminal { _, view -> + val apagar = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL) + conexao(view) { ic -> ic.commitText("ç", 1); ic.commitText("é", 1); ic.sendKeyEvent(apagar); view.dispatchKeyEvent(apagar) } + esperar { escrito().contains("\u007f") } + Thread.sleep(300) + assertEquals(hex("çé\u007f"), hex(escrito())) + assertTrue(deps.conexoes.first().tudoQueFoiEscrito().toList().windowed(1).isNotEmpty()) + } + + @Test fun ditadoChegaSemQuebraDeLinha() = comTerminal { _, view -> + conexao(view) { ic -> ic.commitText("olá mundo", 1) } + esperar { escrito().isNotEmpty() } + Thread.sleep(300) + assertEquals(hex("olá mundo"), hex(escrito())) + assertTrue(!escrito().contains('\n') && !escrito().contains('\r')) + } + + @Test fun ditadoComQuebraDeLinhaPedeConfirmacaoEPorTantoNaoExecuta() = comTerminal { app, view -> + conexao(view) { ic -> ic.commitText("echo a\necho b", 1) } + val ligacao = app.emuladores.de(app.gerenciador.resumos.value.first().id)!! + esperar { ligacao.pedidoDeColar.value != null } + assertEquals("", escrito()) + assertEquals(2, ligacao.pedidoDeColar.value!!.linhas) + ligacao.cancelarColar() + Thread.sleep(300) + assertEquals("cancelar não envia nada", "", escrito()) + } + + @Test fun ctrlCPeloTecladoFisicoViraETX() = comTerminal { _, view -> + val t = System.currentTimeMillis() + val ctrlC = KeyEvent(t, t, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_C, 0, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON) + conexao(view) { _ -> view.dispatchKeyEvent(ctrlC) } + esperar { escrito().isNotEmpty() } + Thread.sleep(300) + assertEquals(hex("\u0003"), hex(escrito())) + } +} diff --git a/app/src/androidTest/java/com/keyflow/computador/app/LigacaoEmuladorTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/LigacaoEmuladorTest.kt new file mode 100644 index 00000000..8082ff3f --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/computador/app/LigacaoEmuladorTest.kt @@ -0,0 +1,171 @@ +package com.keyflow.computador.app + +import androidx.compose.ui.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keyflow.computador.ssh.PtyInfo +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-23, 25, 35, 36 com o emulador do termlib de verdade (JNI) e um destino que só anota. */ +@RunWith(AndroidJUnit4::class) +class LigacaoEmuladorTest { + private val instr = InstrumentationRegistry.getInstrumentation() + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val ligacoes = mutableListOf() + + private class Anotador : DestinoDoEmulador { + val bytes = CopyOnWriteArrayList() + val tamanhos = CopyOnWriteArrayList() + override suspend fun escrever(bytes: ByteArray) { this.bytes += bytes } + override suspend fun redimensionar(pty: PtyInfo) { tamanhos += pty } + fun texto() = String(bytes.fold(ByteArray(0)) { a, b -> a + b }, Charsets.ISO_8859_1) + } + + private fun gravacao(nome: String) = instr.context.assets.open("computador/gravacoes/$nome").use { it.readBytes() } + + private fun nova(destino: Anotador, colunas: Int = 80, linhas: Int = 24): LigacaoEmulador { + var ligacao: LigacaoEmulador? = null + instr.runOnMainSync { ligacao = LigacaoEmulador(escopo, Color.White, Color.Black, colunas, linhas).also { it.ligarAoDestino(destino) } } + return ligacao!!.also { ligacoes += it } + } + + private fun esperar(ms: Long = 10_000, condicao: () -> Boolean) { + val fim = System.currentTimeMillis() + ms + while (!condicao()) { + if (System.currentTimeMillis() > fim) throw AssertionError("tempo esgotado") + instr.waitForIdleSync() + Thread.sleep(20) + } + } + + @After fun limpar() { instr.runOnMainSync { ligacoes.forEach { it.fechar() } }; escopo.cancel() } + + @Test fun textoUtf8ECoresDesenhamNoEstadoDoEmulador() { + val d = Anotador() + val l = nova(d) + l.escreverNaTela(gravacao("texto-cores.bin")) + // Pergunta ao estado do emulador (não à captura): a saída do último comando (OSC 133) é o texto desenhado. + esperar { l.emulador.getLastCommandOutput() == "Olá, terminal!" } + } + + @Test fun respostasPermitidasChegamAoServidorEAsProibidasNaoGeramByte() { + val d = Anotador() + val l = nova(d) + l.escreverNaTela(gravacao("hostil.bin")) + esperar { d.texto().contains("?1;2c") && d.texto().contains("0n") } + instr.waitForIdleSync() + Thread.sleep(300) + val saiu = d.texto() + // DA1, DSR e CPR respondidos; kitty a=q respondido. + assertTrue(saiu, saiu.contains("\u001b[?1;2c")) + assertTrue(saiu.contains("\u001b[0n")) + assertTrue(Regex("\u001b\\[\\d+;\\d+R").containsMatchIn(saiu)) + // A resposta do kitty a=q só existe com imagens ligadas: a Fase 2 (política por host) a testa. + // Nada de DCS (DECRQSS), de OSC 52 nem de relatório de título, e nenhuma resposta ao ENQ. + assertFalse(saiu.contains("\u001bP")) + assertFalse(saiu.contains("\u001b]52")) + assertFalse(saiu.contains("\u001b]l")) + assertFalse(saiu.contains("\u0005")) + assertFalse(saiu.contains("[21t")) + // O que o filtro de saída pegou é contado, nunca guardado. + assertEquals(0, l.descartesDeSaida) + } + + @Test fun osc52DeEscritaPedeConsentimentoENegarELimpa() { + val d = Anotador() + val l = nova(d) + l.escreverNaTela("\u001b]52;c;aGVsbG8=\u0007".toByteArray()) + esperar { l.pedidoDeClipboard.value != null } + assertEquals("hello", l.pedidoDeClipboard.value) + assertNull("negar não devolve o texto", l.responderClipboard(false)) + assertNull(l.pedidoDeClipboard.value) + l.escreverNaTela("\u001b]52;c;aGVsbG8=\u0007".toByteArray()) + esperar { l.pedidoDeClipboard.value != null } + assertEquals("hello", l.responderClipboard(true)) + } + + @Test fun osc52DeLeituraNuncaPedeNemResponde() { + val d = Anotador() + val l = nova(d) + l.escreverNaTela("\u001b]52;c;?\u0007\u001b]52;;?\u001b\\fim\r\n".toByteArray()) + instr.waitForIdleSync() + Thread.sleep(300) + assertNull(l.pedidoDeClipboard.value) + assertEquals("", d.texto()) + } + + @Test fun tituloVirouRotuloSanitizado() { + val d = Anotador() + val l = nova(d) + l.escreverNaTela("\u001b]0;build-box: ~/app\u0007".toByteArray()) + assertEquals("build-box: ~/app", l.titulo.value) + } + + @Test fun mudarAAreaEnviaWindowChangeComColunasLinhasEPixels() { + val d = Anotador() + val l = nova(d) + instr.runOnMainSync { l.emulador.resize(30, 100) } + esperar { d.tamanhos.isNotEmpty() } + val t = d.tamanhos.last() + assertEquals(100, t.colunas) + assertEquals(30, t.linhas) + assertEquals(30, l.tamanhoAtual().linhas) + assertEquals(100, l.tamanhoAtual().colunas) + } + + @Test fun teclasSaemNaOrdemEmQueForamDigitadas() { + val d = Anotador() + val l = nova(d) + val texto = "ls -la /tmp && echo ação" + instr.runOnMainSync { texto.forEach { l.emulador.dispatchCharacter(0, it) } } + esperar { d.texto().length >= texto.toByteArray().size } + assertEquals(texto, String(d.bytes.fold(ByteArray(0)) { a, b -> a + b }, Charsets.UTF_8)) + } + + @Test fun colarPassaPeloEmuladorEMantemOTexto() { + val d = Anotador() + val l = nova(d) + instr.runOnMainSync { l.colar("echo a\necho b") } + esperar { d.texto().contains("echo b") } + assertTrue(d.texto().contains("echo a")) + } + + @Test fun oHistoricoDeTextoSobreviveAoFecharEAbrirATela() { + val d = Anotador() + val l = nova(d) + val linhas = buildString { repeat(200) { append("linha ").append(it).append("\r\n") } } + l.escreverNaTela(linhas.toByteArray()) + esperar { l.emulador.retainedScrollbackLines >= 170 } + val antes = l.emulador.retainedScrollbackLines + // `onTrimMemory` com a tela invisível não descarta o histórico de texto. + val app = ComputadorApp.de(instr.targetContext) + app.aoFaltarMemoria(android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE) + instr.waitForIdleSync() + assertEquals(antes, l.emulador.retainedScrollbackLines) + } + + @Test fun fecharSoltaOEmuladorEIgnoraBytesTardios() { + val d = Anotador() + val l = nova(d) + instr.runOnMainSync { l.fechar() } + l.escreverNaTela("depois de fechar".toByteArray()) + assertEquals("", d.texto()) + val feito = CountDownLatch(1) + instr.runOnMainSync { l.fechar(); feito.countDown() } + assertTrue(feito.await(2, TimeUnit.SECONDS)) + } +} diff --git a/app/src/androidTest/java/com/keyflow/computador/app/TerminalSpikeTest.kt b/app/src/androidTest/java/com/keyflow/computador/app/TerminalSpikeTest.kt deleted file mode 100644 index 96606ebe..00000000 --- a/app/src/androidTest/java/com/keyflow/computador/app/TerminalSpikeTest.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.keyflow.computador.app - -import android.graphics.Bitmap -import android.os.Bundle -import android.os.Debug -import android.util.Base64 -import android.view.KeyEvent -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.view.inputmethod.EditorInfo -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import java.io.ByteArrayOutputStream -import java.io.File -import org.connectbot.terminal.TerminalEmulator -import org.connectbot.terminal.TerminalEmulatorFactory -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.FixMethodOrder -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.MethodSorters - -/** Medições no emulador real: não substitui a validação dos teclados/aparelhos físicos. */ -@RunWith(AndroidJUnit4::class) -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -class TerminalSpikeTest { - @get:Rule val tela = createAndroidComposeRule() - private val instr get() = InstrumentationRegistry.getInstrumentation() - private val cores = setOf(0xfa1020, 0x10ef20, 0x2030fa, 0xfae010) - - private fun salvar(nome: String, json: JSONObject) { - val pasta = File(instr.targetContext.getExternalFilesDir(null), "computador-spike").apply { mkdirs() } - File(pasta, "$nome.json").writeText(json.toString(2)) - instr.sendStatus(0, Bundle().apply { putString("stream", "CMP_SPIKE $nome ${json}\n") }) - } - - private fun bytesGravados(nome: String) = instr.context.assets.open("computador/gravacoes/$nome").use { it.readBytes() } - - private fun eventos(): List { - instr.waitForIdleSync() - return mutableListOf().also { tela.activity.saidas.drainTo(it) } - } - - private fun sincronizarEntrada() { - // Com mouse desligado, esta chamada esvazia a fila do terminal sem emitir bytes. - tela.runOnIdle { tela.activity.emulador.dispatchMouseMove(0, 0) } - instr.waitForIdleSync() - } - - private fun texto(eventos: List): String { - val bytes = ByteArrayOutputStream() - eventos.forEach { bytes.write(it.bytes) } - return bytes.toByteArray().toString(Charsets.UTF_8) - } - - private fun medirImagem(nome: String) { - assertEquals(0, tela.activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) - var imagem: Bitmap? = null - var contagens = emptyMap() - tela.runOnIdle { - // O cursor sólido cobriria os pixels da pequena imagem real do kitten. - tela.activity.alimentar("\u001b[?25l".toByteArray()) - tela.activity.alimentar(bytesGravados(nome)) - } - try { - tela.waitUntil(15_000) { - imagem?.recycle() - imagem = instr.uiAutomation.takeScreenshot() - val bitmap = imagem ?: return@waitUntil false - val altura = minOf(bitmap.height, 1000) - val pixels = IntArray(bitmap.width * altura) - bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, altura) - val contados = cores.associateWith { 0 }.toMutableMap() - pixels.forEach { cor -> val rgb = cor and 0xffffff; if (rgb in cores) contados[rgb] = contados.getValue(rgb) + 1 } - contagens = contados - contagens.values.all { it >= 4 } - } - val pasta = File(instr.targetContext.getExternalFilesDir(null), "computador-spike").apply { mkdirs() } - val png = ByteArrayOutputStream().also { imagem!!.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() - File(pasta, "$nome.png").writeBytes(png) - // O pool devolve o protocolo do instrumentador mesmo quando o worker é descartável. - instr.sendStatus(0, Bundle().apply { putString("cmp_spike_png", nome + " " + Base64.encodeToString(png, Base64.NO_WRAP)) }) - salvar(nome, JSONObject().put("pixels", JSONObject(contagens.mapKeys { "%06x".format(it.key) })).put("flag_secure", false)) - } catch (erro: Throwable) { - imagem?.let { bitmap -> - val png = ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() - instr.sendStatus(0, Bundle().apply { putString("cmp_spike_png", "falha-" + nome + " " + Base64.encodeToString(png, Base64.NO_WRAP)) }) - } - throw AssertionError("$nome: contagem dos quadrantes $contagens", erro) - } finally { imagem?.recycle() } - } - - @Test fun a0TextoUtf8ECoresAnsiPassamPeloEmuladorReal() { - medirImagem("texto-cores.bin") - tela.waitUntil(5_000) { tela.activity.emulador.getLastCommandOutput() == "Olá, terminal!" } - salvar("texto", JSONObject().put("saida", tela.activity.emulador.getLastCommandOutput()).put("cores_ansi", true)) - } - - @Test fun aKittyRealDesenhaOsQuatroQuadrantes() = medirImagem("kitty.bin") - @Test fun bIterm2RealDesenhaOsQuatroQuadrantes() = medirImagem("iterm2.bin") - @Test fun cPlaceholdersReaisDoKittenDesenhamAImagem() = medirImagem("placeholders-kitty.bin") - @Test fun dHerdrRealDesenhaAImagemDoPane() = medirImagem("herdr.bin") - - @Test fun eRespostasRegistramBytesEOrigemAssincrona() { - val medidas = JSONObject() - fun consultar(nome: String, entrada: String): List { - eventos() - tela.runOnIdle { tela.activity.alimentar(entrada.toByteArray()) } - val saida = eventos() - medidas.put(nome, JSONArray().apply { - saida.forEach { put(JSONObject().put("hex", it.bytes.joinToString("") { b -> "%02x".format(b) }).put("dentro_write", it.dentroDoWriteInput)) } - }) - return saida - } - val da1 = consultar("DA1", "\u001b[c") - assertTrue(texto(da1).contains("?1;2c")) - assertTrue(da1.all { !it.dentroDoWriteInput }) - assertTrue(texto(consultar("DSR", "\u001b[5n")).contains("0n")) - assertTrue(texto(consultar("CPR", "\u001b[6n")).contains("R")) - consultar("cor_10", "\u001b]10;?\u0007") - consultar("cor_11", "\u001b]11;?\u0007") - consultar("titulo", "\u001b]0;Título de teste\u0007\u001b[21t") - assertTrue(texto(consultar("kitty_query", "\u001b_Ga=q,t=d,f=24,s=1,v=1,i=50;AAAA\u001b\\")).contains("OK")) - for (meio in listOf("f", "t", "s")) { - val resposta = texto(consultar("kitty_t_$meio", "\u001b_Ga=q,t=$meio,f=24,s=1,v=1,i=51;L25hby1leGlzdGU=\u001b\\")) - assertTrue("O meio $meio precisa ser recusado", resposta.isNotEmpty()) - assertFalse(resposta.contains(";OK")) - } - salvar("respostas", medidas) - } - - private fun campo(view: View): View? { - if (view.javaClass.simpleName == "ImeInputView") return view - if (view is ViewGroup) repeat(view.childCount) { campo(view.getChildAt(it))?.let { achado -> return achado } } - return null - } - - @Test fun fInputConnectionMedeUtf8SemEnterEReproduzCaminhoDuplo() { - val info = EditorInfo() - eventos() - tela.runOnIdle { - val view = checkNotNull(campo(tela.activity.window.decorView)) - view.requestFocus() - val conexao = view.onCreateInputConnection(info) - assertNotNull(conexao) - assertTrue(conexao!!.commitText("ação e voz", 1)) - } - sincronizarEntrada() - val ditado = texto(eventos()) - assertEquals("ação e voz", ditado) - assertFalse(ditado.contains('\r') || ditado.contains('\n')) - tela.runOnIdle { - val view = checkNotNull(campo(tela.activity.window.decorView)) - val conexao = checkNotNull(view.onCreateInputConnection(EditorInfo())) - val baixo = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A) - val alto = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_A) - conexao.sendKeyEvent(baixo) - view.dispatchKeyEvent(baixo) - conexao.sendKeyEvent(alto) - view.dispatchKeyEvent(alto) - } - sincronizarEntrada() - val duplo = texto(eventos()) - // Diagnóstico do upstream: os dois caminhos juntos produzem duplicação. - // A cola da CMP-T13 deverá escolher/deduplicar a origem antes do SSH. - assertEquals("aa", duplo) - tela.runOnIdle { - val view = checkNotNull(campo(tela.activity.window.decorView)) - val conexao = checkNotNull(view.onCreateInputConnection(EditorInfo())) - conexao.setComposingText("a", 1) - conexao.setComposingText("ação", 1) - conexao.finishComposingText() - } - sincronizarEntrada() - val composicao = texto(eventos()) - salvar("entrada", JSONObject().put("input_type", info.inputType).put("commit", ditado).put("caminho_duplo", duplo).put("composicao_tipo_null", composicao) - .put("keyflow_gboard_orbe_reais", "não medido; exercitado o InputConnection real do terminal")) - } - - @Test fun gMouseUsaOModoNegociadoEProduzSgrCorreto() { - eventos() - tela.runOnIdle { tela.activity.alimentar("\u001b[?1000h\u001b[?1006h".toByteArray()) } - tela.waitUntil(5_000) { tela.activity.emulador.mouseMode.value == 1 } - tela.runOnIdle { - tela.activity.emulador.dispatchMouseMove(2, 4) - tela.activity.emulador.dispatchMouseButton(1, true) - tela.activity.emulador.dispatchMouseButton(1, false) - } - val cliques = texto(eventos()) - assertTrue(cliques.contains("\u001b[<0;5;3M")) - assertTrue(cliques.contains("\u001b[<0;5;3m")) - tela.runOnIdle { tela.activity.alimentar("\u001b[?1000l".toByteArray()) } - tela.waitUntil(5_000) { tela.activity.emulador.mouseMode.value == 0 } - tela.runOnIdle { tela.activity.emulador.dispatchMouseButton(1, true) } - assertEquals("", texto(eventos())) - salvar("mouse", JSONObject().put("sgr", cliques).put("modo_desligado_silencioso", true)) - } - - @Test fun hSixelBrutoPermaneceSemTranscodificadorNoSpike() { - tela.runOnIdle { tela.activity.alimentar(bytesGravados("img2sixel.bin")) } - salvar("sixel", JSONObject().put("transcodificador_no_app", "não medido; implementação na CMP-T25/T26").put("fluxo_bruto_sem_crash", true)) - } - - @Test fun zMemoriaEHistoricoMedidosEmCincoEVinteMilLinhas() { - val medidas = JSONArray() - for (limite in listOf(5_000, 20_000)) { - System.gc() - val antes = Debug.MemoryInfo().also { Debug.getMemoryInfo(it) } - val heapAntes = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() - val emulador = TerminalEmulatorFactory.create(initialRows = 24, initialCols = 80, maxScrollbackLines = limite) - try { - fun carregar() { - val bytes = buildString { - repeat(limite + 24) { append("linha ").append(it.toString().padStart(5, '0')).append(' ').append("x".repeat(60)).append("\r\n") } - }.toByteArray() - emulador.writeInput(bytes) - } - carregar() - assertEquals(limite, emulador.retainedScrollbackLines) - instr.waitForIdleSync() - System.gc() - val depois = Debug.MemoryInfo().also { Debug.getMemoryInfo(it) } - val heapDepois = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() - medidas.put(JSONObject().put("linhas", limite).put("retidas", emulador.retainedScrollbackLines).put("colunas", 80).put("rows", 24) - .put("pss_antes_kb", antes.totalPss).put("pss_depois_kb", depois.totalPss).put("delta_pss_kb", depois.totalPss - antes.totalPss) - .put("delta_heap_bytes", heapDepois - heapAntes).put("heap_max_bytes", Runtime.getRuntime().maxMemory())) - } finally { emulador.close() } - } - salvar("memoria", JSONObject().put("sessoes", medidas).put("metodo", "PSS do processo e heap após GC; uma sessão por medição, sem imagens")) - } -} diff --git a/app/src/androidTest/java/com/keyflow/ui/config/ComputadorHostsTest.kt b/app/src/androidTest/java/com/keyflow/ui/config/ComputadorHostsTest.kt new file mode 100644 index 00000000..c6b765aa --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/ui/config/ComputadorHostsTest.kt @@ -0,0 +1,232 @@ +package com.keyflow.ui.config + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.EdicaoDeHost +import com.keyflow.computador.ErroHost +import com.keyflow.computador.Host +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.PoliticaAgente +import com.keyflow.computador.ResultadoEdicao +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ResultadoTeste +import com.keyflow.ui.theme.KeyFlowTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-05, 07, 08, 09, 59, 61: o painel de hosts com a regra de gravação de verdade e um teste de conexão de mentira. */ +@RunWith(AndroidJUnit4::class) +class ComputadorHostsTest { + @get:Rule val regra = createComposeRule() + + private class Acoes(inicial: List = emptyList()) : AcoesDeHost { + var hosts by mutableStateOf(ConfigHosts(inicial)) + val excluidos = mutableListOf() + val agenteDesligadoEm = mutableListOf() + var resultadoDoTeste: ResultadoTeste = ResultadoTeste.Falhou(CausaSsh.RECUSADA) + val testados = mutableListOf>() + val senhas = inicial.filter { it.autenticacao == AutenticacaoHost.Senha && it.lembrarSenha }.associate { it.id to "senha-falsa" }.toMutableMap() + + override suspend fun salvar(host: Host, senha: String?): List = when (val r = EdicaoDeHost.aplicar(hosts, host, credencialDisponivel = !senha.isNullOrEmpty() || !senhas[host.id].isNullOrEmpty())) { + is ResultadoEdicao.Erros -> r.erros + is ResultadoEdicao.Ok -> { hosts = r.config; senha?.let { senhas[r.host.id] = it }; emptyList() } + } + override suspend fun temSenhaGuardada(host: Host) = !senhas[host.id].isNullOrEmpty() + override suspend fun excluir(host: Host) { excluidos += host.id; hosts = ConfigHosts(hosts.hosts.filter { it.id != host.id }) } + override suspend fun testar(host: Host, senha: String?): ResultadoTeste { testados += host to senha; return resultadoDoTeste } + override fun impressoesConhecidas(host: Host): List = emptyList() + override fun esquecerServidor(host: Host) = Unit + override suspend fun desligarAgente(host: Host) { agenteDesligadoEm += host.id } + override fun novoId(existentes: Collection) = "%08x".format(existentes.size + 100) + } + + private fun mostrar(acoes: Acoes, dark: Boolean = false) { + regra.setContent { KeyFlowTheme(dark = dark) { ComputadorPanel(hosts = acoes.hosts, chaves = emptyList(), sessoes = emptyList(), acoes = acoes) } } + } + + private fun nt(tag: String) = regra.onNodeWithTag(tag, useUnmergedTree = true) + private fun nh(texto: String, substring: Boolean = false) = regra.onNode(hasText(texto, substring = substring), useUnmergedTree = true) + + private fun digitar(tag: String, texto: String) { + nt(tag).performScrollTo().performTextReplacement(texto) + } + + private fun preencher(nome: String, endereco: String = "dev.example.org", usuario: String = "yan", porta: String? = null) { + digitar(COMPUTADOR_HOST_NOME_TAG, nome) + digitar(COMPUTADOR_HOST_ENDERECO_TAG, endereco) + digitar(COMPUTADOR_HOST_USUARIO_TAG, usuario) + porta?.let { digitar(COMPUTADOR_HOST_PORTA_TAG, it) } + } + + private fun clicar(tag: String) { nt(tag).performScrollTo().performClick() } + + private fun senhaLembrada(id: String = "00000001") = Host(id, "dev", "dev.example.org", 22, "yan", AutenticacaoHost.Senha, lembrarSenha = true) + + @Test fun tresSecoesEHostsVazioMostraOConvite() { + mostrar(Acoes()) + nt(COMPUTADOR_SECAO_HOSTS_TAG).assertIsDisplayed() + nt(COMPUTADOR_SECAO_CHAVES_TAG).assertIsDisplayed() + nt(COMPUTADOR_SECAO_SESSOES_TAG).assertIsDisplayed() + nt(COMPUTADOR_HOSTS_VAZIO_TAG).assertIsDisplayed() + } + + @Test fun criarHostValidoGrava() { + val a = Acoes() + mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG) + preencher("dev-server") + clicar(COMPUTADOR_HOST_SALVAR_TAG) + regra.waitForIdle() + val h = a.hosts.hosts.single() + assertEquals("dev-server", h.nome) + assertEquals("dev.example.org", h.endereco) + assertEquals(22, h.porta) + assertEquals(PoliticaAgente.PROIBIDO, h.agente) + nt(computadorHostCardTag(h.id)).assertIsDisplayed() + } + + @Test fun porta0ENomeRepetidoMostramOMotivo() { + val a = Acoes(listOf(senhaLembrada())) + mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG) + preencher("DEV", porta = "0") + clicar(COMPUTADOR_HOST_SALVAR_TAG) + nt("computador-erro-porta").performScrollTo().assertIsDisplayed() + nh("A porta vai de 1 a 65535.").assertIsDisplayed() + nh("Já existe um servidor com esse nome.").assertIsDisplayed() + assertEquals(1, a.hosts.hosts.size) + } + + @Test fun testarConexaoMostraImpressaoELatenciaEUsaASenhaDigitada() { + val a = Acoes() + a.resultadoDoTeste = ResultadoTeste.Ok("ssh-ed25519", ImpressaoDigital.de(ByteArray(8) { 3 }), "password", 42) + mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG) + preencher("dev") + clicar(COMPUTADOR_HOST_AUTH_SENHA_TAG) + digitar(COMPUTADOR_HOST_SENHA_TAG, "senha-falsa") + clicar(COMPUTADOR_HOST_TESTAR_TAG) + regra.waitUntil(5_000) { a.testados.isNotEmpty() } + nt(COMPUTADOR_HOST_TESTE_RESULTADO_TAG).performScrollTo() + nh("SHA256:", substring = true).assertIsDisplayed() + nh("42 ms", substring = true).assertIsDisplayed() + assertEquals("senha-falsa", a.testados.single().second) + assertTrue("o teste não grava o host", a.hosts.hosts.isEmpty()) + } + + @Test fun testarContraPortaFechadaDizOMotivoENuncaAFraseAntiga() { + val a = Acoes() + mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG) + preencher("dev") + clicar(COMPUTADOR_HOST_TESTAR_TAG) + regra.waitUntil(5_000) { a.testados.isNotEmpty() } + nt(COMPUTADOR_HOST_TESTE_RESULTADO_TAG).performScrollTo() + nh("O servidor recusou a conexão.").assertIsDisplayed() + nh("Configuração SSH válida", substring = true).assertDoesNotExist() + } + + @Test fun marcarAgenteMostraOAvisoESoLigaAoConfirmar() { + val a = Acoes(listOf(senhaLembrada())) + mostrar(a) + clicar(computadorHostCardTag("00000001")) + clicar(COMPUTADOR_HOST_AGENTE_TAG) + nt(COMPUTADOR_AGENTE_AVISO_TAG).assertIsDisplayed() + nh("O agente poderá abrir terminais neste servidor e ler o texto da tela; esse texto é enviado ao provedor do modelo.").assertIsDisplayed() + nt(COMPUTADOR_AGENTE_AVISO_CANCELAR_TAG).performClick() + clicar(COMPUTADOR_HOST_SALVAR_TAG); regra.waitForIdle() + assertEquals("cancelar não liga", PoliticaAgente.PROIBIDO, a.hosts.hosts.single().agente) + clicar(computadorHostCardTag("00000001")) + clicar(COMPUTADOR_HOST_AGENTE_TAG) + nt(COMPUTADOR_AGENTE_AVISO_CONFIRMAR_TAG).performClick() + clicar(COMPUTADOR_HOST_SALVAR_TAG); regra.waitForIdle() + assertEquals(PoliticaAgente.PERMITIDO, a.hosts.hosts.single().agente) + } + + @Test fun hostComAutenticacaoPerguntarNaoDeixaMarcarEDizOMotivo() { + val a = Acoes(listOf(Host("00000001", "dev", "dev.example.org", 22, "yan", AutenticacaoHost.Perguntar))) + mostrar(a) + clicar(computadorHostCardTag("00000001")) + nt(COMPUTADOR_HOST_AGENTE_MOTIVO_TAG).performScrollTo().assertIsDisplayed() + clicar(COMPUTADOR_HOST_AGENTE_TAG) + nt(COMPUTADOR_AGENTE_AVISO_TAG).assertDoesNotExist() + assertEquals(PoliticaAgente.PROIBIDO, a.hosts.hosts.single().agente) + } + + @Test fun desligarAMarcaFechaAsSessoesDoAgente() { + val a = Acoes(listOf(senhaLembrada().copy(agente = PoliticaAgente.PERMITIDO))) + mostrar(a) + clicar(computadorHostCardTag("00000001")) + clicar(COMPUTADOR_HOST_AGENTE_TAG) + clicar(COMPUTADOR_HOST_SALVAR_TAG); regra.waitForIdle() + assertEquals(PoliticaAgente.PROIBIDO, a.hosts.hosts.single().agente) + assertEquals(listOf("00000001"), a.agenteDesligadoEm) + } + + @Test fun excluirPedeConfirmacao() { + val a = Acoes(listOf(senhaLembrada())) + mostrar(a, dark = true) + clicar(computadorHostCardTag("00000001")) + clicar(COMPUTADOR_HOST_EXCLUIR_TAG) + assertTrue("só pedir não exclui", a.excluidos.isEmpty()) + nt(COMPUTADOR_HOST_EXCLUIR_CONFIRMAR_TAG).performClick() + regra.waitForIdle() + assertEquals(listOf("00000001"), a.excluidos) + assertFalse(a.hosts.hosts.any()) + } + @Test fun semChavesNaoCriaChaveComReferenciaVazia() { + val a = Acoes(); mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG); preencher("dev") + nt(COMPUTADOR_HOST_AUTH_CHAVE_TAG).performScrollTo().assertIsNotEnabled() + clicar(COMPUTADOR_HOST_SALVAR_TAG); regra.waitForIdle() + assertEquals(AutenticacaoHost.Perguntar, a.hosts.hosts.single().autenticacao) + } + + @Test fun lembrarSemSenhaNaoLiberaAgenteNemGrava() { + val a = Acoes(); mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG); preencher("dev") + clicar(COMPUTADOR_HOST_AUTH_SENHA_TAG); clicar(COMPUTADOR_HOST_LEMBRAR_TAG) + nt(COMPUTADOR_HOST_AGENTE_TAG).performScrollTo().assertIsNotEnabled() + clicar(COMPUTADOR_HOST_SALVAR_TAG) + nt(computadorErroTag(com.keyflow.computador.CampoHost.AUTENTICACAO)).performScrollTo().assertIsDisplayed() + assertTrue(a.hosts.hosts.isEmpty()); assertTrue(a.senhas.isEmpty()) + } + + @Test fun preferenciaAntigaSemSegredoNaoPermiteMarcar() { + val a = Acoes(listOf(senhaLembrada())); a.senhas.clear(); mostrar(a) + clicar(computadorHostCardTag("00000001")) + nt(COMPUTADOR_HOST_AGENTE_TAG).performScrollTo().assertIsNotEnabled() + clicar(COMPUTADOR_HOST_SALVAR_TAG) + nt(computadorErroTag(com.keyflow.computador.CampoHost.AUTENTICACAO)).performScrollTo().assertIsDisplayed() + assertFalse(a.hosts.hosts.single().agentePodeUsar) + } + + @Test fun senhaDigitadaPermiteConfirmarEGravarAgente() { + val a = Acoes(); mostrar(a) + clicar(COMPUTADOR_NOVO_HOST_TAG); preencher("dev") + clicar(COMPUTADOR_HOST_AUTH_SENHA_TAG); digitar(COMPUTADOR_HOST_SENHA_TAG, "senha-falsa") + clicar(COMPUTADOR_HOST_LEMBRAR_TAG); clicar(COMPUTADOR_HOST_AGENTE_TAG) + nt(COMPUTADOR_AGENTE_AVISO_CONFIRMAR_TAG).performClick() + clicar(COMPUTADOR_HOST_SALVAR_TAG); regra.waitForIdle() + val h = a.hosts.hosts.single() + assertTrue(h.agentePodeUsar); assertEquals("senha-falsa", a.senhas[h.id]) + } +} diff --git a/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalActivityTest.kt b/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalActivityTest.kt new file mode 100644 index 00000000..a3456ed6 --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalActivityTest.kt @@ -0,0 +1,110 @@ +package com.keyflow.ui.terminal + +import android.content.Context +import android.content.pm.ActivityInfo +import android.view.WindowManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.Host +import com.keyflow.computador.app.ComputadorApp +import com.keyflow.computador.app.DependenciasFalsas +import com.keyflow.computador.app.TerminalActivity +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.EstadoSessao +import com.keyflow.computador.sessao.ResultadoAbertura +import com.keyflow.computador.sessao.SessaoResumo +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.ui.theme.KeyFlowTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-24, 29, 30: a `TerminalActivity` com o emulador real e conexão falsa (sessões do [ComputadorApp] de teste). */ +@RunWith(AndroidJUnit4::class) +class TerminalActivityTest { + private val instr = InstrumentationRegistry.getInstrumentation() + private val ctx: Context get() = instr.targetContext + private val host = Host(id = "h1", nome = "dev-server", endereco = "dev.example.org", usuario = "yan", autenticacao = AutenticacaoHost.Senha) + + private fun resumo(id: String, dono: DonoDaSessao = DonoDaSessao.USUARIO, estado: EstadoSessao = EstadoSessao.Aberta) = SessaoResumo(id, "h1", dono, false, estado) + + private fun esperar(ms: Long = 10_000, condicao: () -> Boolean) { + val fim = System.currentTimeMillis() + ms + while (!condicao()) { + if (System.currentTimeMillis() > fim) throw AssertionError("tempo esgotado") + instr.waitForIdleSync(); Thread.sleep(25) + } + } + + @Test fun activityNaoPedeFlagSecureEVoltarNaoFechaAsSessoes() { + val deps = DependenciasFalsas() + val app = ComputadorApp.paraTeste(ctx, deps) + val aberta = app.abrirSessao(host, DonoDaSessao.USUARIO, androidx.compose.ui.graphics.Color.White, androidx.compose.ui.graphics.Color.Black) as ResultadoAbertura.Aberta + esperar { app.gerenciador.resumos.value.any { it.estado == EstadoSessao.Aberta } } + ActivityScenario.launch(TerminalActivity.intent(ctx, sessaoId = aberta.sessao.id).addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)).use { cenario -> + cenario.onActivity { a -> assertEquals("D17: sem FLAG_SECURE", 0, a.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) } + cenario.onActivity { a -> a.onBackPressedDispatcher.onBackPressed() } + instr.waitForIdleSync() + } + // Voltar saiu da tela; a sessão e o emulador continuam. + assertTrue(app.gerenciador.resumos.value.any { it.id == aberta.sessao.id && it.estado == EstadoSessao.Aberta }) + assertTrue(app.emuladores.de(aberta.sessao.id) != null) + } + + @Test fun fecharAAbaFechaASessaoENoveSessoesMostramOMotivo() { + val deps = DependenciasFalsas() + val app = ComputadorApp.paraTeste(ctx, deps) + val cor = androidx.compose.ui.graphics.Color.White + val sessoes = (1..8).map { (app.abrirSessao(host, DonoDaSessao.USUARIO, cor, cor) as ResultadoAbertura.Aberta).sessao } + val nona = app.abrirSessao(host, DonoDaSessao.USUARIO, cor, cor) + assertTrue(nona is ResultadoAbertura.Recusada) + kotlinx.coroutines.runBlocking { app.gerenciador.fechar(sessoes.first().id) } + assertTrue(app.gerenciador.resumos.value.none { it.id == sessoes.first().id }) + esperar { app.emuladores.de(sessoes.first().id) == null } + kotlinx.coroutines.runBlocking { app.gerenciador.fecharTodas() } + } + + @Test fun girarATelaEnviaWindowChangeComOTamanhoNovo() { + val deps = DependenciasFalsas() + val app = ComputadorApp.paraTeste(ctx, deps) + val cor = androidx.compose.ui.graphics.Color.White + val aberta = app.abrirSessao(host, DonoDaSessao.USUARIO, cor, cor) as ResultadoAbertura.Aberta + esperar { deps.conexoes.firstOrNull()?.ptyInicial != null } + ActivityScenario.launch(TerminalActivity.intent(ctx, sessaoId = aberta.sessao.id).addFlags(android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)).use { cenario -> + cenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } + esperar { deps.conexoes.first().tamanhos.isNotEmpty() } + val retrato = deps.conexoes.first().tamanhos.last() + deps.conexoes.first().tamanhos.clear() + cenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } + esperar { deps.conexoes.first().tamanhos.isNotEmpty() } + val paisagem = deps.conexoes.first().tamanhos.last() + assertTrue("colunas ${retrato.colunas} -> ${paisagem.colunas}", paisagem.colunas > retrato.colunas) + assertNotEquals(retrato.linhas, paisagem.linhas) + assertTrue("pixels vão junto", paisagem.largura > 0 && paisagem.altura > 0) + cenario.onActivity { it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + } + kotlinx.coroutines.runBlocking { app.gerenciador.fecharTodas() } + } + + @Test fun altScreenEscolhaDeTextoDoServidorChegaAoEmulador() { + val deps = DependenciasFalsas() + val app = ComputadorApp.paraTeste(ctx, deps) + val cor = androidx.compose.ui.graphics.Color.White + val aberta = app.abrirSessao(host, DonoDaSessao.USUARIO, cor, cor) as ResultadoAbertura.Aberta + esperar { deps.conexoes.firstOrNull()?.ptyInicial != null } + deps.conexoes.first().servidor("\u001b[?1049h\u001b[2J\u001b[Hhttps://tela.alternativa.test/ok\r\n".toByteArray()) + val emulador = app.emuladores.de(aberta.sessao.id)!!.emulador + esperar { emulador.getUrls().any { it.url == "https://tela.alternativa.test/ok" } } + kotlinx.coroutines.runBlocking { app.gerenciador.fecharTodas() } + } +} diff --git a/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalScreenTest.kt b/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalScreenTest.kt new file mode 100644 index 00000000..2c289659 --- /dev/null +++ b/app/src/androidTest/java/com/keyflow/ui/terminal/TerminalScreenTest.kt @@ -0,0 +1,98 @@ +package com.keyflow.ui.terminal + +import android.content.Context +import android.content.pm.ActivityInfo +import android.view.WindowManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.Host +import com.keyflow.computador.app.ComputadorApp +import com.keyflow.computador.app.DependenciasFalsas +import com.keyflow.computador.app.TerminalActivity +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.EstadoSessao +import com.keyflow.computador.sessao.ResultadoAbertura +import com.keyflow.computador.sessao.SessaoResumo +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.ui.theme.KeyFlowTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** CMP-24, 25, 28, 29, 30, 31: a tela com o emulador real (a ligação vem do [ComputadorApp] com conexão falsa). */ +@RunWith(AndroidJUnit4::class) +class TerminalScreenTest { + @get:Rule val regra = createComposeRule() + private val instr = InstrumentationRegistry.getInstrumentation() + private val ctx: Context get() = instr.targetContext + private val host = Host(id = "h1", nome = "dev-server", endereco = "dev.example.org", usuario = "yan", autenticacao = AutenticacaoHost.Senha) + + private fun resumo(id: String, dono: DonoDaSessao = DonoDaSessao.USUARIO, estado: EstadoSessao = EstadoSessao.Aberta) = SessaoResumo(id, "h1", dono, false, estado) + + private fun esperar(ms: Long = 10_000, condicao: () -> Boolean) { + val fim = System.currentTimeMillis() + ms + while (!condicao()) { + if (System.currentTimeMillis() > fim) throw AssertionError("tempo esgotado") + instr.waitForIdleSync(); Thread.sleep(25) + } + } + + @Test fun abasTemRotulosAlvosGrandesEFecharChamaOCallback() { + val fechadas = mutableListOf() + regra.setContent { + KeyFlowTheme(dark = false) { + TerminalScreen( + sessoes = listOf(resumo("a"), resumo("b", DonoDaSessao.AGENTE)), hosts = listOf(host), ativaId = "a", + ligacaoDe = { null }, aviso = null, onSelecionar = {}, onFecharAba = { fechadas += it }, onNovaSessao = {}, onAbrirLink = {}, onCopiar = {}, lerAreaDeTransferencia = { null }, + ) + } + } + regra.onNodeWithTag(abaTag("a")).assertIsDisplayed() + regra.onNode(hasContentDescription("Terminal 1")).assertIsDisplayed() + regra.onNode(hasContentDescription("Agente 2 ●")).assertIsDisplayed() + val alvo = regra.onNodeWithTag(abaFecharTag("a")).fetchSemanticsNode().size + val dp = ctx.resources.displayMetrics.density + assertTrue("alvo de fechar >= 48dp", alvo.width >= 48 * dp - 1 && alvo.height >= 48 * dp - 1) + val nova = regra.onNodeWithTag(TERMINAL_NOVA_ABA_TAG).fetchSemanticsNode().size + assertTrue("alvo de + >= 48dp", nova.width >= 48 * dp - 1 && nova.height >= 48 * dp - 1) + regra.onNodeWithTag(abaFecharTag("b")).performClick() + assertEquals(listOf("b"), fechadas) + regra.onNodeWithTag(TERMINAL_VAZIO_TAG).assertIsDisplayed() + } + + @Test fun estadosTemSimboloEPalavraNaoSoCor() { + val estados = listOf( + EstadoSessao.Conectando to "Conectando…", EstadoSessao.Aberta to "Conectado", EstadoSessao.Reconectando(3) to "Reconectando… (tentativa 3)", + EstadoSessao.Falhou(CausaSsh.RECUSADA) to "Caiu: O servidor recusou a conexão.", EstadoSessao.PrecisaDoUsuario to "Toque para reconectar", + ) + for ((estado, palavra) in estados) { + val texto = textoDoEstado(estado) + assertTrue("$texto deve conter $palavra", texto.contains(palavra)) + assertTrue("$texto deve começar por um símbolo", texto.first() in "⟳●✕⚠○") + } + } + + @Test fun avisoDaNonaSessaoApareceNaTela() { + regra.setContent { + KeyFlowTheme(dark = true) { + TerminalScreen( + sessoes = emptyList(), hosts = listOf(host), ativaId = null, ligacaoDe = { null }, aviso = "O limite é de 8 sessões abertas. Feche uma para abrir outra.", + onSelecionar = {}, onFecharAba = {}, onNovaSessao = {}, onAbrirLink = {}, onCopiar = {}, lerAreaDeTransferencia = { null }, + ) + } + } + regra.onNodeWithTag(TERMINAL_AVISO_TAG).assertIsDisplayed() + } + +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index dc16837c..33162092 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -2,8 +2,6 @@ - - diff --git a/app/src/debug/java/com/keyflow/computador/app/TerminalSpikeActivity.kt b/app/src/debug/java/com/keyflow/computador/app/TerminalSpikeActivity.kt deleted file mode 100644 index 0725652a..00000000 --- a/app/src/debug/java/com/keyflow/computador/app/TerminalSpikeActivity.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.keyflow.computador.app - -import android.os.Bundle -import android.util.Base64 -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.sp -import androidx.lifecycle.lifecycleScope -import java.security.MessageDigest -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.connectbot.sshlib.AuthResult -import org.connectbot.sshlib.ConnectResult -import org.connectbot.sshlib.HostKeyVerifier -import org.connectbot.sshlib.PublicKey -import org.connectbot.sshlib.SshClient -import org.connectbot.terminal.InlineImageLimits -import org.connectbot.terminal.InlineImages -import org.connectbot.terminal.Terminal -import org.connectbot.terminal.TerminalEmulator -import org.connectbot.terminal.TerminalEmulatorFactory - -/** Bancada descartável: somente debug, 80×24 e SSH de loopback com credencial falsa. */ -class TerminalSpikeActivity : ComponentActivity() { - data class Saida(val bytes: ByteArray, val dentroDoWriteInput: Boolean) - - lateinit var emulador: TerminalEmulator - private set - val saidas = LinkedBlockingQueue() - val dentroDoWriteInput = AtomicBoolean(false) - @Volatile var erroDeConexao: Throwable? = null - private set - @Volatile var sshAberto = false - private set - private val enviados = Channel(Channel.UNLIMITED) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - emulador = TerminalEmulatorFactory.create( - initialRows = 24, - initialCols = 80, - maxScrollbackLines = 5_000, - inlineImages = InlineImages.On(InlineImageLimits(decodedBytes = 16 * 1024 * 1024, encodedBytes = 8 * 1024 * 1024)), - onKeyboardInput = { - saidas.add(Saida(it.copyOf(), dentroDoWriteInput.get())) - enviados.trySend(it.copyOf()) - }, - ) - setContent { - MaterialTheme { - Terminal( - terminalEmulator = emulador, - modifier = Modifier.fillMaxSize(), - forcedSize = 24 to 80, - initialFontSize = 6.sp, - minFontSize = 6.sp, - keyboardEnabled = true, - showSoftKeyboard = intent.getBooleanExtra("mostrar_ime", false), - ) - } - } - val porta = intent.getIntExtra("ssh_port", 0) - if (porta > 0) conectarFixture(porta) - } - - fun alimentar(bytes: ByteArray) { - dentroDoWriteInput.set(true) - try { emulador.writeInput(bytes) } finally { dentroDoWriteInput.set(false) } - } - - private fun conectarFixture(porta: Int) { - require(porta in 1..65535) - val esperada = requireNotNull(intent.getStringExtra("host_sha256")) - val verificador = object : HostKeyVerifier { - override suspend fun verify(key: PublicKey): Boolean { - val hash = MessageDigest.getInstance("SHA-256").digest(key.encoded) - return "SHA256:" + Base64.encodeToString(hash, Base64.NO_WRAP or Base64.NO_PADDING) == esperada - } - } - lifecycleScope.launch(Dispatchers.IO) { - val cliente = SshClient("127.0.0.1", verificador, porta) - try { - check(cliente.connect() is ConnectResult.Success) - check(cliente.authenticatePassword("senha", SENHA_FALSA) is AuthResult.Success) - val canal = checkNotNull(cliente.openSession()) - check(canal.requestPty("xterm-kitty", 80, 24, 640, 384)) - check(canal.requestShell()) - sshAberto = true - val escritor = launch { for (bytes in enviados) canal.write(bytes) } - try { - for (bytes in canal.stdout) withContext(Dispatchers.Main.immediate) { alimentar(bytes) } - } finally { - escritor.cancel() - canal.close() - } - } catch (cancelado: CancellationException) { - throw cancelado - } catch (erro: Exception) { - erroDeConexao = erro - } finally { - sshAberto = false - withContext(NonCancellable) { cliente.disconnect() } - } - } - } - - override fun onDestroy() { - enviados.close() - emulador.close() - super.onDestroy() - } - - companion object { const val SENHA_FALSA = "senha-falsa-keyflow" } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a050c058..6f7f7952 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,17 @@ android:value="Keeps user-started interactive SSH terminal sessions connected while the app is in the background" /> + + + ) : ResultadoEdicao +} + +/** + * A regra de gravar um host vindo do modal, em função **pura** (roda dentro da transação do DataStore): normaliza (espaços das pontas), valida + * contra os outros hosts, aplica a [RegraAgente] (a marca do agente nunca fica ligada em host que o app não autentica sozinho) e troca ou + * acrescenta o host na lista. Não mexe em segredo. + */ +object EdicaoDeHost { + fun aplicar(config: ConfigHosts, editado: Host, credencialDisponivel: Boolean = false, chavePedePassphrase: (String) -> Boolean = { false }): ResultadoEdicao { + var host = editado.copy(nome = editado.nome.trim(), endereco = editado.endereco.trim(), usuario = editado.usuario.trim()) + if (host.autenticacao !is AutenticacaoHost.Senha) host = host.copy(lembrarSenha = false) + if (host.agente == PoliticaAgente.PERMITIDO && RegraAgente.podeMarcar(host, credencialDisponivel, chavePedePassphrase) !is PodeMarcar.Sim) host = host.copy(agente = PoliticaAgente.PROIBIDO) + val outros = config.hosts.filter { it.id != host.id } + val erros = ValidadorHost.validar(host, config.hosts, credencialDisponivel) + if (erros.isNotEmpty()) return ResultadoEdicao.Erros(erros) + val novos = if (config.hosts.any { it.id == host.id }) config.hosts.map { if (it.id == host.id) host else it } else outros + host + return ResultadoEdicao.Ok(ConfigHosts(novos), host) + } + + /** Id novo de 8 hex que não colide com os existentes. */ + fun novoId(existentes: Collection, sorteio: () -> String = { java.util.UUID.randomUUID().toString().replace("-", "").take(8) }): String { + repeat(32) { val id = sorteio(); if (id !in existentes) return id } + error("não achei um id livre") + } +} diff --git a/app/src/main/java/com/keyflow/computador/GravadorDeHosts.kt b/app/src/main/java/com/keyflow/computador/GravadorDeHosts.kt new file mode 100644 index 00000000..c3ff10d3 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/GravadorDeHosts.kt @@ -0,0 +1,65 @@ +package com.keyflow.computador + +import com.keyflow.computador.chaves.ChaveSsh +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Porta da gravação: metadados no DataStore, senhas no cofre; permite provar a ordem sem Android. */ +interface DadosDaEdicaoDeHost { + suspend fun hostsOnce(): ConfigHosts + suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts + suspend fun chavesOnce(): List + fun senhaLembrada(hostId: String): String? + fun lembrarSenha(hostId: String, senha: String) + fun esquecerSenha(hostId: String) +} + +/** CMP-09: a preferência não prova que existe segredo. Publica a permissão só depois de confirmar o cofre. */ +class GravadorDeHosts(private val dados: DadosDaEdicaoDeHost) { + private val trava = Mutex() + + suspend fun salvar(host: Host, senha: String?): ResultadoEdicao = trava.withLock { + val guardar = host.autenticacao == AutenticacaoHost.Senha && host.lembrarSenha + val antiga = if (guardar) dados.senhaLembrada(host.id)?.takeIf { it.isNotEmpty() } else null + val nova = senha?.takeIf { it.isNotEmpty() } + val disponivel = when (val a = host.autenticacao) { + AutenticacaoHost.Senha -> nova != null || antiga != null + is AutenticacaoHost.Chave -> a.keyId.isNotBlank() && dados.chavesOnce().any { it.id == a.keyId } + AutenticacaoHost.Perguntar -> false + } + val previa = EdicaoDeHost.aplicar(dados.hostsOnce(), host, credencialDisponivel = disponivel) + if (previa is ResultadoEdicao.Erros) return@withLock previa + + // Depois do primeiro efeito, fechar o modal não pode desfazer só o segredo de uma configuração já confirmada. + withContext(NonCancellable) { + fun restaurar() { runCatching { if (antiga == null) dados.esquecerSenha(host.id) else dados.lembrarSenha(host.id, antiga) } } + val mudouSenha = guardar && nova != null && nova != antiga + if (mudouSenha) { + try { + dados.lembrarSenha(host.id, nova) + check(dados.senhaLembrada(host.id) == nova) { "cofre não confirmou a senha" } + } catch (e: CancellationException) { restaurar(); throw e + } catch (_: Exception) { + restaurar() + return@withContext ResultadoEdicao.Erros(listOf(ErroHost(CampoHost.AUTENTICACAO, MotivoHost.SENHA_NAO_GUARDADA))) + } + } + var resultado: ResultadoEdicao = previa + try { + dados.atualizarHosts { config -> + // Revalida nome/limite contra a lista mais recente, já com a senha efetivamente gravada. + val temCredencial = if (guardar) !dados.senhaLembrada(host.id).isNullOrEmpty() else disponivel + val r = EdicaoDeHost.aplicar(config, host, credencialDisponivel = temCredencial) + resultado = r + if (r is ResultadoEdicao.Ok) r.config else config + } + } catch (e: Exception) { if (mudouSenha) restaurar(); throw e } + if (resultado is ResultadoEdicao.Erros) { if (mudouSenha) restaurar() } + else if (!guardar) dados.esquecerSenha(host.id) + resultado + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/RegraAgente.kt b/app/src/main/java/com/keyflow/computador/RegraAgente.kt index 88652681..ecff3862 100644 --- a/app/src/main/java/com/keyflow/computador/RegraAgente.kt +++ b/app/src/main/java/com/keyflow/computador/RegraAgente.kt @@ -9,18 +9,25 @@ sealed interface PodeMarcar { enum class MotivoAgente(val frase: String) { PERGUNTA_SEMPRE("Este servidor pede a autenticação na hora; o agente não tem como digitá-la. Guarde uma chave ou a senha."), SENHA_NAO_LEMBRADA("A senha não está guardada. Ligue \"lembrar senha\" para o agente poder usar este servidor."), + CHAVE_NAO_GUARDADA("Escolha uma chave guardada."), + SENHA_INDISPONIVEL("Informe uma senha para guardá-la neste aparelho."), CHAVE_COM_PASSPHRASE("Esta chave pede a senha dela na hora; o agente não pode usá-la."), } /** A marca "Pode ser usado pelo agente" só vale para host cuja autenticação o **app** resolve sozinho (CMP-09, D5). */ object RegraAgente { /** + * @param credencialDisponivel fato conferido no cofre/registro (ou senha digitada no rascunho); o padrão recusa. * @param chavePedePassphrase diz se a chave [AutenticacaoHost.Chave.keyId] precisa de passphrase digitada na hora. As chaves * do app (Keystore/envelope) nunca guardam passphrase, então o padrão é não. */ - fun podeMarcar(host: Host, chavePedePassphrase: (String) -> Boolean = { false }): PodeMarcar = when (val a = host.autenticacao) { + fun podeMarcar(host: Host, credencialDisponivel: Boolean = false, chavePedePassphrase: (String) -> Boolean = { false }): PodeMarcar = when (val a = host.autenticacao) { AutenticacaoHost.Perguntar -> PodeMarcar.Nao(MotivoAgente.PERGUNTA_SEMPRE) - AutenticacaoHost.Senha -> if (host.lembrarSenha) PodeMarcar.Sim else PodeMarcar.Nao(MotivoAgente.SENHA_NAO_LEMBRADA) - is AutenticacaoHost.Chave -> if (chavePedePassphrase(a.keyId)) PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE) else PodeMarcar.Sim + AutenticacaoHost.Senha -> when { + !host.lembrarSenha -> PodeMarcar.Nao(MotivoAgente.SENHA_NAO_LEMBRADA) + !credencialDisponivel -> PodeMarcar.Nao(MotivoAgente.SENHA_INDISPONIVEL) + else -> PodeMarcar.Sim + } + is AutenticacaoHost.Chave -> if (a.keyId.isBlank() || !credencialDisponivel) PodeMarcar.Nao(MotivoAgente.CHAVE_NAO_GUARDADA) else if (chavePedePassphrase(a.keyId)) PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE) else PodeMarcar.Sim } } diff --git a/app/src/main/java/com/keyflow/computador/ValidadorHost.kt b/app/src/main/java/com/keyflow/computador/ValidadorHost.kt index 6010da54..4622406d 100644 --- a/app/src/main/java/com/keyflow/computador/ValidadorHost.kt +++ b/app/src/main/java/com/keyflow/computador/ValidadorHost.kt @@ -1,7 +1,7 @@ package com.keyflow.computador /** Campo de um host que a validação sabe julgar. */ -enum class CampoHost { NOME, ENDERECO, PORTA, USUARIO, LIMITE } +enum class CampoHost { NOME, ENDERECO, PORTA, USUARIO, AUTENTICACAO, LIMITE } /** Motivo da recusa, por campo. O texto da interface sai de `tr(…)` na tela; aqui só o código e uma frase em português. */ enum class MotivoHost(val frase: String) { @@ -16,6 +16,9 @@ enum class MotivoHost(val frase: String) { USUARIO_VAZIO("Informe o usuário."), USUARIO_LONGO("O usuário pode ter no máximo 64 caracteres."), USUARIO_INVALIDO("O usuário não pode ter espaços nem caracteres de controle."), + CHAVE_NAO_GUARDADA("Escolha uma chave guardada."), + SENHA_NAO_INFORMADA("Informe uma senha para guardá-la neste aparelho."), + SENHA_NAO_GUARDADA("Não foi possível guardar a senha neste aparelho."), LISTA_CHEIA("O limite é de 50 servidores."), } @@ -25,8 +28,11 @@ object ValidadorHost { private val rotuloDns = Regex("^[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?$") private val ipv4 = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$") - /** Valida [host] contra os [outros] já cadastrados (o próprio id é ignorado ao conferir o nome único). Lista vazia = válido. */ - fun validar(host: Host, outros: List = emptyList()): List { + /** + * Valida [host] contra [outros]. [credencialDisponivel] é obrigatório na gravação; `null` só valida + * estrutura ao ler JSON, que não contém segredos e não autoriza acesso ao cofre. + */ + fun validar(host: Host, outros: List = emptyList(), credencialDisponivel: Boolean? = null): List { val erros = mutableListOf() val nome = host.nome.trim() when { @@ -43,6 +49,9 @@ object ValidadorHost { host.usuario.any { it.isWhitespace() || it.isISOControl() } -> erros += ErroHost(CampoHost.USUARIO, MotivoHost.USUARIO_INVALIDO) } if (outros.none { it.id == host.id } && outros.size >= LimitesHost.MAX_HOSTS) erros += ErroHost(CampoHost.LIMITE, MotivoHost.LISTA_CHEIA) + val chave = host.autenticacao as? AutenticacaoHost.Chave + if (chave != null && (chave.keyId.isBlank() || credencialDisponivel == false)) erros += ErroHost(CampoHost.AUTENTICACAO, MotivoHost.CHAVE_NAO_GUARDADA) + if (host.autenticacao == AutenticacaoHost.Senha && host.lembrarSenha && credencialDisponivel == false) erros += ErroHost(CampoHost.AUTENTICACAO, MotivoHost.SENHA_NAO_INFORMADA) return erros } diff --git a/app/src/main/java/com/keyflow/computador/app/AcoesDeHostReais.kt b/app/src/main/java/com/keyflow/computador/app/AcoesDeHostReais.kt new file mode 100644 index 00000000..e1e43b53 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/AcoesDeHostReais.kt @@ -0,0 +1,61 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.EdicaoDeHost +import com.keyflow.computador.GravadorDeHosts +import com.keyflow.computador.ErroHost +import com.keyflow.computador.Host +import com.keyflow.computador.ResultadoEdicao +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ResultadoTeste +import com.keyflow.ui.config.AcoesDeHost +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** As ações do painel de hosts ligadas ao repositório, ao gerenciador de sessões e ao teste real. */ +class AcoesDeHostReais( + private val repositorio: RepositorioComputador, + private val gerenciador: GerenciadorDeSessoes, + private val testador: TestadorDeConexao, + private val provedorDeChaves: ProvedorDeChavesAndroid, +) : AcoesDeHost { + private val gravador = GravadorDeHosts(repositorio) + + private fun enderecoDeConfianca(h: Host) = h.endereco.removePrefix("[").removeSuffix("]") + + override suspend fun salvar(host: Host, senha: String?): List = withContext(Dispatchers.IO) { + val antes = repositorio.hostsOnce().porId(host.id) + val resultado = gravador.salvar(host, senha) + when (val r = resultado) { + is ResultadoEdicao.Erros -> r.erros + is ResultadoEdicao.Ok -> { + // `known_hosts` é por `host:porta`: o endereço antigo deixa de valer para este host. + if (antes != null && (antes.endereco != r.host.endereco || antes.porta != r.host.porta)) repositorio.confianca.esquecer(enderecoDeConfianca(antes), antes.porta) + emptyList() + } + } + } + + override suspend fun temSenhaGuardada(host: Host): Boolean = withContext(Dispatchers.IO) { + try { !repositorio.senhaLembrada(host.id).isNullOrEmpty() } catch (_: Exception) { false } + } + + override suspend fun excluir(host: Host) = repositorio.excluirHost(host.id) { gerenciador.fecharDoHost(it) } + + override suspend fun testar(host: Host, senha: String?): ResultadoTeste { + val chave = (host.autenticacao as? AutenticacaoHost.Chave)?.let { a -> repositorio.chavesOnce().firstOrNull { it.id == a.keyId } } + if (host.autenticacao is AutenticacaoHost.Chave && chave == null) return ResultadoTeste.Falhou(CausaSsh.CHAVE_NAO_ACEITA) + val lembrada = if (host.autenticacao == AutenticacaoHost.Senha && host.lembrarSenha) repositorio.senhaLembrada(host.id) else null + return testador.testar(host, senha ?: lembrada, chave?.let(provedorDeChaves::assinador)) + } + + override fun impressoesConhecidas(host: Host): List = repositorio.confianca.chavesConhecidas(enderecoDeConfianca(host), host.porta) + + override fun esquecerServidor(host: Host) = repositorio.confianca.esquecer(enderecoDeConfianca(host), host.porta) + + override suspend fun desligarAgente(host: Host) = gerenciador.fecharDoAgenteNoHost(host.id) + + override fun novoId(existentes: Collection): String = EdicaoDeHost.novoId(existentes) +} diff --git a/app/src/main/java/com/keyflow/computador/app/ComputadorApp.kt b/app/src/main/java/com/keyflow/computador/app/ComputadorApp.kt new file mode 100644 index 00000000..23130a76 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/ComputadorApp.kt @@ -0,0 +1,107 @@ +package com.keyflow.computador.app + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.fragment.app.FragmentActivity +import com.keyflow.App +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DependenciasDeSessao +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.GerenciadorDeSessoes +import com.keyflow.computador.sessao.ResultadoAbertura +import com.keyflow.computador.ssh.ConexaoCbssh +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.PtyInfo +import com.keyflow.data.SettingsRepository +import java.io.File +import java.lang.ref.WeakReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * As peças do Computador que vivem enquanto o processo vive: repositório, gerenciador de sessões, registro de emuladores, o provedor de + * chaves e a interação com a pessoa. Nada é criado até alguém chamar [iniciar] (a `TerminalActivity`, a aba ou o serviço). + */ +class ComputadorApp private constructor(private val contexto: Context, dependenciasDeTeste: DependenciasDeSessao? = null) { + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val atividade = java.util.concurrent.atomic.AtomicReference(WeakReference(null)) + + val settings = SettingsRepository(contexto) + private val cofre = CofreDoApp((contexto.applicationContext as App).authServices.store) + val repositorio = RepositorioComputador(settings, cofre, File(contexto.filesDir, "computador/known_hosts")) + val interacao = InteracaoDoApp() + val emuladores = EmuladoresDeSessao() + val provedorDeChaves = ProvedorDeChavesAndroid(cofre, BiometriaAndroid(contexto) { atividade.get().get() }) + private val rede = ObservadorDeRede(contexto) + + /** Cópia dos metadados das chaves para a decisão síncrona da reconexão (sem bloquear thread). */ + @Volatile private var chaves: List = emptyList() + + private val dependencias: DependenciasDeSessao = dependenciasDeTeste ?: object : DependenciasDeSessao { + override fun novaConexao(): ConexaoSsh = ConexaoCbssh(repositorio.confianca, interacao, escopo) + + override suspend fun destino(host: Host, dono: DonoDaSessao): DestinoSsh? { + val senha = if (host.autenticacao == AutenticacaoHost.Senha && host.lembrarSenha) repositorio.senhaLembrada(host.id) else null + val assinador = (host.autenticacao as? AutenticacaoHost.Chave)?.let { a -> + repositorio.chavesOnce().firstOrNull { it.id == a.keyId }?.let(provedorDeChaves::assinador) ?: return null + } + return DestinoSsh(host, senha, assinador, confiarSozinho = dono == DonoDaSessao.USUARIO) + } + + override fun precisaDoUsuario(host: Host): Boolean { + val a = host.autenticacao as? AutenticacaoHost.Chave ?: return false + val chave = chaves.firstOrNull { it.id == a.keyId } ?: return false + return !provedorDeChaves.janelaAberta(chave) + } + + override val rede: StateFlow get() = this@ComputadorApp.rede.temRede + override fun agoraMs(): Long = System.currentTimeMillis() + } + + val gerenciador = GerenciadorDeSessoes(escopo, dependencias) + + init { + SessoesService.ligar(contexto, gerenciador, escopo) + escopo.launch { repositorio.chavesFlow().collect { chaves = it } } + // O emulador de uma sessão que acabou (fechada, caída sem volta) é liberado. + escopo.launch { gerenciador.resumos.collect { lista -> emuladores.manterSomente(lista.filter { !it.estado.terminal }.map { it.id }.toSet()) } } + } + + /** O `FragmentActivity` visível (o `BiometricPrompt` precisa dele); `null` em segundo plano. */ + fun atividadeVisivel(a: FragmentActivity?) { atividade.set(WeakReference(a)) } + + /** + * Abre uma sessão para [host]: cria o emulador **antes** (para o primeiro byte do servidor já ter onde cair) e o liga à sessão. As + * cores iniciais são as do tema; a tela troca por `setDefaultColors` quando o tema muda. + */ + fun abrirSessao(host: Host, dono: DonoDaSessao, corFrente: Color, corFundo: Color, pty: PtyInfo = PtyInfo(80, 24)): ResultadoAbertura { + val ligacao = LigacaoEmulador(escopo, corFrente, corFundo, pty.colunas, pty.linhas) + val r = gerenciador.abrir(host, dono, pty, ligacao) + when (r) { + is ResultadoAbertura.Aberta -> { emuladores.registrar(r.sessao.id, ligacao); ligacao.ligarASessao(r.sessao) } + is ResultadoAbertura.Recusada -> ligacao.fechar() + } + return r + } + + /** O sistema pede memória (CMP-29). O texto das sessões fica: só imagens (Fase 2) seriam liberadas, e elas ainda não existem. */ + fun aoFaltarMemoria(nivel: Int) { /* nada a soltar sem imagens; o scrollback de texto nunca é descartado */ } + + companion object { + @Volatile private var instancia: ComputadorApp? = null + + fun de(contexto: Context): ComputadorApp = instancia ?: synchronized(this) { + instancia ?: ComputadorApp(contexto.applicationContext).also { instancia = it } + } + + /** Só para testes instrumentados: recria o [ComputadorApp] com [dependencias] falsas (conexão de mentira). Devolve o novo. */ + fun paraTeste(contexto: Context, dependencias: DependenciasDeSessao): ComputadorApp = synchronized(this) { + ComputadorApp(contexto.applicationContext, dependencias).also { instancia = it } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/EmuladoresDeSessao.kt b/app/src/main/java/com/keyflow/computador/app/EmuladoresDeSessao.kt new file mode 100644 index 00000000..f1d5f851 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/EmuladoresDeSessao.kt @@ -0,0 +1,21 @@ +package com.keyflow.computador.app + +import java.util.concurrent.ConcurrentHashMap + +/** + * Um [LigacaoEmulador] por sessão, guardado **fora da activity**: girar a tela, voltar para o app ou recriar a `TerminalActivity` reencontram + * o mesmo emulador, com tudo que já estava desenhado. Sai do registro (e fecha) quando a sessão acaba. + */ +class EmuladoresDeSessao { + private val mapa = ConcurrentHashMap() + + fun registrar(sessaoId: String, ligacao: LigacaoEmulador) { mapa.put(sessaoId, ligacao)?.fechar() } + fun de(sessaoId: String): LigacaoEmulador? = mapa[sessaoId] + + fun remover(sessaoId: String) { mapa.remove(sessaoId)?.fechar() } + + /** Fecha o emulador de toda sessão que não está mais em [vivas]. */ + fun manterSomente(vivas: Set) { mapa.keys.filter { it !in vivas }.forEach { remover(it) } } + + val ids: Set get() = mapa.keys.toSet() +} diff --git a/app/src/main/java/com/keyflow/computador/app/EntradaDoTerminal.kt b/app/src/main/java/com/keyflow/computador/app/EntradaDoTerminal.kt new file mode 100644 index 00000000..0eac6ef4 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/EntradaDoTerminal.kt @@ -0,0 +1,78 @@ +package com.keyflow.computador.app + +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.KeyEvent +import org.connectbot.terminal.ModifierManager + +/** Do código de tecla do Android para a [Tecla] que o app mapeia sozinho (`null` = deixa para o termlib). */ +internal fun teclaDoAndroid(codigo: Int): Tecla? = when (codigo) { + in AndroidKeyEvent.KEYCODE_A..AndroidKeyEvent.KEYCODE_Z -> Tecla.Caractere('a' + (codigo - AndroidKeyEvent.KEYCODE_A)) + AndroidKeyEvent.KEYCODE_SPACE -> Tecla.Caractere(' ') + AndroidKeyEvent.KEYCODE_LEFT_BRACKET -> Tecla.Caractere('[') + AndroidKeyEvent.KEYCODE_RIGHT_BRACKET -> Tecla.Caractere(']') + AndroidKeyEvent.KEYCODE_BACKSLASH -> Tecla.Caractere('\\') + AndroidKeyEvent.KEYCODE_SLASH -> Tecla.Caractere('/') + AndroidKeyEvent.KEYCODE_ENTER, AndroidKeyEvent.KEYCODE_NUMPAD_ENTER -> Tecla.Enter + AndroidKeyEvent.KEYCODE_TAB -> Tecla.Tab + AndroidKeyEvent.KEYCODE_ESCAPE -> Tecla.Esc + AndroidKeyEvent.KEYCODE_DEL -> Tecla.Backspace + AndroidKeyEvent.KEYCODE_FORWARD_DEL -> Tecla.Delete + AndroidKeyEvent.KEYCODE_INSERT -> Tecla.Insert + AndroidKeyEvent.KEYCODE_DPAD_UP -> Tecla.Cima + AndroidKeyEvent.KEYCODE_DPAD_DOWN -> Tecla.Baixo + AndroidKeyEvent.KEYCODE_DPAD_LEFT -> Tecla.Esquerda + AndroidKeyEvent.KEYCODE_DPAD_RIGHT -> Tecla.Direita + AndroidKeyEvent.KEYCODE_MOVE_HOME -> Tecla.Home + AndroidKeyEvent.KEYCODE_MOVE_END -> Tecla.End + AndroidKeyEvent.KEYCODE_PAGE_UP -> Tecla.PageUp + AndroidKeyEvent.KEYCODE_PAGE_DOWN -> Tecla.PageDown + in AndroidKeyEvent.KEYCODE_F1..AndroidKeyEvent.KEYCODE_F12 -> Tecla.F(codigo - AndroidKeyEvent.KEYCODE_F1 + 1) + else -> null +} + +/** + * A entrada do terminal (CMP-26, 27): liga ao `Terminal` do termlib a interceptação de teclas e os modificadores pegajosos. + * + * O `KeyEvent` chega por **dois** caminhos conforme o teclado (a InputConnection e a view; a T1 mediu `aa`): [DeduplicadorDeTeclas] ignora o + * segundo. As teclas que o app mapeia (Ctrl/Alt+letra, Enter, Tab, Esc, Backspace, setas, Home/End, PageUp/Down, F1–F12) viram bytes por + * [MapaTecladoFisico] e saem por [LigacaoEmulador.enviar]; as demais (letras e símbolos soltos) seguem ao termlib, que cuida da composição. + * O texto digitado/ditado pelo teclado de tela chega como `commitText` (UTF-8, sem Enter), pelo caminho normal do termlib. + */ +class EntradaDoTerminal(private val ligacao: LigacaoEmulador) { + val pegajosos = ModificadoresPegajosos() + private val dedup = DeduplicadorDeTeclas() + + /** O termlib consulta isto para as teclas de texto que ele mesmo trata (Ctrl ou Alt travados nas teclas extras valem para elas). */ + val modificadores: ModifierManager = object : ModifierManager { + override fun isCtrlActive() = pegajosos.atuais().ctrl + override fun isAltActive() = pegajosos.atuais().alt + override fun isShiftActive() = false + override fun clearTransients() = pegajosos.consumir() + } + + val interceptar: (KeyEvent) -> Boolean = { evento -> aoTecla(evento.nativeKeyEvent) } + + internal fun aoTecla(k: AndroidKeyEvent): Boolean { + if (dedup.duplicada(k.action, k.keyCode, k.downTime, k.eventTime, k.repeatCount)) return true + val tecla = teclaDoAndroid(k.keyCode) ?: return false + val mods = Modificadores(k.isCtrlPressed, k.isAltPressed, k.isShiftPressed) + pegajosos.atuais() + val bytes = MapaTecladoFisico.bytes(tecla, mods, ligacao.modoAplicacaoDasSetas) ?: return false + if (k.action == AndroidKeyEvent.ACTION_DOWN) { ligacao.enviar(bytes); pegajosos.consumir() } + return true + } + + /** Uma tecla extra tocada (já em bytes): sai pelo mesmo caminho, com Ctrl/Alt pegajosos aplicados. */ + fun teclaExtra(tecla: Tecla) { + val bytes = MapaTecladoFisico.bytes(tecla, pegajosos.atuais(), ligacao.modoAplicacaoDasSetas) ?: return + ligacao.enviar(bytes) + pegajosos.consumir() + } + + /** Um caractere das teclas extras (`|`, `~`, `/`, `-`): com Ctrl/Alt pegajoso vira Ctrl+/ etc.; sem, o caractere puro. */ + fun caractereExtra(c: Char) { + val m = pegajosos.atuais() + val bytes = MapaTecladoFisico.bytes(Tecla.Caractere(c), m) ?: c.toString().toByteArray(Charsets.UTF_8) + ligacao.enviar(bytes) + pegajosos.consumir() + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/FiltroDeRespostas.kt b/app/src/main/java/com/keyflow/computador/app/FiltroDeRespostas.kt new file mode 100644 index 00000000..7381d6f1 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/FiltroDeRespostas.kt @@ -0,0 +1,196 @@ +package com.keyflow.computador.app + +import java.io.ByteArrayOutputStream + +/** CMP-35: lista fechada de teclas/respostas, inclusive C1 de oito bits. Cada callback traz uma resposta completa. */ +class FiltroDeRespostas { + @Volatile var descartes: Int = 0 + private set + + fun filtrar(entrada: ByteArray): ByteArray { + val saida = ByteArrayOutputStream(entrada.size) + val parser = ParserVt( + texto = { saida.write(it) }, + descarte = { descartes++ }, + saidaDoEmulador = true, + sequencia = { tipo, bruto, corpo -> + val t = corpo.toString(Charsets.ISO_8859_1) + val permitir = when (tipo) { + ParserVt.Tipo.ESC -> t.length == 1 || t == " F" + ParserVt.Tipo.CSI -> CsiVt.ler(t)?.let(::csiPermitido) == true + ParserVt.Tipo.OSC -> OscVt.ler(t)?.let { cor.matches("${it.comando};${it.texto}") } == true + ParserVt.Tipo.APC -> apcDoKittyPermitido(t) + else -> false + } + if (permitir) saida.write(bruto) else descartes++ + }, + ) + parser.processar(entrada) + parser.terminarResposta() + return saida.toByteArray() + } + + private fun csiPermitido(c: CsiVt): Boolean { + if (c.intermediarios.isNotEmpty()) return false + val a = c.argumentos + return when (c.final) { + 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S', 'Z', '~', 'u' -> c.lider.isEmpty() || (c.final == 'u' && c.lider == "?") + 'I', 'O' -> c.lider.isEmpty() && a == listOf(null) + 'R' -> c.lider.isEmpty() && a.any { it != null } + 'c' -> c.lider == "?" || c.lider == ">" + 'n' -> (c.lider.isEmpty() && a == listOf(0)) || c.lider == "?" + 'M', 'm' -> c.lider == "<" && a.any { it != null } + 't' -> c.lider.isEmpty() && a.first() in setOf(4, 6, 8) + else -> false + } + } + + private val cor = Regex("^(?:(?:10|11|12);|4;[0-9]{1,3};)rgb:[0-9a-fA-F]{1,4}/[0-9a-fA-F]{1,4}/[0-9a-fA-F]{1,4}$") + private fun apcDoKittyPermitido(t: String): Boolean { + if (t.length !in 2..300 || t[0] != 'G') return false + val ponto = t.indexOf(';') + return ponto >= 0 && t.substring(1, ponto).all { it.isLetterOrDigit() || it == '=' || it == ',' } && t.substring(ponto+1).all { it.code in 0x20..0x7e } + } +} + +/** + * CMP-35: o fluxo do servidor é tokenizado antes de decidir. Prefixos e strings só saem completos; + * cancelamento descarta o trecho ainda retido. Assim retirar uma consulta nunca deixa um OSC/DCS aberto no libvterm. + */ +class FiltroDaSaidaDoServidor(private val aoTitulo: (String) -> Unit = {}) { + private var saida = ByteArrayOutputStream() + @Volatile var consumidos: Int = 0 + private set + @Volatile var modoAplicacaoDasSetas: Boolean = false + private set + private val parser = ParserVt(texto = { saida.write(it) }, descarte = { consumidos++ }, sequencia = ::sequencia) + + fun processar(entrada: ByteArray): ByteArray { + saida = ByteArrayOutputStream(entrada.size) + parser.processar(entrada) + return saida.toByteArray() + } + fun reiniciar() { modoAplicacaoDasSetas = false; parser.reiniciar() } + + private fun sequencia(tipo: ParserVt.Tipo, bruto: ByteArray, corpo: ByteArray) { + val t = corpo.toString(Charsets.ISO_8859_1) + when (tipo) { + ParserVt.Tipo.ESC -> { + if (t == " G") { consumidos++; return } + if (t == "c") modoAplicacaoDasSetas = false // RIS: a libvterm volta as setas ao modo normal + } + ParserVt.Tipo.CSI -> { + val c = CsiVt.ler(t) ?: run { consumidos++; return } + if (c.lider.isEmpty() && c.intermediarios.isEmpty() && c.final == 't' && c.argumentos.first() in setOf(20, 21)) { consumidos++; return } + if (c.lider == "?" && c.intermediarios.isEmpty() && c.final in "hl" && 1 in c.argumentos) modoAplicacaoDasSetas = c.final == 'h' + if (c.lider.isEmpty() && c.intermediarios == "!" && c.final == 'p') modoAplicacaoDasSetas = false // DECSTR + } + ParserVt.Tipo.OSC -> { + val osc = OscVt.ler(t) ?: run { consumidos++; return } + if (osc.comando == 21 || (osc.comando == 52 && osc.texto.substringAfter(';', "").startsWith("?"))) { consumidos++; return } + if (osc.comando == 0 || osc.comando == 2) RotuloDeAba.sanitizar(osc.texto.toByteArray(Charsets.ISO_8859_1).toString(Charsets.UTF_8))?.let(aoTitulo) + } + ParserVt.Tipo.DCS -> if (t.startsWith("\$q")) { consumidos++; return } + else -> Unit + } + saida.write(bruto) + } +} + +/** O texto que vira rótulo da aba (CMP-35): sem controles, sem marcas de direção, até 40 caracteres. */ +object RotuloDeAba { + const val MAX = 40 + + /** `null` quando não sobra nada. */ + fun sanitizar(bruto: String): String? { + val limpo = StringBuilder() + var ultimoEspaco = true + var i = 0 + var pontos = 0 + while (i < bruto.length && pontos < MAX) { + val cp = bruto.codePointAt(i) + i += Character.charCount(cp) + val tipo = Character.getType(cp) + val controle = tipo == Character.CONTROL.toInt() || tipo == Character.FORMAT.toInt() || tipo == Character.LINE_SEPARATOR.toInt() || + tipo == Character.PARAGRAPH_SEPARATOR.toInt() || tipo == Character.PRIVATE_USE.toInt() || tipo == Character.UNASSIGNED.toInt() || tipo == Character.SURROGATE.toInt() + if (controle) continue + if (Character.isWhitespace(cp) || cp == 0xa0) { + if (!ultimoEspaco) { limpo.append(' '); pontos++ } + ultimoEspaco = true + continue + } + limpo.appendCodePoint(cp) + pontos++ + ultimoEspaco = false + } + return limpo.toString().trim().ifEmpty { null } + } +} + +/** + * CMP-28: o que sai de uma colagem (ou de um ditado com quebra de linha). O termlib não publica o estado do bracketed paste, então a + * confirmação vale sempre que há quebra de linha. O texto perde `ESC [ 2 0 0 ~` e `ESC [ 2 0 1 ~` **até não sobrar nenhum** (a remoção em uma passada + * deixaria `ESC ESC [ 2 0 1 ~ [ 2 0 1 ~` virar a marca de novo) e depois todo `ESC` e controle (menos tab, `\n` e `\r`) e os controles C1. + */ +object PoliticaDeColar { + class Preparado(val texto: String, val linhas: Int) { + val precisaConfirmar: Boolean get() = linhas > 1 || texto.any { it == '\n' || it == '\r' } + } + + fun preparar(original: String): Preparado { + var t = original + while (true) { + val novo = t.replace("\u001b[201~", "").replace("\u001b[200~", "") + if (novo == t) break + t = novo + } + val limpo = buildString(t.length) { + for (c in t) { + val manter = c == '\t' || c == '\n' || c == '\r' || (c.code >= 0x20 && c.code != 0x7f && c.code !in 0x80..0x9f) + if (manter) append(c) + } + } + return Preparado(limpo, contarLinhas(limpo)) + } + + private fun contarLinhas(t: String): Int { + if (t.isEmpty()) return 0 + var n = 1 + var i = 0 + while (i < t.length) { + val c = t[i] + if (c == '\r' && i + 1 < t.length && t[i + 1] == '\n') { n++; i += 2; continue } + if (c == '\n' || c == '\r') n++ + i++ + } + // Uma quebra no fim não abre linha nova com texto, mas **executa** a anterior: continua contando como linha para a confirmação. + return n + } +} + +/** Links `OSC 8` (CMP-25, CMP-35): só `http`, `https` e `mailto`; qualquer outro esquema nunca vira `Intent`. */ +object PoliticaDeLinks { + private const val MAX = 2048 + + /** A URL **inteira** a mostrar no diálogo, ou `null` se o link não pode ser aberto. */ + fun aceitar(url: String): String? { + val u = url.trim() + if (u.isEmpty() || u.length > MAX) return null + if (u.any { it.code < 0x20 || it.code == 0x7f || it.code in 0x80..0x9f || Character.isWhitespace(it) || Character.getType(it) == Character.FORMAT.toInt() }) return null + val doisPontos = u.indexOf(':') + if (doisPontos <= 0) return null + val esquema = u.substring(0, doisPontos) + if (esquema.any { !(it.isLetterOrDigit() || it == '+' || it == '-' || it == '.') }) return null + return when (esquema.lowercase()) { + "http", "https" -> { + val resto = u.substring(doisPontos + 1) + if (!resto.startsWith("//")) return null + val autoridade = resto.removePrefix("//").takeWhile { it != '/' && it != '?' && it != '#' } + // `usuario@host`: o trecho antes do `@` engana quem lê o domínio; recusado. + if (autoridade.isEmpty() || '@' in autoridade || autoridade.startsWith(":")) null else u + } + "mailto" -> if (u.length > doisPontos + 1) u else null + else -> null + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/InteracaoDoApp.kt b/app/src/main/java/com/keyflow/computador/app/InteracaoDoApp.kt new file mode 100644 index 00000000..f15ac2f6 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/InteracaoDoApp.kt @@ -0,0 +1,53 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.ssh.InteracaoComUsuario +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** O que a conexão pergunta à pessoa. Cada pedido suspende até [PedidoDeInteracao.resposta] ser completada (a tela da T16 completa). */ +sealed interface PedidoDeInteracao { + val resposta: CompletableDeferred<*> + + class ConfirmarServidor(val host: String, val porta: Int, val algoritmo: String, val impressao: ImpressaoDigital) : PedidoDeInteracao { + override val resposta = CompletableDeferred() + } + + class Senha : PedidoDeInteracao { + override val resposta = CompletableDeferred() + } + + class Codigo(val nome: String, val instrucao: String, val perguntas: List, val eco: List) : PedidoDeInteracao { + override val resposta = CompletableDeferred?>() + } +} + +/** + * A [InteracaoComUsuario] de produção: publica o pedido em [pedido] e **suspende** até a tela responder. **Sem ninguém olhando** ([temTela] + * falso), recusa na hora (servidor não confiado, sem senha, sem código): uma conexão nunca espera um diálogo que ninguém verá. Quem desenha os + * diálogos é a T16; até lá nenhuma tela do app abre sessão sozinha, então esta recusa é o comportamento seguro. + */ +class InteracaoDoApp : InteracaoComUsuario { + private val _pedido = MutableStateFlow(null) + val pedido: StateFlow = _pedido + @Volatile var temTela: Boolean = false + + private suspend fun perguntar(p: PedidoDeInteracao, recusa: T, resposta: CompletableDeferred): T { + if (!temTela) return recusa + _pedido.value = p + return try { resposta.await() } finally { if (_pedido.value === p) _pedido.value = null } + } + + override suspend fun confirmarServidor(host: String, porta: Int, algoritmo: String, impressao: ImpressaoDigital): Boolean { + val p = PedidoDeInteracao.ConfirmarServidor(host, porta, algoritmo, impressao) + return perguntar(p, false, p.resposta) + } + + override suspend fun pedirSenha(): String? { val p = PedidoDeInteracao.Senha(); return perguntar(p, null, p.resposta) } + + override suspend fun pedirCodigo(nome: String, instrucao: String, perguntas: List, eco: List): List? { + val p = PedidoDeInteracao.Codigo(nome, instrucao, perguntas, eco) + return perguntar(p, null, p.resposta) + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/LigacaoEmulador.kt b/app/src/main/java/com/keyflow/computador/app/LigacaoEmulador.kt new file mode 100644 index 00000000..76209bcf --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/LigacaoEmulador.kt @@ -0,0 +1,171 @@ +package com.keyflow.computador.app + +import androidx.compose.ui.graphics.Color +import com.keyflow.computador.sessao.PortaDoEmulador +import com.keyflow.computador.sessao.SessaoTerminal +import com.keyflow.computador.ssh.PtyInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.connectbot.terminal.InlineImages +import org.connectbot.terminal.TerminalEmulator +import org.connectbot.terminal.TerminalEmulatorFactory + +/** Para onde vão os bytes e o tamanho da tela. A [SessaoTerminal] é quem implementa em produção; os testes trocam por um registro. */ +interface DestinoDoEmulador { + suspend fun escrever(bytes: ByteArray) + suspend fun redimensionar(pty: PtyInfo) +} + +/** + * O elo entre a sessão SSH e o termlib (CMP-23, CMP-35): os bytes do servidor passam por [FiltroDaSaidaDoServidor] e entram no emulador + * (`writeInput`); o que o emulador devolve (teclas e respostas) passa por [FiltroDeRespostas] e segue **em ordem** para o servidor; a + * mudança de área vira `window-change` com colunas, linhas **e pixels**. + * + * Vive **mais que a tela**: o registro ([EmuladoresDeSessao]) o guarda por sessão, então girar o aparelho ou voltar para o app não perde o + * que estava desenhado. Só [fechar] (a sessão acabou) libera o emulador. Tudo fica em memória (CMP-36). + */ +class LigacaoEmulador( + private val escopo: CoroutineScope, + corFrente: Color, + corFundo: Color, + colunas: Int = 80, + linhas: Int = 24, + /** Teto de linhas de histórico do emulador (a bancada da T1 mediu 5 000 e 20 000 no fork). */ + scrollback: Int = 5_000, +) : PortaDoEmulador, AutoCloseable { + private sealed interface Saida { + class Bytes(val dados: ByteArray) : Saida + class Tamanho(val pty: PtyInfo) : Saida + } + + private val fila = Channel(Channel.UNLIMITED) + private val destino = CompletableDeferred() + private val filtroDeSaida = FiltroDeRespostas() + private val filtroDeEntrada = FiltroDaSaidaDoServidor { _titulo.value = it } + private val guarda = GuardaDeLinhas() + private val _pedidoDeColar = MutableStateFlow(null) + private val _titulo = MutableStateFlow(null) + private val _pedidoDeClipboard = MutableStateFlow(null) + @Volatile private var fechada = false + + /** O rótulo que o servidor pediu para a aba (já sanitizado), ou `null`. */ + val titulo: StateFlow = _titulo + + /** Texto com quebra de linha (colado, ditado) esperando a confirmação (CMP-28). */ + val pedidoDeColar: StateFlow = _pedidoDeColar + + /** O servidor ligou o modo de aplicação das setas (DECCKM)? */ + val modoAplicacaoDasSetas: Boolean get() = filtroDeEntrada.modoAplicacaoDasSetas + + /** Texto que o servidor quer copiar (`OSC 52` de escrita) e que espera o consentimento da pessoa. Padrão: negar. */ + val pedidoDeClipboard: StateFlow = _pedidoDeClipboard + + val emulador: TerminalEmulator = TerminalEmulatorFactory.create( + initialRows = linhas, + initialCols = colunas, + defaultForeground = corFrente, + defaultBackground = corFundo, + onKeyboardInput = { bytes -> + val ok = filtroDeSaida.filtrar(bytes) + if (ok.isNotEmpty() && !fechada) { + when (val d = guarda.avaliar(ok, System.currentTimeMillis())) { + GuardaDeLinhas.Decisao.Enviar -> fila.trySend(Saida.Bytes(ok.copyOf())) + // Ditado ou colagem do teclado com quebra de linha: pede confirmação em vez de executar. + is GuardaDeLinhas.Decisao.Segurar -> _pedidoDeColar.value = PoliticaDeColar.preparar(d.texto) + } + } + }, + onResize = { dim -> if (!fechada) fila.trySend(Saida.Tamanho(PtyInfo(dim.columns, dim.rows, dim.widthPixels, dim.heightPixels))) }, + onClipboardCopy = { texto -> if (!fechada) _pedidoDeClipboard.value = texto }, + autoDetectUrls = false, + // As imagens (kitty, iTerm2, sixel) entram na Fase 2, com a política por host. + inlineImages = InlineImages.Off, + maxScrollbackLines = scrollback, + ) + + init { + escopo.launch { + val d = destino.await() + // Um único consumidor: a ordem em que o emulador produziu é a ordem em que o servidor recebe (um `launch` por pedaço reordenaria). + var ultimoTamanho: PtyInfo? = null + for (item in fila) { + try { + when (item) { + is Saida.Bytes -> d.escrever(item.dados) + is Saida.Tamanho -> if (item.pty != ultimoTamanho) { ultimoTamanho = item.pty; d.redimensionar(item.pty) } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + // conexão caída: a sessão cuida do estado; o que o usuário digitou nesse intervalo se perde. + } + } + } + } + + /** Liga o destino (a sessão). Os pedaços produzidos antes ficam na fila e saem depois, na mesma ordem. */ + fun ligarAoDestino(d: DestinoDoEmulador) { destino.complete(d) } + + fun ligarASessao(sessao: SessaoTerminal) = ligarAoDestino(object : DestinoDoEmulador { + override suspend fun escrever(bytes: ByteArray) = sessao.escrever(bytes) + override suspend fun redimensionar(pty: PtyInfo) = sessao.redimensionar(pty) + }) + + override fun escreverNaTela(bytes: ByteArray) { + if (fechada) return + val limpo = synchronized(filtroDeEntrada) { filtroDeEntrada.processar(bytes) } + if (limpo.isEmpty()) return + try { emulador.writeInput(limpo) } catch (_: IllegalStateException) { /* fechou entre a checagem e a escrita */ } + } + + override fun tamanhoAtual(): PtyInfo = emulador.dimensions.let { PtyInfo(it.columns, it.rows, it.widthPixels, it.heightPixels) } + + /** Resposta do consentimento do `OSC 52`: [permitir] devolve o texto a copiar (ou `null`). Sempre limpa o pedido. */ + fun responderClipboard(permitir: Boolean): String? { + val texto = _pedidoDeClipboard.value + _pedidoDeClipboard.value = null + return if (permitir) texto else null + } + + /** Cola [texto] já preparado por [PoliticaDeColar] (o emulador respeita o bracketed paste). */ + fun colar(texto: String) { + if (fechada || texto.isEmpty()) return + guarda.liberar(System.currentTimeMillis()) + emulador.pasteText(texto) + } + + /** Pede para colar [original]: uma linha só vai direto; com quebra de linha espera [confirmarColar]. */ + fun pedirColar(original: String) { + val p = PoliticaDeColar.preparar(original) + if (p.texto.isEmpty()) return + if (p.precisaConfirmar) _pedidoDeColar.value = p else colar(p.texto) + } + + fun confirmarColar() { val p = _pedidoDeColar.value; _pedidoDeColar.value = null; if (p != null) colar(p.texto) } + fun cancelarColar() { _pedidoDeColar.value = null } + + /** Bytes de teclas que o app mesmo mapeou (teclas extras, teclado físico): passam pelo mesmo filtro e pela mesma fila, na ordem. */ + fun enviar(bytes: ByteArray) { + if (bytes.isEmpty() || fechada) return + // A mesma FIFO do QueuedTerminal serializa texto/IME e teclas mapeadas antes de postar callbacks. + // O único onKeyboardInput acima aplica o filtro e entrega ao consumidor SSH, sem depender de milissegundos. + try { emulador.dispatchKeyboardInput(bytes) } catch (_: IllegalStateException) { /* fechou durante o envio */ } + } + + /** Visível aos testes: quantos pedaços o filtro de saída descartou. */ + val descartesDeSaida: Int get() = filtroDeSaida.descartes + + override fun close() { fechar() } + + fun fechar() { + if (fechada) return + fechada = true + fila.close() + if (!destino.isCompleted) destino.cancel() + runCatching { emulador.close() } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt b/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt index 1b2db9ad..7026edce 100644 --- a/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt +++ b/app/src/main/java/com/keyflow/computador/app/LigacaoSessoes.kt @@ -4,6 +4,7 @@ import com.keyflow.computador.sessao.EventoGerenciador import com.keyflow.computador.sessao.GerenciadorDeSessoes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch /** @@ -13,7 +14,9 @@ import kotlinx.coroutines.launch */ internal fun observarPrimeiraSessao(gerenciador: GerenciadorDeSessoes, escopo: CoroutineScope, iniciar: () -> Unit) { escopo.launch(start = CoroutineStart.UNDISPATCHED) { - if (gerenciador.resumos.value.any { !it.estado.terminal }) iniciar() - gerenciador.eventos.collect { if (it == EventoGerenciador.PrimeiraSessao) iniciar() } + // `onSubscription` roda **depois** de a inscrição existir: uma sessão que abre entre conferir as vivas e assinar não se perde. + gerenciador.eventos + .onSubscription { if (gerenciador.resumos.value.any { !it.estado.terminal }) iniciar() } + .collect { if (it == EventoGerenciador.PrimeiraSessao) iniciar() } } } diff --git a/app/src/main/java/com/keyflow/computador/app/MapaTecladoFisico.kt b/app/src/main/java/com/keyflow/computador/app/MapaTecladoFisico.kt new file mode 100644 index 00000000..a5dc6abf --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/MapaTecladoFisico.kt @@ -0,0 +1,177 @@ +package com.keyflow.computador.app + +/** As teclas que o app mapeia sozinho (as outras seguem para o termlib: letras e símbolos sem Ctrl/Alt, com a composição do teclado). */ +sealed interface Tecla { + /** Uma tecla de caractere (`a`..`z` e símbolos), em minúscula. */ + data class Caractere(val c: Char) : Tecla + data object Enter : Tecla + data object Tab : Tecla + data object Esc : Tecla + data object Backspace : Tecla + data object Delete : Tecla + data object Insert : Tecla + data object Cima : Tecla + data object Baixo : Tecla + data object Esquerda : Tecla + data object Direita : Tecla + data object Home : Tecla + data object End : Tecla + data object PageUp : Tecla + data object PageDown : Tecla + data class F(val n: Int) : Tecla { init { require(n in 1..12) } } +} + +data class Modificadores(val ctrl: Boolean = false, val alt: Boolean = false, val shift: Boolean = false) { + val algum: Boolean get() = ctrl || alt || shift + + /** O número do xterm: 1 + shift(1) + alt(2) + ctrl(4). */ + val codigoXterm: Int get() = 1 + (if (shift) 1 else 0) + (if (alt) 2 else 0) + (if (ctrl) 4 else 0) + + operator fun plus(o: Modificadores) = Modificadores(ctrl || o.ctrl, alt || o.alt, shift || o.shift) + + companion object { val Nenhum = Modificadores() } +} + +/** + * O que cada tecla vira em bytes (CMP-26), com as sequências do xterm. **Setas e Home/End** dependem do modo de aplicação (DECCKM, + * `CSI ? 1 h`): `ESC O A` em vez de `ESC [ A`. Com modificador a forma é sempre `ESC [ 1 ; m A` (m = [Modificadores.codigoXterm]). + * Devolve `null` para o que o app **não** mapeia (letra sem Ctrl/Alt, símbolo sem Ctrl/Alt): quem trata é o termlib, que cuida da + * composição do teclado. + */ +object MapaTecladoFisico { + private const val ESC = 0x1b.toByte() + + fun bytes(tecla: Tecla, m: Modificadores = Modificadores.Nenhum, modoAplicacao: Boolean = false): ByteArray? = when (tecla) { + is Tecla.Caractere -> caractere(tecla.c, m) + Tecla.Enter -> comAlt(m, byteArrayOf(0x0d)) + Tecla.Tab -> if (m.shift) csi("Z") else comAlt(m, byteArrayOf(0x09)) + Tecla.Esc -> byteArrayOf(ESC) + Tecla.Backspace -> comAlt(m, byteArrayOf(if (m.ctrl) 0x08 else 0x7f)) + Tecla.Delete -> tilde(3, m) + Tecla.Insert -> tilde(2, m) + Tecla.Cima -> cursor('A', m, modoAplicacao) + Tecla.Baixo -> cursor('B', m, modoAplicacao) + Tecla.Direita -> cursor('C', m, modoAplicacao) + Tecla.Esquerda -> cursor('D', m, modoAplicacao) + Tecla.Home -> cursor('H', m, modoAplicacao) + Tecla.End -> cursor('F', m, modoAplicacao) + Tecla.PageUp -> tilde(5, m) + Tecla.PageDown -> tilde(6, m) + is Tecla.F -> funcao(tecla.n, m) + } + + private fun caractere(c: Char, m: Modificadores): ByteArray? { + if (!m.ctrl && !m.alt) return null + val base: Int = if (m.ctrl) { + when (c.lowercaseChar()) { + in 'a'..'z' -> c.lowercaseChar().code - 'a'.code + 1 + ' ', '2', '@' -> 0 + '[' -> 0x1b + '\\' -> 0x1c + ']' -> 0x1d + '^', '6' -> 0x1e + '_', '/', '-' -> 0x1f + else -> return null + } + } else (if (m.shift) c.uppercaseChar() else c).code + val bytes = if (base < 0x80) byteArrayOf(base.toByte()) else String(Character.toChars(base)).toByteArray(Charsets.UTF_8) + return if (m.alt) byteArrayOf(ESC) + bytes else bytes + } + + private fun comAlt(m: Modificadores, b: ByteArray) = if (m.alt) byteArrayOf(ESC) + b else b + + private fun csi(s: String) = byteArrayOf(ESC, '['.code.toByte()) + s.toByteArray(Charsets.ISO_8859_1) + + private fun cursor(final: Char, m: Modificadores, aplicacao: Boolean): ByteArray = when { + m.algum -> csi("1;${m.codigoXterm}$final") + aplicacao -> byteArrayOf(ESC, 'O'.code.toByte(), final.code.toByte()) + else -> csi(final.toString()) + } + + private fun tilde(n: Int, m: Modificadores): ByteArray = if (m.algum) csi("$n;${m.codigoXterm}~") else csi("$n~") + + private val codigosF = mapOf(5 to 15, 6 to 17, 7 to 18, 8 to 19, 9 to 20, 10 to 21, 11 to 23, 12 to 24) + + private fun funcao(n: Int, m: Modificadores): ByteArray = if (n <= 4) { + val letra = "PQRS"[n - 1] + if (m.algum) csi("1;${m.codigoXterm}$letra") else byteArrayOf(ESC, 'O'.code.toByte(), letra.code.toByte()) + } else tilde(codigosF.getValue(n), m) +} + +/** + * O `aa` da T1: o mesmo `KeyEvent` chegando por dois caminhos (InputConnection e a view). Dois eventos **distintos** (duas teclas iguais + * seguidas) nunca têm o mesmo `downTime`/`eventTime`; o repetir da tecla segurada muda `repeatCount` e `eventTime`. + */ +class DeduplicadorDeTeclas(private val memoria: Int = 8) { + private data class Chave(val acao: Int, val codigo: Int, val downTime: Long, val eventTime: Long, val repeticao: Int) + + private val vistos = ArrayDeque() + + /** `true` se este evento já foi visto (e deve ser ignorado). Registra o que for novo. */ + @Synchronized fun duplicada(acao: Int, codigo: Int, downTime: Long, eventTime: Long, repeticao: Int): Boolean { + val c = Chave(acao, codigo, downTime, eventTime, repeticao) + if (c in vistos) return true + vistos.addLast(c) + while (vistos.size > memoria) vistos.removeFirst() + return false + } +} + +enum class EstadoPegajoso { DESLIGADO, UMA_VEZ, TRAVADO } + +/** + * Ctrl e Alt das teclas extras: um toque vale para a **próxima** tecla; outro toque trava; outro solta. Depois de uma tecla, o "uma vez" se + * desfaz e o travado fica. + */ +class ModificadoresPegajosos { + private var ctrl = EstadoPegajoso.DESLIGADO + private var alt = EstadoPegajoso.DESLIGADO + + @Synchronized fun estadoCtrl() = ctrl + @Synchronized fun estadoAlt() = alt + + @Synchronized fun tocarCtrl() { ctrl = proximo(ctrl) } + @Synchronized fun tocarAlt() { alt = proximo(alt) } + + /** O que vale agora. */ + @Synchronized fun atuais() = Modificadores(ctrl = ctrl != EstadoPegajoso.DESLIGADO, alt = alt != EstadoPegajoso.DESLIGADO) + + /** Depois de usar uma tecla: o "uma vez" some. */ + @Synchronized fun consumir() { + if (ctrl == EstadoPegajoso.UMA_VEZ) ctrl = EstadoPegajoso.DESLIGADO + if (alt == EstadoPegajoso.UMA_VEZ) alt = EstadoPegajoso.DESLIGADO + } + + private fun proximo(e: EstadoPegajoso) = when (e) { + EstadoPegajoso.DESLIGADO -> EstadoPegajoso.UMA_VEZ + EstadoPegajoso.UMA_VEZ -> EstadoPegajoso.TRAVADO + EstadoPegajoso.TRAVADO -> EstadoPegajoso.DESLIGADO + } +} + +/** + * CMP-27/28: texto com **quebra de linha** que chega ao `onKeyboardInput` junto de outros caracteres (ditado da orbe, "colar" do teclado, + * texto sugerido) não vai direto ao servidor: uma quebra executa o comando. O **Enter** do usuário chega sozinho (`\r`), e o que o app mesmo + * colou (já confirmado) passa durante [janelaMs]. Sequências de escape nunca são seguradas. + */ +class GuardaDeLinhas(private val janelaMs: Long = 3_000) { + sealed interface Decisao { + data object Enviar : Decisao + class Segurar(val texto: String) : Decisao + } + + @Volatile private var liberadoAte = 0L + + /** O app vai colar algo já confirmado: o que sair do emulador nos próximos [janelaMs] passa. */ + fun liberar(agoraMs: Long) { liberadoAte = agoraMs + janelaMs } + + fun avaliar(bytes: ByteArray, agoraMs: Long): Decisao { + if (agoraMs < liberadoAte) return Decisao.Enviar + if (bytes.size < 2 || bytes[0].toInt() == 0x1b) return Decisao.Enviar + if (bytes.none { it.toInt() == 0x0a || it.toInt() == 0x0d }) return Decisao.Enviar + val texto = String(bytes, Charsets.UTF_8) + // Várias teclas de controle juntas (não é texto): não seguro. + if (texto.all { it == '\r' || it == '\n' }) return Decisao.Enviar + return Decisao.Segurar(texto) + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/ObservadorDeRede.kt b/app/src/main/java/com/keyflow/computador/app/ObservadorDeRede.kt new file mode 100644 index 00000000..72cc53fe --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/ObservadorDeRede.kt @@ -0,0 +1,26 @@ +package com.keyflow.computador.app + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Há rede? A reconexão só tenta com `true` (CMP-22). Começa `true`: sem informação, deixa a conexão tentar e falhar com o motivo. */ +class ObservadorDeRede(contexto: Context) { + private val _temRede = MutableStateFlow(true) + val temRede: StateFlow = _temRede + + init { + val cm = contexto.applicationContext.getSystemService(ConnectivityManager::class.java) + if (cm != null) { + runCatching { + _temRede.value = cm.activeNetwork != null + cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { _temRede.value = true } + override fun onLost(network: Network) { _temRede.value = cm.activeNetwork != null } + }) + } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/ParserVt.kt b/app/src/main/java/com/keyflow/computador/app/ParserVt.kt new file mode 100644 index 00000000..374aeaf2 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/ParserVt.kt @@ -0,0 +1,193 @@ +package com.keyflow.computador.app + +import java.io.ByteArrayOutputStream + +/** + * Fronteira de sequências conforme o parser.c fixado no fork: C0/DEL não encerram comandos, CAN/SUB cancelam, + * ESC reinicia comandos e, dentro de strings, só ESC \\ vira C1. Nenhum prefixo é entregue antes da decisão. + * O orçamento descarta a sequência inteira até o cancelamento/final; nunca muda para "passar" por excesso. + */ +internal class ParserVt( + private val texto: (Int) -> Unit, + private val sequencia: (Tipo, ByteArray, ByteArray) -> Unit, + private val descarte: () -> Unit, + private val saidaDoEmulador: Boolean = false, +) { + enum class Tipo { ESC, CSI, OSC, DCS, APC, PM, SOS } + private enum class Estado { NORMAL, ESC, CSI_LIDER, CSI_ARGS, CSI_INTERMED, OSC_COMANDO, DCS_COMANDO, STRING } + private var estado = Estado.NORMAL + private var tipo = Tipo.ESC + private val bruto = ByteArrayOutputStream() + private val semControles = ByteArrayOutputStream() + private var excedeu = false + private var escNaString = false + private var utf8 = true + private var continuacoes = 0 + + fun reiniciar() { limpar(); utf8 = true; continuacoes = 0 } + + private fun limpar() { estado = Estado.NORMAL; bruto.reset(); semControles.reset(); excedeu = false; escNaString = false } + private fun guardar(b: Int, semantico: Boolean = true) { + if (excedeu) return + if (bruto.size() >= MAX_SEQUENCIA) { bruto.reset(); semControles.reset(); excedeu = true; return } + bruto.write(b) + if (semantico) semControles.write(b) + } + private fun iniciar(t: Tipo, bytes: IntArray) { + limpar(); tipo = t + estado = when (t) { + Tipo.ESC -> Estado.ESC + Tipo.CSI -> Estado.CSI_LIDER + Tipo.OSC -> Estado.OSC_COMANDO + Tipo.DCS -> Estado.DCS_COMANDO + else -> Estado.STRING + } + for (b in bytes) guardar(b, false) + } + private fun concluir() { + if (excedeu) descarte() else { + val conteudo = semControles.toByteArray() + sequencia(tipo, bruto.toByteArray(), conteudo) + } + limpar() + } + + /** O callback de saída entrega teclas/respostas completas; ESC sozinho e Alt+introduzidor continuam sendo teclas. */ + fun terminarResposta() { + if (estado == Estado.NORMAL) return + val b = bruto.toByteArray() + if (!excedeu && b.size in 1..2 && b[0].toInt() == ESC) b.forEach { texto(it.toInt() and 255) } else descarte() + limpar() + } + + fun processar(bytes: ByteArray) { for (b in bytes) passo(b.toInt() and 255) } + + private fun emString() = estado == Estado.OSC_COMANDO || estado == Estado.DCS_COMANDO || estado == Estado.STRING + private fun comum(b: Int) { if (estado == Estado.NORMAL) texto(b) else guardar(b) } + + private fun passo(b: Int) { + // Um byte de continuação UTF-8 não é um controle C1 (por exemplo, ћ contém 0x9b). + if (!escNaString && utf8 && continuacoes > 0 && b in 0x80..0xbf) { continuacoes--; comum(b); return } + continuacoes = 0 + if (!escNaString && utf8 && (estado == Estado.NORMAL || emString()) && b in 0xc2..0xf4) { + continuacoes = when { b < 0xe0 -> 1; b < 0xf0 -> 2; else -> 3 } + comum(b); return + } + if (b == CAN || b == SUB) { + if (saidaDoEmulador && estado == Estado.ESC && bruto.size() == 1) texto(ESC) + else if (estado != Estado.NORMAL) descarte() + limpar(); texto(b); return + } + if (b == ESC) { + if (emString()) { guardar(b, false); escNaString = true } else { + // Alt+Esc é uma tecla legítima; os prefixos de protocolos continuam retidos/descartados. + if (saidaDoEmulador && estado == Estado.ESC && bruto.size() == 1) texto(ESC) + else if (estado != Estado.NORMAL) descarte() + iniciar(Tipo.ESC, intArrayOf(ESC)) + } + return + } + if (b == 0 || b == 127 || (b < 32 && !(b == BEL && emString()))) { + if (b == ENQ && !saidaDoEmulador) descarte() else if (estado == Estado.NORMAL) texto(b) else guardar(b, false) + return + } + if (escNaString) { + if (b == '\\'.code) { guardar(b, false); concluir(); return } + // No libvterm, ESC [ dentro de uma string CANCELA a string e vira escape '[', não um CSI. + // O prefixo não foi emitido: consumir esse escape evita inventar um CSI no emulador agora em NORMAL. + descarte(); limpar() + if (b in 0x40..0x5f) return + iniciar(Tipo.ESC, intArrayOf(ESC)) + } + if (saidaDoEmulador && estado == Estado.ESC && bruto.size() == 1 && b in 0xc2..0xf4) { + // Alt+caractere Unicode não é uma resposta ANSI; mantém também continuações que parecem C1. + bruto.toByteArray().forEach { texto(it.toInt() and 255) } + limpar(); passo(b); return + } + when (estado) { + Estado.NORMAL -> { + val t = if (!utf8 || saidaDoEmulador) when (b) { + 0x90 -> Tipo.DCS; 0x98 -> Tipo.SOS; 0x9b -> Tipo.CSI + 0x9d -> Tipo.OSC; 0x9e -> Tipo.PM; 0x9f -> Tipo.APC + else -> null + } else null + if (t != null) iniciar(t, intArrayOf(b)) else if (saidaDoEmulador && b in 0x80..0x9f) descarte() else texto(b) + } + Estado.ESC -> { + val t = if (semControles.size() == 0) when (b) { + '['.code -> Tipo.CSI; ']'.code -> Tipo.OSC; 'P'.code -> Tipo.DCS + '_'.code -> Tipo.APC; '^'.code -> Tipo.PM; 'X'.code -> Tipo.SOS + else -> null + } else null + if (t != null) { + // C0 entre ESC e o introduzidor foi segurado e precisa conservar a ordem. + val prefixo = bruto.toByteArray() + val jaExcedeu = excedeu + iniciar(t, intArrayOf()) + excedeu = jaExcedeu + prefixo.forEach { guardar(it.toInt() and 255, false) } + guardar(b, false) + } else { + // Bytes fora de intermediários/final são ignorados pelo libvterm, mantendo in_esc. + guardar(b, semantico = b in 0x20..0x7e) + if (b in 0x30..0x7e) concluir() + } + } + Estado.CSI_LIDER -> if (b in 0x3c..0x3f) guardar(b) else { estado = Estado.CSI_ARGS; csi(b) } + Estado.CSI_ARGS, Estado.CSI_INTERMED -> csi(b) + Estado.OSC_COMANDO -> when { + b in '0'.code..'9'.code -> guardar(b) + b == ';'.code -> { guardar(b); estado = Estado.STRING } + else -> { estado = Estado.STRING; string(b) } + } + Estado.DCS_COMANDO -> { guardar(b); if (b in 0x40..0x7e) estado = Estado.STRING } + Estado.STRING -> string(b) + } + } + private fun csi(b: Int) { + if (estado == Estado.CSI_ARGS && (b in '0'.code..'9'.code || b == ';'.code || b == ':'.code)) { guardar(b); return } + estado = Estado.CSI_INTERMED + when (b) { + in 0x20..0x2f -> guardar(b) + in 0x40..0x7e -> { guardar(b); concluir() } + else -> { descarte(); limpar() } + } + } + private fun string(b: Int) { + if (b == BEL || (b == 0x9c && (!utf8 || saidaDoEmulador))) { guardar(b, false); concluir() } else guardar(b) + } + private companion object { + const val ESC = 27; const val CAN = 24; const val SUB = 26; const val BEL = 7; const val ENQ = 5 + const val MAX_SEQUENCIA = 1_048_576 + } +} + +/** Valores fora da faixa do parser são recusados; não podem dar a volta e virar um comando conhecido. */ +internal fun decimalVt(texto: String): Int? { + val n = texto.trimStart('0').ifEmpty { "0" } + return n.toIntOrNull()?.takeIf { it >= 0 && it < Int.MAX_VALUE } +} + +internal data class CsiVt(val lider: String, val argumentos: List, val intermediarios: String, val final: Char) { + companion object { + private val formato = Regex("^([<=>?]*)([0-9;:]*)([ -/]*)([@-~])$") + fun ler(conteudo: String): CsiVt? { + val m = formato.matchEntire(conteudo) ?: return null + if (m.groupValues[1].length > 1 || m.groupValues[3].length > 15) return null + val partes = m.groupValues[2].split(';', ':') + if (partes.size > 16) return null + val args = partes.map { if (it.isEmpty()) null else decimalVt(it) ?: return null } + return CsiVt(m.groupValues[1], args, m.groupValues[3], m.groupValues[4][0]) + } + } +} + +internal data class OscVt(val comando: Int, val texto: String) { + companion object { + fun ler(conteudo: String): OscVt? { + val n = conteudo.takeWhile { it in '0'..'9' } + val comando = if (n.isEmpty()) -1 else decimalVt(n) ?: return null + return OscVt(comando, conteudo.substring(n.length).removePrefix(";")) + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt b/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt index a309374c..e07b25d7 100644 --- a/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt +++ b/app/src/main/java/com/keyflow/computador/app/ProvedorDeChavesAndroid.kt @@ -69,8 +69,17 @@ class ProvedorDeChavesAndroid( // O estado INVALIDADA é **persistente** (no cofre): sobrevive a reinício do processo e a OTA, e uma chave invalidada não volta a funcionar. private fun marcaInvalidada(id: String) = "$PREFIXO_INVALIDADA$id" /** Leitura incerta = bloqueado (fail-closed): se o cofre não responde, a chave conta como invalidada e não assina. */ - private fun jaInvalidada(id: String): Boolean = try { cofre.ler(marcaInvalidada(id)) != null } catch (e: Exception) { true } - private fun invalidar(id: String) = cofre.gravar(marcaInvalidada(id), "1") + private fun jaInvalidada(id: String): Boolean = id in invalidadasNestaExecucao || try { cofre.ler(marcaInvalidada(id)) != null } catch (e: Exception) { true } + + /** + * Marca a chave como invalidada. Se o cofre não gravar, a marca vale **em memória** nesta execução (a chave continua bloqueada) e a exceção + * não escapa de `estado`/`assinar`: o contrato é "bloqueado, `null`, sem exceção". + */ + private fun invalidar(id: String) { + invalidadasNestaExecucao += id + try { cofre.gravar(marcaInvalidada(id), "1") } catch (e: Exception) { /* só em memória */ } + } + private val invalidadasNestaExecucao: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() private fun novoId() = UUID.randomUUID().toString().replace("-", "").take(8) @@ -136,6 +145,9 @@ class ProvedorDeChavesAndroid( fun assinador(chave: ChaveSsh): Assinador = AssinadorAndroid(chave) + /** A janela de biometria desta chave está aberta agora? Chave sem biometria sempre está. A reconexão automática usa isto para não abrir prompt sozinha. */ + fun janelaAberta(chave: ChaveSsh): Boolean = !chave.exigeBiometria || synchronized(janelas) { janelas[chave.id]?.valida() == true } + /** `INVALIDADA` quando o Keystore invalidou a chave (biometria cadastrada mudou); a tela mostra a mensagem. */ fun estado(chave: ChaveSsh): EstadoChave { if (jaInvalidada(chave.id)) return EstadoChave.INVALIDADA @@ -158,6 +170,7 @@ class ProvedorDeChavesAndroid( envelope.apagar(id) cofre.apagar("$PREFIXO_PUBLICA$id") janelas.remove(id) + invalidadasNestaExecucao -= id cofre.apagar(marcaInvalidada(id)) } diff --git a/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt b/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt index 576e522e..492a0ce4 100644 --- a/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt +++ b/app/src/main/java/com/keyflow/computador/app/RepositorioComputador.kt @@ -2,6 +2,7 @@ package com.keyflow.computador.app import com.keyflow.computador.AutenticacaoHost import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.DadosDaEdicaoDeHost import com.keyflow.computador.Host import com.keyflow.computador.MigracaoHostUnico import com.keyflow.computador.PoliticaAgente @@ -21,18 +22,19 @@ class RepositorioComputador( private val settings: SettingsRepository, private val cofre: CofreDeSegredos, arquivoKnownHosts: File, -) { +) : DadosDaEdicaoDeHost { val confianca: RepositorioDeConfianca = RepositorioKnownHostsArquivo(arquivoKnownHosts) fun hostsFlow(): Flow = settings.hostsFlow() - suspend fun hostsOnce(): ConfigHosts = settings.hostsOnce() - suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts = settings.atualizarHosts(f) + override suspend fun hostsOnce(): ConfigHosts = settings.hostsOnce() + override suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts = settings.atualizarHosts(f) fun chavesFlow(): Flow> = settings.chavesFlow() - suspend fun chavesOnce(): List = settings.chavesOnce() + override suspend fun chavesOnce(): List = settings.chavesOnce() suspend fun atualizarChaves(f: (List) -> List): List = settings.atualizarChaves(f) - fun senhaLembrada(hostId: String): String? = cofre.ler("$PREFIXO_SENHA$hostId") - fun lembrarSenha(hostId: String, senha: String) = cofre.gravar("$PREFIXO_SENHA$hostId", senha) + override fun senhaLembrada(hostId: String): String? = cofre.ler("$PREFIXO_SENHA$hostId") + override fun lembrarSenha(hostId: String, senha: String) = cofre.gravar("$PREFIXO_SENHA$hostId", senha) + override fun esquecerSenha(hostId: String) = cofre.apagar("$PREFIXO_SENHA$hostId") /** * CMP-07: fecha as sessões do host, apaga a entrada do `known_hosts`, a senha lembrada, e tira o host da lista (a marca do agente vai diff --git a/app/src/main/java/com/keyflow/computador/app/TerminalActivity.kt b/app/src/main/java/com/keyflow/computador/app/TerminalActivity.kt new file mode 100644 index 00000000..0f1dfeaa --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/TerminalActivity.kt @@ -0,0 +1,131 @@ +package com.keyflow.computador.app + +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.ResultadoAbertura +import com.keyflow.data.Settings +import com.keyflow.i18n.tr +import com.keyflow.ui.common.collectAsStateOnMain +import com.keyflow.ui.terminal.TerminalScreen +import com.keyflow.ui.theme.KeyFlowTheme +import com.keyflow.ui.theme.LocalKeyFlowPalette +import kotlinx.coroutines.launch + +/** + * A tela do terminal (CMP-24): activity **própria**, `exported=false`, sem anúncio, borda a borda, redimensiona com o teclado. **Não** pede + * `FLAG_SECURE` e nenhuma opção liga (D17): a Visão precisa capturar o terminal; `SemFlagSecureTest` falha se alguém pedir. + * + * `Voltar` só sai da tela: as sessões moram no [ComputadorApp] (e no serviço), não aqui. Fechar a aba é que fecha a sessão. Os emuladores + * também moram fora, então girar o aparelho reencontra o que estava desenhado. + */ +class TerminalActivity : FragmentActivity() { + private val app by lazy { ComputadorApp.de(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val primeira = savedInstanceState == null + val hostIdPedido = intent.getStringExtra(EXTRA_HOST_ID) + setContent { + val settings by remember { app.settings.settingsFlow() }.collectAsStateOnMain(initial = Settings()) + val resumos by app.gerenciador.resumos.collectAsStateOnMain() + val hostsCfg by remember { app.repositorio.hostsFlow() }.collectAsStateOnMain(initial = null) + val hosts = hostsCfg?.hosts.orEmpty() + var ativaId by remember { mutableStateOf(intent.getStringExtra(EXTRA_SESSAO_ID)) } + var aviso by remember { mutableStateOf(null) } + val escopo = rememberCoroutineScope() + KeyFlowTheme(dark = settings.themeDark) { + val palette = LocalKeyFlowPalette.current + val abrir = { host: Host -> + when (val r = app.abrirSessao(host, DonoDaSessao.USUARIO, palette.fg, palette.bg)) { + is ResultadoAbertura.Aberta -> { ativaId = r.sessao.id; aviso = null } + is ResultadoAbertura.Recusada -> aviso = r.motivo.texto() + } + } + // Pedido de abrir um host ao chegar (uma vez só, mesmo depois de girar a tela). + androidx.compose.runtime.LaunchedEffect(hostsCfg, hostIdPedido) { + if (primeira && hostIdPedido != null && ativaId == null) hostsCfg?.porId(hostIdPedido)?.let { abrir(it) } + } + TerminalScreen( + sessoes = resumos, + hosts = hosts, + ativaId = ativaId, + ligacaoDe = { app.emuladores.de(it) }, + aviso = aviso, + onSelecionar = { ativaId = it; aviso = null }, + onFecharAba = { id -> escopo.launch { app.gerenciador.fechar(id); if (ativaId == id) ativaId = null } }, + onNovaSessao = abrir, + onAbrirLink = ::abrirLink, + onCopiar = ::copiar, + lerAreaDeTransferencia = ::lerArea, + modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.union(WindowInsets.ime)), + ) + } + } + } + + override fun onStart() { + super.onStart() + app.atividadeVisivel(this) + } + + override fun onStop() { + app.atividadeVisivel(null) + super.onStop() + } + + @Suppress("DEPRECATION") + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + app.aoFaltarMemoria(level) + } + + private fun abrirLink(url: String) { + // Só chega aqui o que `PoliticaDeLinks` aceitou (http, https, mailto): o resto nunca vira Intent. + val seguro = PoliticaDeLinks.aceitar(url) ?: return + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(seguro)).addCategory(Intent.CATEGORY_BROWSABLE)) + } catch (_: android.content.ActivityNotFoundException) { + Toast.makeText(this, tr("Não achei um app para abrir este link."), Toast.LENGTH_SHORT).show() + } + } + + private fun copiar(texto: String) { + getSystemService(ClipboardManager::class.java).setPrimaryClip(android.content.ClipData.newPlainText("", texto)) + } + + private fun lerArea(): String? = getSystemService(ClipboardManager::class.java).primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(this)?.toString() + + companion object { + const val EXTRA_SESSAO_ID = "sessao_id" + const val EXTRA_HOST_ID = "host_id" + + /** Abre a tela já numa sessão (ou pedindo uma nova para [hostId]). Chamado pela aba Computador. */ + fun intent(contexto: Context, sessaoId: String? = null, hostId: String? = null): Intent = + Intent(contexto, TerminalActivity::class.java).apply { + sessaoId?.let { putExtra(EXTRA_SESSAO_ID, it) } + hostId?.let { putExtra(EXTRA_HOST_ID, it) } + } + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/TestadorDeConexao.kt b/app/src/main/java/com/keyflow/computador/app/TestadorDeConexao.kt new file mode 100644 index 00000000..a866c9c8 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/TestadorDeConexao.kt @@ -0,0 +1,55 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.Host +import com.keyflow.computador.ImpressaoDigital +import com.keyflow.computador.RepositorioDeConfianca +import com.keyflow.computador.chaves.Assinador +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ConexaoCbssh +import com.keyflow.computador.ssh.ConexaoSsh +import com.keyflow.computador.ssh.DestinoSsh +import com.keyflow.computador.ssh.InteracaoComUsuario +import com.keyflow.computador.ssh.ResultadoTeste +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withTimeoutOrNull + +/** + * A confiança **só de leitura** do "Testar conexão" (CMP-08): enxerga o `known_hosts` de verdade (um servidor conhecido com chave diferente + * continua sendo "mudou"), mas o que o teste aceita fica em memória e **morre com ele**. Testar nunca passa a confiar num servidor. + */ +class ConfiancaEfemera(private val base: RepositorioDeConfianca) : RepositorioDeConfianca { + private val aceitas = mutableListOf>() + + override fun chavesConhecidas(host: String, porta: Int): List = + base.chavesConhecidas(host, porta) + aceitas.filter { it.first == host && it.second == porta }.map { it.third } + + @Synchronized override fun confiar(host: String, porta: Int, chave: ChaveDoServidor) { aceitas += Triple(host, porta, chave) } + override fun esquecer(host: String, porta: Int) = Unit +} + +/** O "Testar conexão" **real** (CMP-08): TCP, troca de chaves, verificação do servidor e autenticação, sem abrir o shell. Máximo de 15 s. */ +class TestadorDeConexao( + private val confianca: RepositorioDeConfianca, + private val escopo: CoroutineScope, + private val limiteMs: Long = 15_000, + private val fabrica: (RepositorioDeConfianca, InteracaoComUsuario, CoroutineScope) -> ConexaoSsh = { c, i, e -> ConexaoCbssh(c, i, e) }, +) { + /** + * @param senha a senha **digitada na tela** (ou a lembrada); o teste nunca abre diálogo. Host "perguntar" sem senha falha com [CausaSsh.AUTENTICACAO]. + * @param assinador para host com chave; `null` com host de chave falha por [CausaSsh.CHAVE_NAO_ACEITA]. + */ + suspend fun testar(host: Host, senha: String?, assinador: Assinador?): ResultadoTeste { + val efemera = ConfiancaEfemera(confianca) + val interacao = object : InteracaoComUsuario { + // O teste mostra a impressão digital no resultado; aceitar aqui vale só para esta conexão (confiança efêmera). + override suspend fun confirmarServidor(host: String, porta: Int, algoritmo: String, impressao: ImpressaoDigital) = true + override suspend fun pedirSenha(): String? = senha + override suspend fun pedirCodigo(nome: String, instrucao: String, perguntas: List, eco: List): List? = null + } + val conexao = fabrica(efemera, interacao, escopo) + val destino = DestinoSsh(host, senhaLembrada = senha.takeIf { host.autenticacao !is AutenticacaoHost.Chave }, assinador = assinador, confiarSozinho = true) + return withTimeoutOrNull(limiteMs) { conexao.testar(destino) } ?: ResultadoTeste.Falhou(CausaSsh.TEMPO) + } +} diff --git a/app/src/main/java/com/keyflow/computador/app/TextosComputador.kt b/app/src/main/java/com/keyflow/computador/app/TextosComputador.kt new file mode 100644 index 00000000..b40f0b07 --- /dev/null +++ b/app/src/main/java/com/keyflow/computador/app/TextosComputador.kt @@ -0,0 +1,59 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.MotivoAgente +import com.keyflow.computador.MotivoHost +import com.keyflow.computador.sessao.MotivoRecusa +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.i18n.tr + +/** + * As frases em português do núcleo (`ssh/`, `sessao/`, validações) viram texto da interface **aqui**: cada uma passa por `tr` com o + * literal à mostra (é assim que o `TranslationsTest` as enxerga), e o `when` é exaustivo — enum novo sem frase não compila. + */ +fun CausaSsh.texto(): String? = when (this) { + CausaSsh.DNS -> tr("Não encontrei esse endereço.") + CausaSsh.RECUSADA -> tr("O servidor recusou a conexão.") + CausaSsh.TEMPO -> tr("O servidor não respondeu a tempo.") + CausaSsh.REDE -> tr("Sem conexão de rede.") + CausaSsh.SERVIDOR_NAO_CONFIADO -> null + CausaSsh.SERVIDOR_PEDE_CONFIRMACAO -> tr("Confirme o servidor no aplicativo.") + CausaSsh.CHAVE_DO_SERVIDOR_MUDOU -> tr("A identificação deste servidor mudou.") + CausaSsh.AUTENTICACAO -> tr("Usuário ou senha incorretos.") + CausaSsh.CHAVE_NAO_ACEITA -> tr("O servidor não aceitou esta chave. Autorize a chave pública no servidor.") + CausaSsh.SEM_ALGORITMO -> tr("Este servidor só oferece algoritmos antigos que o KeyFlow não usa.") + CausaSsh.BIOMETRIA -> tr("Autenticação cancelada.") + CausaSsh.BIOMETRIA_INVALIDADA -> tr("A chave foi invalidada pela biometria.") + CausaSsh.DESCONECTADO -> tr("O servidor encerrou a conexão.") + CausaSsh.ENCERRADA_PELO_SISTEMA -> tr("O Android encerrou a sessão.") + CausaSsh.PROTOCOLO -> tr("Falha na comunicação com o servidor.") +} + +fun MotivoRecusa.texto(): String = when (this) { + MotivoRecusa.LIMITE_TOTAL -> tr("O limite é de 8 sessões abertas. Feche uma para abrir outra.") + MotivoRecusa.LIMITE_DO_AGENTE -> tr("O agente já tem 3 sessões abertas.") +} +fun MotivoHost.texto(): String = when (this) { + MotivoHost.NOME_VAZIO -> tr("Dê um nome ao servidor.") + MotivoHost.NOME_LONGO -> tr("O nome pode ter no máximo 40 caracteres.") + MotivoHost.NOME_REPETIDO -> tr("Já existe um servidor com esse nome.") + MotivoHost.ENDERECO_VAZIO -> tr("Informe o endereço do servidor.") + MotivoHost.ENDERECO_LONGO -> tr("O endereço pode ter no máximo 253 caracteres.") + MotivoHost.ENDERECO_INVALIDO -> tr("O endereço não é um nome, um IPv4 nem um IPv6 válido.") + MotivoHost.IPV6_SEM_COLCHETES -> tr("Endereço IPv6 vai entre colchetes, por exemplo [::1].") + MotivoHost.PORTA_INVALIDA -> tr("A porta vai de 1 a 65535.") + MotivoHost.USUARIO_VAZIO -> tr("Informe o usuário.") + MotivoHost.USUARIO_LONGO -> tr("O usuário pode ter no máximo 64 caracteres.") + MotivoHost.USUARIO_INVALIDO -> tr("O usuário não pode ter espaços nem caracteres de controle.") + MotivoHost.CHAVE_NAO_GUARDADA -> tr("Escolha uma chave guardada.") + MotivoHost.SENHA_NAO_INFORMADA -> tr("Informe uma senha para guardá-la neste aparelho.") + MotivoHost.SENHA_NAO_GUARDADA -> tr("Não foi possível guardar a senha neste aparelho.") + MotivoHost.LISTA_CHEIA -> tr("O limite é de 50 servidores.") +} + +fun MotivoAgente.texto(): String = when (this) { + MotivoAgente.PERGUNTA_SEMPRE -> tr("Este servidor pede a autenticação na hora; o agente não tem como digitá-la. Guarde uma chave ou a senha.") + MotivoAgente.SENHA_NAO_LEMBRADA -> tr("A senha não está guardada. Ligue \"lembrar senha\" para o agente poder usar este servidor.") + MotivoAgente.CHAVE_NAO_GUARDADA -> tr("Escolha uma chave guardada.") + MotivoAgente.SENHA_INDISPONIVEL -> tr("Informe uma senha para guardá-la neste aparelho.") + MotivoAgente.CHAVE_COM_PASSPHRASE -> tr("Esta chave pede a senha dela na hora; o agente não pode usá-la.") +} diff --git a/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt b/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt index ca784d21..9244241a 100644 --- a/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt +++ b/app/src/main/java/com/keyflow/computador/sessao/GerenciadorDeSessoes.kt @@ -83,6 +83,13 @@ class GerenciadorDeSessoes( suspend fun fecharDoHost(hostId: String) = copia().filter { it.host.id == hostId }.forEach { fechar(it.id, MotivoEncerramento.HOST_REMOVIDO) } + /** CMP-09: o agente deixou de poder usar o host. Fecha as sessões **do agente** nele e desfaz o compartilhamento das do usuário. */ + suspend fun fecharDoAgenteNoHost(hostId: String) { + copia().filter { it.host.id == hostId && it.compartilhada && it.dono == DonoDaSessao.USUARIO }.forEach { it.compartilhada = false } + copia().filter { it.host.id == hostId && it.dono == DonoDaSessao.AGENTE }.forEach { fechar(it.id, MotivoEncerramento.AGENTE_PARADO) } + publicar() + } + suspend fun fecharDoAgente() = copia().filter { it.dono == DonoDaSessao.AGENTE }.forEach { fechar(it.id, MotivoEncerramento.AGENTE_PARADO) } suspend fun fecharTodas() = copia().forEach { fechar(it.id) } @@ -104,7 +111,11 @@ class GerenciadorDeSessoes( private var vivasAntes = 0 - /** Publica a lista e emite `PrimeiraSessao`/`SemSessoes` quando o número de sessões **vivas** (não terminais) passa por zero. */ + /** + * Publica a lista e emite `PrimeiraSessao`/`SemSessoes` quando o número de sessões **vivas** (não terminais) passa por zero. + * Contrato dos coletores de [resumos] e [eventos]: **não podem bloquear** nem chamar o gerenciador de volta de forma síncrona (a publicação + * roda sob a trava; um coletor que bloqueia segura todas as sessões, e um que reentra no gerenciador espera por si mesmo). + */ private fun publicar() { // Tudo sob a trava: o resumo gravado é sempre o do instante em que a lista foi lida (duas publicações simultâneas não se cruzam, e // o último `_resumos.value` nunca é de uma lista mais velha). `tryEmit` não suspende, então segurar a trava aqui é seguro. diff --git a/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt b/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt index 0b1019d6..acf59c28 100644 --- a/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt +++ b/app/src/main/java/com/keyflow/computador/ssh/ConexaoCbssh.kt @@ -35,7 +35,6 @@ import org.connectbot.sshlib.PublicKey import org.connectbot.sshlib.SshClient import org.connectbot.sshlib.SshClientConfig import org.connectbot.sshlib.SshSession -import org.connectbot.sshlib.transport.IpVersion import org.connectbot.sshlib.transport.KtorTcpTransportFactory import org.connectbot.sshlib.transport.TransportFactory import kotlinx.coroutines.withTimeout @@ -187,7 +186,7 @@ class ConexaoCbssh( this.handshakeTimeoutMs = 0 ajustar(this) // O limite do TCP vai por cima do transporte escolhido (o padrão do cbssh, ou o do teste): conectar o socket tem 15 s. - val base = this.transportFactory ?: KtorTcpTransportFactory(endereco, h.porta, IpVersion.AUTO) + val base = this.transportFactory ?: KtorTcpTransportFactory(endereco, h.porta, this.ipVersion) this.transportFactory = TransportFactory { withTimeout(tempoTcpMs) { base.create() } } } val c = SshClient(config) @@ -229,9 +228,13 @@ class ConexaoCbssh( private suspend fun conectarComLimite(c: SshClient, estado: EstadoDaConexao): ConnectResult = coroutineScope { val tarefa = async { c.connect() } val relogio = launch { + // O relógio de 45 s (TCP + troca de chaves) só conta enquanto o usuário **não** está decidindo a impressão digital. + val passo = 250L + var contado = 0L while (isActive) { - delay(tempoTcpMs + tempoTrocaDeChavesMs) - if (!estado.aguardandoUsuario.get()) { estado.estourou = true; tarefa.cancel(); return@launch } + delay(passo) + if (!estado.aguardandoUsuario.get()) contado += passo + if (contado >= tempoTcpMs + tempoTrocaDeChavesMs) { estado.estourou = true; tarefa.cancel(); return@launch } } } try { diff --git a/app/src/main/java/com/keyflow/i18n/en/Computador.kt b/app/src/main/java/com/keyflow/i18n/en/Computador.kt index 419b5864..88a3a01a 100644 --- a/app/src/main/java/com/keyflow/i18n/en/Computador.kt +++ b/app/src/main/java/com/keyflow/i18n/en/Computador.kt @@ -4,8 +4,121 @@ package com.keyflow.i18n.en internal val Computador: Map = mapOf( "%d sessão SSH aberta" to "%d SSH session open", "%d sessões SSH abertas" to "%d SSH sessions open", + "+ Novo host" to "+ New host", + "A chave foi invalidada pela biometria." to "The key was invalidated by biometrics.", + "A identificação deste servidor mudou." to "This server's identity has changed.", + "A lista de sessões chega na próxima etapa." to "The session list arrives in the next step.", + "A porta vai de 1 a 65535." to "The port ranges from 1 to 65535.", + "A senha não está guardada. Ligue \"lembrar senha\" para o agente poder usar este servidor." to "The password is not saved. Turn on \"remember password\" so the agent can use this server.", + "A senha é pedida a cada conexão." to "The password is asked on every connection.", + "Abrir" to "Open", + "Abrir este link?" to "Open this link?", + "Agente %d" to "Agent %d", + "Agente: não" to "Agent: no", + "Agente: permitido" to "Agent: allowed", + "Aguardando a confirmação do servidor…" to "Waiting for server confirmation…", + "Ainda não confiei neste servidor." to "I don't trust this server yet.", + "Alt: um toque vale para a próxima tecla, dois travam" to "Alt: one tap applies to the next key, two lock it", + "As chaves SSH ganham a própria seção na próxima etapa." to "SSH keys get their own section in the next step.", + "As sessões abertas deste servidor fecham e a senha guardada é apagada. A chave não é apagada." to "Open sessions on this server close and the saved password is erased. The key is not erased.", + "Autenticando…" to "Authenticating…", + "Autenticação" to "Authentication", + "Autenticação cancelada." to "Authentication cancelled.", + "Barra vertical" to "Vertical bar", + "Cadastre um servidor na aba Computador." to "Add a server in the Computer tab.", + "Caiu: %s" to "Dropped: %s", + "Cancelar o teste" to "Cancel the test", + "Chave" to "Key", + "Chaves" to "Keys", + "Colar %d linhas?" to "Paste %d lines?", + "Conectado" to "Connected", + "Conexão cancelada" to "Connection cancelled", + "Confirme o servidor no aplicativo." to "Confirm the server in the app.", + "Ctrl: um toque vale para a próxima tecla, dois travam" to "Ctrl: one tap applies to the next key, two lock it", + "Desconectado" to "Disconnected", "Desconectar todas" to "Disconnect all", + "Desligado" to "Off", + "Dê um nome ao servidor." to "Give the server a name.", + "Elas podem ser executadas." to "They may be executed.", + "Endereço" to "Address", + "Endereço IPv6 vai entre colchetes, por exemplo [::1]." to "IPv6 addresses go in brackets, for example [::1].", + "Escolha uma chave guardada." to "Choose a saved key.", + "Esquecer servidor" to "Forget server", + "Esta chave pede a senha dela na hora; o agente não pode usá-la." to "This key asks for its passphrase on the spot; the agent cannot use it.", + "Este servidor pede a autenticação na hora; o agente não tem como digitá-la. Guarde uma chave ou a senha." to "This server asks for authentication on the spot; the agent cannot type it. Save a key or the password.", + "Este servidor só oferece algoritmos antigos que o KeyFlow não usa." to "This server only offers old algorithms that KeyFlow does not use.", + "Excluir %s?" to "Delete %s?", + "Excluir host" to "Delete host", + "Falha na comunicação com o servidor." to "Communication with the server failed.", + "Fechar a sessão %s" to "Close session %s", + "Guarda a senha cifrada neste aparelho. Sem isso, o agente não pode usar este servidor." to "Saves the password encrypted on this device. Without it, the agent cannot use this server.", + "Host" to "Host", + "Hosts" to "Hosts", + "Hífen" to "Hyphen", + "Imagens no terminal" to "Images in the terminal", + "Informe o endereço do servidor." to "Enter the server address.", + "Informe o usuário." to "Enter the username.", + "Informe uma senha para guardá-la neste aparelho." to "Enter a password to save it on this device.", + "Já existe um servidor com esse nome." to "A server with that name already exists.", + "Lembrar a senha" to "Remember the password", + "Liberar este servidor para o agente?" to "Allow the agent to use this server?", + "Link bloqueado" to "Link blocked", + "Mostrar ou esconder as teclas F1 a F12" to "Show or hide the F1 to F12 keys", + "Mudar o endereço ou a porta pede uma nova confirmação da impressão digital na próxima conexão." to "Changing the address or port asks for a new fingerprint confirmation on the next connection.", + "Nenhum servidor ainda. Cadastre o primeiro para abrir um terminal." to "No servers yet. Add the first one to open a terminal.", + "Nenhuma chave guardada. Gere ou importe uma na seção Chaves." to "No saved key. Generate or import one in the Keys section.", + "Nenhuma sessão aberta. Toque em + para abrir uma." to "No open session. Tap + to open one.", + "Nova sessão" to "New session", + "Novo host" to "New host", + "Não achei um app para abrir este link." to "I couldn't find an app to open this link.", + "Não encontrei esse endereço." to "I couldn't find that address.", + "Não foi possível guardar a senha neste aparelho." to "Could not save the password on this device.", + "O Android encerrou a sessão." to "Android ended the session.", + "O KeyFlow só abre links http, https e mailto." to "KeyFlow only opens http, https and mailto links.", + "O agente já tem 3 sessões abertas." to "The agent already has 3 open sessions.", + "O agente poderá abrir terminais neste servidor e ler o texto da tela; esse texto é enviado ao provedor do modelo." to "The agent will be able to open terminals on this server and read the screen text; that text is sent to the model provider.", + "O agente só age em servidores marcados e em sessões dele." to "The agent only acts on marked servers and in its own sessions.", + "O endereço não é um nome, um IPv4 nem um IPv6 válido." to "The address is not a valid name, IPv4 or IPv6.", + "O endereço pode ter no máximo 253 caracteres." to "The address can have at most 253 characters.", + "O limite é de 50 servidores." to "The limit is 50 servers.", + "O limite é de 8 sessões abertas. Feche uma para abrir outra." to "The limit is 8 open sessions. Close one to open another.", + "O nome pode ter no máximo 40 caracteres." to "The name can have at most 40 characters.", + "O servidor encerrou a conexão." to "The server closed the connection.", + "O servidor não aceitou esta chave. Autorize a chave pública no servidor." to "The server did not accept this key. Authorize the public key on the server.", + "O servidor não respondeu a tempo." to "The server did not respond in time.", + "O servidor pediu para colocar %d caracteres na área de transferência. Só permita se foi você quem pediu." to "The server asked to put %d characters on the clipboard. Only allow it if you asked for it.", + "O servidor quer copiar texto" to "The server wants to copy text", + "O servidor recusou a conexão." to "The server refused the connection.", + "O usuário não pode ter espaços nem caracteres de controle." to "The username cannot have spaces or control characters.", + "O usuário pode ter no máximo 64 caracteres." to "The username can have at most 64 characters.", + "OK" to "OK", "Parar o agente" to "Stop the agent", + "Pergunta sempre" to "Always ask", + "Pode ser usado pelo agente" to "Can be used by the agent", + "Reconectando…" to "Reconnecting…", + "Reconectando… (tentativa %d)" to "Reconnecting… (attempt %d)", + "Salvar" to "Save", + "Servidor" to "Server", + "Servidor confirmado. Impressão digital %s (%s). Autenticação: %s. Latência: %d ms." to "Server confirmed. Fingerprint %s (%s). Authentication: %s. Latency: %d ms.", + "Servidor conhecido" to "Known server", + "Sessão encerrada" to "Session ended", + "Sessões" to "Sessions", "Sessões SSH mantidas conectadas em segundo plano" to "SSH sessions kept connected in the background", "Sessões do Computador" to "Computer sessions", + "Seta para a direita" to "Right arrow", + "Seta para a esquerda" to "Left arrow", + "Seta para baixo" to "Down arrow", + "Seta para cima" to "Up arrow", + "Tecla Esc" to "Esc key", + "Tecla F%d" to "F%d key", + "Tecla Tab" to "Tab key", + "Tecla barra" to "Slash key", + "Terminal %d" to "Terminal %d", + "Testando a conexão…" to "Testing the connection…", + "Testar conexão" to "Test connection", + "Til" to "Tilde", + "Toque para reconectar" to "Tap to reconnect", + "Usuário ou senha incorretos." to "Wrong username or password.", + "ligada para a próxima tecla" to "on for the next key", + "travada" to "locked", ) diff --git a/app/src/main/java/com/keyflow/i18n/en/Fluxos.kt b/app/src/main/java/com/keyflow/i18n/en/Fluxos.kt index b94fa5d5..d99a8590 100644 --- a/app/src/main/java/com/keyflow/i18n/en/Fluxos.kt +++ b/app/src/main/java/com/keyflow/i18n/en/Fluxos.kt @@ -11,24 +11,18 @@ internal val Fluxos: Map = mapOf( "Adicione ao menos uma etapa" to "Add at least one step", "Agente" to "Agent", "Algo deu errado nesta etapa." to "Something went wrong in this step.", - "Autenticação SSH" to "SSH authentication", "Barra" to "Bar", "Botão do teclado" to "Keyboard button", "Capturar" to "Capture", "Carregando fluxos…" to "Loading flows…", - "Chave Privada" to "Private Key", - "Chave privada OpenSSH" to "OpenSSH private key", "Complete as etapas para rodar" to "Complete the steps to run", "Complete o fluxo para ligar atalhos" to "Complete the flow to turn on shortcuts", "Conceda permissão de sobreposição para capturar a tela." to "Allow display over other apps to capture the screen.", "Conecte na aba %s" to "Connect in the %s tab", - "Conexão SSH" to "SSH Connection", - "Configuração SSH válida. Pronto para conectar." to "Valid SSH configuration. Ready to connect.", "Da aba" to "From the tab", "Desligar" to "Turn off", "Desligue o atalho e a ação volta." to "Turn the shortcut off and the action comes back.", "Ditar" to "Dictate", - "Em breve você poderá executar scripts, ferramentas de linha de comando e automações locais diretamente no aparelho." to "Soon you will be able to run scripts, command-line tools, and local automations directly on the device.", "Em execução · %s" to "Running · %s", "Entrada" to "Input", "Esta etapa não existe nesta versão do app." to "This step doesn't exist in this version of the app.", @@ -44,10 +38,8 @@ internal val Fluxos: Map = mapOf( "Fluxo incompleto: abra a aba Fluxos" to "Incomplete flow: open the Flows tab", "Fluxo: %s" to "Flow: %s", "Gesto: %d toques" to "Gesture: %d taps", - "Host / Endereço IP" to "Host / IP address", "Incompleto" to "Incomplete", "Indisponível: %s" to "Unavailable: %s", - "Informe o host e usuário para testar." to "Enter host and user to test.", "Inserir" to "Insert", "Instrução (vazio: ler o texto)" to "Instruction (empty: read the text)", "Instrução do sistema (opcional)" to "System instruction (optional)", @@ -67,7 +59,6 @@ internal val Fluxos: Map = mapOf( "Modelo ausente: escolha outro ou instale de novo" to "Model missing: pick another or install it again", "Modelo indisponível" to "Model unavailable", "Modelo · %s" to "Model · %s", - "Modo de execução" to "Execution mode", "Mover a etapa %d, %s, para baixo" to "Move step %d, %s, down", "Mover a etapa %d, %s, para cima" to "Move step %d, %s, up", "Mover para baixo" to "Move down", @@ -84,7 +75,6 @@ internal val Fluxos: Map = mapOf( "O PaddleOCR só lê o texto da imagem: a instrução desta etapa é ignorada." to "PaddleOCR only reads the text in the image: this step's instruction is ignored.", "O fluxo parou em “%s”: %s" to "The flow stopped at “%s”: %s", "O resultado será descartado: termine com Falar, Inserir ou Agente" to "The result will be discarded: end with Speak, Insert or Agent", - "O suporte a shell local está em análise e desenvolvimento para integração com ambiente Linux embarcado." to "Local shell support is under analysis and development for integration with embedded Linux environment.", "Orbe flutuante" to "Floating orb", "Os fluxos padrão já estão como no original" to "The default flows already match the originals", "Ouvir e responder" to "Listen and answer", @@ -100,19 +90,15 @@ internal val Fluxos: Map = mapOf( "Restaurar fluxos padrão" to "Restore default flows", "Roda o fluxo da aba Fluxos neste gesto, no lugar da ação de sempre." to "Runs the flow from the Flows tab on this gesture, instead of the usual action.", "Rodar agora" to "Run now", - "SSH Remoto" to "Remote SSH", "Saída" to "Output", "Sem atalho" to "No shortcut", "Sem espaço (limite de 20): %s" to "No space (limit of 20): %s", "Senha" to "Password", "Será recriado: %s" to "Will be recreated: %s", "Será reescrito: %s" to "Will be reset: %s", - "Shell Local" to "Local Shell", "Sugestão: %s" to "Suggestion: %s", "Só pode ser a primeira etapa" to "Can only be the first step", "Tempo esgotado." to "Timed out.", - "Testando conexão…" to "Testing connection…", - "Testar conexão SSH" to "Test SSH connection", "Toque em “+ Etapa” para começar. Ditar e Capturar abrem o fluxo." to "Tap “+ Step” to start. Dictate and Capture open the flow.", "Usuário" to "User", "Você ainda não tem fluxos. Crie um com “+” ou restaure os padrão." to "You have no flows yet. Create one with “+” or restore the defaults.", diff --git a/app/src/main/java/com/keyflow/i18n/es/Computador.kt b/app/src/main/java/com/keyflow/i18n/es/Computador.kt index 9fa916f9..c279227e 100644 --- a/app/src/main/java/com/keyflow/i18n/es/Computador.kt +++ b/app/src/main/java/com/keyflow/i18n/es/Computador.kt @@ -4,8 +4,121 @@ package com.keyflow.i18n.es internal val Computador: Map = mapOf( "%d sessão SSH aberta" to "%d sesión SSH abierta", "%d sessões SSH abertas" to "%d sesiones SSH abiertas", + "+ Novo host" to "+ Nuevo host", + "A chave foi invalidada pela biometria." to "La clave fue invalidada por la biometría.", + "A identificação deste servidor mudou." to "La identidad de este servidor cambió.", + "A lista de sessões chega na próxima etapa." to "La lista de sesiones llega en el siguiente paso.", + "A porta vai de 1 a 65535." to "El puerto va de 1 a 65535.", + "A senha não está guardada. Ligue \"lembrar senha\" para o agente poder usar este servidor." to "La contraseña no está guardada. Activa \"recordar contraseña\" para que el agente pueda usar este servidor.", + "A senha é pedida a cada conexão." to "La contraseña se pide en cada conexión.", + "Abrir" to "Abrir", + "Abrir este link?" to "¿Abrir este enlace?", + "Agente %d" to "Agente %d", + "Agente: não" to "Agente: no", + "Agente: permitido" to "Agente: permitido", + "Aguardando a confirmação do servidor…" to "Esperando la confirmación del servidor…", + "Ainda não confiei neste servidor." to "Aún no confío en este servidor.", + "Alt: um toque vale para a próxima tecla, dois travam" to "Alt: un toque vale para la siguiente tecla, dos la bloquean", + "As chaves SSH ganham a própria seção na próxima etapa." to "Las claves SSH tendrán su propia sección en el siguiente paso.", + "As sessões abertas deste servidor fecham e a senha guardada é apagada. A chave não é apagada." to "Las sesiones abiertas de este servidor se cierran y la contraseña guardada se borra. La clave no se borra.", + "Autenticando…" to "Autenticando…", + "Autenticação" to "Autenticación", + "Autenticação cancelada." to "Autenticación cancelada.", + "Barra vertical" to "Barra vertical", + "Cadastre um servidor na aba Computador." to "Registra un servidor en la pestaña Computadora.", + "Caiu: %s" to "Se cayó: %s", + "Cancelar o teste" to "Cancelar la prueba", + "Chave" to "Clave", + "Chaves" to "Claves", + "Colar %d linhas?" to "¿Pegar %d líneas?", + "Conectado" to "Conectado", + "Conexão cancelada" to "Conexión cancelada", + "Confirme o servidor no aplicativo." to "Confirma el servidor en la aplicación.", + "Ctrl: um toque vale para a próxima tecla, dois travam" to "Ctrl: un toque vale para la siguiente tecla, dos la bloquean", + "Desconectado" to "Desconectado", "Desconectar todas" to "Desconectar todas", + "Desligado" to "Apagado", + "Dê um nome ao servidor." to "Ponle un nombre al servidor.", + "Elas podem ser executadas." to "Podrían ejecutarse.", + "Endereço" to "Dirección", + "Endereço IPv6 vai entre colchetes, por exemplo [::1]." to "Las direcciones IPv6 van entre corchetes, por ejemplo [::1].", + "Escolha uma chave guardada." to "Elige una clave guardada.", + "Esquecer servidor" to "Olvidar servidor", + "Esta chave pede a senha dela na hora; o agente não pode usá-la." to "Esta clave pide su contraseña en el momento; el agente no puede usarla.", + "Este servidor pede a autenticação na hora; o agente não tem como digitá-la. Guarde uma chave ou a senha." to "Este servidor pide la autenticación en el momento; el agente no puede escribirla. Guarda una clave o la contraseña.", + "Este servidor só oferece algoritmos antigos que o KeyFlow não usa." to "Este servidor solo ofrece algoritmos antiguos que KeyFlow no usa.", + "Excluir %s?" to "¿Eliminar %s?", + "Excluir host" to "Eliminar host", + "Falha na comunicação com o servidor." to "Falló la comunicación con el servidor.", + "Fechar a sessão %s" to "Cerrar la sesión %s", + "Guarda a senha cifrada neste aparelho. Sem isso, o agente não pode usar este servidor." to "Guarda la contraseña cifrada en este dispositivo. Sin esto, el agente no puede usar este servidor.", + "Host" to "Host", + "Hosts" to "Hosts", + "Hífen" to "Guion", + "Imagens no terminal" to "Imágenes en la terminal", + "Informe o endereço do servidor." to "Indica la dirección del servidor.", + "Informe o usuário." to "Indica el usuario.", + "Informe uma senha para guardá-la neste aparelho." to "Introduce una contraseña para guardarla en este dispositivo.", + "Já existe um servidor com esse nome." to "Ya existe un servidor con ese nombre.", + "Lembrar a senha" to "Recordar la contraseña", + "Liberar este servidor para o agente?" to "¿Permitir que el agente use este servidor?", + "Link bloqueado" to "Enlace bloqueado", + "Mostrar ou esconder as teclas F1 a F12" to "Mostrar u ocultar las teclas F1 a F12", + "Mudar o endereço ou a porta pede uma nova confirmação da impressão digital na próxima conexão." to "Cambiar la dirección o el puerto pide una nueva confirmación de la huella en la próxima conexión.", + "Nenhum servidor ainda. Cadastre o primeiro para abrir um terminal." to "Aún no hay servidores. Registra el primero para abrir una terminal.", + "Nenhuma chave guardada. Gere ou importe uma na seção Chaves." to "No hay claves guardadas. Genera o importa una en la sección Claves.", + "Nenhuma sessão aberta. Toque em + para abrir uma." to "Ninguna sesión abierta. Toca + para abrir una.", + "Nova sessão" to "Nueva sesión", + "Novo host" to "Nuevo host", + "Não achei um app para abrir este link." to "No encontré una app para abrir este enlace.", + "Não encontrei esse endereço." to "No encontré esa dirección.", + "Não foi possível guardar a senha neste aparelho." to "No se pudo guardar la contraseña en este dispositivo.", + "O Android encerrou a sessão." to "Android terminó la sesión.", + "O KeyFlow só abre links http, https e mailto." to "KeyFlow solo abre enlaces http, https y mailto.", + "O agente já tem 3 sessões abertas." to "El agente ya tiene 3 sesiones abiertas.", + "O agente poderá abrir terminais neste servidor e ler o texto da tela; esse texto é enviado ao provedor do modelo." to "El agente podrá abrir terminales en este servidor y leer el texto de la pantalla; ese texto se envía al proveedor del modelo.", + "O agente só age em servidores marcados e em sessões dele." to "El agente solo actúa en servidores marcados y en sus propias sesiones.", + "O endereço não é um nome, um IPv4 nem um IPv6 válido." to "La dirección no es un nombre, IPv4 ni IPv6 válido.", + "O endereço pode ter no máximo 253 caracteres." to "La dirección puede tener como máximo 253 caracteres.", + "O limite é de 50 servidores." to "El límite es de 50 servidores.", + "O limite é de 8 sessões abertas. Feche uma para abrir outra." to "El límite es de 8 sesiones abiertas. Cierra una para abrir otra.", + "O nome pode ter no máximo 40 caracteres." to "El nombre puede tener como máximo 40 caracteres.", + "O servidor encerrou a conexão." to "El servidor cerró la conexión.", + "O servidor não aceitou esta chave. Autorize a chave pública no servidor." to "El servidor no aceptó esta clave. Autoriza la clave pública en el servidor.", + "O servidor não respondeu a tempo." to "El servidor no respondió a tiempo.", + "O servidor pediu para colocar %d caracteres na área de transferência. Só permita se foi você quem pediu." to "El servidor pidió poner %d caracteres en el portapapeles. Permítelo solo si lo pediste tú.", + "O servidor quer copiar texto" to "El servidor quiere copiar texto", + "O servidor recusou a conexão." to "El servidor rechazó la conexión.", + "O usuário não pode ter espaços nem caracteres de controle." to "El usuario no puede tener espacios ni caracteres de control.", + "O usuário pode ter no máximo 64 caracteres." to "El usuario puede tener como máximo 64 caracteres.", + "OK" to "OK", "Parar o agente" to "Detener al agente", + "Pergunta sempre" to "Preguntar siempre", + "Pode ser usado pelo agente" to "Puede usarlo el agente", + "Reconectando…" to "Reconectando…", + "Reconectando… (tentativa %d)" to "Reconectando… (intento %d)", + "Salvar" to "Guardar", + "Servidor" to "Servidor", + "Servidor confirmado. Impressão digital %s (%s). Autenticação: %s. Latência: %d ms." to "Servidor confirmado. Huella %s (%s). Autenticación: %s. Latencia: %d ms.", + "Servidor conhecido" to "Servidor conocido", + "Sessão encerrada" to "Sesión terminada", + "Sessões" to "Sesiones", "Sessões SSH mantidas conectadas em segundo plano" to "Sesiones SSH mantenidas conectadas en segundo plano", "Sessões do Computador" to "Sesiones del Computador", + "Seta para a direita" to "Flecha derecha", + "Seta para a esquerda" to "Flecha izquierda", + "Seta para baixo" to "Flecha abajo", + "Seta para cima" to "Flecha arriba", + "Tecla Esc" to "Tecla Esc", + "Tecla F%d" to "Tecla F%d", + "Tecla Tab" to "Tecla Tab", + "Tecla barra" to "Tecla barra", + "Terminal %d" to "Terminal %d", + "Testando a conexão…" to "Probando la conexión…", + "Testar conexão" to "Probar conexión", + "Til" to "Tilde", + "Toque para reconectar" to "Toca para reconectar", + "Usuário ou senha incorretos." to "Usuario o contraseña incorrectos.", + "ligada para a próxima tecla" to "activada para la siguiente tecla", + "travada" to "bloqueada", ) diff --git a/app/src/main/java/com/keyflow/i18n/es/Fluxos.kt b/app/src/main/java/com/keyflow/i18n/es/Fluxos.kt index e883ed86..042a6b2a 100644 --- a/app/src/main/java/com/keyflow/i18n/es/Fluxos.kt +++ b/app/src/main/java/com/keyflow/i18n/es/Fluxos.kt @@ -11,24 +11,18 @@ internal val Fluxos: Map = mapOf( "Adicione ao menos uma etapa" to "Añade al menos un paso", "Agente" to "Agente", "Algo deu errado nesta etapa." to "Algo salió mal en este paso.", - "Autenticação SSH" to "Autenticación SSH", "Barra" to "Barra", "Botão do teclado" to "Botón del teclado", "Capturar" to "Capturar", "Carregando fluxos…" to "Cargando flujos…", - "Chave Privada" to "Clave privada", - "Chave privada OpenSSH" to "Clave privada OpenSSH", "Complete as etapas para rodar" to "Completa los pasos para ejecutar", "Complete o fluxo para ligar atalhos" to "Completa el flujo para activar atajos", "Conceda permissão de sobreposição para capturar a tela." to "Permite mostrar sobre otras aplicaciones para capturar la pantalla.", "Conecte na aba %s" to "Conecta en la pestaña %s", - "Conexão SSH" to "Conexión SSH", - "Configuração SSH válida. Pronto para conectar." to "Configuración SSH válida. Listo para conectar.", "Da aba" to "De la pestaña", "Desligar" to "Desactivar", "Desligue o atalho e a ação volta." to "Desactiva el atajo y la acción vuelve.", "Ditar" to "Dictar", - "Em breve você poderá executar scripts, ferramentas de linha de comando e automações locais diretamente no aparelho." to "Pronto podrá ejecutar scripts, herramientas de línea de comandos y automatizaciones locales directamente en el dispositivo.", "Em execução · %s" to "En ejecución · %s", "Entrada" to "Entrada", "Esta etapa não existe nesta versão do app." to "Este paso no existe en esta versión de la app.", @@ -44,10 +38,8 @@ internal val Fluxos: Map = mapOf( "Fluxo incompleto: abra a aba Fluxos" to "Flujo incompleto: abre la pestaña Flujos", "Fluxo: %s" to "Flujo: %s", "Gesto: %d toques" to "Gesto: %d toques", - "Host / Endereço IP" to "Host / Dirección IP", "Incompleto" to "Incompleto", "Indisponível: %s" to "No disponible: %s", - "Informe o host e usuário para testar." to "Ingrese el host y usuario para probar.", "Inserir" to "Insertar", "Instrução (vazio: ler o texto)" to "Instrucción (vacío: leer el texto)", "Instrução do sistema (opcional)" to "Instrucción del sistema (opcional)", @@ -67,7 +59,6 @@ internal val Fluxos: Map = mapOf( "Modelo ausente: escolha outro ou instale de novo" to "Modelo ausente: elige otro o instálalo de nuevo", "Modelo indisponível" to "Modelo no disponible", "Modelo · %s" to "Modelo · %s", - "Modo de execução" to "Modo de ejecución", "Mover a etapa %d, %s, para baixo" to "Bajar el paso %d, %s", "Mover a etapa %d, %s, para cima" to "Subir el paso %d, %s", "Mover para baixo" to "Bajar", @@ -84,7 +75,6 @@ internal val Fluxos: Map = mapOf( "O PaddleOCR só lê o texto da imagem: a instrução desta etapa é ignorada." to "PaddleOCR solo lee el texto de la imagen: la instrucción de este paso se ignora.", "O fluxo parou em “%s”: %s" to "El flujo se detuvo en “%s”: %s", "O resultado será descartado: termine com Falar, Inserir ou Agente" to "El resultado se descartará: termina con Hablar, Insertar o Agente", - "O suporte a shell local está em análise e desenvolvimento para integração com ambiente Linux embarcado." to "El soporte de shell local está en análisis y desarrollo para integración con entorno Linux integrado.", "Orbe flutuante" to "Orbe flotante", "Os fluxos padrão já estão como no original" to "Los flujos predeterminados ya están como los originales", "Ouvir e responder" to "Escuchar y responder", @@ -100,19 +90,15 @@ internal val Fluxos: Map = mapOf( "Restaurar fluxos padrão" to "Restaurar flujos predeterminados", "Roda o fluxo da aba Fluxos neste gesto, no lugar da ação de sempre." to "Ejecuta el flujo de la pestaña Flujos en este gesto, en lugar de la acción de siempre.", "Rodar agora" to "Ejecutar ahora", - "SSH Remoto" to "SSH remoto", "Saída" to "Salida", "Sem atalho" to "Sin atajo", "Sem espaço (limite de 20): %s" to "Sin espacio (límite de 20): %s", "Senha" to "Contraseña", "Será recriado: %s" to "Se volverá a crear: %s", "Será reescrito: %s" to "Se restablecerá: %s", - "Shell Local" to "Shell local", "Sugestão: %s" to "Sugerencia: %s", "Só pode ser a primeira etapa" to "Solo puede ser el primer paso", "Tempo esgotado." to "Tiempo agotado.", - "Testando conexão…" to "Probando conexión…", - "Testar conexão SSH" to "Probar conexión SSH", "Toque em “+ Etapa” para começar. Ditar e Capturar abrem o fluxo." to "Toca “+ Paso” para empezar. Dictar y Capturar abren el flujo.", "Usuário" to "Usuario", "Você ainda não tem fluxos. Crie um com “+” ou restaure os padrão." to "Todavía no tienes flujos. Crea uno con “+” o restaura los predeterminados.", diff --git a/app/src/main/java/com/keyflow/ui/config/ComputadorHostModal.kt b/app/src/main/java/com/keyflow/ui/config/ComputadorHostModal.kt new file mode 100644 index 00000000..bf3e3ef9 --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/config/ComputadorHostModal.kt @@ -0,0 +1,276 @@ +package com.keyflow.ui.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.CampoHost +import com.keyflow.computador.ErroHost +import com.keyflow.computador.Host +import com.keyflow.computador.LimitesHost +import com.keyflow.computador.PodeMarcar +import com.keyflow.computador.PoliticaAgente +import com.keyflow.computador.PoliticaImagens +import com.keyflow.computador.RegraAgente +import com.keyflow.computador.app.texto +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.ssh.ResultadoTeste +import com.keyflow.i18n.tr +import com.keyflow.i18n.trKey +import com.keyflow.ui.theme.LocalKeyFlowPalette +import com.keyflow.ui.theme.SpaceGrotesk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch + +const val COMPUTADOR_MODAL_FECHAR_TAG = "computador-modal-fechar" +const val COMPUTADOR_HOST_NOME_TAG = "computador-host-nome" +const val COMPUTADOR_HOST_ENDERECO_TAG = "computador-host-endereco" +const val COMPUTADOR_HOST_PORTA_TAG = "computador-host-porta" +const val COMPUTADOR_HOST_USUARIO_TAG = "computador-host-usuario" +const val COMPUTADOR_HOST_SENHA_TAG = "computador-host-senha" +const val COMPUTADOR_HOST_CHAVE_TAG = "computador-host-chave" +const val COMPUTADOR_HOST_AUTH_SENHA_TAG = "computador-host-auth-senha" +const val COMPUTADOR_HOST_AUTH_CHAVE_TAG = "computador-host-auth-chave" +const val COMPUTADOR_HOST_AUTH_PERGUNTAR_TAG = "computador-host-auth-perguntar" +const val COMPUTADOR_HOST_LEMBRAR_TAG = "computador-host-lembrar-senha" +const val COMPUTADOR_HOST_AGENTE_TAG = "computador-host-agente" +const val COMPUTADOR_HOST_AGENTE_MOTIVO_TAG = "computador-host-agente-motivo" +const val COMPUTADOR_AGENTE_AVISO_TAG = "computador-agente-aviso" +const val COMPUTADOR_AGENTE_AVISO_CONFIRMAR_TAG = "computador-agente-aviso-confirmar" +const val COMPUTADOR_AGENTE_AVISO_CANCELAR_TAG = "computador-agente-aviso-cancelar" +const val COMPUTADOR_HOST_TESTAR_TAG = "computador-host-testar" +const val COMPUTADOR_HOST_TESTE_RESULTADO_TAG = "computador-host-teste-resultado" +const val COMPUTADOR_HOST_SALVAR_TAG = "computador-host-salvar" +const val COMPUTADOR_HOST_EXCLUIR_TAG = "computador-host-excluir" +const val COMPUTADOR_HOST_EXCLUIR_CONFIRMAR_TAG = "computador-host-excluir-confirmar" +const val COMPUTADOR_HOST_ESQUECER_TAG = "computador-host-esquecer-servidor" +const val COMPUTADOR_HOST_IMPRESSAO_TAG = "computador-host-impressao" +fun computadorErroTag(campo: CampoHost) = "computador-erro-${campo.name.lowercase()}" +fun computadorImagensTag(p: PoliticaImagens) = "computador-host-imagens-${p.name.lowercase()}" + +private enum class TipoAuth(private val rotuloPt: String) { + CHAVE(trKey("Chave")), SENHA(trKey("Senha")), PERGUNTAR(trKey("Perguntar")); + val rotulo: String get() = tr(rotuloPt) +} + +private fun PoliticaImagens.rotulo() = when (this) { + PoliticaImagens.DESLIGADO -> tr("Desligado") + PoliticaImagens.PERGUNTAR -> tr("Perguntar") + PoliticaImagens.LIGADO -> tr("Ligado") +} + +private sealed interface Teste { + data object Rodando : Teste + class Resultado(val texto: String, val ok: Boolean) : Teste +} + +private fun ResultadoTeste.paraTexto(): Teste.Resultado = when (this) { + is ResultadoTeste.Ok -> Teste.Resultado( + tr("Servidor confirmado. Impressão digital %s (%s). Autenticação: %s. Latência: %d ms.", impressao.texto, algoritmoDoServidor, metodo.ifEmpty { "—" }, latenciaMs), + ok = true, + ) + is ResultadoTeste.Falhou -> Teste.Resultado(causa.texto() ?: tr("Conexão cancelada"), ok = false) +} + +/** + * O modal do host (CMP-05, 07, 08, 09): campos, autenticação, "Pode ser usado pelo agente" (com aviso e confirmação), imagens, impressão + * digital conhecida, **Testar conexão** de verdade, salvar e excluir com confirmação. O que se digita fica num rascunho; só **Salvar** grava. + * [host] `null` = novo. + */ +@Composable +internal fun ComputadorHostModal(host: Host?, hosts: List, chaves: List, acoes: AcoesDeHost, onDismiss: () -> Unit) { + val palette = LocalKeyFlowPalette.current + val scope = rememberCoroutineScope() + val inicial = remember(host?.id) { host ?: Host(id = acoes.novoId(hosts.map { it.id }), nome = "", endereco = "", usuario = "", autenticacao = AutenticacaoHost.Perguntar) } + var rascunho by remember(inicial.id) { mutableStateOf(inicial) } + var senha by remember(inicial.id) { mutableStateOf("") } + var senhaDigitada by remember(inicial.id) { mutableStateOf(false) } + var senhaGuardada by remember(inicial.id) { mutableStateOf(false) } + LaunchedEffect(inicial.id) { + val guardada = host?.let { acoes.temSenhaGuardada(it) } == true + withContext(Dispatchers.Main.immediate) { senhaGuardada = guardada } + } + var erros by remember { mutableStateOf>(emptyList()) } + var teste by remember { mutableStateOf(null) } + var testeJob by remember { mutableStateOf(null) } + var avisoAgente by remember { mutableStateOf(false) } + var confirmarExclusao by remember { mutableStateOf(false) } + var impressoes by remember(inicial.id) { mutableStateOf(host?.let(acoes::impressoesConhecidas).orEmpty()) } + val pending = remember { PendingEdits() } + // Os campos de texto só entregam ao desfocar; o rascunho vivo (para Salvar/Testar) é lido daqui, depois do `pending.confirm()`. + val vivo = remember(inicial.id) { arrayOf(inicial) } + fun mudar(f: (Host) -> Host) { vivo[0] = f(vivo[0]); rascunho = vivo[0] } + fun erro(c: CampoHost) = erros.firstOrNull { it.campo == c }?.motivo?.texto() + val tipo = when (rascunho.autenticacao) { is AutenticacaoHost.Chave -> TipoAuth.CHAVE; AutenticacaoHost.Senha -> TipoAuth.SENHA; AutenticacaoHost.Perguntar -> TipoAuth.PERGUNTAR } + fun podeMarcar(h: Host): PodeMarcar { + val disponivel = when (val a = h.autenticacao) { + AutenticacaoHost.Senha -> senhaDigitada || senhaGuardada + is AutenticacaoHost.Chave -> chaves.any { it.id == a.keyId } + AutenticacaoHost.Perguntar -> false + } + return RegraAgente.podeMarcar(h, credencialDisponivel = disponivel) + } + val podeAgente = podeMarcar(rascunho) + + ConfigModal(if (host == null) tr("Novo host") else rascunho.nome.ifBlank { tr("Host") }, onDismiss, COMPUTADOR_MODAL_FECHAR_TAG, accessibleHeader = true) { + Column(Modifier.padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + AutoSaveField(tr("Nome"), rascunho.nome, { v -> mudar { it.copy(nome = v) } }, COMPUTADOR_HOST_NOME_TAG, pending = pending) + erro(CampoHost.NOME)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.NOME)) } + AutoSaveField(tr("Endereço"), rascunho.endereco, { v -> mudar { it.copy(endereco = v) } }, COMPUTADOR_HOST_ENDERECO_TAG, keyboardType = KeyboardType.Uri, pending = pending) + erro(CampoHost.ENDERECO)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.ENDERECO)) } + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.weight(0.35f)) { + AutoSaveField(tr("Porta"), rascunho.porta.toString(), { v -> mudar { it.copy(porta = v.trim().toIntOrNull() ?: 0) } }, COMPUTADOR_HOST_PORTA_TAG, keyboardType = KeyboardType.Number, pending = pending) + erro(CampoHost.PORTA)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.PORTA)) } + } + Column(Modifier.weight(0.65f)) { + AutoSaveField(tr("Usuário"), rascunho.usuario, { v -> mudar { it.copy(usuario = v) } }, COMPUTADOR_HOST_USUARIO_TAG, pending = pending) + erro(CampoHost.USUARIO)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.USUARIO)) } + } + } + if (host != null && (host.endereco != rascunho.endereco.trim() || host.porta != rascunho.porta)) { + SettingSub(tr("Mudar o endereço ou a porta pede uma nova confirmação da impressão digital na próxima conexão.")) + } + erro(CampoHost.LIMITE)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.LIMITE)) } + + Eyebrow(tr("Autenticação")) + Segmented( + options = TipoAuth.entries, selected = tipo, label = { it.rotulo }, + disabled = { it == TipoAuth.CHAVE && chaves.isEmpty() }, + tag = { when (it) { TipoAuth.CHAVE -> COMPUTADOR_HOST_AUTH_CHAVE_TAG; TipoAuth.SENHA -> COMPUTADOR_HOST_AUTH_SENHA_TAG; TipoAuth.PERGUNTAR -> COMPUTADOR_HOST_AUTH_PERGUNTAR_TAG } }, + onSelect = { novo -> + mudar { h -> + when (novo) { + TipoAuth.CHAVE -> chaves.firstOrNull()?.let { h.copy(autenticacao = AutenticacaoHost.Chave(it.id), lembrarSenha = false) } ?: h + TipoAuth.SENHA -> h.copy(autenticacao = AutenticacaoHost.Senha) + TipoAuth.PERGUNTAR -> h.copy(autenticacao = AutenticacaoHost.Perguntar, lembrarSenha = false) + } + } + // Mudar o jeito de autenticar pode tirar a base da marca do agente (CMP-09). + if (podeMarcar(vivo[0]) !is PodeMarcar.Sim && vivo[0].agente == PoliticaAgente.PERMITIDO) mudar { it.copy(agente = PoliticaAgente.PROIBIDO) } + }, + ) + if (chaves.isEmpty()) InfoNote(tr("Nenhuma chave guardada. Gere ou importe uma na seção Chaves."), "computador-host-sem-chaves") + when (tipo) { + TipoAuth.CHAVE -> if (chaves.isNotEmpty()) { + val atual = (rascunho.autenticacao as? AutenticacaoHost.Chave)?.keyId.orEmpty() + Dropdown( + label = tr("Chave"), options = chaves.map { it.id }, selected = atual.takeIf { id -> chaves.any { it.id == id } } ?: chaves.first().id, + onSelect = { id -> mudar { it.copy(autenticacao = AutenticacaoHost.Chave(id)) } }, tag = COMPUTADOR_HOST_CHAVE_TAG, + display = { id -> chaves.firstOrNull { it.id == id }?.nome ?: id }, + ) + } + TipoAuth.SENHA -> { + AutoSaveField(tr("Senha"), senha, { senha = it }, COMPUTADOR_HOST_SENHA_TAG, keyboardType = KeyboardType.Password, pending = pending, onDraftChange = { senhaDigitada = it.isNotEmpty() }) + ToggleRow(tr("Lembrar a senha"), tr("Guarda a senha cifrada neste aparelho. Sem isso, o agente não pode usar este servidor."), rascunho.lembrarSenha, + { v -> mudar { h -> h.copy(lembrarSenha = v) }; if (!v && vivo[0].agente == PoliticaAgente.PERMITIDO) mudar { it.copy(agente = PoliticaAgente.PROIBIDO) } }, COMPUTADOR_HOST_LEMBRAR_TAG) + } + TipoAuth.PERGUNTAR -> SettingSub(tr("A senha é pedida a cada conexão.")) + } + + erro(CampoHost.AUTENTICACAO)?.let { MensagemDeErro(it, computadorErroTag(CampoHost.AUTENTICACAO)) } + + ToggleRow( + tr("Pode ser usado pelo agente"), tr("O agente só age em servidores marcados e em sessões dele."), rascunho.agentePodeUsar, + { v -> pending.confirm(); if (v && podeMarcar(vivo[0]) is PodeMarcar.Sim) avisoAgente = true else mudar { it.copy(agente = PoliticaAgente.PROIBIDO) } }, + COMPUTADOR_HOST_AGENTE_TAG, enabled = podeAgente is PodeMarcar.Sim || rascunho.agentePodeUsar, + ) + (podeAgente as? PodeMarcar.Nao)?.let { if (!rascunho.agentePodeUsar) MensagemDeErro(it.motivo.texto(), COMPUTADOR_HOST_AGENTE_MOTIVO_TAG) } + + Eyebrow(tr("Imagens no terminal")) + Segmented( + options = PoliticaImagens.entries, selected = rascunho.imagens, label = { it.rotulo() }, tag = ::computadorImagensTag, + onSelect = { p -> mudar { it.copy(imagens = p) } }, + ) + + if (host != null) { + Eyebrow(tr("Servidor conhecido")) + if (impressoes.isEmpty()) SettingSub(tr("Ainda não confiei neste servidor.")) + impressoes.forEach { SettingSub("${it.algoritmo} ${it.impressao.texto}") } + if (impressoes.isNotEmpty()) { + SecondaryButton(tr("Esquecer servidor"), COMPUTADOR_HOST_ESQUECER_TAG, { acoes.esquecerServidor(host); impressoes = acoes.impressoesConhecidas(host) }) + } + } + + SecondaryButton( + if (teste is Teste.Rodando) tr("Cancelar o teste") else tr("Testar conexão"), COMPUTADOR_HOST_TESTAR_TAG, + onClick = { + if (teste is Teste.Rodando) { testeJob?.cancel(); teste = null; return@SecondaryButton } + pending.confirm() + val h = vivo[0].copy(nome = vivo[0].nome.ifBlank { "teste" }, endereco = vivo[0].endereco.trim(), usuario = vivo[0].usuario.trim()) + teste = Teste.Rodando + testeJob = scope.launch { + try { teste = acoes.testar(h, senha.takeIf { it.isNotEmpty() }).paraTexto() } catch (e: CancellationException) { throw e } + } + }, + ) + when (val t = teste) { + null -> Unit + Teste.Rodando -> Text(tr("Testando a conexão…"), color = palette.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp, modifier = Modifier.testTag(COMPUTADOR_HOST_TESTE_RESULTADO_TAG)) + is Teste.Resultado -> Text(t.texto, color = if (t.ok) palette.teal else palette.terracotta, fontFamily = SpaceGrotesk, fontSize = 12.sp, modifier = Modifier.testTag(COMPUTADOR_HOST_TESTE_RESULTADO_TAG)) + } + + PrimaryButton(tr("Salvar"), COMPUTADOR_HOST_SALVAR_TAG, { + pending.confirm() + val h = vivo[0] + scope.launch { + val e = acoes.salvar(h, senha.takeIf { it.isNotEmpty() && h.autenticacao == AutenticacaoHost.Senha }) + withContext(Dispatchers.Main.immediate) { + erros = e + if (e.isEmpty()) { + if (host?.agentePodeUsar == true && !h.agentePodeUsar) acoes.desligarAgente(h) + onDismiss() + } + } + } + }, Modifier.heightIn(min = 48.dp)) + if (host != null) SecondaryButton(tr("Excluir host"), COMPUTADOR_HOST_EXCLUIR_TAG, { confirmarExclusao = true }, accent = palette.terracotta) + } + } + + if (avisoAgente) { + AlertDialog( + modifier = Modifier.testTag(COMPUTADOR_AGENTE_AVISO_TAG), + onDismissRequest = { avisoAgente = false }, + title = { Text(tr("Liberar este servidor para o agente?")) }, + text = { Text(tr("O agente poderá abrir terminais neste servidor e ler o texto da tela; esse texto é enviado ao provedor do modelo.")) }, + confirmButton = { TextButton(onClick = { avisoAgente = false; if (podeMarcar(vivo[0]) is PodeMarcar.Sim) mudar { it.copy(agente = PoliticaAgente.PERMITIDO) } }, modifier = Modifier.testTag(COMPUTADOR_AGENTE_AVISO_CONFIRMAR_TAG)) { Text(tr("Permitir")) } }, + dismissButton = { TextButton(onClick = { avisoAgente = false }, modifier = Modifier.testTag(COMPUTADOR_AGENTE_AVISO_CANCELAR_TAG)) { Text(tr("Cancelar")) } }, + ) + } + if (confirmarExclusao && host != null) { + AlertDialog( + onDismissRequest = { confirmarExclusao = false }, + title = { Text(tr("Excluir %s?", host.nome)) }, + text = { Text(tr("As sessões abertas deste servidor fecham e a senha guardada é apagada. A chave não é apagada.")) }, + confirmButton = { TextButton(onClick = { confirmarExclusao = false; scope.launch { acoes.excluir(host); onDismiss() } }, modifier = Modifier.testTag(COMPUTADOR_HOST_EXCLUIR_CONFIRMAR_TAG)) { Text(tr("Excluir")) } }, + dismissButton = { TextButton(onClick = { confirmarExclusao = false }) { Text(tr("Cancelar")) } }, + ) + } +} + +@Composable +private fun MensagemDeErro(texto: String, tag: String) { + Text(texto, color = LocalKeyFlowPalette.current.terracotta, fontFamily = SpaceGrotesk, fontSize = 12.sp, modifier = Modifier.padding(top = 2.dp).testTag(tag)) +} diff --git a/app/src/main/java/com/keyflow/ui/config/ComputadorHosts.kt b/app/src/main/java/com/keyflow/ui/config/ComputadorHosts.kt new file mode 100644 index 00000000..a0e728b6 --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/config/ComputadorHosts.kt @@ -0,0 +1,121 @@ +package com.keyflow.ui.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.Host +import com.keyflow.computador.ErroHost +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.sessao.EstadoSessao +import com.keyflow.computador.sessao.SessaoResumo +import com.keyflow.computador.ssh.ResultadoTeste +import com.keyflow.i18n.tr +import com.keyflow.ui.icons.KfIcons +import com.keyflow.ui.theme.LocalKeyFlowPalette +import com.keyflow.ui.theme.SpaceGrotesk +import com.keyflow.ui.theme.SpaceMono + +const val COMPUTADOR_NOVO_HOST_TAG = "computador-novo-host" +const val COMPUTADOR_HOSTS_VAZIO_TAG = "computador-hosts-vazio" +fun computadorHostCardTag(id: String) = "computador-host-$id" + +/** Tudo que o painel de hosts faz fora da tela (gravar, testar, excluir…). O app liga ao repositório; os testes trocam por fakes. */ +interface AcoesDeHost { + /** Valida e grava [host] (novo ou editado), com a [senha] digitada se houver. Devolve os erros por campo ou a lista vazia. */ + suspend fun salvar(host: Host, senha: String?): List + + /** Consulta só a existência do segredo, sem entregá-lo à interface. */ + suspend fun temSenhaGuardada(host: Host): Boolean = false + + /** CMP-07: fecha as sessões do host, apaga `known_hosts`, a senha e o host. A chave fica. */ + suspend fun excluir(host: Host) + + /** CMP-08: o teste real (sem abrir o shell). [senha] é a digitada na tela. */ + suspend fun testar(host: Host, senha: String?): ResultadoTeste + + /** O que o `known_hosts` já sabe deste `endereço:porta`. */ + fun impressoesConhecidas(host: Host): List + fun esquecerServidor(host: Host) + + /** CMP-09: o agente deixou de poder usar o host (fecha as sessões do agente nele e desfaz compartilhamento). */ + suspend fun desligarAgente(host: Host) + + /** O id de um host novo. */ + fun novoId(existentes: Collection): String +} + +private fun Host.textoDeAutenticacao(chaves: List): String = when (val a = autenticacao) { + is AutenticacaoHost.Chave -> tr("Chave") + (chaves.firstOrNull { it.id == a.keyId }?.let { " · ${it.nome}" } ?: "") + AutenticacaoHost.Senha -> tr("Senha") + AutenticacaoHost.Perguntar -> tr("Pergunta sempre") +} + +private fun EstadoSessao?.texto(): String = when (this) { + null -> "○ " + tr("Desconectado") + EstadoSessao.Aberta -> "● " + tr("Conectado") + is EstadoSessao.Reconectando -> "⟳ " + tr("Reconectando…") + EstadoSessao.Conectando, EstadoSessao.AguardandoServidor, EstadoSessao.Autenticando -> "⟳ " + tr("Conectando…") + EstadoSessao.PrecisaDoUsuario -> "⚠ " + tr("Toque para reconectar") + is EstadoSessao.Encerrada, is EstadoSessao.Falhou -> "○ " + tr("Desconectado") +} + +/** A lista de servidores (CMP-59, design §11.1): cartões, "+ Novo host" e o modal. */ +@Composable +internal fun ComputadorHosts(hosts: List, chaves: List, sessoes: List, acoes: AcoesDeHost) { + val palette = LocalKeyFlowPalette.current + var editando by remember { mutableStateOf(null) } + var criando by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (hosts.isEmpty()) InfoNote(tr("Nenhum servidor ainda. Cadastre o primeiro para abrir um terminal."), COMPUTADOR_HOSTS_VAZIO_TAG) + hosts.forEach { h -> + val estado = sessoes.firstOrNull { it.hostId == h.id && !it.estado.terminal }?.estado + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(palette.card).border(1.dp, palette.line2, RoundedCornerShape(16.dp)) + .clickable(role = Role.Button) { editando = h }.heightIn(min = 48.dp).padding(14.dp).testTag(computadorHostCardTag(h.id)), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(h.nome, color = palette.fg, fontFamily = SpaceGrotesk, fontWeight = FontWeight.Medium, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Text(estado.texto(), color = if (estado == EstadoSessao.Aberta) palette.teal else palette.muted, fontFamily = SpaceGrotesk, fontSize = 11.sp) + } + Text("${h.usuario}@${h.endereco}:${h.porta}", color = palette.muted, fontFamily = SpaceMono, fontSize = 11.sp) + Text( + h.textoDeAutenticacao(chaves) + " · " + (if (h.agentePodeUsar) tr("Agente: permitido") else tr("Agente: não")), + color = if (h.agentePodeUsar) palette.terracotta else palette.faint, fontFamily = SpaceGrotesk, fontSize = 11.sp, + ) + } + } + SecondaryButton(tr("+ Novo host"), COMPUTADOR_NOVO_HOST_TAG, { criando = true }, icon = KfIcons.Plus) + } + if (criando) ComputadorHostModal(host = null, hosts = hosts, chaves = chaves, acoes = acoes, onDismiss = { criando = false }) + editando?.let { atual -> + // O host pode ter mudado por baixo (outro lugar): o modal sempre abre com o que está na lista. + val corrente = hosts.firstOrNull { it.id == atual.id } + if (corrente == null) editando = null + else ComputadorHostModal(host = corrente, hosts = hosts, chaves = chaves, acoes = acoes, onDismiss = { editando = null }) + } +} diff --git a/app/src/main/java/com/keyflow/ui/config/ComputadorPanel.kt b/app/src/main/java/com/keyflow/ui/config/ComputadorPanel.kt index 3ccde6a2..efcf441f 100644 --- a/app/src/main/java/com/keyflow/ui/config/ComputadorPanel.kt +++ b/app/src/main/java/com/keyflow/ui/config/ComputadorPanel.kt @@ -2,7 +2,6 @@ package com.keyflow.ui.config import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -11,64 +10,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.keyflow.data.Settings +import com.keyflow.computador.ConfigHosts +import com.keyflow.computador.chaves.ChaveSsh +import com.keyflow.computador.sessao.SessaoResumo import com.keyflow.i18n.tr import com.keyflow.i18n.trKey import com.keyflow.ui.common.verticalScrollbar import com.keyflow.ui.theme.LocalKeyFlowPalette -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch const val COMPUTADOR_PANEL_TAG = "computador-panel" -const val COMPUTADOR_MODE_SSH_TAG = "computador-mode-ssh" -const val COMPUTADOR_MODE_LOCAL_TAG = "computador-mode-local" -const val COMPUTADOR_SSH_HOST_TAG = "computador-ssh-host" -const val COMPUTADOR_SSH_PORT_TAG = "computador-ssh-port" -const val COMPUTADOR_SSH_USER_TAG = "computador-ssh-user" -const val COMPUTADOR_SSH_PASSWORD_TAG = "computador-ssh-password" -const val COMPUTADOR_SSH_KEY_TAG = "computador-ssh-key" -const val COMPUTADOR_SSH_TEST_TAG = "computador-ssh-testar" -const val COMPUTADOR_SSH_RESULT_TAG = "computador-ssh-resultado" -const val COMPUTADOR_LOCAL_INFO_TAG = "computador-local-info" +const val COMPUTADOR_SECAO_HOSTS_TAG = "computador-secao-hosts" +const val COMPUTADOR_SECAO_CHAVES_TAG = "computador-secao-chaves" +const val COMPUTADOR_SECAO_SESSOES_TAG = "computador-secao-sessoes" +const val COMPUTADOR_EM_BREVE_TAG = "computador-secao-em-breve" -enum class ComputerMode(private val labelPt: String) { - SSH(trKey("SSH Remoto")), - LOCAL(trKey("Shell Local")), +/** As três seções da aba (CMP-59). */ +enum class SecaoComputador(private val rotuloPt: String) { + HOSTS(trKey("Hosts")), + CHAVES(trKey("Chaves")), + SESSOES(trKey("Sessões")), ; - val label: String get() = tr(labelPt) -} - -enum class SshAuthType(private val labelPt: String) { - PASSWORD(trKey("Senha")), - KEY(trKey("Chave Privada")), - ; - val label: String get() = tr(labelPt) + val rotulo: String get() = tr(rotuloPt) } /** - * Aba Computador: execução remota via SSH e shell local para automação e comandos do modelo. + * Aba Computador: **Hosts · Chaves · Sessões** (CMP-59), sem banner. Os hosts vêm do [dados]; as chaves e as sessões ganham tela nas próximas + * etapas (CMP-T16 e T17) e aqui só avisam. Sem `delay` simulado: o "Testar conexão" é o real ([AcoesDeHost.testar]). */ @Composable fun ComputadorPanel( - settings: Settings, - onSettingsChange: (Settings) -> Unit, + hosts: ConfigHosts, + chaves: List, + sessoes: List, + acoes: AcoesDeHost, modifier: Modifier = Modifier, + secaoInicial: SecaoComputador = SecaoComputador.HOSTS, ) { val palette = LocalKeyFlowPalette.current val scroll = rememberScrollState() - val scope = rememberCoroutineScope() - var testRunning by remember { mutableStateOf(false) } - var testResult by remember { mutableStateOf(null) } - - val currentMode = if (settings.computerMode == "local") ComputerMode.LOCAL else ComputerMode.SSH - val currentAuth = if (settings.computerSshAuthType == "key") SshAuthType.KEY else SshAuthType.PASSWORD - + var secao by remember { mutableStateOf(secaoInicial) } Column( modifier .fillMaxWidth() @@ -78,123 +62,17 @@ fun ComputadorPanel( .padding(top = 20.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(GROUP_GAP), ) { - SettingsGroup(tr("Modo de execução")) { - Column(Modifier.padding(vertical = 16.dp)) { - Segmented( - options = ComputerMode.entries, - selected = currentMode, - label = { it.label }, - tag = { if (it == ComputerMode.SSH) COMPUTADOR_MODE_SSH_TAG else COMPUTADOR_MODE_LOCAL_TAG }, - onSelect = { mode -> - onSettingsChange(settings.copy(computerMode = if (mode == ComputerMode.LOCAL) "local" else "ssh")) - }, - ) - } - } - - if (currentMode == ComputerMode.SSH) { - SettingsGroup(tr("Conexão SSH")) { - Column(Modifier.padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - AutoSaveField( - label = tr("Host / Endereço IP"), - value = settings.computerSshHost, - onCommit = { onSettingsChange(settings.copy(computerSshHost = it)) }, - tag = COMPUTADOR_SSH_HOST_TAG, - keyboardType = KeyboardType.Uri, - ) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - AutoSaveField( - label = tr("Porta"), - value = settings.computerSshPort.toString(), - onCommit = { - val port = it.toIntOrNull() ?: 22 - onSettingsChange(settings.copy(computerSshPort = port)) - }, - tag = COMPUTADOR_SSH_PORT_TAG, - keyboardType = KeyboardType.Number, - modifier = Modifier.weight(0.35f), - ) - AutoSaveField( - label = tr("Usuário"), - value = settings.computerSshUser, - onCommit = { onSettingsChange(settings.copy(computerSshUser = it)) }, - tag = COMPUTADOR_SSH_USER_TAG, - modifier = Modifier.weight(0.65f), - ) - } - } - } - - SettingsGroup(tr("Autenticação SSH")) { - Column(Modifier.padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Segmented( - options = SshAuthType.entries, - selected = currentAuth, - label = { it.label }, - tag = { "computador-ssh-auth-${it.name.lowercase()}" }, - onSelect = { auth -> - onSettingsChange(settings.copy(computerSshAuthType = if (auth == SshAuthType.KEY) "key" else "password")) - }, - ) - - if (currentAuth == SshAuthType.PASSWORD) { - AutoSaveField( - label = tr("Senha"), - value = settings.computerSshPassword, - onCommit = { onSettingsChange(settings.copy(computerSshPassword = it)) }, - tag = COMPUTADOR_SSH_PASSWORD_TAG, - keyboardType = KeyboardType.Password, - ) - } else { - AutoSaveField( - label = tr("Chave privada OpenSSH"), - value = settings.computerSshKey, - onCommit = { onSettingsChange(settings.copy(computerSshKey = it)) }, - tag = COMPUTADOR_SSH_KEY_TAG, - ) - } - - SecondaryButton( - text = if (testRunning) tr("Testando conexão…") else tr("Testar conexão SSH"), - tag = COMPUTADOR_SSH_TEST_TAG, - onClick = { - testRunning = true - testResult = null - scope.launch { - delay(600) - testRunning = false - testResult = if (settings.computerSshHost.isBlank()) { - tr("Informe o host e usuário para testar.") - } else { - tr("Configuração SSH válida. Pronto para conectar.") - } - } - }, - modifier = Modifier.padding(top = 4.dp), - ) - - testResult?.let { msg -> - InfoNote( - text = msg, - tag = COMPUTADOR_SSH_RESULT_TAG, - modifier = Modifier.padding(top = 8.dp), - ) - } - } - } - } else { - SettingsGroup(tr("Shell Local")) { - Column(Modifier.padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - InfoNote( - text = tr("O suporte a shell local está em análise e desenvolvimento para integração com ambiente Linux embarcado."), - tag = COMPUTADOR_LOCAL_INFO_TAG, - ) - SettingSub( - tr("Em breve você poderá executar scripts, ferramentas de linha de comando e automações locais diretamente no aparelho."), - ) - } - } + Segmented( + options = SecaoComputador.entries, + selected = secao, + label = { it.rotulo }, + tag = { when (it) { SecaoComputador.HOSTS -> COMPUTADOR_SECAO_HOSTS_TAG; SecaoComputador.CHAVES -> COMPUTADOR_SECAO_CHAVES_TAG; SecaoComputador.SESSOES -> COMPUTADOR_SECAO_SESSOES_TAG } }, + onSelect = { secao = it }, + ) + when (secao) { + SecaoComputador.HOSTS -> ComputadorHosts(hosts.hosts, chaves, sessoes, acoes) + SecaoComputador.CHAVES -> InfoNote(tr("As chaves SSH ganham a própria seção na próxima etapa."), COMPUTADOR_EM_BREVE_TAG) + SecaoComputador.SESSOES -> InfoNote(tr("A lista de sessões chega na próxima etapa."), COMPUTADOR_EM_BREVE_TAG) } } } diff --git a/app/src/main/java/com/keyflow/ui/config/ConfigFields.kt b/app/src/main/java/com/keyflow/ui/config/ConfigFields.kt index 405b528f..4945303c 100644 --- a/app/src/main/java/com/keyflow/ui/config/ConfigFields.kt +++ b/app/src/main/java/com/keyflow/ui/config/ConfigFields.kt @@ -799,6 +799,7 @@ internal fun AutoSaveField( keyboardType: KeyboardType = KeyboardType.Text, masked: Boolean = false, pending: PendingEdits? = null, + onDraftChange: ((String) -> Unit)? = null, ) { val palette = LocalKeyFlowPalette.current val focusManager = LocalFocusManager.current @@ -840,6 +841,7 @@ internal fun AutoSaveField( // desfoque/Done/descarte. val pasted = new.length - text.length > 1 text = new + onDraftChange?.invoke(new) if (masked) maskedEditDirty = true if (pasted) commitIfChanged() }, diff --git a/app/src/main/java/com/keyflow/ui/terminal/AbasDeSessao.kt b/app/src/main/java/com/keyflow/ui/terminal/AbasDeSessao.kt new file mode 100644 index 00000000..3a27fc7a --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/terminal/AbasDeSessao.kt @@ -0,0 +1,122 @@ +package com.keyflow.ui.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.Host +import com.keyflow.computador.sessao.DonoDaSessao +import com.keyflow.computador.sessao.SessaoResumo +import com.keyflow.i18n.tr +import com.keyflow.ui.theme.LocalKeyFlowPalette +import com.keyflow.ui.theme.SpaceGrotesk + +const val TERMINAL_ABAS_TAG = "terminal-abas" +const val TERMINAL_NOVA_ABA_TAG = "terminal-aba-nova" +fun abaTag(id: String) = "terminal-aba-$id" +fun abaFecharTag(id: String) = "terminal-aba-fechar-$id" +fun abaHostTag(hostId: String) = "terminal-aba-host-$hostId" + +/** O rótulo da aba: `Terminal N` (do usuário) ou `Agente N ●` (do agente), ou o título que o servidor pediu (já sanitizado). */ +fun rotuloDaAba(indice: Int, resumo: SessaoResumo, titulo: String?): String { + val base = titulo ?: if (resumo.dono == DonoDaSessao.AGENTE) tr("Agente %d", indice) else tr("Terminal %d", indice) + return if (resumo.dono == DonoDaSessao.AGENTE) "$base ●" else base +} + +/** + * `[Terminal 1] [Agente 2 ●] [+]` (CMP-24). Cada aba tem alvo de pelo menos 48 dp para tocar e outro para fechar; fechar a aba **fecha a sessão**. + * O `+` abre a lista de servidores; sem servidor cadastrado, diz o que fazer. + */ +@Composable +fun AbasDeSessao( + sessoes: List, + ativaId: String?, + hosts: List, + titulos: Map, + onSelecionar: (String) -> Unit, + onFechar: (String) -> Unit, + onNova: (Host) -> Unit, + modifier: Modifier = Modifier, +) { + val palette = LocalKeyFlowPalette.current + var menu by remember { mutableStateOf(false) } + Row( + modifier.fillMaxWidth().background(palette.panel).testTag(TERMINAL_ABAS_TAG), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(Modifier.weight(1f).horizontalScroll(rememberScrollState()).padding(horizontal = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + sessoes.forEachIndexed { i, s -> + val ativa = s.id == ativaId + val rotulo = rotuloDaAba(i + 1, s, titulos[s.id]) + Row( + Modifier + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (ativa) palette.card else palette.panel) + .border(1.dp, if (ativa) palette.teal else palette.line, RoundedCornerShape(8.dp)) + .testTag(abaTag(s.id)) + .semantics { role = Role.Tab; selected = ativa; contentDescription = rotulo }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + rotulo, color = palette.fg, fontFamily = SpaceGrotesk, fontSize = 13.sp, maxLines = 1, + modifier = Modifier.heightIn(min = 48.dp).widthIn(max = 160.dp).clickable { onSelecionar(s.id) }.padding(start = 12.dp, end = 4.dp).wrapCenter(), + ) + Box( + Modifier.size(48.dp).clickable { onFechar(s.id) }.testTag(abaFecharTag(s.id)).semantics { contentDescription = tr("Fechar a sessão %s", rotulo) }, + contentAlignment = Alignment.Center, + ) { Text("✕", color = palette.muted, fontSize = 14.sp) } + } + } + } + Box { + Box( + Modifier.size(48.dp).clickable { menu = true }.testTag(TERMINAL_NOVA_ABA_TAG).semantics { contentDescription = tr("Nova sessão") }, + contentAlignment = Alignment.Center, + ) { Text("+", color = palette.fg, fontSize = 22.sp) } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (hosts.isEmpty()) { + DropdownMenuItem(text = { Text(tr("Cadastre um servidor na aba Computador.")) }, onClick = { menu = false }) + } + hosts.forEach { h -> + DropdownMenuItem( + text = { Text("${h.nome} · ${h.usuario}@${h.endereco}") }, + onClick = { menu = false; onNova(h) }, + modifier = Modifier.testTag(abaHostTag(h.id)).heightIn(min = 48.dp), + ) + } + } + } + } +} + +private fun Modifier.wrapCenter(): Modifier = this.then(Modifier.padding(vertical = 14.dp)) diff --git a/app/src/main/java/com/keyflow/ui/terminal/BarraDeEstado.kt b/app/src/main/java/com/keyflow/ui/terminal/BarraDeEstado.kt new file mode 100644 index 00000000..a78c75aa --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/terminal/BarraDeEstado.kt @@ -0,0 +1,64 @@ +package com.keyflow.ui.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.app.texto +import com.keyflow.computador.sessao.EstadoSessao +import com.keyflow.i18n.tr +import com.keyflow.ui.theme.LocalKeyFlowPalette +import com.keyflow.ui.theme.SpaceGrotesk + +const val TERMINAL_BARRA_TAG = "terminal-barra-estado" +const val TERMINAL_ESTADO_TAG = "terminal-estado-texto" + +/** O texto do estado da sessão (CMP-31: nunca só cor; cada estado tem símbolo e palavra). */ +fun textoDoEstado(estado: EstadoSessao): String = when (estado) { + EstadoSessao.Conectando -> "⟳ " + tr("Conectando…") + EstadoSessao.AguardandoServidor -> "⟳ " + tr("Aguardando a confirmação do servidor…") + EstadoSessao.Autenticando -> "⟳ " + tr("Autenticando…") + EstadoSessao.Aberta -> "● " + tr("Conectado") + is EstadoSessao.Reconectando -> "⟳ " + tr("Reconectando… (tentativa %d)", estado.tentativa) + EstadoSessao.PrecisaDoUsuario -> "⚠ " + tr("Toque para reconectar") + is EstadoSessao.Encerrada -> "○ " + tr("Sessão encerrada") + is EstadoSessao.Falhou -> "✕ " + (estado.causa.texto()?.let { tr("Caiu: %s", it) } ?: tr("Conexão cancelada")) +} + +/** Barra do topo: `servidor · estado`. A cor reforça (verde/âmbar/vermelho), o texto diz. */ +@Composable +fun BarraDeEstado(nomeDoHost: String, estado: EstadoSessao, modifier: Modifier = Modifier) { + val palette = LocalKeyFlowPalette.current + val cor = when (estado) { + EstadoSessao.Aberta -> palette.teal + is EstadoSessao.Falhou, is EstadoSessao.Encerrada -> palette.terracotta + else -> palette.record + } + val texto = textoDoEstado(estado) + Row( + modifier + .fillMaxWidth() + .background(palette.panel) + .heightIn(min = 40.dp) + .padding(horizontal = 12.dp) + .testTag(TERMINAL_BARRA_TAG) + .semantics { contentDescription = "$nomeDoHost, $texto" }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(nomeDoHost, color = palette.fg, fontFamily = SpaceGrotesk, fontWeight = FontWeight.Medium, fontSize = 13.sp, maxLines = 1, modifier = Modifier.weight(1f, fill = false)) + Text(texto, color = cor, fontFamily = SpaceGrotesk, fontSize = 12.sp, maxLines = 1, modifier = Modifier.testTag(TERMINAL_ESTADO_TAG)) + } +} diff --git a/app/src/main/java/com/keyflow/ui/terminal/TeclasExtras.kt b/app/src/main/java/com/keyflow/ui/terminal/TeclasExtras.kt new file mode 100644 index 00000000..92f124bc --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/terminal/TeclasExtras.kt @@ -0,0 +1,108 @@ +package com.keyflow.ui.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.app.EntradaDoTerminal +import com.keyflow.computador.app.EstadoPegajoso +import com.keyflow.computador.app.Tecla +import com.keyflow.i18n.tr +import com.keyflow.ui.theme.LocalKeyFlowPalette +import com.keyflow.ui.theme.SpaceMono + +const val TERMINAL_TECLAS_TAG = "terminal-teclas-extras" +fun teclaExtraTag(id: String) = "terminal-tecla-$id" + +/** Uma tecla extra: o rótulo curto e a descrição para o leitor de tela. [id] estável (usado nos testes). */ +class TeclaExtra(val id: String, val rotulo: String, val descricao: String, val acao: (EntradaDoTerminal) -> Unit, val pegajosa: Boolean = false) + +/** A lista das teclas (CMP-26): primeira linha Esc, Tab, Ctrl, Alt, setas, `| ~ / -`; segunda linha F1–F12. */ +fun teclasExtras(): List = listOf( + TeclaExtra("esc", "Esc", tr("Tecla Esc"), { it.teclaExtra(Tecla.Esc) }), + TeclaExtra("tab", "Tab", tr("Tecla Tab"), { it.teclaExtra(Tecla.Tab) }), + TeclaExtra("ctrl", "Ctrl", tr("Ctrl: um toque vale para a próxima tecla, dois travam"), { it.pegajosos.tocarCtrl() }, pegajosa = true), + TeclaExtra("alt", "Alt", tr("Alt: um toque vale para a próxima tecla, dois travam"), { it.pegajosos.tocarAlt() }, pegajosa = true), + TeclaExtra("esq", "←", tr("Seta para a esquerda"), { it.teclaExtra(Tecla.Esquerda) }), + TeclaExtra("baixo", "↓", tr("Seta para baixo"), { it.teclaExtra(Tecla.Baixo) }), + TeclaExtra("cima", "↑", tr("Seta para cima"), { it.teclaExtra(Tecla.Cima) }), + TeclaExtra("dir", "→", tr("Seta para a direita"), { it.teclaExtra(Tecla.Direita) }), + TeclaExtra("pipe", "|", tr("Barra vertical"), { it.caractereExtra('|') }), + TeclaExtra("til", "~", tr("Til"), { it.caractereExtra('~') }), + TeclaExtra("barra", "/", tr("Tecla barra"), { it.caractereExtra('/') }), + TeclaExtra("hifen", "-", tr("Hífen"), { it.caractereExtra('-') }), +) + +fun teclasDeFuncao(): List = (1..12).map { n -> TeclaExtra("f$n", "F$n", tr("Tecla F%d", n), { it.teclaExtra(Tecla.F(n)) }) } + +/** As teclas extras acima do teclado (CMP-26, 31): alvos de 48 dp, descrição em todas, Ctrl/Alt mostram o estado também por texto. */ +@Composable +fun TeclasExtras(entrada: EntradaDoTerminal, modifier: Modifier = Modifier) { + val palette = LocalKeyFlowPalette.current + var funcoes by remember { mutableStateOf(false) } + var versao by remember { mutableIntStateOf(0) } + val principal = remember { teclasExtras() } + val fs = remember { teclasDeFuncao() } + + @Composable + fun Linha(teclas: List) { + Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + for (t in teclas) { + val estado = when (t.id) { "ctrl" -> entrada.pegajosos.estadoCtrl(); "alt" -> entrada.pegajosos.estadoAlt(); else -> EstadoPegajoso.DESLIGADO } + val textoEstado = when (estado) { EstadoPegajoso.DESLIGADO -> ""; EstadoPegajoso.UMA_VEZ -> tr("ligada para a próxima tecla"); EstadoPegajoso.TRAVADO -> tr("travada") } + Box( + Modifier + .heightIn(min = 48.dp).widthIn(min = 48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (estado != EstadoPegajoso.DESLIGADO) palette.tealLight else palette.kbKey) + .border(1.dp, if (estado == EstadoPegajoso.TRAVADO) palette.teal else palette.kbKeyBorder, RoundedCornerShape(8.dp)) + .clickable { t.acao(entrada); versao++ } + .testTag(teclaExtraTag(t.id)) + .semantics { role = Role.Button; contentDescription = t.descricao; if (textoEstado.isNotEmpty()) stateDescription = textoEstado }, + contentAlignment = Alignment.Center, + ) { Text(t.rotulo + if (estado == EstadoPegajoso.TRAVADO) "•" else "", color = palette.fg, fontFamily = SpaceMono, fontSize = 13.sp, modifier = Modifier.padding(horizontal = 8.dp)) } + } + if (teclas === principal) { + Box( + Modifier.heightIn(min = 48.dp).widthIn(min = 48.dp).clip(RoundedCornerShape(8.dp)).background(palette.kbModifier).clickable { funcoes = !funcoes } + .testTag(teclaExtraTag("fn")).semantics { role = Role.Button; contentDescription = tr("Mostrar ou esconder as teclas F1 a F12") }, + contentAlignment = Alignment.Center, + ) { Text(if (funcoes) "F ▾" else "F ▸", color = palette.fg, fontFamily = SpaceMono, fontSize = 13.sp, modifier = Modifier.padding(horizontal = 8.dp)) } + } + } + } + // `versao` força a releitura do estado pegajoso depois de cada toque. + @Suppress("UNUSED_EXPRESSION") versao + Column(modifier.fillMaxWidth().background(palette.kbTray).testTag(TERMINAL_TECLAS_TAG), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Linha(principal) + if (funcoes) Linha(fs) + } +} diff --git a/app/src/main/java/com/keyflow/ui/terminal/TerminalScreen.kt b/app/src/main/java/com/keyflow/ui/terminal/TerminalScreen.kt new file mode 100644 index 00000000..c790a4b3 --- /dev/null +++ b/app/src/main/java/com/keyflow/ui/terminal/TerminalScreen.kt @@ -0,0 +1,169 @@ +package com.keyflow.ui.terminal + +import android.graphics.Typeface +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.keyflow.computador.Host +import com.keyflow.computador.app.EntradaDoTerminal +import com.keyflow.computador.app.LigacaoEmulador +import com.keyflow.computador.app.PoliticaDeColar +import com.keyflow.computador.app.PoliticaDeLinks +import com.keyflow.computador.sessao.SessaoResumo +import com.keyflow.i18n.tr +import com.keyflow.ui.common.collectAsStateOnMain +import com.keyflow.ui.theme.LocalKeyFlowPalette +import org.connectbot.terminal.Terminal + +const val TERMINAL_TELA_TAG = "terminal-tela" +const val TERMINAL_VAZIO_TAG = "terminal-sem-sessao" +const val TERMINAL_AVISO_TAG = "terminal-aviso" +const val TERMINAL_COLAR_DIALOGO_TAG = "terminal-colar-dialogo" +const val TERMINAL_COLAR_CONFIRMAR_TAG = "terminal-colar-confirmar" +const val TERMINAL_COLAR_CANCELAR_TAG = "terminal-colar-cancelar" +const val TERMINAL_OSC52_DIALOGO_TAG = "terminal-osc52-dialogo" +const val TERMINAL_OSC52_PERMITIR_TAG = "terminal-osc52-permitir" +const val TERMINAL_OSC52_NEGAR_TAG = "terminal-osc52-negar" +const val TERMINAL_LINK_DIALOGO_TAG = "terminal-link-dialogo" +const val TERMINAL_LINK_ABRIR_TAG = "terminal-link-abrir" +const val TERMINAL_LINK_CANCELAR_TAG = "terminal-link-cancelar" +const val TERMINAL_FONTE_ASSET = "fonts/JetBrainsMonoNerdFontMono-Regular.ttf" + +/** A fonte do terminal (D16): JetBrains Mono com glifos Nerd (OFL). Sem o arquivo, a monoespaçada do sistema. */ +fun fonteDoTerminal(contexto: android.content.Context): Typeface = + try { Typeface.createFromAsset(contexto.assets, TERMINAL_FONTE_ASSET) } catch (_: RuntimeException) { Typeface.MONOSPACE } + +/** + * O terminal na tela (CMP-24, 25, 28, 35). Sem efeitos próprios: quem abre sessão, fecha aba ou abre link recebe por callback, então a + * mesma tela serve à activity e ao teste. O emulador vem de [ligacaoDe] (vive fora da tela: girar não perde o que estava desenhado). + * + * Os três diálogos aqui são os da segurança das sequências: **colar várias linhas** (confirma sempre que há quebra), **`OSC 52` de escrita** + * (padrão negar) e **link `OSC 8`** (a URL inteira, só `http`, `https` e `mailto`). + */ +@Composable +fun TerminalScreen( + sessoes: List, + hosts: List, + ativaId: String?, + ligacaoDe: (String) -> LigacaoEmulador?, + aviso: String?, + onSelecionar: (String) -> Unit, + onFecharAba: (String) -> Unit, + onNovaSessao: (Host) -> Unit, + onAbrirLink: (String) -> Unit, + onCopiar: (String) -> Unit, + lerAreaDeTransferencia: () -> String?, + modifier: Modifier = Modifier, +) { + val palette = LocalKeyFlowPalette.current + val contexto = LocalContext.current + val tipo = remember { fonteDoTerminal(contexto) } + val ativa = sessoes.firstOrNull { it.id == ativaId } ?: sessoes.firstOrNull() + val ligacao = ativa?.let { ligacaoDe(it.id) } + val entrada = remember(ligacao) { ligacao?.let { EntradaDoTerminal(it) } } + val pedidoDeColar by (ligacao?.pedidoDeColar ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }).collectAsStateOnMain() + val titulos = sessoes.associate { it.id to ligacaoDe(it.id)?.titulo?.value } + var link by remember { mutableStateOf(null) } + var linkBloqueado by remember { mutableStateOf(false) } + + Column(modifier.fillMaxSize().background(palette.bg).testTag(TERMINAL_TELA_TAG)) { + if (ativa != null) { + val nome = hosts.firstOrNull { it.id == ativa.hostId }?.nome ?: tr("Servidor") + BarraDeEstado(nome, ativa.estado) + } + aviso?.let { + Text(it, color = palette.terracotta, fontSize = 12.sp, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp).testTag(TERMINAL_AVISO_TAG)) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + if (ligacao == null) { + Text(tr("Nenhuma sessão aberta. Toque em + para abrir uma."), color = palette.muted, fontSize = 14.sp, modifier = Modifier.padding(20.dp).testTag(TERMINAL_VAZIO_TAG)) + } else { + key(ativa.id) { + val pedidoOsc52 by ligacao.pedidoDeClipboard.collectAsStateOnMain() + LaunchedEffect(palette.isDark, ligacao) { ligacao.emulador.setDefaultColors(palette.fg.toArgb(), palette.bg.toArgb()) } + Terminal( + terminalEmulator = ligacao.emulador, + modifier = Modifier.fillMaxSize(), + typeface = tipo, + initialFontSize = 14.sp, + backgroundColor = palette.bg, + foregroundColor = palette.fg, + keyboardEnabled = true, + showSoftKeyboard = true, + onHyperlinkClick = { url -> if (PoliticaDeLinks.aceitar(url) != null) link = url.trim() else linkBloqueado = true }, + onPasteRequest = { lerAreaDeTransferencia()?.takeIf { it.isNotEmpty() }?.let(ligacao::pedirColar) }, + onInterceptKey = entrada!!.interceptar, + modifierManager = entrada.modificadores, + ) + pedidoOsc52?.let { texto -> + AlertDialog( + modifier = Modifier.testTag(TERMINAL_OSC52_DIALOGO_TAG), + onDismissRequest = { ligacao.responderClipboard(false) }, + title = { Text(tr("O servidor quer copiar texto")) }, + text = { Text(tr("O servidor pediu para colocar %d caracteres na área de transferência. Só permita se foi você quem pediu.", texto.length)) }, + confirmButton = { + TextButton(onClick = { ligacao.responderClipboard(true)?.let(onCopiar) }, modifier = Modifier.testTag(TERMINAL_OSC52_PERMITIR_TAG)) { Text(tr("Permitir")) } + }, + dismissButton = { + TextButton(onClick = { ligacao.responderClipboard(false) }, modifier = Modifier.testTag(TERMINAL_OSC52_NEGAR_TAG)) { Text(tr("Negar")) } + }, + ) + } + } + } + } + if (entrada != null) TeclasExtras(entrada) + AbasDeSessao(sessoes, ativa?.id, hosts, titulos, onSelecionar, onFecharAba, onNovaSessao) + } + + pedidoDeColar?.let { p -> + AlertDialog( + modifier = Modifier.testTag(TERMINAL_COLAR_DIALOGO_TAG), + onDismissRequest = { ligacao?.cancelarColar() }, + title = { Text(tr("Colar %d linhas?", p.linhas)) }, + text = { Text(tr("Elas podem ser executadas.")) }, + confirmButton = { + TextButton(onClick = { ligacao?.confirmarColar() }, modifier = Modifier.testTag(TERMINAL_COLAR_CONFIRMAR_TAG)) { Text(tr("Colar")) } + }, + dismissButton = { TextButton(onClick = { ligacao?.cancelarColar() }, modifier = Modifier.testTag(TERMINAL_COLAR_CANCELAR_TAG)) { Text(tr("Cancelar")) } }, + ) + } + link?.let { url -> + AlertDialog( + modifier = Modifier.testTag(TERMINAL_LINK_DIALOGO_TAG), + onDismissRequest = { link = null }, + title = { Text(tr("Abrir este link?")) }, + text = { Text(url) }, + confirmButton = { TextButton(onClick = { link = null; onAbrirLink(url) }, modifier = Modifier.testTag(TERMINAL_LINK_ABRIR_TAG)) { Text(tr("Abrir")) } }, + dismissButton = { TextButton(onClick = { link = null }, modifier = Modifier.testTag(TERMINAL_LINK_CANCELAR_TAG)) { Text(tr("Cancelar")) } }, + ) + } + if (linkBloqueado) { + AlertDialog( + onDismissRequest = { linkBloqueado = false }, + title = { Text(tr("Link bloqueado")) }, + text = { Text(tr("O KeyFlow só abre links http, https e mailto.")) }, + confirmButton = { TextButton(onClick = { linkBloqueado = false }) { Text(tr("OK")) } }, + ) + } +} diff --git a/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt b/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt index 8246b20f..9154d37e 100644 --- a/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt +++ b/app/src/test/java/com/keyflow/computador/CodecHostsFuzzTest.kt @@ -45,7 +45,7 @@ class CodecHostsFuzzTest { assertEquals(config.hosts.size, config.hosts.map { it.id }.distinct().size) for (h in config.hosts) { assertTrue("host inválido sobreviveu: $h de $entrada", ValidadorHost.validar(h).none { it.campo != CampoHost.LIMITE }) - if (h.agentePodeUsar) assertTrue(RegraAgente.podeMarcar(h) is PodeMarcar.Sim) + if (h.agentePodeUsar) assertTrue(RegraAgente.podeMarcar(h, credencialDisponivel = true) is PodeMarcar.Sim) } } } diff --git a/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt b/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt index ca9e2fc0..de708239 100644 --- a/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt +++ b/app/src/test/java/com/keyflow/computador/CodecHostsPropertyTest.kt @@ -26,7 +26,7 @@ class CodecHostsPropertyTest { ) { id, nome, endereco, porta, usuario, auth, lembrar, agente, imagens -> val h = Host(id, nome, endereco, porta, usuario, auth, lembrar, if (agente) PoliticaAgente.PERMITIDO else PoliticaAgente.PROIBIDO, imagens) // O codec aplica a regra do agente; o gerador só produz o que o app produziria. - if (h.agentePodeUsar && RegraAgente.podeMarcar(h) !is PodeMarcar.Sim) h.copy(agente = PoliticaAgente.PROIBIDO) else h + if (h.agentePodeUsar && RegraAgente.podeMarcar(h, credencialDisponivel = true) !is PodeMarcar.Sim) h.copy(agente = PoliticaAgente.PROIBIDO) else h } private val configs = Arb.list(hostsArb, 0..12).map { lista -> diff --git a/app/src/test/java/com/keyflow/computador/EdicaoDeHostTest.kt b/app/src/test/java/com/keyflow/computador/EdicaoDeHostTest.kt new file mode 100644 index 00000000..65546b78 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/EdicaoDeHostTest.kt @@ -0,0 +1,60 @@ +package com.keyflow.computador + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EdicaoDeHostTest { + private fun host(id: String = "aaaaaaaa", nome: String = "dev", auth: AutenticacaoHost = AutenticacaoHost.Senha, lembrar: Boolean = true, agente: PoliticaAgente = PoliticaAgente.PROIBIDO, porta: Int = 22) = + Host(id, nome, "dev.example.org", porta, "yan", auth, lembrar, agente) + + private fun aplicar(c: ConfigHosts, h: Host, passphrase: (String) -> Boolean = { false }) = + EdicaoDeHost.aplicar(c, h, credencialDisponivel = true, chavePedePassphrase = passphrase) + + @Test fun hostValidoEAcrescentadoComEspacosAparados() { + val r = aplicar(ConfigHosts(), host(nome = " dev ")) as ResultadoEdicao.Ok + assertEquals("dev", r.host.nome) + assertEquals(1, r.config.hosts.size) + } + + @Test fun porta0ENomeRepetidoVoltamOMotivo() { + val base = ConfigHosts(listOf(host(id = "11111111", nome = "dev"))) + val r = aplicar(base, host(id = "22222222", nome = "DEV", porta = 0)) as ResultadoEdicao.Erros + assertTrue(r.erros.any { it.campo == CampoHost.PORTA && it.motivo == MotivoHost.PORTA_INVALIDA }) + assertTrue(r.erros.any { it.campo == CampoHost.NOME && it.motivo == MotivoHost.NOME_REPETIDO }) + } + + @Test fun editarOProprioHostNaoColideComOProprioNome() { + val base = ConfigHosts(listOf(host(nome = "dev"))) + val r = aplicar(base, host(nome = "dev").copy(usuario = "outro")) as ResultadoEdicao.Ok + assertEquals(1, r.config.hosts.size) + assertEquals("outro", r.config.hosts.single().usuario) + } + + @Test fun agenteNuncaFicaLigadoEmHostQueOAppNaoAutenticaSozinho() { + val perguntar = aplicar(ConfigHosts(), host(auth = AutenticacaoHost.Perguntar, lembrar = false, agente = PoliticaAgente.PERMITIDO)) as ResultadoEdicao.Ok + assertEquals(PoliticaAgente.PROIBIDO, perguntar.host.agente) + val semSenha = aplicar(ConfigHosts(), host(auth = AutenticacaoHost.Senha, lembrar = false, agente = PoliticaAgente.PERMITIDO)) as ResultadoEdicao.Ok + assertEquals(PoliticaAgente.PROIBIDO, semSenha.host.agente) + val ok = aplicar(ConfigHosts(), host(auth = AutenticacaoHost.Senha, lembrar = true, agente = PoliticaAgente.PERMITIDO)) as ResultadoEdicao.Ok + assertEquals(PoliticaAgente.PERMITIDO, ok.host.agente) + val chavePassphrase = aplicar(ConfigHosts(), host(auth = AutenticacaoHost.Chave("k1"), lembrar = false, agente = PoliticaAgente.PERMITIDO)) { true } as ResultadoEdicao.Ok + assertEquals(PoliticaAgente.PROIBIDO, chavePassphrase.host.agente) + } + + @Test fun lembrarSenhaSoVaiComAutenticacaoPorSenha() { + val r = aplicar(ConfigHosts(), host(auth = AutenticacaoHost.Chave("k"), lembrar = true)) as ResultadoEdicao.Ok + assertEquals(false, r.host.lembrarSenha) + } + + @Test fun cinquentaHostsEOLimite() { + val cheio = ConfigHosts((1..50).map { host(id = "%08x".format(it), nome = "h$it") }) + val r = aplicar(cheio, host(id = "ffffffff", nome = "novo")) as ResultadoEdicao.Erros + assertTrue(r.erros.any { it.motivo == MotivoHost.LISTA_CHEIA }) + } + + @Test fun idNovoNaoColide() { + val ids = ArrayDeque(listOf("aaaaaaaa", "aaaaaaaa", "bbbbbbbb")) + assertEquals("bbbbbbbb", EdicaoDeHost.novoId(setOf("aaaaaaaa")) { ids.removeFirst() }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/GravadorDeHostsTest.kt b/app/src/test/java/com/keyflow/computador/GravadorDeHostsTest.kt new file mode 100644 index 00000000..bebc3ea0 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/GravadorDeHostsTest.kt @@ -0,0 +1,118 @@ +package com.keyflow.computador + +import com.keyflow.computador.chaves.ChaveSsh +import java.io.IOException +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GravadorDeHostsTest { + private class Dados : DadosDaEdicaoDeHost { + var config = ConfigHosts() + val segredos = mutableMapOf() + val eventos = mutableListOf() + var falhaNoCofre = false + var ignoraCofre = false + var falhaNoConfig = false + override suspend fun hostsOnce() = config + override suspend fun chavesOnce() = emptyList() + override fun senhaLembrada(hostId: String) = segredos[hostId] + override fun lembrarSenha(hostId: String, senha: String) { + eventos += "senha" + if (falhaNoCofre) throw IOException("cofre falso indisponível") + if (!ignoraCofre) segredos[hostId] = senha + } + override fun esquecerSenha(hostId: String) { eventos += "apagar"; segredos.remove(hostId) } + override suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts { + if (falhaNoConfig) throw IOException("DataStore falso indisponível") + config = f(config) + eventos += "config" + for (h in config.hosts) if (h.agentePodeUsar && h.autenticacao == AutenticacaoHost.Senha) { + assertTrue("a marca não pode ser publicada antes do segredo", !senhaLembrada(h.id).isNullOrEmpty()) + } + return config + } + } + private fun h(auth: AutenticacaoHost = AutenticacaoHost.Senha, lembrar: Boolean = true) = + Host("aaaaaaaa", "dev", "dev.example.org", 22, "yan", auth, lembrar, PoliticaAgente.PERMITIDO) + + @Test fun lembrarSemSenhaNaoGravaHostNemPermiteAgente() = runTest { + for (senha in listOf(null, "")) { + val d = Dados() + val r = GravadorDeHosts(d).salvar(h(), senha) as ResultadoEdicao.Erros + assertTrue(r.erros.any { it.motivo == MotivoHost.SENHA_NAO_INFORMADA }) + assertTrue(d.config.hosts.isEmpty()); assertTrue(d.eventos.isEmpty()) + } + } + @Test fun segredoEConfirmadoAntesDaPublicacaoDaMarca() = runTest { + val d = Dados() + val r = GravadorDeHosts(d).salvar(h(), "senha-falsa") as ResultadoEdicao.Ok + assertEquals(listOf("senha", "config"), d.eventos) + assertTrue(r.host.agentePodeUsar); assertEquals("senha-falsa", d.segredos[r.host.id]) + } + @Test fun edicaoSemNovaSenhaPreservaOSegredoAnterior() = runTest { + val d = Dados(); d.segredos["aaaaaaaa"] = "senha-falsa" + val r = GravadorDeHosts(d).salvar(h(), null) as ResultadoEdicao.Ok + assertTrue(r.host.agentePodeUsar); assertEquals(listOf("config"), d.eventos) + assertEquals("senha-falsa", d.segredos[r.host.id]) + } + @Test fun cofreQueFalhaOuIgnoraAGravacaoNaoPublicaPermissao() = runTest { + for (ignora in listOf(false, true)) { + val d = Dados(); d.falhaNoCofre = !ignora; d.ignoraCofre = ignora + val r = GravadorDeHosts(d).salvar(h(), "senha-falsa") as ResultadoEdicao.Erros + assertEquals(MotivoHost.SENHA_NAO_GUARDADA, r.erros.single().motivo) + assertTrue(d.config.hosts.isEmpty()); assertFalse(d.eventos.contains("config")) + } + } + @Test fun erroNosCamposNaoTocaNoCofre() = runTest { + val d = Dados() + assertTrue(GravadorDeHosts(d).salvar(h().copy(porta = 0), "senha-falsa") is ResultadoEdicao.Erros) + assertTrue(d.eventos.isEmpty()) + } + @Test fun erroNoDataStoreRestauraSenhaAnterior() = runTest { + val d = Dados(); d.segredos["aaaaaaaa"] = "anterior-falsa"; d.falhaNoConfig = true + try { GravadorDeHosts(d).salvar(h(), "nova-falsa"); throw AssertionError("deveria falhar") } catch (_: IOException) { } + assertEquals("anterior-falsa", d.segredos["aaaaaaaa"]); assertTrue(d.config.hosts.isEmpty()) + } + @Test fun chaveVaziaOuQueNaoExisteNaoPodeSerGravada() = runTest { + for (id in listOf("", " ", "inexistente")) { + val d = Dados() + val r = GravadorDeHosts(d).salvar(h(AutenticacaoHost.Chave(id), false), null) as ResultadoEdicao.Erros + assertEquals(MotivoHost.CHAVE_NAO_GUARDADA, r.erros.single().motivo) + assertTrue(d.config.hosts.isEmpty()) + } + } + @Test fun esquecerSenhaPrimeiroPublicaHostSemPermissaoEDepoisApaga() = runTest { + val d = Dados(); d.config = ConfigHosts(listOf(h())); d.segredos["aaaaaaaa"] = "senha-falsa" + val r = GravadorDeHosts(d).salvar(h(lembrar = false), null) as ResultadoEdicao.Ok + assertFalse(r.host.agentePodeUsar); assertFalse(r.host.lembrarSenha) + assertEquals(listOf("config", "apagar"), d.eventos); assertTrue(d.segredos.isEmpty()) + } + @Test fun cancelarEnquantoODataStoreConfirmaNaoDeixaPermissaoSemSenha() = runTest { + val d = Dados() + val iniciou = CompletableDeferred() + val liberar = CompletableDeferred() + val porta = object : DadosDaEdicaoDeHost by d { + override suspend fun atualizarHosts(f: (ConfigHosts) -> ConfigHosts): ConfigHosts { + // Como um ator persistente: pode confirmar antes de o chamador receber a resposta cancelada. + withContext(NonCancellable) { + iniciou.complete(Unit); liberar.await(); d.atualizarHosts(f) + } + currentCoroutineContext().ensureActive() + return d.config + } + } + val job = launch { GravadorDeHosts(porta).salvar(h(), "senha-falsa") } + iniciou.await(); job.cancel(); liberar.complete(Unit); job.join() + assertTrue(d.config.hosts.single().agentePodeUsar) + assertEquals("senha-falsa", d.segredos["aaaaaaaa"]) + } +} diff --git a/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt b/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt index f6da766f..28f71634 100644 --- a/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt +++ b/app/src/test/java/com/keyflow/computador/RegraAgenteTest.kt @@ -11,11 +11,19 @@ class RegraAgenteTest { @Test fun senhaSoComLembrar() { assertEquals(PodeMarcar.Nao(MotivoAgente.SENHA_NAO_LEMBRADA), RegraAgente.podeMarcar(h(AutenticacaoHost.Senha))) - assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Senha, lembrar = true))) + assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Senha, lembrar = true), credencialDisponivel = true)) } @Test fun chaveGuardadaPodeMasComPassphraseNaHoraNao() { - assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1")))) - assertEquals(PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE), RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1"))) { true }) + assertEquals(PodeMarcar.Sim, RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1")), credencialDisponivel = true)) + assertEquals(PodeMarcar.Nao(MotivoAgente.CHAVE_COM_PASSPHRASE), RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1")), credencialDisponivel = true) { true }) + } + @Test fun lembrarNaoEProvaDeSegredoGuardado() { + assertEquals(PodeMarcar.Nao(MotivoAgente.SENHA_INDISPONIVEL), RegraAgente.podeMarcar(h(AutenticacaoHost.Senha, lembrar = true))) + } + + @Test fun chaveVaziaOuIndisponivelNaoPermiteAgente() { + for (id in listOf("", " ")) assertEquals(PodeMarcar.Nao(MotivoAgente.CHAVE_NAO_GUARDADA), RegraAgente.podeMarcar(h(AutenticacaoHost.Chave(id)), credencialDisponivel = true)) + assertEquals(PodeMarcar.Nao(MotivoAgente.CHAVE_NAO_GUARDADA), RegraAgente.podeMarcar(h(AutenticacaoHost.Chave("k1")))) } } diff --git a/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt b/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt index c4727147..5599840f 100644 --- a/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt +++ b/app/src/test/java/com/keyflow/computador/ValidadorHostTest.kt @@ -58,4 +58,10 @@ class ValidadorHostTest { assertEquals(listOf(CampoHost.LIMITE to MotivoHost.LISTA_CHEIA), motivos(host(nome = "novo", id = "ffffffff"), cheios)) assertTrue(motivos(cheios[3], cheios).isEmpty()) } + @Test fun autenticacaoExigeReferenciaValidaEFatoSobreOSegredo() { + assertTrue(ValidadorHost.validar(host().copy(autenticacao = AutenticacaoHost.Chave(""))).any { it.motivo == MotivoHost.CHAVE_NAO_GUARDADA }) + val senha = host().copy(autenticacao = AutenticacaoHost.Senha, lembrarSenha = true) + assertTrue(ValidadorHost.validar(senha, credencialDisponivel = false).any { it.motivo == MotivoHost.SENHA_NAO_INFORMADA }) + assertTrue(ValidadorHost.validar(senha, credencialDisponivel = true).isEmpty()) + } } diff --git a/app/src/test/java/com/keyflow/computador/app/ComputadorPanelArquiteturaTest.kt b/app/src/test/java/com/keyflow/computador/app/ComputadorPanelArquiteturaTest.kt new file mode 100644 index 00000000..64c66db3 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/ComputadorPanelArquiteturaTest.kt @@ -0,0 +1,20 @@ +package com.keyflow.computador.app + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Test + +/** CMP-08: sem teste simulado. O painel não importa `delay` e a frase fixa antiga sumiu do código. */ +class ComputadorPanelArquiteturaTest { + private val arquivos = listOf("ComputadorPanel.kt", "ComputadorHosts.kt", "ComputadorHostModal.kt").map { File("src/main/java/com/keyflow/ui/config/$it") } + + @Test fun painelNaoImportaDelay() { + for (f in arquivos) assertFalse("${f.name} importa delay", f.readLines().any { it.trim() == "import kotlinx.coroutines.delay" }) + } + + @Test fun fraseFixaESemSimulacaoSairamDoCodigo() { + val todo = File("src/main/java").walkTopDown().filter { it.isFile && it.extension == "kt" }.joinToString("\n") { it.readText() } + assertFalse(todo.contains("Configuração SSH válida")) + assertFalse(arquivos.any { Regex("""\bdelay\(""").containsMatchIn(it.readText()) }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/DivergenciasDoParserTest.kt b/app/src/test/java/com/keyflow/computador/app/DivergenciasDoParserTest.kt new file mode 100644 index 00000000..bb017f98 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/DivergenciasDoParserTest.kt @@ -0,0 +1,104 @@ +package com.keyflow.computador.app + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Regressões da revisão: equivalências que o parser C reconhece e comparações literais não reconheciam. */ +class DivergenciasDoParserTest { + private fun bytes(s: String) = s.toByteArray(Charsets.ISO_8859_1) + private fun filtrar(s: String) = FiltroDaSaidaDoServidor().processar(bytes(s)) + + @Test fun s8c1tNaoLigaRespostasEmOitoBits() { + assertEquals(0, filtrar("\u001b G").size) + assertEquals(0, filtrar("\u001b \u0000\u007fG").size) + } + + @Test fun respostasC1SemEscTambemPassamPelaListaFechada() { + for (s in listOf("\u0090\$r0m\u009c", "\u009b21t", "\u009b>0q", "\u009b?1;1\$y", "\u009d52;c;c2VncmVkbw==\u009c")) { + assertEquals(s.toList().toString(), 0, FiltroDeRespostas().filtrar(bytes(s)).size) + } + assertArrayEquals(bytes("\u009b0n"), FiltroDeRespostas().filtrar(bytes("\u009b0n"))) + val utf8 = "ação ЛћÜ 日本".toByteArray() + assertArrayEquals(utf8, FiltroDeRespostas().filtrar(utf8)) + } + + /** A libvterm do app fica sempre em UTF-8 (Terminal.cpp) e ignora `ESC % @`: 0x9c não fecha a string depois dele. */ + @Test fun escPercentArrobaNaoDesligaUtf8() { + assertArrayEquals(bytes("\u001b%@"), filtrar("\u001b%@\u001b]52;c\u009c;?\u0007")) + val titulos = mutableListOf() + FiltroDaSaidaDoServidor { titulos += it }.processar(bytes("\u001b%@\u001b]0;\u009cTITULO\u0007")) + assertTrue(titulos.toString(), titulos.any { "TITULO" in it }) + } + + @Test fun risEDecstrVoltamAsSetasAoModoNormal() { + for (reset in listOf("\u001bc", "\u001b[!p")) { + val f = FiltroDaSaidaDoServidor() + f.processar(bytes("\u001b[?1h")) + assertTrue(f.modoAplicacaoDasSetas) + f.processar(bytes(reset)) + assertFalse(reset, f.modoAplicacaoDasSetas) + } + } + + @Test fun parametrosNumericosSaoNormalizados() { + for (s in listOf("\u001b[021t", "\u001b[00020t", "\u001b]052;c;?\u0007", "\u001b]021;?\u0007")) { + assertEquals(s.toList().toString(), 0, filtrar(s).size) + } + val titulos = mutableListOf() + val f = FiltroDaSaidaDoServidor { titulos += it } + val titulo = bytes("\u001b]00;servidor\u0007") + assertArrayEquals(titulo, f.processar(titulo)) + assertEquals(listOf("servidor"), titulos) + f.processar(bytes("\u001b[?01h")) + assertTrue(f.modoAplicacaoDasSetas) + f.processar(bytes("\u001b[?0001l")) + assertFalse(f.modoAplicacaoDasSetas) + } + + @Test fun seletoresDoClipboardNaoTemAtalhoQueLibereConsultaLonga() { + for (n in listOf(30, 9_000, 100_000, 1_100_000)) { + assertEquals(0, filtrar("\u001b]052;${"c".repeat(n)};?\u001b\\").size) + } + } + + @Test fun c0EDelNaoInterrompemCsiOuComandoDcs() { + for (c in listOf('\u0000', '\u0001', '\u0005', '\n', '\u007f')) { + assertEquals(0, filtrar("\u001b[02${c}1t").size) + assertEquals(0, filtrar("\u001bP${c}\$qm\u001b\\").size) + assertEquals(0, filtrar("\u001b]05${c}2;c;?\u0007").size) + } + } + + @Test fun cancelarTituloNaoPublicaTituloNemPrendeOProximoTexto() { + for (c in listOf('\u0018', '\u001a')) { + val titulos = mutableListOf() + val f = FiltroDaSaidaDoServidor { titulos += it } + val saiu = f.processar(bytes("\u001b]0;velho${c}texto\u001b]00;novo\u0007")) + assertEquals(listOf("novo"), titulos) + assertTrue(String(saiu, Charsets.ISO_8859_1).contains("texto")) + } + } + @Test fun controlesDoUsuarioIncluindoCtrlENaoSaoConsultasDoServidor() { + for (codigo in (0..31).toList()+127) { + val tecla = byteArrayOf(codigo.toByte()) + assertArrayEquals("Ctrl de código $codigo", tecla, FiltroDeRespostas().filtrar(tecla)) + val alt = byteArrayOf(27) + tecla + assertArrayEquals("Alt+Ctrl de código $codigo", alt, FiltroDeRespostas().filtrar(alt)) + } + assertEquals("ENQ do servidor continua bloqueado", 0, filtrar("\u0005").size) + val altEsc = byteArrayOf(27, 27) + assertArrayEquals(altEsc, FiltroDeRespostas().filtrar(altEsc)) + val altUnicode = byteArrayOf(27) + "ћ".toByteArray() + assertArrayEquals(altUnicode, FiltroDeRespostas().filtrar(altUnicode)) + } + @Test fun bytesIgnoradosDentroDeEscNaoEscondemComandos() { + for (codigo in 128..255) { + val ignorado = codigo.toChar() + assertEquals("S8C1T com $codigo", 0, filtrar("\u001b${ignorado} G").size) + assertEquals("CSI com $codigo", 0, filtrar("\u001b${ignorado}[021t").size) + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/FiltroDaSaidaDoServidorTest.kt b/app/src/test/java/com/keyflow/computador/app/FiltroDaSaidaDoServidorTest.kt new file mode 100644 index 00000000..d2b8facc --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/FiltroDaSaidaDoServidorTest.kt @@ -0,0 +1,74 @@ +package com.keyflow.computador.app + +import java.io.ByteArrayOutputStream +import kotlin.random.Random +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** O filtro de entrada tem estado (a rede parte as sequências onde quiser): o resultado não pode depender de onde parte. */ +class FiltroDaSaidaDoServidorTest { + private fun inteiro(bytes: ByteArray) = FiltroDaSaidaDoServidor().processar(bytes) + + private fun emPedacos(bytes: ByteArray, sorteio: Random): ByteArray { + val f = FiltroDaSaidaDoServidor() + val saida = ByteArrayOutputStream() + var i = 0 + while (i < bytes.size) { + val n = 1 + sorteio.nextInt(7) + val fim = minOf(bytes.size, i + n) + saida.write(f.processar(bytes.copyOfRange(i, fim))) + i = fim + } + return saida.toByteArray() + } + + private val vocabulario: List = Hostil.segmentos.map { it.second.toByteArray(Charsets.UTF_8) } + listOf( + "texto simples\r\n", "\u001b[1;31mcor\u001b[0m", "\u001b[2J", "\u001b]8;;https://a.b/c\u001b\\", "\u001b[?1049h", "ação ✓ 日本", + "\u001b[21", "\u001b]52;", "\u001bP", "\u001b", "\u0018", "\u0007", "t", "?", ";", + ).map { it.toByteArray(Charsets.UTF_8) } + + @Test fun qualquerDivisaoDaOMesmoResultado() { + val sorteio = Random(20261031) + repeat(400) { + val fluxo = ByteArrayOutputStream() + repeat(1 + sorteio.nextInt(12)) { fluxo.write(vocabulario[sorteio.nextInt(vocabulario.size)]) } + val bytes = fluxo.toByteArray() + val esperado = inteiro(bytes) + repeat(5) { assertArrayEquals("fluxo ${bytes.toList()}", esperado, emPedacos(bytes, sorteio)) } + } + } + + @Test fun bytesAleatoriosNuncaQuebramEUmByteDeCadaVez() { + val sorteio = Random(7) + repeat(300) { + val bytes = ByteArray(sorteio.nextInt(1, 300)) { (if (sorteio.nextInt(3) == 0) 0x1b else sorteio.nextInt(256)).toByte() } + val esperado = inteiro(bytes) + assertArrayEquals(esperado, emPedacos(bytes, sorteio)) + } + } + + @Test fun nadaProibidoSobraDepoisDoFiltroMesmoComRemocoesQueSeEmendam() { + val sorteio = Random(99) + val proibidos = listOf("\u001b[21t", "\u001b[20t", "\u001bP\$q", "\u001b]52;c;?", "\u001b]52;;?", "\u001b]21", "\u0005") + repeat(600) { + val fluxo = ByteArrayOutputStream() + repeat(1 + sorteio.nextInt(14)) { fluxo.write(vocabulario[sorteio.nextInt(vocabulario.size)]) } + val saiu = inteiro(fluxo.toByteArray()) + for (p in proibidos) assertFalse("sobrou ${p.replace("\u001b", "ESC")} em ${fluxo.toByteArray().toList()}", saiu.contem(p)) + } + } + + @Test fun textoComumPassaIgual() { + val comum = "ls -la\r\ntotal 0\r\n\u001b[1;32mok\u001b[0m ação\r\n".toByteArray(Charsets.UTF_8) + assertArrayEquals(comum, inteiro(comum)) + } + + @Test fun reiniciarLimpaOQueEstavaSegurado() { + val f = FiltroDaSaidaDoServidor() + f.processar("\u001b[2".toByteArray()) + f.reiniciar() + assertEquals("1t", String(f.processar("1t".toByteArray()))) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/Hostil.kt b/app/src/test/java/com/keyflow/computador/app/Hostil.kt new file mode 100644 index 00000000..085c97b2 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/Hostil.kt @@ -0,0 +1,48 @@ +package com.keyflow.computador.app + +/** A gravação "hostil" (CMP-35): um trecho por ataque ou consulta. O arquivo `hostil.bin` é a concatenação, e o teste confere. */ +object Hostil { + const val ESC = "\u001b" + const val BEL = "\u0007" + const val ST = "\u001b\\" + + val segmentos: List> = listOf( + "texto" to "Olá, terminal!\r\n", + "da1" to "$ESC[c", + "da2" to "$ESC[>c", + "dsr" to "$ESC[5n", + "cpr" to "$ESC[6n", + "cor10" to "$ESC]10;?$BEL", + "cor11" to "$ESC]11;?$BEL", + "kitty-q" to "${ESC}_Ga=q,t=d,f=24,s=1,v=1,i=50;AAAA$ST", + "osc52-leitura" to "$ESC]52;c;?$BEL", + "osc52-leitura-st" to "$ESC]52;;?$ST", + "osc52-escrita" to "$ESC]52;c;aGVsbG8=$BEL", + "relatorio-titulo-21" to "$ESC[21t", + "relatorio-titulo-20" to "$ESC[20t", + "osc21" to "$ESC]21;?$ST", + "decrqss" to "${ESC}P\$qm$ST", + "enq" to "\u0005", + "titulo" to "$ESC]0;build-box: ~/app$BEL", + "link-javascript" to "$ESC]8;;javascript:alert(1)${ST}clique$ESC]8;;$ST", + "link-file" to "$ESC]8;;file:///etc/passwd${ST}arquivo$ESC]8;;$ST", + "link-https" to "$ESC]8;;https://exemplo.org/a${ST}site$ESC]8;;$ST", + "final" to "fim\r\n", + ) + + fun trecho(nome: String): String = segmentos.first { it.first == nome }.second + fun tudo(): ByteArray = segmentos.joinToString("") { it.second }.toByteArray(Charsets.UTF_8) + fun bytes(nome: String): ByteArray = trecho(nome).toByteArray(Charsets.UTF_8) +} + +/** Procura uma sequência de bytes dentro de outra (os testes comparam bytes, não texto). */ +fun ByteArray.contem(trecho: ByteArray): Boolean { + if (trecho.isEmpty()) return true + outer@ for (i in 0..size - trecho.size) { + for (j in trecho.indices) if (this[i + j] != trecho[j]) continue@outer + return true + } + return false +} + +fun ByteArray.contem(texto: String) = contem(texto.toByteArray(Charsets.ISO_8859_1)) diff --git a/app/src/test/java/com/keyflow/computador/app/MapaTecladoFisicoTest.kt b/app/src/test/java/com/keyflow/computador/app/MapaTecladoFisicoTest.kt new file mode 100644 index 00000000..fbde7b1a --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/MapaTecladoFisicoTest.kt @@ -0,0 +1,153 @@ +package com.keyflow.computador.app + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MapaTecladoFisicoTest { + private fun b(s: String) = s.toByteArray(Charsets.ISO_8859_1) + private val ESC = "\u001b" + private val ctrl = Modificadores(ctrl = true) + private val alt = Modificadores(alt = true) + + private fun mapa(t: Tecla, m: Modificadores = Modificadores.Nenhum, app: Boolean = false) = MapaTecladoFisico.bytes(t, m, app) + + @Test fun ctrlLetraVira0x01a0x1a() { + assertArrayEquals(byteArrayOf(0x03), mapa(Tecla.Caractere('c'), ctrl)) + assertArrayEquals(byteArrayOf(0x01), mapa(Tecla.Caractere('a'), ctrl)) + assertArrayEquals(byteArrayOf(0x1a), mapa(Tecla.Caractere('z'), ctrl)) + assertArrayEquals(byteArrayOf(0x04), mapa(Tecla.Caractere('d'), ctrl)) + } + + @Test fun ctrlAltLetraTemEscNaFrente() { + assertArrayEquals(byteArrayOf(0x1b, 0x03), mapa(Tecla.Caractere('c'), Modificadores(ctrl = true, alt = true))) + } + + @Test fun altLetraViraEscLetra() { + assertArrayEquals(b("${ESC}x"), mapa(Tecla.Caractere('x'), alt)) + assertArrayEquals(b("${ESC}X"), mapa(Tecla.Caractere('x'), Modificadores(alt = true, shift = true))) + } + + @Test fun letraSemModificadorNaoEMapeada() { + assertNull(mapa(Tecla.Caractere('a'))) + assertNull(mapa(Tecla.Caractere('a'), Modificadores(shift = true))) + } + + @Test fun controlesEspeciais() { + assertArrayEquals(byteArrayOf(0x00), mapa(Tecla.Caractere(' '), ctrl)) + assertArrayEquals(byteArrayOf(0x1b), mapa(Tecla.Caractere('['), ctrl)) + assertArrayEquals(byteArrayOf(0x1c), mapa(Tecla.Caractere('\\'), ctrl)) + } + + @Test fun enterTabEscBackspace() { + assertArrayEquals(byteArrayOf(0x0d), mapa(Tecla.Enter)) + assertArrayEquals(byteArrayOf(0x09), mapa(Tecla.Tab)) + assertArrayEquals(b("$ESC[Z"), mapa(Tecla.Tab, Modificadores(shift = true))) + assertArrayEquals(byteArrayOf(0x1b), mapa(Tecla.Esc)) + assertArrayEquals(byteArrayOf(0x7f), mapa(Tecla.Backspace)) + assertArrayEquals(byteArrayOf(0x1b, 0x7f), mapa(Tecla.Backspace, alt)) + assertArrayEquals(b("$ESC[3~"), mapa(Tecla.Delete)) + } + + @Test fun setasNormaisEEmModoDeAplicacao() { + assertArrayEquals(b("$ESC[A"), mapa(Tecla.Cima)) + assertArrayEquals(b("$ESC[B"), mapa(Tecla.Baixo)) + assertArrayEquals(b("$ESC[C"), mapa(Tecla.Direita)) + assertArrayEquals(b("$ESC[D"), mapa(Tecla.Esquerda)) + assertArrayEquals(b("${ESC}OA"), mapa(Tecla.Cima, app = true)) + assertArrayEquals(b("${ESC}OD"), mapa(Tecla.Esquerda, app = true)) + assertArrayEquals(b("${ESC}OH"), mapa(Tecla.Home, app = true)) + assertArrayEquals(b("$ESC[H"), mapa(Tecla.Home)) + assertArrayEquals(b("$ESC[F"), mapa(Tecla.End)) + } + + @Test fun setaComModificadorUsaFormaDoXterm() { + assertArrayEquals(b("$ESC[1;5C"), mapa(Tecla.Direita, ctrl)) + assertArrayEquals(b("$ESC[1;3A"), mapa(Tecla.Cima, alt, app = true)) + assertArrayEquals(b("$ESC[1;2B"), mapa(Tecla.Baixo, Modificadores(shift = true))) + assertArrayEquals(b("$ESC[1;7D"), mapa(Tecla.Esquerda, Modificadores(ctrl = true, alt = true))) + } + + @Test fun paginasEFuncoes() { + assertArrayEquals(b("$ESC[5~"), mapa(Tecla.PageUp)) + assertArrayEquals(b("$ESC[6~"), mapa(Tecla.PageDown)) + assertArrayEquals(b("${ESC}OP"), mapa(Tecla.F(1))) + assertArrayEquals(b("${ESC}OS"), mapa(Tecla.F(4))) + assertArrayEquals(b("$ESC[15~"), mapa(Tecla.F(5))) + assertArrayEquals(b("$ESC[17~"), mapa(Tecla.F(6))) + assertArrayEquals(b("$ESC[21~"), mapa(Tecla.F(10))) + assertArrayEquals(b("$ESC[23~"), mapa(Tecla.F(11))) + assertArrayEquals(b("$ESC[24~"), mapa(Tecla.F(12))) + assertArrayEquals(b("$ESC[15;5~"), mapa(Tecla.F(5), ctrl)) + assertArrayEquals(b("$ESC[1;2P"), mapa(Tecla.F(1), Modificadores(shift = true))) + } + + @Test fun todasAsTeclasSaemPeloFiltroDeRespostas() { + // O que o mapa produz nunca é barrado pelo filtro de saída (senão a tecla morreria em silêncio). + val filtro = FiltroDeRespostas() + val teclas = listOf(Tecla.Enter, Tecla.Tab, Tecla.Esc, Tecla.Backspace, Tecla.Delete, Tecla.Insert, Tecla.Cima, Tecla.Baixo, Tecla.Esquerda, Tecla.Direita, Tecla.Home, Tecla.End, Tecla.PageUp, Tecla.PageDown) + + (1..12).map { Tecla.F(it) } + ('a'..'z').map { Tecla.Caractere(it) } + for (t in teclas) for (m in listOf(Modificadores.Nenhum, ctrl, alt, Modificadores(ctrl = true, alt = true, shift = true))) for (app in listOf(false, true)) { + val x = mapa(t, m, app) ?: continue + assertArrayEquals("$t $m $app", x, filtro.filtrar(x)) + } + } + + @Test fun duplicataDoMesmoEventoEIgnoradaMasTeclasIguaisSeguidasNao() { + val d = DeduplicadorDeTeclas() + assertFalse(d.duplicada(0, 29, 1000, 1000, 0)) + assertTrue("mesmo evento por outro caminho", d.duplicada(0, 29, 1000, 1000, 0)) + assertFalse("outra pressão do 'a' (outro downTime)", d.duplicada(0, 29, 1500, 1500, 0)) + assertFalse("tecla segurada repetindo", d.duplicada(0, 29, 1500, 1540, 1)) + assertFalse("soltar é outro evento", d.duplicada(1, 29, 1500, 1600, 0)) + assertTrue(d.duplicada(1, 29, 1500, 1600, 0)) + } + + @Test fun pegajososUmToqueUmaVezDoisTravamTresSoltam() { + val p = ModificadoresPegajosos() + assertEquals(Modificadores.Nenhum, p.atuais()) + p.tocarCtrl() + assertEquals(EstadoPegajoso.UMA_VEZ, p.estadoCtrl()) + assertTrue(p.atuais().ctrl) + p.consumir() + assertFalse("uma vez se desfaz", p.atuais().ctrl) + p.tocarCtrl(); p.tocarCtrl() + assertEquals(EstadoPegajoso.TRAVADO, p.estadoCtrl()) + p.consumir() + assertTrue("travado fica", p.atuais().ctrl) + p.tocarCtrl() + assertEquals(EstadoPegajoso.DESLIGADO, p.estadoCtrl()) + p.tocarAlt() + assertEquals(Modificadores(alt = true), p.atuais()) + } + + @Test fun guardaSeguraTextoComQuebraMasNaoOEnterNemOsEscapes() { + val g = GuardaDeLinhas(janelaMs = 1000) + fun av(s: String, t: Long = 0) = g.avaliar(s.toByteArray(), t) + assertTrue(av("\r") === GuardaDeLinhas.Decisao.Enviar) + assertTrue(av("ls -la") === GuardaDeLinhas.Decisao.Enviar) + assertTrue(av("olá mundo") === GuardaDeLinhas.Decisao.Enviar) + assertTrue(av("${ESC}[200~a\nb${ESC}[201~") === GuardaDeLinhas.Decisao.Enviar) + val segurou = av("olá\nmundo") + assertTrue(segurou is GuardaDeLinhas.Decisao.Segurar) + assertEquals("olá\nmundo", (segurou as GuardaDeLinhas.Decisao.Segurar).texto) + // Colagem já confirmada passa pela janela e volta a segurar depois dela. + g.liberar(10_000) + assertTrue(av("a\nb", 10_500) === GuardaDeLinhas.Decisao.Enviar) + assertTrue(av("a\nb", 11_500) is GuardaDeLinhas.Decisao.Segurar) + } + + @Test fun modoDeAplicacaoVemDoDeckmDoServidor() { + val f = FiltroDaSaidaDoServidor() + assertFalse(f.modoAplicacaoDasSetas) + f.processar("${ESC}[?1h".toByteArray()) + assertTrue(f.modoAplicacaoDasSetas) + f.processar("${ESC}[?25l${ESC}[?1;7l".toByteArray()) + assertFalse(f.modoAplicacaoDasSetas) + f.processar("${ESC}[?1049h".toByteArray()) + assertFalse("1049 não é 1", f.modoAplicacaoDasSetas) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/ParserLibvtermTest.kt b/app/src/test/java/com/keyflow/computador/app/ParserLibvtermTest.kt new file mode 100644 index 00000000..fd54d0e3 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/ParserLibvtermTest.kt @@ -0,0 +1,112 @@ +package com.keyflow.computador.app + +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.ClassRule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** O oráculo compila o C original DURANTE os unitários do pool: não simula o parser em Kotlin. */ +class ParserLibvtermTest { + private fun bytes(s: String) = s.toByteArray(Charsets.ISO_8859_1) + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 255) } + private data class Observado(val proibidos: Int, val modo: Boolean, val estado: Int, val texto: String) + + private fun analisar(fluxos: List, oitoBits: Boolean = false): List { + val out = temporarios.newFile() + val erro = temporarios.newFile() + val cmd = mutableListOf(executavel().absolutePath).apply { if (oitoBits) add("8bit") } + val p = ProcessBuilder(cmd).redirectOutput(out).redirectError(erro).start() + p.outputStream.bufferedWriter().use { w -> fluxos.forEach { w.write(hex(it)); w.newLine() } } + assertTrue("oráculo excedeu 20 s", p.waitFor(20, TimeUnit.SECONDS)) + assertEquals(erro.readText(), 0, p.exitValue()) + return out.readLines().map { + val c = it.split(' ', limit = 4) + Observado(c[0].toInt(), c[1] == "1", c[2].toInt(), c.getOrElse(3) { "" }) + }.also { assertEquals(fluxos.size, it.size) } + } + + @Test fun oraculoReconheceOsAtaquesDaRevisao() { + val ataques = listOf("\u0005", "\u001b G", "\u001b[021t", "\u001b\u00ff G", "\u001b\u00ff[021t", "\u001b]021;?\u0007", "\u001b]052;${"c".repeat(40)};?\u001b\\", "\u001bP\u0000\$qm\u001b\\") + val brutos = analisar(ataques.map(::bytes)) + assertEquals(listOf(1, 2, 4, 2, 4, 8, 16, 32), brutos.map { it.proibidos }) + val filtrados = analisar(ataques.map { FiltroDaSaidaDoServidor().processar(bytes(it)) }) + assertTrue(filtrados.all { it.proibidos == 0 && it.estado == 0 }) + } + + @Test fun escapeDentroDeStringNaoPrendeOLibvtermDepoisDaRemocao() { + val fluxos = listOf("\u001b]0;velho\u001b[21tMARCADOR", "\u001bPzvelho\u001b]52;c;?\u0007MARCADOR") + val original = analisar(fluxos.map(::bytes)) + val filtrado = analisar(fluxos.map { FiltroDaSaidaDoServidor().processar(bytes(it)) }) + original.zip(filtrado).forEach { (a, b) -> + assertEquals(0, b.proibidos) + assertEquals(0, b.estado) + assertTrue(a.texto.endsWith(hex(bytes("MARCADOR")))) + assertTrue(b.texto.endsWith(hex(bytes("MARCADOR")))) + } + } + + @Test fun fuzzComparaFluxosFiltradosComOParserRealEConfereDecckm() { + val r = Random(20261031) + val vocabulario = listOf("\u001b\u00ff G", "\u001b\u00ff[021t", "texto", "\u001b[021t", "\u001b[20t", "\u001b]052;cccc;?\u0007", "\u001b]021;?\u001b\\", "\u001bP\u0000\$qm\u001b\\", "\u001b G", "\u001b[?01h", "\u001b[?1l", "\u001b[2\u00051t", "\u001b]00;nome\u001b\\", "\u0018", "\u001a", "\u007f", "\u0000", "\u001b", "\u001b]8;;https://a.b/\u001b\\", "\u001bP", "\u001b]52;", "\u0007") + val modos = mutableListOf() + val filtrados = List(500) { + val original = buildString { repeat(r.nextInt(1, 15)) { append(vocabulario.random(r)) }; append("\u0018FIM") } + val f = FiltroDaSaidaDoServidor() + val out = ByteArrayOutputStream() + val b = bytes(original) + var i = 0 + while (i < b.size) { val fim = minOf(b.size, i+r.nextInt(1, 8)); out.write(f.processar(b.copyOfRange(i, fim))); i=fim } + modos += f.modoAplicacaoDasSetas + out.toByteArray() + } + analisar(filtrados).forEachIndexed { i, o -> + assertEquals("fluxo $i: ${hex(filtrados[i])}", 0, o.proibidos) + assertEquals("estado do fluxo $i", 0, o.estado) + assertEquals("DECCKM do fluxo $i", modos[i], o.modo) + assertTrue("texto posterior ao fluxo $i", o.texto.endsWith(hex(bytes("FIM")))) + } + } + + @Test fun bytesArbitrariosFiltradosNaoDeixamConsultaNemPrefixoAbertoNoParserReal() { + val r = Random(96) + val fim = bytes("FIM") + val filtrados = List(300) { + val b = ByteArray(r.nextInt(1, 300)) { (if (r.nextInt(3) == 0) 27 else r.nextInt(256)).toByte() } + FiltroDaSaidaDoServidor().processar(b)+fim + } + analisar(filtrados).forEachIndexed { i, o -> + assertEquals("consultas do fluxo $i", 0, o.proibidos) + assertEquals("estado do fluxo $i", 0, o.estado) + assertTrue("texto posterior do fluxo $i: ${hex(filtrados[i])}", o.texto.endsWith(hex(fim))) + } + } + + @Test fun respostasC1ProibidasNaoChegamAoParserDoServidor() { + val ataques = listOf("\u0090\$qm\u009c", "\u009b021t", "\u009d052;c;?\u009c") + assertEquals(listOf(32, 4, 16), analisar(ataques.map(::bytes), true).map { it.proibidos }) + assertTrue(analisar(ataques.map { FiltroDeRespostas().filtrar(bytes(it)) }, true).all { it.proibidos == 0 }) + } + + companion object { + @get:ClassRule @JvmStatic val temporarios = TemporaryFolder() + private var oraculo: File? = null + @Synchronized private fun executavel(): File { + oraculo?.let { return it } + val lib = File("../third_party/termlib/src/main/cpp/libvterm").canonicalFile + val exe = File(temporarios.root, "parser-libvterm") + val log = File(temporarios.root, "compilacao.log") + val fontes = lib.resolve("src").listFiles()!!.filter { it.extension == "c" }.map { it.path } + val cmd = listOf("cc", "-std=gnu11", "-DVTERM_STATIC", "-I${lib.resolve("include")}", "-I${lib.resolve("src")}", "src/test/resources/computador/parser/oraculo.c") + fontes + listOf("-o", exe.path) + val p = ProcessBuilder(cmd).redirectErrorStream(true).redirectOutput(log).start() + assertTrue("compilação do oráculo excedeu 30 s", p.waitFor(30, TimeUnit.SECONDS)) + assertEquals(log.readText(), 0, p.exitValue()) + oraculo = exe + return exe + } + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/PoliticasDoTerminalTest.kt b/app/src/test/java/com/keyflow/computador/app/PoliticasDoTerminalTest.kt new file mode 100644 index 00000000..9123a773 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/PoliticasDoTerminalTest.kt @@ -0,0 +1,73 @@ +package com.keyflow.computador.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PoliticasDoTerminalTest { + private val ESC = "\u001b" + + @Test fun colarUmaLinhaNaoPedeConfirmacao() { + val p = PoliticaDeColar.preparar("ls -la") + assertFalse(p.precisaConfirmar) + assertEquals(1, p.linhas) + assertEquals("ls -la", p.texto) + } + + @Test fun colarVariasLinhasPedeConfirmacaoEContaAsLinhas() { + val p = PoliticaDeColar.preparar("a\nb") + assertTrue(p.precisaConfirmar) + assertEquals(2, p.linhas) + assertEquals(3, PoliticaDeColar.preparar("a\r\nb\rc").linhas) + // Uma quebra no fim executa o comando: também confirma. + assertTrue(PoliticaDeColar.preparar("rm -rf x\n").precisaConfirmar) + assertTrue(PoliticaDeColar.preparar("a\rb").precisaConfirmar) + } + + @Test fun colarRemoveOFimDoBracketedPaste() { + assertEquals("ab", PoliticaDeColar.preparar("a$ESC[201~b").texto) + assertEquals("ab", PoliticaDeColar.preparar("a$ESC[200~b").texto) + } + + @Test fun colarComMarcaAninhadaNaoReconstroiOFim() { + // Remover uma vez só deixaria `ESC [ 2 0 1 ~` de pé: o texto de dentro forma a marca de novo. + val p = PoliticaDeColar.preparar("a$ESC$ESC[201~[201~b; rm -rf ~") + assertFalse(p.texto.contains("[201~")) + assertFalse(p.texto.contains(ESC)) + } + + @Test fun colarRemoveControlesMasMantemTabENovaLinha() { + val p = PoliticaDeColar.preparar("a\tb\u0001\u0007\u007f\u009bc\nd") + assertEquals("a\tbc\nd", p.texto) + } + + @Test fun colarVazioNaoTemLinhas() { + assertEquals(0, PoliticaDeColar.preparar("").linhas) + assertEquals("", PoliticaDeColar.preparar("$ESC[201~").texto) + } + + @Test fun linksSoHttpHttpsEMailto() { + assertEquals("https://exemplo.org/a?b=c#d", PoliticaDeLinks.aceitar(" https://exemplo.org/a?b=c#d ")) + assertEquals("HTTP://exemplo.org", PoliticaDeLinks.aceitar("HTTP://exemplo.org")) + assertEquals("mailto:yan@exemplo.org", PoliticaDeLinks.aceitar("mailto:yan@exemplo.org")) + for (ruim in listOf( + "javascript:alert(1)", "JaVaScRiPt:alert(1)", "file:///etc/passwd", "intent://x#Intent;end", "content://x/y", "data:text/html,x", "tel:123", "ssh://h", + "https:exemplo.org", "https://", "https://user@exemplo.org", "https://exemplo.org/a b", "https://a.b/\u0000", "https://a.b/‮", "/relativo", "", "sem-esquema", + "https://" + "a".repeat(3000), "ht tp://a.b", "java\nscript:x", + )) assertNull("devia recusar: $ruim", PoliticaDeLinks.aceitar(ruim)) + } + + @Test fun rotuloSemControlesEAte40() { + assertEquals("build-box", RotuloDeAba.sanitizar(" build-box \u0001")) + assertEquals("a b", RotuloDeAba.sanitizar("a\n\n b")) + assertNull(RotuloDeAba.sanitizar("\u0001\u0002 ‮")) + assertEquals(40, RotuloDeAba.sanitizar("x".repeat(100))!!.length) + assertEquals("日本語ação", RotuloDeAba.sanitizar("日本語ação")) + // Par substituto (emoji) nunca é cortado ao meio. + val emoji = RotuloDeAba.sanitizar("😀".repeat(60))!! + assertEquals(40, emoji.codePointCount(0, emoji.length)) + assertFalse(emoji.last().isHighSurrogate()) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/SequenciasHostisTest.kt b/app/src/test/java/com/keyflow/computador/app/SequenciasHostisTest.kt new file mode 100644 index 00000000..08262b14 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/SequenciasHostisTest.kt @@ -0,0 +1,124 @@ +package com.keyflow.computador.app + +import java.io.File +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * CMP-35 com a gravação hostil: o que **chega** ao emulador (já filtrado) e o que **sai** dele rumo ao servidor. + * As respostas do emulador real a estas consultas são conferidas no instrumentado (`LigacaoEmuladorTest`); aqui as respostas são escritas à mão, + * pelo que um emulador **poderia** devolver, para provar que a lista de permitidos as separa. + */ +class SequenciasHostisTest { + private val ESC = "\u001b" + private val BEL = "\u0007" + private val ST = "\u001b\\" + + private fun entrada(bytes: ByteArray): ByteArray = FiltroDaSaidaDoServidor().processar(bytes) + private fun saida(texto: String): ByteArray = FiltroDeRespostas().filtrar(texto.toByteArray(Charsets.ISO_8859_1)) + + @Test fun oArquivoEAConcatenacaoDosTrechos() { + val arquivo = File("src/test/resources/computador/gravacoes/hostil.bin").readBytes() + assertArrayEquals(Hostil.tudo(), arquivo) + } + + @Test fun oQueChegaAoEmuladorNaoTemOQueNaoPodeSerRespondido() { + val chegou = entrada(Hostil.tudo()) + assertFalse("ENQ", chegou.contains(0x05)) + assertFalse("CSI 21 t", chegou.contem("${ESC}[21t")) + assertFalse("CSI 20 t", chegou.contem("${ESC}[20t")) + assertFalse("OSC 21", chegou.contem("${ESC}]21")) + assertFalse("DECRQSS", chegou.contem("${ESC}P\$q")) + assertFalse("OSC 52 de leitura", chegou.contem("52;c;?") || chegou.contem("52;;?")) + } + + @Test fun oQuePrecisaPassarPassa() { + val chegou = entrada(Hostil.tudo()) + for (nome in listOf("texto", "da1", "da2", "dsr", "cpr", "cor10", "cor11", "kitty-q", "osc52-escrita", "titulo", "link-https", "final")) { + assertTrue("Faltou $nome", chegou.contem(Hostil.bytes(nome))) + } + // O OSC 52 de **escrita** chega ao termlib, que o entrega ao consentimento. + assertTrue(chegou.contem("52;c;aGVsbG8=")) + } + + @Test fun cadaAtaqueEConsumidoSozinho() { + for (nome in listOf("osc52-leitura", "osc52-leitura-st", "relatorio-titulo-21", "relatorio-titulo-20", "osc21", "decrqss", "enq")) { + assertEquals("O trecho $nome devia sumir inteiro", 0, entrada(Hostil.bytes(nome)).size) + } + } + + @Test fun osLinksPerigososNuncaViramIntent() { + // O OSC 8 chega ao emulador (o texto "clique" aparece); quem decide abrir é `PoliticaDeLinks`, que recusa o esquema. + assertNull(PoliticaDeLinks.aceitar("javascript:alert(1)")) + assertNull(PoliticaDeLinks.aceitar("file:///etc/passwd")) + assertEquals("https://exemplo.org/a", PoliticaDeLinks.aceitar("https://exemplo.org/a")) + } + + @Test fun respostasPermitidasSaemEAsOutrasNao() { + // As que o emulador devolve (lista de permitidos). + for (r in listOf( + "$ESC[?1;2c", "$ESC[>0;0;0c", "$ESC[0n", "$ESC[12;40R", "$ESC[8;24;80t", "$ESC[4;384;640t", + "$ESC]10;rgb:ffff/ffff/ffff$BEL", "$ESC]11;rgb:0000/0000/0000$ST", "${ESC}_Gi=50;OK$ST", + )) assertArrayEquals(r, r.toByteArray(Charsets.ISO_8859_1), saida(r)) + // As que **não** podem sair, mesmo que um emulador as produza. + for (r in listOf( + "${ESC}P1\$r0m$ST", // resposta ao DECRQSS + "$ESC]lbuild-box$ST", // relatório de título + "${ESC}[21t", // pedido disfarçado de resposta + "$ESC]52;c;c2VnZWRv$BEL", // conteúdo do clipboard + "$ESC]52;c;?$BEL", + "$ESC]21;?$ST", + "${ESC}_Gi=50;ENOTSUP:" + "x".repeat(400) + ST, // mensagem enorme + "$ESC]10;rgb:ffff/ffff/ffff;extra$BEL", + )) assertEquals("Devia ser descartada: ${r.replace(ESC, "ESC")}", 0, saida(r).size) + } + + @Test fun teclasDoUsuarioSaemIntactas() { + val teclas = listOf( + "ls -la\r", "\u0003", "$ESC[A", "${ESC}OA", "${ESC}x", "$ESC[15~", "$ESC[1;5C", "$ESC[Z", "$ESC[200~", "$ESC[201~", ESC, "$ESC[3~", + "${ESC}[<0;5;3M", "$ESC[I", "${ESC}P", "${ESC}_", + ) + for (t in teclas) assertArrayEquals(t.replace(ESC, "ESC"), t.toByteArray(Charsets.ISO_8859_1), saida(t)) + assertEquals("ação é UTF-8", "ação", String(FiltroDeRespostas().filtrar("ação".toByteArray(Charsets.UTF_8)), Charsets.UTF_8)) + } + + @Test fun sequenciaTruncadaNaoVazaOResto() { + assertEquals(0, saida("$ESC]52;c;c2VnZWRv").size) + assertEquals(0, saida("${ESC}P1\$r0m").size) + assertEquals(0, saida("$ESC[1;").size) + } + + @Test fun naoSeJuntaUmaSequenciaProibidaRemovendoOMeio() { + // `ESC [ 2`, ENQ, `1 t`: um emulador tolerante junta tudo em CSI 21 t. O filtro descarta o pedaço aberto, então nada sobra para juntar. + val entrada = "${ESC}[2\u00051t".toByteArray(Charsets.ISO_8859_1) + val saiu = FiltroDaSaidaDoServidor().processar(entrada) + assertFalse(saiu.contem("${ESC}[21t")) + assertFalse(saiu.contem("${ESC}[2")) + // Controle dentro do CSI + o pedido de verdade logo depois. + val dois = FiltroDaSaidaDoServidor().processar("${ESC}[2\n${ESC}[21t1t".toByteArray(Charsets.ISO_8859_1)) + assertFalse(dois.contem("${ESC}[21t")) + assertFalse(dois.contem("[21t")) + // OSC 52 de leitura escondido atrás de um ESC que aborta o prefixo. + val tres = FiltroDaSaidaDoServidor().processar("${ESC}]5${ESC}[21t2;c;?$BEL".toByteArray(Charsets.ISO_8859_1)) + assertFalse(tres.contem("52;c;?")) + assertFalse(tres.contem("[21t")) + } + + @Test fun oTituloVira40CaracteresSemControles() { + val rotulos = mutableListOf() + FiltroDaSaidaDoServidor { rotulos += it }.processar(Hostil.bytes("titulo")) + assertEquals(listOf("build-box: ~/app"), rotulos) + val longo = mutableListOf() + FiltroDaSaidaDoServidor { longo += it }.processar("$ESC]2;${"x".repeat(200)}\u0007\u0001".toByteArray()) + assertEquals(40, longo.single().length) + val controles = mutableListOf() + FiltroDaSaidaDoServidor { controles += it }.processar("$ESC]0;a\u0001\u0002b‮evil$ST".toByteArray(Charsets.UTF_8)) + assertEquals("abevil", controles.single()) + } +} + +private fun ByteArray.contains(b: Int) = any { it.toInt() == b } diff --git a/app/src/test/java/com/keyflow/computador/app/TeclasExtrasTest.kt b/app/src/test/java/com/keyflow/computador/app/TeclasExtrasTest.kt new file mode 100644 index 00000000..71186d28 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/TeclasExtrasTest.kt @@ -0,0 +1,29 @@ +package com.keyflow.computador.app + +import com.keyflow.ui.terminal.teclasDeFuncao +import com.keyflow.ui.terminal.teclasExtras +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** CMP-26 e CMP-31: as teclas extras que o spec lista, todas com descrição para o leitor de tela. */ +class TeclasExtrasTest { + @Test fun primeiraLinhaTemAsTeclasDoSpec() { + assertEquals(listOf("esc", "tab", "ctrl", "alt", "esq", "baixo", "cima", "dir", "pipe", "til", "barra", "hifen"), teclasExtras().map { it.id }) + assertEquals(listOf("Esc", "Tab", "Ctrl", "Alt", "←", "↓", "↑", "→", "|", "~", "/", "-"), teclasExtras().map { it.rotulo }) + } + + @Test fun segundaLinhaTemF1AteF12() { + assertEquals((1..12).map { "F$it" }, teclasDeFuncao().map { it.rotulo }) + } + + @Test fun todaTeclaTemDescricaoUnica() { + val todas = teclasExtras() + teclasDeFuncao() + assertTrue(todas.all { it.descricao.isNotBlank() }) + assertEquals(todas.size, todas.map { it.descricao }.toSet().size) + } + + @Test fun apenasCtrlEAltSaoPegajosas() { + assertEquals(listOf("ctrl", "alt"), (teclasExtras() + teclasDeFuncao()).filter { it.pegajosa }.map { it.id }) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/TerminalActivityRegrasTest.kt b/app/src/test/java/com/keyflow/computador/app/TerminalActivityRegrasTest.kt new file mode 100644 index 00000000..686a9ce3 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/TerminalActivityRegrasTest.kt @@ -0,0 +1,45 @@ +package com.keyflow.computador.app + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** D17 e CMP-24: regras do terminal que se lê no código e no manifesto (o instrumentado confere a janela de verdade). */ +class TerminalActivityRegrasTest { + private val raizes = listOf(File("src/main/java/com/keyflow/computador/app"), File("src/main/java/com/keyflow/ui/terminal")) + private fun fontes() = raizes.flatMap { r -> r.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() } + + @Test fun nenhumArquivoDoTerminalPedeFlagSecure() { + val achados = fontes().filter { f -> f.readLines().any { l -> "FLAG_SECURE" in l && !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*") && !l.trimStart().startsWith("/*") } }.map { it.name } + assertEquals("FLAG_SECURE no terminal (D17: a Visão precisa capturar a tela)", emptyList(), achados) + assertFalse(fontes().any { "setSecure" in it.readText() }) + assertTrue(fontes().isNotEmpty()) + } + + @Test fun aActivityNaoSaiDoApp() { + val manifesto = File("src/main/AndroidManifest.xml").readText() + val bloco = manifesto.substringAfter("android:name=\".computador.app.TerminalActivity\"").substringBefore("/>") + assertTrue(bloco.contains("android:exported=\"false\"")) + assertTrue(bloco.contains("android:windowSoftInputMode=\"adjustResize\"")) + assertTrue(bloco.contains("android:launchMode=\"singleTask\"")) + assertFalse("sem intent-filter", bloco.contains("intent-filter")) + } + + @Test fun aBancadaDaT1FoiRemovida() { + assertFalse(File("src/debug/java/com/keyflow/computador/app/TerminalSpikeActivity.kt").exists()) + assertFalse(File("src/debug/AndroidManifest.xml").readText().contains("TerminalSpike")) + } + + @Test fun aTelaDoTerminalNaoTemAnuncio() { + val tela = fontes().joinToString("\n") { it.readText() } + for (proibido in listOf("AdMob", "AdView", "adsController", "Interstitial", "gms.ads")) assertFalse(proibido, tela.contains(proibido)) + } + + @Test fun aFonteEmbutidaTemLicencaOfl() { + assertTrue(File("src/main/assets/fonts/JetBrainsMonoNerdFontMono-Regular.ttf").length() > 100_000) + val licenca = File("licenses/OFL_JetBrainsMonoNerdFont.txt").readText() + assertTrue(licenca.contains("SIL OPEN FONT LICENSE Version 1.1")) + } +} diff --git a/app/src/test/java/com/keyflow/computador/app/TestadorDeConexaoTest.kt b/app/src/test/java/com/keyflow/computador/app/TestadorDeConexaoTest.kt new file mode 100644 index 00000000..303ff1d9 --- /dev/null +++ b/app/src/test/java/com/keyflow/computador/app/TestadorDeConexaoTest.kt @@ -0,0 +1,85 @@ +package com.keyflow.computador.app + +import com.keyflow.computador.AutenticacaoHost +import com.keyflow.computador.ChaveDoServidor +import com.keyflow.computador.Host +import com.keyflow.computador.RepositorioDeConfianca +import com.keyflow.computador.fixture.SshFixtureServer +import com.keyflow.computador.ssh.CausaSsh +import com.keyflow.computador.ssh.ResultadoTeste +import java.net.ServerSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** CMP-08 contra o `SshFixtureServer` de verdade: o teste é real, mostra impressão e latência e nunca passa a confiar no servidor. */ +class TestadorDeConexaoTest { + private val escopo = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private class ConfiancaMem : RepositorioDeConfianca { + val itens = mutableListOf>() + override fun chavesConhecidas(host: String, porta: Int) = itens.filter { it.first == host && it.second == porta }.map { it.third } + override fun confiar(host: String, porta: Int, chave: ChaveDoServidor) { itens += Triple(host, porta, chave) } + override fun esquecer(host: String, porta: Int) { itens.removeAll { it.first == host && it.second == porta } } + } + + private fun host(porta: Int, usuario: String = "senha", auth: AutenticacaoHost = AutenticacaoHost.Senha) = Host("a1b2c3d4", "fixture", "127.0.0.1", porta, usuario, auth) + + @Test fun mostraImpressaoMetodoELatenciaENaoPassaAConfiarNoServidor() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val base = ConfiancaMem() + val r = TestadorDeConexao(base, escopo).testar(host(s.porta), SshFixtureServer.SENHA_FALSA, null) + r as ResultadoTeste.Ok + assertEquals(s.hostSha256, r.impressao.texto) + assertEquals("password", r.metodo) + assertTrue(r.latenciaMs >= 0) + assertTrue("o teste não grava no known_hosts", base.itens.isEmpty()) + // E uma segunda vez também pergunta de novo (nada ficou). + assertTrue(TestadorDeConexao(base, escopo).testar(host(s.porta), SshFixtureServer.SENHA_FALSA, null) is ResultadoTeste.Ok) + } + } + + @Test fun senhaErradaEAutenticacaoESemSenhaTambem() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val t = TestadorDeConexao(ConfiancaMem(), escopo) + assertEquals(ResultadoTeste.Falhou(CausaSsh.AUTENTICACAO), t.testar(host(s.porta), "errada", null)) + assertEquals(ResultadoTeste.Falhou(CausaSsh.AUTENTICACAO), t.testar(host(s.porta, auth = AutenticacaoHost.Perguntar), null, null)) + } + } + + @Test fun portaFechadaDizOServidorRecusouEnuncaAFraseFixaAntiga() = runBlocking(Dispatchers.IO) { + val livre = ServerSocket(0).use { it.localPort } + val r = TestadorDeConexao(ConfiancaMem(), escopo).testar(host(livre), "x", null) + assertEquals(ResultadoTeste.Falhou(CausaSsh.RECUSADA), r) + assertEquals("O servidor recusou a conexão.", CausaSsh.RECUSADA.frase) + } + + @Test fun servidorConhecidoComChaveDiferenteContinuaBloqueado() = runBlocking(Dispatchers.IO) { + SshFixtureServer().use { s -> + val base = ConfiancaMem() + base.confiar("127.0.0.1", s.porta, ChaveDoServidor("ecdsa-sha2-nistp256", ByteArray(8) { 1 })) + val r = TestadorDeConexao(base, escopo).testar(host(s.porta), SshFixtureServer.SENHA_FALSA, null) + assertEquals(ResultadoTeste.Falhou(CausaSsh.CHAVE_DO_SERVIDOR_MUDOU), r) + } + } + + @Test fun tempoMaximoVira15SegundosDeTempo() = runBlocking(Dispatchers.IO) { + val parado = com.keyflow.computador.ssh.ResultadoTeste.Falhou(CausaSsh.TEMPO) + val fabrica = { _: RepositorioDeConfianca, _: com.keyflow.computador.ssh.InteracaoComUsuario, _: CoroutineScope -> + object : com.keyflow.computador.ssh.ConexaoSsh { + override val eventos = kotlinx.coroutines.flow.MutableSharedFlow() + override val saida = kotlinx.coroutines.flow.emptyFlow() + override suspend fun conectar(destino: com.keyflow.computador.ssh.DestinoSsh, pty: com.keyflow.computador.ssh.PtyInfo): com.keyflow.computador.ssh.ResultadoConexao = error("não") + override suspend fun testar(destino: com.keyflow.computador.ssh.DestinoSsh): ResultadoTeste = kotlinx.coroutines.awaitCancellation() + override suspend fun escrever(bytes: ByteArray) = Unit + override suspend fun redimensionar(pty: com.keyflow.computador.ssh.PtyInfo) = Unit + override suspend fun fechar() = Unit + } + } + assertEquals(parado, TestadorDeConexao(ConfiancaMem(), escopo, limiteMs = 200, fabrica = fabrica).testar(host(1), null, null)) + } +} diff --git a/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt b/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt index a56a8332..c2ee3f5f 100644 --- a/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt +++ b/app/src/test/java/com/keyflow/computador/sessao/GerenciadorDeSessoesTest.kt @@ -149,4 +149,20 @@ class GerenciadorDeSessoesTest { escopo.cancel() } } + + @Test fun desligarOAgenteNoHostFechaSoAsSessoesDoAgenteNeleEDesfazOCompartilhamento() = runTest { + val g = gerenciador(DependenciasFalsas { currentTime }) + val agenteNoHost = aberta(g.abrir(h(1), DonoDaSessao.AGENTE)) + val agenteOutro = aberta(g.abrir(h(2), DonoDaSessao.AGENTE)) + val usuario = aberta(g.abrir(h(1), DonoDaSessao.USUARIO)) + runCurrent() + g.compartilhar(usuario.id, true) + g.fecharDoAgenteNoHost(h(1).id) + runCurrent() + assertEquals(EstadoSessao.Encerrada(MotivoEncerramento.AGENTE_PARADO), agenteNoHost.estado.value) + assertEquals(EstadoSessao.Aberta, agenteOutro.estado.value) + assertEquals(EstadoSessao.Aberta, usuario.estado.value) + assertEquals(false, usuario.compartilhada) + assertTrue(g.sessoesVisiveisAoAgente().map { it.id } == listOf(agenteOutro.id)) + } } diff --git a/app/src/test/resources/computador/gravacoes/README.md b/app/src/test/resources/computador/gravacoes/README.md index bd447b9b..65be3933 100644 --- a/app/src/test/resources/computador/gravacoes/README.md +++ b/app/src/test/resources/computador/gravacoes/README.md @@ -13,6 +13,7 @@ Os executáveis geradores não são dependências do app, não são copiados par | `iterm2.bin` | Script oficial `gnachman/iTerm2-shell-integration`, `utilities/imgcat`: `bash imgcat -W 4 -H 2 keyflow-fixture.png` | | `img2sixel.bin` | libsixel 1.10.3: `img2sixel keyflow-fixture.png` | | `chafa.bin` | Chafa 1.12.4: `chafa --format=sixels --size=4x2 --animate=off keyflow-fixture.png` | +| `hostil.bin` | **Escrita à mão**, não de uma ferramenta: uma sequência de cada ataque da CMP-35 (OSC 52 de leitura e de escrita, `CSI 20/21 t`, `OSC 21`, DECRQSS, ENQ, título, links `javascript:`, `file:` e `https:`) mais as consultas permitidas (DA1, DA2, DSR, CPR, cores, kitty `a=q`). Os trechos estão em `SequenciasHostisTest` e o teste confere que a concatenação é este arquivo. | | `herdr.bin` | Herdr 0.9.5 real, sessão isolada `keyflow-cmp`, PTY 80×24/640×384; no pane, o comando de kitty acima. Captura até o primeiro placement completo da imagem. | Herdr: `HERDR_CONFIG_PATH` e todos os `XDG_*_HOME` apontaram para uma área de teste em `/tmp`; diff --git a/app/src/test/resources/computador/gravacoes/SHA256SUMS b/app/src/test/resources/computador/gravacoes/SHA256SUMS index 469e85fa..c1c07146 100644 --- a/app/src/test/resources/computador/gravacoes/SHA256SUMS +++ b/app/src/test/resources/computador/gravacoes/SHA256SUMS @@ -1,5 +1,6 @@ 66165840e9d5d338cf241bb339057de83fdaacfb90e2a4e7a71d9fd0d866cdde chafa.bin 77bd60ba474ce68d9d62af6e25836aba23e067a63c3e08cbe8b7b449f596097e herdr.bin +15bca99916584e449b47500a2234ef94de9bcb274c6b97a960a9ca0d6ef527d2 hostil.bin d486609cd5ccc3d28ce1edb6fe7027491b23a04e799117bdf19483806353dc9a img2sixel.bin a016f7bd56c85f4a34006316bc85a6313d7fd7c5ecd06d70640f4caffe331886 iterm2.bin b3672a4957ea188f9a9dfec557f2bb37a1d6907edca5d832f431a4d80a2a1659 keyflow-fixture.png diff --git a/app/src/test/resources/computador/gravacoes/hostil.bin b/app/src/test/resources/computador/gravacoes/hostil.bin new file mode 100644 index 00000000..a7589d17 --- /dev/null +++ b/app/src/test/resources/computador/gravacoes/hostil.bin @@ -0,0 +1,2 @@ +Olá, terminal! +[>c]10;?]11;?_Ga=q,t=d,f=24,s=1,v=1,i=50;AAAA\]52;c;?]52;;?\]52;c;aGVsbG8=]21;?\P$qm\]0;build-box: ~/app]8;;javascript:alert(1)\clique]8;;\]8;;file:///etc/passwd\arquivo]8;;\]8;;https://exemplo.org/a\site]8;;\fim diff --git a/app/src/test/resources/computador/parser/oraculo.c b/app/src/test/resources/computador/parser/oraculo.c new file mode 100644 index 00000000..9dad11e7 --- /dev/null +++ b/app/src/test/resources/computador/parser/oraculo.c @@ -0,0 +1,72 @@ +/* Oráculo de teste: usa o parser.c real, sem JNI nem uma segunda implementação do parser. */ +#include "vterm_internal.h" +#include +#include +#include + +typedef struct { + VTerm *vt; + unsigned proibidos; + int modo, selecao; + unsigned char ultimos[16]; + size_t textos; +} Observacao; +static int texto(const char *bytes, size_t len, void *user) { + Observacao *o = user; + size_t i = 0; + for (; i < len; i++) { + unsigned char b = bytes[i]; + if (b < 32 || b == 127 || (!vterm_get_utf8(o->vt) && b >= 128 && b < 160)) break; + o->ultimos[o->textos++ % 16] = b; + } + return i; +} +static int controle(unsigned char c, void *user) { + if (c == 5) ((Observacao *)user)->proibidos |= 1; + return 1; +} +static int escape(const char *s, size_t n, void *user) { + Observacao *o = user; + if (n == 2 && !memcmp(s, " G", 2)) o->proibidos |= 2; + return 1; +} +static int csi(const char *lider, const long args[], int n, const char *inter, char final, void *user) { + Observacao *o = user; + if (!lider && !inter && final == 't' && (CSI_ARG(args[0]) == 20 || CSI_ARG(args[0]) == 21)) o->proibidos |= 4; + if (lider && !strcmp(lider, "?") && !inter && (final == 'h' || final == 'l')) { + for (int i = 0; i < n; i++) if (CSI_ARG(args[i]) == 1) o->modo = final == 'h'; + } + return 1; +} +static int osc(int comando, VTermStringFragment f, void *user) { + Observacao *o = user; + if (comando == 21) o->proibidos |= 8; + if (f.initial) o->selecao = 0; + if (comando == 52) for (size_t i = 0; i < f.len; i++) { + if (o->selecao == 0) { if (f.str[i] == ';') o->selecao = 1; } + else if (o->selecao == 1) { if (f.str[i] == '?') o->proibidos |= 16; o->selecao = 2; } + } + return 1; +} +static int dcs(const char *comando, size_t n, VTermStringFragment f, void *user) { + (void)f; + if (n == 2 && !memcmp(comando, "$q", 2)) ((Observacao *)user)->proibidos |= 32; + return 1; +} +int main(int argc, char **argv) { + char *linha = NULL; size_t cap = 0; ssize_t n; + const VTermParserCallbacks cb = { .text = texto, .control = controle, .escape = escape, .csi = csi, .osc = osc, .dcs = dcs }; + while ((n = getline(&linha, &cap, stdin)) > 0) { + Observacao o = {0}; o.vt = vterm_new(24, 80); + vterm_set_utf8(o.vt, argc < 2 || strcmp(argv[1], "8bit")); + vterm_parser_set_callbacks(o.vt, &cb, &o); + unsigned char *bytes = malloc((size_t)n/2+1); size_t len = 0; + for (ssize_t i = 0; i+1 < n && linha[i] != '\n'; i += 2) { unsigned x; if (sscanf(linha+i, "%2x", &x) != 1) return 2; bytes[len++] = x; } + vterm_input_write(o.vt, (const char *)bytes, len); + printf("%u %d %d ", o.proibidos, o.modo, o.vt->parser.state); + size_t inicio = o.textos > 16 ? o.textos-16 : 0; + for (size_t i = inicio; i < o.textos; i++) printf("%02x", o.ultimos[i % 16]); + printf("\n"); free(bytes); vterm_free(o.vt); + } + free(linha); return 0; +} diff --git a/third_party/termlib/UPSTREAM.md b/third_party/termlib/UPSTREAM.md index a8f75067..e232e424 100644 --- a/third_party/termlib/UPSTREAM.md +++ b/third_party/termlib/UPSTREAM.md @@ -46,3 +46,9 @@ Auditoria do NDK efetivamente usado: `clang_source_info.md` do CI registra LLVM `toolchain/llvm_android@1dab3288f660d43a6cb2479107e2b54b3ab0a2a1`. Os quatro `LICENSE.TXT` dessa base foram baixados e são **bit-idênticos** aos textos preservados neste diretório. Prova e hashes: `.equipe/provas/computador-a/t3-licencas-ndk-real.json` (fora do git). + +## Entrada ordenada do app (revisão #96) + +`dispatchKeyboardInput` enfileira os bytes já mapeados na mesma `TerminalDispatcher` usada pelo +`QueuedTerminal` do Compose. A produção e a postagem dos callbacks seguem essa FIFO, sem atrasos arbitrários. +É uma alteração Kotlin; não muda o JNI nem os binários fixados. diff --git a/third_party/termlib/build.gradle.kts b/third_party/termlib/build.gradle.kts index 291935b9..582f1cd6 100644 --- a/third_party/termlib/build.gradle.kts +++ b/third_party/termlib/build.gradle.kts @@ -28,6 +28,7 @@ extensions.configure("android") { tasks.named("preBuild") { dependsOn(":app:prepareTermlibJni") } dependencies { + testImplementation("junit:junit:4.13.2") // Apenas APIs usadas no fonte: sem AppCompat/Material Views ou plugins de publicação upstream. implementation("org.slf4j:slf4j-api:2.0.20") implementation("androidx.core:core-ktx:1.13.1") diff --git a/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalDispatcher.kt b/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalDispatcher.kt index c034502f..364c481e 100644 --- a/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalDispatcher.kt +++ b/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalDispatcher.kt @@ -23,6 +23,12 @@ internal class TerminalDispatcher { schedule() } + /** Bytes mapeados entram antes da produção do callback, na mesma fila do texto/IME. */ + fun keyboardInput(data: ByteArray, output: (ByteArray) -> Unit) { + val copia = data.copyOf() + execute { output(copia) } + } + fun executeAfter(delayMillis: Long, action: () -> Unit) { workers.schedule({ execute(action) }, delayMillis, TimeUnit.MILLISECONDS) } diff --git a/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt b/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt index 3f782bc2..72537621 100644 --- a/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt +++ b/third_party/termlib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt @@ -101,6 +101,9 @@ sealed interface TerminalEmulator : AutoCloseable { /** Queue pasted text in the same FIFO as keyboard/IME input, honoring bracketed paste. */ fun pasteText(text: String) + /** Bytes já mapeados pelo app, na mesma FIFO de texto/IME e com o mesmo callback de saída. */ + fun dispatchKeyboardInput(data: ByteArray) + /** Policy controlling whether incoming inline image commands are accepted. */ val inlineImages: InlineImages @@ -728,6 +731,13 @@ internal class TerminalEmulatorImpl( } } + override fun dispatchKeyboardInput(data: ByteArray) { + checkNotClosed() + commands.keyboardInput(data) { copia -> + synchronized(damageLock) { if (!closed) postCallback { onKeyboardInput.invoke(copia) } } + } + } + override fun pasteText(text: String) { enqueueCommand { batchOutput { diff --git a/third_party/termlib/src/test/java/org/connectbot/terminal/KeyboardOrderingTest.kt b/third_party/termlib/src/test/java/org/connectbot/terminal/KeyboardOrderingTest.kt new file mode 100644 index 00000000..bd3d7ebc --- /dev/null +++ b/third_party/termlib/src/test/java/org/connectbot/terminal/KeyboardOrderingTest.kt @@ -0,0 +1,39 @@ +package org.connectbot.terminal + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** A fila de produção real sob um comando retido: nenhuma suposição sobre "40 ms bastam". */ +class KeyboardOrderingTest { + private fun verificar(antes: String, depois: String, teclaPrimeiro: Boolean) { + val fila = TerminalDispatcher() + val entrou = CountDownLatch(1) + val liberar = CountDownLatch(1) + val terminou = CountDownLatch(1) + val escritos = CopyOnWriteArrayList() + fila.execute { entrou.countDown(); check(liberar.await(5, TimeUnit.SECONDS)) } + assertTrue(entrou.await(5, TimeUnit.SECONDS)) + try { + if (teclaPrimeiro) { + fila.keyboardInput(antes.toByteArray()) { escritos += it } + fila.execute { escritos += depois.toByteArray() } + } else { + fila.execute { escritos += antes.toByteArray() } + fila.keyboardInput(depois.toByteArray()) { escritos += it } + } + // Se a tecla sair por fora da fila, passa à frente do trabalho retido. + assertEquals(0, escritos.size) + fila.execute { terminou.countDown() } + } finally { liberar.countDown() } + assertTrue(terminou.await(5, TimeUnit.SECONDS)) + assertEquals(antes+depois, escritos.joinToString("") { String(it) }) + } + + @Test fun escAntesDeI() = verificar("\u001b", "i", true) + @Test fun backspaceAntesDeLetra() = verificar("\u007f", "a", true) + @Test fun textoAntesDeEnterMesmoComAFilaOcupada() = verificar("x".repeat(10_000), "\r", false) +}