feat(mobile): scaffold Capacitor pra Android Play Store + adapter nativo

ESTRUTURA NOVA: mobile/ + scripts/sync-html.mjs

- mobile/package.json: Capacitor 6 + plugins (geolocation, local-notifications, network, preferences, status-bar)
- mobile/capacitor.config.json: appId br.com.pontualtech.shivao, allowNavigation pra OSM/Windy/CDNs
- mobile/.gitignore: protege keystore (NUNCA commitar chaves privadas)
- mobile/README.md: setup completo (JDK + Android Studio + keystore + build APK/AAB + Play Store submission + iOS futuro + troubleshooting)
- scripts/sync-html.mjs: copia app/diario-bordo.html → server/public + mobile/www (1 fonte da verdade)

ADAPTER NATIVO no HTML (sincronizado app/ + server/public/):
- isNative() / nativePlatform() detecta Capacitor
- nativeWatchPosition() usa Capacitor.Geolocation (background-capable) com fallback navigator.geolocation
- nativeNotify() usa Capacitor.LocalNotifications com fallback toast
- initServiceWorker() pula registro no Capacitor (WebView nativo já tem cache próprio)

NÃO INCLUI (ainda):
- Build local: precisa JDK 17 + Android Studio (~3GB) — instruções no README
- Keystore: gerar 1 vez via keytool (script no README)
- AAB pra Play Store: comandos no README
- Conta Google Play Developer: $25 1× pelo dono

Próximo passo manual: instalar JDK + Android Studio, rodar 'npm install && npx cap add android'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-27 16:02:34 -03:00
parent ca9de52ae1
commit 7a523b8873
7 changed files with 412 additions and 2 deletions

View file

