After nearly a decade of building Android apps professionally, I found myself wanting to do something I rarely get to do at work: build something from scratch, end-to-end, with zero compromises. No legacy code to respect. No sprint pressure. No "we'll refactor that later."
So I asked Claude to challenge me. I gave it a brief: design a senior-level Android engineering assignment — 8+ years experience, must cover real-world production concerns, no shortcuts. It came back with a 968-line spec covering 15 screens, 10 deep technical challenges, a full REST + WebSocket API contract, a 13-module architecture, performance benchmarks, security requirements, and a CI/CD pipeline. Then I committed to building every bit of it.
The App: Nexus
Nexus is a real-time collaborative project management app. Think Linear meets Notion, built natively for Android. The data model is a four-level hierarchy:
Workspaces → Projects → Boards → Tasks
Multiple users collaborate in real time. When a teammate moves a task on their phone, it reflects on yours immediately — no pull-to-refresh, no polling. The app works fully offline and syncs when connectivity returns, without the user doing anything.
That description sounds simple. The implementation is not.
What I Already Have
Before writing any Android code I set up the infrastructure to build against a real backend from day one. Mock APIs are a shortcut that creates problems later — offline sync, WebSocket reconnection, token refresh — all of these need a real server to test correctly.
The backend is a Node.js + TypeScript + Express + Prisma service, Dockerized and deployed on system. It exposes a full REST API and a WebSocket server for real-time events. Auth is JWT with refresh token rotation. WebSocket clients subscribe to project channels and receive pushed events when any team member makes a change.
Getting it deployed was more eventful than expected. Four consecutive Docker build failures hit four different layers: npm ci with no lockfile, a TypeScript type regression in @types/jsonwebtoken v9.0.6 that tightened expiresIn from string to a branded StringValue type, a missing OpenSSL binary in Alpine Linux, and a Prisma binary target mismatch between the build stage and the runtime container. That's probably its own post.
The Android project is a fresh Gradle project (in.singhangad.nexus) with the module skeleton in place. The structure is designed before any feature code exists — because at this level, architecture is not something you refactor into.
The Architecture
The project uses 13 Gradle modules with strict unidirectional dependency flow: feature modules depend on core modules, never on each other.
:app
├── :build-logic (convention plugins — no buildSrc)
├── :core
│ ├── :core:common Kotlin extensions, Result<T>, dispatchers
│ ├── :core:network Retrofit, OkHttp, WebSocket client, auth interceptor
│ ├── :core:database Room DB, all DAOs, entities, migrations
│ ├── :core:datastore Proto DataStore — UserPreferences, SessionData
│ ├── :core:ui Design system, Material 3 tokens, shared composables
│ └── :core:testing Fakes, fixtures, test dispatchers, shared rules
└── :feature
├── :feature:auth
├── :feature:workspace
├── :feature:board
├── :feature:task-detail
├── :feature:search
├── :feature:notifications
├── :feature:analytics
└── :feature:profile
The pattern is Clean Architecture + MVVM with Unidirectional Data Flow. A few hard constraints I've set upfront:
- ViewModels import nothing from
android.*exceptViewModelandSavedStateHandle - Use cases have exactly one public
operator fun invoke()— no utility methods on the side - Repository implementations own the caching strategy; callers never decide network vs. cache
- No business logic inside
@Composablefunctions — only state consumption and event emission
These aren't rules I'm following because the documentation says to. Each one exists because I've seen the alternative fail in a production codebase.
The UI state model is explicit and exhaustive. Every screen defines a typed state object:
data class BoardUiState(
val columns: ImmutableList<BoardColumn> = persistentListOf(),
val isLoading: Boolean = false,
val error: UiError? = null,
val isOffline: Boolean = false,
val syncPending: Int = 0
)
One-time effects — navigation events, snackbars — go through Channel<UiEffect> exposed as Flow<UiEffect>, never StateFlow. The distinction matters: StateFlow replays its last value to new collectors, which causes snackbars to reappear after configuration changes.
The data flow is single-direction and Room is the only source of truth the UI ever touches:
Network response
│
▼
Repository ──► upsert ──► Room
│
▼
DAO Flow<List<T>>
│
▼
ViewModel StateFlow
│
▼
Compose UI
The UI never sees a network response directly.
The 10 Technical Challenges
There are 15 screens to build, but the screens are not the hard part. These are:
1. Offline-First Sync
The UI always reads from Room. The network is write-through only. A ConnectivityObserver wraps NetworkCallback in a Flow<NetworkStatus> that every screen observes. Writes go to Room immediately with SyncStatus.PENDING, then enqueue a SyncWorker if the API call fails or the device is offline. The worker processes a sync_queue table in order, with exponential backoff and a max retry limit after which the entry is marked FAILED and the user is notified. Conflict resolution is server-wins: if the server returns a newer updatedAt than the locally-pending version, the server version applies and a snackbar informs the user.
2. WebSocket Real-Time Engine
A singleton WebSocketManager (Hilt @SingletonComponent) manages one authenticated OkHttp WebSocket connection. Reconnection uses exponential backoff — 1s → 2s → 4s → … → 64s cap — resetting after a successful ping/pong. Screens subscribe on entry and unsubscribe on onStop. Incoming events are parsed into a WebSocketEvent sealed class and dispatched through a MutableSharedFlow that repositories collect to upsert into Room. The UI updates automatically through the DAO Flow chain — no repository method is called from the UI layer in response to WebSocket events.
3. Optimistic UI with Rollback
Task moves and comment posts must feel instant. The flow: write to Room with SyncStatus.OPTIMISTIC, hold a pre-action snapshot in memory, fire the API call async. On success, update to SyncStatus.SYNCED. On failure, restore the snapshot in Room and surface an error snackbar. No spinners on primary actions.
4. Drag-and-Drop Kanban in Compose
The board is a LazyRow of LazyColumns. A DragDropState tracks the dragged item's offset via Modifier.pointerInput. The floating shadow copy renders in a Box with elevated zIndex. The drag offset is driven by MutableState<Offset> updated inside the pointerInput coroutine — not recomposition state — which is the key to hitting 60 fps during a drag. On drop, hit-test the composable bounds to determine target column and insertion index, then write the move optimistically.
5. Paging 3 with RemoteMediator
Task lists and search results use RemoteMediator backed by Room, with a remote_keys table keyed by (entity, boardId). All three LoadType cases — REFRESH, PREPEND, APPEND — are handled. REFRESH clears the existing page data for the board before fetching. Three distinct UI states: shimmer skeleton for initial load, a footer spinner for append, and a full-screen error with retry for failure.
6. Rich Text Editor
Task descriptions support bold, italic, strikethrough, inline code, ordered lists, and unordered lists — built on BasicTextField with AnnotatedString and a floating toolbar. The interesting constraint: correctly toggling styles on a selected range that partially overlaps an existing span. Persisted as a JSON document; rendered in read-only mode by mapping the schema back to AnnotatedString spans.
7. FCM in All App States
Data-only FCM payloads, with FirebaseMessagingService handling all display logic. Three app states with different behaviour: foreground shows an in-app slide-in overlay composable, background and killed show a system notification that deep-links to the correct task. Due-date reminders are scheduled with WorkManager OneTimeWorkRequest and setInitialDelay. Deep-link URI pattern: nexus://task/{taskId}, registered in both the manifest and the Navigation Compose graph.
8. Biometric + Encrypted Session
Auth tokens live exclusively in EncryptedSharedPreferences backed by Android Keystore — never plain SharedPreferences, never DataStore, never a log. After the app is backgrounded for more than 5 minutes, a BiometricPrompt blocks access on resume. While the prompt is pending, a full-screen composable obscures the content so the recents thumbnail is also protected. FLAG_SECURE is set on MainActivity.window in production builds.
9. Startup Performance
Measurable targets enforced by a :benchmark Gradle module: cold start < 500ms (MacrobenchmarkRule + StartupTimingMetric), board render for 50 tasks < 300ms, list fling frame time < 16ms at p95. Baseline Profiles generated by BaselineProfileRule and committed to :app/src/main/. Heavy SDK initialisation (Firebase, analytics) moved off the main thread using App Startup library.
10. Security Hardening
CertificatePinner on OkHttpClient with the server's SHA-256 leaf certificate pin. network_security_config.xml blocks all cleartext and mirrors the pin. R8 full mode in release with proguard-rules.pro that correctly preserves Retrofit interfaces, kotlinx.serialization models, Hilt components, and Room entities — the release build must not crash. StrictMode.detectAll() in debug with zero violations at runtime.
What This Series Will Cover
Each of the ten challenges above will get its own post — not a tutorial, but an engineering decision log. Why I made a specific trade-off. Where the naive approach fails under load or edge cases. What I'd do differently with more time.
The backend is live. The module skeleton is in place. The build is green.
Time to write some Android code.