Implementação Técnica de Notificações de Contagem Regressiva via Huawei Push Kit no HarmonyOS

Arquitetura de Notificações Persistentes com Push Kit

A implementação de alertas temporais em aplicações móveis apresenta desafios específicos quando se trata de garantir a entrega fora do contexto ativo do aplicativo. Em cenários onde o usuário precisa ser notificado de um evento futuro mesmo que o processo tenha sido finalizado ou suspendido, soluções baseadas em timers locais são insuficientes. A estratégia adequada envolve delegar o gatilho de tempo ao servidor e utilizar canais de push nativos do sistema.

Neste guia, abordaremos a construção de um mecanismo de lembretes diários para contagens regressivas. A solução integra autorização de notificação no lado do cliente, gestão de tokens de dispositivo e um fluxo assíncrobio no backend coordenado por filas de mensagens e agendamento distribuído.

Pilha Tecnológica

O desenvolvimento requer componentes tanto no ecossistema HarmonyOS quanto no backend corporativo:

  • Lado Cliente: Utilização de ArkTS e ArkUI para a interface. Acesso ao notificationManager para verificação e solicitação de permissões; obtenção de credencial de push via pushService.getToken(); persistência local utilizando preferences e comunicação HTTP via Network Kit.
  • Lado Servidor: Framework Spring Boot. Gerenciamento de estado via Spring Data JPA e MySQL. Agendamento de tarefas utilizando Spring Scheduling. Controle de concorrência e estados transientes via Redis. Filtragem e distribuição de eventos através de RabbitMQ. Integração direta com a API REST do Huawei Push Kit usando HttpClient e autenticação JWT (PS256).

Observação importante sobre nomenclatura: O campo armazenado no banco de dados para identificação do dispositivo é frequentemente referenciado como deviceId. Na prática, este valor corresponde ao token retornado pelo serviço de push do cliente. Para fins de clareza técnica neste documento, manteremos essa convenção, mas entende-se que se trata de um token de canal.

Fluxo de Dados Geral

A arquitetura divide-se em duas direções operacionais principais:

  1. Sincronização de Assinatura (Cliente → Servidor): O usuário habilita uma regra de aviso. O aplicativo valida permissões, solicita o Push Token, envia os metadados da tarefa (data alvo, horário, título) e o token para uma endpoint HTTPS seguro no backend.
  2. Disparo de Notificação (Servidor → Cliente): Um agendador verifica periodicamente assinaturas expiradas. Se houver correspondência, uma mensagem é enviada para uma fila RabbitMQ. Um consumidor valida o contexto novamente e invoca a API externa do fabricante para empurrar a notificação para o dispositivo específico.

Implementação no Lado do Cliente (ArkTS)

O foco inicial é garantir que o ambiente esteja apto a receber comunicações externsa. É necessário verificar o status atual e solicitar acesso ao usuário caso seja negado previamente. Além disso, a credencial de push deve ser obtida e armazenada eficientemente.

Abaixo encontra-se uma refatoração dos serviços de gerenciamento de token e permissões, utilizando nomes mais descritivos e tratamento de erro encapsulado:

import { context } from '@kit.AbilityKit';
import { preferencesManager } from '@kit.ArkData';
import { notificationAccess } from '@kit.NotificationKit';
import { pushChannel } from '@kit.PushKit';

const PREFS_KEY_TOKEN = 'hms_push_credential_v1';
const PREFS_KEY_STATE = 'notif_state_cache';

/**
 * Verifica se as permissões de notificação estão ativas.
 */
export async function checkNotificationStatus(): Promise<boolean> {
  try {
    return await notificationAccess.isEnabled();
  } catch {
    return false;
  }
}

/**
 * Solicita permissão ao usuário e retorna o resultado.
 */
export async function requestNotificationAccess(appContext: context.UIAbilityContext): Promise<boolean> {
  if (await checkNotificationStatus()) {
    return true;
  }

  try {
    // Abre o diálogo de permissão padrão
    await notificationAccess.requestPermissions({ abilities: [appContext.abilityInfo.name] });
  } catch (e) {
    console.error('Falha na solicitação de notificação', e);
  }
  
  return await checkNotificationStatus();
}

/**
 * Garante a existência do Token de Push, retornando vazio se falhar.
 */
export async function acquirePushCredential(ctx: context.Context): Promise<string> {
  const prefs = preferencesManager.getPreferencesSync(ctx, 'com.example.push.cache');
  const rawState = prefs.getSync(PREFS_KEY_TOKEN) as string;

  if (rawState && rawState.trim().length > 0) {
    return rawState.trim();
  }

  try {
    const tokenValue = (await pushChannel.getToken()).trim();
    if (!tokenValue) {
      return '';
    }
    
    // Persistência imediata após obtenção
    prefs.putSync(PREFS_KEY_TOKEN, tokenValue);
    prefs.flush();
    return tokenValue;
  } catch {
    // Falhas silenciosas evitam crash na UI
    return '';
  }
}

/**
 * Abre as configurações globais de notificação se necessário.
 */