@ -3141,8 +3141,51 @@ async function testWindyKey(){
} }
} }
// ===== Service Worker (offline real) ===== // ===== Capacitor adapter (detecta nativo + usa APIs nativas com fallback Web) =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
// Geolocation: Capacitor.Geolocation (background-capable) > navigator.geolocation
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
// Pede permission upfront
const perm=await Geolocation.requestPermissions({permissions:['location']}).catch(()=>({location:'denied'}));
if(perm.location==='denied'){onError({code:1,PERMISSION_DENIED:1,message:'Permissão negada'});return null}
const id=await Geolocation.watchPosition({enableHighAccuracy:!!opts?.enableHighAccuracy,timeout:opts?.timeout||15000,maximumAge:opts?.maximumAge||0},(pos,err)=>{
if(err){onError({code:err.code||3,message:err.message||'GPS erro',PERMISSION_DENIED:1,POSITION_UNAVAILABLE:2,TIMEOUT:3});return}
onUpdate({coords:{latitude:pos.coords.latitude,longitude:pos.coords.longitude,accuracy:pos.coords.accuracy,speed:pos.coords.speed},timestamp:pos.timestamp});
});
return{native:true,id};
}catch(e){console.warn('[native gps] fallback to web:',e.message)}
}
// Fallback web
const id=navigator.geolocation.watchPosition(onUpdate,onError,opts);
return{native:false,id};
}
async function nativeClearWatch(handle){
if(!handle)return;
if(handle.native&&isNative()){try{await window.Capacitor.Plugins.Geolocation.clearWatch({id:handle.id})}catch(e){}}
else if(handle.id!=null)navigator.geolocation.clearWatch(handle.id);
}
// Local notifications: nativo no Android, fallback toast no web
async function nativeNotify(title,body,id){
if(isNative()&&window.Capacitor.Plugins.LocalNotifications){
try{
const{LocalNotifications}=window.Capacitor.Plugins;
await LocalNotifications.requestPermissions().catch(()=>{});
await LocalNotifications.schedule({notifications:[{id:id||Date.now()%2147483647,title,body,sound:null,smallIcon:'ic_stat_icon_config_sample'}]});
return;
}catch(e){console.warn('[native notify]',e.message)}
}
toast(title+(body?': '+body:''));
}
// ===== Service Worker (offline real — só registra no Web, no Capacitor o WebView usa cache nativo) =====
function initServiceWorker(){ function initServiceWorker(){
if(isNative())return; // Capacitor não usa SW (tem cache próprio + APIs nativas)
if(!('serviceWorker' in navigator))return; if(!('serviceWorker' in navigator))return;
navigator.serviceWorker.register('/sw.js').then(reg=>{ navigator.serviceWorker.register('/sw.js').then(reg=>{
console.log('[SW] registered:',reg.scope); console.log('[SW] registered:',reg.scope);

19
mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Capacitor
node_modules/
www/
android/.gradle/
android/local.properties
android/app/release/
android/app/build/
android/build/
android/.idea/
android/captures/
android/*.iml
ios/App/build/
ios/App/Pods/
ios/App/output/
# Keystore (CRÍTICO — nunca commitar chaves privadas)
*.keystore
*.jks
keystore-passwords.txt

213
mobile/README.md Normal file
View file

@ -0,0 +1,213 @@
# Shivao Mobile · Capacitor Android (e iOS futuro)
Wrapper Capacitor pra empacotar o Shivao web como app nativo Android (.apk pra sideload, .aab pra Play Store) e iOS no futuro.
**Arquitetura:** o frontend HTML mora em `app/diario-bordo.html` (1 fonte de verdade). Um script (`scripts/sync-html.mjs`) copia pra `server/public/index.html` (Express serve) e `mobile/www/index.html` (Capacitor empacota).
---
## 🛠️ Pré-requisitos (instalar 1 vez)
### Windows (recomendado pra você)
1. **JDK 17+** — Adoptium Temurin é a melhor escolha:
```
winget install EclipseAdoptium.Temurin.17.JDK
```
Verificar: `java -version` deve mostrar `17.x`
2. **Android Studio** — instala SDK + emulador + tudo:
```
winget install Google.AndroidStudio
```
Após abrir, ir em **Tools → SDK Manager** e instalar:
- Android SDK Platform 34 (Android 14)
- Android SDK Build-Tools 34.0.0
- Android SDK Command-line Tools (latest)
- Android SDK Platform-Tools
3. **Variáveis de ambiente** (Painel de Controle → Sistema → Variáveis de Ambiente):
- `JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.x` (ajuste path)
- `ANDROID_HOME=C:\Users\pontu\AppData\Local\Android\Sdk`
- Adicionar ao PATH: `%JAVA_HOME%\bin`, `%ANDROID_HOME%\platform-tools`
4. Reiniciar terminal. Verificar:
```bash
java -version
adb --version
```
**Tempo total de instalação:** ~30-45 min (download de ~3GB).
---
## 🚀 Build inicial (faz 1 vez)
```bash
cd mobile
# Instala deps Capacitor + plugins
npm install
# Sincroniza HTML do app/ pra mobile/www/
node ../scripts/sync-html.mjs
# Adiciona projeto Android (gera mobile/android/)
npx cap add android
# Sincroniza plugins nativos no projeto Android
npx cap sync android
```
### Permissões Android (editar manualmente após `cap add android`)
Abra `mobile/android/app/src/main/AndroidManifest.xml` e adicione DENTRO do `<manifest>`:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
```
---
## 🔐 Keystore PERMANENTE (CRÍTICO — gera 1 vez, guarda pra sempre)
```bash
cd mobile/android/app
keytool -genkey -v -keystore shivao-release.keystore -alias shivao -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Karlao, O=PontualTech, L=Sao Paulo, ST=SP, C=BR"
# Senha forte (mínimo 8 chars). ANOTE em local seguro — perda = perda permanente do app na Play Store.
```
**BACKUP IMEDIATO:**
```bash
cp shivao-release.keystore C:/Users/pontu/Downloads/Shivao-keystore-backup/shivao-release.keystore
echo "Senha keystore: SUA_SENHA_AQUI" >> C:/Users/pontu/Downloads/Shivao-keystore-backup/keystore-passwords.txt
echo "Alias: shivao" >> C:/Users/pontu/Downloads/Shivao-keystore-backup/keystore-passwords.txt
```
E também faça upload em Drive privado, HD externo etc — perdeu = não publica update na Play Store nunca mais.
### Configurar signing no Gradle
Edite `mobile/android/app/build.gradle`, dentro de `android { ... }`:
```gradle
signingConfigs {
release {
storeFile file('shivao-release.keystore')
storePassword System.getenv('SHIVAO_KEYSTORE_PWD') ?: 'SUA_SENHA_AQUI'
keyAlias 'shivao'
keyPassword System.getenv('SHIVAO_KEY_PWD') ?: 'SUA_SENHA_AQUI'
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
}
}
```
---
## 📦 Build do APK (sideload) e AAB (Play Store)
```bash
cd mobile
# Sincroniza última versão do HTML antes de build
npm run sync
# APK assinado pra sideload (~3-5 min primeira vez, ~1 min em rebuilds)
npm run android:build:apk
# Output: mobile/android/app/build/outputs/apk/release/app-release.apk
# AAB pra Play Store (formato preferido)
npm run android:build:aab
# Output: mobile/android/app/build/outputs/bundle/release/app-release.aab
```
---
## 🌐 Alternativa: Build na nuvem (sem instalar nada local)
Se não quiser instalar 3GB de Android Studio:
### Opção A: Codemagic (mais simples)
- 500min grátis/mês, suficiente pra ~10 builds
- Conecta ao Forgejo, push detecta e roda
- Configura assinatura no painel
### Opção B: Container Docker no Coolify (mais self-hosted)
- Subir imagem `mingc/android-build-box` no Coolify
- Cron job ou webhook do Forgejo dispara build
- APK/AAB salvo em volume persistente
Ambas opções: vamos preparar quando você decidir.
---
## 🎮 Submeter Play Store
1. **Conta Google Play Developer:** $25 1× em https://play.google.com/console
2. Após aprovado (~24h), criar novo app:
- Nome: "Shivao - Diário de Bordo"
- Categoria: "Maps & Navigation"
- Idioma: pt-BR
3. **Subir AAB:** Production → Releases → Create new release → upload `app-release.aab`
4. **Conteúdo obrigatório:**
- Política de privacidade (URL pública — vou criar uma rota `/politica-privacidade` no servidor)
- Screenshots: mínimo 2 (vou gerar via Playwright se quiser)
- Ícone 512×512 (vou gerar do icon.svg)
- Descrição curta (80 chars) e completa
5. **Content rating:** preencher questionário (~5min)
6. **Pricing:** Free (cobrança vai por dentro do app via Asaas, não Google IAP — economia de 30%)
7. **Submit pra review:** Google revisa em 1-7 dias
---
## 📱 iOS (FASE 4 futura)
Requer:
- Mac (mini M4 ~R$5k mais barato)
- Apple Developer Program $99/ano
- Xcode
```bash
npx cap add ios
npx cap sync ios
npx cap open ios # abre Xcode
# Build Archive → Distribute → App Store Connect
```
---
## 🐛 Troubleshooting
**`adb: command not found`** — adicione `%ANDROID_HOME%\platform-tools` ao PATH
**`SDK location not found`** — crie `mobile/android/local.properties`:
```
sdk.dir=C:\\Users\\pontu\\AppData\\Local\\Android\\Sdk
```
**Build muito lento** — primeiro build baixa Gradle + dependências (~500MB). Próximos builds usam cache.
**APK muito grande** — habilitar minify em `build.gradle`:
```gradle
buildTypes.release.minifyEnabled true
buildTypes.release.shrinkResources true
```

View file

@ -0,0 +1,44 @@
{
"appId": "br.com.pontualtech.shivao",
"appName": "Shivao",
"webDir": "www",
"bundledWebRuntime": false,
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"backgroundColor": "#0e2a3dFF"
},
"ios": {
"contentInset": "automatic",
"scrollEnabled": true,
"backgroundColor": "#0e2a3dFF"
},
"server": {
"androidScheme": "https",
"iosScheme": "capacitor",
"cleartext": false,
"url": "https://shivao.pontualtech.work",
"allowNavigation": [
"shivao.pontualtech.work",
"tile.openstreetmap.org",
"*.tile.openstreetmap.org",
"api.windy.com",
"api.open-meteo.com",
"marine-api.open-meteo.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
"cdnjs.cloudflare.com"
]
},
"plugins": {
"Geolocation": {
"permissions": ["location"]
},
"LocalNotifications": {
"smallIcon": "ic_stat_icon_config_sample",
"iconColor": "#a07832",
"sound": "alarme.wav"
}
}
}

27
mobile/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "shivao-mobile",
"version": "1.2.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",
"scripts": {
"sync": "node ../scripts/sync-html.mjs && npx cap sync",
"android:open": "npx cap open android",
"android:build:apk": "cd android && ./gradlew assembleRelease",
"android:build:aab": "cd android && ./gradlew bundleRelease",
"ios:open": "npx cap open ios"
},
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.1.2",
"@capacitor/geolocation": "^6.0.1",
"@capacitor/local-notifications": "^6.1.0",
"@capacitor/network": "^6.0.2",
"@capacitor/preferences": "^6.0.2",
"@capacitor/status-bar": "^6.0.1"
},
"devDependencies": {
"@capacitor/cli": "^6.1.2"
}
}

25
scripts/sync-html.mjs Normal file
View file

@ -0,0 +1,25 @@
// Sync único do HTML do Shivão pra todos os destinos.
// Fonte de verdade: app/diario-bordo.html
// Destinos: server/public/index.html (Express) + mobile/www/index.html (Capacitor)
import { copyFile, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const src = resolve(root, 'app/diario-bordo.html');
const targets = [
resolve(root, 'server/public/index.html'),
resolve(root, 'mobile/www/index.html'),
];
async function sync() {
for (const t of targets) {
await mkdir(dirname(t), { recursive: true });
await copyFile(src, t);
console.log(`${src}${t}`);
}
}
sync().catch(e => { console.error(e); process.exit(1); });

View file

@ -3118,8 +3118,47 @@ async function testWindyKey(){
} }
} }
// ===== Service Worker (offline real) ===== // ===== Capacitor adapter =====
const isNative=()=>!!(window.Capacitor&&window.Capacitor.isNativePlatform&&window.Capacitor.isNativePlatform());
const nativePlatform=()=>(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'web';
async function nativeWatchPosition(onUpdate,onError,opts){
if(isNative()&&window.Capacitor.Plugins.Geolocation){
try{
const{Geolocation}=window.Capacitor.Plugins;
const perm=await Geolocation.requestPermissions({permissions:['location']}).catch(()=>({location:'denied'}));
if(perm.location==='denied'){onError({code:1,PERMISSION_DENIED:1,message:'Permissão negada'});return null}
const id=await Geolocation.watchPosition({enableHighAccuracy:!!opts?.enableHighAccuracy,timeout:opts?.timeout||15000,maximumAge:opts?.maximumAge||0},(pos,err)=>{
if(err){onError({code:err.code||3,message:err.message||'GPS erro',PERMISSION_DENIED:1,POSITION_UNAVAILABLE:2,TIMEOUT:3});return}
onUpdate({coords:{latitude:pos.coords.latitude,longitude:pos.coords.longitude,accuracy:pos.coords.accuracy,speed:pos.coords.speed},timestamp:pos.timestamp});
});
return{native:true,id};
}catch(e){console.warn('[native gps] fallback to web:',e.message)}
}
const id=navigator.geolocation.watchPosition(onUpdate,onError,opts);
return{native:false,id};
}
async function nativeClearWatch(handle){
if(!handle)return;
if(handle.native&&isNative()){try{await window.Capacitor.Plugins.Geolocation.clearWatch({id:handle.id})}catch(e){}}
else if(handle.id!=null)navigator.geolocation.clearWatch(handle.id);
}
async function nativeNotify(title,body,id){
if(isNative()&&window.Capacitor.Plugins.LocalNotifications){
try{
const{LocalNotifications}=window.Capacitor.Plugins;
await LocalNotifications.requestPermissions().catch(()=>{});
await LocalNotifications.schedule({notifications:[{id:id||Date.now()%2147483647,title,body,sound:null,smallIcon:'ic_stat_icon_config_sample'}]});
return;
}catch(e){console.warn('[native notify]',e.message)}
}
toast(title+(body?': '+body:''));
}
// ===== Service Worker (apenas Web — Capacitor usa cache nativo) =====
function initServiceWorker(){ function initServiceWorker(){
if(isNative())return;
if(!('serviceWorker' in navigator))return; if(!('serviceWorker' in navigator))return;
navigator.serviceWorker.register('/sw.js').then(reg=>{ navigator.serviceWorker.register('/sw.js').then(reg=>{
console.log('[SW] registered:',reg.scope); console.log('[SW] registered:',reg.scope);