# Push pelo Firebase: o guia para quem mantém o aplicativo (10/10/2026)

O dono decidiu, em 10/10/2026, trocar o OneSignal pelo Firebase (FCM) direto, pelo custo mensal do OneSignal
ao escalar, deixando aberto o caminho para o Novu instalado por nós (a decisão e a medição estão em
`docs/notificacoes-push-comparativo.md`). **O servidor já está pronto**: ele manda pelo FCM aos aparelhos que
o aplicativo registrar, e pelo OneSignal a quem ainda não tem aparelho registrado. **Falta o aplicativo novo**,
e é dele que este guia trata. O que ainda espera o dono (as credenciais, quem mantém o aplicativo) está no
**D461** de `docs/debitos-tecnicos.md`.

**Decidido pelo dono em 11/10/2026** (as respostas 71, 73, 75 e 76 do lote): o aplicativo novo **adota o login
V4** (o código no WhatsApp), e o servidor ganhou a **renovação da sessão** (abaixo, "O login"); o teto de
**10 aparelhos** por cliente fica; a convivência no `ambos` fica **por cliente**; e o liga-desliga do push
(Configurações > App > Push) fica **como está até o aplicativo novo** — é ele que diz o que lê do `app.json`
(abaixo, "O liga-desliga do push"). E o servidor de teste está preparado para falar com o Google, com um projeto
Firebase só de teste (a resposta 74; abaixo, "O teste em aparelho").

## O que muda, em uma frase

Hoje o OneSignal guarda os aparelhos: o aplicativo grava a marcação `usuario` com o id do cliente, e o ERP manda
"ao usuário tal". Com o Firebase, **o ERP guarda os aparelhos** (a tabela `clientes_aparelhos`), e o aplicativo
passa a **mandar o identificador do aparelho ao ERP**, por uma rota da API autenticada.

## O que o aplicativo novo faz

1. **Troca o SDK.** Sai o do OneSignal; entra o do Firebase Cloud Messaging da pilha do aplicativo (Android:
   `firebase-messaging`; iOS: `FirebaseMessaging`; Flutter: `firebase_messaging`; nos outros, o plugin oficial ou
   o mais usado da pilha). A pilha de hoje não é conhecida: o registro de acessos tem sinais de mais de uma (D5).
   A versão velha, já instalada, continua com o OneSignal, e o servidor continua mandando a ela por lá enquanto
   o `push.provedor` for `ambos` — **ao cliente que não tem nenhum aparelho no aplicativo novo**: a escolha é por
   cliente, e não por aparelho (abaixo, "A convivência").
2. **Põe os arquivos do projeto Firebase**: o `google-services.json` (Android) e o `GoogleService-Info.plist`
   (iOS), baixados do console do projeto Firebase que o dono escolher (o que o OneSignal já usa, ou um novo — a
   pergunta 4 da comparação). O servidor usa a **conta de serviço do mesmo projeto**; projetos diferentes não se
   enxergam (o FCM responde `SENDER_ID_MISMATCH`).
3. **No iOS, a chave da APNs no Firebase.** A chave `.p8` da conta Apple Developer vai para o console do
   Firebase (Configurações do projeto > Cloud Messaging > chave de autenticação da APNs, com o Key ID e o Team
   ID). Sem ela, todo envio a iPhone volta `THIRD_PARTY_AUTH_ERROR` (o servidor o registra como falha, uma vez
   por rodada). No Xcode, as capacidades *Push Notifications* e *Background Modes > Remote notifications*.
4. **As exigências das lojas.** Toda versão nova do iOS sai do **Xcode 26 com o SDK do iOS 26** (exigência da
   Apple desde 28/04/2026), e toda atualização no Google Play pede o **target SDK 36** (desde 31/08/2026, com
   extensão até 01/11/2026). Se o aplicativo estiver atrás disso, essa atualização vem antes do push.
5. **Pede a permissão de notificação** — no Android 13 ou mais novo, a `POST_NOTIFICATIONS` em tempo de execução;
   no iOS, a autorização do `UNUserNotificationCenter`. Peça depois do login, dizendo para que serve (o lembrete
   do horário, o cupom, a pesquisa). Recusada, o aplicativo segue sem push; não registre o aparelho.
