@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,384 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Notification Service Extension do PUSHMESH — a peça que faz a IMAGEM
|
|
6
|
+
* aparecer no push do iOS.
|
|
7
|
+
*
|
|
8
|
+
* POR QUE ESTE ARQUIVO EXISTE (e por que ele não pode morar dentro do app):
|
|
9
|
+
* o servidor já manda tudo que é preciso — `mutable-content: 1` no `aps` e a
|
|
10
|
+
* URL da imagem no payload. Só que no iOS QUEM baixa a imagem é o aparelho,
|
|
11
|
+
* num processo SEPARADO do app, e esse processo só existe se o projeto tiver
|
|
12
|
+
* um alvo (target) do tipo Notification Service Extension. Sem esse alvo o
|
|
13
|
+
* iOS ignora o `mutable-content` em silêncio e a notificação chega sem
|
|
14
|
+
* imagem — exatamente o sintoma "no Android a imagem aparece, no iOS não".
|
|
15
|
+
* É exigência da Apple, não limitação do SDK: nenhum código dentro do app
|
|
16
|
+
* consegue substituir a extensão, porque em background/app morto o app nem
|
|
17
|
+
* roda. Passo a passo de instalação: LEIA-ME.md, nesta mesma pasta.
|
|
18
|
+
*
|
|
19
|
+
* O QUE ESTA EXTENSÃO FAZ (só isto — de propósito):
|
|
20
|
+
* 1. lê a URL da imagem no payload (chaves do servidor, ver `PushMeshAnexo`);
|
|
21
|
+
* 2. baixa com prazo curto e teto de tamanho;
|
|
22
|
+
* 3. grava num arquivo temporário com a extensão CERTA (.jpg/.png/.gif);
|
|
23
|
+
* 4. anexa e entrega.
|
|
24
|
+
*
|
|
25
|
+
* A LEI DESTE ARQUIVO: a notificação NUNCA some. Imagem é enfeite; a mensagem
|
|
26
|
+
* é a promessa. Falha de rede, 404, formato que a Apple não aceita, arquivo
|
|
27
|
+
* gigante, tempo esgotado — todo caminho de erro entrega o conteúdo ORIGINAL.
|
|
28
|
+
* O `contentHandler` é chamado EXATAMENTE UMA VEZ, sempre (chamar duas vezes
|
|
29
|
+
* é comportamento indefinido; não chamar faz o iOS entregar uma notificação
|
|
30
|
+
* degradada depois de ~30s).
|
|
31
|
+
*
|
|
32
|
+
* O QUE ESTA EXTENSÃO **NÃO** FAZ (não é esquecimento, é escopo):
|
|
33
|
+
* recibo de entrega em background/app morto. Isso é outra frente — exigiria
|
|
34
|
+
* a extensão conhecer appId/baseUrl e falar com o servidor. Enquanto não
|
|
35
|
+
* chega, o recibo do iOS segue contando o que o app vê (foreground/toque),
|
|
36
|
+
* como está documentado no README do SDK.
|
|
37
|
+
*
|
|
38
|
+
* DEPENDÊNCIAS: nenhuma. Só Foundation + UserNotifications, ambos do SDK do
|
|
39
|
+
* sistema. Sem CocoaPods, sem App Group, sem Keychain compartilhado — por
|
|
40
|
+
* isso a instalação é "criar o alvo e apontar este arquivo", e nada mais.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// MARK: - Partes puras (é o que o cenário executável dos testes exercita)
|
|
44
|
+
|
|
45
|
+
/// Leitura do payload e decisão do formato — SEM rede e SEM UNNotification*,
|
|
46
|
+
/// para poder ser exercitado por um binário de teste de verdade
|
|
47
|
+
/// (`__tests__/ios/cenario_nse.swift`).
|
|
48
|
+
enum PushMeshAnexo {
|
|
49
|
+
|
|
50
|
+
// As chaves são as do servidor (src/domain/payload.rs). Mudou lá, muda aqui
|
|
51
|
+
// — e o teste `ios-nse.test.ts` lê o Rust e falha se os nomes divergirem.
|
|
52
|
+
|
|
53
|
+
/// `ios_attachments` da API: objeto `{nome: url}`. Vai no payload APNs nos
|
|
54
|
+
/// DOIS caminhos de envio (APNs direto e via FCM).
|
|
55
|
+
static let CHAVE_ANEXOS = "pm_attachments"
|
|
56
|
+
|
|
57
|
+
/// `big_picture` da API — a imagem única, a que o painel de fato manda.
|
|
58
|
+
/// No APNs direto ela chega na RAIZ do payload; no caminho FCM ela sai em
|
|
59
|
+
/// `message.data` e o FCM copia os dados para as chaves custom do APNs.
|
|
60
|
+
static let CHAVE_IMAGEM = "pm_big_picture"
|
|
61
|
+
|
|
62
|
+
/// Espelho da mesma URL no bloco de exibição do Android (`pmx_image`).
|
|
63
|
+
/// Só é usado se as duas de cima faltarem: custa uma linha e salva o caso
|
|
64
|
+
/// de um payload antigo/parcial chegar sem `pm_big_picture`.
|
|
65
|
+
static let CHAVE_IMAGEM_ESPELHO = "pmx_image"
|
|
66
|
+
|
|
67
|
+
/// Teto do arquivo baixado. A Apple recusa anexo de imagem acima de 10 MB —
|
|
68
|
+
/// baixar mais que isso é gastar a janela para levar erro na cara.
|
|
69
|
+
static let TETO_BYTES = 10 * 1024 * 1024
|
|
70
|
+
|
|
71
|
+
/// Prazo de UMA tentativa de download. A Apple dá ~30s para a extensão
|
|
72
|
+
/// inteira; 10s deixa folga para uma segunda URL e para gravar o anexo.
|
|
73
|
+
static let TIMEOUT_SEGUNDOS: TimeInterval = 10
|
|
74
|
+
|
|
75
|
+
/// Prazo de TODAS as tentativas somadas. Passou disto, entrega sem imagem —
|
|
76
|
+
/// esperar o tapa do sistema entregaria uma notificação pior.
|
|
77
|
+
static let JANELA_TOTAL_SEGUNDOS: TimeInterval = 20
|
|
78
|
+
|
|
79
|
+
/// URLs candidatas, na ordem de preferência e sem repetição.
|
|
80
|
+
///
|
|
81
|
+
/// A ordem dos anexos é por NOME ORDENADO, não a ordem do JSON: o payload
|
|
82
|
+
/// vira dicionário (NSDictionary), que não tem ordem — sem ordenar, duas
|
|
83
|
+
/// entregas do mesmo push podiam escolher imagens diferentes.
|
|
84
|
+
static func urlsCandidatas(_ userInfo: [AnyHashable: Any]) -> [String] {
|
|
85
|
+
var achadas: [String] = []
|
|
86
|
+
|
|
87
|
+
func juntar(_ valor: Any?) {
|
|
88
|
+
guard let texto = normalizar(valor) else { return }
|
|
89
|
+
if !achadas.contains(texto) { achadas.append(texto) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if let anexos = userInfo[CHAVE_ANEXOS] as? [String: Any] {
|
|
93
|
+
for nome in anexos.keys.sorted() { juntar(anexos[nome]) }
|
|
94
|
+
}
|
|
95
|
+
juntar(userInfo[CHAVE_IMAGEM])
|
|
96
|
+
juntar(userInfo[CHAVE_IMAGEM_ESPELHO])
|
|
97
|
+
return achadas
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// String não vazia e absoluta http(s) — o resto não é URL de mídia.
|
|
101
|
+
static func normalizar(_ valor: Any?) -> String? {
|
|
102
|
+
guard let texto = (valor as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
103
|
+
!texto.isEmpty else { return nil }
|
|
104
|
+
let minuscula = texto.lowercased()
|
|
105
|
+
guard minuscula.hasPrefix("https://") || minuscula.hasPrefix("http://") else { return nil }
|
|
106
|
+
return texto
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Extensão de arquivo + UTI do anexo, ou `nil` se o formato não serve.
|
|
110
|
+
///
|
|
111
|
+
/// POR QUE A EXTENSÃO IMPORTA: o `UNNotificationAttachment` decide o tipo
|
|
112
|
+
/// pelo NOME do arquivo. Arquivo sem extensão (ou com extensão errada) é
|
|
113
|
+
/// recusado e a imagem some — e o download da CDN vem, quase sempre, com
|
|
114
|
+
/// nome/URL sem extensão nenhuma.
|
|
115
|
+
///
|
|
116
|
+
/// A ordem da decisão é do mais confiável para o menos:
|
|
117
|
+
/// 1. os bytes do arquivo (verdade absoluta, não mente);
|
|
118
|
+
/// 2. o `Content-Type` da resposta (CDN mal configurada manda
|
|
119
|
+
/// `application/octet-stream`);
|
|
120
|
+
/// 3. a extensão na URL (some em URL assinada/`?token=`).
|
|
121
|
+
///
|
|
122
|
+
/// Só jpg/png/gif: são os formatos de imagem que a Apple aceita em anexo.
|
|
123
|
+
/// WEBP/HEIC/AVIF são recusados — por isso a mídia do painel deve ser
|
|
124
|
+
/// jpg/png/gif.
|
|
125
|
+
static func formatoSuportado(
|
|
126
|
+
bytes: [UInt8],
|
|
127
|
+
contentType: String?,
|
|
128
|
+
url: String?
|
|
129
|
+
) -> (extensao: String, uti: String)? {
|
|
130
|
+
if bytes.count >= 3, bytes[0] == 0xFF, bytes[1] == 0xD8, bytes[2] == 0xFF {
|
|
131
|
+
return ("jpg", "public.jpeg")
|
|
132
|
+
}
|
|
133
|
+
if bytes.count >= 4, bytes[0] == 0x89, bytes[1] == 0x50, bytes[2] == 0x4E, bytes[3] == 0x47 {
|
|
134
|
+
return ("png", "public.png")
|
|
135
|
+
}
|
|
136
|
+
if bytes.count >= 4, bytes[0] == 0x47, bytes[1] == 0x49, bytes[2] == 0x46, bytes[3] == 0x38 {
|
|
137
|
+
return ("gif", "com.compuserve.gif")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if let tipo = contentType?
|
|
141
|
+
.split(separator: ";").first?
|
|
142
|
+
.trimmingCharacters(in: .whitespaces)
|
|
143
|
+
.lowercased() {
|
|
144
|
+
switch tipo {
|
|
145
|
+
case "image/jpeg", "image/jpg": return ("jpg", "public.jpeg")
|
|
146
|
+
case "image/png": return ("png", "public.png")
|
|
147
|
+
case "image/gif": return ("gif", "com.compuserve.gif")
|
|
148
|
+
default: break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if let sufixo = extensaoDaUrl(url) {
|
|
153
|
+
switch sufixo {
|
|
154
|
+
case "jpg", "jpeg": return ("jpg", "public.jpeg")
|
|
155
|
+
case "png": return ("png", "public.png")
|
|
156
|
+
case "gif": return ("gif", "com.compuserve.gif")
|
|
157
|
+
default: break
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return nil
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Extensão no caminho da URL, já sem `?query` e sem `#fragmento`.
|
|
164
|
+
static func extensaoDaUrl(_ url: String?) -> String? {
|
|
165
|
+
guard let url else { return nil }
|
|
166
|
+
let caminho = url.split(separator: "?").first.map(String.init) ?? url
|
|
167
|
+
let semAncora = caminho.split(separator: "#").first.map(String.init) ?? caminho
|
|
168
|
+
guard let ultimo = semAncora.split(separator: "/").last else { return nil }
|
|
169
|
+
let partes = ultimo.split(separator: ".")
|
|
170
|
+
guard partes.count >= 2, let sufixo = partes.last else { return nil }
|
|
171
|
+
return sufixo.lowercased()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// MARK: - A extensão
|
|
176
|
+
|
|
177
|
+
/// Classe principal do alvo. O nome é o mesmo que o Xcode dá ao criar o alvo
|
|
178
|
+
/// (`NotificationService`), então o `NSExtensionPrincipalClass` do Info.plist
|
|
179
|
+
/// gerado — `$(PRODUCT_MODULE_NAME).NotificationService` — já aponta para cá
|
|
180
|
+
/// e NÃO precisa ser editado. Trocar o nome desta classe sem trocar o
|
|
181
|
+
/// Info.plist faz a extensão não rodar EM SILÊNCIO (a imagem simplesmente
|
|
182
|
+
/// não aparece, sem erro nenhum): não troque.
|
|
183
|
+
final class NotificationService: UNNotificationServiceExtension {
|
|
184
|
+
|
|
185
|
+
private var entregar: ((UNNotificationContent) -> Void)?
|
|
186
|
+
private var conteudo: UNMutableNotificationContent?
|
|
187
|
+
private var original: UNNotificationContent?
|
|
188
|
+
private var tarefa: URLSessionDownloadTask?
|
|
189
|
+
private var candidatas: [String] = []
|
|
190
|
+
private var limite = Date.distantPast
|
|
191
|
+
|
|
192
|
+
/// Guarda da entrega única. O `serviceExtensionTimeWillExpire` chega numa
|
|
193
|
+
/// thread DIFERENTE da resposta do download: sem trava, os dois podiam
|
|
194
|
+
/// chamar o `contentHandler` no mesmo instante.
|
|
195
|
+
private let trava = NSLock()
|
|
196
|
+
private var jaEntregue = false
|
|
197
|
+
|
|
198
|
+
override func didReceive(
|
|
199
|
+
_ request: UNNotificationRequest,
|
|
200
|
+
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
201
|
+
) {
|
|
202
|
+
entregar = contentHandler
|
|
203
|
+
original = request.content
|
|
204
|
+
conteudo = request.content.mutableCopy() as? UNMutableNotificationContent
|
|
205
|
+
limite = Date().addingTimeInterval(PushMeshAnexo.JANELA_TOTAL_SEGUNDOS)
|
|
206
|
+
candidatas = PushMeshAnexo.urlsCandidatas(request.content.userInfo)
|
|
207
|
+
|
|
208
|
+
// Sem imagem no payload (a maioria dos pushes): entrega JÁ. A extensão
|
|
209
|
+
// roda em todo push com mutable-content — segurar aqui atrasaria tudo.
|
|
210
|
+
if candidatas.isEmpty { entregarUmaVezSo(); return }
|
|
211
|
+
|
|
212
|
+
guard conteudo != nil else {
|
|
213
|
+
// Nunca visto em campo; se acontecer, a mensagem vai intacta.
|
|
214
|
+
NSLog("[PushMesh NSE] conteúdo não é mutável — entregando sem imagem")
|
|
215
|
+
entregarUmaVezSo()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
tentarProxima()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Fim da janela da Apple (~30s). Entregar aqui é OBRIGATÓRIO: sem isto o
|
|
222
|
+
/// sistema mostra a notificação sem passar pelo nosso conteúdo.
|
|
223
|
+
override func serviceExtensionTimeWillExpire() {
|
|
224
|
+
NSLog(
|
|
225
|
+
"[PushMesh NSE] a janela da extensão expirou antes de a imagem baixar — "
|
|
226
|
+
+ "entregando a notificação SEM imagem (a mensagem nunca é sacrificada). "
|
|
227
|
+
+ "Como corrigir: imagem menor (< 1 MB) ou CDN mais rápida.")
|
|
228
|
+
tarefa?.cancel()
|
|
229
|
+
entregarUmaVezSo()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// MARK: - Passos
|
|
233
|
+
|
|
234
|
+
private func tentarProxima() {
|
|
235
|
+
guard !candidatas.isEmpty else { entregarUmaVezSo(); return }
|
|
236
|
+
guard Date() < limite else {
|
|
237
|
+
NSLog("[PushMesh NSE] tempo de download esgotado — entregando sem imagem")
|
|
238
|
+
entregarUmaVezSo()
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
let texto = candidatas.removeFirst()
|
|
242
|
+
guard let url = URL(string: texto) else {
|
|
243
|
+
NSLog(
|
|
244
|
+
"[PushMesh NSE] URL de imagem inválida: %@. "
|
|
245
|
+
+ "Como corrigir: mande a mídia como URL absoluta https:// e sem espaços.",
|
|
246
|
+
texto)
|
|
247
|
+
tentarProxima()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
baixar(url) { [weak self] deuCerto in
|
|
251
|
+
guard let self else { return }
|
|
252
|
+
if deuCerto { self.entregarUmaVezSo() } else { self.tentarProxima() }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func baixar(_ url: URL, _ pronto: @escaping (Bool) -> Void) {
|
|
257
|
+
// Sessão efêmera: a extensão vive segundos, não faz sentido deixar cache
|
|
258
|
+
// e cookies no contêiner dela.
|
|
259
|
+
let config = URLSessionConfiguration.ephemeral
|
|
260
|
+
config.timeoutIntervalForRequest = PushMeshAnexo.TIMEOUT_SEGUNDOS
|
|
261
|
+
config.timeoutIntervalForResource = PushMeshAnexo.TIMEOUT_SEGUNDOS
|
|
262
|
+
config.waitsForConnectivity = false
|
|
263
|
+
let sessao = URLSession(configuration: config)
|
|
264
|
+
|
|
265
|
+
// downloadTask (e não dataTask): o corpo vai direto para disco — imagem
|
|
266
|
+
// grande não passa pela memória de um processo que o iOS mata sem dó.
|
|
267
|
+
let tarefa = sessao.downloadTask(with: url) { [weak self] temporario, resposta, erro in
|
|
268
|
+
defer { sessao.finishTasksAndInvalidate() }
|
|
269
|
+
guard let self else { pronto(false); return }
|
|
270
|
+
pronto(self.anexar(temporario: temporario, resposta: resposta, erro: erro, url: url))
|
|
271
|
+
}
|
|
272
|
+
self.tarefa = tarefa
|
|
273
|
+
tarefa.resume()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Valida o que veio e anexa. Devolve `false` para qualquer motivo de
|
|
277
|
+
/// desistência — e todo `false` é logado com causa + como corrigir, porque
|
|
278
|
+
/// o sintoma no aparelho é sempre o mesmo (imagem some) e sem log o
|
|
279
|
+
/// operador não tem como saber se a culpa é da URL, do formato ou do peso.
|
|
280
|
+
private func anexar(
|
|
281
|
+
temporario: URL?,
|
|
282
|
+
resposta: URLResponse?,
|
|
283
|
+
erro: Error?,
|
|
284
|
+
url: URL
|
|
285
|
+
) -> Bool {
|
|
286
|
+
if let erro {
|
|
287
|
+
let dica =
|
|
288
|
+
url.scheme?.lowercased() == "http"
|
|
289
|
+
? " A URL é http:// — o iOS bloqueia por App Transport Security. "
|
|
290
|
+
+ "Como corrigir: publique a mídia em https://."
|
|
291
|
+
: " Como corrigir: confira se a URL abre no navegador e se a CDN responde rápido."
|
|
292
|
+
NSLog("[PushMesh NSE] download da imagem falhou: %@.%@", erro.localizedDescription, dica)
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
guard let temporario else { return false }
|
|
296
|
+
|
|
297
|
+
if let http = resposta as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
298
|
+
NSLog(
|
|
299
|
+
"[PushMesh NSE] a CDN respondeu %ld para a imagem. "
|
|
300
|
+
+ "Como corrigir: verifique se a URL é pública (sem login/token expirado).",
|
|
301
|
+
http.statusCode)
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let atributos = try? FileManager.default.attributesOfItem(atPath: temporario.path)
|
|
306
|
+
let bytes = (atributos?[.size] as? NSNumber)?.intValue ?? 0
|
|
307
|
+
guard bytes > 0 else {
|
|
308
|
+
NSLog("[PushMesh NSE] a imagem baixada veio vazia — entregando sem imagem")
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
guard bytes <= PushMeshAnexo.TETO_BYTES else {
|
|
312
|
+
NSLog(
|
|
313
|
+
"[PushMesh NSE] imagem de %ld bytes acima do teto de %ld. "
|
|
314
|
+
+ "Como corrigir: a Apple recusa anexo de imagem acima de 10 MB — "
|
|
315
|
+
+ "publique uma versão comprimida.",
|
|
316
|
+
bytes, PushMeshAnexo.TETO_BYTES)
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let contentType = (resposta as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type")
|
|
321
|
+
guard
|
|
322
|
+
let formato = PushMeshAnexo.formatoSuportado(
|
|
323
|
+
bytes: primeirosBytes(temporario, 12),
|
|
324
|
+
contentType: contentType,
|
|
325
|
+
url: url.absoluteString)
|
|
326
|
+
else {
|
|
327
|
+
NSLog(
|
|
328
|
+
"[PushMesh NSE] formato de imagem não reconhecido (Content-Type: %@). "
|
|
329
|
+
+ "Como corrigir: o iOS só aceita jpg, png e gif em anexo de notificação — "
|
|
330
|
+
+ "webp/heic/avif não entram.",
|
|
331
|
+
contentType ?? "ausente")
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// O arquivo do downloadTask é apagado quando este bloco retorna: mover
|
|
336
|
+
// para uma pasta nossa (com a extensão certa no nome) é obrigatório.
|
|
337
|
+
let pasta = FileManager.default.temporaryDirectory
|
|
338
|
+
.appendingPathComponent("pushmesh-\(UUID().uuidString)", isDirectory: true)
|
|
339
|
+
do {
|
|
340
|
+
try FileManager.default.createDirectory(at: pasta, withIntermediateDirectories: true)
|
|
341
|
+
let destino = pasta.appendingPathComponent("imagem.\(formato.extensao)")
|
|
342
|
+
try FileManager.default.moveItem(at: temporario, to: destino)
|
|
343
|
+
// identifier vazio = o sistema gera um; o typeHint evita que o iOS
|
|
344
|
+
// tenha de adivinhar o tipo de novo.
|
|
345
|
+
let anexo = try UNNotificationAttachment(
|
|
346
|
+
identifier: "",
|
|
347
|
+
url: destino,
|
|
348
|
+
options: [UNNotificationAttachmentOptionsTypeHintKey: formato.uti])
|
|
349
|
+
conteudo?.attachments = [anexo]
|
|
350
|
+
return true
|
|
351
|
+
} catch {
|
|
352
|
+
// O anexo consome o arquivo quando dá certo; quando dá errado, a
|
|
353
|
+
// sujeira é nossa para limpar.
|
|
354
|
+
try? FileManager.default.removeItem(at: pasta)
|
|
355
|
+
NSLog("[PushMesh NSE] não deu para anexar a imagem: %@", error.localizedDescription)
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// Cabeça do arquivo para farejar o formato — lê só alguns bytes, nunca a
|
|
361
|
+
/// imagem inteira.
|
|
362
|
+
private func primeirosBytes(_ arquivo: URL, _ quantos: Int) -> [UInt8] {
|
|
363
|
+
guard let fh = try? FileHandle(forReadingFrom: arquivo) else { return [] }
|
|
364
|
+
defer { try? fh.close() }
|
|
365
|
+
let dados = (try? fh.read(upToCount: quantos)) ?? Data()
|
|
366
|
+
return [UInt8](dados)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/// Entrega o melhor conteúdo que existir — uma vez só, venha de onde vier.
|
|
370
|
+
private func entregarUmaVezSo() {
|
|
371
|
+
trava.lock()
|
|
372
|
+
if jaEntregue {
|
|
373
|
+
trava.unlock()
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
jaEntregue = true
|
|
377
|
+
let mao = entregar
|
|
378
|
+
let carga = conteudo ?? original
|
|
379
|
+
entregar = nil
|
|
380
|
+
trava.unlock()
|
|
381
|
+
|
|
382
|
+
if let mao, let carga { mao(carga) }
|
|
383
|
+
}
|
|
384
|
+
}
|