html2apk 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -186,6 +186,7 @@ html2apk build --entry-file index.html
186
186
  html2apk build --web-root .
187
187
  html2apk build --app-name MeuApp
188
188
  html2apk build --package-id com.seuapp.meuapp
189
+ html2apk build --theme auto
189
190
  html2apk build --android-platform android@15.0.0
190
191
  ```
191
192
 
@@ -209,9 +210,21 @@ Exemplo completo:
209
210
  "orientation": "default",
210
211
  "minSdkVersion": 24,
211
212
  "themeColor": "#126fff",
213
+ "themeMode": "fixed",
214
+ "oneSignalAppId": "",
212
215
  "icon": "",
213
216
  "splash": "",
214
- "permissions": ["INTERNET", "CAMERA", "POST_NOTIFICATIONS", "VIBRATE"],
217
+ "deepLinks": {
218
+ "schemes": ["meuapp"],
219
+ "appLinks": [
220
+ {
221
+ "host": "meusite.com",
222
+ "paths": ["/produto/*", "/pedido/*"],
223
+ "autoVerify": false
224
+ }
225
+ ]
226
+ },
227
+ "permissions": ["INTERNET", "CAMERA", "RECORD_AUDIO", "POST_NOTIFICATIONS", "VIBRATE"],
215
228
  "plugins": ["cordova-plugin-camera"],
216
229
  "release": false,
217
230
  "androidPlatform": "android@15.0.0",
@@ -238,7 +251,10 @@ Campos principais:
238
251
  | `mode` | `fullscreen` para tela cheia, `standalone` para modo normal ou `floating` para icone flutuante. |
239
252
  | `orientation` | `default`, `vertical`, `horizontal`, `portrait` ou `landscape`. |
240
253
  | `minSdkVersion` | Versao minima do Android em API level. Padrao: `24`. |
241
- | `themeColor` | Cor base do tema/splash Android, em hexadecimal. |
254
+ | `themeColor` | Cor base do tema/splash Android, em hexadecimal. Tambem vira fallback do modo automatico. |
255
+ | `themeMode` | `fixed` usa a cor fixa. `auto` adapta as barras Android a cor visivel na tela do APK. Tambem aceita `theme: "auto"`. |
256
+ | `oneSignalAppId` | Opcional. App ID publico do OneSignal para push remoto. Nao coloque REST API Key no APK. |
257
+ | `deepLinks` | URLs que podem abrir o APK, como `meuapp://rota` ou `https://meusite.com/produto/1`. |
242
258
  | `entryFile` | Arquivo HTML inicial. Normalmente `index.html`. |
243
259
  | `webRoot` | Pasta onde estao os arquivos web. Normalmente `"."`. |
244
260
  | `permissions` | Permissoes Android adicionadas ao app. |
@@ -254,6 +270,33 @@ Prioridade de configuracao:
254
270
  2. `app.json` ou `config.json`.
255
271
  3. Valores padrao do html2apk.
256
272
 
273
+ ### Tema Automatico Do APK
274
+
275
+ Use `themeMode: "auto"` ou `theme: "auto"` para o APK adaptar as barras nativas do Android a cor que esta visivel na tela. O html2apk observa a tela do WebView e ajusta status bar/navigation bar em tempo real.
276
+
277
+ ```json
278
+ {
279
+ "themeMode": "auto",
280
+ "themeColor": "#126fff"
281
+ }
282
+ ```
283
+
284
+ `themeColor` continua importante: ela e usada no splash e como fallback quando o app ainda nao conseguiu detectar uma cor visivel.
285
+
286
+ ### OneSignal Opcional
287
+
288
+ Se quiser receber notificacoes remotas pelo OneSignal, preencha `oneSignalAppId` no `app.json` ou no campo OneSignal App ID da interface grafica:
289
+
290
+ ```json
291
+ {
292
+ "oneSignalAppId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
293
+ }
294
+ ```
295
+
296
+ Quando esse campo esta vazio, nada do OneSignal e instalado no APK. Quando ele esta preenchido, o html2apk instala `onesignal-cordova-plugin`, injeta a inicializacao no HTML e mantem as notificacoes locais existentes (`notificar`, `agendarNotificacao`, loops etc.).
297
+
298
+ Importante: o App ID do OneSignal pode ficar no APK. A REST API Key nao deve ir no app, porque e segredo de servidor. Envie pushes pelo painel do OneSignal ou por um backend seu.
299
+
257
300
  ## Exemplo Minimo
