Tech stack and coding guidelines
Languages, frameworks, project structure and the coding rules both apps follow, taken from the Saymail mobile apps.
Updated: 18 September 2026
Incico v3 is built the way Saymail’s mobile apps are built: two fully native apps, one platform-agnostic specification, one visual design, no cross-platform framework. The differences from Saymail are only where Incico’s problem is different (a server-backed shared dataset instead of a local-first mail store).
Android
| Item | Choice |
|---|---|
| Language | Kotlin 2.4.20 (moved up from the spec’s 2.2.10: supabase-kt 3.8.0 ships Kotlin 2.4 metadata; KSP moved with it to 2.3.12, which no longer carries a Kotlin-version prefix) |
| UI | Jetpack Compose, Material 3, Compose BOM 2025.12.00 |
| Min / target SDK | 27 / 36 (Saymail’s values; confirmed, not revisited) |
| Architecture | MVVM with unidirectional state: XxxRoute.kt (nav entry and VM wiring), XxxScreen.kt (stateless composable), XxxViewModel.kt exposing StateFlow |
| DI | No framework. A hand-written AppContainer created in Application.onCreate, exposed via LocalAppContainer, with the 8-line appViewModel { } helper from Saymail. Kept small by grouping into AuthContainer, ProfileContainer, ContactsContainer, SyncContainer (Saymail’s single 1 366-line container is the anti-pattern to avoid). |
| Navigation | navigation-compose 2.8.5, four tab destinations plus pushed routes in one Routes object; TabletShell for two-pane |
| Backend client | supabase-kt 3.8.0 (Auth, PostgREST, Realtime modules) over Ktor 3.5.1 |
| Local cache | Room 2.7.1 with exported schemas, one entity per server table plus SyncCursor; destructive migration allowed only before 1.0 |
| Background work | WorkManager for periodic external-contact sync (15 min minimum, network-constrained) and for retrying failed uploads — arrives in phase 4; not built in phase 2 |
| Push | Firebase Cloud Messaging, initialised manually from build-time values — arrives in phase 4; not built in phase 2 |
| Images | Coil — arrives in phase 3; not built in phase 2 |
| Phone numbers | libphonenumber-android |
| Contacts | ContactsContract via a small DeviceContactsGateway |
| Google Contacts | People API over Ktor with tokens from AppAuth (PKCE, public client) |
| Sign-in | androidx.credentials 1.5.0 + googleid 1.1.1 for native Google sign-in via Credential Manager |
| Web pages | androidx.browser Custom Tabs for the FAQ, What’s new, Terms and Privacy (Settings → Help and About) |
| Licences | AboutLibraries Gradle plugin 15.2.0 (com.mikepenz.aboutlibraries.plugin.android) generates raw/aboutlibraries per variant; no AboutLibraries runtime or UI library, the app renders the list itself (A90) |
| Logging | One AppLog facade over logcat and the diagnostics log; android.util.Log is called nowhere else, and tools/log-check.mjs fails CI on a sensitive name in a log template (A88) |
| Analytics and crashes | A hand-written PostHog /batch client (Telemetry), no vendor SDK (A50); crash reports through the same client (A89) |
| Tests | JUnit 4, Truth, MockK, Turbine, Robolectric, Room testing, MockWebServer; Compose UI tests with semantics only |
iOS
| Item | Choice |
|---|---|
| Language | Swift 6 toolchain in Swift 5 language mode, SWIFT_STRICT_CONCURRENCY = complete with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor |
| UI | SwiftUI, iOS 17 minimum (deployment target 17.0; confirmed, not revisited) |
| Architecture | Feature models with @Observable, one per screen or flow, created by a small AppContainer injected through the environment. The single 114 KB Store that Saymail iOS grew into is explicitly not repeated: no type over 300 lines. |
| Navigation | TabView + NavigationStack per tab; SplitShell for two-pane on iPad |
| Backend client | supabase-swift 2.55.2 (the one third-party dependency, via Swift Package Manager, pinned) |
| Local cache | SQLite via SQLite3 in the document-store layout Saymail iOS uses (fixed indexed columns plus a JSON blob), WAL, one connection behind a serial queue |
| Background work | BGAppRefreshTask for external-contact sync — arrives in phase 4; not built in phase 2 |
| Push | APNs via UIApplicationDelegateAdaptor, one alert push per notification carrying content-available so the cache refreshes before the tap (A57) — arrives in phase 4; not built in phase 2 |
| Contacts | Contacts framework via DeviceContactsGateway |
| Google Contacts | People API over URLSession with tokens from ASWebAuthenticationSession (PKCE, public iOS client) |
| Phone numbers | PhoneNumberKit is avoided to keep dependencies minimal; a vendored subset of libphonenumber metadata for parsing and formatting, generated at build time, shared with Android’s expectations by golden tests |
| Tests | XCTest, protocol-based fakes, no mocking framework |
Shared
mobile/design/tokens.json(design tokens) andmobile/design/i18n/en.json+nl.json(76 canonical keys, typed into string tables on both platforms) are the contract, checked bytools/tokens-check.mjsandtools/i18n-diff.mjs; this documentation site is the specification itself, not a separateSPECIFICATION.mdfile.- Golden vectors shared from
docs/vectors/*.json(phone formatting, address formatting, snapshot hashing, circle-dot rendering order) start in phase 3; phase 2 has nothing to pin yet. - The same i18n key set, checked by
tools/i18n-diff.mjs. - The same screen names, test tags and analytics event names.
Coding guidelines (from Saymail’s docs/coding-guidelines.md, applied)
- Screaming architecture. Folders by feature and use case (
feature/contacts/invite/,feature/profile/addresses/), never by technical role. Shared code bubbles up one level only when two use cases need it. Incico applies the rule strictly, including for data access: afeature/contacts/ContactsRepository.ktlives with its feature, not in adata/folder. - Single responsibility, ~300-line files, ~50-line methods. Orchestration methods read like an outline of well-named private steps. A file over 300 lines is refactored, not split into partials/extensions.
- Create dependencies close to use. No constructing objects at the top of a method that might early-return.
- Error boundaries at the edge. Calls to Supabase, Google or the OS address book are wrapped once at the boundary; internal methods throw. Errors are typed:
Invalid(message)surfaced verbatim,NotFound(entity id),Network,Unauthorized(forces re-login),Server(redacted, logged). No swallowed results;runCatching/try?without logging is a review failure. - Untrusted input at every boundary. Trim once, reject empty and whitespace-only, cap lengths, validate lists, before binding. The server re-validates with constraints.
- No dead code, no commented-out code, orphan functions deleted rather than kept.
- Secrets only in the secure store, identifiers may be constants; one
AppConfigreads build values. - Document the decision in the code when a choice is non-obvious, with the date and the spec section.
- Tests: Arrange-Act-Assert with comments,
Action_WithCondition_ShouldResultnaming, one test class per source class, both success and failure paths, builders in aBuilders/folder beside the tests, test files under 400 lines. - Database rules for the local cache: WAL,
busy_timeout, never touch the database from UI code, tight transactions, FTS5 for contact search, noLIKEon JSON.
Website
Astro 7, Tailwind v4 tokens, MDX docs collection, hand-rolled en/nl i18n, Netlify. See the site’s own README in web/.
Repository tooling
| Tool | Use |
|---|---|
ktlint + detekt | Kotlin style and complexity (file length, method length rules set to the guideline values) |
SwiftFormat + SwiftLint | Swift equivalents with file_length: 300, function_body_length: 50 |
gitleaks | pre-commit and CI |
tools/i18n-diff.mjs, tools/vectors-check.mjs | parity checks |
| PlantUML in Docker | architecture diagrams under docs/architecture/*.puml, ASCII-only |