Skip to content
xscriptor-ai

mobile

Mobile platform reference pack (2026): cross-platform framework selection (native, React Native/Expo, Flutter, Kotlin Multiplatform, MAUI), iOS (Swift 6 concurrency, SwiftUI, SwiftData) and Android (Kotlin 2, Jetpack Compose) architecture, offline-first data and sync engines with conflict resolution, startup/jank/memory performance, mobile security against OWASP MASVS, and release engineering across App Store and Google Play with fastlane/EAS and CI/CD. Use when choosing a mobile stack, designing or reviewing an iOS or Android app, implementing offline sync or background work, debugging startup time, jank, memory, or app size, hardening secure storage, TLS, and secrets, handling permissions and privacy, or shipping builds through store review, staged rollouts, and crash reporting.

seniorv2.0.0senior/mobile
View source on GitHub

Mobile

Reference pack for building, reviewing, and shipping mobile applications in 2026. It covers the whole delivery path: how to pick a stack, how to architect iOS and Android apps, how data survives a bad network and a backgrounded process, how the app stays fast and secure on mid-range hardware, and how a build reaches a store without breaking.

The pack is framework-aware but not framework-locked: Swift/SwiftUI, Kotlin/Compose, React Native with Expo, Flutter, and Kotlin Multiplatform appear side by side. Treat named libraries as examples of a category. Version numbers are floors or ranges; when an API, limit, or store policy matters, verify upstream.

Non-Negotiable Core Rules

  1. Pick the stack once, explicitly, against a written decision matrix. Re-litigating frameworks mid-project is the most expensive mobile mistake. Record the choice, the constraints, and the exit cost in an ADR.
  2. Design for the device, not the simulator. Mid-range Android, small batteries, spotty networks, low storage, and interrupted sessions are the default; the flagship on Wi-Fi is the exception.
  3. Offline is a state, not an error. Every read path should render something from local data; every write path should queue, reconcile, and show sync status. Assume the network is absent first.
  4. The main thread renders; everything else moves off it. Disk, network, JSON, image decode, crypto, and database work belong off the UI thread on every platform.
  5. Own the lifecycle. Processes are killed, background work is throttled, permissions are revoked, and OS versions drop APIs. Persist through onPause/scene disconnect, not just on exit.
  6. Continuous frames are a hard requirement. Nail the frame budget for the device refresh rate (16.7 ms at 60 Hz, 8.3 ms at 120 Hz) and measure in release mode on real hardware.
  7. Secure storage is the only storage for credentials. Keychain on iOS, Keystore-backed storage on Android; never SharedPreferences, UserDefaults, plists, or plain files for tokens.
  8. Secrets do not ship in the binary. Anything bundled can be extracted. Server-side keys stay server-side; client identifiers are public by definition.
  9. Minimize permissions and justify each one. Request in context, degrade when denied, and never gate the core experience on an optional permission.
  10. Measure startup, memory, and crash-free rate in the field, not only in the lab. Store vitals and crash reporting are part of the definition of done.
  11. Accessibility and localization are correctness. Screen readers, dynamic type, contrast, reduced motion, RTL, and plural rules are built in, not retrofitted.
  12. Automate the release path. Signing, versioning, notes, screenshots, and uploads are code in CI; a release that only one person can perform is a bus factor of one.
  13. Keep platform APIs first choice. Widgets, live activities, notifications, share sheets, biometrics, and background schedulers have system behavior users expect; wrappers leak.

Decision Tables

Stack at a glance

| Option | Language / UI | Sharing | Best for | Weak spots | |---|---|---|---|---| | Native | Swift + SwiftUI; Kotlin + Compose | None | Flagship, platform-deep, performance-critical | Two codebases, highest cost | | React Native + Expo | TypeScript + RN views | 80-95% (logic + UI) | Product teams with web/TS skills, fast iteration | Native module edges, JS runtime limits | | Flutter | Dart + own renderer | ~100% | Pixel-identical UI, custom design systems | Larger binary, Dart hiring, platform fidelity gaps | | Kotlin Multiplatform | Kotlin shared core; Compose or native UI | Logic 100%, UI optional | Existing native apps, shared domain/data layers | iOS toolchain maturity; CMP still evolving | | .NET MAUI | C# + XAML | ~100% | Microsoft-centric enterprise and LOB apps | Smaller ecosystem, iOS/Android rough edges |

When native wins

| Signal | Why native | |---|---| | Widgets, watch, TV, CarPlay/Android Auto, App Intents | First-class SDK access and release cadence | | Camera, audio, BLE, AR, ML accelerators | Direct access to platform frameworks and hardware | | Latency-critical UI (games, camera pipelines, drawing) | No bridge or second runtime in the path | | Accessibility and platform conventions are product requirements | Native semantics for free | | Long-lived app with dedicated per-platform teams | Lowest long-term platform friction |

Data layer at a glance

| Need | Local store | Notes | |---|---|---| | Relational, queries, migrations | SQLite (Room, GRDB, Drift, SQLDelight) | Default for structured app data | | Small key/value, flags, prefs | DataStore, MMKV, NSUserDefaults (non-sensitive) | Never for tokens | | Large blobs | Files + metadata index | Keep blobs out of the database | | Document sync with a backend | Sync engine (PowerSync, Electric, Couchbase Lite, Firestore) | Buy sync before building it | | Ephemeral cache | In-memory LRU + disk cache | Has an owner, TTL, and invalidation rule |

Release readiness gates

| Gate | Check | |---|---| | Correctness | Crash-free sessions >= 99.5% on the beta track | | Performance | Startup, frame, and memory budgets verified in release on real devices | | Security | MASVS checklist passed; no secrets in the bundle; pinning decision recorded | | Privacy | Store privacy labels/data safety form match actual collection | | Rollback | Staged rollout plan, kill switch, and hotfix path defined | | Automation | CI builds, signs, and uploads from a clean checkout |

Reference Index

| File | Scope | Load when | |---|---|---| | references/01-cross-platform-decision.md | Framework comparison across team, cost, performance, ecosystem, time-to-market; native-win signals; hybrid and migration paths | Choosing or reviewing a mobile stack, brownfield adoption, build-vs-buy on sharing | | references/02-ios.md | Swift 6 strict concurrency, SwiftUI architecture and navigation, SwiftData/Core Data, URLSession, Instruments, Swift Testing | Writing or reviewing iOS code, concurrency bugs, persistence, launch and energy issues | | references/03-android.md | Kotlin 2, Jetpack Compose, lifecycle and ViewModel, Hilt, Room/DataStore, Retrofit/Ktor, baseline profiles, testing | Writing or reviewing Android code, recomposition problems, process death, startup regressions | | references/04-react-native.md | New Architecture (Fabric, TurboModules, JSI), Expo Router, native modules, EAS build/update, performance pitfalls | Working in a React Native/Expo codebase, native module work, OTA and build pipeline questions | | references/05-flutter.md | Impeller, state management, isolates, platform channels and Pigeon, performance, widget and golden testing | Working in Flutter, rendering and jank issues, isolate and channel design, Dart architecture | | references/06-data-offline-sync.md | Local storage choices, sync engines, outbox patterns, conflict resolution, background sync, offline UX | Building offline-first features, choosing a sync engine, debugging divergent or lost writes | | references/07-performance.md | Startup, frame budgets and jank, memory, network, per-platform profiling, release-mode caveats, field monitoring | Diagnosing slow launch, dropped frames, leaks, OOMs, or setting performance budgets | | references/08-security.md | OWASP MASVS/MASWE, secure storage, TLS and pinning tradeoffs, obfuscation, secrets, integrity and root detection | Threat modeling, credential storage, transport hardening, privacy and permission review | | references/09-release-store-ci.md | Signing, fastlane/EAS, store requirements, staged rollouts, crash reporting, CI/CD pipelines and hotfixes | Setting up release automation, store submission, rollouts, or responding to a bad release |

How to Use This Pack

  • Start from the decision tables, then load only the references the task needs; do not load all nine by default.
  • New app or stack choice: load 01 first, then the chosen platform reference.
  • Feature work with a backend: pair the platform reference with 06 (offline/sync) early.
  • Before any release: load 07 (performance), 08 (security), and 09 (release) as a checklist pass.
  • References cross-link with relative paths such as ./06-data-offline-sync.md; follow them when a topic borders another domain.

Port

  • Port id — skill://senior/mobile (version in metadata.port-version).
  • Kind — read-only reference pack; no side effects, no tools, no scripts required.
  • Entrypoint — this SKILL.md; depth lives in references/.
  • Load modes
    1. Installed agent: skill({ name: "mobile" }) in OpenCode; Claude Code reads <skills-dir>/mobile/SKILL.md.
    2. Orchestrator: read SKILL.md, then load only the references the task needs.
    3. Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
  • Consumer contract — metadata.consumers lists the agents that may load it; consumers reference it as load skill mobile (optional).
  • Stability — stable; breaking changes bump port-version major.

References (9)

01-cross-platform-decision

Cross-Platform Decision

Choosing a mobile stack across native, React Native/Expo, Flutter, Kotlin Multiplatform, and MAUI, with cost, risk, and exit criteria.

Deciding Well Is Mostly About Constraints

Framework debates fail when they optimize for benchmarks nobody can feel. The decision is driven by five real constraints:

  1. Team — what languages the team already writes fluently and can hire for.
  2. Cost — total engineering cost over 2-3 years, including platform-specific work and upgrades.
  3. Performance — the worst realistic case (cold start, lists, camera, animation), not the median.
  4. Ecosystem — SDKs for payments, maps, analytics, ads, BLE, media, and niche hardware.
  5. Time-to-market — how fast the first meaningful release ships and how fast iteration continues.

Everything else (code sharing percentage, hot reload, "write once") is a means to one of those five.

Sharing Models, Not Frameworks

Understand the axis before comparing products.

| Model | What is shared | Typical stacks | |---|---|---| | Native-native | Nothing (two codebases) | SwiftUI + Compose | | Shared logic only | Domain, data, networking, persistence | Kotlin Multiplatform, .NET shared libraries | | Shared logic + UI | Logic plus screen definitions | Flutter, React Native, .NET MAUI, Compose Multiplatform | | Shared UI only | Unusual; hybrid shells | Web views, embedded mini-apps |

Rule of thumb: the further down the table, the higher the visible fidelity risk and the more you depend on the framework's escape hatches to reach native behavior.

Decision Matrix

Score each option 1-5 for the project's actual needs, then weight. A worked example:

| Criterion (weight) | Native | RN + Expo | Flutter | KMP | MAUI | |---|---|---|---|---|---| | Existing team skills (25%) | Swift/Kotlin needed | TS reuse | Dart needed | Kotlin reuse | C# reuse | | Time to first release (20%) | Slowest (2 codebases) | Fast | Fast | Medium | Medium | | UI fidelity to platform (15%) | Perfect | Approximate | Custom, consistent | Depends on UI layer | Approximate | | Performance worst case (15%) | Best | Good with new arch | Very good | Near-native | Good | | Library and SDK coverage (15%) | Complete | Very broad, gaps at hardware edges | Broad, some gaps | Kotlin/JVM ecosystem | Smallest | | Hiring and long-term cost (10%) | Two roles | One web-ish role | One niche role | One Kotlin role | One .NET role |

Do not carry a score table into the ADR without the reasoning; the reasoning is what survives personnel changes.

Framework Notes (2026)

Native — Swift/SwiftUI and Kotlin/Compose

Apple ships annual OS releases; Android ships yearly API levels plus monthly security patches. Native gets new platform capabilities on day one (widgets, Live Activities, App Intents, predictive back, health, wearables). Cost is two codebases and duplicated feature work, offset by lower risk at the edges and better long-term maintainability for platform-deep apps.

Choose native when the product's differentiation is a platform capability, not the shared CRUD shell around it.

React Native + Expo

One TypeScript codebase for iOS, Android, and often web. Expo provides a managed toolchain: EAS Build, Submit, and Update, config plugins, and prebuild for native projects. The New Architecture (Fabric renderer, TurboModules, JSI) has been the default for new apps for years; the old bridge is legacy and shrinking. The main risks are native module gaps and JS-runtime-dependent performance on low-end Android — always test there.

Choose RN + Expo when the team is already TypeScript-strong, the app is content/CRUD/commerce shaped, and fast iteration with OTA updates matters more than platform-native flourishes.

Flutter

Dart with its own rendering engine (Impeller) draws every pixel, so UI is identical across platforms and highly controllable. Strong for design-heavy apps, dashboards, and teams that want a single deterministic UI layer. Costs: a larger binary than native or RN, Dart hiring pool, and platform fidelity that must be explicitly implemented (Cupertino widgets, platform channels). Web and desktop targets exist but are secondary for most product work.

Choose Flutter when pixel consistency and custom motion/design are core, or when the app targets many form factors with one UI investment.

Kotlin Multiplatform (KMP)

Share business logic, networking, persistence, and optionally UI (Compose Multiplatform) while keeping SwiftUI/Compose for platform-specific screens. Excellent fit for an existing native app adopting sharing incrementally, especially Android-first teams. iOS interop is stable but adds build complexity; expect to own Gradle-to-Xcode integration and framework distribution.

Choose KMP when you want native UI with maximum shared logic, or when Android is the primary business and iOS must follow quickly.

.NET MAUI

For Microsoft-centric organizations with C# skills and LOB backends. Good tooling integration with Visual Studio and Azure; smaller third-party ecosystem and more frequent need for custom renderers or handlers at the platform edges.

Choose MAUI when the organization is a .NET shop and the app is line-of-business rather than consumer-scale.

Non-Obvious Cost Drivers

| Cost | Where it bites | |---|---| | Platform SDK gaps | A required bank/health/BLE SDK exists only natively; you write the module or the screen natively | | Upgrade cadence | Framework majors, OS betas, store target-API deadlines each force work | | Design divergence | iOS and Android interaction conventions require per-platform UI work regardless of stack | | Accessibility | Screen-reader semantics often need per-platform implementation | | Team topology | One team owning both platforms can bottleneck; two teams erode code sharing | | Toolchain ownership | Custom native builds, signing, and CI for each target |

When Native Wins (hard signals)

  • A core feature is only exposed through a native SDK with no maintained wrapper.
  • The app is performance-bounded: camera pipelines, audio DSP, games, AR, ML on device.
  • Platform presence matters: widgets, watch, TV, auto, App Intents, share extensions.
  • Accessibility, security, or regulatory requirements demand first-party APIs.
  • The app is long-lived and platform-specific polish is a competitive advantage.

When Cross-Platform Wins (hard signals)

  • Screens are data-driven and conventional: lists, forms, detail pages, checkout, chat.
  • A single small team must ship both platforms and web from one skill set.
  • Iteration speed dominates: OTA updates, shared design system, one release train.
  • The product is an internal or B2B app where 95% fidelity is indistinguishable.

Greenfield vs Brownfield

| Situation | Recommended path | |---|---| | New app, small TS team | React Native + Expo | | New app, design-led, multi-form-factor | Flutter | | New app, platform-deep, dedicated teams | Native | | Existing native app, duplicate logic hurting | KMP for shared core, keep native UI | | Existing native app, one screen at a time | Embed RN or Flutter module in a native shell | | Existing .NET organization | MAUI, with a native fallback plan for edge features |

Brownfield rule: introduce sharing behind a stable boundary (repositories, domain services), prove it for a release, then expand. Never rewrite the shell first.

Exit Cost and Lock-In

Before committing, answer:

  • What is the migration path if the framework loses support?
  • Which native modules would we have to rewrite?
  • How much UI is framework-specific versus standard?
  • Can we keep the domain layer portable (plain models, interface boundaries)?
  • What is the realistic cost to move to native per screen?

Keep domain models free of framework types (no UI imports, no ORM annotations leaking everywhere) so the expensive part — business logic and tests — survives a stack change.

Common Anti-Patterns

  • Benchmark-driven choice. Picking by synthetic startup numbers rather than hiring, SDK access, and iteration speed.
  • Sharing percentage as a goal. 100% sharing often means the worst platform experience wins.
  • Ignoring the native module tax. Every gap becomes custom platform work; estimate it upfront.
  • Skipping a spike. Not building one hard screen (camera, map, heavy list) before committing.
  • One-team ownership of both platforms without platform specialists. Review quality drops at OS-specific behavior and edge cases.
  • Re-platforming for its own sake. A rewrite with no product driver usually ships the same app later and worse.
  • Choosing on hot-reload demos. Iteration speed matters, but production performance and store compliance decide renewal.

Migration Notes

  • From web-only to mobile: start cross-platform (RN/Expo or Flutter) unless platform depth is core.
  • From RN old architecture to New Architecture: migrate dependencies first, then enable per-module; see ./04-react-native.md.
  • From a native app to shared logic: extract domain and data layers into a KMP module and keep the UI native; see ./02-ios.md and ./03-android.md.
  • Never migrate UI and data at the same time; one axis per release.

Decision Checklist

  • [ ] Written constraints: team skills, hiring plan, budget horizon, deadline.
  • [ ] One hard screen prototyped in the leading candidate.
  • [ ] Required third-party SDKs verified on each candidate platform.
  • [ ] Performance target defined against a mid-range device, not a flagship.
  • [ ] Platform-specific UI budget estimated (interaction conventions, accessibility).
  • [ ] Release automation story validated (signing, CI, store accounts).
  • [ ] Exit cost and portability boundary documented in an ADR.
  • [ ] Decision reviewed after the first release, with criteria to revisit.

Cross-Links

02-ios

iOS

Swift 6 concurrency, SwiftUI architecture, navigation, persistence, networking, performance, and testing for iOS apps built on current Xcode toolchains.

Version Floor and Toolchain

Target the two most recent major iOS releases for new apps; support the oldest version the product requires and verify against Apple's current SDK. Swift 6 language mode with strict concurrency is the default for new code in 2026. Use Xcode's current stable release; beta Xcode is for adapter work, not releases. Always verify SDK availability and deprecations upstream — iOS deprecates APIs aggressively and App Store review enforces current SDK builds.

Swift 6 Concurrency

Swift 6 makes data-race safety a compile-time concern. The core model:

  • async/await with structured concurrency (TaskGroup, async let) for parallel work.
  • Actors isolate mutable state; @MainActor isolates UI state.
  • Sendable marks types safe to cross isolation boundaries; value types and immutable references are the easy cases.
  • Task and task cancellation propagate through structured scopes; handle CancellationError.
actor ImageCache {
    private var store: [URL: Data] = [:]
    func data(for url: URL) -> Data? { store[url] }
    func insert(_ data: Data, for url: URL) { store[url] = data }
}

@MainActor
@Observable
final class FeedModel {
    private(set) var items: [Item] = []
    private let client: APIClient

    init(client: APIClient) { self.client = client }

    func load() async {
        do {
            items = try await client.feed()
        } catch is CancellationError {
            // view disappeared; keep previous state
        } catch {
            // surface error state
        }
    }
}

Rules:

  1. Prefer async functions over completion handlers and GCD. Keep DispatchQueue only for legacy interop; do not mix queues and actors without a documented boundary.
  2. Annotate view models and UI-touching types @MainActor; do the heavy work in nonisolated functions or actors and hand back Sendable values.
  3. Task { @MainActor in ... } from a view action; cancel long tasks when the view disappears.
  4. Avoid unstructured Task.detached; it drops priority and cancellation context.
  5. Migrate incrementally: enable strict concurrency per target, fix warnings, then per module. Use @preconcurrency import only as a temporary bridge, with a removal ticket.
  6. Async sequences (for await) are the idiomatic way to consume streams (notifications, sockets, observation).

Anti-patterns in concurrency

  • Singletons with mutable state accessed from several threads without isolation.
  • @unchecked Sendable used to silence the compiler rather than wrapping state in an actor.
  • Blocking the main actor with synchronous disk or network calls.
  • Fire-and-forget tasks that outlive their screen and mutate stale state.
  • Using Task.sleep for debouncing when .task(id:) or observation already models the dependency.

SwiftUI Architecture

  • Model layer — plain Sendable structs/enums, no view imports. Codable for transport.
  • State layer — @Observable classes (Observation framework) for screen models; @State owns them, @Bindable binds into controls. Prefer local state over global stores.
  • View layer — small views, formatting and layout only; no business logic in body.
  • Services — protocols injected via initializer or an environment key; avoid singletons in views so previews and tests can substitute fakes.
struct UserListView: View {
    @State private var model = UserListModel(client: .live)
    var body: some View {
        List(model.users) { user in
            NavigationLink(value: user) { UserRow(user: user) }
        }
        .task { await model.load() }
        .refreshable { await model.load() }
    }
}

Principles:

  • One source of truth per state; derive the rest with computed properties.
  • Keep body cheap: no allocations of heavy formatters; cache NumberFormatter/DateFormatter.
  • View identity is performance: avoid changing id values on every render, and use Identifiable entities with stable IDs in ForEach.
  • Split screens into small View structs; the compiler can only diff what is isolated.

Navigation

  • NavigationStack with a typed path for push/pop; navigationDestination(for:) per type.
  • Model the path as an array of enums or Codable routes so deep links and state restoration map to navigation without view callbacks.
  • Sheets and full-screen covers hold their own small navigation stacks; do not nest stacks arbitrarily.
  • Deep links: parse URL into route, then set the path; keep one router for app-wide destinations.