258
301
 
259
302
  Este repositorio ja inclui um exemplo em:
@@ -310,7 +353,59 @@ Retorno:
310
353
 
311
354
  ## Bridge Nativa
312
355
 
313
- A v0.1 instala um plugin Cordova local com uma API global simples para recursos Android.
356
+ A v0.1 instala um plugin Cordova local com uma API global simples para recursos Android. Todas as funcoes retornam `Promise`, exceto os ouvintes `aoEvento`/atalhos, que retornam uma funcao para cancelar a escuta.
357
+
358
+ Cada funcao em portugues tambem tem alias em ingles. A interface grafica mostra a sintaxe PT-BR quando o idioma esta em portugues e mostra a sintaxe em ingles quando o usuario troca o idioma para English.
359
+
360
+ Exemplos de aliases:
361
+
362
+ | PT-BR | English |
363
+ | --- | --- |
364
+ | `notificar()` | `notify()` |
365
+ | `agendarNotificacao()` | `scheduleNotification()` |
366
+ | `agendarNotificacoes()` | `scheduleNotifications()` |
367
+ | `agendarLoopNotificacoes()` | `scheduleNotificationLoop()` |
368
+ | `cancelarNotificacao()` | `cancelNotification()` |
369
+ | `solicitarPermissaoNotificacoes()` | `requestNotificationPermission()` |
370
+ | `solicitarPermissaoPush()` | `requestPushPermission()` |
371
+ | `aoClicarPush()` | `onPushClick()` |
372
+ | `identificarUsuarioPush()` | `loginPushUser()` |
373
+ | `adicionarTagPush()` | `addPushTag()` |
374
+ | `solicitarPermissaoMicrofone()` | `requestMicrophonePermission()` |
375
+ | `statusMicrofone()` | `microphoneStatus()` |
376
+ | `ouvirMic()` | `listenMic()` / `startMic()` |
377
+ | `pararMic()` | `stopMic()` |
378
+ | `lanterna()` | `flashlight()` |
379
+ | `alternarLanterna()` | `toggleFlashlight()` |
380
+ | `escolherArquivo()` | `pickFile()` |
381
+ | `escolherImagem()` | `pickImage()` |
382
+ | `salvarArquivo()` | `saveFile()` |
383
+ | `compartilhar()` | `share()` |
384
+ | `copiarTexto()` | `copyText()` |
385
+ | `lerTextoCopiado()` | `readText()` |
386
+ | `abrirNoApp()` | `openInApp()` |
387
+ | `abrirForaDoApp()` | `openOutsideApp()` |
388
+ | `abrirUrlExterno()` | `openExternalUrl()` |
389
+ | `manterTelaLigada()` | `keepScreenOn()` |
390
+ | `brilhoTela()` | `setScreenBrightness()` |
391
+ | `definirCorTema()` | `setThemeColor()` |
392
+ | `infoMemoria()` | `memoryInfo()` |
393
+ | `infoArmazenamento()` | `storageInfo()` |
394
+ | `infoDesempenho()` | `performanceInfo()` |
395
+ | `appsAbertos()` | `openAppsMemory()` |
396
+ | `aoEvento()` | `onEvent()` |
397
+ | `aoMinimizar()` | `onMinimize()` |
398
+ | `obterLinkInicial()` | `getInitialLink()` |
399
+
400
+ Os eventos tambem aceitam aliases em ingles em `onEvent()`: `app:ready`, `app:background`, `app:resumed`, `back:button`, `link:opened`, `network:changed`, `battery:changed`, `notification:received` e `notification:clicked`.
401
+
402
+ Como tratar retornos:
403
+
404
+ - Use `await` dentro de `try/catch` quando a acao depender de Android, permissao ou outro app instalado.
405
+ - Seletores de arquivo podem retornar `null`, array vazio ou objeto vazio quando o usuario cancela.
406
+ - APIs de permissao retornam objetos com `granted`; se vier `false`, desative o recurso ou mostre uma etapa de permissao.
407
+ - Eventos retornam uma funcao de cancelamento; guarde essa funcao e chame quando a tela/componente sair.
408
+ - Medidas de memoria, armazenamento e apps abertos sao diagnosticas. Android moderno pode limitar dados de outros apps por privacidade.
314
409
 
