The Android twin of the iOS app plan: a comprehensive development
plan for the native Android companion to the MOS Operations Platform, covering every current and future
platform feature (Phases 1–5). Governed end-to-end by the
Android App Constitution: Kotlin + Jetpack Compose, strict MVVM with
StateFlow<UiState>, SOLID, Material Design 3, Android/Jetpack-native only — no third-party
dependencies.
MOS Field for Android is the same product as the iOS app — same roles, same phases, same backend contract — expressed natively in Kotlin, Jetpack Compose, and Material Design 3. It does not chase pixel-parity with iOS; it chases behavior parity: a field crew member on a Pixel and one on an iPhone can run the same day the same way. Everything platform-shaped (navigation, theming, storage, background work) follows Android idiom.
iOS Phases A–D and Android Phases A–D carry identical feature scope. Neither platform ships a phase feature the other can't; the shared FastAPI contract enforces this naturally.
Material 3 components, dynamic color, predictive back gesture, Snackbars (never Toasts), 48dp touch targets, TalkBack — the app must feel like it was born on Android, not ported.
Crews often carry rugged or mid-range Android hardware. minSdk 29 (Android 10) covers the realistic fleet; R8-shrunk builds and Baseline Profiles keep cold starts fast on weak devices.
WindowSizeClass-adaptive layouts from day one — a truck-mounted tablet gets a two-pane
jobs/detail layout for free instead of a stretched phone UI.
Assigned → In Progress → Waiting → Completed), and endpoint catalog are shared —
defined once in the FastAPI OpenAPI schema.Every pillar of the Android constitution, mapped to the concrete decision in this plan. As on iOS, exactly one deviation exists and it is explicitly authorized by the product owner — the same deviation, for the same reason.
| Constitutional pillar | Decision in this plan | Status |
|---|---|---|
| Stack — Kotlin, Compose, Single Activity | Kotlin (latest stable), Jetpack Compose UI, one MainActivity, all navigation via Navigation Compose in a single NavHost (AppNavigation.kt). minSdk 29, targetSdk latest. | Compliant |
| Dependency rule — no third-party libraries | Allowed surface = Android SDK + AndroidX/Jetpack + kotlinx, exactly the libraries the constitution itself mandates (Navigation Compose, Room, androidx.biometric, androidx.security EncryptedSharedPreferences, kotlinx.serialization, DataStore, WorkManager, Glance). Forbidden = everything else: no Retrofit/OkHttp, no Hilt/Koin, no Glide/Coil, no Supabase Kotlin SDK. Networking is hand-rolled on HttpsURLConnection. | Compliant |
| MVVM — UI / ViewModel / Repository / Model | One ViewModel per screen exposing a single immutable StateFlow<FeatureUiState>; SharedFlow for one-shot events; Composables are logic-free and collect via collectAsStateWithLifecycle(); all I/O behind interface-typed Repositories (§03). | Compliant |
| SOLID | Interface-first (AuthRepositoryInterface, NetworkServiceInterface, JobReadable/JobWritable…), constructor injection via a hand-written AppContainer (no Hilt — manual DI is the no-dependency answer), small role-scoped interfaces (§03). | Compliant |
| Authentication — Google Sign-In primary | Deviation (owner-authorized, identical to iOS): primary auth is email/password against the existing Supabase employee accounts, because roles are admin-assigned to company identities and must match web + iOS. Biometric re-entry via androidx.biometric is kept exactly as mandated. Google Sign-In via Credential Manager can be layered on later as a linked provider. | Authorized deviation |
| Encrypted storage only | Access + refresh tokens in EncryptedSharedPreferences (MasterKey, AES256_GCM) behind a typed EncryptedPreferencesManager. Nothing sensitive in plain SharedPreferences, DataStore, or rememberSaveable. | Compliant |
| Networking — HttpsURLConnection, typed | Interface-driven layer returning NetworkResult<T>; kotlinx.serialization with explicit @SerialName; cert pinning via network_security_config.xml; usesCleartextTraffic="false"; all calls suspend on Dispatchers.IO (§05). | Compliant |
| Persistence — Room + encrypted prefs + DataStore | Room @Entity/@Dao/single @Database behind repositories; DAOs are suspend or return Flow; offline write-queue drained by WorkManager; DataStore for benign preferences (§06). | Compliant |
| Material Design 3 | M3 components exclusively; Theme.kt/Color.kt/Typography.kt/Shape.kt; dynamic color on Android 12+ with a static MOS brand scheme as fallback; light + dark tested; sp-scaled type; 48dp targets; TalkBack + WCAG 2.1 AA contrast (§07). | Compliant |
| Coroutines | suspend/Flow everywhere; viewModelScope only; Dispatchers.IO for I/O; no GlobalScope, no callbacks in new code. | Compliant |
| Release hardening | FLAG_SECURE in release, allowBackup="false", R8 full mode with keep rules for serialization models + Keystore classes, PII-stripping Logger behind BuildConfig.DEBUG (§09). | Compliant |
| Testing — JUnit + coroutines-test, no mocking frameworks | Interface-based fakes for every injected dependency; >80% business-logic coverage; Compose UI tests for sign-in, jobs, photo flows; deterministic dispatchers (§10). | Compliant |
firebase-messaging (Google-published) is treated as sanctioned platform
infrastructure, not a third-party dependency. It is the only such addition, and it is isolated behind
PushRegistrationService so the rest of the app never sees Firebase types.Strict MVVM with a core/repository spine, instantiated with MOS feature names under
com.mikeodellsurveys.mosfield. Phase A packages are created at kickoff; later-phase packages only
when their phase begins (no speculative scaffolding, constitution §4).
/app/src/main/java/com/mikeodellsurveys/mosfield/
/app
MainActivity.kt ← single Activity; FLAG_SECURE in release
AppNavigation.kt ← one NavHost; routes as a sealed class
AppContainer.kt ← manual constructor-injection graph (no Hilt)
/features
/auth /ui /viewmodel /model
/jobs /ui /viewmodel /model ← list, detail, status updates
/notes /ui /viewmodel /model
/documents /ui /viewmodel /model
/photos /ui /viewmodel /model ← Phase A
/fieldreports /ui /viewmodel /model ← Phase A
/timetracking /ui /viewmodel /model ← Phase B
/mileage /ui /viewmodel /model ← Phase B
/equipment /ui /viewmodel /model ← Phase B
/dashboard /ui /viewmodel /model ← Phase C (owner/PM)
/search /ui /viewmodel /model ← Phase C
/voicereports /ui /viewmodel /model ← Phase D
/core
/network NetworkService.kt · ApiEndpoints.kt · NetworkResult.kt · CertPinnedConnectionFactory.kt
/repository JobRepository.kt · NoteRepository.kt · DocumentRepository.kt
TimeEntryRepository.kt · UploadQueueRepository.kt
/database AppDatabase.kt · JobDao.kt · NoteDao.kt · UploadQueueDao.kt
/security KeystoreManager.kt · BiometricManager.kt · EncryptedPreferencesManager.kt
/auth AuthManager.kt ← GoTrue REST client + token refresh mutex
/sync SyncWorker.kt · PhotoUploadWorker.kt ← WorkManager
/shared
/components PrimaryButton.kt · InputField.kt · LoadingIndicator.kt
StatusPill.kt · RoleBadge.kt · DataStateContent.kt
/extensions ContextExtensions.kt · StringExtensions.kt
/constants AppConstants.kt · ApiConstants.kt
/utils Logger.kt · Validator.kt
/theme
Theme.kt · Color.kt · Typography.kt · Shape.kt
StatusPill, RoleBadge,
and DataStateContent (loading/empty/error/populated) mirror the primitives shipped in
apps/web and planned for iOS. MOS brand colors live in Color.kt feeding the static
M3 color scheme; dynamic color is offered on Android 12+ per the constitution, with the brand scheme as the
out-of-box default so the app still reads as MOS.| Layer | Rules applied |
|---|---|
| Model | @Serializable data classes with explicit @SerialName mirroring API schemas (Job, JobNote, UserProfile, RoleKey enum). No Compose imports. Domain rules (allowed status transitions) as pure functions on the model. |
| ViewModel | One per screen; private MutableStateFlow, public immutable StateFlow<UiState>; SharedFlow for navigation/snackbar events; depends only on interfaces via constructor; launches in viewModelScope; never touches HttpsURLConnection or Room directly. |
| UI (Composables) | Scaffold-rooted screens; collectAsStateWithLifecycle(); zero business logic; errors via M3 Snackbar; every interactive element has contentDescription/semantics and a 48dp minimum target. |
| Repository / Service | One data domain each; interfaces split by consumer need (JobReadable vs JobWritable); combine Room + network; expose Flow for streams, suspend for one-shots. |
// The UiState + DI pattern used by every screen in the app data class JobListUiState( val isLoading: Boolean = false, val jobs: List<Job> = emptyList(), val pendingSyncCount: Int = 0, val errorMessage: String? = null ) class JobListViewModel( private val jobRepository: JobReadable ) : ViewModel() { private val _uiState = MutableStateFlow(JobListUiState()) val uiState: StateFlow<JobListUiState> = _uiState.asStateFlow() fun loadAssignedJobs() = viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } when (val result = jobRepository.assignedJobs()) { is NetworkResult.Success -> _uiState.update { it.copy(isLoading = false, jobs = result.data) } is NetworkResult.Error -> _uiState.update { it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) } } } }
Identical auth contract to iOS: Supabase Auth (GoTrue) consumed as plain HTTPS REST — no
Supabase Kotlin SDK — so the whole stack is HttpsURLConnection + EncryptedSharedPreferences +
BiometricPrompt, all inside the constitution's allowed surface.
| Step | Mechanism |
|---|---|
| 1 · Sign in | POST /auth/v1/token?grant_type=password on the Supabase project URL with the publishable (anon) key header. Response decoded via kotlinx.serialization into a typed SessionTokens model. |
| 2 · Store | Access + refresh tokens → EncryptedPreferencesManager (MasterKey AES256_GCM, Android Keystore-backed). The anon key is a publishable client identifier; real secrets never ship in the APK. |
| 3 · Call the API | Every FastAPI request carries Authorization: Bearer <access_token>; the backend verifies the ES256 signature via JWKS — no shared secret on any client. |
| 4 · Refresh | AuthManager refreshes proactively before expiry and reactively on 401, serialized behind a Mutex so concurrent requests trigger exactly one refresh. |
| 5 · Re-entry | On cold start/foreground with a stored session: BiometricPrompt (class 3 biometrics, device-credential fallback) gates access before tokens are read. Sign-out calls clearToken() and wipes the Room cache. |
| 6 · Roles | GET /api/me + GET /api/me/roles (already live) hydrate UserProfile and List<RoleKey>; roles select the NavigationBar destinations. Client checks are UX only — FastAPI re-enforces RBAC from the database on every request. |
AppNavigation.kt; start destination chosen by session state: SignedOut → login, Locked → biometricGate, Active(roles) → home(roles). Predictive back gesture enabled.One interface-driven layer on HttpsURLConnection, exactly per constitution §7 —
the constitution's own contracts, extended with the MOS endpoint catalog and error mapping.
interface NetworkServiceInterface { suspend fun <T> request(endpoint: Endpoint, deserializer: DeserializationStrategy<T>): NetworkResult<T> } data class Endpoint( val path: String, val method: HttpMethod, // GET · POST · PUT · DELETE · PATCH val headers: Map<String, String> = emptyMap(), val body: String? = null ) sealed class NetworkResult<out T> { data class Success<T>(val data: T) : NetworkResult<T>() data class Error(val error: AppError) : NetworkResult<Nothing>() } sealed class AppError { object NetworkUnavailable : AppError() object Unauthorized : AppError() object Forbidden : AppError() // role gate — server said no object DecodingFailed : AppError() data class ValidationFailed(val message: String) : AppError() data class HttpError(val code: Int, val message: String) : AppError() object SyncConflict : AppError() }
android:usesCleartextTraffic="false"; connections created through a single factory that rejects non-TLS URLs.network_security_config.xml pins the production API host's public keys with an expiry and backup pin; DEBUG builds get a separate config permitting localhost only.kotlinx.serialization with explicit @SerialName (snake_case API → camelCase Kotlin); raw String/JSONObject never reaches a ViewModel; Any is banned.Unauthorized; 403 → Forbidden; 422 → ValidationFailed; 5xx → HttpError; IOException → NetworkUnavailable.suspend on Dispatchers.IO; no callbacks; connection/read timeouts tuned for rural LTE.ApiConstants per build type: DEBUG → local FastAPI; RELEASE → the production API domain. No keys in source; R8 keep rules cover serialization models.Same product decision as iOS — the local store is the read source of truth and writes queue when offline — implemented the Android way: Room for domain data, WorkManager for guaranteed background sync.
| @Entity | Purpose | Sync behavior |
|---|---|---|
JobEntity | Assigned jobs cache: number, client, site, status, due, crew | Pull-refresh + on-launch delta fetch; server wins on conflict; DAO exposes Flow<List<JobEntity>> |
JobNoteEntity | Notes authored on device | Queued write; pending → synced flag |
PhotoUploadEntity | Captured photos + job link + GPS + timestamp | PhotoUploadWorker (WorkManager, network-constrained, backoff) — survives process death and reboot |
DailyReportEntity | Field report drafts | Draft locally; explicit submit; queued if offline |
TimeEntryEntity | Clock in/out pairs (Phase B) | Timestamped at tap-time; reconciled on sync |
MileageEntity | Trip logs (Phase B) | Queued write |
BGAppRefreshTask and is strictly stronger. Failures retry with exponential backoff; a quiet "pending sync" badge surfaces state, never a blocking dialog.PhotoUploadWorker against a presigned URL — multi-MB site photos finish even if the crew pockets the phone.Identical feature scope to the iOS phases, with each feature naming its MVVM units and the Android/Jetpack-native API that satisfies the dependency rule. Later phases are planned here but scaffolded only when they begin.
A field crew member can run their whole day from the phone: see jobs, get to site, capture evidence, file the daily report.
| Feature | MVVM units | Android APIs |
|---|---|---|
| Sign in + biometric re-entry | LoginScreen/ViewModel, BiometricGateScreen, AuthManager, EncryptedPreferencesManager | androidx.biometric, androidx.security, Android Keystore |
| My Jobs list + detail | JobListScreen/ViewModel, JobDetailScreen/ViewModel, JobRepository | Compose, Room, Navigation Compose |
| Status updates (Assigned → In Progress → Waiting → Completed) | Transition rules in Job model; JobDetailViewModel.updateStatus() | — (pure domain logic) |
| Job notes | NoteComposer composable + NoteRepository | Compose |
| Document viewing | DocumentListScreen/ViewModel, DocumentRepository | PdfRenderer, platform viewers via SAF |
| Photo capture + upload (GPS + timestamp stamped) | PhotoCaptureScreen/ViewModel, UploadQueueRepository, PhotoUploadWorker | CameraX (Jetpack), WorkManager, location APIs |
| Site maps + navigation hand-off | JobMapSection in job detail; deep-link out to the device's maps app for turn-by-turn | Location APIs + geo-intent hand-off (no bundled map SDK — keeps the dependency rule intact) |
| Daily field reports | FieldReportScreen/ViewModel, DailyReportEntity | Compose, Room |
| Push notifications (assignment, status, due-date) | PushRegistrationService; notification tap deep-links into job detail via NavHost | FCM (sanctioned, isolated), UserNotifications-equivalent NotificationManager channels |
The phone becomes the timesheet and the equipment ledger.
| Feature | MVVM units | Android APIs |
|---|---|---|
| Clock in/out with arrive/leave reminders at the site | TimeClockScreen/ViewModel, TimeEntryRepository, LocationService | Geofencing / location APIs, Room, WorkManager |
| Mileage tracking (start/stop trip, odometer entry) | MileageScreen/ViewModel, MileageEntity | Location APIs |
| Equipment checkout/return (GPS units, total stations) | EquipmentScreen/ViewModel, EquipmentRepository | Compose; CameraX barcode capture if assets are tagged |
| Payroll-ready summaries (read-only week view) | TimesheetSummaryScreen/ViewModel | Compose |
Owner and office roles get real value beyond the web parity baseline.
| Feature | MVVM units | Android APIs |
|---|---|---|
| Executive dashboard (active jobs, revenue, utilization, AR) | DashboardScreen/ViewModel | Compose Canvas charts (no chart library exists in the allowed surface — bar/line/donut composables are built once in /shared/components) |
| Reports (rendered server-side, viewed natively) | ReportListScreen/ViewModel | PdfRenderer |
| Historical search across jobs/clients/docs/notes | SearchScreen/ViewModel, debounced Flow query to backend | Compose |
| Office scheduling adjustments | ScheduleScreen/ViewModel | Compose; WindowSizeClass two-pane on tablets |
| Home-screen at-a-glance (jobs due today / crew status) | Glance widget | androidx.glance (Jetpack) |
Heavy AI runs on the backend; the app contributes what only a phone can — voice, camera, and context.
| Feature | MVVM units | Android APIs |
|---|---|---|
| Voice-to-report — dictate the daily report hands-free; on-device transcription, then backend structuring | VoiceReportScreen/ViewModel, SpeechTranscriptionService | android.speech.SpeechRecognizer (on-device where supported) |
| Document summaries — AI summaries of title commitments/deeds on job detail | Summary section in JobDetailViewModel | — (backend-computed) |
| Intelligent search — natural-language queries against the backend AI search | Extends SearchViewModel | Compose |
| Smart capture — document scanning with perspective correction for field paperwork | DocScanScreen | CameraX + backend correction; ML Kit doc scanner considered only if the owner sanctions it as Google-service infrastructure (like FCM) |
Nearly identical to the iOS list — by design. The one Android-specific addition is FCM delivery alongside APNs in the push service.
| Needed by | Backend addition | Notes |
|---|---|---|
| Phase A | Jobs CRUD + status transition endpoints (/api/jobs…) | Day-5 web scope; shared as-is |
| Phase A | Notes + documents endpoints; presigned upload/download URLs (Supabase Storage brokered by FastAPI) | App never holds storage credentials — short-lived signed URLs only |
| Phase A | Device-token registration (POST /api/devices with a platform field) + push fan-out service speaking both APNs and FCM | One registration endpoint serves both mobile apps |
| Phase B | Time-entry, mileage, and equipment endpoints | Same tables feed payroll export on the web |
| Phase C | Dashboard aggregates + search endpoint | Server-computed; app only renders |
| Phase D | AI endpoints: report structuring from transcript, doc summaries, NL search | Platform Phase 5 scope |
@Serializable models and Swift Codable structs are both
written against that schema. One source of truth, three clients — divergence is impossible by construction.EncryptedPreferencesManager (saveToken/getToken/clearToken, SecureKey enum) over EncryptedSharedPreferences with MasterKey AES256_GCM; tokens never in plain SharedPreferences, DataStore, rememberSaveable, or logs.BiometricPrompt on re-entry with device-credential fallback; sign-out clears encrypted prefs and the Room cache.android:usesCleartextTraffic="false"; every connection built by one TLS-only factory.network_security_config.xml pins the production API host (primary + backup pin, expiry set); DEBUG config permits localhost only and never ships.@SerialName on every model; no JSONObject, no Any, no raw String past the network layer.FLAG_SECURE on the MainActivity window in release builds (job sites, client addresses, and financials stay out of the app switcher).android:allowBackup="false"; nothing sensitive leaves the device via device backup.Logger that redacts emails/tokens/coordinates in release; diagnostics behind BuildConfig.DEBUG.Validator rules (lengths, formats) applied before values reach ViewModels or the wire.require_roles()).kotlinx-coroutines-test, UnconfinedTestDispatcher); interface-based fakes, no mocking frameworks (FakeAuthRepository : AuthRepositoryInterface…); >80% coverage on business logic.ui-test-junit4) for the critical flows: sign-in + biometric gate, job list → detail → status update, photo capture → queued upload, daily report submit.Flow-driven UI + pull-to-refresh; DataStateContent states everywhere.AppError handling, contentDescription/semantics everywhere, light + dark tested, font scaling,
no !!, ViewModel/Repository tests, strings.xml, no dead code, FLAG_SECURE in release, R8 rules
verified.Android App Constitution — the governing document for all Android work on this project, reproduced as supplied.
/app/src/main/java/com/[company]/[appname]/ — /app (MainActivity.kt — single Activity entry
point; AppNavigation.kt — NavHost and route definitions) · /features/[featurename]/{ui: FeatureScreen.kt,
FeatureComponents.kt · viewmodel: FeatureViewModel.kt, FeatureUiState.kt · model: FeatureModel.kt} ·
/core/{network: NetworkService.kt, ApiEndpoints.kt, NetworkResult.kt · repository: UserRepository.kt ·
database: AppDatabase.kt, UserDao.kt · security: KeystoreManager.kt, BiometricManager.kt,
EncryptedPreferencesManager.kt · auth: AuthManager.kt} · /shared/{components: PrimaryButton.kt, InputField.kt,
LoadingIndicator.kt · extensions: ContextExtensions.kt, StringExtensions.kt · constants: AppConstants.kt,
ApiConstants.kt · utils: Logger.kt, Validator.kt} · /theme (Theme.kt, Color.kt, Typography.kt, Shape.kt)
TODO() or throw NotImplementedError() in production implementations.android:usesCleartextTraffic="false". Certificate pinning via network_security_config.xml for production endpoints. Validate all server responses with type-safe decoding. kotlinx.serialization with explicit @SerialName — never Any or untyped maps.android:allowBackup="false" unless backup is explicitly required and encrypted. Never log sensitive data — custom Logger strips PII in release. BuildConfig.DEBUG guards on diagnostics. FLAG_SECURE on the MainActivity window in non-debug builds.Only HttpsURLConnection from the Android SDK, structured through a typed, interface-driven service layer:
NetworkServiceInterface.request(endpoint, deserializer): NetworkResult<T>;
Endpoint(path, method, headers, body); HttpMethod { GET, POST, PUT, DELETE, PATCH };
NetworkResult = Success(data) | Error(AppError). All calls are suspend functions (viewModelScope,
Dispatchers.IO). No callbacks in new code. All HTTP error codes mapped explicitly to typed AppError cases.
Responses decoded into typed Kotlin models immediately — never raw String or JSONObject to the ViewModel.
All network operations on Dispatchers.IO, never the main thread.
StateFlow<UiState> — primary mechanism for exposing screen state from ViewModel to UIMutableStateFlow — private to ViewModel; only the immutable StateFlow is exposedSharedFlow — one-time events (navigation, snackbars) not replayed on recompositionrememberSaveable — local UI state that must survive configuration changesremember — local UI state that does not need to survive configuration changescollectAsStateWithLifecycle() — never lifecycle-unaware collectAsState()Typed AppError sealed class (NetworkUnavailable, Unauthorized, DecodingFailed, ValidationFailed(message),
HttpError(code, message)) with toUserMessage(). Every suspending function returns
NetworkResult<T> or propagates typed exceptions — no empty catch blocks. ViewModels expose errorMessage
within the UiState data class. Errors shown via Material 3 Snackbar (SnackbarHostState) — never Toast.
suspend functions and Flow for all async operations. Launch from viewModelScope — never from composables directly. Dispatchers.IO for network/database; Dispatchers.Default for CPU-intensive work. Never GlobalScope. withContext(Dispatchers.Main) only when explicitly needed. viewModelScope cancels automatically on onCleared(). Flow for streams (Room, continuous sources); suspend for one-shots.
Room as primary local persistence. EncryptedSharedPreferences for all sensitive persistent data. Preferences DataStore for non-sensitive preferences — never SharedPreferences directly. Clear @Entity data classes, @Dao interfaces, a single @Database class. Never perform database operations in a ViewModel or Composable — route through the Repository layer. All DAO methods suspend or returning Flow<T>.
Unit tests for every ViewModel and Repository (JUnit 4/5 + kotlinx-coroutines-test). Interface-based fakes/mocks — no mocking frameworks. >80% coverage on business logic. Compose UI tests (ui-test-junit4) for critical flows. No production code ships with failing tests. TestCoroutineDispatcher / UnconfinedTestDispatcher for deterministic coroutine testing.
Kotlin coding conventions — clear, expressive names. Default to private. data class for state/models,
immutable via val. sealed class/interface for exhaustive state — always exhaustive when. No !! —
use ?.let, ?: return, or requireNotNull() with a message. when over
if-else chains. Extension functions for reusable utilities. object for singletons. Standard file header
comment (filename, project, creation date) in every file.
This constitution governs all Android development in this project. When in doubt, choose the simpler, more secure, more native solution.