@pushmesh/sdk 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -0
  3. package/android/build.gradle +82 -0
  4. package/android/src/main/AndroidManifest.xml +69 -0
  5. package/android/src/main/java/io/pushmesh/sdk/PushMeshClickActivity.kt +124 -0
  6. package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebaseModule.kt +512 -0
  7. package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebasePackage.kt +20 -0
  8. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageBuffer.kt +53 -0
  9. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageRouter.kt +477 -0
  10. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingReceiver.kt +77 -0
  11. package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingService.kt +88 -0
  12. package/dist/cjs/boot.d.ts +7 -0
  13. package/dist/cjs/boot.js +1 -0
  14. package/dist/cjs/compat.d.ts +63 -0
  15. package/dist/cjs/compat.js +1 -0
  16. package/dist/cjs/doctor.d.ts +27 -0
  17. package/dist/cjs/doctor.js +2 -0
  18. package/dist/cjs/events.d.ts +21 -0
  19. package/dist/cjs/events.js +1 -0
  20. package/dist/cjs/http.d.ts +20 -0
  21. package/dist/cjs/http.js +1 -0
  22. package/dist/cjs/inapp/SkeletonShimmer.d.ts +1 -0
  23. package/dist/cjs/inapp/SkeletonShimmer.js +1 -0
  24. package/dist/cjs/inapp/auto.d.ts +1 -0
  25. package/dist/cjs/inapp/auto.js +1 -0
  26. package/dist/cjs/inapp/components.d.ts +14 -0
  27. package/dist/cjs/inapp/components.js +1 -0
  28. package/dist/cjs/inapp/engine.d.ts +1 -0
  29. package/dist/cjs/inapp/engine.js +1 -0
  30. package/dist/cjs/inapp/imageCache.d.ts +1 -0
  31. package/dist/cjs/inapp/imageCache.js +1 -0
  32. package/dist/cjs/inapp/index.d.ts +20 -0
  33. package/dist/cjs/inapp/index.js +1 -0
  34. package/dist/cjs/inapp/lifecycle.d.ts +1 -0
  35. package/dist/cjs/inapp/lifecycle.js +1 -0
  36. package/dist/cjs/inapp/normalize.d.ts +1 -0
  37. package/dist/cjs/inapp/normalize.js +1 -0
  38. package/dist/cjs/inapp/registro.d.ts +1 -0
  39. package/dist/cjs/inapp/registro.js +1 -0
  40. package/dist/cjs/inapp/sessao.d.ts +1 -0
  41. package/dist/cjs/inapp/sessao.js +1 -0
  42. package/dist/cjs/index.d.ts +118 -0
  43. package/dist/cjs/index.js +1 -0
  44. package/dist/cjs/perfil.d.ts +52 -0
  45. package/dist/cjs/perfil.js +1 -0
  46. package/dist/cjs/permissions.d.ts +54 -0
  47. package/dist/cjs/permissions.js +1 -0
  48. package/dist/cjs/players.d.ts +20 -0
  49. package/dist/cjs/players.js +1 -0
  50. package/dist/cjs/queue.d.ts +1 -0
  51. package/dist/cjs/queue.js +1 -0
  52. package/dist/cjs/receipts.d.ts +32 -0
  53. package/dist/cjs/receipts.js +1 -0
  54. package/dist/cjs/state.d.ts +21 -0
  55. package/dist/cjs/state.js +1 -0
  56. package/dist/cjs/storage.d.ts +1 -0
  57. package/dist/cjs/storage.js +1 -0
  58. package/dist/cjs/tags.d.ts +13 -0
  59. package/dist/cjs/tags.js +1 -0
  60. package/dist/cjs/triggers.d.ts +22 -0
  61. package/dist/cjs/triggers.js +1 -0
  62. package/dist/cjs/types.d.ts +258 -0
  63. package/dist/cjs/types.js +1 -0
  64. package/dist/cjs/zeroconfig.d.ts +45 -0
  65. package/dist/cjs/zeroconfig.js +1 -0
  66. package/ios/NotificationService/LEIA-ME.md +105 -0
  67. package/ios/NotificationService/PushMeshNotificationService.swift +384 -0
  68. package/ios/PushMeshApns.swift +746 -0
  69. package/ios/PushMeshApnsModule.m +76 -0
  70. package/ios/PushMeshSwizzleRegistro.swift +141 -0
  71. package/package.json +113 -0
  72. package/pushmesh-sdk.podspec +32 -0
  73. package/react-native.config.js +17 -0