enum Route: Hashable { case user(User.ID), settings }

@Observable final class Router {
    var path: [Route] = []
    func handle(_ url: URL) { /* map url to Route, replace or append */ }
}

NavigationStack(path: $router.path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            switch route {
            case .user(let id): UserView(id: id)
            case .settings: SettingsView()
            }
        }
}

Persistence

| Need | Choice | |---|---| | New app, moderate model, iOS-first | SwiftData (@Model, ModelContainer, @Query) | | Complex migrations, shared store, mature tooling | Core Data with an NSPersistentContainer | | Relational with SQL control and cross-platform | GRDB (SQLite) | | Small settings | UserDefaults / @AppStorage (never secrets) | | Tokens and credentials | Keychain with an accessibility class | | Files and media | FileManager, app container, exclude caches from backup |

Rules:

  • SwiftData is fast to adopt but treat the schema as a migration contract from day one; version it and test lightweight plus custom migrations before shipping model changes.
  • Do not put large blobs in the database; store file references.
  • All database access happens off the main actor for large reads/writes; SwiftData ModelContext is not Sendable — pass PersistentIdentifier across actors and refetch.
  • @Query is convenient but unbounded; paginate or filter for large datasets.
  • Back up, then migrate; never mutate a schema and hope the store opens.

Networking

  • URLSession async APIs with Codable; define one APIClient actor per service.
  • Inject URLSessionConfiguration so tests can use a stub protocol or URLProtocol.
  • Handle cancellation, 401 refresh with a single-flight token refresh, and retry with exponential backoff plus jitter for idempotent GETs only.
  • Pin only with a documented key-rotation plan; pinning without a backup pin bricks the app when a certificate rotates — see ./08-security.md.
  • Use background URLSession for uploads/downloads that must survive app suspension.

Performance

  • Launch: keep application(_:didFinishLaunchingWithOptions:) trivial; defer work to first frame. Measure with Instruments' App Launch template and Xcode Organizer's launch metrics.
  • Lists: LazyVStack/List with stable identity; avoid nested lazy containers in scroll views.
  • Images: decode off the main thread, downsample to display size, cache with NSCache or a library.
  • Memory: watch retain cycles in closures ([weak self] where appropriate), and image caches; use Instruments' Leaks and Allocations, plus MetricKit for field data.
  • Energy: batch network calls, avoid high-frequency timers, let the system coalesce background work.
  • Release builds differ: Swift optimizations, -O, and no debug overhead. Profile release builds with a release scheme and dSYM symbols available.

Testing

  • Swift Testing (@Test, #expect, #require, parameterized tests) for unit and integration tests in new code; XCTest remains for UI tests and legacy suites.
  • Test domain logic without UI; use protocol fakes, not network stubs, where possible.
  • UI tests: XCUITest for critical flows only (login, purchase, onboarding); keep them few and stable with accessibility identifiers.
  • Snapshot tests for design-system components can catch regressions but need strict tolerance and device pinning to avoid flakiness.
  • Test concurrency: async tests with timeouts, and actors behind protocols.

Anti-Patterns

  • Massive view models that mix networking, persistence, and formatting.
  • try!/force unwraps in shipping code; fatalError on recoverable paths.
  • Doing work in init or body that should be a .task.
  • Ignoring cancellation, then updating UI after the user left the screen.
  • Storing tokens in UserDefaults.
  • Using Debug builds to judge battery, startup, or frame performance.
  • Depending on undocumented behavior of the OS; verify against the SDK.

Checklist

  • [ ] Strict concurrency enabled; no @unchecked Sendable without justification.
  • [ ] Screens have a single state owner and injected dependencies.
  • [ ] Navigation is driven by typed routes; deep links and restoration verified.
  • [ ] Persistence schema versioned; migration tested with existing data.
  • [ ] Network layer handles cancellation, auth refresh, and retry policy.
  • [ ] Release build profiled for launch, scroll, and memory on a real device.
  • [ ] Critical flows covered by UI tests; domain covered by unit tests.
  • [ ] Accessibility labels, dynamic type, and VoiceOver pass on core screens.

Cross-Links

03-android

Android

Kotlin 2, Jetpack Compose, lifecycle and ViewModel, persistence, networking, performance, and testing for Android apps built on current AGP and SDK levels.

Version Floor and Toolchain

Build with the current stable Android Gradle Plugin, Kotlin 2.x with the K2 compiler, and the Compose BOM rather than pinned artifact versions. minSdk should cover the product's real device floor; targetSdk must meet the Play target API requirement (it advances yearly — verify the current deadline upstream). Compose is the default UI toolkit; the View system remains for legacy code and some widgets/accessibility edge cases.

Kotlin 2 and Language Practice

  • K2 compiler: faster builds and real compiler plugins (Compose compiler ships with Kotlin now).
  • Coroutines + Flow for async: viewModelScope, StateFlow for UI state, SharedFlow for events; never expose MutableStateFlow.
  • Sealed interfaces/enums model UI state, results, and navigation events.
  • @Immutable/@Stable annotations and immutable data classes let Compose skip recomposition.
  • Avoid platform types leaking from Java; annotate or wrap third-party APIs.
sealed interface UiState {
    data object Loading : UiState
    data class Ready(val items: List<Item>) : UiState
    data class Failed(val message: String) : UiState
}

Jetpack Compose Architecture

State and recomposition

  • State hoisting: composables take values and emit events; state lives in the ViewModel or a state holder, not deep in the tree.
  • Prefer rememberSaveable for state that must survive process death (form input, selection).
  • Derived values: derivedStateOf for expensive computations from frequently changing state; remember(key) to memoize.
  • Avoid reading state in composition that only affects layout/draw; use lambdas and Modifier.drawWithContent/graphicsLayer for animation-driven changes.
  • Keys in LazyColumn must be stable and unique; never index-only for reorderable data.
@Composable
fun FeedScreen(vm: FeedViewModel, onItemClick: (String) -> Unit) {
    val state by vm.uiState.collectAsStateWithLifecycle()
    when (val s = state) {
        is UiState.Loading -> LoadingRow()
        is UiState.Failed -> ErrorRow(s.message, onRetry = vm::refresh)
        is UiState.Ready -> LazyColumn {
            items(s.items, key = { it.id }) { item ->
                FeedRow(item, onClick = { onItemClick(item.id) })
            }
        }
    }
}

Side effects

| Effect | Use for | |---|---| | LaunchedEffect(key) | Suspend work tied to a key (load, observe) | | rememberCoroutineScope | User-triggered launches from callbacks | | DisposableEffect | Register/unregister listeners, lifecycle observers | | produceState | Convert non-Compose sources into state | | snapshotFlow | Turn Compose state into a flow for business logic |

Never launch coroutines directly inside a composable body; use the effects above.

Lifecycle and ViewModel

  • ViewModel holds screen state and survives configuration changes; it does not hold Activity, View, or Context references (use AndroidViewModel only when a context is truly required).
  • Collect UI state with collectAsStateWithLifecycle so collection stops when the view is stopped.
  • Model events as one-shot effects (channels/shared flows) rather than consumable state booleans.
  • SavedStateHandle for minimal state needed after process death; large data is refetched.
  • Predictive back: opt in, handle back with BackHandler where behavior differs, and support gesture previews; never block back navigation without a strong reason.

Navigation

  • Navigation Compose with type-safe, serializable routes (Kotlin serialization) rather than string URLs.
  • One nav graph per feature plus an app graph; pass IDs, not whole models, between destinations.
  • Deep links declared in the graph; validate arguments and fall back safely.
  • Do not put navigation logic in composables; emit destination events from the ViewModel.

Dependency Injection

  • Hilt for most apps (compile-time safety, Android lifecycle integration); Koin or manual constructor injection for small apps.
  • Scope correctly: @Singleton for app services, ViewModel-scoped for screen state; avoid holding app context in long-lived objects.
  • Inject dispatchers (@IoDispatcher, @DefaultDispatcher) so tests can substitute test dispatchers.

Persistence

| Need | Choice | |---|---| | Relational app data | Room (KSP, flows from DAO; supports KMP targets now) | | Small preferences/flags | DataStore (Preferences or Proto) | | Tokens/credentials | Keystore-wrapped encryption + DataStore; never plain prefs | | Files/media | App storage with scoped storage APIs; MediaStore for shared media | | Cross-platform local DB | SQLDelight or Room KMP |

Room rules: DAOs return Flow for observed queries; transactions for multi-table writes; schema export on so migrations are checked into version control and tested; fallbackToDestructiveMigration never ships.

Networking

  • Retrofit + OkHttp (or Ktor client for KMP) with kotlinx.serialization or Moshi.
  • One OkHttp client per app; interceptors for auth, headers, logging (debug only), and retry.
  • Token refresh must be single-flight to avoid parallel refresh storms; exclude auth endpoints from the authenticator to prevent loops.
  • Timeouts are explicit; connect/read/write defaults are inappropriate for slow networks.
  • Map transport errors to domain errors at the data layer; UI never sees IOException directly.

Performance

  • Startup: minimize Application.onCreate, lazy-init libraries, avoid disk/network there; use App Startup for ordered initialization, baseline profiles and Macrobenchmark to measure.
  • Jank: Compose recomposition scope is the unit of cost; make state reads as local as possible, stabilize parameters, and use LazyColumn with stable keys and content types.
  • Images: Coil with proper sizing and crossfade off for lists; request the display size, not the source size.
  • Background work: WorkManager with constraints and unique work names; expedited work only for user-visible tasks; respect Doze and battery restrictions.
  • Size: R8 with resource shrinking, per-ABI splits or App Bundles, remove unused locales/density assets; measure with the APK Analyzer.
  • Memory: avoid Activity leaks via static references; use LeakCanary in debug; watch bitmap cache sizes; handle onTrimMemory.
  • Enable R8 full mode and verify keep rules for reflection/serialization; obfuscation mapping must be uploaded for readable crash reports.

Testing

| Level | Tooling | |---|---| | Unit | JUnit 5 or 4, kotlin.test, Turbine for flows, coroutines-test | | Compose UI | createComposeRule, semantics assertions, waitUntil/idling resources | | Integration | Room in-memory, MockWebServer for API, Hilt test runner | | Screenshot | Roborazzi/Paparazzi for deterministic screenshots in JVM tests | | End-to-end | Instrumented tests on device/emulator in CI (limited matrix) |

Rules: test ViewModels without Android framework classes; fake repositories with flows; run instrumented tests on at least one API level; keep E2E suites small and stable.

Anti-Patterns

  • Passing Context into ViewModels or long-lived objects.
  • collectAsState() without lifecycle awareness in screens.
  • Launching coroutines in composable bodies or init blocks that outlive the screen.
  • Giant ViewModels that own networking, caching, formatting, and navigation.
  • GlobalScope for anything.
  • One mutable state object mutated from multiple places without a single reducer.
  • Ignoring process death; state that vanishes on low-memory kill.
  • fallbackToDestructiveMigration in production.
  • Using the View system for new feature UI when Compose is already the stack.

Checklist

  • [ ] Immutable UI state, single state owner, events modeled explicitly.
  • [ ] No coroutine launched from composition without an effect.
  • [ ] Room schema versioned, exported, and migration-tested.
  • [ ] Network layer: single client, typed errors, single-flight refresh.
  • [ ] Baseline profile generated and Macrobenchmark startup/jank tracked in CI.
  • [ ] WorkManager jobs constrained, unique, and observable.
  • [ ] Accessibility: content descriptions, touch targets, TalkBack pass on core flows.
  • [ ] Release build with R8 verified; mapping file uploaded to crash reporting.
  • [ ] Handles process death for critical screens.

Cross-Links

04-react-native

React Native

The New Architecture, Expo tooling, navigation, native modules, EAS pipelines, and the performance pitfalls that decide whether a React Native app feels native.

Version Floor and Architecture

In 2026, the New Architecture is the default for new React Native apps: Fabric (renderer), TurboModules (native modules), and JSI (synchronous host calls), with Hermes as the engine. The legacy bridge remains through an interop layer for unmigrated libraries only. New apps should not enable legacy mode. Verify the current React Native, React, and Expo SDK versions upstream; Expo releases a new SDK on a regular cadence and RN majors land several times a year.

Practical floors: TypeScript everywhere, Hermes on both platforms, Fabric-compatible dependencies, and React 19-era semantics (automatic batching, transitions, use and Suspense patterns where the framework supports them).

Project Setup: Expo vs Bare

Expo is the default recommendation for most apps. It provides:

  • expo package with modules for camera, location, notifications, secure storage, and more.
  • Config plugins and Continuous Native Generation (CNG): ios/ and android/ are generated from app.json/app.config.ts, so they stay out of version control.
  • EAS Build/Submit/Update for cloud builds, store uploads, and OTA updates.
  • Expo Router for file-based routing on top of React Navigation.

Choose bare React Native (or eject via expo prebuild with a committed native project) only when a dependency requires manual native edits that no config plugin covers, or when the team owns a large existing native project.

# New app, TypeScript, Expo Router by default
npx create-expo-app@latest my-app
cd my-app && npx expo start

Rules:

  • Never commit generated ios/ and android/ unless you have explicitly opted out of CNG.
  • Pin the Expo SDK; upgrade through the recommended expo install --fix path rather than bumping packages ad hoc.
  • Keep one package manager and lockfile; the Metro resolver and native autolinking depend on it.

Expo Router

File-based routing where the filesystem is the nav graph. Layouts compose, groups organize, and dynamic segments map to params.

app/
  _layout.tsx          # root stack, providers, auth gate
  (tabs)/
    _layout.tsx        # tab bar
    index.tsx          # /
    feed.tsx           # /feed
  user/[id].tsx        # /user/:id
  +not-found.tsx
// app/user/[id].tsx
import { useLocalSearchParams, router } from "expo-router";

export default function UserScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { data, isPending, error } = useUser(id);
  if (isPending) return <Spinner />;
  if (error) return <ErrorView error={error} onRetry={refetch} />;
  return <UserProfile user={data} onBack={() => router.back()} />;
}