6. **Pega o identificador do aparelho no Firebase.** A documentação do Firebase recomenda, desde 07/10/2026, o
   **FID** (o *Firebase Installation ID*) e dá o token de registro por obsoleto, os dois aceitos lado a lado:
   - **FID (recomendado)**: no Android, o `meta-data` `firebase_messaging_installation_id_enabled` no manifesto
     (`firebase-messaging` 25.1.0 ou mais novo) e o `onRegistered()` do `FirebaseMessagingService`; no iOS,
     `FirebaseMessagingInstallationIdEnabled = YES` no `Info.plist` e o `messaging:didReceiveRegistration:`; no
     Flutter, `register()` e o `onRegistered`. Mande com `"tipo": "fid"`.
   - **Token de registro (obsoleto, ainda aceito)**: `getToken()` / `token(completion:)`, com a renovação pelo
     `onNewToken` / `messaging:didReceiveRegistrationToken:` / `onTokenRefresh`. Mande com `"tipo": "token"`.
   - **Não misture os dois** no mesmo aplicativo (a documentação do Firebase pede isso). E mande o `tipo` certo:
     o servidor põe o token em `message.token` e o FID em `message.fid`.
7. **Manda o identificador ao ERP depois do login e A CADA VEZ QUE O CALLBACK DO PASSO 6 DISPARAR** — e ele
   dispara quando o identificador nasce, quando muda (reinstalação, restauração em outro aparelho, dados
   apagados) **e a cada abertura do aplicativo**, na sincronização de rotina (a página do Android: o FID vai ao
   servidor "whenever this callback triggers"; a do iOS: o `didReceiveRegistration` dispara a cada abertura). No
   caminho do token, que não tem a sincronização de rotina, mande-o ao menos **uma vez por mês** (a recomendação da
   manage-tokens). **O reenvio importa para o servidor:** cada `POST` renova a `data_atualizacao` do aparelho, e é
   por ela que o teto de 10 escolhe quem sai, e que um dia sairá o aparelho parado (D461) — o celular principal
   mandado uma vez só, no login, parece parado. Mande também quando a pessoa **trocar de cliente** no aplicativo (o
   telefone tem mais de um cliente, abaixo). **O 401 nesse reenvio em segundo plano não abre a tela de login**:
   guarde o identificador e mande de novo depois do próximo login (o token da sessão vale 30 dias, e o aplicativo
   o renova antes de vencer — abaixo, "O login"; D59).
8. **Retira o aparelho no logout, nas duas pontas.** Primeiro o `DELETE` da rota, antes de apagar a sessão (ele
   precisa do token da sessão). **Depois, qualquer que seja a resposta — 200, 401 da sessão vencida, ou sem rede
   —, apague o identificador no próprio aparelho:** no caminho do FID, a instalação do Firebase
   (`FirebaseInstallations.getInstance().delete()` no Android, `Installations.installations().delete()` no iOS,
   `FirebaseInstallations.instance.delete()` no Flutter — a página manage-installations); no do token, o
   `deleteToken()` do SDK de mensagens. Sem isso, o `DELETE` que não chega (a sessão de 30 dias venceu, o
   aparelho estava sem rede) deixa a linha no ERP, e o aparelho — emprestado, vendido sem reinstalar — segue
   recebendo o push daquele cliente (o código do resgate, o cupom). Apagado no aparelho, o FCM passa a recusar o
   identificador (a manage-tokens: o envio volta inválido quando o cliente se desregistrou), e o servidor tira a
   linha sozinho no envio seguinte. Depois do próximo login, peça o identificador de novo ao SDK (o `register()`
   no caminho do FID, o `getToken()` no do token): vem um novo, sem ligação com o apagado, e o passo 7 o manda.
9. **Abre o link ao tocar.** O servidor manda o título e o texto na `notification` (que o sistema mostra com o
   aplicativo fechado) e de novo no `data`, com o link: `data.titulo`, `data.texto`, `data.link` (texto; vazio
   quando a mensagem não tem link). O toque abre o `data.link` — o FCM não tem o `url` do OneSignal.
