Files
canteen-asset-tracker/.hermes/plans/2026-05-15-android-app.md
T

32 KiB

Canteen Asset Tracker — Android App Implementation Plan

For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.

Goal: Native Android app that uses device GPS to detect when a technician is near a machine, enabling background proximity alerts, automatic check-in prompts, and offline-capable asset lookup — all backed by the existing FastAPI backend.

Architecture: Kotlin + Jetpack Compose native Android app communicating with the existing FastAPI backend via REST. FusedLocationProviderClient for efficient background GPS. GeofencingClient for proximity zones defined by asset/location coordinates. Firebase Cloud Messaging (FCM) for push notifications. Room database for offline cache.

Tech Stack:

  • Language: Kotlin 2.0+
  • UI: Jetpack Compose (Material 3)
  • Navigation: Compose Navigation
  • Networking: Retrofit 2 + OkHttp + kotlinx.serialization
  • Location: Google Play Services (FusedLocationProvider + GeofencingClient)
  • Local DB: Room (offline asset cache, pending check-ins)
  • Camera: CameraX (barcode scanning + OCR photo upload)
  • Maps: Google Maps Compose / OSMDroid (open-source fallback)
  • Push: Firebase Cloud Messaging
  • DI: Hilt
  • Target: Android API 26+ (covers 96%+ devices)

Phase 0: Backend Prerequisites

The existing backend at ~/projects/canteen-asset-tracker/server.py needs GPS-coordinate storage and a proximity-check API before the Android app can function. These are gating changes.

Task 0.1: Add latitude/longitude columns to assets and locations tables

Objective: Store GPS coordinates for each machine and location so the Android app can check proximity.

Files:

  • Modify: server.py:_create_v2_tables (assets + locations table DDL)
  • Modify: server.py: migration block (add ALTER TABLE if missing)
  • Modify: server.py: AssetCreate/AssetUpdate Pydantic models
  • Modify: server.py: LocationCreate/LocationUpdate Pydantic models
  • Modify: tests/test_server.py

Step 1: Add columns to table creation

In _create_v2_tables() (around line 77), add to locations:

latitude    REAL    DEFAULT NULL,
longitude   REAL    DEFAULT NULL,

In _create_v2_tables() (around line 136), add to assets:

latitude    REAL    DEFAULT NULL,
longitude   REAL    DEFAULT NULL,
geofence_radius_meters INTEGER DEFAULT 50,

Step 2: Add migration logic

After the existing migration block (around line 340), add:

# Add lat/lng to assets if not present (v3 migration)
cursor = conn.execute("PRAGMA table_info(assets)")
asset_cols = {row[1] for row in cursor.fetchall()}
if "latitude" not in asset_cols:
    conn.execute("ALTER TABLE assets ADD COLUMN latitude REAL DEFAULT NULL")
if "longitude" not in asset_cols:
    conn.execute("ALTER TABLE assets ADD COLUMN longitude REAL DEFAULT NULL")
if "geofence_radius_meters" not in asset_cols:
    conn.execute("ALTER TABLE assets ADD COLUMN geofence_radius_meters INTEGER DEFAULT 50")

cursor = conn.execute("PRAGMA table_info(locations)")
loc_cols = {row[1] for row in cursor.fetchall()}
if "latitude" not in loc_cols:
    conn.execute("ALTER TABLE locations ADD COLUMN latitude REAL DEFAULT NULL")
if "longitude" not in loc_cols:
    conn.execute("ALTER TABLE locations ADD COLUMN longitude REAL DEFAULT NULL")

Step 3: Update Pydantic models

Add optional fields to AssetCreate and AssetUpdate:

latitude: Optional[float] = None
longitude: Optional[float] = None
geofence_radius_meters: Optional[int] = 50

Add optional fields to LocationCreate and LocationUpdate:

latitude: Optional[float] = None
longitude: Optional[float] = None

Step 4: Update create/update handlers

Modify POST /api/assets, PUT /api/assets/{id}, POST /api/locations, PUT /api/locations/{id} to include the new optional fields in INSERT/UPDATE SQL.

Step 5: Run tests

Run: cd ~/projects/canteen-asset-tracker && python -m pytest tests/test_server.py -v -x Expected: All 319 existing tests pass (new columns have defaults, existing INSERTs unaffected)

