- 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
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'makescredentials: '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):
- Go to https://console.firebase.google.com
- Create project (or use existing) named
dumpster-chat - Add Android app with package name
coffee.dustin.dumpster - Download
google-services.json→ place inweb/android/app/google-services.json - 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:
- In
Subscribe(): detectfcm:prefix on endpoint, store differently (or store as-is, the prefix distinguishes it). - In the send functions (
Send,SendToUser): check if subscription endpoint starts withfcm:→ 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:
- 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.
- 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'
}
}
}
- Build AAB:
cd web/android
KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease
Output: web/android/app/build/outputs/bundle/release/app-release.aab
- 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.