@@ -0,0 +1,477 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.graphics.Bitmap
10
+ import android.graphics.BitmapFactory
11
+ import android.graphics.Color
12
+ import android.media.RingtoneManager
13
+ import android.net.Uri
14
+ import android.os.Build
15
+ import android.os.SystemClock
16
+ import androidx.core.app.NotificationCompat
17
+ import com.facebook.react.ReactApplication
18
+ import com.facebook.react.bridge.Arguments
19
+ import com.facebook.react.bridge.ReactContext
20
+ import com.facebook.react.modules.core.DeviceEventManagerModule
21
+ import org.json.JSONObject
22
+ import java.io.ByteArrayOutputStream
23
+ import java.io.InputStream
24
+ import java.net.HttpURLConnection
25
+ import java.net.URL
26
+ import kotlin.concurrent.thread
27
+
28
+ /**
29
+ * Roteador central das mensagens PUSHMESH no Android (modo zero-config).
30
+ * Usado pelo [PushMeshMessagingReceiver] (dual-run — caminho principal) e
31
+ * pelo [PushMeshMessagingService] (fallback, quando a cadeia de broadcast
32
+ * não passa pelo receiver).
33
+ *
34
+ * Com o corpo DATA-ONLY do servidor (decisão 2026-07-28), NADA é exibido pelo
35
+ * sistema — então em TODOS os estados (foreground/background/morto) este
36
+ * router:
37
+ * 1. avisa o JS (PushMeshMessage) se há instância ativa, ou bufferiza para
38
+ * o próximo init (PushMeshMessageBuffer);
39
+ * 2. exibe a notificação via NotificationCompat (título/corpo/imagem/canal/
40
+ * som/cor do `pm_*`; imagem baixada com timeout, TETO DE MEMÓRIA e
41
+ * fallback sem imagem);
42
+ * 3. posta o RECIBO DE ENTREGA direto em Kotlin (não depende de JS vivo nem
43
+ * de o usuário abrir o app; o JS reconcilia/deduplica pelo pm_msg_id e o
44
+ * servidor deduplica pelo pm:rcpt NX — um eventual recibo duplo via JS é
45
+ * absorvido lá).
46
+ *
47
+ * Tudo dentro de uma JANELA de tempo: ver o KDoc de [handle].
48
+ */
49
+ object PushMeshMessageRouter {
50
+
51
+ const val CANAL_PADRAO = "pushmesh_default"
52
+ const val PREFS = "pushmesh_prefs"
53
+
54
+ /**
55
+ * Modo de inicialização do FCM escolhido pelo `configure()`
56
+ * (PushMeshFirebaseModule) e persistido aqui porque quem precisa dele —
57
+ * o [PushMeshMessagingService.onNewToken] — roda com o JS morto, em
58
+ * processo recém-acordado pelo sistema, onde o módulo nativo nem existe.
59
+ */
60
+ const val CHAVE_MODO_FCM = "pmx_fcm_mode"
61
+
62
+ /** Nós somos o FirebaseApp DEFAULT: o token do onNewToken é NOSSO. */
63
+ const val MODO_DEFAULT = "default"
64
+
65
+ /** O host é o DEFAULT e nós somos o app secundário: onNewToken NÃO é nosso. */
66
+ const val MODO_SECUNDARIO = "secundario"
67
+
68
+ private const val TAG = "PushMeshRouter"
69
+ private const val TIMEOUT_IMG_MS = 5_000
70
+ private const val TIMEOUT_HTTP_MS = 8_000
71
+ private const val PAUSA_RETRY_MS = 1_000L
72
+
73
+ /**
74
+ * Janela total do trabalho de UMA mensagem. O receiver segura o broadcast
75
+ * com goAsync() e o service do FCM segura um wakelock — os dois têm limite
76
+ * de ~10s, e estourá-lo é ANR. 9s deixa folga para o finish().
77
+ */
78
+ private const val JANELA_TOTAL_MS = 9_000L
79
+
80
+ /** Teto do corpo da imagem baixada (ver [baixarImagem]). */
81
+ private const val MAX_BYTES_IMG = 4L * 1024 * 1024
82
+
83
+ /** Teto de pixels do bitmap decodificado — 2 MP (ver [baixarImagem]). */
84
+ private const val MAX_PIXELS_IMG = 2_000_000L
85
+
86
+ fun prefs(context: Context) =
87
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
88
+
89
+ /**
90
+ * Marcador do dual-run: a mensagem é NOSSA se tem alguma chave `pm_*`
91
+ * (o servidor manda o bloco de exibição nessas chaves). ESPELHO da função
92
+ * JS `mensagemDoPushMesh` (src/zeroconfig.ts) — mudou aqui, muda lá.
93
+ * A prova real do roteamento no manifest mesclado é em device (o push do
94
+ * provedor legado, sem pm_*, passa intocado).
95
+ */
96
+ fun isNossa(data: Map<String, String>): Boolean = data.keys.any { it.startsWith("pmx_") }
97
+
98
+ /**
99
+ * Contexto React vivo — nas DUAS arquiteturas e SEM NUNCA lançar.
100
+ *
101
+ * Armadilha que derrubou o app piloto em produção (2026-07-29): na New
102
+ * Architecture o getter `ReactApplication.reactNativeHost` **lança
103
+ * RuntimeException de propósito** ("You should not use ReactNativeHost
104
+ * directly in the New Architecture"). Como o receiver/service rodam em
105
+ * thread de broadcast do sistema, a exceção não é "erro de push": ela
106
+ * DERRUBA O APP INTEIRO a cada mensagem recebida.
107
+ *
108
+ * Ordem: `reactHost` primeiro (New Architecture, default do RN desde 0.76);
109
+ * `reactNativeHost` só como fallback para app em arquitetura antiga, dentro
110
+ * de try/catch. Contexto ausente NÃO é falha: o router bufferiza para o
111
+ * próximo init e a exibição/recibo nativos independem de JS.
112
+ */
113
+ fun contextReact(context: Context): ReactContext? {
114
+ val app = context.applicationContext as? ReactApplication ?: return null
115
+ try {
116
+ app.reactHost?.currentReactContext?.let { return it }
117
+ } catch (_: Throwable) {
118
+ // host novo indisponível — cai para o caminho antigo
119
+ }
120
+ return try {
121
+ @Suppress("DEPRECATION")
122
+ app.reactNativeHost.reactInstanceManager.currentReactContext
123
+ } catch (_: Throwable) {
124
+ null
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Pipeline completo de uma mensagem nossa (ver KDoc da classe).
130
+ *
131
+ * BLOQUEANTE DE PROPÓSITO (2026-08-01) — só chame de thread de background
132
+ * (o `goAsync` do receiver ou a thread do FCM no service).
133
+ *
134
+ * POR QUÊ: quem chama só mantém o processo com prioridade enquanto o
135
+ * callback NÃO retorna. Na versão anterior o recibo e a exibição saíam em
136
+ * `thread {}` soltas e handle() voltava em milissegundos — o
137
+ * `pending.finish()` acontecia logo em seguida e, com o app MORTO (o caso
138
+ * que o receiver existe para cobrir), o processo virava "cached" na hora.
139
+ * Sob pressão de memória — justamente nos OEMs agressivos que o SDK mede em
140
+ * `bateria_irrestrita` — o LMK matava o processo NO MEIO do download da
141
+ * imagem: push que nunca apareceu e recibo perdido, sem erro em lugar
142
+ * nenhum. Agora o trabalho termina ANTES de handle() voltar.
143
+ *
144
+ * JANELA: a do broadcast/wakelock é de ~10s e estourá-la é ANR, então tudo
145
+ * respeita [JANELA_TOTAL_MS]. A ordem é deliberada:
146
+ * 1. evento/buffer do JS — instantâneo, sem rede, e é o FALLBACK durável do
147
+ * recibo (o JS drena no próximo init);
148
+ * 2. exibição — é o que o usuário vê e o que NÃO tem segunda chance;
149
+ * 3. recibo direto — com o que sobrar da janela; se não couber, o buffer +
150
+ * o próximo init reenviam (o servidor deduplica pelo pm:rcpt NX).
151
+ *
152
+ * Cada passo é isolado em seu próprio `runCatching`: falha de um não
153
+ * impede os outros e NADA escapa daqui (LEI do SDK: push nunca derruba o
154
+ * app do cliente).
155
+ */
156
+ fun handle(context: Context, data: Map<String, String>, reactContext: ReactContext?) {
157
+ val prazo = SystemClock.elapsedRealtime() + JANELA_TOTAL_MS
158
+ runCatching { avisarJs(context, data, reactContext) }
159
+ .onFailure { android.util.Log.w(TAG, "aviso ao JS falhou (o resto do push segue)", it) }
160
+ runCatching { exibirAgora(context, data, prazo) }
161
+ .onFailure { android.util.Log.w(TAG, "exibição do push falhou (app segue normal)", it) }
162
+ runCatching { postarReciboAgora(context, data, "recebido", prazo) }
163
+ .onFailure { android.util.Log.w(TAG, "recibo direto falhou (o buffer reenvia)", it) }
164
+ }
165
+
166
+ /** Evento para o JS vivo; sem instância ativa, bufferiza para o próximo init. */
167
+ private fun avisarJs(context: Context, data: Map<String, String>, reactContext: ReactContext?) {
168
+ if (reactContext != null && reactContext.hasActiveReactInstance()) {
169
+ val dataMap = Arguments.createMap()
170
+ for ((k, v) in data) dataMap.putString(k, v)
171
+ val payload = Arguments.createMap()
172
+ payload.putMap("data", dataMap)
173
+ reactContext
174
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
175
+ .emit(PushMeshFirebaseModule.EVENT_MESSAGE, payload)
176
+ } else {
177
+ PushMeshMessageBuffer.add(context, data)
178
+ }
179
+ }
180
+
181
+ /** Token FCM novo chegou sem JS (ou com JS): sempre persiste — o boot reconcilia. */
182
+ fun salvarTokenPendente(context: Context, token: String) {
183
+ prefs(context).edit().putString("pmx_pending_token", token).apply()
184
+ }
185
+
186
+ fun tokenPendente(context: Context): String? = prefs(context).getString("pmx_pending_token", null)
187
+
188
+ fun limparTokenPendente(context: Context) {
189
+ prefs(context).edit().remove("pmx_pending_token").apply()
190
+ }
191
+
192
+ /** Grava o modo do FCM decidido no configure() (ver [CHAVE_MODO_FCM]). */
193
+ fun salvarModoFcm(context: Context, modo: String) {
194
+ prefs(context).edit().putString(CHAVE_MODO_FCM, modo).apply()
195
+ }
196
+
197
+ /**
198
+ * Modo do FCM persistido. O default é [MODO_DEFAULT] porque esse é o caminho
199
+ * principal do zero-config (app do cliente sem Firebase): antes do primeiro
200
+ * `configure()` o token que chegar é, quase sempre, do FirebaseApp que nós
201
+ * mesmos vamos assumir — e o boot ainda reconcilia com um getToken fresco.
202
+ */
203
+ fun modoFcm(context: Context): String =
204
+ prefs(context).getString(CHAVE_MODO_FCM, MODO_DEFAULT) ?: MODO_DEFAULT
205
+
206
+ // -------------------------------------------------------------------------
207
+ // Recibo de entrega direto (1 retry leve, sempre dentro da janela)
208
+ // -------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Recibo em thread própria (fire-and-forget) — para quem NÃO pode bloquear:
212
+ * o clique chega na main thread da [PushMeshClickActivity], onde rede
213
+ * síncrona seria NetworkOnMainThreadException.
214
+ *
215
+ * No caminho de ENTREGA use [postarReciboAgora] (dentro de handle): lá o
216
+ * trabalho precisa terminar antes de o processo perder prioridade.
217
+ */
218
+ fun postarRecibo(context: Context, data: Map<String, String>, evento: String = "recebido") {
219
+ val prazo = SystemClock.elapsedRealtime() + JANELA_TOTAL_MS
220
+ thread {
221
+ // LEI do SDK: push nunca derruba o app do cliente. Exceção (ou Error)
222
+ // não tratada em thread própria mata o processo inteiro — aqui ela para.
223
+ try {
224
+ postarReciboAgora(context, data, evento, prazo)
225
+ } catch (t: Throwable) {
226
+ android.util.Log.w(TAG, "recibo em background falhou (app segue normal)", t)
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Recibo SÍNCRONO: roda na thread de quem chamou e respeita o `prazo`
233
+ * (relógio de [SystemClock.elapsedRealtime]). Falhar aqui não perde o dado —
234
+ * o buffer nativo + o drain do JS no próximo init reenviam.
235
+ */
236
+ private fun postarReciboAgora(
237
+ context: Context,
238
+ data: Map<String, String>,
239
+ evento: String,
240
+ prazo: Long,
241
+ ) {
242
+ val msgId = data["pm_msg_id"] ?: return
243
+ val rcpt = data["pm_rcpt"] ?: return
244
+ val prefs = prefs(context)
245
+ // Faltando qualquer um: player ainda não registrado (1º boot). O recibo sai
246
+ // pelo JS quando o register acontecer e o buffer for drenado — nada se perde.
247
+ val baseUrl = prefs.getString("pmx_base_url", null)?.takeIf { it.isNotBlank() } ?: return
248
+ val appId = prefs.getString("pmx_app_id", null)?.takeIf { it.isNotBlank() } ?: return
249
+ val playerId = prefs.getString("pmx_player_id", null)?.takeIf { it.isNotBlank() } ?: return
250
+ val corpo = JSONObject()
251
+ .put("app_id", appId)
252
+ .put("notification_id", msgId)
253
+ .put("player_id", playerId)
254
+ .put("rcpt", rcpt)
255
+ .put("evento", evento)
256
+ .toString()
257
+
258
+ for (tentativa in 1..2) {
259
+ val restante = restanteMs(prazo)
260
+ if (restante <= 0L) break
261
+ val timeout = restante.coerceAtMost(TIMEOUT_HTTP_MS.toLong()).toInt()
262
+ if (enviarRecibo(baseUrl, corpo, timeout)) return
263
+ // Só pausa para a 2ª tentativa se ainda houver janela para a pausa MAIS
264
+ // um POST curto — senão a espera só serviria para estourar o limite.
265
+ if (tentativa == 1) {
266
+ if (restanteMs(prazo) < PAUSA_RETRY_MS + 1_000L) break
267
+ Thread.sleep(PAUSA_RETRY_MS)
268
+ }
269
+ }
270
+ android.util.Log.w(TAG, "recibo direto não confirmou; o buffer/JS reenviam no próximo init")
271
+ }
272
+
273
+ /** POST do recibo; true = servidor confirmou (2xx). Nunca lança. */
274
+ private fun enviarRecibo(baseUrl: String, corpo: String, timeoutMs: Int): Boolean = try {
275
+ val conn = URL("$baseUrl/api/v1/receipts").openConnection() as HttpURLConnection
276
+ try {
277
+ conn.requestMethod = "POST"
278
+ conn.setRequestProperty("Content-Type", "application/json")
279
+ conn.connectTimeout = timeoutMs
280
+ conn.readTimeout = timeoutMs
281
+ conn.doOutput = true
282
+ conn.outputStream.use { it.write(corpo.toByteArray(Charsets.UTF_8)) }
283
+ conn.responseCode in 200..299
284
+ } finally {
285
+ conn.disconnect()
286
+ }
287
+ } catch (t: Throwable) {
288
+ android.util.Log.w(TAG, "recibo direto falhou: ${t.message}")
289
+ false
290
+ }
291
+
292
+ /** Quanto sobrou da janela, nunca negativo. */
293
+ private fun restanteMs(prazo: Long): Long = (prazo - SystemClock.elapsedRealtime()).coerceAtLeast(0L)
294
+
295
+ // -------------------------------------------------------------------------
296
+ // Display via NotificationCompat (data-only: nós exibimos em todos os estados)
297
+ // -------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Exibe a notificação NA THREAD DE QUEM CHAMOU (ver KDoc de [handle]: em
301
+ * thread solta o processo podia morrer antes do `notify`). Respeita o prazo
302
+ * da janela no download da imagem.
303
+ */
304
+ private fun exibirAgora(context: Context, data: Map<String, String>, prazo: Long) {
305
+ val title = data["pmx_title"]
306
+ val body = data["pmx_body"]
307
+ if (title.isNullOrBlank() && body.isNullOrBlank()) return
308
+
309
+ val bitmap = data["pmx_image"]?.takeIf { it.isNotBlank() }?.let { baixarImagem(it, restanteMs(prazo)) }
310
+ val channelId = data["pmx_channel"]?.takeIf { it.isNotBlank() } ?: CANAL_PADRAO
311
+ garantirCanal(context, channelId, data["pmx_sound"])
312
+
313
+ // CLIQUE: passa pelo NOSSO trampolim (PushMeshClickActivity).
314
+ //
315
+ // Por quê: quando a mensagem tem `pm_url`, o toque precisa abrir a URL —
316
+ // e se o PendingIntent fosse um ACTION_VIEW direto, o navegador abriria
317
+ // sem o nosso processo rodar e o CLIQUE NUNCA SERIA CONTADO (o funil
318
+ // ficaria zerado justamente nas campanhas com link, que são as que mais
319
+ // importam). O receiver registra o clique e só então abre o destino:
320
+ // a URL, quando existe; a MainActivity, quando não.
321
+ val notifId = data["pm_msg_id"]?.hashCode() ?: (System.currentTimeMillis() % 100_000).toInt()
322
+ val intentClique = Intent(context, PushMeshClickActivity::class.java).apply {
323
+ for ((k, v) in data) putExtra(k, v)
324
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
325
+ }
326
+ // getActivity, NÃO getBroadcast: só um PendingIntent de Activity pode
327
+ // abrir tela a partir da notificação (Android 10+ bloqueia abertura de
328
+ // tela por app em segundo plano, e bloqueia em silêncio).
329
+ val pi = PendingIntent.getActivity(
330
+ context, notifId, intentClique,
331
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
332
+ )
333
+
334
+ val builder = NotificationCompat.Builder(context, channelId)
335
+ .setSmallIcon(resolverIcone(context, data["pmx_icon"]))
336
+ .setContentTitle(title)
337
+ .setContentText(body)
338
+ .setAutoCancel(true)
339
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
340
+ builder.setContentIntent(pi)
341
+ data["pmx_color"]?.let {
342
+ try {
343
+ builder.setColor(Color.parseColor(it))
344
+ } catch (_: IllegalArgumentException) {
345
+ // cor inválida: segue sem cor
346
+ }
347
+ }
348
+ resolverSom(context, data["pmx_sound"])?.let { builder.setSound(it) } // < API 26 (canal manda no 26+)
349
+ if (bitmap != null) {
350
+ builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(bitmap))
351
+ } else if (!body.isNullOrBlank()) {
352
+ builder.setStyle(NotificationCompat.BigTextStyle().bigText(body))
353
+ }
354
+
355
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
356
+ nm.notify(notifId, builder.build())
357
+ }
358
+
359
+ private fun garantirCanal(context: Context, id: String, sound: String?) {
360
+ if (Build.VERSION.SDK_INT < 26) return
361
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
362
+ if (nm.getNotificationChannel(id) != null) return // canal do host: não mexe
363
+ val canal = NotificationChannel(
364
+ id,
365
+ if (id == CANAL_PADRAO) "Notificações" else id,
366
+ NotificationManager.IMPORTANCE_HIGH,
367
+ )
368
+ resolverSom(context, sound)?.let { canal.setSound(it, Notification.AUDIO_ATTRIBUTES_DEFAULT) }
369
+ nm.createNotificationChannel(canal)
370
+ }
371
+
372
+ private fun resolverSom(context: Context, sound: String?): Uri? {
373
+ if (sound.isNullOrBlank()) return null
374
+ if (sound == "default") return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
375
+ val nome = sound.substringBeforeLast('.')
376
+ return Uri.parse("android.resource://${context.packageName}/raw/$nome")
377
+ }
378
+
379
+ private fun resolverIcone(context: Context, icon: String?): Int {
380
+ if (!icon.isNullOrBlank()) {
381
+ val porDrawable = context.resources.getIdentifier(icon, "drawable", context.packageName)
382
+ if (porDrawable != 0) return porDrawable
383
+ val porMipmap = context.resources.getIdentifier(icon, "mipmap", context.packageName)
384
+ if (porMipmap != 0) return porMipmap
385
+ }
386
+ return context.applicationInfo.icon
387
+ }
388
+
389
+ /**
390
+ * Baixa a imagem do bigPicture com TETO DE MEMÓRIA e timeout; qualquer falha
391
+ * ⇒ null (a notificação sai SEM imagem — nunca sem notificação).
392
+ *
393
+ * ARMADILHA REAL (corrigida em 2026-08-01): decodificar direto do stream não
394
+ * tem teto nenhum. O bitmap custa largura × altura × 4 bytes, então uma
395
+ * campanha com foto de 12 MP (4000 × 3000) pede ~48 MB de uma vez — e o que
396
+ * estoura é `OutOfMemoryError`, que é `java.lang.Error`, NÃO `Exception`:
397
+ * um `catch (e: Exception)` deixa ele passar e, vindo de thread própria, ele
398
+ * MATA O PROCESSO do app do cliente. E não adianta confiar no upload do
399
+ * painel: a API compatível aceita a URL da imagem vinda de fora (big_picture),
400
+ * e um JPEG de 1 MB pode ser 8000 × 4000.
401
+ *
402
+ * Três defesas, nesta ordem:
403
+ * 1. recusar Content-Length absurdo ANTES de ler, e cortar a leitura em
404
+ * [MAX_BYTES_IMG] (servidor que mente no header não passa);
405
+ * 2. medir com `inJustDecodeBounds` e reamostrar com `inSampleSize` para no
406
+ * máximo [MAX_PIXELS_IMG] — o BigPictureStyle não usa mais que isso, então
407
+ * cortar pixel aqui não tira NADA da tela do usuário;
408
+ * 3. `catch (t: Throwable)` — é o único catch que pega OutOfMemoryError.
409
+ */
410
+ private fun baixarImagem(url: String, tetoMs: Long): Bitmap? {
411
+ if (tetoMs <= 0L) return null // sem janela: melhor notificação sem imagem que push atrasado
412
+ return try {
413
+ val timeout = tetoMs.coerceAtMost(TIMEOUT_IMG_MS.toLong()).toInt()
414
+ val conn = URL(url).openConnection() as HttpURLConnection
415
+ try {
416
+ conn.connectTimeout = timeout
417
+ conn.readTimeout = timeout
418
+ val declarado = conn.contentLengthLong // -1 quando o servidor não declara
419
+ if (declarado > MAX_BYTES_IMG) {
420
+ android.util.Log.w(TAG, "imagem do push recusada: $declarado bytes declarados")
421
+ return null
422
+ }
423
+ val bytes = conn.inputStream.use { lerAteOTeto(it) }
424
+ ?: run {
425
+ android.util.Log.w(TAG, "imagem do push recusada: corpo acima de $MAX_BYTES_IMG bytes")
426
+ return null
427
+ }
428
+ decodificarComTeto(bytes)
429
+ } finally {
430
+ conn.disconnect()
431
+ }
432
+ } catch (t: Throwable) {
433
+ // Throwable, não Exception: OutOfMemoryError é Error e é exatamente o
434
+ // que precisa morrer AQUI, e não no processo do cliente.
435
+ android.util.Log.w(TAG, "imagem do push falhou: ${t.message}")
436
+ null
437
+ }
438
+ }
439
+
440
+ /** Lê o corpo até [MAX_BYTES_IMG]; passou do teto ⇒ null (não guardamos lixo em RAM). */
441
+ private fun lerAteOTeto(entrada: InputStream): ByteArray? {
442
+ val saida = ByteArrayOutputStream()
443
+ val balde = ByteArray(16 * 1024)
444
+ var total = 0L
445
+ while (true) {
446
+ val lidos = entrada.read(balde)
447
+ if (lidos < 0) break
448
+ total += lidos
449
+ if (total > MAX_BYTES_IMG) return null
450
+ saida.write(balde, 0, lidos)
451
+ }
452
+ return saida.toByteArray()
453
+ }
454
+
455
+ /** Duas passadas: mede (sem alocar) e só então decodifica reamostrado. */
456
+ private fun decodificarComTeto(bytes: ByteArray): Bitmap? {
457
+ val medida = BitmapFactory.Options().apply { inJustDecodeBounds = true }
458
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, medida)
459
+ val largura = medida.outWidth
460
+ val altura = medida.outHeight
461
+ if (largura <= 0 || altura <= 0) return null // não é imagem que sabemos ler
462
+ val opcoes = BitmapFactory.Options().apply { inSampleSize = amostragem(largura, altura) }
463
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opcoes)
464
+ }
465
+
466
+ /**
467
+ * Menor potência de 2 que traz largura × altura para ≤ [MAX_PIXELS_IMG]
468
+ * (o `inSampleSize` só respeita potência de 2 — valor quebrado é arredondado
469
+ * para baixo pelo próprio BitmapFactory).
470
+ */
471
+ private fun amostragem(largura: Int, altura: Int): Int {
472
+ val pixels = largura.toLong() * altura.toLong()
473
+ var amostra = 1
474
+ while (pixels / (amostra.toLong() * amostra.toLong()) > MAX_PIXELS_IMG) amostra *= 2
475
+ return amostra
476
+ }
477
+ }
@@ -0,0 +1,77 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import kotlin.concurrent.thread
7
+
8
+ /**
9
+ * Receiver de ALTA PRIORIDADE para `com.google.android.c2dm.intent.RECEIVE` —
10
+ * a peça do DUAL-RUN (migração do provedor legado):
11
+ *
12
+ * No manifest mesclado, o FCM entrega a mensagem como broadcast ORDENADO —
13
+ * apenas UM service/receiver a processa. Sem este receiver, ou o push do
14
+ * provedor legado morre, ou o nosso nunca chega. A regra:
15
+ *
16
+ * - mensagem NOSSA (marcador `pm_*`, ver PushMeshMessageRouter.isNossa):
17
+ * processamos aqui (recibo direto + display NotificationCompat + evento/
18
+ * buffer JS) e `abortBroadcast()` — o SDK do provedor legado nem vê;
19
+ * - mensagem SEM o marcador: return imediato, SEM abort — o broadcast segue
20
+ * a cadeia e o SDK legado recebe normalmente (dual-run de verdade).
21
+ *
22
+ * A prova real do roteamento no manifest mesclado é em device (NÃO VERIFICADO
23
+ * em build); a decisão do marcador é testada em JS (espelho) e revisada aqui.
24
+ */
25
+ class PushMeshMessagingReceiver : BroadcastReceiver() {
26
+
27
+ override fun onReceive(context: Context, intent: Intent) {
28
+ val extras = intent.extras ?: return
29
+ val data = HashMap<String, String>()
30
+ for (k in extras.keySet()) {
31
+ (extras.get(k) as? String)?.let { data[k] = it }
32
+ }
33
+ if (!PushMeshMessageRouter.isNossa(data)) return // pass-through: provedor legado segue
34
+
35
+ // ABORTAR ANTES do goAsync(). O goAsync() transfere o resultado do
36
+ // broadcast para o PendingResult; depois dele o receiver não tem mais
37
+ // resultado "pendente" e abortBroadcast() lança IllegalStateException —
38
+ // que num receiver do sistema NÃO é erro de push, é o app inteiro caindo
39
+ // (crash real no piloto, 2026-07-29). E só há o que abortar em broadcast
40
+ // ORDENADO: com o FCM ele é, mas a guarda evita depender disso.
41
+ if (isOrderedBroadcast) {
42
+ try {
43
+ abortBroadcast() // a mensagem é nossa: o SDK legado não a vê
44
+ } catch (_: Throwable) {
45
+ // sem resultado pendente (broadcast não ordenado): seguir sem abortar
46
+ }
47
+ }
48
+
49
+ // NUNCA acessar reactNativeHost direto: na New Architecture o getter lança
50
+ // e, num receiver do sistema, isso derruba o app a cada push (2026-07-29).
51
+ val reactContext = PushMeshMessageRouter.contextReact(context)
52
+ // goAsync() + trabalho SÍNCRONO dentro da thread: o `finish()` só acontece
53
+ // depois que a exibição e o recibo terminaram DE VERDADE.
54
+ //
55
+ // POR QUE ISSO IMPORTA (corrigido em 2026-08-01): o goAsync só segura a
56
+ // prioridade do processo enquanto o resultado está pendente. Enquanto o
57
+ // `handle` apenas DISPARAVA threads soltas, o finish() saía em
58
+ // milissegundos e, com o app MORTO — o caso que este receiver existe para
59
+ // cobrir —, o processo virava "cached" na hora; sob pressão de memória
60
+ // (os mesmos OEMs agressivos que o SDK mede em `bateria_irrestrita`) o LMK
61
+ // podia matá-lo NO MEIO do download da imagem: push que nunca apareceu e
62
+ // recibo perdido, sem erro em lugar nenhum. O `handle` agora é bloqueante
63
+ // e cabe na janela do broadcast (~10s) por construção — ver o KDoc dele.
64
+ val pending = goAsync()
65
+ thread {
66
+ try {
67
+ PushMeshMessageRouter.handle(context.applicationContext, data, reactContext)
68
+ } catch (t: Throwable) {
69
+ // LEI do SDK: push nunca derruba o app do cliente. Exceção não tratada
70
+ // em thread própria mata o processo — aqui ela para.
71
+ android.util.Log.w("PushMesh", "falha ao processar push (app segue normal)", t)
72
+ } finally {
73
+ pending.finish()
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,88 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import com.facebook.react.bridge.ReactContext
4
+ import com.facebook.react.modules.core.DeviceEventManagerModule
5
+ import com.google.firebase.messaging.FirebaseMessagingService
6
+ import com.google.firebase.messaging.RemoteMessage
7
+
8
+ /**
9
+ * FirebaseMessagingService do modo ZERO-CONFIG — caminho FALLBACK de entrega:
10
+ * quando a cadeia de broadcast passa pelo PushMeshMessagingReceiver (dual-run),
11
+ * é ele quem processa; este service cobre o cenário em que o FCM entrega
12
+ * direto ao service (sem o receiver na cadeia, comportamento de alguns OEMs).
13
+ *
14
+ * Com o corpo DATA-ONLY do servidor, onMessageReceived roda em TODOS os
15
+ * estados — tudo é delegado ao PushMeshMessageRouter (recibo direto +
16
+ * display NotificationCompat + evento/buffer JS), a MESMA lógica do receiver.
17
+ *
18
+ * onNewToken: emite 'PushMeshTokenRefresh' quando há JS e persiste o token
19
+ * pendente (SharedPreferences) — o boot reconcilia, então rotação de token
20
+ * não se perde. Exceção: no modo secundário o token NÃO é nosso (ver
21
+ * o comentário dentro do onNewToken).
22
+ */
23
+ class PushMeshMessagingService : FirebaseMessagingService() {
24
+
25
+ // LEI do SDK: push nunca derruba o app do cliente. Estes dois callbacks
26
+ // rodam em thread do FCM — exceção que escapa daqui mata o processo
27
+ // inteiro (crash já visto em campo). Tudo fica dentro de guarda.
28
+
29
+ override fun onMessageReceived(message: RemoteMessage) {
30
+ val data = message.data
31
+ if (data.isEmpty()) return
32
+ // SEPARAÇÃO TOTAL (regra do produto, 2026-07-30): mensagem sem o NOSSO marcador
33
+ // é de outro SDK — não exibimos, não bufferizamos, não avisamos o JS. O
34
+ // receiver (caminho principal do dual-run) já deixa a cadeia seguir; aqui
35
+ // no fallback simplesmente não tocamos nela.
36
+ if (!PushMeshMessageRouter.isNossa(data)) return
37
+ try {
38
+ // handle() é BLOQUEANTE de propósito e aqui isso é o certo: onMessageReceived
39
+ // roda em thread de background do FCM, que mantém um wakelock enquanto não
40
+ // retorna. Voltar cedo (como fazia a versão com threads soltas) entregava o
41
+ // processo ao LMK no meio do download da imagem. O teto de ~9s do handle
42
+ // cabe na janela do serviço.
43
+ PushMeshMessageRouter.handle(applicationContext, data, currentReactContext())
44
+ } catch (t: Throwable) {
45
+ android.util.Log.w("PushMesh", "falha ao processar push (app segue normal)", t)
46
+ }
47
+ }
48
+
49
+ override fun onNewToken(token: String) {
50
+ super.onNewToken(token)
51
+ try {
52
+ // ARMADILHA DO MULTI-PROJETO (corrigida em 2026-08-01): o onNewToken do
53
+ // FirebaseMessagingService só é chamado para o FirebaseApp **DEFAULT**.
54
+ // No fallback secundário — o host tem Firebase próprio e nós viramos o
55
+ // app nomeado 'pushmesh' (ver PushMeshFirebaseModule.configure) — o
56
+ // token que chega aqui é do PROJETO DO HOST. Registrá-lo como nosso
57
+ // quebra o player em silêncio: o servidor passa a enviar para um token
58
+ // de outro sender e todo envio falha com SENDER_ID_MISMATCH, sem erro
59
+ // visível para ninguém. A rotação do NOSSO token nesse modo já é coberta
60
+ // pelo getToken fresco que o boot faz em toda abertura do app.
61
+ if (PushMeshMessageRouter.modoFcm(applicationContext) == PushMeshMessageRouter.MODO_SECUNDARIO) {
62
+ android.util.Log.w(
63
+ "PushMesh",
64
+ "token novo ignorado: é do FirebaseApp default (do app host), não do nosso projeto — " +
65
+ "o boot revalida o token pelo app secundário",
66
+ )
67
+ return
68
+ }
69
+ PushMeshMessageRouter.salvarTokenPendente(applicationContext, token)
70
+ val reactContext = currentReactContext()
71
+ if (reactContext != null && reactContext.hasActiveReactInstance()) {
72
+ reactContext
73
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
74
+ .emit(PushMeshFirebaseModule.EVENT_TOKEN_REFRESH, token)
75
+ }
76
+ } catch (t: Throwable) {
77
+ android.util.Log.w("PushMesh", "falha ao tratar token novo (boot reconcilia)", t)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Delegado ao router: na New Architecture `reactNativeHost` LANÇA, e aqui
83
+ * estamos numa thread de serviço do FCM — a exceção derrubaria o app a
84
+ * cada push/rotação de token (crash real observado em campo).
85
+ */
86
+ private fun currentReactContext(): ReactContext? =
87
+ PushMeshMessageRouter.contextReact(applicationContext)
88
+ }
@@ -0,0 +1,7 @@
1
+ import type { PushMeshConfig } from './types';
2
+ /**
3
+ * Sequência de boot do SDK: montagem automática do host in-app + init + hook de
4
+ * AppState + reporte de permissão no cold start (fire-and-forget — não bloqueia
5
+ * a abertura do app). Usado pelo PushMesh.init e pela API clássica (compat).
6
+ */
7
+ export declare function boot(config: PushMeshConfig): Promise<void>;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true}),exports.boot=boot;const auto_1=require("./inapp/auto"),permissions_1=require("./permissions"),players_1=require("./players");async function boot(config){true===config.inAppAutoMontar&&(0,auto_1.montarInAppAutomaticamente)(),await(0,players_1.init)(config),(0,permissions_1.attachAppStateHook)(),(0,permissions_1.checkAndReport)().catch(e=>console.warn("[PushMesh] checkAndReport falhou",e))}