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:
parent
ca9de52ae1
commit
7a523b8873
7 changed files with 412 additions and 2 deletions
|
|
@ -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(){
|
||||
if(isNative())return; // Capacitor não usa SW (tem cache próprio + APIs nativas)
|
||||
if(!('serviceWorker' in navigator))return;
|
||||
navigator.serviceWorker.register('/sw.js').then(reg=>{
|
||||
console.log('[SW] registered:',reg.scope);
|
||||
|
|
|
|||
19
mobile/.gitignore
vendored
Normal file
19
mobile/.gitignore
vendored
Normal 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
213
mobile/README.md
Normal 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
|
||||
```
|
||||
44
mobile/capacitor.config.json
Normal file
44
mobile/capacitor.config.json
Normal 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
27
mobile/package.json
Normal 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
25
scripts/sync-html.mjs
Normal 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); });
|
||||
|
|
@ -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(){
|
||||
if(isNative())return;
|
||||
if(!('serviceWorker' in navigator))return;
|
||||
navigator.serviceWorker.register('/sw.js').then(reg=>{
|
||||
console.log('[SW] registered:',reg.scope);
|
||||
|
|
|
|||
Loading…
Reference in a new issue