Step 6: Add tests for new fields

Add ~6 new tests verifying:

  • Create asset with lat/lng
  • Update asset lat/lng
  • Create location with lat/lng
  • Update location lat/lng
  • GET returns lat/lng fields
  • geofence_radius_meters defaults to 50

Task 0.2: Add proximity-check API endpoint

Objective: Backend endpoint that takes a GPS point and returns nearby assets sorted by distance.

Files:

  • Modify: server.py (new GET /api/proximity endpoint)
  • Modify: tests/test_server.py (proximity tests)

Step 1: Write failing test

def test_proximity_returns_nearby_assets(client, seed_db):
    # Seed assets with coordinates
    resp = client.post("/api/assets", json={
        "machine_id": "PROX-000001",
        "name": "Nearby Fridge",
        "category": "Appliances",
        "latitude": 40.7128, "longitude": -74.0060,  # NYC
        "geofence_radius_meters": 100,
    })
    assert resp.status_code == 201

    resp = client.post("/api/assets", json={
        "machine_id": "PROX-000002",
        "name": "Far Freezer",
        "category": "Appliances",
        "latitude": 40.8000, "longitude": -74.1000,  # ~10km away
        "geofence_radius_meters": 100,
    })
    assert resp.status_code == 201

    # Query from near the first asset
    resp = client.get("/api/proximity?lat=40.7129&lng=-74.0061&radius_meters=200")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) >= 1
    assert data[0]["machine_id"] == "PROX-000001"  # Closest first

Step 2: Run test to verify failure

Run: pytest tests/test_server.py::test_proximity_returns_nearby_assets -v Expected: FAIL — 404 Not Found

Step 3: Implement the endpoint

@app.get("/api/proximity")
def proximity_check(
    lat: float = Query(...),
    lng: float = Query(...),
    radius_meters: int = Query(200, ge=1, le=50000),
):
    """
    Return assets within radius_meters of (lat, lng), sorted by distance.
    Uses Haversine formula for accurate spherical distance.
    """
    conn = get_db()
    rows = conn.execute("""
        SELECT *, (
            6371000 * acos(
                cos(radians(?)) * cos(radians(latitude)) *
                cos(radians(longitude) - radians(?)) +
                sin(radians(?)) * sin(radians(latitude))
            )
        ) AS distance_meters
        FROM assets
        WHERE latitude IS NOT NULL AND longitude IS NOT NULL
        HAVING distance_meters <= ?
        ORDER BY distance_meters
        LIMIT 50
    """, (lat, lng, lat, radius_meters)).fetchall()
    conn.close()
    results = [row_to_dict(r) for r in rows]
    return results

Step 4: Run test to verify pass

Run: pytest tests/test_server.py::test_proximity_returns_nearby_assets -v Expected: PASS

Step 5: Add edge case tests

  • No nearby assets returns []
  • Missing lat/lng params returns 422
  • radius_meters below 1 or above 50000 returns 422
  • Assets with NULL lat/lng are excluded

Step 6: Commit

git add server.py tests/test_server.py
git commit -m "feat: add lat/lng to assets/locations + proximity API"

Task 0.3: Add geofence point-check API endpoint

Objective: Endpoint that checks whether a given GPS point falls inside any geofence polygon.

Files:

  • Modify: server.py (new POST /api/geofences/check endpoint)
  • Modify: tests/test_server.py (geofence check tests)

Step 1: Implement the endpoint

@app.post("/api/geofences/check")
def check_geofence_point(body: GeofencePointCheck):
    """
    Check if a GPS point falls inside any geofence polygon.
    Returns list of matching geofences.
    """
    conn = get_db()
    rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall()
    conn.close()

    matches = []
    for row in rows:
        points = _json.loads(row["points"])
        if _point_in_polygon(body.lat, body.lng, points):
            matches.append(row_to_dict(row))
    return matches