Rules:

  • Auth gating lives in a layout with redirects; never render protected screens then redirect.
  • Use typed routes so route names and params are checked at build time.
  • Deep links map to routes automatically; validate params defensively.
  • Keep screens thin: data fetching and business logic in hooks/services, not components.

Data and State

| Concern | Recommended | |---|---| | Server state (fetch/cache/invalidate) | TanStack Query (or the framework's equivalent) | | Small global client state | Zustand or useSyncExternalStore | | Form state | React Hook Form or controlled local state | | Fast key/value persistence | react-native-mmkv (sync JSI reads) | | Structured local data | SQLite (expo-sqlite, Drizzle ORM) | | Sensitive values | expo-secure-store / Keychain + Keystore |

Rules:

  • Server cache is not client state: do not copy query results into a global store; let the cache own it and subscribe with selectors.
  • MMKV is fast but plaintext by default; enable encryption for anything sensitive and use SecureStore for credentials.
  • Persist only what you can rehydrate: schema-version the client store and handle migrations.

Native Modules

Write a native module when a capability has no maintained library: Bluetooth edge protocols, a vendor SDK, platform widgets, or heavy synchronous math that JSI enables.

Options, in order of preference:

  1. Existing Expo module or community package compatible with the New Architecture.
  2. Expo Modules API (Swift/Kotlin) for new modules — less boilerplate and Swift/Kotlin first.
  3. TurboModule via codegen — full control for bare projects.
  4. Legacy bridge module — only for interop with unmigrated dependencies; plan removal.
// native module surface stays small and typed
import { requireNativeModule } from "expo-modules-core";

const BatteryInfo = requireNativeModule("BatteryInfo");
export const getLevel = (): number => BatteryInfo.getLevel();

Rules:

  • Keep the JS surface minimal; return plain serializable data, not opaque handles.
  • Never block the JS thread with synchronous JSI calls that do I/O.
  • Every native module needs unit tests on the native side and a typed TS wrapper.
  • Budget native module work explicitly; it is where cross-platform schedules slip.

EAS: Build, Submit, Update

eas build --platform ios --profile production
eas submit --platform ios --latest
eas update --branch production --message "fix checkout copy"

Rules:

  • eas.json defines profiles (development, preview, production) and channels; keep development builds with the dev client, previews internal-distribution, production store-signed.
  • OTA updates may only change JS/asset content, never native code, permissions, or store metadata. Apple and Google allow JavaScript updates within policy bounds but a change that affects the app's primary purpose or native behavior must go through review.
  • Always keep a rollback story: EAS Update lets you republish a previous compatible update; native builds still require store review.
  • Runtime versions gate which native builds can receive an update; bump when native modules change.
  • Signing credentials live in the EAS credential store or CI secrets, never in the repo.

Performance Pitfalls

React Native performance is mostly about what runs on the JS thread and how often React renders.

| Pitfall | Fix | |---|---| | Re-rendering whole lists | Memoized row components, stable keys, FlashList for large lists | | Inline objects/functions in hot props | useCallback/useMemo where they cross memo boundaries | | Context value recreated each render | Split contexts, memoize values, use selectors | | Heavy JSON parsing on JS thread | Parse in a native module or move to a worker/native layer | | Large images in list cells | Request sized images, cache with expo-image or FastImage | | Animations on JS driver | Use Reanimated (UI thread) with worklets and gesture handler | | Startup work in module scope | Lazy-import screens; defer non-critical init | | Bundle growth | Enable RAM bundles/Hermes bytecode, tree-shake, audit imports | | Slow first render after navigation | Prefetch data on interaction, use React.startTransition |

Profiling: React DevTools profiler for render churn, the Hermes sampling profiler for JS CPU, Perfetto/Android Studio for native frames, and Xcode Instruments for iOS. Always profile a release build: dev builds add enormous overhead.

Testing

| Level | Tooling | |---|---| | Unit | Jest, React Native Testing Library (user-centric queries) | | Integration | RNTL with mocked network (MSW) and a test store | | End-to-end | Maestro (simple flows) or Detox (deep native control) | | Type safety | TypeScript strict, tsc --noEmit in CI | | Lint/format | ESLint with the RN config, Prettier |

Rules: test hooks and services, not implementation details; keep E2E to critical journeys; run E2E on one iOS and one Android target to control CI cost.

Upgrade Strategy

  • Upgrade one minor at a time on RN; read the changelog and the New Architecture compatibility list before bumping.
  • Expo SDK upgrades bundle RN, React, and module versions; use the official upgrade command and fix warnings before adding features.
  • Native build upgrades: Gradle, AGP, Kotlin, Xcode, CocoaPods; keep a CI job that builds both platforms on every PR to catch toolchain drift early.
  • Freeze dependency upgrades before a release branch; upgrade after the release, not during.

Anti-Patterns

  • Fighting the New Architecture by leaving libraries on the legacy bridge indefinitely.
  • Committing generated native folders while pretending to use CNG.
  • Putting server data in Zustand/Redux instead of the query cache.
  • Business logic in components; screens with lifecycle-dependent side effects.
  • OTA updates used to push policy-violating changes.
  • Profiling in dev mode and concluding performance is fine.
  • One gigantic app/ route tree with shared mutable state across tabs.
  • AsyncStorage for large datasets or secrets.

Checklist

  • [ ] New Architecture enabled; all native deps Fabric-compatible.
  • [ ] Expo SDK and RN versions pinned; CI builds both platforms on PRs.
  • [ ] Router structure documented; auth gating in layouts; typed routes enabled.
  • [ ] Server state in a query cache; persistence schema versioned.
  • [ ] Secrets in SecureStore; MMKV encrypted where used.
  • [ ] Lists virtualized with stable keys; animations on the UI thread.
  • [ ] OTA policy, runtime version, and rollback procedure documented.
  • [ ] Release-mode profiling for startup, lists, and navigation.

Cross-Links

05-flutter

Flutter

Impeller rendering, Dart 3 language practice, state management, isolates, platform channels, performance, and widget-level testing.

Version Floor and Toolchain

Target the current stable Flutter channel (never beta for releases) with Dart 3.x. Impeller is the default renderer on iOS and Android for modern devices; the legacy Skia path is a fallback for older hardware and some platforms. Verify current support matrices upstream before relying on any renderer behavior. Use flutter analyze with flutter_lints (or stricter) and treat warnings as errors in CI.

Rules:

  • Pin the Flutter SDK per repo (FVM or the version in CI) so engines and generated code match.
  • Keep pubspec.lock committed for applications.
  • Prefer stable plugins with recent releases and platform support; audit before adoption.
  • Do not mix multiple state-management paradigms in one app.

Dart 3 Language Practice

  • Sound null safety everywhere; avoid ! outside invariants you assert in tests.
  • Records, patterns, and switch expressions for destructuring and exhaustive handling.
  • Sealed classes for state machines and results; exhaustiveness is compiler-checked.
  • Extension types and final class for zero-cost wrappers.
  • async/await plus Stream for async; FutureBuilder/StreamBuilder only for simple cases.
sealed class FeedState {
  const FeedState();
}
final class FeedLoading extends FeedState { const FeedLoading(); }
final class FeedReady extends FeedState {
  const FeedReady(this.items);
  final List<Item> items;
}
final class FeedFailed extends FeedState {
  const FeedFailed(this.message);
  final String message;
}

Rendering: Impeller and the Frame Pipeline

Flutter builds a widget tree, then an element tree, then a render tree, then layers, then paints with Impeller. Each frame runs build, layout, paint, and raster on the UI thread and raster thread.

Performance implications:

  • Build cost is per-widget; keep build pure and cheap.
  • Layout is the most expensive phase; avoid unbounded constraints and deep nesting.
  • Repaint only what changed; RepaintBoundary isolates expensive subtrees.
  • Impeller compiles shaders ahead of time; custom fragment shaders via FragmentProgram are supported but add pipeline variance — measure before adopting.
  • Avoid Opacity/ClipRRect/blur in scrolling content; use AnimatedOpacity, cached clips, and image shadows instead.

State Management

Choose one primary approach and enforce it.

| Approach | Shape | Best for | |---|---|---| | Riverpod | Providers, compile-safe DI + state | Most apps; testable, no BuildContext coupling | | Bloc | Events to states, explicit transitions | Complex flows, auditable state machines | | Provider / InheritedWidget | Minimal dependency passing | Small apps, libraries | | Signals / ValueNotifier | Fine-grained reactivity | Leaf-level state, performance-sensitive UI |

Riverpod sketch:

@riverpod
class Feed extends _$Feed {
  @override
  Future<List<Item>> build() => ref.watch(feedRepositoryProvider).fetch();

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => ref.read(feedRepositoryProvider).fetch());
  }
}

