@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,512 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.app.NotificationManager
7
+ import android.app.usage.UsageStatsManager
8
+ import android.net.ConnectivityManager
9
+ import android.net.NetworkCapabilities
10
+ import android.os.Build
11
+ import android.os.PowerManager
12
+ import android.provider.Settings
13
+ import java.security.MessageDigest
14
+ import android.telephony.TelephonyManager
15
+ import android.util.Log
16
+ import androidx.core.app.NotificationManagerCompat
17
+ import java.io.File
18
+ import com.facebook.react.bridge.ActivityEventListener
19
+ import com.facebook.react.bridge.Arguments
20
+ import com.facebook.react.bridge.Promise
21
+ import com.facebook.react.bridge.ReactApplicationContext
22
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
23
+ import com.facebook.react.bridge.ReactMethod
24
+ import com.facebook.react.bridge.ReadableMap
25
+ import com.facebook.react.bridge.WritableMap
26
+ import com.facebook.react.modules.core.DeviceEventManagerModule
27
+ import com.google.firebase.FirebaseApp
28
+ import com.google.firebase.FirebaseOptions
29
+ import com.google.firebase.messaging.FirebaseMessaging
30
+
31
+ /**
32
+ * Módulo nativo do modo ZERO-CONFIG (Android).
33
+ *
34
+ * Inicializa o Firebase por código, com os parâmetros públicos (api_key,
35
+ * sender_id, app_id, project_id) que o JS buscou no servidor PUSHMESH
36
+ * (GET /api/v1/apps/:appId/firebase_params) — sem google-services.json e sem
37
+ * o plugin google-services no app do cliente.
38
+ *
39
+ * POLÍTICA DE INICIALIZAÇÃO (default-first):
40
+ * - Caminho principal: o app do cliente NÃO tem Firebase → inicializamos o
41
+ * FirebaseApp DEFAULT (sem nome). É o caso-alvo do zero-config.
42
+ * - Fallback raro: o host JÁ tem um FirebaseApp default (tem Firebase, mas não
43
+ * passou getToken no init) → caímos para o app secundário nomeado
44
+ * "pushmesh". Esse suporte multi-projeto do FCM foi DEPRECADO pelo Google
45
+ * no firebase-messaging 25.1.0 (PR firebase-android-sdk#8087) — por isso
46
+ * logamos um aviso orientando o host a passar getToken, e por isso o pin
47
+ * de versão no build.gradle fica FORA da linha 25.1.x.
48
+ *
49
+ * Também carrega as pontes de runtime do zero-config: setReceiptConfig (o
50
+ * recibo direto do PushMeshMessageRouter), token pendente (rotação sem JS),
51
+ * e clique na notificação (getInitialNotification + onNewIntent).
52
+ *
53
+ * Módulo old-arch (NativeModule): funciona na New Architecture (Fabric) via
54
+ * interop layer do React Native. A migração para TurboModule/codegen é uma
55
+ * fase posterior.
56
+ */
57
+ class PushMeshFirebaseModule(private val reactContext: ReactApplicationContext) :
58
+ ReactContextBaseJavaModule(reactContext), ActivityEventListener {
59
+
60
+ /** App escolhido pelo configure() (default OU secundário) — usado pelo getToken. */
61
+ private var appEscolhido: FirebaseApp? = null
62
+
63
+ override fun getName(): String = NAME
64
+
65
+ override fun initialize() {
66
+ reactContext.addActivityEventListener(this)
67
+ }
68
+
69
+ override fun invalidate() {
70
+ reactContext.removeActivityEventListener(this)
71
+ }
72
+
73
+ /**
74
+ * Configura o Firebase por código (ver a política default-first no KDoc da classe).
75
+ *
76
+ * Idempotente, nesta ordem de decisão:
77
+ * - não há FirebaseApp default → criamos o DEFAULT (caminho principal) e
78
+ * guardamos a AUTORIA dele;
79
+ * - o default existe com as MESMAS options → reutiliza (no-op);
80
+ * - o default existe com options DIFERENTES **e é NOSSO** (cliente trocou as
81
+ * credenciais no painel) → destrói e recria o DEFAULT com as options novas;
82
+ * - o default existe com options diferentes e é do HOST → app secundário
83
+ * 'pushmesh' (fallback deprecado).
84
+ *
85
+ * O terceiro caso faltava (corrigido em 2026-08-01) e o efeito era mudo: a
86
+ * troca de credenciais no painel caía no braço do secundário, logava um aviso
87
+ * MENTIROSO ("o host tem Firebase") e deixava o PushMeshMessagingService
88
+ * amarrado ao default com as credenciais VELHAS — getToken devolvia o token
89
+ * do projeto novo enquanto o onNewToken entregava o do antigo.
90
+ */
91
+ @ReactMethod
92
+ fun configure(params: ReadableMap, promise: Promise) {
93
+ try {
94
+ val appId = params.getString("app_id")
95
+ val apiKey = params.getString("api_key")
96
+ val senderId = params.getString("sender_id")
97
+ val projectId =
98
+ if (params.hasKey("project_id") && !params.isNull("project_id")) params.getString("project_id")
99
+ else null
100
+
101
+ if (appId.isNullOrBlank() || apiKey.isNullOrBlank() || senderId.isNullOrBlank()) {
102
+ promise.reject(ERROR_CONFIGURE, "params incompletos: app_id, api_key e sender_id são obrigatórios")
103
+ return
104
+ }
105
+
106
+ val builder = FirebaseOptions.Builder()
107
+ .setApplicationId(appId)
108
+ .setApiKey(apiKey)
109
+ .setGcmSenderId(senderId)
110
+ if (projectId != null) builder.setProjectId(projectId)
111
+ val options = builder.build()
112
+
113
+ val context = reactContext.applicationContext
114
+ val defaultApp = FirebaseApp.getApps(context).firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME }
115
+
116
+ val escolhido = when {
117
+ // Caminho principal: nenhum FirebaseApp default → o app do cliente não
118
+ // tem Firebase. Inicializamos o DEFAULT (imune à deprecação de
119
+ // multi-projeto do firebase-messaging 25.1.x).
120
+ defaultApp == null -> criarDefaultNosso(context, options)
121
+
122
+ // O default já está com as options que queremos (re-configure no mesmo
123
+ // processo, ou o host usa exatamente o mesmo projeto): reutiliza.
124
+ // FirebaseOptions implementa equals() comparando os campos públicos.
125
+ // NÃO reivindicamos autoria aqui: pode ser o default do host.
126
+ defaultApp.options == options -> defaultApp
127
+
128
+ // Credenciais NOVAS no painel e o default é comprovadamente NOSSO:
129
+ // derruba e recria como DEFAULT. É o único jeito de o
130
+ // PushMeshMessagingService (que é sempre do default) passar a falar
131
+ // com o projeto novo; delete() é permitido inclusive no default e um
132
+ // initializeApp com o mesmo nome depois dele funciona.
133
+ defaultENosso(context, defaultApp) -> {
134
+ Log.w(TAG, "PushMesh: credenciais do FCM mudaram — recriando o FirebaseApp default.")
135
+ defaultApp.delete()
136
+ criarDefaultNosso(context, options)
137
+ }
138
+
139
+ // Fallback raro: o host tem Firebase próprio e mesmo assim não passou
140
+ // getToken. Mantemos o app secundário nomeado — mas avisamos: o
141
+ // suporte multi-projeto do FCM está deprecado (25.1.0, PR #8087).
142
+ else -> {
143
+ Log.w(
144
+ TAG,
145
+ "PushMesh: FirebaseApp default já existe (o host tem Firebase). " +
146
+ "Caindo para o app secundário '$APP_NAME' — o suporte multi-projeto do FCM " +
147
+ "está deprecado pelo Google (firebase-messaging 25.1.0). " +
148
+ "Recomendado: passe getToken no PushMesh.init (modo host).",
149
+ )
150
+ val secundario = FirebaseApp.getApps(context).firstOrNull { it.name == APP_NAME }
151
+ if (secundario != null && secundario.options == options) {
152
+ secundario
153
+ } else {
154
+ secundario?.delete()
155
+ FirebaseApp.initializeApp(context, options, APP_NAME)
156
+ }
157
+ }
158
+ }
159
+ appEscolhido = escolhido
160
+
161
+ // MODO persistido para quem roda SEM JS: o onNewToken só recebe token do
162
+ // FirebaseApp DEFAULT, então no modo secundário aquele token é do HOST e
163
+ // precisa ser ignorado (ver PushMeshMessagingService.onNewToken).
164
+ // Derivado do app escolhido, nunca duplicado em cada braço acima.
165
+ PushMeshMessageRouter.salvarModoFcm(
166
+ context,
167
+ if (escolhido.name == FirebaseApp.DEFAULT_APP_NAME) PushMeshMessageRouter.MODO_DEFAULT
168
+ else PushMeshMessageRouter.MODO_SECUNDARIO,
169
+ )
170
+ promise.resolve(null)
171
+ } catch (e: Exception) {
172
+ promise.reject(ERROR_CONFIGURE, e.message, e)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Cria o FirebaseApp DEFAULT e ASSINA a autoria: guardamos o applicationId
178
+ * das options usadas. É esse registro que autoriza um `delete()` futuro.
179
+ */
180
+ private fun criarDefaultNosso(context: Context, options: FirebaseOptions): FirebaseApp {
181
+ val app = FirebaseApp.initializeApp(context, options)
182
+ PushMeshMessageRouter.prefs(context).edit()
183
+ .putString(PREF_DEFAULT_NOSSO, options.applicationId)
184
+ .apply()
185
+ return app
186
+ }
187
+
188
+ /**
189
+ * O FirebaseApp default é obra NOSSA?
190
+ *
191
+ * A prova é o applicationId com que NÓS o criamos ([criarDefaultNosso]) —
192
+ * e não um simples booleano "já criei um default alguma vez". A diferença é
193
+ * o cenário que estraga o app do cliente: se ele publicar uma versão com
194
+ * Firebase próprio (google-services.json), o default passa a nascer sozinho,
195
+ * pelo ContentProvider do Firebase, ANTES de qualquer código nosso. Com um
196
+ * booleano velho no disco nós deletaríamos o Firebase DO HOST — levando
197
+ * junto o Analytics/Crashlytics dele. Comparando o applicationId, o default
198
+ * do host (outro projeto ⇒ outro id) simplesmente não bate, e caímos no
199
+ * fallback secundário como manda a política.
200
+ */
201
+ private fun defaultENosso(context: Context, defaultApp: FirebaseApp): Boolean {
202
+ val nossoId = PushMeshMessageRouter.prefs(context).getString(PREF_DEFAULT_NOSSO, null)
203
+ return !nossoId.isNullOrBlank() && nossoId == defaultApp.options.applicationId
204
+ }
205
+
206
+ /** Token FCM do app escolhido no configure(). Exige configure() antes. */
207
+ @ReactMethod
208
+ fun getToken(promise: Promise) {
209
+ try {
210
+ val app = appEscolhido
211
+ ?: throw IllegalStateException("configure() precisa rodar antes de getToken()")
212
+ app.get(FirebaseMessaging::class.java).token
213
+ .addOnSuccessListener { token -> promise.resolve(token) }
214
+ .addOnFailureListener { e -> promise.reject(ERROR_TOKEN, e.message, e) }
215
+ } catch (e: Exception) {
216
+ promise.reject(ERROR_TOKEN, e.message, e)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Drena as mensagens recebidas sem JS ativo (buffer em SharedPreferences).
222
+ * O JS chama no init e processa cada uma (recibo + display).
223
+ */
224
+ @ReactMethod
225
+ fun getPendingMessages(promise: Promise) {
226
+ try {
227
+ promise.resolve(PushMeshMessageBuffer.drain(reactContext.applicationContext))
228
+ } catch (e: Exception) {
229
+ promise.reject(ERROR_PENDING, e.message, e)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Config do recibo DIRETO nativo (PushMeshMessageRouter.postarRecibo):
235
+ * base_url/app_id/player_id persistidos — o POST sai mesmo com o JS morto.
236
+ * Chamado pelo JS após cada registro bem-sucedido.
237
+ */
238
+ @ReactMethod
239
+ fun setReceiptConfig(params: ReadableMap, promise: Promise) {
240
+ try {
241
+ PushMeshMessageRouter.prefs(reactContext.applicationContext).edit()
242
+ .putString("pmx_base_url", params.getString("base_url")?.trimEnd('/'))
243
+ .putString("pmx_app_id", params.getString("app_id"))
244
+ .putString("pmx_player_id", params.getString("player_id"))
245
+ .apply()
246
+ promise.resolve(null)
247
+ } catch (e: Exception) {
248
+ promise.reject(ERROR_RECEIPT, e.message, e)
249
+ }
250
+ }
251
+
252
+ /** Token FCM persistido pelo onNewToken (rotação sem JS vivo) — o boot reconcilia. */
253
+ @ReactMethod
254
+ fun getPendingToken(promise: Promise) {
255
+ try {
256
+ promise.resolve(PushMeshMessageRouter.tokenPendente(reactContext.applicationContext))
257
+ } catch (e: Exception) {
258
+ promise.reject(ERROR_TOKEN, e.message, e)
259
+ }
260
+ }
261
+
262
+ @ReactMethod
263
+ fun clearPendingToken(promise: Promise) {
264
+ try {
265
+ PushMeshMessageRouter.limparTokenPendente(reactContext.applicationContext)
266
+ promise.resolve(null)
267
+ } catch (e: Exception) {
268
+ promise.reject(ERROR_TOKEN, e.message, e)
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Clique com o app MORTO: a MainActivity nasce com os extras do push no
274
+ * intent. Lê e LIMPA (recreate/rotate não entrega 2x). null quando o app
275
+ * não foi aberto por notificação.
276
+ */
277
+ @ReactMethod
278
+ fun getInitialNotification(promise: Promise) {
279
+ try {
280
+ val intent = reactContext.currentActivity?.intent
281
+ val data = extrairPushExtras(intent)
282
+ if (data != null && intent != null) {
283
+ for (k in data.keys) intent.removeExtra(k)
284
+ }
285
+ promise.resolve(data?.let { paraWritable(it) })
286
+ } catch (e: Exception) {
287
+ promise.reject(ERROR_CLICK, e.message, e)
288
+ }
289
+ }
290
+
291
+ /**
292
+ * PERFIL DO APARELHO — nível GRATUITO de rastreamento.
293
+ *
294
+ * Só o que NÃO pede permissão ao usuário e NÃO altera a ficha de
295
+ * privacidade do app do cliente na Play (id de publicidade e localização
296
+ * ficam de fora por decisão de produto).
297
+ *
298
+ * Cada leitura é isolada: aparelho de OEM exótico que quebre uma delas
299
+ * registra igual, só com aquele campo vazio. NUNCA rejeita a promise —
300
+ * perfil ausente não pode impedir o aparelho de receber push.
301
+ */
302
+ @ReactMethod
303
+ fun perfilDispositivo(promise: Promise) {
304
+ val map = Arguments.createMap()
305
+ // Build.* nunca lança e não pede permissão.
306
+ map.putString("device_os", Build.VERSION.RELEASE)
307
+ map.putString("fabricante", Build.MANUFACTURER)
308
+ map.putString("modelo", Build.MODEL)
309
+
310
+ // Operadora: getNetworkOperatorName NÃO exige permissão. Vazio em tablet
311
+ // sem chip — melhor ausente que "".
312
+ runCatching {
313
+ val tm = reactContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
314
+ tm?.networkOperatorName?.takeIf { it.isNotBlank() }?.let { map.putString("carrier", it) }
315
+ }
316
+
317
+ // Rede: ACCESS_NETWORK_STATE é permissão NORMAL (concedida na instalação,
318
+ // sem diálogo) e está declarada no manifest do SDK.
319
+ runCatching {
320
+ val cm = reactContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
321
+ val caps = cm?.getNetworkCapabilities(cm.activeNetwork)
322
+ val tipo = when {
323
+ caps == null -> null
324
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
325
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "celular"
326
+ else -> "outro"
327
+ }
328
+ tipo?.let { map.putString("net_type", it) }
329
+ }
330
+
331
+ map.putBoolean("rooted", indicioDeRoot())
332
+
333
+ // --- Diagnóstico de ENTREGA (migration 0013) ---
334
+
335
+ // Fila de standby: É o throttling de push data-only dito pelo próprio
336
+ // sistema. App em 'rare'/'restricted' tem a entrega ADIADA pelo Android.
337
+ runCatching {
338
+ if (Build.VERSION.SDK_INT >= 28) {
339
+ val usm = reactContext.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
340
+ usm?.appStandbyBucket?.let { b ->
341
+ val nome = when (b) {
342
+ 5 -> "exempted"
343
+ UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active"
344
+ UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working_set"
345
+ UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent"
346
+ UsageStatsManager.STANDBY_BUCKET_RARE -> "rare"
347
+ 45 -> "restricted" // constante só na API 30+; o valor é estável
348
+ else -> "b$b"
349
+ }
350
+ map.putString("standby_bucket", nome)
351
+ }
352
+ }
353
+ }
354
+
355
+ // Isenção da otimização de bateria: sem ela, OEMs agressivos (Xiaomi/
356
+ // Samsung/Huawei) matam o processo e o push data-only nem acorda o app.
357
+ // LER não pede permissão nenhuma (só PEDIR a isenção pediria).
358
+ runCatching {
359
+ val pm = reactContext.getSystemService(Context.POWER_SERVICE) as? PowerManager
360
+ pm?.let { map.putBoolean("bateria_irrestrita", it.isIgnoringBatteryOptimizations(reactContext.packageName)) }
361
+ }
362
+
363
+ runCatching { map.putInt("notif_permissoes", bitmaskNotificacoes()) }
364
+ runCatching { idDaInstalacao()?.let { map.putString("instalacao_id", it) } }
365
+ promise.resolve(map)
366
+ }
367
+
368
+ /**
369
+ * Identificador estável do APARELHO — em HASH, nunca cru.
370
+ *
371
+ * Fonte: `Settings.Secure.ANDROID_ID`. Não pede permissão e é escopado por
372
+ * (chave de assinatura do app + usuário + aparelho): não serve para
373
+ * correlacionar entre apps, e SOBREVIVE À REINSTALAÇÃO. É isso que permite
374
+ * ao servidor saber que o token novo é o MESMO aparelho e derrubar o
375
+ * fantasma na hora, em vez de esperar um envio falhar.
376
+ *
377
+ * Devolvemos SHA-256 truncado: para comparar igualdade o hash basta, e o
378
+ * identificador cru nunca sai do aparelho (privacidade por desenho).
379
+ */
380
+ private fun idDaInstalacao(): String? {
381
+ val cru = Settings.Secure.getString(reactContext.contentResolver, Settings.Secure.ANDROID_ID)
382
+ if (cru.isNullOrBlank()) return null
383
+ val digest = MessageDigest.getInstance("SHA-256")
384
+ // sal fixo do SDK: hash sem sal de um espaço pequeno é reversível
385
+ .digest("pushmesh:$cru".toByteArray(Charsets.UTF_8))
386
+ return digest.take(16).joinToString("") { "%02x".format(it) }
387
+ }
388
+
389
+ /**
390
+ * BITMASK granular da permissão de notificação (contrato da migration 0013):
391
+ * 1=habilitadas · 2=alerta/banner · 4=som · 8=badge · 16=tela de bloqueio ·
392
+ * 32=canal padrão não bloqueado. Bitmask por decisão de produto: 6 sinais
393
+ * num INT — filtrar "quem tem permissão mas não vê banner" vira operação
394
+ * de bits.
395
+ *
396
+ * O detalhe vem do NOSSO canal (`pushmesh_default`). Antes do 1º push o
397
+ * canal ainda não existe — aí os bits refletem os defaults com que ele
398
+ * NASCERÁ (IMPORTANCE_HIGH): reportar só a chave-mestra pareceria "sem
399
+ * alerta", o que seria mentira.
400
+ */
401
+ private fun bitmaskNotificacoes(): Int {
402
+ val ctx = reactContext.applicationContext
403
+ var bits = 0
404
+ val master = runCatching { NotificationManagerCompat.from(ctx).areNotificationsEnabled() }
405
+ .getOrDefault(false)
406
+ if (!master) return 0
407
+ bits = bits or 1
408
+ val canal = runCatching {
409
+ (ctx.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)
410
+ ?.getNotificationChannel(PushMeshMessageRouter.CANAL_PADRAO)
411
+ }.getOrNull()
412
+ if (canal == null) {
413
+ // canal ainda não criado: nascerá HIGH com som/badge/lockscreen default
414
+ return bits or 2 or 4 or 8 or 16 or 32
415
+ }
416
+ val importancia = canal.importance
417
+ if (importancia == NotificationManager.IMPORTANCE_NONE) return bits // canal bloqueado
418
+ bits = bits or 32
419
+ if (importancia >= NotificationManager.IMPORTANCE_HIGH) bits = bits or 2 // banner/heads-up
420
+ if (importancia >= NotificationManager.IMPORTANCE_DEFAULT && canal.sound != null) bits = bits or 4
421
+ if (canal.canShowBadge()) bits = bits or 8
422
+ // -1000 = sem override (mostra); só VISIBILITY_SECRET esconde do bloqueio
423
+ if (canal.lockscreenVisibility != android.app.Notification.VISIBILITY_SECRET) bits = bits or 16
424
+ return bits
425
+ }
426
+
427
+ /**
428
+ * Heurística de root — SINAL, não prova (um root bem feito se esconde).
429
+ * Serve como insumo antifraude, e por isso `false` significa "a heurística
430
+ * não achou indício", nunca "o aparelho é confiável".
431
+ */
432
+ private fun indicioDeRoot(): Boolean {
433
+ val caminhos = listOf(
434
+ "/system/app/Superuser.apk",
435
+ "/sbin/su",
436
+ "/system/bin/su",
437
+ "/system/xbin/su",
438
+ "/data/local/xbin/su",
439
+ "/data/local/bin/su",
440
+ "/system/sd/xbin/su",
441
+ "/system/bin/failsafe/su",
442
+ "/data/local/su",
443
+ "/su/bin/su",
444
+ )
445
+ if (caminhos.any { runCatching { File(it).exists() }.getOrDefault(false) }) return true
446
+ // build de teste/engenharia: assinado com chaves de dev, não de fábrica
447
+ return Build.TAGS?.contains("test-keys") == true
448
+ }
449
+
450
+ /**
451
+ * Clique com o app ABERTO (singleTop → onNewIntent): emite para o JS.
452
+ *
453
+ * Parâmetro NÃO-NULO de propósito: desde a conversão do RN para Kotlin
454
+ * (`ActivityEventListener.onNewIntent(intent: Intent)`, RN 0.8x) um
455
+ * `Intent?` aqui NÃO sobrescreve nada e a compilação do app quebra. Contra
456
+ * as versões antigas (interface em Java) o tipo é plataforma, então a
457
+ * assinatura não-nula compila igual — vale para toda a faixa suportada.
458
+ */
459
+ override fun onNewIntent(intent: Intent) {
460
+ val data = extrairPushExtras(intent) ?: return
461
+ if (reactContext.hasActiveReactInstance()) {
462
+ val payload = Arguments.createMap()
463
+ payload.putMap("data", paraWritable(data))
464
+ reactContext
465
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
466
+ .emit(EVENT_CLICK, payload)
467
+ }
468
+ }
469
+
470
+ override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
471
+ // nada — a interface exige (não usamos startActivityForResult)
472
+ }
473
+
474
+ /** Extras string do intent de clique (o PendingIntent carrega o data inteiro do push). */
475
+ private fun extrairPushExtras(intent: Intent?): Map<String, String>? {
476
+ val extras = intent?.extras ?: return null
477
+ val data = HashMap<String, String>()
478
+ for (k in extras.keySet()) {
479
+ (extras.get(k) as? String)?.let { data[k] = it }
480
+ }
481
+ // só é "clique de push" se carrega o marcador do nosso payload
482
+ return if (data.keys.any { it.startsWith("pmx_") || it.startsWith("pm_") }) data else null
483
+ }
484
+
485
+ private fun paraWritable(data: Map<String, String>): WritableMap {
486
+ val map = Arguments.createMap()
487
+ for ((k, v) in data) map.putString(k, v)
488
+ return map
489
+ }
490
+
491
+ companion object {
492
+ const val NAME = "PushMeshFirebase"
493
+ const val APP_NAME = "pushmesh"
494
+ const val EVENT_MESSAGE = "PushMeshMessage"
495
+ const val EVENT_TOKEN_REFRESH = "PushMeshTokenRefresh"
496
+ const val EVENT_CLICK = "PushMeshNotificationClick"
497
+ private const val TAG = "PushMeshFirebase"
498
+
499
+ /**
500
+ * applicationId do FirebaseApp DEFAULT que NÓS criamos — a assinatura de
501
+ * autoria que autoriza recriá-lo quando as credenciais mudam no painel
502
+ * (ver [defaultENosso]).
503
+ */
504
+ private const val PREF_DEFAULT_NOSSO = "pmx_default_nosso_app_id"
505
+
506
+ private const val ERROR_CONFIGURE = "pushmesh_configure"
507
+ private const val ERROR_TOKEN = "pushmesh_token"
508
+ private const val ERROR_PENDING = "pushmesh_pending"
509
+ private const val ERROR_RECEIPT = "pushmesh_receipt"
510
+ private const val ERROR_CLICK = "pushmesh_click"
511
+ }
512
+ }
@@ -0,0 +1,20 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ /**
9
+ * Package do módulo zero-config (autolinking do RN).
10
+ * Módulo old-arch (NativeModule) — funciona na New Architecture via interop
11
+ * layer do RN; a migração para TurboModule é uma fase posterior.
12
+ */
13
+ class PushMeshFirebasePackage : ReactPackage {
14
+
15
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
16
+ listOf(PushMeshFirebaseModule(reactContext))
17
+
18
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
19
+ emptyList()
20
+ }
@@ -0,0 +1,53 @@
1
+ package io.pushmesh.sdk
2
+
3
+ import android.content.Context
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.WritableArray
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * Buffer de mensagens recebidas SEM JS ativo (app em background/morto, push
11
+ * data-only). Persiste em SharedPreferences; o JS drena no init via
12
+ * getPendingMessages() — o recibo de entrega nunca se perde.
13
+ *
14
+ * Cap de 20 mensagens (descarta a mais antiga): o buffer existe para o
15
+ * recibo, não para histórico.
16
+ */
17
+ object PushMeshMessageBuffer {
18
+
19
+ private const val PREFS = "pushmesh_pending"
20
+ private const val KEY = "messages"
21
+ private const val MAX = 20
22
+
23
+ @Synchronized
24
+ fun add(context: Context, data: Map<String, String>) {
25
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
26
+ val atual = JSONArray(prefs.getString(KEY, "[]"))
27
+ val item = JSONObject()
28
+ for ((k, v) in data) item.put(k, v)
29
+ atual.put(item)
30
+ while (atual.length() > MAX) atual.remove(0)
31
+ prefs.edit().putString(KEY, atual.toString()).apply()
32
+ }
33
+
34
+ /** Lê e limpa o buffer. Retorna um WritableArray de mapas {chave: valor}. */
35
+ @Synchronized
36
+ fun drain(context: Context): WritableArray {
37
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
38
+ val bruto = JSONArray(prefs.getString(KEY, "[]"))
39
+ prefs.edit().remove(KEY).apply()
40
+ val out = Arguments.createArray()
41
+ for (i in 0 until bruto.length()) {
42
+ val obj = bruto.optJSONObject(i) ?: continue
43
+ val map = Arguments.createMap()
44
+ val keys = obj.keys()
45
+ while (keys.hasNext()) {
46
+ val k = keys.next()
47
+ map.putString(k, obj.optString(k))
48
+ }
49
+ out.pushMap(map)
50
+ }
51
+ return out
52
+ }
53
+ }