# Capacitor Android App — Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play. **Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app. **Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build) --- ### Phase 1: Capacitor Init #### Task 1: Add Capacitor dependencies **Objective:** Install Capacitor core + CLI in the web project. **Files:** - Modify: `web/package.json` **Steps:** ```bash cd web npm install @capacitor/core @capacitor/cli @capacitor/android ``` Then init Capacitor: ```bash npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist ``` This creates `capacitor.config.ts` at the web root. **Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`. --- #### Task 2: Configure capacitor.config.ts **Objective:** Set server URL for dev, configure Android-specific settings. **Files:** - Modify: `web/capacitor.config.ts` **Content:** ```ts import type { CapacitorConfig } from '@capacitor/cli'; const config: CapacitorConfig = { appId: 'coffee.dustin.dumpster', appName: 'Dumpster Chat', webDir: 'dist', server: { // ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts. androidScheme: 'https', // cookies work over https scheme in WebView }, plugins: { PushNotifications: { presentationOptions: ['badge', 'sound', 'alert'], }, }, }; export default config; ``` **Key decisions:** - `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them). - No `server.url` — local assets load from the APK, not from the web. Faster, works offline. --- #### Task 3: Add Android platform **Objective:** Generate the native Android project. **Files:** - Create: `web/android/` (generated by Capacitor) **Steps:** ```bash cd web npx cap add android ``` **Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists. --- ### Phase 2: API Client Fix #### Task 4: Update API base URL for native **Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA. **Files:** - Modify: `web/src/lib/api.ts` **Changes:** ```ts import { Capacitor } from '@capacitor/core'; // ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin). const API_BASE = Capacitor.isNativePlatform() ? 'https://dumpster.dustin.coffee/api/v1' : '/api/v1'; ``` The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView. **Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break. --- ### Phase 3: Push Notifications (FCM) This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM. #### Task 5: Create Firebase project **Objective:** Set up FCM credentials for native push. **Steps (manual, one-time):** 1. Go to https://console.firebase.google.com 2. Create project (or use existing) named `dumpster-chat` 3. Add Android app with package name `coffee.dustin.dumpster` 4. Download `google-services.json` → place in `web/android/app/google-services.json` 5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account **Verify:** `google-services.json` exists in `web/android/app/`. --- #### Task 6: Add Capacitor Push Notifications plugin **Objective:** Register for FCM token on Android, send it to the server. **Files:** - Modify: `web/package.json` (install plugin) - Modify: `web/src/stores/push.ts` (add native branch) **Install:** ```bash cd web npm install @capacitor/push-notifications ``` **Modify `push.ts`** — add a native registration path alongside the existing web push: ```ts import { Capacitor } from '@capacitor/core'; // Existing web push subscribe stays as-is for PWA. // Add native branch: async function subscribeNative() { const { PushNotifications } = await import('@capacitor/push-notifications'); const permStatus = await PushNotifications.requestPermissions(); if (permStatus.receive !== 'granted') return; await PushNotifications.register(); // Server sends us the FCM token via this event PushNotifications.addListener('registration', async (token) => { await api.post('/push/subscribe', { endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape }); }); PushNotifications.addListener('pushNotificationReceived', (notification) => { // Foreground notification — show in-app toast or badge // ponytail: handled by existing in-app notification system }); } ``` Then in the existing `subscribe()` function, branch: ```ts if (Capacitor.isNativePlatform()) { return subscribeNative(); } // ... existing web push logic ``` --- #### Task 7: Server-side FCM send support **Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID. **Files:** - Modify: `internal/push/handlers.go` **Changes:** 1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it). 2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send. **Install Go Firebase Admin:** ```bash go get firebase.google.com/go/v4 ``` **Pattern:** ```go // ponytail: one if/else in the send loop. endpoint prefix = routing key. if strings.HasPrefix(sub.Endpoint, "fcm:") { token := strings.TrimPrefix(sub.Endpoint, "fcm:") msg := &messaging.Message{ Token: token, Notification: &messaging.Notification{ Title: title, Body: body, }, Data: map[string]string{"url": url}, } _, err = fcmClient.Send(ctx, msg) } else { // existing VAPID webpush send } ``` **Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose. **Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows. --- ### Phase 4: Gradle / Build Config #### Task 8: Configure Android build **Objective:** Set minimum SDK, app icon, theme. **Files:** - Modify: `web/android/app/build.gradle` - Modify: `web/android/app/src/main/res/values/strings.xml` **Changes in `build.gradle`:** ```gradle minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs. ``` **App name in `strings.xml`:** ```xml Dumpster Chat ``` **App icon:** Copy existing PWA icons into Android mipmap directories: ```bash # Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024) # or manually: web/android/app/src/main/res/mipmap-*/ npx cap assets generate # if a 1024x1024 source icon exists ``` --- ### Phase 5: Build & Publish #### Task 9: Sync and build debug APK **Objective:** Verify the app runs on a real device or emulator. **Steps:** ```bash cd web npm run build # builds Vite → dist/ npx cap sync android # copies dist/ into android/assets, syncs plugins cd android ./gradlew assembleDebug ``` **Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk` **Verify:** Install on Android device: ```bash adb install app-debug.apk ``` --- #### Task 10: Build signed release AAB for Play Store **Objective:** Create a signed Android App Bundle (.aab) for Google Play upload. **Steps:** 1. Generate keystore (one-time): ```bash keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster ``` Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app. 2. Add signing config to `web/android/app/build.gradle`: ```gradle android { signingConfigs { release { storeFile file('dumpster-release.jks') storePassword System.getenv('KEYSTORE_PASSWORD') keyAlias 'dumpster' keyPassword System.getenv('KEY_PASSWORD') } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } } ``` 3. Build AAB: ```bash cd web/android KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease ``` **Output:** `web/android/app/build/outputs/bundle/release/app-release.aab` 4. Upload to Google Play Console → your new developer account → Create app → Upload AAB. --- #### Task 11: Clean up Tauri dependencies **Objective:** Remove unused Tauri packages (pivoted away from Tauri). **Files:** - Modify: `web/package.json` **Steps:** ```bash cd web npm uninstall @tauri-apps/api @tauri-apps/cli ``` --- ### Summary | Phase | What | Time estimate | |-------|------|---------------| | 1 | Capacitor init + Android platform | 10 min | | 2 | API base URL native branch | 5 min | | 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) | | 4 | Gradle config / icons | 15 min | | 5 | Build, test, publish | 30 min | **Total:** ~2-3 hours end-to-end. Phase 3 is the only real work. **Dependencies between tasks:** - Tasks 1-3 sequential (Capacitor init) - Task 4 independent of 5-7 - Tasks 5-7 sequential (FCM chain) - Task 8 depends on 1-3 - Task 9 depends on all above - Task 10 depends on 9 - Task 11 independent, do anytime **After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too.