Files
dumpsterChat/docs/CapacitorAndroidPlan.md
T
hobokenchicken bda4c9d73d fix: handle 401 gracefully on web; add Bearer token auth for Tauri
- fetchMe() no longer surfaces 401 as a user-facing error (it just
  means 'no session', not a failure)
- API client auto-clears auth state on 401 mid-session so the user
  gets redirected to login instead of seeing 'ERR: Request failed: 401'
- Session middleware now accepts Authorization: Bearer <token> header
  as fallback when no cookie is present (for Tauri/native clients)
- Login, register, and WebAuthn endpoints expose X-Session-Token header
  so non-browser clients can capture the token
2026-07-16 14:46:17 -04:00

9.9 KiB

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:

cd web
npm install @capacitor/core @capacitor/cli @capacitor/android

Then init Capacitor:

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:

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:

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:

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:

cd web
npm install @capacitor/push-notifications

Modify push.ts — add a native registration path alongside the existing web push:

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:

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:

go get firebase.google.com/go/v4

Pattern:

// 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:

minSdkVersion = 24  // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs.

App name in strings.xml:

<string name="app_name">Dumpster Chat</string>

App icon: Copy existing PWA icons into Android mipmap directories:

# 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:

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:

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):
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.

  1. Add signing config to web/android/app/build.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'
        }
    }
}
  1. Build AAB:
cd web/android
KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease

Output: web/android/app/build/outputs/bundle/release/app-release.aab

  1. 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:

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.