export function openSystemNotificationSettings(ctx: context.UIAbilityContext): void {
  notificationAccess.openNotificationSettings(ctx);
}
</string></boolean></boolean>

Note-se que não propaga erros diretamente para a camada de UI. Em vez disso, captura exceções internas e retorna valores padronizados, permitindo que a interface trate mensagens amigáveis como "Serviço indisponível".

Gestão de Estado da Interface

Na página principal, a lógica de salvamento une a persistência local com a sincronização remota. Antes de enviar dados sensíveis ao backend, validamos o login e obtemos o token dinamicamente.

private async persistCountdownRule(data: CountdownData): Promise<void> {
  const now = new Date().toISOString();
  
  // Atualiza metadados locais
  let record = this.cloneRecord(data);
  if (record.id === '') {
    record.id = generateUuid();
    record.createdAt = now;
  }
  record.updatedAt = now;

  // Validação crítica antes do sync externo
  if (data.isActiveReminder) {
    if (!this.isAuthenticated || !this.authToken) {
      this.showAlert('Autenticação necessária para ativação de lembretes');
      await launchLoginFlow();
      return;
    }

    const ctx = this.getUIContext().getHostContext();
    const deviceCred = await acquirePushCredential(ctx);

    if (!deviceCred.length) {
      this.showToast('Não foi possível obter credencial de push');
      return;
    }

    try {
      // Conversão de data lunar para solar conforme exigência do backend
      const targetDate = convertToSolarDate(record.targetCalendar, record.targetDate);
      
      await api.upsertReminder({
        sourceEventId: record.id,
        kind: record.type,
        title: record.title,
        targetDate: targetDate,
        repeatRule: calculateRepeatPolicy(record.repeatRule),
        reminderDate: record.reminderDate,
        reminderTime: record.reminderTime,
        enabled: true,
        deviceId: deviceCred 
      }, this.authHeader);

      record.reminderDeviceId = deviceCred;
      record.syncStatus = 'synced';
    } catch (err) {
      record.errorMessage = err.message;
      this.showToast('Erro ao sincronizar regras');
      return;
    }
  } else {
    // Desativação também pode precisar de sync
    await api.deleteReminder(ruleId, this.authToken);
    record.enabled = false;
  }

  // Commit no store local
  this.store.updateItem(record);
}
</void>

No arquivo de manifesto do módulo (module.json5), certifique-se de definir apenas as permissões estritamente necessárias. As configurações de Push geralmente não exigem declaração explícita de requestPermissions, pois são gerenciadas via Runtime Kit, mas o acesso à rede é vital.

Lógica no Lado do Servidor (Spring Boot)

O backend atua como orquestrador central. Ele armazena as regras, gerencia a temporalidade e executa a chamada externa.

Camada de Domínio

Essenciais são as entidades que guardam a assinatura do usuário. A estrutura abaixo utiliza padrões de design para manipular o upsert e a disparabilidade da tarefa.

@Service
public class ReminderDomainService {
    
    private final ReminderRepository repository;
    private final MessageQueueProducer producer;

    public void updateReminder(Long userId, ReminderRequest request) {
        // Validação de integridade
        String eventId = trim(request.getSourceEventId());
        
        boolean isActive = Boolean.parseBoolean(request.getIsActive());
        
        // Buscar ou criar nova assinatura
        var existing = repository.findByUserIdAndSource(userId, eventId).orElse(null);
        
        if (existing == null) {
            existing = new ReminderEntity();
            existing.setOwner(userId);
            existing.setSource(eventId);
        }

        // Mapeamento de campos e limpeza de flag de envio anterior
        if (isActive) {
            if (StringUtils.isEmpty(request.getDeviceId())) {
                throw new BusinessException("Token de dispositivo obrigatório");
            }
            existing.setIsEnabled(true);
            existing.setDeviceToken(request.getDeviceId());
            // Reseta histórico de envio para novas regras
            existing.setLastSentDate(null); 
            existing.setDailyKey("");
            
            // Verifica se deve disparar imediatamente
            LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
            LocalTime current = LocalTime.now(ZoneId.of("Asia/Shanghai"));
            if (shouldBeSentImmediately(existing, today, current)) {
                // Registrar sincronismo de transação para produção assimétrica
                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        producer.publishDueReminder(existing.getId());
                    }
                });
            }
        } else {
            existing.setIsEnabled(false);
        }

        repository.save(existing);
    }
    
    // Métodos auxiliares de parse de datas e validação de repetição
    // ...
}

Agendador e Controle de Concorrência

O agendamento roda periódicamente. Para evitar que múltiplas instâncias do servidor processem o mesmo lote simultaneamente, utilizamos bloqueio em Redis.

@Component
public class TriggerAggregator {
    
    private final ReminderRepository repo;
    private final QueuePublisher publisher;
    
