Pular para o conteúdo

Visão geral ​

react-native-notti é o SDK oficial de React Native para notificações push do Notti (FCM no Android, APNs no iOS). Ele cuida do registro de dispositivo, tags, ID de usuário externo, estado da assinatura e dos eventos de notificação recebida/aberta/clicada, para que a integração nunca precise montar chamadas REST manualmente contra a API de Devices. É um Turbo Module (New Architecture) com uma camada fina em TypeScript sobre código nativo Kotlin/Swift, e inclui um config plugin do Expo para projetos que usam Expo prebuild.

Por padrão, o SDK se comunica com o Notti (https://app.zeepnotti.app), então initialize() só precisa do seu appId e clientKey.

Requisitos ​

  • React Native com a New Architecture habilitada (Turbo Modules).
  • Android: um minSdkVersion compatível com com.google.firebase:firebase-messaging e um projeto Firebase com Cloud Messaging configurado.
  • iOS: a capability Push Notifications habilitada no target do seu app (APNs).
  • Um App do Notti e seus appId/clientKey - veja Primeiros passos com o Notti para criar um.

Instalação ​

sh
npm install react-native-notti

Isso instala o Turbo Module tanto para React Native puro (bare) quanto para Expo. A configuração nativa difere conforme o caso:

  • React Native bare: coloque o google-services.json do seu App em android/app/google-services.json, aplique o plugin Gradle do Google Services e declare a permissão de runtime POST_NOTIFICATIONS no seu AndroidManifest.xml (Android). Habilite a capability Push Notifications no Xcode e encaminhe os callbacks do APNs do seu próprio AppDelegate para NottiBridge/NottiPushDelegate (iOS) - o SDK evita method swizzling de propósito, então esse pequeno trecho de código do integrador é obrigatório.

    Android Studio: google-services.json dentro de android/app
  • Expo: adicione "react-native-notti" ao array de plugins do seu app.json/app.config.js. No expo prebuild, o plugin aplica o plugin Gradle do Google Services e copia o google-services.json configurado (Android), e adiciona o entitlement aps-environment (iOS). A declaração da permissão de runtime no Android e o encaminhamento no AppDelegate do iOS descritos acima continuam valendo da mesma forma - o plugin cuida só da configuração do projeto nativo, não desse caminho de código.

Xcode: Signing & Capabilities com a capability Push Notifications adicionada

O valor de aps-environment que o plugin escreve é resolvido assim: a prop de plugin explícita apsEnvironment: 'development' | 'production' tem prioridade; caso contrário, production somente quando a variável de ambiente EAS_BUILD_PROFILE for exatamente production; caso contrário, development (qualquer outro nome de perfil do EAS - preview, staging, … - ou um expo prebuild local). O perfil preview padrão do EAS é ad hoc (distribution: "internal"), e builds ad hoc recebem tokens APNs de produção enquanto o plugin escreveria development; o archive/export do Xcode normalmente reescreve aps-environment a partir do provisioning profile de distribuição, mas, para que a saída do prebuild corresponda explicitamente à distribuição, fixe a prop por perfil em app.config.js:

js
const adHocOrStore = ['production', 'preview'].includes(process.env.EAS_BUILD_PROFILE);

export default {
  expo: {
    plugins: [['react-native-notti', { apsEnvironment: adHocOrStore ? 'production' : 'development' }]],
  },
};

Inicializando e registrando um dispositivo ​

Chame initialize() uma vez, por exemplo na inicialização do app, com o appId e o clientKey do seu App (nunca a chave REST - veja a Referência da API de Apps para entender o motivo):

ts
import { Notti } from 'react-native-notti';

Notti.initialize('<appId>', '<clientKey>');

Isso registra o dispositivo no Notti usando o token atual de FCM (Android) / APNs (iOS). É seguro chamar mais de uma vez - uma nova chamada com os mesmos appId/clientKey não faz nada - e nunca lança exceção: argumentos ausentes ou inválidos, ou um pré-requisito nativo de push ausente (sem google-services.json, sem a capability de APNs), são registrados em log, não lançados.

Peça a permissão de push do SO explicitamente, quando o seu app estiver pronto para exibir o prompt - ela não está atrelada a initialize():

ts
const granted = await Notti.requestPermission();

Tags, ID de usuário externo, ID do dispositivo e estado da assinatura ​

ts
// Tag the device for Notti Segments (see /api-reference/segments).
Notti.User.addTag('plan', 'vip');
Notti.User.addTags({ plan: 'vip', region: 'br' });
Notti.User.removeTag('plan');
Notti.User.removeTags(['plan', 'region']);

// Associate the device with your own user id.
Notti.login('external-user-123');
Notti.logout();

// The Notti-internal device id your own backend needs to route
// notifications to this device.
const deviceIdChanged = Notti.addEventListener('deviceIdChanged', (id) => {
  console.log('Notti device id', id);
});
const deviceId = Notti.getDeviceId();
deviceIdChanged.remove();

// Enable/disable delivery without unregistering the device.
Notti.setSubscription(true);

Todas essas mutações são serializadas no cliente (uma chamada de rede em andamento por vez, last-write-wins sobre o estado local mesclado), então chamá-las em sequência é seguro. logout() também desvincula o dispositivo do usuário no backend do Notti: ele envia external_user_id: null, então pushes enviados ao usuário anterior por external_user_ids deixam de chegar a este dispositivo. Tags e inscrição são mantidas. Se o dispositivo estiver offline ou a chamada falhar, a limpeza é reenviada no próximo registro. Versões do SDK anteriores a esse comportamento limpam o ID só localmente, e a linha do Device mantém o valor anterior até um novo login().

getDeviceId() é uma leitura síncrona em cache que retorna null até que o registro atribua um ID (sem round-trip de rede). Assine 'deviceIdChanged'antes de chamá-la: o registro pode terminar entre as duas chamadas, e o evento não reemite um valor já atribuído. O evento também dispara quando o ID muda depois (reinstalação, troca de dispositivo, revogação/renovação).

Tratando eventos de notificação ​

ts
const received = Notti.addEventListener('notificationReceived', (payload) => {
  console.log(payload.title, payload.body, payload.data);
});
const clicked = Notti.addEventListener('notificationClicked', (payload) => {
  console.log(payload.title, payload.body, payload.data);
});

// Call .remove() when done listening, e.g. in a useEffect cleanup.
received.remove();
clicked.remove();

'notificationReceived' dispara em foreground; 'notificationClicked' dispara sempre que o app já está em execução (em background ou em foreground) e o usuário toca na notificação. Nenhum dos dois dispara para um toque em cold start - o toque que abre o app acontece antes de qualquer listener poder ser registrado. Verifique esse caso uma vez na inicialização:

ts
Notti.getInitialNotificationClick().then((payload) => {
  if (payload) {
    console.log('app was launched by a notification tap', payload);
  }
});

Isso resolve a notificação que abriu o app em cold start, ou null se o app não foi aberto dessa forma, e só resolve uma vez por cold start - o lado nativo a limpa depois que essa chamada a lê.

O payload é { title?: string | null; body?: string | null; data?: { [key: string]: string } }. Ambas as plataformas entregam title/body ausente como null (não undefined), então compare com == null (ou ?? fallback) em vez de === undefined.

'deviceIdChanged' é um evento separado que você pode escutar junto com os eventos de notificação - ele dispara quando o ID do dispositivo é atribuído pela primeira vez ou muda depois (veja ID do dispositivo).

Relatório de eventos de notificação (CTR) ​

O SDK reporta automaticamente métricas de click-through ao Notti - não há API JS para isso, e nenhuma ação é necessária além de initialize(). Para todo push cujo payload data carregue tanto notification_id quanto delivery_id (definidos pelo Notti quando o push foi enviado pela API de Notifications), o SDK chama POST /v1/apps/{appId}/notifications/{notification_id}/events com o token de push atual do dispositivo:

  • opened e clicked - o usuário tocou no corpo da notificação (ação padrão), em foreground, em background ou a partir de um cold start. Um toque reporta os dois eventos para a mesma entrega.
  • somente clicked - o usuário tocou em um botão de ação customizado (iOS).
  • received - a notificação chegou enquanto o app estava em foreground. Notificações entregues enquanto o app está em background ou encerrado não são reportadas como received.

Dispensas (dismissals) não são reportadas.

As versões do SDK até a 0.5.0 reportam apenas clicked para um toque no corpo. O backend conta uma entrega como aberta quando ela tem um evento opened ou clicked, e como clicada quando tem um evento clicked, então o CTR mantém seu significado entre versões do SDK e a taxa de abertura é igual ao CTR em toda versão do SDK hoje. O SDK 0.6.0 e posteriores também reportam opened.

A entrega é durável, mas best-effort: os eventos são persistidos em disco antes da primeira tentativa de rede, repetidos com o mesmo backoff do registro de dispositivo (veja Política de retentativas abaixo) e reenviados na próxima abertura do app ou mudança de rede se não tiverem sido enviados. Por isso, o backend pode receber o mesmo evento mais de uma vez (entrega at-least-once) - os números de CTR na API de Notifications (delivered_count, received_count, ctr) são calculados para tolerar isso. delivered_count conta as entregas que o provedor aceitou; received_count conta os dispositivos distintos que reportaram received.

Segmentação por localização (opt-in) ​

setLocationSharingEnabled permite que o país aproximado de um dispositivo seja usado na segmentação por Segmento. Vem desativado por padrão e o SDK nunca solicita a permissão de localização do SO por conta própria:

ts
// Opt in - requires the host app to already hold OS location permission.
Notti.setLocationSharingEnabled(true);

// Opt out - clears any previously reported country server-side.
Notti.setLocationSharingEnabled(false);

Com o opt-in ativo, no próximo início de sessão o SDK lê a última localização em cache do dispositivo (sem requisição ativa de localização, sem rastreamento em background) e faz geocodificação reversa no próprio dispositivo para um código de país ISO 3166-1 alpha-2, que é o único valor enviado ao Notti - coordenadas brutas nunca são enviadas nem armazenadas. Se o app host não tiver permissão de localização, os serviços de localização estiverem desligados ou não houver localização em cache, o campo é simplesmente omitido - sem prompt, sem erro.

O opt-out tem efeito imediato e durável: o país do dispositivo é limpo no servidor mesmo que o dispositivo esteja offline ou o app seja encerrado no meio da requisição, reenviando a limpeza pendente no próximo registro/foreground/retorno da rede.

Requisitos do app host:

  • iOS: solicite a permissão de localização você mesmo e declare NSLocationWhenInUseUsageDescription no Info.plist.
  • Android: declare e solicite ACCESS_COARSE_LOCATION (ou ACCESS_FINE_LOCATION) você mesmo - o manifest do SDK não a declara.

Política de retentativas ​

Todas as chamadas HTTP que o SDK faz - registro de dispositivo, toda atualização de dispositivo (tags, assinatura, telemetria, país) e eventos de CTR - compartilham um mesmo loop de retry: até 5 tentativas com backoff exponencial (2s, 4s, 8s, 16s) em erros de rede, 5xx, 408 e 429. Qualquer outro 4xx é terminal e não é repetido.

Notificações push ricas (iOS) ​

Anexar imagem/vídeo/áudio a um push no iOS exige uma Notification Service Extension (NSE) - um target de app-extension separado que a Apple exige para baixar e anexar mídia antes de a notificação ser exibida. O SDK fornece isso como um helper que você conecta a um target criado por você; não é algo que um Turbo Module consiga injetar automaticamente no seu projeto Xcode.

Não suportado no fluxo managed do Expo - isso exige criar manualmente um target nativo no Xcode, e o expo prebuild o destruiria. Somente React Native bare (ou um app Expo ejetado).

  1. No Xcode: File > New > Target… > Notification Service Extension.

    Defina o iOS Deployment Target da extension com a mesma versão do seu app: o Xcode usa por padrão o iOS mais recente, e a extension não carregaria em dispositivos com uma versão mais antiga.

    Se o build falhar com Sandbox: bash deny(1) file-write-create .../Pods/resources-to-copy-..., defina Build Settings > User Script Sandboxing como No no target da extension: com o sandbox ligado, o script de cópia de recursos do CocoaPods não consegue escrever em Pods/.

    Xcode: File > New > Target com Notification Service Extension selecionado
  2. Adicione-o ao seu Podfile, em um target próprio:

    ruby
    target 'NotificationService' do
      pod 'Notti/NotificationServiceExtension', :path => '../node_modules/react-native-notti'
    end
  3. Substitua o corpo gerado da extensão:

    swift
    import UserNotifications
    import Notti
    
    @objc(NotificationService)
    class NotificationService: UNNotificationServiceExtension {
      var contentHandler: ((UNNotificationContent) -> Void)?
      var bestAttemptContent: UNMutableNotificationContent?
    
      override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
      ) {
        self.contentHandler = contentHandler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
        NottiNotificationServiceExtension.didReceive(request, withContentHandler: contentHandler)
      }
    
      override func serviceExtensionTimeWillExpire() {
        guard let contentHandler else { return }
        NottiNotificationServiceExtension.serviceExtensionTimeWillExpire(
          for: bestAttemptContent, contentHandler: contentHandler
        )
      }
    }

    O nome @objc(NotificationService) é obrigatório - sem ele, o iOS falha silenciosamente ao instanciar a extensão em runtime.

    Navegador de projeto do Xcode mostrando o target NotificationService e seu NotificationService.swift