10. **Mostra a notificação com o aplicativo aberto.** Em primeiro plano o sistema não mostra sozinho: no Android,
    o `onMessageReceived` cria a notificação local; no iOS, o `userNotificationCenter(_:willPresent:)` devolve
    `.banner` e `.sound`. O som é o padrão nas duas plataformas.

## A rota do aparelho

**Ela é da API V4, com a sessão do cliente**: o `Authorization: Bearer` que o login por código no WhatsApp
devolve (**D59**). Não há rota sem login para isso, de propósito: com o id na URL valendo como credencial, qualquer
pessoa poria o próprio aparelho no lugar do de outra e passaria a receber o push dela (o código do resgate da
fidelidade, o cupom).

**O APLICATIVO INSTALADO HOJE NÃO TEM O LOGIN V4, E O NOVO O ADOTA** (decidido pelo dono em 11/10/2026, a
resposta 71): as telas de telefone e código, a guarda do token no Keychain/Keystore, a renovação antes de vencer
e o 401 que leva a um novo login. **O código sai pelo WhatsApp central da franqueadora** (decidido pelo dono em
12/10/2026, a resposta 147: *"Para o envio de codigo, utilizar um WhatsApp centralizado, da franqueadora e nao da
unidade"*), e não mais pelo da unidade do cliente — as 6 das 32 unidades ativas sem WhatsApp (na cópia local de
11/10/2026, com 18.735 clientes ativos) deixam de ser problema. **Qual número e qual credencial são do dono** (a
pergunta está no lote, WHATSAPP-F0-E-CENTRAL): o `whatsapp.central` do `local.php` (`config/autoload/local.php.dist`
traz o bloco vazio e o passo; o provedor vale em qualquer caixa, "WAHA" ou `waha`). Até ele ser
preenchido, nenhum cliente recebe o código (o pedido responde 202, como sempre, e nada chega). Só o código de entrada
mudou de canal: o código do resgate da fidelidade, o parabéns e as campanhas seguem pelo WhatsApp da unidade.

### O login (já existe; a renovação desde 11/10/2026)

```bash
# 1. pede o codigo (202 sempre, para numero cadastrado ou nao)
curl -X POST https://<servidor>/api/v4/sessoes -H 'Content-Type: application/json' \
     -d '{"telefone": "47999990000"}'
# 2. troca o codigo pelo token (201; 401 quando o codigo nao serve)
curl -X POST https://<servidor>/api/v4/sessoes/confirmacao -H 'Content-Type: application/json' \
     -d '{"telefone": "47999990000", "codigo": "123456"}'
#    -> {"token": "...", "tipo": "Bearer", "expiraEmDias": 30}
# 3. os clientes do telefone (a sessao e do TELEFONE, e pode alcancar varios clientes)
curl https://<servidor>/api/v4/clientes -H 'Authorization: Bearer <token>'
# 4. a renovacao, antes de vencer: troca o token que ainda vale por um novo (201; 401 quando nao vale mais)
curl -X POST https://<servidor>/api/v4/sessoes/renovacao -H 'Authorization: Bearer <token>'
#    -> {"token": "...", "tipo": "Bearer", "expiraEmDias": 30}   (o token novo; o de antes ja nao vale)
```

**A renovação** (a resposta 71 do dono; D59): o token que ainda vale troca por um novo, de mais 30 dias **a partir
do pedido**, e **o de antes morre na hora** — renovar não multiplica credenciais. Por isso:

- **Guarde o novo antes de usar qualquer outro pedido**, e no mesmo lugar do de antes (o Keychain/Keystore). A
  resposta perdida no meio do caminho (sem rede no instante errado) deixa o aplicativo com um token que já não vale:
  o pedido seguinte dá 401, e o caminho é o de sempre, um novo login por código. Não repita a renovação com o token
  de antes "para garantir": dois pedidos com o mesmo token renovam uma vez só, e o segundo dá 401.
- **Renove de vez em quando, e não a cada pedido**: por exemplo, ao abrir o aplicativo, se faltarem menos de 15 dias
  para vencer (a resposta da confirmação e a da renovação dizem `expiraEmDias`; guarde o instante). Renovada a cada
  abertura, a sessão nunca vence enquanto o aplicativo for usado; o aparelho parado por mais de 30 dias volta ao
  login.