Rules:

  • UI reads state and calls notifiers; it does not contain business logic.
  • Dependencies are injected through providers and overridden in tests.
  • Keep global state for cross-cutting concerns only (auth, theme, connectivity); feature state is scoped and disposed with the feature.
  • Never hold BuildContext in async logic across awaits (use_build_context_synchronously).

Navigation

  • go_router for declarative, URL-addressable routes with deep linking, redirects, and shell routes (tabs).
  • Typed route classes for parameters; validate and normalize path/query arguments.
  • Auth redirects in a single redirect callback; never render protected screens and pop.
  • Keep navigation decisions in one place; widgets navigate by invoking intents, not by embedding route strings everywhere.
final router = GoRouter(
  redirect: (context, state) {
    final signedIn = ref.read(authProvider).isSignedIn;
    final atLogin = state.matchedLocation == '/login';
    if (!signedIn) return atLogin ? null : '/login';
    return atLogin ? '/' : null;
  },
  routes: [
    GoRoute(path: '/', builder: (c, s) => const HomeScreen()),
    GoRoute(path: '/user/:id', builder: (c, s) => UserScreen(id: s.pathParameters['id']!)),
  ],
);

Isolates and Concurrency

Dart runs one event loop per isolate; the main isolate renders UI and must stay responsive.

  • Use Isolate.run for one-shot CPU-heavy work (parsing, image processing, crypto).
  • Use long-lived isolates with ports for repeated work; compute() is the simple one-shot helper.
  • Keep payloads small: isolates copy memory unless using TransferableTypedData or Isolate.exit.
  • dart:ffi runs on the calling isolate; a blocking native call still blocks the UI unless moved to an isolate or NativeCallable.listener.
  • Never share mutable state across isolates; pass immutable messages.
  • Platform channels are asynchronous by nature and do not solve CPU work on the Dart side.
final parsed = await Isolate.run(() => parseLargeJson(rawBytes));

Platform Channels and Plugins

Options, preferred order:

  1. Maintained pub.dev package for the capability.
  2. MethodChannel/EventChannel via Pigeon for typed, code-generated bindings (no string keys).
  3. dart:ffi for C libraries and performance-critical native calls.
  4. Federated plugin with separate Android/iOS implementations for reusable native surfaces.

Rules:

  • Always generate bindings with Pigeon for new channels; hand-written string method names rot.
  • Handle PlatformException and MissingPluginException explicitly; degrade gracefully.
  • Channel calls are async and add latency; batch hot-path calls or move to FFI.
  • Test channels with mocks/fakes and at least one integration test per platform.
  • Keep native Kotlin/Swift code minimal and standard-conformant; Flutter does not excuse platform conventions such as Android foreground-service types or iOS background modes.

Performance

  • Startup: defer non-critical plugin registration, avoid heavy work in main(), use WidgetsFlutterBinding.ensureInitialized() only when needed, and measure with flutter run --profile and DevTools timeline.
  • Jank: profile with the DevTools performance page; look for long build/layout/paint events and raster thread saturation; use --trace-skia/Impeller traces where needed.
  • Lists: ListView.builder/SliverList with stable keys; addAutomaticKeepAlives only when required; image decode with cacheWidth/cacheHeight.
  • Images: cached_network_image or Image.network with cacheWidth; avoid full-resolution decodes in grids.
  • App size: flutter build appbundle --analyze-size; remove unused assets and fonts; split ABI/per-ABI artifacts; tree-shake icons (--tree-shake-icons).
  • Memory: watch image cache (imageCache.maximumSizeBytes), dispose controllers, and use DevTools memory profiling for leaks (undisposed AnimationController, listeners, streams).
  • Release-mode caveat: --profile disables some optimizer passes; final numbers come from flutter build --release on real devices. Debug builds are not representative at all.

Testing

| Level | Tooling | |---|---| | Unit | test package, mocktail/mockito, fake_async for time | | Widget | flutter_test, pumpWidget, semantics finders | | Golden | matchesGoldenFile with pinned fonts and devices | | Integration | integration_test package on device/emulator | | Lint | flutter analyze, custom lint rules, formatting in CI |

Rules:

  • Widget tests assert user-visible behavior via finders and semantics, not private members.
  • Golden tests are valuable but need deterministic fonts (bundle a test font) and generation on the CI platform; otherwise they flake across machines.
  • Integration tests cover critical journeys (login, purchase, offline recovery); keep the suite small.
  • Use ProviderScope(overrides: [...]) to fake repositories in widget tests.

Anti-Patterns

  • Business logic in widgets; setState used for app-wide state.
  • Creating providers, controllers, or futures inside build (recreated every frame).
  • Unbounded Column inside SingleChildScrollView with many children instead of slivers.
  • Opacity/ClipRRect/BackdropFilter on scrolling content without measuring.
  • Heavy JSON parsing on the main isolate.
  • Channel string keys duplicated in Dart and native code instead of Pigeon.
  • Ignoring dispose for controllers, subscriptions, and focus nodes.
  • Judging performance from debug builds.

Checklist

  • [ ] Flutter SDK pinned; flutter analyze clean and formatted in CI.
  • [ ] One state-management approach; providers overridable in tests.
  • [ ] Navigation centralized with deep links and typed params.
  • [ ] Isolates used for CPU-heavy work; no blocking calls on the UI isolate.
  • [ ] Channels generated via Pigeon; platform exceptions handled.
  • [ ] Profile builds measured for startup, scroll jank, and memory on a mid-range device.
  • [ ] Golden tests deterministic; integration tests cover critical journeys.
  • [ ] All controllers/listeners disposed; leaks checked in DevTools.

Cross-Links

06-data-offline-sync

Data, Offline, and Sync

Local storage choices, sync engine options, outbox and conflict resolution patterns, background sync constraints, and offline-first UX for mobile clients.

Offline Is the Default State

Mobile networks fail, processes are killed, and users expect the app to keep working. Model the client as a local database that happens to replicate with a server:

  • Reads resolve from the local store first, then reconcile.
  • Writes commit locally, enter an outbox, and sync when possible.
  • Every entity has a stable client-generated ID (UUID/ULID) so records can be created offline.
  • Every row carries sync metadata: updated_at, revision/version, deleted_at (tombstone), and an origin marker to suppress echo.

The alternative — network-first with a cache — produces blank screens on a train and lost writes on a crash. Avoid it for any app whose data matters.

Local Storage Choices

| Store | Model | Strengths | Watch out for | |---|---|---|---| | SQLite (Room, GRDB, Drift, SQLDelight) | Relational | Queries, transactions, migrations, mature | Schema migration and thread discipline are on you | | Key-value (MMKV, DataStore, UserDefaults) | KV | Fast, tiny surface | Not queryable; never for tokens | | Files + index | Blobs | Media, documents, large payloads | Keep out of DB; manage eviction and backup | | Document stores (Couchbase Lite) | JSON docs | Built-in replication, conflict handling | Heavier footprint; query model differs | | Sync engines (PowerSync, Electric, Firestore) | Managed | Handles replication, auth, and often conflicts | Vendor coupling, cost, sync semantics to learn |

Decision guide:

  • Small datasets, simple queries, existing SQL skills: SQLite.
  • Blobs and media: file storage with a metadata table.
  • Apps that are fundamentally about syncing shared documents: prefer a sync engine or a document database over hand-rolled replication.
  • Never store credentials in a KV store; see ./08-security.md.

Note on deprecated options: MongoDB's Realm/Atlas Device SDK line has been deprecated; do not start new projects on it, and plan migration for existing ones. Verify the current status of any managed backend upstream before adoption.

Sync Engine Landscape

| Engine | Model | Best for | |---|---|---| | PowerSync | SQLite replica + server connector | Existing Postgres/MySQL backends, teams wanting SQL locally | | ElectricSQL | Postgres shapes to local SQLite | Read-heavy Postgres apps, incremental sync | | Couchbase Lite | Document DB with replication | Multi-master document data, Couchbase Server | | Firestore | Cloud document store with offline cache | Firebase-native apps; conflicts are last-write-wins | | Supabase Realtime + local cache | Postgres + realtime | Supabase apps willing to own reconciliation | | Custom (delta API + outbox) | Full control | Unusual auth or compliance constraints |

Buying sync is usually cheaper than building it. Build custom only when no engine fits the data model or compliance boundary, and budget for the hard parts: conflict policy, schema evolution, tombstones, and per-account encryption.

Outbox Pattern

A write path that survives offline:

  1. User action creates a local record with a client ID and pending status.
  2. An outbox row records the intended mutation (entity, operation, payload, timestamp, retry count).
  3. A sync worker drains the outbox in order, applying server acknowledgment or conflict resolution.
  4. On success, the local record is marked synced and the outbox row removed.
  5. On failure, classify: retryable (network, 5xx, timeout) vs terminal (validation, 4xx auth). Terminal failures surface to the user with a resolution path; retryable failures back off.

Rules:

  • One outbox per account; clear it on logout to prevent cross-account leakage.
  • Mutations must be idempotent server-side using the client-generated key.
  • Ordering matters for dependent writes; sync parent before child or use a dependency graph.
  • Bound the outbox: surface sync failures before the queue grows silently forever.

Conflict Resolution

