@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.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/android/build.gradle +82 -0
- package/android/src/main/AndroidManifest.xml +69 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshClickActivity.kt +124 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebaseModule.kt +512 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshFirebasePackage.kt +20 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageBuffer.kt +53 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshMessageRouter.kt +477 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingReceiver.kt +77 -0
- package/android/src/main/java/io/pushmesh/sdk/PushMeshMessagingService.kt +88 -0
- package/dist/cjs/boot.d.ts +7 -0
- package/dist/cjs/boot.js +1 -0
- package/dist/cjs/compat.d.ts +63 -0
- package/dist/cjs/compat.js +1 -0
- package/dist/cjs/doctor.d.ts +27 -0
- package/dist/cjs/doctor.js +2 -0
- package/dist/cjs/events.d.ts +21 -0
- package/dist/cjs/events.js +1 -0
- package/dist/cjs/http.d.ts +20 -0
- package/dist/cjs/http.js +1 -0
- package/dist/cjs/inapp/SkeletonShimmer.d.ts +1 -0
- package/dist/cjs/inapp/SkeletonShimmer.js +1 -0
- package/dist/cjs/inapp/auto.d.ts +1 -0
- package/dist/cjs/inapp/auto.js +1 -0
- package/dist/cjs/inapp/components.d.ts +14 -0
- package/dist/cjs/inapp/components.js +1 -0
- package/dist/cjs/inapp/engine.d.ts +1 -0
- package/dist/cjs/inapp/engine.js +1 -0
- package/dist/cjs/inapp/imageCache.d.ts +1 -0
- package/dist/cjs/inapp/imageCache.js +1 -0
- package/dist/cjs/inapp/index.d.ts +20 -0
- package/dist/cjs/inapp/index.js +1 -0
- package/dist/cjs/inapp/lifecycle.d.ts +1 -0
- package/dist/cjs/inapp/lifecycle.js +1 -0
- package/dist/cjs/inapp/normalize.d.ts +1 -0
- package/dist/cjs/inapp/normalize.js +1 -0
- package/dist/cjs/inapp/registro.d.ts +1 -0
- package/dist/cjs/inapp/registro.js +1 -0
- package/dist/cjs/inapp/sessao.d.ts +1 -0
- package/dist/cjs/inapp/sessao.js +1 -0
- package/dist/cjs/index.d.ts +118 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/perfil.d.ts +52 -0
- package/dist/cjs/perfil.js +1 -0
- package/dist/cjs/permissions.d.ts +54 -0
- package/dist/cjs/permissions.js +1 -0
- package/dist/cjs/players.d.ts +20 -0
- package/dist/cjs/players.js +1 -0
- package/dist/cjs/queue.d.ts +1 -0
- package/dist/cjs/queue.js +1 -0
- package/dist/cjs/receipts.d.ts +32 -0
- package/dist/cjs/receipts.js +1 -0
- package/dist/cjs/state.d.ts +21 -0
- package/dist/cjs/state.js +1 -0
- package/dist/cjs/storage.d.ts +1 -0
- package/dist/cjs/storage.js +1 -0
- package/dist/cjs/tags.d.ts +13 -0
- package/dist/cjs/tags.js +1 -0
- package/dist/cjs/triggers.d.ts +22 -0
- package/dist/cjs/triggers.js +1 -0
- package/dist/cjs/types.d.ts +258 -0
- package/dist/cjs/types.js +1 -0
- package/dist/cjs/zeroconfig.d.ts +45 -0
- package/dist/cjs/zeroconfig.js +1 -0
- package/ios/NotificationService/LEIA-ME.md +105 -0
- package/ios/NotificationService/PushMeshNotificationService.swift +384 -0
- package/ios/PushMeshApns.swift +746 -0
- package/ios/PushMeshApnsModule.m +76 -0
- package/ios/PushMeshSwizzleRegistro.swift +141 -0
- package/package.json +113 -0
- package/pushmesh-sdk.podspec +32 -0
- 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
|
+
}
|