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
notificationManagerpara verificação e solicitação de permissões; obtenção de credencial de push viapushService.getToken(); persistência local utilizandopreferencese comunicação HTTP viaNetwork 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
HttpCliente 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:
- 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.
- 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:
- Verificar se o app no dispositivo tem permissão de notificação ativada nas configurações do SO.
- Validar se o retorno de
getToken()no dispositivo contém um token válido (não vazia). - Confirmar que o usuário não está em modo de economia de bateria agressivo que impede conexões de fundo.
- 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.