| Strategy | How it works | Use when | |---|---|---| | Last-write-wins (LWW) | Highest timestamp/revision wins | Low-stakes fields, independent edits | | Per-field LWW | Merge at field granularity | Profiles, settings where fields rarely collide | | Server authority | Server value always wins on conflict | Inventory, pricing, regulated data | | Version vectors / optimistic concurrency | Client sends base revision; server rejects stale writes | Collaborative records where losing an edit is unacceptable | | CRDTs | Data types merge deterministically | Rich text, lists, counters, true offline collaboration |

Rules:

  • Pick the strategy per entity, not per app, and document it where the mapping lives.
  • Resolve conflicts where the data is merged, not in the UI; UI only presents outcomes.
  • Keep a conflict log for terminal cases so support can explain what happened.
  • Never silently discard a user edit without surfacing it (badge, banner, or resolution sheet).
  • Clocks lie: prefer server-assigned timestamps or logical clocks over device time.

Background Sync

iOS

  • BGTaskScheduler (BGAppRefreshTask, BGProcessingTask) runs opportunistically; there is no guaranteed cadence and no background execution after user force-quit.
  • Background URLSession for transfers; completion handlers must be called promptly.
  • Push notifications with content-available wake the app briefly; do not abuse for polling.
  • Budget and OS version determine what runs; design so sync progresses in small chunks.

Android

  • WorkManager with constraints (NetworkType.CONNECTED), unique work names, exponential backoff, and ExistingWorkPolicy semantics.
  • Expedited work for user-visible actions, with quota awareness.
  • Doze and battery restrictions throttle background work; foreground services need declared types and justification.
  • Sync adapters are legacy; prefer WorkManager.

Cross-platform frameworks

  • React Native: expo-background-task/expo-task-manager wrap platform schedulers; background fetch intervals are OS-controlled, not promises.
  • Flutter: workmanager or native scheduler plugins; verify current platform support and store policy compliance.

Rules:

  • Sync on: app foreground, connectivity regain, push, and a low-frequency periodic task. Never poll aggressively.
  • Make each sync step incremental and resumable; assume it can be killed at any moment.
  • Merge background results into the local store, then let the UI observe the store; background tasks never touch UI state directly.

Offline UX

  • Show local data immediately with a subtle "syncing" indicator, not a blocking spinner.
  • Mark pending items (clock/badge) and failed items distinctly (error affordance with retry).
  • Optimistic UI with reconciliation: apply the write visually, then correct if the server rejects.
  • Explain conflicts when user action is required; offer keep-mine/keep-theirs/save-both where the data supports it.
  • Empty states differ for "no data" vs "not synced yet".
  • Connectivity is a hint, not a gate: the request can still fail with connectivity present.

Schema Migration and Evolution

  • Version every local schema; migrations are forward-only code with tests from a real previous version's fixture.
  • Server and client versions coexist during staged rollouts; the API must tolerate old clients (additive fields, no repurposing).
  • Tombstones must outlive clients that still hold the deleted row; garbage-collect after a safe window.
  • Encrypt at rest on device for sensitive data and plan key rotation without data loss.
  • Backfills are explicit migration steps, not lazy code paths that assume new fields exist.

Testing Sync

  • Unit-test the conflict resolver and outbox state machine as pure logic.
  • Integration-test repositories against an in-memory or temp-file SQLite with migration fixtures.
  • Simulate: offline creation, queued edits, reconnect, partial sync kill, duplicate delivery, server rejection, clock skew, and cross-device concurrent edits.
  • Property-based or fuzz tests for merge functions (commutativity, idempotence, convergence).
  • Test multi-account switching and logout clearing local data.

Anti-Patterns

  • Network-first UI with a cache as an afterthought.
  • Mutating server copies of data only when online; losing writes on crash.
  • Using device timestamps as the only ordering signal.
  • Hand-rolled sync without idempotency keys, tombstones, or a conflict policy.
  • Sync loops: echoing server changes back as client mutations.
  • Unbounded outbox with no error surfacing.
  • Background tasks doing full-table syncs.
  • Storing the local database unencrypted when it holds personal data.
  • Treating "online" as a boolean from the OS without verifying the request.

Checklist

  • [ ] Local store is the source of truth for reads; writes are optimistic and queued.
  • [ ] Outbox persisted transactionally with the mutation.
  • [ ] Client-generated IDs and idempotent server mutations.
  • [ ] Conflict strategy documented per entity; conflict log retained.
  • [ ] Sync triggers: foreground, reconnect, push, low-frequency background.
  • [ ] Background jobs idempotent, incremental, and resumable.
  • [ ] Migration tests run from previous-version fixtures.
  • [ ] Offline and error states designed, not default spinners.
  • [ ] Logout clears local data and outbox for the signed-out account.

Cross-Links

07-performance

Performance

Startup, frame budgets and jank, memory, network, per-platform profiling, release-mode caveats, and field monitoring for mobile apps.

Budgets First

Performance work without a target becomes taste. Set budgets per app and enforce them in CI:

| Metric | Target | Notes | |---|---|---| | Cold start (TTID, interactive) | Platform-dependent; define and ratchet | Perfetto/MetricKit/Xcode Organizer | | Warm start | Faster than cold; no regressions | Measure both | | Janky frames | < 1% at 95th percentile | Android Vitals definition | | Frame budget | 16.7 ms at 60 Hz; 8.3 ms at 120 Hz | Per-refresh-rate; high-refresh devices quantize | | Frozen frames | < 0.1% | Frames > 700 ms | | App size (download) | Team target, e.g. < 50 MB | App Bundle/App Store thinning | | Memory (foreground peak) | Device-class dependent | OOM kills are store-tracked | | Crash-free sessions | >= 99.5% | Definition of done | | Network payload per screen | Define per API | Bytes and request count on first render |

Budgets are floors for acceptance, not aspirations; any regression beyond noise reopens the change.

Startup

Startup is what the user feels before the first useful frame: process launch, runtime init, dependency setup, session restore, first data, first render.

Phases:

  1. Process start — dylib/framework loading (iOS), class loading and Application init (Android).
  2. Runtime init — JS engine and bundle load (RN), Flutter engine init, KMP framework load.
  3. App init — DI graph, SDK setup, config, migrations.
  4. First screen — auth check, local data read, first render.
  5. Data — network fetch completes and replaces placeholders.

Techniques:

  • Defer non-critical SDK init until after first frame; use startup tasks with declared dependencies (Android App Startup, iOS Task priorities, lazy imports).
  • Avoid disk and network on the launch path; read the session from memory/Keychain only.
  • Keep the launch storyboard/window frameless then render progressively.
  • Precompute migrations; never block launch on a long migration — run offline with a migration UX.
  • Reduce binary/framework count: fewer dynamic frameworks (iOS), avoid unnecessary services.
  • For RN: enable Hermes, avoid large synchronous module init, use RAM bundles where appropriate.
  • For Flutter: delay plugin registration and avoid heavy main() work.
  • Measure both cold and warm; clearing caches manually is more realistic than reinstall loops.

Jank and Frame Budgets

At 60 Hz a frame has 16.7 ms; at 120 Hz, 8.3 ms. Misses appear as jank or frozen frames. Work belongs on background threads; the UI thread only builds and draws.

Common causes by platform:

| Platform | Typical jank sources | |---|---| | SwiftUI | Heavy body, unstable IDs, image decode on main, layout thrash, expensive formatters | | Android/Compose | Recomposition storms, unstable params, main-thread DB/JSON, layout in draw pass | | React Native | JS thread re-renders, bridge calls, non-Reanimated animations, long lists without virtualization | | Flutter | Expensive build/layout, unbounded constraints, raster thread saturation, custom shaders |

Fixes:

  • Move work off the UI thread: Kotlin coroutines with proper dispatchers, Swift Task/actors, RN native/worklet code, Flutter isolates.
  • Virtualize lists with stable keys and item recycling; keep row build/body cheap.
  • Cache images and decode at display size; never decode full-resolution in a scroll.
  • Drive animations on the render/UI thread (Reanimated worklets, Compose animation APIs, Core Animation/withAnimation, Flutter implicit animations).
  • Avoid allocations in hot loops and per-frame closures.
  • Instrument first: Perfetto/JankStats (Android), Instruments Core Animation (iOS), Hermes profiler (RN), DevTools timeline (Flutter).

Memory

Mobile kills processes under pressure; the foreground app must stay well below budget. Watch for:

  • Leaks: retained Activities/ViewControllers, undisposed listeners/controllers, closures capturing owners, static caches of contexts.
  • Image memory: bitmaps are width x height x 4 bytes; downsample to the display size.
  • Unbounded caches: LRU with explicit limits; clear on trim/memory warning.
  • Large object graphs: streaming responses instead of loading everything.
  • Fragmentation/alloc churn: pooling for high-frequency buffers.

Tools: Android Studio Memory Profiler + LeakCanary; Xcode Instruments Allocations/Leaks + MetricKit; Flutter DevTools memory + leak_tracker; RN Hermes heap snapshots and native profilers.

Handle memory warnings: release caches, drop large previews, and persist state so restoration is cheap. Test on a low-RAM device class, not just a flagship.

Network

  • Reduce requests: batch, aggregate, and prefetch on interaction.
  • Compress: gzip/br for text, modern image formats (AVIF/WebP) at display dimensions.
  • Cache: HTTP caching with ETags, plus a local store/view cache; never refetch unchanged data.
  • Keep payloads small: field selection, pagination, delta sync; avoid over-fetching screens' worth of data.
  • Timeouts and retries: explicit small timeouts, exponential backoff with jitter, retry idempotent operations only.
  • Optimize for constrained networks (high RTT, packet loss) not just bandwidth; measure on a throttled profile.
  • On failure, render local data instead of an empty error screen (./06-data-offline-sync.md).

Profiling per Platform

| Platform | Tools | |---|---| | iOS | Instruments (Time Profiler, Allocations, Leaks, App Launch, Core Animation, Energy), Xcode Organizer metrics, MetricKit | | Android | Android Studio Profiler, Perfetto/systrace, Macrobenchmark + Baseline Profiles, JankStats, Play Console Android Vitals | | React Native | Hermes sampling profiler, React DevTools Profiler, Perfetto, Instruments, Flipper (if retained) | | Flutter | DevTools Performance/Memory, timeline events, --profile builds, Impeller traces, flutter build --analyze-size | | Cross | Firebase Performance Monitoring, Sentry Performance, custom RUM with startup/frame markers |

Method:

  1. Reproduce on a real mid-range device in profile/release mode.
  2. Measure baseline; capture traces, not impressions.
  3. Form one hypothesis at a time; change one variable.
  4. Re-measure on the same device and scenario; keep a record.
  5. Move the improvement into CI (Macrobenchmark, startup markers, bundle-size checks).