- **Uma renovação por vez** no aplicativo: duas telas renovando juntas fariam uma delas cair no 401.
- O 401 da renovação é o mesmo de toda rota da V4 (`nao_autenticado`), e não diz por quê: leva ao login.

Hoje a sessão renovada não tem um teto de vida (renova sem fim, enquanto for renovada antes de vencer); se o dono
quiser um, a pergunta está no lote (PUSH-TERCEIRA-RODADA), e o aplicativo não muda — o teto, se vier, aparece como
o 401 de sempre.

### Registrar: `POST /api/v4/clientes/{id}/aparelhos`

O `{id}` é o do cliente escolhido no aplicativo, da lista do passo 3. O corpo vai em JSON:

```bash
curl -X POST https://<servidor>/api/v4/clientes/149612/aparelhos \
     -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
     -d '{"identificador": "<o FID ou o token>", "tipo": "fid", "plataforma": "ios", "versao": "4.0.0 (120)"}'
```

| Campo | Obrigatório | O que é |
|---|---|---|
| `identificador` | sim | o FID ou o token do Firebase, como o SDK deu: até 255 caracteres, letras, dígitos e `_ - : .` |
| `tipo` | sim | `fid` ou `token` |
| `plataforma` | sim | `android` ou `ios` |
| `versao` | não | a versão do aplicativo, até 30 caracteres visíveis; ajuda a medir a cauda que ainda não atualizou |

| Resposta | Quando |
|---|---|
| `200 {"aparelho": {"tipo", "plataforma", "versao"}}` | gravou — novo ou renovado; para o aplicativo é a mesma coisa |
| `401 {"erro": {"codigo": "nao_autenticado"}}` | sem token, token inventado ou vencido: o aplicativo leva a um novo login |
| `403 {"erro": {"codigo": "sem_permissao"}}` | o `{id}` não é de um cliente do telefone da sessão |
| `422 {"erro": {"codigo": "validacao", "campos": {...}}}` | um campo não serve; `campos` diz qual |
| `503 {"erro": {"codigo": "indisponivel"}}` | o servidor ainda não tem a tabela dos aparelhos (o de teste, antes da migração), ou o banco não respondeu agora: guarde e mande de novo depois |

**Um aparelho é de um cliente por vez.** O mesmo identificador registrado para outro cliente **muda de dono**: é
o caso da família que divide o telefone e troca de cliente no aplicativo — o push do cliente anterior deixa de
chegar àquele aparelho, e o do novo passa a chegar. Por isso o aplicativo registra de novo ao trocar de cliente.
Cada cliente guarda no máximo **10** aparelhos (decidido pelo dono em 11/10/2026, a resposta 73); o décimo primeiro tira o que está
há mais tempo sem renovar — sem `POST`, pelo passo 7. E o identificador que o FCM der por morto (`UNREGISTERED`, ou
`INVALID_ARGUMENT` sobre o próprio identificador) **sai sozinho** do cadastro, na primeira campanha depois.

**E o aparelho vale enquanto o telefone do cliente for o da sessão que o registrou.** A sessão é do telefone, e
alcança o cliente só enquanto o telefone dele for aquele; o servidor guarda, com o aparelho, o telefone da sessão. Se a
unidade corrigir o telefone do cliente (o número digitado errado, ou reciclado pela operadora, era de outra pessoa),
o aparelho de quem tinha o número deixa de receber o push do cliente na hora — e o `DELETE` dele passa a responder
403, porque a sessão já não alcança aquele cliente. Para o aplicativo, nada a fazer: o próprio cliente, ao entrar com
o telefone novo, registra o aparelho de novo pelo passo 7.

### Retirar: `DELETE /api/v4/clientes/{id}/aparelhos`

O identificador vai **no corpo, em JSON** — nunca no caminho nem na busca, que caem no registro de acesso do
servidor:

```bash
curl -X DELETE https://<servidor>/api/v4/clientes/149612/aparelhos \
     -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
     -d '{"identificador": "<o FID ou o token>"}'
#    -> 200 {"retirado": true}   (false quando nao havia o que retirar: trate igual)
```

Retira só o aparelho **daquele cliente**: o que já mudou de dono não sai pelo pedido do dono antigo. As recusas
são as mesmas do registro (401, 403, 422, 503).

## A convivência, na configuração do servidor