    @Scheduled(fixedDelay = "${aggregator.interval-ms:60000}")
    public void scanExpiringItems() {
        // Lock para evitar duplicidade entre nós de cluster
        if (!acquireRedisLock("global_scan_lock")) {
            return;
        }

        LocalDate hoje = LocalDate.now(ZoneId.of("Asia/Shanghai"));
        
        // Busca parcelada para não travar DB
        var candidatos = repo.findCandidatesForDate(hoje, Pageable.ofSize(100));
        
        for (var item : candidatos) {
            if (verificarDuplicacao(item, hoje.toString())) continue;
            
            // Publica ID apenas para consumo processual
            publishViaRabbitMQ(item.getId());
        }
    }
    
    private boolean verificarDuplicacao(ReminderEntity item, String dataChave) {
        return dataChave.equals(item.getDailyKey());
    }
}

Processamento de Mensagens e Envio

O consumer consome da fila, realiza nova validação (estado pode ter mudado entre o scan e o envio) e chama a API do fabricante.

@Service
public class NotificationExecutor {
    
    private final HuaweiApiClient client;

    public void handleRemindingMessage(Long subscriptionId) {
        // Trava específica por ID de subscrição
        if (!lockProcessing(subscriptionId)) return;

        var sub = repo.findById(subscriptionId).orElseThrow();
        
        if (!sub.getIsEnabled()) return;
        if (LocalDate.now().equals(sub.getDailyKey())) return; // Skip se já enviou hoje

        try {
            var response = client.sendNotification(
                sub.getDeviceToken(),
                buildTitle(sub.getTitle()),
                buildBody(sub.getTitle()),
                generateNotifyId(sub),
                buildActionMap(sub),
                86400 // TTL de 24 horas
            );
            
            // Atualiza estado pós-envio
            sub.setLastSentAt(LocalDateTime.now());
            sub.setDailyKey(LocalDate.now().toString());
            sub.setErrorMsg("");
            
            repo.save(sub);
        } catch (Exception ex) {
            // Log para auditoria sem quebrar fila inteira
            sub.setErrorMsg("Falha: " + ex.getMessage());
            repo.save(sub);
        }
    }
}

Integração com Huawei Push Kit REST API

A comunicação segura requer geração de JWT com chave privada RSA-PS256. O payload enviado segue um formato específico.

@Service
public class HuaweiApiClient {
    
    private static final String TOKEN_URI = "https://oauth-login.cloud.huawei.com/oauth2/v3/token";

    public String send(String token, Map<string object=""> payload) throws Exception {
        String jwt = generateJWT();
        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(sendEndpoint(token)))
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(toJson(payload)))
            .build();
        
        HttpResponse<string> resp = HttpClient.newHttpClient().send(req, BodyHandlers.ofString());
        
        if (resp.statusCode() >= 300) throw new IOException("API Returnd Error: " + resp.statusCode());
        
        return parseRequestId(resp.body());
    }
    
    private Map<string object=""> buildPayload(String deviceToken, String msgTitle, String msgBody) {
        var notification = new LinkedHashMap<>();
        notification.put("category", "SUBSCRIPTION");
        notification.put("title", msgTitle);
        notification.put("body", msgBody);
        notification.put("notifyId", System.nanoTime()); // ID único
        
        var action = new LinkedHashMap<>();
        action.put("actionType", 0);
        action.put("data", Map.of("target", "feature", "id", "countdown_detail"));
        
        var payload = Map.<string object="">of("notification", notification);
        var target = Map.of("token", List.of(deviceToken));
        var options = Map.<string object="">of("ttl", 86400, "testMessage", false);
        
        var root = new LinkedHashMap<>();
        root.put("payload", payload);
        root.put("target", target);
        root.put("pushOptions", options);
        return root;
    }
}
</string></string></string></string></string>

Configuração e Considerações Finais

A estabilidade deste sistema depende de várias variáveis externas. Certifique-se de que o project-id no código corresponda exatamente ao projeto criado no AppGallery Connect. O uso de chaves privadas deve ser feito via arquivos protegidos em disco ou variáveis de ambiente secretas, jamais hardcoding.

Para testes de integração, considere:

  1. Verificar se o app no dispositivo tem permissão de notificação ativada nas configurações do SO.
  2. Validar se o retorno de getToken() no dispositivo contém um token válido (não vazia).
  3. Confirmar que o usuário não está em modo de economia de bateria agressivo que impede conexões de fundo.
  4. Monitorar logs de falha na API Push para códigos como "Invalid Token" ou rate limiting.

Um ponto crítico é o manejo de calendários lunares. Como o backend opera predominantemente em Gregorian (solar), eventos recorrentes baseados em calendário lunar (ex: aniversário chinês) requerem conversão prévia para o dia solar correspondente ou regras condicionais complexas no side do servidor para evitar dias incorretos.

Melhorias Futuras

Com a base estabelecida, recomendações incluem renomear todos os campos deviceId para pushToken para evitar ambiguidades técnicas, adicionar mecanismos de retry exponencial para falhas transitórias na rede e expandir o cabeçalho de ação de clique para suportar deep links mais granulares dentro do app.

Tags: HarmonyOS Huawei Push Kit Spring Boot ArkTS java

Publicado em 8-31 11:47