Release-Mode Caveats

  • Debug builds disable optimizations, add verification, and represent nothing: never profile them.
  • Android: R8 obfuscation changes timings; test release builds with the same keep rules as production. Baseline Profiles change startup materially — generate and ship them.
  • iOS: Swift whole-module optimization and -O change performance; profile release scheme builds with symbols. Thermal throttling on long sessions skews results.
  • Flutter: --profile is close to release but disables some compiler passes; final validation is flutter build --release.
  • RN: dev mode uses the debug JS engine without Hermes bytecode; a development build with the dev client is still not release. Test with --variant release.
  • Hermetic conditions (fresh install, cleared cache, airplane mode toggles) change results; document the scenario with the number.

Field Monitoring

  • Android Vitals (ANRs, crashes, startup, jank) and Play Console release metrics.
  • MetricKit and Xcode Organizer for iOS launch, hang, memory, and energy diagnostics.
  • Crash reporting with performance traces (Crashlytics, Sentry, New Relic); symbol/mapping upload is mandatory for readable reports.
  • RUM markers for app-specific milestones (login screen ready, feed rendered, checkout complete).
  • Segment by device class and OS version; aggregate hides the devices that need work.
  • Alert on regressions per release with thresholds; tie alerts to dashboards reviewers check.

Anti-Patterns

  • Optimizing without a budget or baseline.
  • Profiling debug builds.
  • Moving work between threads without measuring (thread ping-pong and contention are real).
  • Caching everything forever; memory grows silently until the OS kills the app.
  • Assuming the network is fast because the office Wi-Fi is.
  • Chasing a single benchmark number while ignoring thermal throttling and sustained use.
  • Deferring performance to "after launch" — startup and jank are shaped by architecture.
  • Ignoring app size and download conversion.

Checklist

  • [ ] Startup, frame, memory, size, and crash budgets written and ratcheted in CI.
  • [ ] Cold and warm start measured on a mid-range device in release.
  • [ ] Jank measured with platform tools and Android Vitals/MetricKit in the field.
  • [ ] Lists virtualized; images decoded at display size and cached with limits.
  • [ ] Network calls reduced, cached, compressed, and resilient with timeouts/backoff.
  • [ ] Memory warnings and leaks handled; low-RAM device tested.
  • [ ] Release-mode profiling performed on both platforms before shipping.
  • [ ] Symbol/mapping uploads automated for crash reports.
  • [ ] Regression alerts configured per release track.

Cross-Links

08-security

Security

OWASP MASVS-aligned mobile security: secure storage, transport, obfuscation, secrets, platform integrity, and privacy for iOS and Android clients.

Threat Model

Mobile clients are fully under the attacker's control: the binary can be decompiled, storage inspected on a rooted/jailbroken device, traffic intercepted with a user-installed root CA, and APIs called directly. Client-side checks raise cost; they never make an API safe.

Therefore:

  • The server enforces authorization. Every endpoint validates the token, scopes, and object ownership. Client checks are UX, not security.
  • Assume secrets in the binary are public. Obfuscation slows extraction; it does not prevent it.
  • Protect data by classification. Credentials and health/payment data get hardware-backed storage and minimal retention; public catalog data does not need Fort Knox.
  • Log nothing sensitive. Tokens, PII, and payload bodies stay out of logs, analytics, and crash breadcrumbs.

Reference framework: OWASP MASVS (verification standard), MASWE (weakness enumeration), and MASTG (testing guide). Verify the current MASVS revision upstream; the v2 control groups are a good checklist skeleton:

| Group | Focus | |---|---| | MASVS-STORAGE | Sensitive data at rest, backups, screenshots | | MASVS-CRYPTO | Key management, algorithms, randomness | | MASVS-AUTH | Authentication, session, biometrics | | MASVS-NETWORK | TLS, pinning, cleartext | | MASVS-PLATFORM | IPC, WebViews, deep links, permissions | | MASVS-CODE | Platform usage, dependencies, update hygiene | | MASVS-RESILIENCE | Tamper/integrity detection (defense in depth) | | MASVS-PRIVACY | Data minimization, consent, telemetry |

Secure Storage

| Data | iOS | Android | |---|---|---| | Tokens, keys | Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (or stricter) | Keystore-backed encryption; store ciphertext in DataStore/prefs | | Small secrets needing biometric gate | Keychain access control with SecAccessControl | Keystore key with setUserAuthenticationRequired(true) | | Structured local data (PII) | File protection class + SQLCipher/GRDB encryption | SQLCipher or SQLiteDatabase with support; encrypt sensitive columns | | Cache/temp | Caches directory, excluded from backup | cacheDir, excluded from backup | | Never | UserDefaults, plists, unencrypted files | SharedPreferences, plain files, external storage |

Notes:

  • Android's androidx.security:security-crypto API (EncryptedSharedPreferences/EncryptedFile) was deprecated; do not start new code on it. Use Keystore directly or a maintained alternative, and verify the current recommendation upstream.
  • Keychain items sync via iCloud if you choose a synchronizable accessibility class; for device-bound credentials use ThisDeviceOnly classes.
  • Biometric prompts authenticate the user to unlock a key; they are not proof of identity to the server. Bind the unlocked secret to a server verification.
  • Clear sensitive storage on logout, and mark views with FLAG_SECURE (Android) / avoid sensitive content in app switcher snapshots (iOS).

Transport Security

  • TLS 1.2+ everywhere, TLS 1.3 preferred; no cleartext exceptions in release.
    • Android: networkSecurityConfig with cleartextTrafficPermitted="false"; no user CA trust in release.
    • iOS: App Transport Security on; only scoped exceptions with justification.
  • Certificate pinning: pin the leaf or intermediate public key (SPKI), always ship at least one backup pin, and have an update path before certificate expiry.
    • Pinning breaks when operations rotates certs without you; monitoring and a remote kill switch are mandatory if you pin.
    • Pinning does not stop a determined attacker on a compromised device; it raises interception cost.
  • Do not disable hostname verification or accept all certificates, even in debug — use a debug pinning bypass with a release guard instead.
  • Certificate transparency and platform trust stores give baseline protection without pinning; for most apps that is the right default.

Authentication and Sessions

  • OAuth 2.0/OIDC with PKCE for public clients; never ship a client secret in the app.
  • Use platform browsers (ASWebAuthenticationSession/Custom Tabs) for web sign-in; embedded WebViews are discouraged and often blocked by providers.
  • Short-lived access tokens plus rotating refresh tokens stored per secure storage rules.
  • Refresh single-flight to avoid races; on refresh failure, sign out and clear local data.
  • Biometrics unlock local credentials; the server still validates sessions.
  • Logout must revoke server-side refresh tokens where supported, not just delete local state.
  • Deep links: treat as untrusted input; never authorize a session from a link alone.

Obfuscation and Reverse Engineering

  • Android: R8 full mode with obfuscation; keep rules minimal and reviewed; upload the mapping file to crash reporting.
  • iOS: Swift symbols are stripped in release; string obfuscation requires tooling and adds little against a motivated attacker. Prefer moving logic server-side.
  • Avoid shipping API keys that grant privileges. Identify the client with Play Integrity / App Attest instead of a bundled secret.
  • Anti-debugging and tamper checks are deterrents; combine with server-side anomaly detection rather than relying on them.

Platform Integrity and Device Compromise

| Control | What it proves | Limits | |---|---|---| | Play Integrity API | App binary, device, and account verdicts | Attestation is probabilistic; server policy needed | | App Attest (iOS) | Request comes from a genuine app instance on genuine hardware | Requires server assertion validation | | Jailbreak/root detection | Device is likely modified | Bypassed on real threats; false positives on power users | | Emulator detection | Environment heuristics | Developers and QA use emulators; never hard-block silently |

Rules:

  • Treat all signals as risk scores combined server-side, not binary gates.
  • Define policy per action: browsing may ignore root; payments and health data may require strong integrity and step-up authentication.
  • Provide a support path for false positives; users on rooted devices are still customers.
  • Never store the decision only on the device; the server re-evaluates.

WebViews, IPC, and Deep Links

  • Prefer native UI over WebViews for anything authenticated. If you must:
    • Disable JavaScript where possible; never expose native bridges to untrusted content.
    • Restrict navigation to an allowlist; block file://, content://, and arbitrary custom schemes from loaded pages.
    • Keep cookies out of the WebView for app sessions; use a separate auth flow.
  • Deep links and app links:
    • Validate and canonicalize every parameter; never pass raw URLs into WebView loads or file paths.
    • Prefer verified App Links / Universal Links over custom schemes (other apps can claim schemes).
    • Avoid intent redirection: never proxy an Intent/URL to an exported component based on untrusted input.
  • Exported Android components: require permissions or explicit intent filters; set android:exported precisely; validate all incoming data.
  • iOS URL schemes and universal links: validate the host and path; do not auto-login from link data.

Privacy and Permissions

  • Request the minimum permissions, at the moment of use, with a rationale; handle denial and "don't ask again"/restricted states without blocking the app.
  • iOS: purpose strings must be accurate; privacy manifests (PrivacyInfo.xcprivacy) declare data collection and required-reason APIs; App Privacy labels must match what you actually collect.
  • Android: Data Safety form must match behavior; permission declarations in the manifest are visible; foreground service types must be justified.
  • Third-party SDKs collect data you are responsible for; inventory them and keep the store forms accurate.
  • Consent for tracking where required (ATT on iOS, regional rules); do not fingerprint as a fallback for denied tracking.
  • Analytics events: no PII, no free-text user content, no tokens.

Dependency and Supply Chain

  • Pin versions and use lockfiles; review new dependencies for maintainership and permissions.
  • Remove abandoned SDKs; a stale analytics or ad library is both a vulnerability and a store risk.
  • Track SDK update deadlines (target API requirements, 16 KB page size alignment on Android, privacy manifest requirements on iOS) and verify current dates upstream.
  • Reproducible builds and signed artifacts: verify checksums; build release artifacts only in CI.

Testing

  • Static: SAST for the app code, secret scanning in the repo, dependency scanning (SCA).
  • Dynamic: proxy interception (mitmproxy/Proxyman), storage inspection on a rooted device, binary analysis, deep-link fuzzing.
  • MASVS/MASTG test cases mapped to product risks; automate what is stable, do the rest pre-release.
  • Server-side authorization tests are the highest-value security tests; client testing does not substitute.

Anti-Patterns

  • Secrets and API keys bundled in the app.
  • Trusting a client-provided "isPremium" or "isAdmin" flag.
  • Certificate pinning with no backup pin or rotation plan.
  • Root detection that blocks paying users with no support path.
  • Logging tokens, PII, or full responses.
  • Custom crypto; rolling your own KDF or "encryption" scheme.
  • Encrypting the database but leaving the key next to it.
  • WebView bridges exposed to remote content.
  • Backup enabled for token storage.
  • Treating MASVS as a one-time audit rather than a recurring checklist.