def _point_in_polygon(lat: float, lng: float, polygon: list) -> bool:
    """Ray-casting algorithm for point-in-polygon test."""
    inside = False
    n = len(polygon)
    j = n - 1
    for i in range(n):
        yi = polygon[i]["lat"] if isinstance(polygon[i], dict) else polygon[i][0]
        xi = polygon[i]["lng"] if isinstance(polygon[i], dict) else polygon[i][1]
        yj = polygon[j]["lat"] if isinstance(polygon[j], dict) else polygon[j][0]
        xj = polygon[j]["lng"] if isinstance(polygon[j], dict) else polygon[j][1]

        if ((yi > lng) != (yj > lng)) and (lat < (xj - xi) * (lng - yi) / (yj - yi) + xi):
            inside = not inside
        j = i
    return inside

Step 2: Add Pydantic model

class GeofencePointCheck(BaseModel):
    lat: float
    lng: float

Step 3: Write tests (~5 tests: inside, outside, empty geofences, multiple matches)

Step 4: Commit


Phase 1: Android Project Scaffold

Task 1.1: Create Android project

Objective: Boot a bare Kotlin + Compose project with Gradle Kotlin DSL.

Files:

  • Create: android/ directory in project root
  • Create: android/build.gradle.kts (project-level)
  • Create: android/app/build.gradle.kts (module-level)
  • Create: android/gradle.properties
  • Create: android/settings.gradle.kts
  • Create: android/app/src/main/AndroidManifest.xml

Dependencies in app/build.gradle.kts:

dependencies {
    // Compose BOM
    implementation(platform("androidx.compose:compose-bom:2024.06.00"))
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.9.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")

    // Navigation
    implementation("androidx.navigation:navigation-compose:2.7.7")

    // Networking
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
    implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")

    // Location
    implementation("com.google.android.gms:play-services-location:21.3.0")

    // CameraX
    implementation("androidx.camera:camera-camera2:1.3.4")
    implementation("androidx.camera:camera-lifecycle:1.3.4")
    implementation("androidx.camera:camera-view:1.3.4")

    // Local DB
    implementation("androidx.room:room-runtime:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")

    // DI
    implementation("com.google.dagger:hilt-android:2.51.1")
    ksp("com.google.dagger:hilt-compiler:2.51.1")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")

    // Maps (OSMDroid — open-source, no API key)
    implementation("org.osmdroid:osmdroid-android:6.1.18")

    // Testing
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

Step 1: mkdir -p ~/projects/canteen-asset-tracker/android Step 2: Write all scaffold files Step 3: Verify: cd android && ./gradlew assembleDebug succeeds

Step 4: Commit

git add android/
git commit -m "feat: Android project scaffold (Kotlin + Compose)"

Task 1.2: Networking layer — Retrofit API client

Objective: Define typed API client for all backend endpoints the Android app needs.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/network/CanteenApi.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/network/dto/*.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/network/RetrofitModule.kt (Hilt DI)

API endpoints to model:

  • POST /api/auth/loginAuthToken
  • GET /api/auth/meUser
  • GET /api/assetsList<Asset>
  • GET /api/assets/{id}AssetDetail
  • GET /api/assets/search?machine_id=List<Asset>
  • POST /api/checkinsCheckin
  • GET /api/checkins?asset_id=&limit=List<Checkin>
  • GET /api/proximity?lat=&lng=&radius_meters=List<Asset>
  • POST /api/geofences/checkList<Geofence>
  • GET /api/geofencesList<Geofence>
  • POST /api/ocr (multipart) → OcrResult

DTOs: LoginRequest, AuthResponse, User, Asset, AssetDetail, CheckinCreate, Checkin, GeofencePointCheck, Geofence, OcrResult, ProximityResponse

Step 1: Write all DTOs as @Serializable data classes Step 2: Write CanteenApi interface with Retrofit annotations Step 3: Write Hilt @Module for providing Retrofit instance with auth token interceptor Step 4: Write unit test mocking OkHttp (verify auth header injection)

Step 5: Commit

Task 1.3: Local database — Room cache

Objective: Offline cache of assets, pending check-ins, and auth token.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/data/AppDatabase.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/data/entity/*.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/data/dao/*.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/data/DatabaseModule.kt (Hilt DI)

Entities:

  • CachedAsset — mirrors API asset with lat/lng, last_synced timestamp
  • PendingCheckin — check-ins created offline, synced when online
  • AuthSession — token, user_id, role, expiry

DAOs:

  • AssetDao — insertAll, getAll, searchByName, getById, clearAll
  • PendingCheckinDao — insert, getAllPending, markSynced, delete
  • AuthSessionDao — insert, getActive, invalidate

Step 1: Write entities and DAOs Step 2: Write AppDatabase (Room 2.6.1, schema export on) Step 3: Write Hilt module Step 4: Write instrumentation test: insert → query → verify

Step 5: Commit


Phase 2: Core Android Features

Task 2.1: Login screen + auth flow

Objective: Login screen that authenticates against the backend, stores token securely, and gates the rest of the app.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/login/LoginScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/login/LoginViewModel.kt
  • Modify: android/app/src/main/java/com/canteen/assettracker/MainActivity.kt
  • Modify: android/app/src/main/java/com/canteen/assettracker/navigation/NavGraph.kt

UI: Username + password fields, Sign In button, error banner, "Connecting..." loading state. Dark theme matching webapp. Server URL configurable via settings (default: https://canteen.ourpad.casa:8901).

Auth flow:

  1. POST /api/auth/login → get bearer token
  2. Store token in Room + OkHttp interceptor
  3. GET /api/auth/me → show user badge
  4. Navigate to main screen

Step 1: Write LoginViewModel (StateFlow: isLoading, error, token) Step 2: Write LoginScreen composable Step 3: Wire into NavGraph (login → authenticated graph) Step 4: Write RetrofitModule auth interceptor (reads token from Room) Step 5: Compose preview + manual test on device/emulator

Step 6: Commit

Task 2.2: Home dashboard screen

Objective: Dashboard with stats (total assets, by category, recent check-ins). Data loaded from backend, cached in Room.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/dashboard/DashboardScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/dashboard/DashboardViewModel.kt

UI: Stats cards (total, active, maintenance), category breakdown bar, recent check-ins list, pull-to-refresh.

Step 1: Write DashboardViewModel (loads assets + checkins on init) Step 2: Write DashboardScreen (LazyColumn with stat cards) Step 3: Error state — show retry button when offline Step 4: Commit

Task 2.3: Asset list + search + detail

Objective: Browse all assets, search by name/machine_id, view full asset detail with map pin.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetListScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetDetailScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/assets/AssetViewModel.kt

UI:

  • List: search bar + LazyColumn cards (name, machine_id, status chip, category icon)
  • Detail: full fields, map showing pin if lat/lng set, "Check In" FAB button

Step 1: AssetViewModel (load, search local + remote, cache) Step 2: AssetListScreen Step 3: AssetDetailScreen with map Step 4: Commit

Task 2.4: Map screen with asset pins

Objective: Full map showing all asset markers, geofence overlays, and current GPS position.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/map/MapScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/map/MapViewModel.kt

UI: OSMDroid map view, asset markers (tappable → asset detail), geofence polygon overlays, current-location blue dot, "center on me" button.

Step 1: MapViewModel (load geofences + assets with lat/lng) Step 2: MapScreen composable with MapView AndroidView wrapper Step 3: Marker click → navigate to AssetDetail Step 4: Current location dot Step 5: Commit


Phase 3: GPS Proximity Detection (Core Feature)

Task 3.1: Background location service

Objective: Foreground service that continuously tracks device location using FusedLocationProviderClient. Runs even when app is in background.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/location/LocationService.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/location/LocationRepository.kt
  • Modify: android/app/src/main/AndroidManifest.xml (add FOREGROUND_SERVICE + location permissions)

Permissions added to manifest:

<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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

LocationService behavior:

  • Request location updates every 10 seconds (configurable: 5-60s)
  • Priority: PRIORITY_HIGH_ACCURACY (GPS)
  • Emit location to a shared Kotlin SharedFlow
  • Show persistent notification: "Canteen Tracker — monitoring location"
  • Auto-restart on device boot (BootReceiver)

Step 1: Write LocationRepository (wraps FusedLocationProviderClient) Step 2: Write LocationService (ForegroundService) Step 3: Register in AndroidManifest Step 4: Add runtime permission request flow in MainActivity Step 5: Test: start service, background app, verify location updates in logcat

Step 6: Commit

Task 3.2: Proximity engine — detect when near a machine

Objective: Core logic that continuously checks device location against asset/location coordinates and triggers notifications when entering proximity zones.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/location/ProximityEngine.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/location/ProximityRepository.kt

ProximityEngine algorithm:

Every location update (debounced to 5 seconds):
  1. If user is moving (speed > 1 m/s), skip (save battery)
  2. Query local Room cache for assets within 500m (pre-filter using simple lat/lng box)
  3. Compute Haversine distance for each candidate
  4. If distance < asset.geofence_radius_meters AND asset not in activeProximities:
     → Emit "entered proximity" event
     → Show notification: "Near MachineName (50m) — tap to check in"
     → Add to activeProximities set
  5. If distance > asset.geofence_radius_meters * 1.5 AND asset in activeProximities:
     → Emit "exited proximity" event
     → Remove from activeProximities
  6. Also check geofences: call POST /api/geofences/check every 60 seconds

ProximityRepository:

  • Cache assets with lat/lng locally
  • Periodically sync assets from server (every 5 min)
  • Expose observeProximityEvents(): Flow<ProximityEvent>
  • Expose getActiveProximities(): StateFlow<List<Asset>>

Step 1: Implement Haversine distance function (in km, returns meters)

fun haversineDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
    val R = 6371000.0 // Earth radius in meters
    val dLat = Math.toRadians(lat2 - lat1)
    val dLng = Math.toRadians(lng2 - lng1)
    val a = sin(dLat / 2) * sin(dLat / 2) +
            cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
            sin(dLng / 2) * sin(dLng / 2)
    val c = 2 * atan2(sqrt(a), sqrt(1 - a))
    return R * c
}

Step 2: Write ProximityEngine with the full check loop Step 3: Write ProximityRepository (local cache + periodic sync) Step 4: Unit test: mock locations, verify proximity events fire correctly Step 5: Commit

Task 3.3: Push notifications for proximity alerts

Objective: When device enters a machine's proximity zone, show a local notification with quick-action buttons (Check In, Dismiss).

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/notification/NotificationHelper.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/notification/ProximityNotificationReceiver.kt

Notification channels:

  • proximity_alerts — high importance, heads-up, sound + vibration
  • checkin_reminders — default importance

Proximity notification content:

  • Title: "Near MachineName"
  • Body: "~50m away at 123 Main St — Room 3A"
  • Actions: "Check In" (opens check-in screen or auto-checks-in), "Dismiss" (snoozes for 5 min)
  • Tap: opens AssetDetailScreen

Step 1: Write NotificationHelper (channel creation, notification builder) Step 2: Wire ProximityEngine events → NotificationHelper Step 3: Implement BroadcastReceiver for notification actions Step 4: Commit

Task 3.4: Check-in flow from proximity alert

Objective: When user taps "Check In" on a proximity notification, auto-create a check-in with current GPS coordinates and minimal interaction.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/checkin/QuickCheckinViewModel.kt
  • Modify: ProximityNotificationReceiver

Flow:

  1. User taps "Check In" on notification or FAB on asset detail
  2. QuickCheckinScreen shows asset name, current GPS coords, accuracy
  3. Optional: photo capture (CameraX), notes field
  4. "Submit" → POST /api/checkins
  5. On success: toast + dismiss notification + remove from activeProximities
  6. On failure (offline): save to PendingCheckin table, retry on connectivity

Step 1: QuickCheckinViewModel Step 2: QuickCheckinScreen (sheet/bottom sheet) Step 3: Pending check-in retry logic (WorkManager periodic task) Step 4: Commit


Phase 4: Camera & OCR

Task 4.1: Barcode scanner (CameraX + ML Kit)

Objective: Scan machine_id barcodes using device camera. Same as webapp scan tab but native Android performance.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/scan/ScanScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/scan/ScanViewModel.kt

Stack: CameraX + Google ML Kit Barcode Scanning (on-device, no network needed). Alternative: ZXing (same as webapp) via com.journeyapps:zxing-android-embedded.

Flow:

  1. Open ScanScreen → CameraX preview with barcode overlay
  2. Detect barcode → extract machine_id
  3. Call GET /api/assets/search?machine_id=...
  4. If found → navigate to AssetDetail
  5. If not found → prompt "Add new asset?"

Step 1: ScanViewModel with ML Kit integration Step 2: ScanScreen with CameraX preview + barcode bounding box Step 3: Manual entry fallback (type machine_id) Step 4: Commit

Task 4.2: OCR photo upload for sticker IDs

Objective: Take photo of sticker, upload to backend OCR endpoint, display extracted machine_id.

Files:

  • Modify: ScanScreen.kt (add "Photo OCR" tab/mode)
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/scan/OcrViewModel.kt

Flow:

  1. Switch to OCR mode in ScanScreen
  2. Tap capture button → CameraX takePicture()
  3. Show preview, confirm or retake
  4. Upload as multipart to POST /api/ocr
  5. Show extracted machine_id + confidence
  6. Offer "Look up asset" button

Step 1: OcrViewModel (multipart upload via Retrofit) Step 2: Photo capture + preview UI Step 3: Commit


Phase 5: Polish & Release

Task 5.1: Offline mode handling

Objective: Gracefully handle no-network states. Show cached data, queue mutations.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/util/ConnectivityObserver.kt
  • Modify: All ViewModels (add offline-aware data sources)

Approach:

  • ConnectivityObserver monitors ConnectivityManager → emits StateFlow<Boolean>
  • When offline: load from Room, show "Offline" banner
  • Pending check-ins saved to Room, synced via WorkManager when connectivity returns
  • Asset list shows last-synced timestamp

Step 1: ConnectivityObserver Step 2: Offline data-source layer in repositories Step 3: WorkManager sync worker Step 4: Commit

Task 5.2: Settings screen

Objective: Server URL config, GPS update interval, geofence radius defaults, dark mode toggle, logout.

Files:

  • Create: android/app/src/main/java/com/canteen/assettracker/ui/settings/SettingsScreen.kt
  • Create: android/app/src/main/java/com/canteen/assettracker/ui/settings/SettingsViewModel.kt

Settings stored via DataStore (Preferences):

  • server_url — default: https://canteen.ourpad.casa:8901
  • gps_interval_seconds — default: 10, range: 5-60
  • proximity_scan_radius_meters — default: 500
  • dark_mode — boolean (app is dark by default)
  • offline_mode — boolean (skip network calls, use Room only)

Step 1: DataStore preferences Step 2: SettingsScreen composable Step 3: Wire settings into LocationService and ProximityEngine Step 4: Commit

Task 5.3: Self-signed cert trust

Objective: The backend uses a self-signed TLS cert. OkHttp must trust it.

Files:

  • Modify: RetrofitModule.kt

Approach: Bundle cert.pem as raw resource. Create custom TrustManager that trusts the bundled cert. Alternatively: add network_security_config.xml for dev builds.

Step 1: Copy ~/projects/canteen-asset-tracker/cert.pemandroid/app/src/main/res/raw/cert.pem Step 2: Update OkHttpClient builder to trust the custom cert Step 3: Commit

Task 5.4: Signed APK build + distribution

Objective: Produce a signed release APK for distribution.

Files:

  • Create: android/keystore.properties (gitignored)
  • Modify: android/app/build.gradle.kts (signing config)

Step 1: Generate release keystore: keytool -genkey -v -keystore canteen-release.jks ... Step 2: Signing config in build.gradle.kts Step 3: ./gradlew assembleReleaseapp/build/outputs/apk/release/app-release.apk Step 4: Document installation: sideload APK on Android device

Step 5: Commit


Phase 6: Testing

Task 6.1: Unit tests (ViewModels, repositories, proximity engine)

Objective: Test core logic without device dependencies.

Files:

  • Create: android/app/src/test/java/com/canteen/assettracker/location/ProximityEngineTest.kt
  • Create: android/app/src/test/java/com/canteen/assettracker/ui/login/LoginViewModelTest.kt
  • Create: android/app/src/test/java/com/canteen/assettracker/ui/dashboard/DashboardViewModelTest.kt

Test targets:

  • LoginViewModel: successful login, auth failure, network error
  • ProximityEngine: inside geofence triggers event, outside doesn't, exit event, debounce
  • DashboardViewModel: loads stats, handles empty, refresh
  • Haversine distance: known coordinates (NYC → LA ~3944 km)

Step 1: Write ProximityEngineTest Step 2: Write LoginViewModelTest Step 3: Write DashboardViewModelTest Step 4: Verify: ./gradlew test — all pass

Step 5: Commit

Task 6.2: Instrumentation tests (Room, Compose UI)

Objective: Test database operations and Compose UI on device/emulator.

Files:

  • Create: android/app/src/androidTest/java/com/canteen/assettracker/data/AssetDaoTest.kt
  • Create: android/app/src/androidTest/java/com/canteen/assettracker/ui/login/LoginScreenTest.kt

Step 1: AssetDaoTest (insert, query, search, clear) Step 2: LoginScreenTest (fill fields, tap sign in, verify navigation) Step 3: Verify: ./gradlew connectedAndroidTest

Step 4: Commit


Technical Decisions & Tradeoffs

Decision Rationale Alternative considered
Kotlin + Compose Native performance, best GPS/BG service support Flutter (BG services trickier), React Native (same)
OSMDroid (not Google Maps) No API key required, works offline Google Maps Compose (richer, needs billing)
Room (not SQLDelight) Mature, Hilt integration, Compose-friendly SQLDelight (KMP-friendly, overkill here)
FusedLocationProvider Battery-efficient, Google Play Services Raw GPS (LocationManager) — worse battery
Haversine client-side No server round-trip per location update, instant Server-side proximity (more accurate, network-dependent)
Local notifications (not FCM) No Firebase dependency for basic alerts, works offline FCM push (needed for server-triggered alerts in future)
Self-signed cert bundling Backend uses self-signed TLS, must trust it for Retrofit Let's Encrypt cert on backend (preferred long-term)

Risks

  • Background process killed by Android: Android 14+ aggressively kills background services. Mitigation: Foreground service with persistent notification. Test on Android 14/15.
  • Battery drain: GPS every 10 seconds can drain battery. Mitigation: debounce when stationary, increase interval when battery low, use geofencing API for wide zones.
  • Self-signed cert: Android 14+ blocks user-added CAs by default for apps targeting SDK 34+. Mitigation: network_security_config.xml for debug, or switch backend to Let's Encrypt.
  • Location permissions: Users may deny background location — app becomes foreground-only. Mitigation: degrade gracefully, explain why background access is needed.

Open Questions

  1. Should proximity also check locations (buildings) in addition to individual assets?
  2. Should the app support multiple backend server profiles (e.g., different customer sites)?
  3. Do we need Firebase Cloud Messaging for server-pushed alerts, or are local notifications sufficient for v1?
  4. Should the app upload check-in photos immediately or queue them for WiFi-only upload?

Phase 0 (Backend):     Tasks 0.1 → 0.2 → 0.3        [prerequisite for all Android work]
Phase 1 (Scaffold):    Tasks 1.1 → 1.2 → 1.3        [sequential, no parallelism]
Phase 2 (Core UI):     Tasks 2.1 → (2.2, 2.3, 2.4)  [2.2+2.3+2.4 can run in parallel after 2.1]
Phase 3 (GPS Engine):  Tasks 3.1 → 3.2 → 3.3 → 3.4 [sequential dependencies]
Phase 4 (Camera):      Tasks 4.1, 4.2               [can run in parallel]
Phase 5 (Polish):      Tasks 5.1 → 5.2 → 5.3 → 5.4 [sequential]
Phase 6 (Testing):     Tasks 6.1, 6.2               [can run in parallel]

Estimated total effort: ~25-35 hours of focused development.


Verification Checklist

  • Backend: proximity API returns correct nearest assets
  • Backend: geofence point-check works (inside/outside polygon)
  • Android: app installs on API 26+ device
  • Android: login flow completes against real backend
  • Android: GPS location updates appear in logcat (foreground + background)
  • Android: proximity notification fires when walking within 50m of a machine with coordinates
  • Android: "Check In" from notification creates check-in on backend
  • Android: asset list loads and displays from backend
  • Android: barcode scan finds asset by machine_id
  • Android: offline mode shows cached assets, queues check-ins
  • Android: queued check-ins sync when connectivity returns
  • Android: release APK installs and runs on a real device