O servidor escolhe o provedor pela chave `push.provedor` do `config/autoload/local.php`, e a conta de serviço do
Firebase fica num arquivo **fora** do repositório (`push.fcm.conta_de_servico`; o `php scripts/verificar-local-php.php`
diz se o arquivo serve):

| Fase | `push.provedor` | O que acontece |
|---|---|---|
| Hoje, até o aplicativo novo sair nas lojas | `onesignal` (o padrão) | tudo pelo OneSignal; o servidor nem consulta a tabela dos aparelhos |
| O aplicativo novo nas lojas | `ambos` | o cliente com **algum** aparelho registrado recebe pelo FCM, em todos os aparelhos registrados e só neles; o cliente sem nenhum, pelo OneSignal |
| A cauda atualizou | `fcm` | só o FCM; quem não registrou aparelho não recebe |

**No `ambos`, a escolha é por CLIENTE, e não por aparelho.** O cliente com o celular já no aplicativo novo e um
tablet ainda no velho (a atualização automática desligada) recebe só pelo FCM, no celular: **o tablet deixa de
receber durante a convivência**, porque o OneSignal não é chamado para quem tem aparelho registrado. Mandar pelos
dois a esse cliente manteria o tablet, com o risco de o aparelho que atualizou receber duas vezes (o OneSignal pode
ainda alcançá-lo pelo registro antigo). **O dono decidiu manter por cliente** (11/10/2026, a resposta 75): nenhum
aparelho recebe duas vezes, e a perda fica no cliente com dois aparelhos em versões diferentes, durante a
convivência. Para quem testa: o aparelho no aplicativo velho de um cliente que já registrou outro no novo
**não** recebe no `ambos`, e isso não é defeito do aplicativo.

### O liga-desliga do push

A tela Configurações > App > Push do ERP grava o campo `push` do `public/app.json` ("ativo"/"inativo"), e **nenhum
envio do servidor o lê**: ele não desliga nada. O dono decidiu (11/10/2026, a resposta 76) deixá-lo **como está até o
aplicativo novo**, e decidir então, junto com o que o aplicativo lê do `app.json`. Quem mantém o aplicativo: diga se a
versão de hoje ou a nova leem o `public/app.json` (e o campo `push`), e para quê — é essa a resposta que falta para a
decisão (D67).

**A virada para o `fcm` se mede**, e não se adivinha: quantos clientes ativos têm aparelho em `clientes_aparelhos`
contra o MAU do painel do OneSignal, e a `versao` dos aparelhos registrados. A conta é por cliente, como a regra
acima: o cliente que registrou um aparelho já saiu do OneSignal em todos os outros, e o que sobra no MAU do
OneSignal é, em boa parte, de clientes que já contam como migrados. A comparação estima de 30 a 90 dias de
convivência. Para o `ambos` e o `fcm`, o servidor precisa da tabela (`scripts/migracao-clientes-aparelhos.sql`), da
conta de serviço e da saída para `fcm.googleapis.com` e `oauth2.googleapis.com` — que a homologação **não tem, de
propósito** (o loopback dos `docker-compose`), e o servidor de teste só tem quando o dono liga o
`docker-compose.teste-firebase.yml` (a resposta 74). Sem a tabela, o servidor manda tudo pelo
OneSignal e escreve uma linha no registro das rotinas; sem a conta de serviço, também.

## O teste em aparelho, antes de mandar às lojas

Num **iPhone e num Android de verdade** (o simulador do iOS não recebe push remoto), com a versão de teste
apontando para um servidor que tenha a tabela:

- [ ] a permissão concedida registra o aparelho (`POST` responde 200); recusada, não registra;
- [ ] o push chega com o aplicativo **fechado**, em **segundo plano** e em **primeiro plano** (este pela notificação
      local do passo 10);
- [ ] o toque abre o `data.link`; a mensagem sem link abre o aplicativo;
- [ ] o FID (ou o token) renovado — reinstale o aplicativo — chega de novo à rota, e o velho sai do cadastro na
      campanha seguinte;
- [ ] trocar de cliente no mesmo telefone move o aparelho para o novo cliente;
- [ ] o logout retira o aparelho (`DELETE` responde `"retirado": true`) e apaga o identificador no aparelho (passo 8),
      e o push para de chegar — no servidor, a campanha que já estava andando relê os aparelhos a cada 30 segundos,
      e o identificador apagado no aparelho já não recebe nem nessa janela;