Checklist

  • [ ] Token/credential storage uses Keychain/Keystore with strict accessibility.
  • [ ] Cleartext disabled in release; pinning decision documented with backup pins and rollback.
  • [ ] OAuth PKCE with platform browser; no client secrets in the binary.
  • [ ] R8/obfuscation enabled; mapping upload automated.
  • [ ] Integrity signals evaluated server-side per action, with false-positive support path.
  • [ ] Deep links and exported components validate untrusted input.
  • [ ] Privacy manifests/labels and Data Safety match actual collection.
  • [ ] Dependency and secret scanning in CI.
  • [ ] MASVS checklist reviewed for the release; findings tracked to closure.

Cross-Links

09-release-store-ci

Release, Stores, and CI

Signing, build automation with fastlane/EAS, store requirements, staged rollouts, crash reporting, and CI/CD for iOS and Android releases.

Release Pipeline Shape

commit -> CI build (debug + tests)
       -> CI signed release artifacts
       -> upload to internal track / TestFlight
       -> QA + internal validation
       -> staged rollout (Play) / phased release (App Store)
       -> monitoring window -> full rollout
       -> post-release review and cleanup

Rules:

  • Every release is built by CI from a tagged commit; no local builds with personal credentials.
  • Artifacts are immutable and archived with symbols/mappings and their source commit.
  • The same binary that passed QA is the one promoted to production; no rebuild-per-track.
  • Release notes, screenshots, and metadata live in version control where the tooling supports it.
  • One owner per release, with a documented checklist and a rollback decision point.

Versioning

  • Use semantic versioning for user-facing versionName/CFBundleShortVersionString, and a monotonic integer build number for versionCode/CFBundleVersion.
  • Never reuse a build number: stores and crash reporting treat it as identity.
  • Drive build numbers from CI (run number or commit count); avoid manual increments.
  • Keep a changelog per release; internal notes explain risk and testing done, not just features.
  • Hotfix versions increment patch and build; do not rewrite history on a published version.

iOS Signing and Submission

Components:

  • Apple Developer Program membership and App Store Connect access.
  • Development and distribution certificates; provisioning profiles per bundle ID and capability.
  • An App Store Connect API key for automation (issuer ID, key ID, .p8 private key) with minimal roles.
  • App ID capabilities (push, associated domains, keychain sharing, app groups) declared in the project and the portal.

Automation options:

  • fastlane match for certificate/profile management: one encrypted repo, CI reads it with a deploy key, new machines onboard without portal clicking.
  • Xcode-managed signing with cloud signing via xcodebuild -allowProvisioningUpdates using the API key for simple setups.
  • xcodebuild archive then xcodebuild -exportArchive with an ExportOptions.plist; or fastlane gym plus pilot/deliver for TestFlight and metadata.

Rules:

  • Certificates and keys never live on developer laptops as the source of truth.
  • Profiles are regenerated when capabilities change; treat "no profiles available" as a config bug, not a manual task.
  • TestFlight builds expire; automate a rolling beta rather than uploading by hand.

Android Signing and Submission

Components:

  • Keystore with the upload key; Play App Signing holds the app signing key (recommended; required for new apps and App Bundles).
  • signingConfigs wired from environment/CI secret store; the keystore is never committed.
  • Play Console service account with JSON credentials for API automation (Gradle Play Publisher or fastlane supply).

Rules:

  • Back up the upload keystore and credentials in a team secret store with documented recovery. Lost upload keys can be reset with Play support; lost app signing keys are unrecoverable without it.
  • Build App Bundles (.aab); Play generates per-device splits. APKs are for local testing or distribution outside Play.
  • Target the current required API level; the deadline advances annually and blocks uploads when missed. Verify the current requirement upstream.
  • Align native libraries to the required page size (16 KB requirement for recent Android versions); check all .so files and third-party SDKs. Verify the current deadline upstream.
  • Review manifest changes each release: permissions, exported components, foreground service types, and data safety declarations must match behavior.

Store Requirements (verify current policies upstream)

| Area | iOS | Android | |---|---|---| | Privacy | App Privacy labels, privacy manifest, ATT where applicable | Data Safety form, permission declarations | | Account | Account deletion in-app if accounts can be created | Account deletion requirement | | Payments | In-app purchase for digital goods; entitlements for external purchase in some regions | Play Billing for in-app digital goods; regional rules vary | | Sign-in | Sign in with Apple when other third-party sign-ins exist | No equivalent universal rule | | Content | Age ratings, UGC moderation, reporting/blocking | Content rating, UGC policy, families policy | | Tracking | ATT prompt before tracking | Advertising ID policies, consent requirements | | EU DSA | Trader status and contact info required for distribution | Developer verification and trader declarations | | Review | Human review with App Review Guidelines; rejections need a resolution path | Automated plus policy review; appeals and policy status |

Practical notes:

  • Review times vary; never plan a marketing date that depends on same-day approval.
  • Keep demo accounts and review notes current; reviewers must reach every feature.
  • Rejections are usually metadata/privacy mismatches, not bugs. Fix the mismatch, respond factually, and avoid resubmitting unchanged.
  • Pre-review with fastlane precheck-style checks and internal review of screenshots, keywords, and privacy answers.

Build Automation

fastlane

# Fastfile (sketch)
lane :beta do
  match(type: "appstore", readonly: true)
  build_app(scheme: "App", export_method: "app-store")
  upload_to_testflight(skip_waiting_for_build_processing: true)
end
  • match for iOS signing; supply for Play uploads with metadata and screenshots.
  • Keep lanes small and composable; secrets come from CI environment.
  • Use deliver for metadata/screenshots; store them in the repo for review.

EAS (React Native/Expo)

eas build --platform all --profile production --non-interactive
eas submit --platform android --latest --non-interactive
  • eas.json profiles define distribution and env; secrets in EAS or CI.
  • EAS Update handles OTA JS changes within policy; native changes require a new build.
  • Version and build numbers managed via app.json/remote versioning; keep them monotonic.

Gradle/Xcode directly

  • ./gradlew bundleRelease with R8, resource shrinking, and signing from env.
  • xcodebuild archive + -exportArchive; use -resultBundlePath to archive diagnostics.
  • Cache Gradle and CocoaPods/SwiftPM between CI runs; cache invalidation is a common source of flaky builds.

CI/CD

Recommended shape:

| Stage | Runs on | Purpose | |---|---|---| | Lint/typecheck | Every PR, Linux | Fast feedback (ktlint/detekt, swiftlint/swift-format, eslint/tsc, dart analyze) | | Unit tests | Every PR | Domain logic with coverage on critical modules | | Build (both platforms) | Every PR to main | Toolchain drift detection; produce debug/dev artifacts | | Instrumented/E2E | Merge queue or nightly | Device-level regressions; keep the matrix small | | Release build | Tag or manual | Signed artifacts, changelog, upload to internal track | | Rollout | Manual gate | Staged percentages and monitoring windows |

Rules:

  • macOS runners are required for iOS builds; budget cost and cache aggressively.
  • CI holds signing material in a secrets manager with least privilege; rotate on personnel change.
  • Builds must be reproducible enough to rerun a tag; pin toolchain versions (Xcode, Gradle, AGP, JDK, Flutter/Expo SDK).
  • Fail fast on lint and tests, but never let a flaky test silently disable a gate; quarantine with a ticket.
  • Artifact retention: keep release artifacts, symbol files, and mapping files as long as crash reports reference them.

Staged Rollouts and Monitoring

  • Play: start at a small percentage (for example 5-10%), watch Android Vitals and crash-free rate for 24-72 hours, then increase. Halt immediately on regressions.
  • App Store: phased release over 7 days; pause when crash or hang metrics rise.
  • Define go/no-go criteria before rollout: crash-free sessions, ANRs/hangs, startup P90, business KPIs, support volume.
  • Monitor during the window; an unwatched rollout is not staged, it is delayed.
  • Freeze changes and deploys to backend dependencies during the rollout window to keep causality.
  • Keep a rollback plan: halt rollout for native, hotfix branch for critical defects, OTA rollback where the framework allows it.

Crash Reporting and Observability

  • Install crash reporting before the first beta: Crashlytics, Sentry, or equivalent.
  • Upload symbols (dSYM), ProGuard/R8 mappings, and source maps on every build; automate it.
  • Configure grouping, breadcrumbs without PII, and user context that respects privacy.
  • Track non-fatal errors, ANRs/hangs, and startup traces, not just crashes.
  • Alert to a monitored channel with clear ownership; wire alerts to the on-call rotation.
  • Verify symbolication with a test crash per release candidate.

Hotfix and Rollback

| Situation | Action | |---|---| | JS/asset-only bug in RN/Expo | OTA update on a rollback-safe runtime version, or republish a previous update | | Native bug, low severity | Fix in the next scheduled release; document workaround | | Native crash or data-loss bug | Halt rollout, ship hotfix patch as fast as review allows, consider remote kill switch | | Backend incompatibility | Feature-flag off server-side immediately; clients should degrade, not crash | | Bad store metadata | Update metadata without a binary where the store allows |

Requirements for this to work: features behind flags, server-side kill switches, version gates for APIs, and backward-compatible APIs for at least one version back.

Release Checklist

  • [ ] Version and build numbers incremented monotonically.
  • [ ] CI built the artifact from a tagged commit; archive retained with symbols.
  • [ ] Tests, lint, and typechecks green; no quarantined failures unaccounted for.
  • [ ] Privacy labels/Data Safety and permission declarations reviewed against actual behavior.
  • [ ] Store metadata, screenshots, and review notes current.
  • [ ] Rollout plan with percentages and go/no-go thresholds agreed.
  • [ ] Crash reporting and performance monitoring active for the new version.
  • [ ] Rollback/kill-switch plan documented and tested.
  • [ ] On-call aware of the release window and monitoring channel.
  • [ ] Post-release review scheduled; cleanup of flags and dead code after full rollout.

Anti-Patterns

  • Manual signing on a laptop as the only path to release.
  • Rebuilding per track, so QA tested a different binary than production.
  • Uploading .apk when the store expects .aab (or vice versa).
  • Ignoring target API or page-size deadlines until uploads fail.
  • 100% rollout immediately with no monitoring.
  • Shipping OTA updates that change native behavior or violate store policy.
  • No mapping/symbol upload, producing useless crash reports.
  • Release without a rollback or kill-switch path.
  • Treating store review as a formality and planning a launch date that depends on approval.
  • Letting one person hold all credentials and knowledge.

Cross-Links