Somente URLs de anexo https:// são baixadas (limite de 10 MB); qualquer outra cai para a notificação simples. Uma Notification Service Extension não roda no iOS Simulator - teste em um dispositivo físico.

Uma notificação no iOS exibindo a imagem anexada

O podspec é dividido em dois subspecs: Notti/Core (o Turbo Module; o default_subspec, então o pod 'Notti' simples e o autolinking continuam resolvendo para ele) e Notti/NotificationServiceExtension (o helper acima, sem dependência de React). Podfiles que fixam subspecs explicitamente devem incluir Core no target do app. Notti/Core linka CoreLocation e referencia APIs de localização mesmo que você nunca ative o opt-in de reporte de país - a validação de upload do App Store Connect pode, por isso, avisar sobre uma purpose string de localização ausente (ITMS-90683) para qualquer app que use este SDK; declarar NSLocationWhenInUseUsageDescription evita isso.

Privacidade, rótulos da App Store e LGPD ​

Isto não é aconselhamento jurídico - valide com seu próprio time jurídico/DPO antes de publicar.

  • O SDK coleta automaticamente, sem opt-out nesta versão: token de push, plataforma, versão do app e agregados de sessão (contagem de sessões, tempo cumulativo em foreground, timestamps da primeira/última sessão) usados para métricas de CTR e engajamento.
  • Um botão de mensagem in-app com a ação push_permission pede a permissão de notificação do SO ou abre as configurações de notificação do app. Ele não coleta nem envia nada novo: o SDK já reporta permission_status como parte do perfil do dispositivo, e a caixa de diálogo do sistema é uma escolha do próprio usuário. O listener de clique recebe esse clique com action: null.
  • setLocationSharingEnabled (acima) é o único opt-in: quando ativado, apenas um código de país ISO é enviado, nunca coordenadas.
  • Para os rótulos "nutrition" da App Store e o formulário Data safety do Google Play, isto tipicamente se mapeia para: Usage Data / Product Interaction (CTR, dados de sessão), Identifiers / Device ID (e User ID se você chamar login) e Location / Coarse Location se você ativar o reporte de país.
  • O SDK não coleta consentimento e não decide uma base legal por você - o integrador é responsável pelo aviso de privacidade, pelo fluxo de consentimento e pela política de retenção desses dados sob a LGPD (ou equivalente).