315
410
  No seu JavaScript do app:
316
411
 
@@ -323,6 +418,10 @@ vibrar(250);
323
418
  notificar({
324
419
  titulo: "Pedido aprovado",
325
420
  texto: "Toque para abrir os detalhes",
421
+ acoes: [
422
+ { id: "abrir", titulo: "Abrir" },
423
+ { id: "cancelar", titulo: "Cancelar" }
424
+ ],
326
425
  aoClicar: {
327
426
  acao: "abrir-rota",
328
427
  rota: "/pedido/123",
@@ -340,9 +439,216 @@ agendarNotificacao({
340
439
  }
341
440
  });
342
441
 
442
+ await agendarNotificacoes([
443
+ { titulo: "Primeiro aviso", texto: "Daqui 1 minuto", quando: Date.now() + 60000 },
444
+ { titulo: "Segundo aviso", texto: "Daqui 2 minutos", quando: Date.now() + 120000 }
445
+ ]);
446
+
447
+ const loop = await agendarLoopNotificacoes({
448
+ aCada: "12h",
449
+ notificacoes: [
450
+ {
451
+ titulo: "Hidrate-se",
452
+ texto: "Beba agua agora",
453
+ aoClicar: { acao: "abrir-hidratacao" }
454
+ },
455
+ {
456
+ titulo: "Alongamento",
457
+ texto: "Pausa rapida para alongar",
458
+ aoClicar: { acao: "abrir-alongamento" }
459
+ }
460
+ ]
461
+ });
462
+
343
463
  fullscreen(true);
344
464
  ```
345
465
 
466
+ `agendarNotificacao()` agenda uma notificacao. Se voce passar um array para ela, ou usar `agendarNotificacoes()`, o html2apk agenda varias em sequencia. Cada item recebe `id` automatico se voce nao informar um.
467
+
468
+ `agendarLoopNotificacoes()` cria um loop recorrente que funciona com o app fechado. Use `aCada`, `intervalo`, `every` ou `interval` em milissegundos ou como texto (`"30min"`, `"12h"`, `"1d"`). A cada disparo, o Android mostra a proxima notificacao da lista. Para parar:
469
+
470
+ ```js
471
+ await cancelarNotificacao(loop.id);
472
+ ```
473
+
474
+ Cliques continuam chegando normalmente:
475
+
476
+ ```js
477
+ aoClicarNotificacao((evento) => {
478
+ if ((evento.aoClicar || evento.onClick).acao === "abrir-hidratacao") {
479
+ abrirNoApp("#/hidratacao");
480
+ }
481
+ });
482
+ ```
483
+
484
+ As notificacoes usam o icone do APK gerado como icone nativo. No Android, esse icone pode aparecer monocromatico na barra de notificacoes por regra do sistema.
485
+
486
+ OneSignal, quando `oneSignalAppId` esta preenchido:
487
+
488
+ ```js
489
+ const permitido = await solicitarPermissaoPush();
490
+
491
+ identificarUsuarioPush("user-123");
492
+ adicionarTagPush("plano", "premium");
493
+
494
+ const pararPush = aoClicarPush((evento) => {
495
+ console.log("Push clicado", evento);
496
+ abrirNoApp("#/notificacoes");
497
+ });
498
+ ```
499
+
500
+ Use OneSignal para pushes enviados remotamente pelo painel/API do OneSignal. Use `notificar()` e `agendarNotificacao()` para notificacoes locais criadas dentro do APK.
501
+
502
+ Arquivos, galeria e compartilhamento:
503
+
504
+ ```js
505
+ const imagem = await escolherImagem();
506
+ const imagens = await escolherImagens({ multiplo: true });
507
+ const pdf = await escolherArquivo({ tipos: ["application/pdf"] });
508
+ const arquivos = await escolherArquivos({ multiplo: true });
509
+
510
+ await salvarArquivo({
511
+ nome: "relatorio.txt",
512
+ mimeType: "text/plain",
513
+ conteudo: "Conteudo salvo pelo app"
514
+ });
515
+
516
+ await compartilhar({ texto: "Veja isso", url: "https://exemplo.com" });
517
+ ```
518
+
519
+ O retorno de arquivos tem este formato:
520
+
521
+ ```json
522
+ {
523
+ "uri": "content://...",
524
+ "name": "foto.png",
525
+ "nome": "foto.png",
526
+ "size": 12345,
527
+ "tamanho": 12345,
528
+ "mimeType": "image/png"
529
+ }
530
+ ```
531
+
532
+ Microfone:
533
+
534
+ ```js
535
+ await solicitarPermissaoMicrofone();
536
+
537
+ await ouvirMic();
538
+
539
+ // ... depois, quando quiser parar
540
+ const audio = await pararMic();
541
+ const audioUrl = `data:${audio.mimeType};base64,${audio.base64}`;
542
+
543
+ const player = new Audio(audioUrl);
544
+ player.play();
545
+ ```
546
+
547
+ `ouvirMic()` comeca a gravar e tambem pede permissao se ela ainda nao foi concedida. Para uma experiencia mais clara, prefira chamar `solicitarPermissaoMicrofone()` antes e explicar ao usuario por que o app precisa do microfone.
548
+
549
+ `pararMic()` encerra a gravacao e retorna:
550
+
551
+ ```json
552
+ {
553
+ "base64": "AAAA...",
554
+ "mimeType": "audio/mp4",
555
+ "extension": "m4a",
556
+ "size": 12345,
557
+ "durationMs": 3200
558
+ }
559
+ ```
560
+
561
+ Para tratar o retorno, use `mimeType` e `base64` juntos em um Data URL quando quiser tocar, baixar ou enviar o audio. Se `pararMic()` for chamado rapido demais, o Android pode nao conseguir finalizar o arquivo; aguarde alguns instantes apos `ouvirMic()`.
562
+
563
+ Lanterna, tela, clipboard e intents:
564
+
565
+ ```js
566
+ await solicitarPermissaoCamera();
567
+ const lanternaStatus = await lanterna(true);
568
+ await alternarLanterna();
569
+
570
+ await manterTelaLigada(true);
571
+ await brilhoTela(0.8);
572
+
573
+ await copiarTexto("codigo123");
574
+ const texto = await lerTextoCopiado();
575
+
576
+ await abrirNoApp("/sobre.html");
577
+ await abrirNoApp("#/pedido/123", { substituir: true });
578
+ await abrirForaDoApp("https://exemplo.com");
579
+ await abrirWhatsapp("559999999999", "Oi");
580
+ await discar("11999999999");
581
+ await abrirMapa("Avenida Paulista, Sao Paulo");
582
+ ```
583
+
584
+ Use `abrirNoApp()`/`openInApp()` quando a navegacao deve acontecer dentro do proprio APK/WebView. Use `abrirForaDoApp()`/`openOutsideApp()` ou `abrirUrlExterno()`/`openExternalUrl()` quando quer mandar o usuario para navegador, WhatsApp, Maps ou outro app Android.
585
+
586
+ Informacoes e desempenho:
587
+
588
+ ```js
589
+ const aparelho = await infoDispositivo();
590
+ const rede = await infoRede();
591
+ const bateria = await infoBateria();
592
+ const memoria = await infoMemoria();
593
+ const armazenamento = await infoArmazenamento();
594
+ const desempenho = await infoDesempenho();
595
+ const abertos = await appsAbertos();
596
+ ```
597
+
598
+ `infoDesempenho()` agrupa memoria, armazenamento, bateria, rede e `timestamp`. Valores de memoria e armazenamento retornam bytes.
599
+
600
+ `appsAbertos()` retorna os processos/apps que o Android permite o APK enxergar:
601
+
602
+ ```json
603
+ {
604
+ "apps": [
605
+ {
606
+ "name": "MeuApp",
607
+ "packageName": "com.seuapp.meuapp",
608
+ "ramBytes": 12345678,
609
+ "ramMb": 11.77,
610
+ "importanceName": "foreground"
611
+ }
612
+ ],
613
+ "porNome": {
614
+ "MeuApp": {
615
+ "ramBytes": 12345678,
616
+ "ramMb": 11.77
617
+ }
618
+ },
619
+ "limited": true
620
+ }
621
+ ```
622
+
623
+ Por privacidade, Android moderno pode limitar essa lista ao proprio app e alguns processos visiveis ao APK. Entao essa funcao nao deve ser tratada como gerenciador completo de tarefas do sistema.
624
+
625
+ Eventos nativos:
626
+
627
+ ```js
628
+ const parar = aoEvento("app:background", (evento) => {
629
+ console.log("App saiu da frente", evento.timestamp);
630
+ });
631
+
632
+ aoEvento("app:voltou", console.log);
633
+ aoEvento("botao:voltar", console.log);
634
+ aoEvento("link:aberto", (evento) => console.log(evento.url));
635
+ aoEvento("rede:mudou", console.log);
636
+ aoEvento("bateria:mudou", console.log);
637
+ aoEvento("notificacao:clicada", console.log);
638
+
639
+ parar();
640
+ ```
641
+
642
+ Deep links:
643
+
644
+ ```js
645
+ const linkInicial = await obterLinkInicial();
646
+
647
+ aoAbrirLink((evento) => {
648
+ console.log(evento.url, evento.path, evento.query);
649
+ });
650
+ ```
651
+
346
652
  Clique em notificacao:
347
653
 
348
654
  ```js
@@ -7,6 +7,8 @@
7
7
  "orientation": "default",
8
8
  "minSdkVersion": 24,
9
9
  "themeColor": "#126fff",
10
+ "themeMode": "fixed",
11
+ "oneSignalAppId": "",
10
12
  "icon": "",
11
13
  "splash": "",
12
14
  "permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html2apk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Node CLI and library to turn an HTML/CSS/JS folder into an Android APK through Cordova.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -71,6 +71,7 @@
71
71
  "node": ">=18"
72
72
  },
73
73
  "dependencies": {
74
- "cordova": "^13.0.0"
74
+ "cordova": "^13.0.0",
75
+ "onesignal-cordova-plugin": "^5.3.10"
75
76
  }
76
77
  }