- [ ] **o logout com a sessão vencida, e o logout sem rede**: o `DELETE` dá 401 (ou não sai), o identificador é
      apagado no aparelho de todo jeito, e o push seguinte daquele cliente não chega ali (o servidor registra o
      `UNREGISTERED` e tira a linha);
- [ ] o identificador vai de novo à rota a cada abertura do aplicativo (passo 7), e o 401 desse reenvio, em segundo
      plano, não abre a tela de login;
- [ ] o iPhone recebe (a `.p8` está no Firebase: sem ela, o servidor registra `THIRD_PARTY_AUTH_ERROR`);
- [ ] a mensagem de teste do console do Firebase chega ao identificador registrado — prova o lado do aplicativo
      sem o servidor;
- [ ] o 401 da rota (sessão vencida) leva a um novo login, e não a um erro na tela.

**O servidor de teste pode falar com o Google** (decidido pelo dono em 11/10/2026, a resposta 74), com **um projeto
Firebase só de teste**: a versão de teste do aplicativo traz o `google-services.json` e o `GoogleService-Info.plist`
desse projeto, e o servidor de teste, a conta de serviço dele, em `ambos`, com o OneSignal ainda cortado. O
repositório está pronto, e ligar é do dono (`docs/ambiente-teste.md`, "O push pelo Firebase no teste"). Até lá, o
lado do aparelho se prova pelo console do Firebase (a mensagem de teste para o identificador). **O aparelho de teste
entra no login V4 do servidor de teste pela mão do dono** (a resposta 149, 12/10/2026; o D467): lá o WhatsApp está
cortado, o `local.php` ganha um WhatsApp central de mentira só para o código nascer, e o código fica na fila, lido no
banco do teste e passado a quem testa (`docs/ambiente-teste.md`, o passo 9.1 de "O push pelo Firebase no teste").

## Fontes oficiais (lidas em 10/10/2026)

- O FCM no Android, o FID e o `POST_NOTIFICATIONS`: https://firebase.google.com/docs/cloud-messaging/android/client
  (atualizada em 07/10/2026).
- O FCM no iOS, a chave da APNs e o FID: https://firebase.google.com/docs/cloud-messaging/ios/client (atualizada em
  08/10/2026).
- O FCM no Flutter, o `register()` e o `onRegistered`: https://firebase.google.com/docs/cloud-messaging/flutter/client
  (atualizada em 07/10/2026).
- O FID e o token convivendo, o `UNREGISTERED` e o `INVALID_ARGUMENT`, a renovação (o FID na sincronização de
  rotina, o token uma vez por mês, a data guardada a cada envio):
  https://firebase.google.com/docs/cloud-messaging/manage-tokens (atualizada em 07/10/2026; relida em 10/10/2026).
- Apagar a instalação (o FID) no aparelho: https://firebase.google.com/docs/projects/manage-installations
  (atualizada em 07/10/2026, lida em 10/10/2026). O `deleteToken()` do caminho do token é do SDK de mensagens; a
  página de referência dele não abriu inteira na leitura de 10/10/2026.
- O envio pela HTTP v1: https://firebase.google.com/docs/cloud-messaging/send/v1-api (atualizada em 07/10/2026) e a
  referência https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages (o `fid` entre os alvos; lida
  por busca, porque a página não abriu inteira).
- As lojas: https://developer.apple.com/news/upcoming-requirements/ ;
  https://developer.android.com/google/play/requirements/target-sdk (as duas lidas em 09/10/2026, pela comparação).
- O servidor: `module/ISBase/src/Push/` (o envio), `module/ISCadastro/src/Entity/ClienteAparelhoRepository.php` e
  `module/ISCadastro/src/Service/AparelhosNoBanco.php` (o cadastro), `module/ISApi/src/Controller/V4/ClientesController.php`
  (a rota); os casos em `tests/Caracterizacao/AparelhosDoClienteTest.php`, `EnvioDePushTest.php`,
  `PushNasRotinasTest.php` e `tests/Contrato/ApiV4Test.php`.