Limitações conhecidas ​

  • Sem confirmação confiável para app_version. O backend aceita os campos de telemetria e os persiste, e a resposta do PATCH do dispositivo devolve app_version, mas o SDK não o compara: trata qualquer 2xx como confirmado e não o reenvia até que o valor mude. Os snapshots de sessão são cumulativos e reenviados ao fim de toda sessão, então não são afetados da mesma forma.
  • Um evento de CTR ruim pode travar a fila de eventos. Os eventos são enviados em ordem e o envio para no primeiro erro não terminal; um evento que recebe 5xx/429 de forma determinística bloqueia os eventos atrás dele até que 32 eventos mais novos o empurrem para fora da fila em disco.
  • Uma limpeza de país pendente que recebe um 4xx permanente é reenviada a cada gatilho (registro, app em foreground, retorno da rede), porque a flag só é limpa em um 2xx.
  • Os eventos são entregues at-least-once: um reporte duplicado do mesmo evento received/opened/clicked é possível após uma falha entre um envio bem-sucedido e a limpeza local. As métricas de CTR são calculadas para tolerar isso.
  • Ainda não validado em dispositivos reais em todos os cenários: fechamento de sessão em cold start / process kill, entrega em background no iOS sob rede lenta e o valor de aps-environment em um build preview do EAS.

Sem encaminhamento do APNs ​

Se a etapa de encaminhamento no AppDelegate do iOS descrita acima for omitida, initialize() ainda registra o dispositivo - com estado subscribed: false - mas a entrega de push não se completa até que os callbacks sejam conectados.