package/src/cli/index.js CHANGED
@@ -10,7 +10,7 @@ function printHelp() {
10
10
 
11
11
  Usage:
12
12
  html2apk init
13
- html2apk build [--release] [--debug] [--mode fullscreen|standalone|floating] [--orientation vertical|horizontal] [--min-sdk 24] [--android-platform android@15.0.0]
13
+ html2apk build [--release] [--debug] [--mode fullscreen|standalone|floating] [--theme fixed|auto] [--orientation vertical|horizontal] [--min-sdk 24] [--android-platform android@15.0.0]
14
14
  html2apk doctor
15
15
 
16
16
  The current working directory is always treated as the user app root.`);
@@ -34,6 +34,9 @@ function parseBuildArgs(args) {
34
34
  } else if (arg === "--theme-color") {
35
35
  options.themeColor = args[index + 1];
36
36
  index += 1;
37
+ } else if (arg === "--theme" || arg === "--theme-mode") {
38
+ options.themeMode = args[index + 1];
39
+ index += 1;
37
40
  } else if (arg === "--min-sdk" || arg === "--min-sdk-version" || arg === "--minSdkVersion") {
38
41
  options.minSdkVersion = args[index + 1];
39
42
  index += 1;
@@ -76,6 +79,12 @@ function createPlaceholderConfig(projectName = "MeuApp") {
76
79
  orientation: "default",
77
80
  minSdkVersion: 24,
78
81
  themeColor: "#126fff",
82
+ themeMode: "fixed",
83
+ oneSignalAppId: "",
84
+ deepLinks: {
85
+ schemes: [],
86
+ appLinks: []
87
+ },
79
88
  icon: "",
80
89
  splash: "",
81
90
  permissions: [
@@ -77,6 +77,59 @@ function renderAndroidSplashPreferences(options) {
77
77
  ].filter(Boolean).join("\n");
78
78
  }
79
79
 
80
+ function renderDeepLinkData(pathItem) {
81
+ if (!pathItem || pathItem === "*" || pathItem === "/*") {
82
+ return "";
83
+ }
84
+
85
+ if (pathItem.includes("*")) {
86
+ return ` android:pathPattern="${xmlEscape(pathItem.replace(/\*/g, ".*"))}"`;
87
+ }
88
+
89
+ return ` android:pathPrefix="${xmlEscape(pathItem)}"`;
90
+ }
91
+
92
+ function renderDeepLinkIntentFilters(deepLinks = {}) {
93
+ const filters = [];
94
+ const schemes = Array.isArray(deepLinks.schemes) ? deepLinks.schemes : [];
95
+ const appLinks = Array.isArray(deepLinks.appLinks) ? deepLinks.appLinks : [];
96
+
97
+ for (const scheme of schemes) {
98
+ filters.push(` <config-file target="AndroidManifest.xml" parent="/manifest/application/activity">
99
+ <intent-filter>
100
+ <action android:name="android.intent.action.VIEW" />
101
+ <category android:name="android.intent.category.DEFAULT" />
102
+ <category android:name="android.intent.category.BROWSABLE" />
103
+ <data android:scheme="${xmlEscape(scheme)}" />
104
+ </intent-filter>
105
+ </config-file>`);
106
+ }
107
+
108
+ for (const link of appLinks) {
109
+ if (!link || !link.host) {
110
+ continue;
111
+ }
112
+
113
+ const scheme = link.scheme || "https";
114
+ const paths = Array.isArray(link.paths) && link.paths.length ? link.paths : [""];
115
+ const dataItems = paths
116
+ .map((pathItem) => ` <data android:scheme="${xmlEscape(scheme)}" android:host="${xmlEscape(link.host)}"${renderDeepLinkData(pathItem)} />`)
117
+ .join("\n");
118
+ const verify = link.autoVerify ? " android:autoVerify=\"true\"" : "";
119
+
120
+ filters.push(` <config-file target="AndroidManifest.xml" parent="/manifest/application/activity">
121
+ <intent-filter${verify}>
122
+ <action android:name="android.intent.action.VIEW" />
123
+ <category android:name="android.intent.category.DEFAULT" />
124
+ <category android:name="android.intent.category.BROWSABLE" />
125
+ ${dataItems}
126
+ </intent-filter>
127
+ </config-file>`);
128
+ }
129
+
130
+ return filters.join("\n");
131
+ }
132
+
80
133
  function renderConfigXml(options) {
81
134
  const fullscreen = options.mode === "fullscreen" ? "true" : "false";
82
135
  const permissionsList = options.mode === "floating"
@@ -85,8 +138,11 @@ function renderConfigXml(options) {
85
138
  const permissions = renderPermissions(permissionsList);
86
139
  const icon = renderIcon(options.icon);
87
140
  const splashPreferences = renderAndroidSplashPreferences(options);
88
- const platformItems = [permissions, icon, splashPreferences].filter(Boolean).join("\n");
141
+ const deepLinkIntentFilters = renderDeepLinkIntentFilters(options.deepLinks);
142
+ const platformItems = [permissions, deepLinkIntentFilters, icon, splashPreferences].filter(Boolean).join("\n");
89
143
  const backgroundPreference = renderPreference("BackgroundColor", options.themeColor || options.backgroundColor);
144
+ const themeModePreference = renderPreference("Html2ApkThemeMode", options.themeMode || options.theme || "fixed");
145
+ const oneSignalPreference = renderPreference("Html2ApkOneSignalAppId", options.oneSignalAppId);
90
146
  const modePreference = renderPreference("Html2ApkMode", options.mode || "standalone");
91
147
  const minSdkPreference = renderPreference(
92
148
  "android-minSdkVersion",
@@ -124,6 +180,8 @@ function renderConfigXml(options) {
124
180
  <preference name="GradlePluginKotlinEnabled" value="true" />
125
181
  ${minSdkPreference}
126
182
  ${modePreference}
183
+ ${themeModePreference}
184
+ ${oneSignalPreference}
127
185
  ${orientationPreference ? `${orientationPreference}\n` : ""}${backgroundPreference ? `${backgroundPreference}\n` : ""}
128
186
 
129
187
  <platform name="android">
@@ -8,11 +8,15 @@ const { validateEntryFile, validateRequiredOptions } = require("./validation");
8
8
  const { createCordovaProject, addAndroidPlatform, buildAndroid, addCordovaPlugin } = require("../cordova/project");
9
9
  const { writeConfigXml } = require("../cordova/config-xml");
10
10
  const { findApk } = require("../cordova/apk-finder");
11
- const { copyWebAssets, ensureDir, removePath, copyFile } = require("../utils/fs-extra");
11
+ const { copyWebAssets, ensureDir, removePath, copyFile, copyDirectory } = require("../utils/fs-extra");
12
12
  const { createCommandRunner } = require("../utils/command-runner");
13
13
  const { installBridgePlugin } = require("../bridge/install-bridge");
14
14
  const { getRuntimeEnvironment } = require("../runtime-manager");
15
15
 
16
+ const AUTO_THEME_SCRIPT_NAME = "html2apk-auto-theme.js";
17
+ const ONESIGNAL_SCRIPT_NAME = "html2apk-onesignal.js";
18
+ const ONESIGNAL_PLUGIN_PACKAGE = "onesignal-cordova-plugin";
19
+
16
20
  function isRemoteAsset(assetPath) {
17
21
  return /^https?:\/\//i.test(String(assetPath || ""));
18
22
  }
@@ -34,6 +38,141 @@ function toCordovaPath(value) {
34
38
  return String(value).replace(/\\/g, "/");
35
39
  }
36
40
 
41
+ function isAutoTheme(options) {
42
+ return String(options.themeMode || options.theme || "").toLowerCase() === "auto";
43
+ }
44
+
45
+ function oneSignalAppId(options) {
46
+ return String(options.oneSignalAppId || options.onesignalAppId || options.oneSignal?.appId || options.onesignal?.appId || "").trim();
47
+ }
48
+
49
+ function hasOneSignal(options) {
50
+ return oneSignalAppId(options).length > 0;
51
+ }
52
+
53
+ function scriptTag(scriptPath) {
54
+ return `<script src="${scriptPath}"></script>`;
55
+ }
56
+
57
+ async function injectScriptIntoHtml(htmlPath, scriptPath) {
58
+ let html = await fs.readFile(htmlPath, "utf8");
59
+ if (html.includes(scriptPath)) {
60
+ return;
61
+ }
62
+
63
+ const tag = scriptTag(scriptPath);
64
+ if (/<\/body>/i.test(html)) {
65
+ html = html.replace(/<\/body>/i, ` ${tag}\n</body>`);
66
+ } else {
67
+ html = `${html}\n${tag}\n`;
68
+ }
69
+
70
+ await fs.writeFile(htmlPath, html, "utf8");
71
+ }
72
+
73
+ async function installAutoThemeScript(buildDir, options) {
74
+ if (!isAutoTheme(options)) {
75
+ return false;
76
+ }
77
+
78
+ const wwwDir = path.join(buildDir, "www");
79
+ const source = path.resolve(__dirname, "..", "templates", AUTO_THEME_SCRIPT_NAME);
80
+ const target = path.join(wwwDir, AUTO_THEME_SCRIPT_NAME);
81
+ const entryHtmlPath = path.resolve(wwwDir, options.entryFile || "index.html");
82
+ const scriptPath = toCordovaPath(path.relative(path.dirname(entryHtmlPath), target));
83
+
84
+ if (!isInside(wwwDir, entryHtmlPath)) {
85
+ throw new Error(`Entry file must stay inside the Cordova www folder: ${options.entryFile}`);
86
+ }
87
+
88
+ await copyFile(source, target);
89
+ await injectScriptIntoHtml(entryHtmlPath, scriptPath || AUTO_THEME_SCRIPT_NAME);
90
+ return true;
91
+ }
92
+
93
+ function jsString(value) {
94
+ return JSON.stringify(String(value || ""));
95
+ }
96
+
97
+ async function installOneSignalScript(buildDir, options) {
98
+ const appId = oneSignalAppId(options);
99
+ if (!appId) {
100
+ return false;
101
+ }
102
+
103
+ const wwwDir = path.join(buildDir, "www");
104
+ const source = path.resolve(__dirname, "..", "templates", ONESIGNAL_SCRIPT_NAME);
105
+ const target = path.join(wwwDir, ONESIGNAL_SCRIPT_NAME);
106
+ const entryHtmlPath = path.resolve(wwwDir, options.entryFile || "index.html");
107
+ const scriptPath = toCordovaPath(path.relative(path.dirname(entryHtmlPath), target));
108
+ const template = await fs.readFile(source, "utf8");
109
+
110
+ if (!isInside(wwwDir, entryHtmlPath)) {
111
+ throw new Error(`Entry file must stay inside the Cordova www folder: ${options.entryFile}`);
112
+ }
113
+
114
+ await fs.writeFile(
115
+ target,
116
+ template.replace("__HTML2APK_ONESIGNAL_APP_ID__", jsString(appId)),
117
+ "utf8"
118
+ );
119
+ await injectScriptIntoHtml(entryHtmlPath, scriptPath || ONESIGNAL_SCRIPT_NAME);
120
+ return true;
121
+ }
122
+
123
+ function removePluginPlatform(pluginXml, platformName) {
124
+ const pattern = new RegExp(`\\n?\\s*<platform name="${platformName}">[\\s\\S]*?\\n\\s*</platform>`, "i");
125
+ return pluginXml.replace(pattern, "");
126
+ }
127
+
128
+ async function prepareBundledPlugin(buildDir, packageName) {
129
+ const localPath = path.resolve(__dirname, "..", "..", "node_modules", packageName);
130
+ try {
131
+ await fs.access(path.join(localPath, "plugin.xml"));
132
+ } catch {
133
+ return packageName;
134
+ }
135
+
136
+ const destination = path.join(buildDir, `${packageName}-android`);
137
+ await removePath(destination);
138
+ await copyDirectory(localPath, destination, () => false);
139
+
140
+ const packageJsonPath = path.join(destination, "package.json");
141
+ const sourcePackage = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
142
+ const pluginPackage = {
143
+ name: sourcePackage.name,
144
+ version: sourcePackage.version,
145
+ description: sourcePackage.description,
146
+ license: sourcePackage.license,
147
+ main: sourcePackage.main || "dist/index.cjs",
148
+ types: sourcePackage.types,
149
+ cordova: {
150
+ id: sourcePackage.cordova?.id || packageName,
151
+ platforms: ["android"]
152
+ }
153
+ };
154
+ Object.keys(pluginPackage).forEach((key) => {
155
+ if (pluginPackage[key] === undefined) {
156
+ delete pluginPackage[key];
157
+ }
158
+ });
159
+ await fs.writeFile(packageJsonPath, `${JSON.stringify(pluginPackage, null, 2)}\n`, "utf8");
160
+
161
+ const pluginXmlPath = path.join(destination, "plugin.xml");
162
+ let pluginXml = await fs.readFile(pluginXmlPath, "utf8");
163
+ pluginXml = removePluginPlatform(pluginXml, "ios");
164
+ await fs.writeFile(pluginXmlPath, pluginXml, "utf8");
165
+
166
+ return destination;
167
+ }
168
+
169
+ function hasPlugin(plugins, packageName) {
170
+ return (plugins || []).some((plugin) => {
171
+ const text = String(plugin || "").replace(/\\/g, "/").toLowerCase();
172
+ return text === packageName || text.endsWith(`/node_modules/${packageName}`) || text.endsWith(`/${packageName}`);
173
+ });
174
+ }
175
+
37
176
  async function copyCordovaAsset(projectRoot, buildDir, assetPath, assetName) {
38
177
  if (!assetPath || isRemoteAsset(assetPath)) {
39
178
  return assetPath;
@@ -136,10 +275,20 @@ async function buildApk(overrides = {}) {
136
275
  cordovaOptions.androidSplashScreenAnimatedIcon = toBuildAssetPath(buildDir, cordovaOptions.splash);
137
276
  await writeConfigXml(path.join(buildDir, "config.xml"), cordovaOptions);
138
277
  await copyWebAssets(webRoot, path.join(buildDir, "www"), options, projectRoot);
278
+ if (await installAutoThemeScript(buildDir, options)) {
279
+ log("Theme mode: auto (system bars follow the visible screen color).");
280
+ }
281
+ if (await installOneSignalScript(buildDir, options)) {
282
+ log("OneSignal: enabled for remote push notifications.");
283
+ }
139
284
 
140
285
  const bridgePluginPath = await installBridgePlugin(buildDir);
141
286
  await addCordovaPlugin(buildDir, bridgePluginPath, runner);
142
287
 
288
+ if (hasOneSignal(options) && !hasPlugin(options.plugins, ONESIGNAL_PLUGIN_PACKAGE)) {
289
+ await addCordovaPlugin(buildDir, await prepareBundledPlugin(buildDir, ONESIGNAL_PLUGIN_PACKAGE), runner);
290
+ }
291
+
143
292
  for (const plugin of options.plugins) {
144
293
  await addCordovaPlugin(buildDir, plugin, runner);
145
294
  }