Mobile App Module
The MobileApp module provides the complete mobile app experience — gating, push notifications via Firebase Cloud Messaging HTTP v1 API, device management, voice assistant, and full settings screens for profile, security, roles, activity logs, and more.
Overview
- Gate: Mobile app only works if this module is enabled on the tenant
- Push Notifications: FCM HTTP v1 API (production-ready, OAuth2 via service account)
- Voice Assistant: Long-press FAB to activate, live microphone with waveform visualization
- Settings Screens: Profile, appearance, notifications, login history, roles, activity logs, team
- App Version Control: Force update / soft update enforcement
- Mobile Dashboard: Single aggregated endpoint with card-based UI
- Dynamic Navigation: Customizable tab bar and FAB quick actions
Architecture
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Expo SDK 55, React Native 0.83, React 19.2 |
| Router | Expo Router v7 (file-based) |
| Language | TypeScript |
| UI | Custom shadcn-style RN components |
| State | React Context (Auth, Theme, Modules, Notifications, Navigation) |
| Storage | AsyncStorage (general), SecureStore (tokens) |
| Real-time | Reverb WebSocket via pusher-js |
| Push | Firebase Cloud Messaging HTTP v1 API |
Project Structure
app/
├── app/ # Expo Router file-based routes
│ ├── _layout.tsx # Root layout (providers)
│ ├── (auth)/
│ │ └── login.tsx # Login + 2FA verification
│ └── (app)/
│ ├── _layout.tsx # Module gate + Stack navigator
│ ├── notifications.tsx # Notifications screen
│ ├── orders/[id].tsx # Order detail
│ ├── m/[...slug].tsx # SDUI catch-all route (dynamic screens)
│ ├── (tabs)/
│ │ ├── _layout.tsx # Custom tab bar + FAB + voice assistant
│ │ ├── dashboard.tsx # Card-based dashboard
│ │ ├── orders.tsx # Order list
│ │ ├── customers.tsx # Customer list
│ │ ├── warehouse.tsx # WMS operations
│ │ └── settings.tsx # Settings hub
│ └── settings/
│ ├── profile.tsx # Profile management
│ ├── appearance.tsx # Theme + color mode
│ ├── notifications.tsx # Push preferences
│ ├── login-history.tsx # Login history
│ ├── roles.tsx # Roles & permissions (admin)
│ ├── activity-logs.tsx # Audit trail (admin)
│ ├── navigation.tsx # Tab/FAB customization
│ └── team.tsx # Team management
├── components/
│ ├── screen-header.tsx # Rounded header with actions
│ ├── voice-assistant.tsx # Full-screen voice overlay
│ └── ui/ # Base UI components
├── contexts/
│ ├── auth-context.tsx # Auth state + permissions
│ ├── theme-context.tsx # Theme + color mode
│ ├── modules-context.tsx # Module discovery
│ ├── notifications-context.tsx # Real-time notifications
│ └── navigation-context.tsx # Dynamic tab/FAB config
├── lib/
│ ├── api/client.ts # API client (mirrors web)
│ ├── sdui/ # Server-Driven UI engine
│ │ ├── screen-renderer.tsx # Core renderer
│ │ ├── section-registry.ts # Component registry
│ │ ├── action-handler.ts # Action execution
│ │ ├── field-renderer.tsx # Data field rendering
│ │ ├── cache.ts # Two-layer cache
│ │ ├── event-bus.ts # Cross-component events
│ │ └── sections/ # 14 section components
│ ├── notifications/push.ts # Push token + FCM registration
│ ├── echo.ts # Reverb WebSocket setup
│ └── storage/ # SecureStore + AsyncStorage
├── theme/
│ ├── shadcn/ # Default theme
│ └── modern/ # Vibrant indigo theme
└── google-services.json # Firebase config (Android)
Server-Driven UI (SDUI)
The mobile app supports dynamic module screens via Server-Driven UI. Any module can contribute mobile screens by defining them as JSON on the backend — no app rebuild required.
- Catch-all route:
/(app)/m/[...slug]renders any SDUI screen - Sidebar integration: Dynamic screens appear in the app sidebar automatically
- 14 section types: stats-row, list, grid, detail-fields, form, chart, timeline, actions-bar, tabs, and more
- Search, filters, pagination: Built into the list section component
- Actions: navigate, API calls, share, copy, modals, sheets — all driven by JSON
See the full documentation:
- SDUI System Overview — architecture, schema, section types
- Creating Mobile Screens — step-by-step developer guide
SDUI API Endpoints
| Endpoint | Description |
|---|---|
GET /mobile/screens |
List all registered screens |
GET /mobile/screens/manifest |
Versioned manifest for cache sync |
GET /mobile/screens/{id} |
Full SDUI JSON definition |
GET /mobile/screens/{id}/data/{section} |
Section data (paginated, filterable) |
POST /mobile/screens/{id}/action/{actionId} |
Execute server-side action |
How the Gate Works
- User logs in via
/api/v1/auth/login - App calls
GET /api/v1/mobile/check(public, no auth required) - If
installed: false→ slide-up "Mobile Access Not Available" screen with setup instructions - If
installed: true→ proceeds to main app - The gate also displays license notice (mobile access requires separate license)
Custom Tab Bar
The bottom tab bar is fully custom with:
- Rounded top corners (20px) with card background
- Center FAB that floats above the bar
- Dynamic tabs — configured via backend API, user-customizable
- FAB arc menu — tap opens animated arc fan of quick actions
- Voice assistant — long-press FAB (400ms) activates voice overlay
FAB Icon Animation
The FAB cycles through 3 icons with smooth crossfade:
- Plus (3s idle) → Mic (1.5s) → Sparkles (1.5s) → back to Plus
This hints at the dual functionality (menu + voice) without adding UI clutter.
Voice Assistant
Activated by long-pressing the center FAB button.
Flow
- Listening — live microphone input with animated waveform
- Processing — typing dots animation
- Response — animated text with mock response (AI integration ready)
Audio Processing
| Feature | Implementation |
|---|---|
| Recording | expo-av Audio.Recording with metering (dev build) |
| Noise gate | -35dB threshold — ambient noise filtered |
| Smoothing | Exponential moving average (factor 0.35) |
| Silence detection | Below 0.05 normalized = zero |
| Sample rate | 16kHz mono (voice-optimized) |
| Fallback | Simulated waveform in Expo Go |
Waveform Visualization
- 28 bars updated every 60ms from mic metering
- Bars shift left like a scrolling waveform
- Center bars slightly taller (natural wave shape)
- Per-bar jitter for organic feel
- Opacity scales with intensity
Push Notifications
Architecture
NotificationService::send()
├── Creates Notification record (tenant DB)
├── Broadcasts NotificationCreated event (WebSocket → real-time)
└── SendPushOnNotification listener (queued)
└── PushNotificationService::sendToUser()
└── ExpoPushClient::send()
├── ExponentPushToken → Expo Push API
└── FCM token → FCM HTTP v1 API
FCM HTTP v1 API (Production)
The module uses the modern FCM HTTP v1 API with OAuth2 — not the deprecated legacy server key API.
| Component | Detail |
|---|---|
| Endpoint | POST https://fcm.googleapis.com/v1/projects/{projectId}/messages:send |
| Auth | OAuth2 Bearer token via service account JWT |
| Token cache | 1 hour (auto-refreshes via Laravel Cache) |
| JWT library | firebase/php-jwt (bundled with Laravel Passport) |
| Service account | storage/app/firebase-service-account.json |
The calls channel
An incoming call gets its own Android channel at MAX importance, with
bypassDnd and a ring-length vibration, and is sent with a ttl of the ring
window plus a little slack.
Both of those are deliberate. Every other channel announces something the agent
can read when they next look; a call expires, so a notification waiting politely
in the shade has already failed. And without a ttl, FCM holds a message for
four weeks — a phone that was switched off would ring for a call that ended days
ago, which is how people learn to ignore the sound.
The body is the assistant's handoff reason rather than "incoming call", because that is the one thing worth reading before picking up.
Needs
FCM_SERVICE_ACCOUNT_PATHto point at a Firebase service account JSON. Without it every push fails with "FCM service account not configured", which is recorded inpush_notification_log— the app still rings when open, via the websocket, so the failure is invisible unless you look there.
Token Auto-Routing
ExpoPushClient::send() automatically detects the token format:
ExponentPushToken[...] → Expo Push API (https://exp.host/--/api/v2/push/send)
Raw FCM token → FCM v1 API (https://fcm.googleapis.com/v1/projects/.../messages:send)
Notification Preferences
Users can toggle push categories per-device:
| Category | Type | Description |
|---|---|---|
| Orders | order_update |
New orders, status changes, cancellations |
| Inventory | inventory_alert |
Low stock warnings, restock reminders |
| Team | team_notification |
Member joins, role changes, invitations |
| Custom | custom |
Admin broadcasts, system updates |
Auto-Push Triggers
Every notification created via NotificationService::send() automatically triggers a push via the SendPushOnNotification event listener. This covers:
- Order created/updated/shipped/delivered
- Low stock alerts
- Team member events
- Admin broadcasts
- Any custom notification
Firebase Setup (Android)
- Create project at Firebase Console
- Add Android app with package name
app.commstate.mobile - Download
google-services.json→ place atapp/google-services.json - Generate service account key (Project Settings → Service accounts → Generate new private key)
- Place at
storage/app/firebase-service-account.jsonin the backend
Push Notification Log
All push attempts are logged in push_notification_log:
| Field | Description |
|---|---|
| status | pending, sent, failed |
| error | Error message if failed |
| type | order_update, inventory_alert, team_notification, custom |
Settings Screens
Profile (settings/profile)
- Avatar — tap to pick from gallery (expo-image-picker, 1:1 crop), uploads via
POST /profile/avatar - Name — editable with save button
- Email — read-only display
- Password change — expandable section with current/new/confirm fields, show/hide toggles, match indicator
- 2FA status — shows enabled/disabled with backup codes count
- Active sessions — lists all OAuth tokens with device name, last active time, revoke per-session or all
Appearance (settings/appearance)
- Color mode — Light/Dark/System cards with icon, label, description, check indicator
- Theme picker — large cards per theme with:
- Mini UI mockup (tiny replica of the app using that theme's actual color tokens)
- Color swatches (Primary, Accent, BG, Card, Text, Border, Alert)
- Active pill badge + border highlight
Notifications (settings/notifications)
- Status card — Push (active/off), Realtime (connected/off), Unread count
- Push preferences — 4 toggle switches (Orders, Inventory, Team, Custom)
- Graceful fallbacks — info cards when push unavailable (Expo Go) or disabled (no permission)
- Mark all as read action
Login History (settings/login-history)
- Stats row — sign-ins, failed attempts, flagged, unique IPs (last 30 days)
- Filter pills — All / Success / Failed / Blocked
- Entry list — status dot, device icon, browser + platform, IP, failure reason, relative time
- Pagination — infinite scroll with pull-to-refresh
- API —
GET /login-historywith status/date filters
Roles & Permissions (settings/roles) — Admin Only
Requires team.manage_roles permission.
- Role list — cards with shield icon, name, permission count, system badge
- View mode — role details, permission groups (collapsible), edit/delete actions
- Edit/Create mode — name/description inputs, permission toggles per group with Select All, sensitive permission warnings
- Constraints — system roles (owner, admin, manager, support, viewer) are read-only
Activity Logs (settings/activity-logs) — Admin Only
Requires admin.audit_log permission.
- 3 tabs — All / Security / Team (each hits dedicated endpoint)
- Entry list — colored action icon, action label, user name, IP, relative time
- 21 action types mapped with distinct icons/colors
- Pagination — infinite scroll
Team (settings/team)
- Team member management (view, invite, suspend, remove)
- Role assignment
API Client Methods
Profile & Auth
| Method | Endpoint | Description |
|---|---|---|
updateProfile({name, email}) |
PATCH /profile |
Update name |
updatePassword({...}) |
POST /auth/change-password |
Change password (revokes other sessions) |
uploadAvatar(uri) |
POST /profile/avatar |
FormData image upload |
getSessions() |
GET /profile/sessions |
List active OAuth tokens |
revokeSession(id) |
DELETE /profile/sessions/{id} |
End specific session |
revokeAllSessions() |
DELETE /profile/sessions |
End all other sessions |
2FA
| Method | Endpoint |
|---|---|
get2FAStatus() |
GET /2fa/status |
enable2FA(password) |
POST /2fa/enable |
confirm2FA(code) |
POST /2fa/confirm |
disable2FA(password, code) |
POST /2fa/disable |
Login History
| Method | Endpoint |
|---|---|
getLoginHistory({status, per_page, page}) |
GET /login-history |
getLoginHistoryStats() |
GET /login-history/stats |
Activity Logs
| Method | Endpoint |
|---|---|
getActivityLogs({action, per_page, page}) |
GET /activity-logs |
getSecurityEvents({per_page, page}) |
GET /activity-logs/security |
getTeamEvents({per_page, page}) |
GET /activity-logs/team |
Roles & Permissions
| Method | Endpoint |
|---|---|
getRoles() |
GET /team/roles |
getRole(id) |
GET /team/roles/{id} |
createRole({name, description, permissions}) |
POST /team/roles |
updateRole(id, {...}) |
PUT /team/roles/{id} |
deleteRole(id) |
DELETE /team/roles/{id} |
getPermissions() |
GET /team/permissions |
Notifications
| Method | Endpoint |
|---|---|
getNotifications({...}) |
GET /notifications |
getUnreadNotificationCount() |
GET /notifications/unread-count |
markNotificationAsRead(id) |
PATCH /notifications/{id}/read |
markAllNotificationsAsRead() |
POST /notifications/mark-all-read |
Mobile-Specific
| Method | Endpoint | Description |
|---|---|---|
checkMobileModule() |
GET /mobile/check |
Public module check |
POST /mobile/devices |
Register device | FCM token + device info |
DELETE /mobile/devices/{token} |
Unregister device | Called on logout |
PATCH /mobile/devices/preferences |
Update push preferences | Per-type toggles |
POST /mobile/devices/heartbeat |
Device heartbeat | Periodic keep-alive |
GET /mobile/dashboard |
Aggregated dashboard | Overview + stats + orders |
Configuration
Backend (config/mobile-app.php)
return [
'push' => ['enabled' => env('MOBILE_PUSH_ENABLED', true)],
'fcm_service_account' => env('FCM_SERVICE_ACCOUNT_PATH', storage_path('app/firebase-service-account.json')),
'version_check' => ['enabled' => env('MOBILE_VERSION_CHECK_ENABLED', true)],
'dashboard_cache_ttl' => env('MOBILE_DASHBOARD_CACHE_TTL', 300),
'max_devices_per_user' => env('MOBILE_MAX_DEVICES_PER_USER', 5),
'cleanup_inactive_days' => env('MOBILE_CLEANUP_INACTIVE_DAYS', 90),
'default_tabs' => ['dashboard', 'orders', 'warehouse', 'settings'],
'default_quick_actions' => [...],
'available_mobile_tabs' => [...],
];
Mobile App (app/.env)
EXPO_PUBLIC_API_URL=http://your-api:5350/api/v1
EXPO_PUBLIC_REVERB_APP_KEY=your_reverb_key
EXPO_PUBLIC_REVERB_HOST=your-api-host
EXPO_PUBLIC_REVERB_PORT=5352
EXPO_PUBLIC_REVERB_SCHEME=http
Android Permissions (app.json)
{
"android": {
"permissions": ["RECORD_AUDIO", "READ_MEDIA_IMAGES", "android.permission.CAMERA"],
"googleServicesFile": "./google-services.json"
},
"ios": {
"infoPlist": {
"NSMicrophoneUsageDescription": "Commstate uses your microphone for the AI voice assistant.",
"NSPhotoLibraryUsageDescription": "Commstate needs access to your photos to update your profile picture."
}
}
}
Backend Services
| Service | Responsibility |
|---|---|
ExpoPushClient |
Push delivery — auto-routes Expo tokens vs FCM tokens, FCM v1 OAuth2 |
PushNotificationService |
Push orchestration, user targeting, preference filtering, stats |
SendPushOnNotification |
Event listener — bridges NotificationCreated → push automatically |
DeviceService |
Device registration, cleanup, max device enforcement |
MobileDashboardService |
Aggregated dashboard with caching |
NavigationService |
Dynamic tab bar and quick action configuration |
MobileScreenRegistry |
SDUI screen registration, definition serving, action execution |
AppVersionService |
Version CRUD, version comparison |
Building the App
Prerequisites
- Node.js 18+
- JDK 17 (Azul Zulu recommended:
brew install --cask zulu@17) - Android SDK (platform-tools, build-tools 35+, SDK platform 35+)
ANDROID_HOMEandJAVA_HOMEenvironment variables set
Development Build (Android)
cd app
bun install
bunx expo prebuild --platform android
bunx expo run:android # builds native + starts Metro
Development Build (iOS)
cd app
bun install
bunx expo prebuild --platform ios
bunx expo run:ios
Dev Server Only (after first build)
cd app
npx expo start --dev-client
Important Notes
- First build takes 10-15 minutes (downloads NDK, compiles native modules)
- Subsequent builds are fast (~30s incremental)
android/andios/directories are gitignored — regenerated vianpx expo prebuildgoogle-services.jsonmust be inapp/root (Expo copies it during prebuild)- Firebase service account JSON must be in backend
storage/app/(not committed — deploy secret)
Artisan Commands
| Command | Description |
|---|---|
mobile:cleanup-devices --days=90 |
Deactivate devices inactive for N days |
Deployment Checklist
- Firebase project created with Android app registered
google-services.jsoninapp/root- Firebase service account JSON in backend
storage/app/firebase-service-account.json MOBILE_PUSH_ENABLED=truein backend.env- Queue worker running (
php artisan horizonorqueue:work) for push job processing - Reverb WebSocket server running for real-time notifications
- Mobile App module enabled on tenant via admin panel