sota-mobile
State-of-the-art mobile engineering for building and auditing iOS and Android applications. Use when the task involves mobile apps in any form — native (Swift, SwiftUI, Kotlin, Jetpack Compose), cross-platform (React Native, Flutter, Kotlin Multiplatform), Swift as a language — S
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-mobile
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Mobile Engineering
Expert-level rules for building new mobile apps and auditing existing ones. Mobile is unlike web or backend in three load-bearing ways, and every rule in this skill flows from them:
- You cannot roll back a shipped binary. Users update on their own schedule; some never do. Every release is permanent for some cohort. Design for kill switches, forced updates, and servers that tolerate ancient clients.
- The device is hostile territory. The attacker owns the hardware, can decompile the binary, and can read anything you store insecurely. Client-side checks are deterrents, not controls; enforcement lives on the server.
- Resources are budgeted, not abundant. Main thread, battery, memory, radio, and background execution time are all rationed by the OS. Apps that overspend get janked, killed, or throttled.
Facts in this skill (OS versions, store policies, framework status) were verified against primary sources in June 2026. Mobile platforms move fast — when a specific deadline or version matters, re-verify against Apple/Google developer docs before relying on it.
BUILD mode
When creating or extending a mobile app:
- Settle the platform decision first. Stack choice (native vs cross-platform), minimum OS floor, and target SDK are one-way doors. Use
rules/01decision factors; record the decision and its rationale in the repo. - Establish architecture before features. Unidirectional data flow, DI seams, module boundaries, and navigation pattern from day one (
rules/02). Retrofitting UDF onto a ball of mutable state is a rewrite. - Decide the offline posture explicitly. "Online-only with graceful errors" is a valid choice; "accidentally breaks offline" is not. If offline-first: local DB is the source of truth, mutations queue, sync is a background concern (
rules/03). - Wire operational survival kit before v1.0 ships: crash reporting with symbol upload, forced-update mechanism, remote kill switches for risky features, API version header on every request (
rules/06). These cannot be added retroactively for already-shipped binaries. - Security defaults from the start: secrets in Keychain/Keystore only, TLS everywhere, deep links validated, WebView locked down (
rules/04). - Budget performance up front: cold start, frame time, and app size budgets in CI, not as a post-launch rescue (
rules/05). - Comply with current store requirements before first submission: privacy manifest + required-reason APIs (iOS), Data safety form + target API level + 16 KB page support (Android) (
rules/01,rules/06).
AUDIT mode
When auditing an existing mobile app, work through the rules files in order and report findings using this convention.
Severity levels
- CRITICAL — Exploitable security flaw or guaranteed user-facing breakage: secrets in SharedPreferences/UserDefaults/NSUserDefaults, tokens in deep-link URLs, unvalidated deep links reaching auth-sensitive screens,
javaScriptEnabledWebView loading untrusted content with a JS bridge, no forced-update mechanism plus a known-bad shipped version, biometric auth gating a boolean instead of a key. - HIGH — Likely production incident or store rejection: missing crash reporting/symbolication, blocking main thread on I/O, no kill switch for a server-dependent feature, store policy violations (missing privacy manifest entries, stale target API), unbounded silent-push reliance, sync without conflict resolution.
- MEDIUM — Degrades quality or future velocity: no DI seams (untestable), monolithic module (slow builds), missing list virtualization, no startup budget, permission prompts fired at launch, no staged rollout process.
- LOW — Hygiene: missing snapshot tests, inconsistent navigation patterns, unbatched analytics, image caching misconfiguration.
Finding format
[SEVERITY] <rule-file>#<rule> — <one-line title>
Location: <file:line or module>
Evidence: <the offending code/config, quoted>
Impact: <what breaks, who exploits it, or what it costs>
Fix: <concrete change, with code where non-obvious>
Order the report by severity, then by blast radius. An audit that returns only style nits has failed — check the CRITICAL list above explicitly and state "verified absent" for each.
Rules index
| File | Covers |
|---|---|
| rules/01-platform-and-stack.md | Native vs cross-platform decision, React Native new architecture, Flutter, KMP/CMP status, when web/PWA suffices, minimum OS floors, target SDK policy, current platform baselines |
| rules/02-architecture-and-state.md | Unidirectional data flow (MVVM/MVI/TCA), state modeling, dependency injection, modularization for build times, navigation patterns |
| rules/03-offline-background-push.md | Offline-first design, local DB as source of truth, sync engines, conflict resolution, mutation queues, optimistic UI, iOS background modes, WorkManager/Doze, APNs/FCM, token lifecycle, permission timing UX |
| rules/04-security.md | Keychain/Keystore, certificate pinning tradeoffs, biometrics gating keys, App Attest/Play Integrity, token handling, deep link validation, root/jailbreak detection honesty, WebView hardening, obfuscation reality, OWASP MASVS |
| rules/05-performance.md | Startup budgets, main-thread discipline, jank/frame budgets, list virtualization, image loading, memory pressure, battery, app size, ANR avoidance, MetricKit/Android vitals |
| rules/06-release-and-operations.md | Store submission requirements, phased rollouts, feature flags/kill switches, forced updates, crash reporting, OTA updates policy, API versioning for old clients, testing strategy |
| rules/07-swift-language.md | Swift as a language (any target, incl. server-side Swift): Swift 6 strict concurrency (actors, Sendable, isolation), value semantics/COW, protocol-oriented design, optionals/typed throws, ARC and retain cycles, unsafe pointer/C interop, SwiftPM supply chain (Package.resolved, registry signing, binary checksums), Swift Testing vs XCTest |
Top 10 non-negotiables
- Secrets live in Keychain (iOS) or Keystore-backed encrypted storage (Android) — never in UserDefaults, SharedPreferences, plist files, or hardcoded in the binary. The binary is public; assume it is decompiled the day you ship.
- The server enforces; the client suggests. Any authorization, entitlement, price, or integrity decision made only client-side is a finding. Root detection, pinning, and obfuscation are deterrents that raise cost — never the control.
- Never block the main thread on I/O, parsing, or crypto. Main thread is for UI. Violations are jank on iOS and ANRs (and Play Store visibility penalties) on Android.
- Ship a forced-update mechanism in v1.0. A version-check endpoint plus a blocking upgrade screen. The release where you discover you need it is the release you cannot fix.
- Every risky or server-dependent feature ships behind a remotely controllable kill switch. You cannot roll back a binary; you can flip a flag.
- The API must tolerate every app version still in the wild. Version every client request; never remove or repurpose fields a shipped binary reads; test the oldest supported client in CI against new server releases.
- Offline is a designed state, not an error state. Local database as source of truth, queued mutations with idempotency keys, explicit conflict resolution. If you choose online-only, fail with designed UX, not spinners.
- Biometric auth must gate a cryptographic key, not a boolean.
if (authenticated) { unlock() }is patchable with one Frida hook; a key released by the secure enclave/StrongBox is not. - Crash reporting with symbol upload (dSYM/mapping) wired into CI before first release, with crash-free-session rate monitored per release and gates on staged rollout promotion.
- Meet current store requirements proactively: iOS — privacy manifests and required-reason API declarations (mandatory since May 2024), built with the latest required SDK (iOS 26 SDK as of April 28, 2026); Android — target API 36 by Aug 31, 2026, Data safety form accuracy, 16 KB page-size support (required since Nov 1, 2025 for apps targeting Android 15+).
Files (sota-skills)
-
rules
-
01-platform-and-stack.md 13.8 KB
# 01 — Platform & Stack Choice Stack choice is a one-way door: migrating a shipped app between stacks is a rewrite, and a half-migrated hybrid is worse than either endpoint. Decide deliberately, record the rationale, and revisit only on major product inflection points. ## Current baselines (verified June 2026 — re-verify before relying on specifics) | Item | State | |---|---| | iOS | iOS 26 current (26.5.x); iOS 27 announced at WWDC (June 8–12, 2026), developer betas out, public release expected Sept 2026. Apple uses year-based naming (jumped 18 → 26 in 2025). | | iOS SDK requirement | Since **April 28, 2026**, App Store uploads must be built with Xcode 26 / iOS 26 SDK. Building with the 26 SDK applies the Liquid Glass appearance to system controls by default — re-test UI on SDK bump. | | Android | Android 17 (API 37) stable since June 16, 2026 (Pixels first, OEM rollouts ongoing); Play target-API mandate remains API 36 (next row). | | Play target API | New apps and updates must target **API 36 by Aug 31, 2026** (API 35 floor for Wear OS / Android TV). Stale targets make the app invisible to new users on newer devices. | | Play 16 KB pages | Since **Nov 1, 2025**, new apps/updates targeting Android 15+ must support 16 KB page sizes on 64-bit devices. Pure-JVM/Kotlin apps comply automatically; anything with native `.so` libraries (including most RN/Flutter plugins) must use compatible builds. Verify in Play Console app bundle explorer. | | Swift | Swift 6.x on current Xcode (verify the latest). Swift 6 strict concurrency is the norm for new code. | | Kotlin | Kotlin 2.x with K2 compiler; coroutines/Flow standard. | | SwiftUI / Compose | Default UI toolkits for new native code on both platforms. UIKit/Views are for interop, framework gaps, and legacy — not greenfield screens without a stated reason. | | React Native | 0.86 (Jun 2026) — fixes the Android 15+ edge-to-edge issues (insets, `KeyboardAvoidingView`) that the mandatory API-36 edge-to-edge (1.4) exposes; repo moved to the `react` GitHub org under the React Foundation. 0.85 (Apr 2026) **removed** the Bridge from the codebase entirely (no fallback, no interop, no shim). New Architecture (JSI + Fabric + TurboModules) became non-disableable in 0.82 (Oct 2025); the Bridge interop layer stayed functional through 0.84. Hermes is the default engine on both platforms. | | Flutter | recent stable (~4 releases/year, verify current); Material/Cupertino libraries being split into separately-versioned packages. | | Kotlin Multiplatform | KMP stable since 2023 for shared logic. Compose Multiplatform for iOS **stable since 1.8.0 (May 2025)** — production-ready (Netflix, Cash App scale), but iOS fidelity still trails SwiftUI for platform-idiomatic feel; budget per-platform polish. | | OWASP MASVS | v2.1 current (adds MASVS-PRIVACY); verification levels replaced by MAS profiles + MASWE weakness enumeration. MASTG v2.0.0 stable (June 2026) supplies the test procedures. | ## Rules ### 1.1 Choose the stack from team and product constraints, not fashion Work through the factors in priority order and stop at the first decisive one: 1. **Team skills.** A Swift/Kotlin team forced onto Flutter (or a JS team forced onto native) ships worse software for at least a year. For teams under ~8 engineers, existing skills dominate every other factor. 2. **Platform fidelity required.** Heavy platform integration — widgets, watchOS/Wear OS companions, App Intents/Siri, CarPlay/Android Auto, camera pipelines, real-time audio, platform-design-language UX — favors native. Cross-platform frameworks *can* reach all of these, but every integration point is a bridge you write, test, and maintain on two platforms forever. 3. **Where is the expensive code?** If the hard part is business logic (sync engine, domain rules, pricing, crypto), KMP shares the logic and keeps fully native UI — the lowest-risk sharing model. If the hard part is hundreds of screens, RN/Flutter/CMP share UI too, at the cost of fidelity and bridge maintenance. 4. **One platform or two?** A single-platform product gains nothing from a cross-platform framework. Don't pay the abstraction tax for a hypothetical second platform that may never ship. 5. **Hiring market.** RN taps the largest pool (JS/TS); Flutter and KMP pools are smaller but strong; native pools are deep but bid up. Defaults when nothing else dominates: | Situation | Default | |---|---| | New native iOS | SwiftUI + Swift 6 concurrency; UIKit interop where SwiftUI gaps bite | | New native Android | Jetpack Compose + Kotlin coroutines/Flow | | Cross-platform, JS/TS team | React Native (New Architecture only) with Expo tooling | | Cross-platform, pixel-identical brand UI | Flutter | | Kotlin team wanting shared UI | Compose Multiplatform | | Share logic, keep native UI | Kotlin Multiplatform (logic-only) | Anti-patterns to flag in audits: - A cross-platform app with > ~30% platform-specific native code per platform — the sharing premise has failed; the team maintains three codebases. - Two stacks in one app (e.g., RN screens embedded in a native app "temporarily" for 3+ years) without a written convergence plan. - Framework chosen by a since-departed engineer's preference, with no one remaining who can debug the native layer. ### 1.2 Ask whether a web app suffices before building an app at all An installable app is justified by at least one of: offline operation, push notifications as a core loop, background processing, deep hardware access (BLE, NFC, sensors, camera pipelines), home-screen presence as a retention strategy, or store distribution as an acquisition channel. If the product is "a website the user visits sometimes," a responsive web app or PWA avoids the entire cost structure of this skill: store review latency, binary permanence, two codebases, forced-update plumbing, annual SDK ratchets. PWAs on iOS remain second-class (constrained push and background capabilities relative to Android); treat current iOS PWA capability as something to verify against Safari/WebKit release notes, not assume in either direction. The hybrid middle ground — a native shell around WebViews — buys store presence and push at the cost of web-feeling UX. It is legitimate for long-tail content screens inside a real app (see WebView hardening, rules/04 §4.7), and a smell as the app's primary architecture for interaction-heavy products. ### 1.3 Set the minimum OS floor by data, and write it down - A policy that ages well: **latest major minus 2** (e.g., iOS 24-equivalent floor under iOS 26; Android floor around API 28–30 depending on market). But check *your* analytics, not global stats — emerging-market Android skews years older than US iOS; enterprise fleets pin old versions. - Raising the floor later is cheap: existing users keep the last compatible binary; you stop shipping them new features. Lowering a floor is impossible. Still, don't start lower than your market demands — every supported major is test-matrix cost. - Every `if #available` / `Build.VERSION.SDK_INT` branch is a permanent test obligation. A floor of N-2 keeps the matrix at three majors. ```swift // BAD: floor set by a developer's personal device // IPHONEOS_DEPLOYMENT_TARGET = 26.0 // → cuts a third of devices for zero product reason // GOOD: floor recorded with rationale and a revisit date // IPHONEOS_DEPLOYMENT_TARGET = 24.0 // Covers 96% of our MAU (analytics snapshot 2026-05). // Lets us use Observation + NavigationStack unconditionally. // Revisit every September after the new iOS ships. ``` ```kotlin // BAD: copy-pasted template value nobody owns minSdk = 21 // Android 5.0, 2014 — forces multidex hacks and dead API branches // GOOD minSdk = 28 // 99.2% of our installs (Play Console, 2026-05); revisit annually ``` ### 1.4 Distinguish minSdk/deployment target from targetSdk/build SDK These are different knobs with different policies: - **Build SDK / targetSdk** tracks *store mandates*: iOS 26 SDK since April 2026; Android target API 36 by Aug 31, 2026. Bump within weeks of each annual deadline. Bumping in the deadline week under pressure is how behavior-change regressions ship to 100% of users at once. - **minSdk / deployment target** tracks *your users* (1.3) and changes rarely. Each Android targetSdk bump activates behavior changes for your app. For API 36 specifically: - **Edge-to-edge is mandatory** — the opt-out flag is ignored; audit every screen for window-inset handling (status/navigation bar overlap, IME insets). - **Predictive back is on by default** — `onBackPressed()` is no longer called and `KEYCODE_BACK` is not dispatched; migrate to `OnBackInvokedCallback`/`BackHandler` or back navigation silently breaks. Treat the annual targetSdk bump as a named project with an owner: read the behavior-changes page, grep for affected APIs, test on the new OS, then bump — not the reverse order. ### 1.5 React Native: New Architecture only, Hermes, no orphaned bridges - The legacy architecture and Bridge interop layer are gone (interop functional through 0.84; deleted in RN 0.85). Reject any dependency that hasn't migrated to TurboModules/Fabric — it will not load. Audit `package.json` for unmaintained native modules (last publish > 18 months, open New-Architecture issues) during every stack review. - Stay within ~2 versions of current RN. Upgrade debt compounds brutally because Gradle, AGP, Xcode, and CocoaPods/SPM drift underneath the framework; a 5-version jump is routinely a multi-week project. - Every custom native module is a maintenance contract across two platforms. Prefer maintained community modules (check Expo Modules ecosystem first); isolate unavoidable custom native code behind a single typed TS interface so it can be replaced without touching product code. - Use Expo tooling (prebuild, EAS) even for "bare" apps unless a hard constraint forbids it — hand-maintained native projects for RN apps are where upgrades go to die. ```ts // BAD: native module accessed ad hoc from product code everywhere import { NativeModules } from 'react-native'; NativeModules.PaymentsBridge.charge(amountString); // untyped, untestable, scattered // GOOD: one typed boundary, mockable in tests // payments/native.ts export interface Payments { charge(cents: number, currency: string): Promise<Receipt>; } export const payments: Payments = TurboModuleRegistry.getEnforcing<PaymentsSpec>('Payments'); ``` ### 1.6 Flutter and Compose Multiplatform: own the native edges - Flutter renders its own pixels — brand-consistent by construction, but platform conventions (text selection behavior, scroll physics, context menus, accessibility traits) need deliberate effort; allocate design review on real devices of both platforms, not just goldens. - Keep Flutter within one stable release of current (quarterly cadence) and watch the Material/Cupertino package split — pinning old design packages while upgrading the SDK is the new upgrade hazard. - CMP on iOS is stable but young: profile scrolling and text-heavy screens on real iPhones (jank and memory regressions are the reported weak spots), and keep an escape hatch — CMP interops cleanly with SwiftUI screens, so the riskiest screens can go native without abandoning the shared core. - For all three sharing models, the **plugin/native layer is your risk register**: list every plugin with native code, its maintenance status, and its 16 KB page-size compliance (Android, see baselines). ### 1.7 Cross-platform does not mean zero native engineers Budget at least part-time iOS and Android native capability for any RN/Flutter/KMP team. Build systems, store submissions, native crash triage, permission flows, background-execution quirks, and annual platform behavior changes all land in native code. An RN team with nobody who can read a symbolicated native stack trace is blind to an entire class of production crashes — typically the worst ones (startup, OOM, native module misuse). ### 1.8 Record the decision Create `docs/adr/0001-mobile-stack.md` (or equivalent) capturing: the chosen stack; the 1.1 factors as actually weighed; minimum OS floor with revisit cadence; explicit non-goals ("no iPad-optimized layout in v1", "no tablet Android"); and the trigger conditions that would reopen the decision. Auditors: absence of any recorded rationale on a multi-platform codebase is a MEDIUM finding — it reliably predicts incoherent platform divergence and relitigated arguments. ## Audit checklist - [ ] Stack rationale recorded (ADR or equivalent); the actual codebase matches it; no second stack accreting without a convergence plan. - [ ] iOS: built with the currently required SDK (iOS 26 SDK as of Apr 28, 2026); Apple "Upcoming Requirements" page reviewed and nothing unaddressed. - [ ] Android: targetSdk meets the current Play deadline (API 36 by Aug 31, 2026); API-36 behavior changes (edge-to-edge insets, predictive back) handled, not suppressed with opt-out flags. - [ ] Android: 16 KB page-size compliance verified in Play Console bundle explorer if any native libraries are present (includes RN/Flutter plugin `.so` files). - [ ] minSdk / deployment target justified by user analytics, documented, with a revisit cadence; no dead `#available`/`SDK_INT` branches below the floor. - [ ] React Native: within ~2 versions of current stable; New Architecture throughout; Hermes enabled; no dependencies stranded on the removed legacy architecture; custom native code behind one typed boundary. - [ ] Flutter: within one stable release of current; design-package versions coherent with SDK; no abandoned plugins. - [ ] KMP/CMP: the shared-code boundary is deliberate (logic-only vs shared UI); iOS-specific polish has a named owner; riskiest screens have a native escape hatch. - [ ] Plugin/native-module inventory exists with maintenance status per entry. - [ ] Team has native Swift + Kotlin capability for store, crash, and build-system work even if the app is cross-platform. - [ ] If the product could be a website, someone has written down why it's an app. -
02-architecture-and-state.md 14.4 KB
# 02 — Architecture & State Mobile apps die from state bugs: stale screens, double-fired effects, races on rotation and backgrounding, and untestable god-objects. The cure is the same on every stack — unidirectional data flow, explicit state machines, injected dependencies, and enforced module boundaries. None of this is optional ceremony; each rule below pays for itself in a class of production bug it makes impossible. ## Rules ### 2.1 Unidirectional data flow, exactly one pattern per codebase State flows down, events flow up, and every piece of state has exactly one writer. Pick the platform-idiomatic flavor and apply it uniformly: - **iOS/SwiftUI:** `@Observable` view models (MVVM) or TCA-style reducers. One `@MainActor` observable object per screen owning that screen's state. - **Android/Compose:** ViewModel exposing a single `StateFlow<UiState>` (MVVM) or an MVI reducer. Collect with `collectAsStateWithLifecycle` — plain `collectAsState` keeps collecting while backgrounded. - **React Native:** server state in TanStack Query (cache, refetch, invalidation), client state in Zustand/Redux Toolkit; components render state and dispatch events. Don't reimplement a server cache in Redux by hand. - **Flutter:** BLoC or Riverpod; widgets are pure functions of state. Two patterns in one codebase is worse than either alone: every screen transition crosses a paradigm boundary, and new code copies whichever pattern the author saw last. Auditors: mixed paradigms (half MVVM, half massive-view-controller; Redux and ad-hoc context state interleaved) is a MEDIUM finding with a migration-plan remediation, not a rewrite demand. ```kotlin // BAD: three writers, no owner, untestable, races on config change @Composable fun CartScreen(repo: CartRepo) { var items by remember { mutableStateOf(listOf<Item>()) } LaunchedEffect(Unit) { items = repo.load() } // writer 1 Button(onClick = { repo.items.add(item) // writer 2 GlobalScope.launch { repo.sync() } // leaks past screen }) { Text("Add") } } // GOOD: single owner; events in, immutable state out class CartViewModel(private val repo: CartRepo) : ViewModel() { private val _state = MutableStateFlow<CartState>(CartState.Loading) val state: StateFlow<CartState> = _state.asStateFlow() fun onEvent(e: CartEvent) = when (e) { is CartEvent.Add -> viewModelScope.launch { _state.update { it.withPendingItem(e.item) } repo.add(e.item) // repo persists + queues sync (rules/03) } // ... } } @Composable fun CartScreen(vm: CartViewModel) { val state by vm.state.collectAsStateWithLifecycle() CartContent(state = state, onEvent = vm::onEvent) // stateless, previewable } ``` ```swift // GOOD: same shape on iOS @MainActor @Observable final class CartViewModel { private(set) var state: CartState = .loading private let repo: CartRepository init(repo: CartRepository) { self.repo = repo } func send(_ event: CartEvent) { /* reduce + structured Task */ } } ``` ### 2.2 Model UI state as a closed type, not a pile of booleans `isLoading + error + data` triplets allow 8 combinations, of which 5 are bugs ("loading spinner over an error banner over stale data"). Use sealed types / enums with associated data so illegal states don't compile: ```swift // BAD final class OrdersVM { var isLoading = false var error: Error? var orders: [Order] = [] // loading == true && error != nil && !orders.isEmpty — what renders? } // GOOD: every case is a designed screen enum OrdersState: Equatable { case loading case loaded(orders: [Order], refreshing: Bool, isStale: Bool) case empty case failed(message: String, retryable: Bool) } ``` ```kotlin sealed interface OrdersState { data object Loading : OrdersState data class Loaded(val orders: List<Order>, val refreshing: Boolean, val isStale: Boolean) : OrdersState data object Empty : OrdersState data class Failed(val message: String, val retryable: Boolean) : OrdersState } ``` Include offline/stale variants (`isStale`) — offline-first behavior (rules/03) is unrepresentable without them, and "stale data shown while refreshing" is the single most common mobile screen state. Each sealed case doubles as a snapshot-test fixture (2.9). ### 2.3 State must survive process death and configuration change The OS kills backgrounded apps routinely; Android additionally recreates activities on rotation, resize, and locale change. Triage every piece of state into one of three buckets: 1. **Ephemeral UI state** (scroll position, half-typed text, selected tab): `SavedStateHandle` / `rememberSaveable` (Android), `@SceneStorage` / state restoration (iOS). Losing a user's half-typed review to a phone call is a bug users *feel*. 2. **Durable data:** the local DB (rules/03). Never "it's still in the singleton" — after process death it isn't, and the crash on unwrapping it lands in your top-5 crash signatures. 3. **In-flight work:** must be resumable or idempotent. A checkout that corrupts if the process dies mid-request is a HIGH finding; the outbox pattern (rules/03 §3.2) is the fix. Make it testable in QA: Android — enable "Don't keep activities" and use `adb shell am kill` on the backgrounded app; iOS — Xcode's simulated termination, plus launch-into-deep-link and launch-from-notification paths (which are also cold starts with no warm state). ```kotlin // BAD: survives rotation only by accident, dies with the process object DraftHolder { var draft: String = "" } // GOOD class ComposeViewModel(private val saved: SavedStateHandle) : ViewModel() { var draft: String get() = saved["draft"] ?: "" set(v) { saved["draft"] = v } } ``` ### 2.4 Effects are owned by the state owner and tied to lifecycle - Network calls, timers, and subscriptions launch from the state owner's scope — `viewModelScope`, or structured-concurrency `Task`s tied to the observable's lifetime — never from view/render code, and never on `GlobalScope` / detached tasks. Views re-render arbitrarily often; an effect in a view body fires arbitrarily often. A `GlobalScope` job outlives the screen and writes to dead state. - **One-shot effects** (navigate, toast, haptic) are *consumed events*, not state: a `Channel`/`SharedFlow(replay=0)` on Android, an `AsyncStream` or explicit callback on iOS. Modeling "navigate to success screen" as a sticky `Bool` re-fires it on every rotation — the canonical double-navigation bug. - Effects must be cancellable: leaving the screen cancels its in-flight loads (structured concurrency gives this for free if you don't detach). ```kotlin // BAD: sticky event state — re-navigates on every config change data class State(..., val navigateToSuccess: Boolean = false) // GOOD: consumed exactly once private val _effects = Channel<CartEffect>(Channel.BUFFERED) val effects = _effects.receiveAsFlow() // collector: LaunchedEffect(Unit) { vm.effects.collect { handle(it) } } ``` ### 2.5 Dependency injection: constructor injection behind interfaces, one composition root - Every view model takes its dependencies (repositories, clock, dispatchers) via constructor, typed as protocols/interfaces. No `Foo.shared` / `object` singletons reached from inside business logic — they make tests order-dependent, previews impossible, and parallel test execution flaky. - One composition root at the edge: Hilt/Koin modules (Android), an `AppDependencies` container or swift-dependencies (iOS — the pattern matters, not the framework), a context/DI shell (RN). Product code never constructs its own infrastructure. - **Inject the clock and the dispatchers/schedulers.** Time and threading are dependencies. Hardcoded `Date()` / `Dispatchers.IO` / `DispatchQueue.global()` is the root cause of most flaky mobile test suites. ```swift // BAD final class ProfileVM { func load() async { let user = try? await APIClient.shared.currentUser() // unmockable self.greeting = user.map { greet($0, at: Date()) } // untestable at midnight } } // GOOD protocol UserFetching { func currentUser() async throws -> User } @MainActor @Observable final class ProfileVM { private let api: UserFetching private let now: () -> Date init(api: UserFetching, now: @escaping () -> Date = Date.init) { self.api = api; self.now = now } } ``` ```kotlin // GOOD: dispatchers injected, swapped for a TestDispatcher in tests class SyncRepo( private val api: Api, private val io: CoroutineDispatcher, // = Dispatchers.IO in prod module ) ``` ### 2.6 Modularize for build time and ownership, not aesthetics Monolithic app modules hit a wall: every change triggers near-full rebuilds, merge conflicts concentrate, and layering is unenforceable. The structure that works: - **Layers:** `:core:*` (network, database, design-system, analytics) ← `:feature:*` (one module per user-facing feature) ← `:app` (composition root + navigation wiring only, near-zero code). - **Features never depend on features.** Cross-feature flows go through navigation contracts or shared core abstractions. The first feature-to-feature import is the first brick of the big ball of mud — make it a lint/CI error (Gradle dependency rules, SPM target graph, eslint-plugin-boundaries, Dart `import_lint`). - Split api/impl where compile-time fan-out hurts: features depend on `:core:network:api` (interfaces), only `:app` sees `:core:network:impl` — changing the implementation then rebuilds one module. - iOS: same shape with local SPM packages; keep the Xcode project a thin shell. RN/Flutter: package-per-feature with enforced import rules. - **Thresholds:** under ~30k LOC a single module is fine — don't gold-plate. Beyond that, incremental build time and test isolation pay for the structure. Track clean and incremental build times in CI so the wall is visible before you hit it. ### 2.7 Navigation is declarative, centralized, and deep-link-addressable - One navigation system per app: Compose Navigation with type-safe routes, SwiftUI `NavigationStack` driven by a route enum in a router/coordinator, React Navigation, GoRouter. Ad-hoc `present(_:)` / `startActivity()` calls scattered through views defeat deep linking, restoration, and testing. - **Routes are data.** If a screen can't be expressed as a serializable route value, it can't be deep-linked, restored after process death, or opened from a push notification — and all three will be product requirements eventually. ```swift enum Route: Hashable, Codable { case orders case order(id: String) case settings(SettingsSection) } // NavigationStack(path: $router.path) — the whole stack is a [Route], save/restore trivially ``` - Every route reachable from a notification or deep link must handle being the **first** screen: cold start, no back stack, possibly no valid session. Build the pipeline once, centrally: parse → validate (security: rules/04 §4.6) → auth-gate → synthesize a sensible back stack (order detail gets an Orders parent, not an empty stack). - Navigation logic (which route follows which event) lives in the state owner / coordinator, not in view code — it's business logic and needs tests. ### 2.8 Repository layer mediates all data access Views and view models never touch the network client or the DB directly. One repository per domain aggregate decides: cache vs network, offline queueing (rules/03), retry policy, and DTO → domain mapping. - **DTOs do not leak above the repository.** Wire types change with the API; domain types change with the product. Coupling screens to wire types turns every backend rename into a 40-file diff — and old shipped binaries pin old wire assumptions (rules/06 §6.8), so the mapping layer is also where tolerance for unknown/missing fields lives. - Repositories expose reactive reads (`Flow` / `AsyncSequence` / Query observables) backed by the DB, plus imperative commands. They are interfaces in `:core:*:api`, faked in view-model tests. ```kotlin // BAD: VM knows about Retrofit DTOs and Room entities class OrdersVM(private val api: OrdersApi, private val dao: OrdersDao) // GOOD interface OrdersRepository { fun orders(): Flow<List<Order>> // observes local truth suspend fun refresh(): RefreshResult // network → DB suspend fun cancel(id: OrderId): Result<Unit> // queued mutation } ``` ### 2.9 Testing follows from the architecture If 2.1–2.8 hold, testing is cheap. If testing is hard, the architecture is wrong — fix the architecture, don't write more heroic tests. - **Unit (the bulk):** reducers/view models with fake repositories; sealed-state assertions; injected `TestDispatcher`/test clock. Milliseconds each, thousands run per commit. - **Snapshot:** every design-system component, and key screens rendered once per sealed state case (2.2 fixtures), across dark mode, large font scale, and RTL. - **UI/E2E:** a handful of critical flows only — login, the core loop, purchase. Expensive and flaky by nature; keep the pyramid shaped like a pyramid. Full strategy in rules/06 §6.9. ## Audit checklist - [ ] Single UDF pattern applied consistently; every screen's state has exactly one writer; no mixed paradigms without a written migration plan. - [ ] UI state modeled as sealed/closed types with stale/offline variants; no loading/error/data boolean piles. - [ ] State triaged for process death: saved-state handles for ephemeral UI, DB for durable data, idempotent/resumable in-flight work; QA exercises "Don't keep activities" / simulated termination / launch-from-notification. - [ ] No `GlobalScope`/detached effects; no network or side effects launched from view/render code; in-flight work cancelled on screen exit. - [ ] One-shot effects (navigation, toasts) delivered as consumed events, never sticky state. - [ ] Constructor injection throughout; no singleton reach-ins from business logic; clock and dispatchers injectable; one composition root. - [ ] Module graph: features don't import features (enforced by tooling, not convention); composition root only in `:app`; build times tracked in CI. - [ ] Navigation centralized, route-as-data; every push/deep-link target survives cold-start entry with synthesized back stack; navigation decisions unit-tested. - [ ] Repository layer present; DTOs don't leak past it; repositories exposed as fakeable interfaces with reactive reads. - [ ] Test pyramid intact: fast unit bulk on VMs/reducers, snapshot coverage of design system + state cases (incl. dark/RTL/font scale), ≤ ~10 E2E flows. -
03-offline-background-push.md 16.6 KB
# 03 — Offline-First, Background Work & Push Mobile networks fail constantly — elevators, subways, captive portals, congested stadium cells — and the OS suspends or kills your process whenever it likes. Apps that treat connectivity and foreground execution as defaults exhibit the classic failure modes: infinite spinners, lost user input, duplicate writes, and "works until you background it." This file covers the three disciplines that prevent them. ## Offline-first ### 3.1 Declare the offline posture; if offline-first, the local DB is the source of truth Two valid postures exist: - **Online-only with designed failure UX** — acceptable when stale data is dangerous (live trading, dispatch). Every screen still needs a designed offline state: a clear banner and disabled actions, not a spinner that never resolves. - **Offline-first** — the default for content, messaging, productivity, and field-work apps. For offline-first, one structural rule generates everything else: **the UI reads only from the local database, and the network writes into the database.** - DB options: Room/SQLDelight (Android/KMP), GRDB/SwiftData/Core Data (iOS), SQLite/WatermelonDB/op-sqlite (RN), Drift (Flutter). - Screens observe the DB reactively (Room `Flow`, GRDB `ValueObservation`, Drift watches). The UI never renders a network response directly. - This kills the cache-coherence problem dead: one copy of truth on device, and every screen observing it updates together. No "list shows the old title after editing on the detail screen." ```kotlin // BAD: screen state = network response; other screens show stale copies; // offline = spinner forever class OrdersVM(private val api: OrdersApi) : ViewModel() { suspend fun load() { _state.value = Loaded(api.getOrders()) } } // GOOD: DB is truth; network refreshes DB; all observers update together class OrdersRepository(private val api: OrdersApi, private val dao: OrdersDao) { fun orders(): Flow<List<Order>> = dao.observeOrders().map { it.map(OrderEntity::toDomain) } // UI reads this suspend fun refresh(): RefreshResult = try { val page = api.getOrders(since = dao.syncCursor()) dao.transaction { dao.upsertAll(page.orders.map(::toEntity)) dao.saveSyncCursor(page.nextCursor) } RefreshResult.Ok } catch (e: IOException) { RefreshResult.Offline } // data still renders } ``` ```swift // GOOD (iOS/GRDB): observation drives the screen; refresh is a side concern func observeOrders() -> AsyncValueObservation<[Order]> { ValueObservation.tracking { db in try Order.fetchAll(db) }.values(in: dbQueue) } ``` ### 3.2 Queue mutations in a persistent outbox; make every mutation idempotent Writes made offline — or on links that die mid-request, which is the same thing — go into an **outbox table**, not fire-and-forget coroutines: - Each queued mutation carries a **client-generated idempotency key** (UUIDv4/v7), sent as a header or field; the server deduplicates on it. Without this, retries create duplicate orders, posts, and payments. Any retrying write path without idempotency keys is a HIGH audit finding. - The queue drains via the platform scheduler (3.6/3.7) with exponential backoff and jitter, preserving **per-aggregate ordering** (two edits to the same record must not reorder; edits to different records may parallelize). - Outbox rows survive process death (they're DB rows in the same transaction as the optimistic local write — atomicity matters: local change and its queued upload commit together or not at all). - **Poison-message policy:** after N terminal failures (4xx that isn't auth/409), stop retrying, surface to the user, and report to telemetry. An outbox that retries a permanently rejected mutation forever burns battery and hides data loss. ```sql CREATE TABLE outbox ( id TEXT PRIMARY KEY, -- idempotency key, sent to server aggregate_id TEXT NOT NULL, -- ordering scope op TEXT NOT NULL, -- 'order.cancel', payload JSON below payload TEXT NOT NULL, base_version INTEGER, -- for conflict detection (3.4) attempts INTEGER DEFAULT 0, created_at INTEGER NOT NULL ); ``` ### 3.3 Optimistic UI with visible pending state and visible rollback - Apply the mutation locally immediately — write to the DB with `pending` status in the same transaction as the outbox row — and let the observing UI update instantly. - Render pending state subtly once it's older than a few seconds (dimmed row, clock glyph). Users on bad networks deserve to know what hasn't landed yet. - On terminal failure: roll back the local change **and tell the user** ("Couldn't post your comment — tap to retry"). Silently reverting an edit is data loss from the user's perspective and erodes trust in every future optimistic update. ### 3.4 Choose a conflict-resolution strategy per entity, explicitly "Last write wins" is a decision, not a default you fall into. The escalation ladder, cheapest first: 1. **LWW with server timestamps** — fine for single-writer data (the user's own settings from their own devices, mostly). 2. **Field-level merge** — server merges non-overlapping field updates, rejects overlapping ones back to the client. 3. **Version preconditions** — the client sends `base_version` it edited against; server returns 409 + current state on mismatch; client merges or asks the user. The right default for shared business records. 4. **CRDTs / dedicated sync engines** — for genuinely collaborative data (shared documents, multi-user checklists). Prefer an existing engine or CRDT library over hand-rolling; hand-rolled sync protocols are multi-year bug farms with corruption-shaped failure modes. Write the chosen strategy per entity in the repo (a table in the sync design doc). Auditors: offline-writable entity with no stated strategy = MEDIUM; concurrently-edited data under blind LWW = HIGH (it silently destroys user input). ### 3.5 Sync protocol hygiene - **Delta sync** with a server-issued cursor (`since` token), never repeated full downloads. The server must handle "cursor too old/invalid" with an explicit full-resync signal, and the client must implement it — the untested resync path is where year-old installs break. - Tombstones: deletions must sync as explicit records, or deleted items resurrect from stale caches. - Detect connectivity by **attempting the request**, not by trusting reachability APIs — captive portals report "connected" while blackholing traffic. Reachability/`ConnectivityManager` signals are for *deferring* retries, never for *gating* attempts. - Sync work is: off the main thread, batched, compressed, cancellable, and resumable mid-batch (commit progress per page, not at the end). - Instrument it: sync success rate, drain latency, queue depth, and conflict rate are product health metrics, not nice-to-haves. ## Background work ### 3.6 iOS: background execution is a budgeted privilege, not a capability - Use `BGTaskScheduler`: `BGAppRefreshTask` for short periodic refresh, `BGProcessingTask` for longer maintenance (with `requiresNetworkConnectivity`/`requiresExternalPower` set honestly). The OS schedules based on user habits and battery — treat scheduling as a **hint**. Anything that *must* happen also happens on next foreground. - Every task registers an **expiration handler**, checkpoints progress, and calls `setTaskCompleted(success:)`. Tasks that run to the buzzer get throttled in future scheduling. - Large uploads/downloads use **background `URLSession`** — transfers continue after process death and relaunch the app on completion. Keeping a process alive to babysit a transfer is the wrong tool and will be killed. - Dedicated background modes (location, audio, VoIP/PushKit) only when the product genuinely *is* that feature — App Review rejects mode abuse, and PushKit VoIP pushes that don't present calls get the app killed. - **Silent pushes (`content-available: 1`) are throttled and best-effort:** budgeted to roughly a few per hour, dropped under battery pressure, and never delivered to force-quit apps. An architecture that *requires* silent push delivery is broken by design. Use them only as a "sync sooner" accelerant layered over pull-based sync that works without them. ```swift BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.outbox", using: nil) { task in let work = Task { await syncEngine.drainOutbox(checkpointing: true) } task.expirationHandler = { work.cancel() } // mandatory Task { task.setTaskCompleted(success: await work.value) } } ``` ### 3.7 Android: WorkManager for deferrable work; respect Doze, don't fight it - **WorkManager** is the default for all deferrable-guaranteed work (sync, outbox drain, uploads): constraints (`NETWORK_CONNECTED`, charging), exponential backoff, **unique work names** (`enqueueUniqueWork` — without them, every trigger enqueues a duplicate chain), and persistence across reboots. ```kotlin val drain = OneTimeWorkRequestBuilder<OutboxWorker>() .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() WorkManager.getInstance(ctx) .enqueueUniqueWork("outbox-drain", ExistingWorkPolicy.KEEP, drain) ``` - **Doze and App Standby Buckets** will batch and defer your work — that's the contract. Do not fight them with `setExactAndAllowWhileIdle` alarms or wakelocks for routine sync. Exact alarms are permission-gated and policy-reviewed; they're for user-facing scheduled moments (an actual alarm, a medication reminder), nothing else. - **Foreground services** only for user-initiated, user-visible ongoing work (playback, navigation, active workout) with the correct `foregroundServiceType` declared — Play policy enforces declared types and rejects misuse. "Foreground service to keep sync alive" is both a policy violation and a battery-review magnet. - `BroadcastReceiver.onReceive` does nothing but delegate to WorkManager — slow receivers ANR (rules/05). - FCM **high-priority** messages punch through Doze, but repeated delivery without a user-visible result causes the platform to deprioritize your app's messages. Reserve high priority for genuinely time-critical user-facing events (incoming call, ride arriving); everything else is normal priority. ### 3.8 Every background job is idempotent, checkpointed, and time-indifferent The universal contract both platforms converge on: background time can end mid-instruction. Therefore every job is written to be resumable from a checkpoint, idempotent on re-run (3.2 keys), and indifferent to running now versus in three hours. If the product genuinely demands real-time guarantees, that is a foreground feature or a server-side feature — not a background hack, because there is no reliable background on modern mobile OSes. ## Push notifications ### 3.9 Token lifecycle is server-side state with hygiene - Fetch the APNs/FCM token on every launch and on the rotation callbacks (`didRegisterForRemoteNotificationsWithDeviceToken`, `onNewToken`) — tokens rotate on reinstall, restore-from-backup, and OS updates. Upload on change, keyed to **device + account**, with app version and environment (sandbox/prod APNs mixups are a classic "push works in dev only" bug). - Process feedback: delete tokens on APNs 410/`Unregistered` and FCM `UNREGISTERED` responses. A token table that only grows means paying to push to ghosts and corrupting delivery metrics. - **On logout, disassociate the token from the account server-side immediately.** Pushing user A's message previews to a device now logged in as user B is a CRITICAL privacy finding — and it happens by default if logout only clears local state. - Server side: use token-based APNs auth (p8 key), batch sends, and respect collapse IDs to replace stale notifications instead of stacking them. ### 3.10 Push is a doorbell, not a delivery truck Push delivery is at-most-once, unordered, size-limited (~4 KB), and transits third-party infrastructure. Therefore: - The payload carries **identifiers and a collapse key, not data of record**. The app fetches truth from the API/DB on tap or in the background handler. State synced via push payloads diverges the first time a push is dropped — and pushes are dropped daily. - Minimize sensitive content in payloads (it appears on lock screens and in transit metadata). When the product needs rich-but-private notifications, use the iOS **Notification Service Extension** / FCM data-message handler to fetch or decrypt content on-device before display. - Never put auth tokens or signed URLs with broad scope in payloads (rules/04 §4.4). ### 3.11 Rich, actionable, and well-channeled notifications - iOS: Notification Service Extension for attachments/decryption — it gets ~30 seconds and a tight memory cap, so it must degrade to plain text gracefully, never crash (a crashing NSE silently drops your notification content). Categories + actions for inline reply/approve; communication-style notifications and interruption levels used honestly (`time-sensitive` for genuinely time-sensitive things, or users revoke it). - Android: **channels are mandatory UX architecture** — one channel per user-meaningful category ("Order updates", "Promotions"), with honest default importance, so users can mute marketing without muting the product. One channel for everything earns app-level mutes and uninstalls. - Every notification deep-links to the **exact content** (route-as-data, rules/02 §2.7) through the validated deep-link pipeline (rules/04 §4.6) — landing on the home screen after tapping "Your order shipped" is a quality bug users notice. - Localize and collapse: use collapse IDs / `setGroup` so ten events become one stacked notification, not ten rows. ### 3.12 Ask for notification permission in context, never at first launch Android 13+ requires the runtime `POST_NOTIFICATIONS` permission; iOS always has. Both give you effectively **one** clean shot at the system prompt: - Sequence: the user takes an action whose value depends on notifications ("notify me when it ships") → show your own pre-prompt explaining the concrete value → only then fire the system prompt. Acceptance rates double or better versus prompt-at-launch. - On denial: degrade gracefully, surface a settings deep link *from the relevant feature*, and don't nag on a timer. - iOS **provisional authorization** (quiet delivery to Notification Center without any prompt) is a legitimate warm-up: deliver value first, ask for full alerts later. - A system permission prompt on first launch, before the user has seen any value, is an automatic MEDIUM finding: it maximizes the denial rate and permanently burns the only shot. ## Audit checklist - [ ] Offline posture documented; online-only screens have designed failure states (no infinite spinners). - [ ] Offline-first: UI reads exclusively from the local DB via reactive observation; network writes into the DB; no screen renders a network response directly. - [ ] Mutations persisted in an outbox, committed atomically with the optimistic local write; client idempotency keys sent and deduplicated server-side; per-aggregate ordering preserved; poison-message policy exists. - [ ] Optimistic updates show pending state and roll back visibly with a retry affordance on terminal failure. - [ ] Conflict strategy written down per offline-writable entity; concurrent-edit data is not blind LWW; 409/merge path implemented and tested. - [ ] Delta sync with cursor + tombstones + tested full-resync path; connectivity probed by attempting requests, not reachability flags; sync metrics instrumented. - [ ] iOS: BGTaskScheduler with expiration handlers and completion calls; background URLSession for large transfers; background modes match the product; nothing *depends* on silent push delivery. - [ ] Android: WorkManager with constraints/backoff/unique names; no exact alarms or wakelocks for routine sync; foreground services only user-visible with declared types; receivers delegate immediately; FCM high priority reserved for time-critical user-facing events. - [ ] Every background job idempotent, checkpointed, and correct whether it runs now or hours late. - [ ] Push tokens uploaded on rotation, keyed to device+account, deleted on feedback, disassociated on logout (verified — this is the privacy-critical one). - [ ] Push payloads carry IDs/collapse keys, not data of record or secrets; rich content fetched/decrypted on-device; NSE degrades gracefully. - [ ] Android channels map to user-meaningful categories; notifications deep-link to exact content via the validated route pipeline; collapse/grouping used. - [ ] Notification permission requested in context with a pre-prompt; provisional auth considered on iOS; denial path designed; never prompted at first launch. -
04-security.md 17.7 KB
# 04 — Mobile Security Threat model first: the attacker **owns the device**. They can decompile your binary (jadx, Hopper), hook your functions at runtime (Frida, objection), proxy and strip your TLS, and read any file your app writes. Two consequences drive every rule here: 1. Client-side mechanisms raise attacker *cost* — they are deterrents. Only the server *enforces*. 2. Anything in the binary — strings, endpoints, keys, feature flags — is public the day you ship. Anchor audits to **OWASP MASVS v2.1** (control groups: MASVS-STORAGE, -CRYPTO, -AUTH, -NETWORK, -PLATFORM, -CODE, -RESILIENCE, -PRIVACY) with the **MASTG v2** (v2.0.0 stable since June 2026; modular tests with stable `MASTG-TEST-*` IDs — cite the IDs in findings) for concrete test procedures. An audit should be able to state, per group, what was examined. ## Storage & secrets ### 4.1 Secrets go in Keychain/Keystore — nothing else, ever - **iOS:** Keychain Services. Default to the most restrictive accessibility that works — `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for tokens; never `kSecAttrAccessibleAlways`-class. `ThisDeviceOnly` variants keep items out of backups and off other devices. - **Android:** key material in **Android Keystore** (request StrongBox where available); secrets-at-rest encrypted with a Keystore-held key via a maintained wrapper (e.g., Tink). Note Jetpack's `EncryptedSharedPreferences`/`security-crypto` was deprecated — verify the currently recommended wrapper before adopting one; the architecture (Keystore key + AEAD over the value) is what matters. - `UserDefaults`, `SharedPreferences`, plists, unencrypted SQLite/Room/Core Data, and `Documents/` are **plaintext to a rooted/jailbroken device and frequently included in backups**. Tokens, session cookies, API secrets, or PII caches in any of them = CRITICAL. ```kotlin // BAD — plaintext on disk, readable by root, may land in cloud backups prefs.edit().putString("refresh_token", token).apply() // GOOD — AEAD over the value, key non-exportable in hardware val aead: Aead = keystoreBackedAead("token_key") // Tink AndroidKeystore integration prefs.edit().putString("refresh_token", aead.encrypt(token.toByteArray(), "rt".toByteArray()).b64()).apply() ``` ```swift // GOOD — Keychain with tight accessibility var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "auth.refreshToken", kSecValueData as String: tokenData, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] SecItemAdd(query as CFDictionary, nil) ``` - **Hardcoded secrets are public.** `strings`-level extraction defeats them; "obfuscated" keys delay extraction by hours. Anything truly secret stays server-side. Client "API keys" (Maps, analytics) are identifiers, not secrets — restrict them server-side (bundle ID / SHA-256 signing-cert restrictions, quotas, per-key scopes). - Scope backups away from secret stores: Android `dataExtractionRules` excluding the encrypted prefs/DB files; iOS `URLResourceValues.isExcludedFromBackup` for sensitive caches. - Screen-level leakage: redact sensitive screens in the app switcher (iOS: overlay on `sceneWillResignActive`); Android `FLAG_SECURE` on screens showing credentials/financial data (applied selectively — it also blocks the user's own screenshots); mark sensitive fields to suppress keyboard learning (`textContentType`/`importantForAutofill`, `isSecureTextEntry`). ### 4.2 Biometric auth gates a key, not a boolean `if (biometricSuccess) { showVault() }` is defeated by hooking one return value with Frida. The correct design makes biometrics **cryptographically load-bearing**: the secret is encrypted under a hardware key that the secure element will only operate *after* user authentication. No hook can conjure the plaintext. ```kotlin // BAD — a boolean a hook flips biometricPrompt.authenticate(...) // onSuccess: { unlockVault() } // GOOD — key usable only after biometric auth; decryption fails without it val spec = KeyGenParameterSpec.Builder("vault", PURPOSE_ENCRYPT or PURPOSE_DECRYPT) .setBlockModes(BLOCK_MODE_GCM).setEncryptionPaddings(ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(true) .setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) // per-use, strong class only .setInvalidatedByBiometricEnrollment(true) // new finger ≠ your finger .build() // Pass the Cipher in a CryptoObject through BiometricPrompt; decrypt with the returned cipher. ``` ```swift // GOOD — Keychain item gated by current biometric set let acl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil) // re-enrollment invalidates the item ``` - `biometryCurrentSet` / `setInvalidatedByBiometricEnrollment(true)` are mandatory: without them, an attacker with the passcode adds their own fingerprint and unlocks everything. - Android: require `BIOMETRIC_STRONG` for key release; Class 2 (weak) biometrics must not gate cryptography. - Biometrics are **local user verification only** — never proof of identity to a server. Server auth remains tokens; biometrics merely authorize use of the locally stored token/key. "We send `biometric: true` to the API" is a CRITICAL design finding. ## Network ### 4.3 TLS everywhere; pin only with an exit strategy - No cleartext, no exceptions baked in to "make staging work": iOS ATS stays fully on (any `NSAllowsArbitraryLoads` in a release build is HIGH); Android Network Security Config with `cleartextTrafficPermitted="false"`. - **Certificate pinning** defends users on hostile networks against rogue/compromised CAs. It also bricks your app's networking if keys rotate and the pins don't — and you can't roll back binaries. If you pin: - Pin **SPKI hashes**, not certificates; pin your CA/intermediate or include **at least one backup pin** for an offline-held key. - Use platform mechanisms: Android Network Security Config `<pin-set expiration="...">` (expiration = graceful failure to unpinned TLS rather than permanent brickage); iOS `NSPinnedDomains` in Info.plist or a URLSession challenge delegate. - Ship a **remote kill switch** for pinning enforcement (rules/06 §6.4) and an update path before any planned rotation. - Without a backup pin and a rotation runbook, pinning is a self-inflicted outage scheduled for cert-renewal day — for most apps that's worse than not pinning. - Accept what pinning is not: it does not protect your API from the device's owner — Frida unpinning scripts are commodity. That problem is attestation (4.5) plus server-side controls. ### 4.4 Token handling - OAuth2/OIDC with **short-lived access tokens (minutes-hours) + rotating refresh tokens**, refresh token stored per 4.1. Server implements refresh-token rotation with reuse detection (a replayed old refresh token kills the family). - Third-party/IdP auth flows run in the **system browser** — `ASWebAuthenticationSession` (iOS) / Custom Tabs (Android) — **with PKCE**. Never an embedded WebView: it's phishable (no URL bar), cookie-isolated, and rejected by major IdPs. - **No tokens in URLs.** Not in deep links (history, referrers, other apps' interception), not in push payloads, not in query strings of GETs that hit logs/CDNs, not in analytics events, not in crash breadcrumbs. Magic-link pattern: the link carries a one-time short-lived code, exchanged server-side for tokens. - Logout is a checklist, not a navigation event: revoke server-side → wipe Keychain/Keystore entries → clear WebView cookies/storage → disassociate push token (rules/03 §3.9) → clear in-memory caches. Audit by logging out and inspecting what survives. - Clock skew: never validate token expiry against device time alone (users set clocks wrong); honor server 401s as truth. ### 4.5 App attestation for endpoints worth abusing For endpoints attractive at scale — auth, signup, promotions, scraping-prone content APIs — add hardware-backed attestation, verified **server-side**: - **iOS: App Attest** (+ DeviceCheck bits for per-device state). **Android: Play Integrity API** — SafetyNet Attestation is fully shut down (since January 2025); any code still calling it is dead code at best. - Play Integrity verdict policy is a *decision table*, not a boolean. Current verdict mechanics: `MEETS_STRONG_INTEGRITY` requires hardware-backed signals and a recent security patch (on Android 13+, patched within ~12 months). Hard-requiring strong integrity locks out a long tail of real users on old-but-honest devices. Typical policy: deny on failed basic/device integrity; step-up (challenge, friction) when strong integrity is absent; log everything for tuning. Use the Integrity API's remediation dialogs where user-fixable. - Bind attestation to requests (challenge nonces, not bare verdict caching) or attackers replay verdicts. - Attestation raises bot cost substantially; it is still not absolute (device farms with genuine hardware exist). Keep server-side rate limiting, anomaly detection, and abuse economics as the real control. ## Platform attack surface ### 4.6 Deep links and universal links: untrusted input from hostile neighbors Any app on the device can fire intents/URLs at yours. Rules: - Prefer **verified links**: iOS Universal Links (apple-app-site-association) and Android App Links (`assetlinks.json` + `android:autoVerify="true"`). Custom URI schemes (`myapp://`) are claimable by any installed app — never use them for auth callbacks or sensitive flows. (Exception: OAuth-with-PKCE makes scheme interception unprofitable, but https callbacks remain preferable.) - **One central router** (rules/02 §2.7) validates every inbound link: allowlist host+path patterns, type-check and bound every parameter, then **re-authenticate and re-authorize before showing protected content**. A deep link is a navigation *request*, not an authorization *grant* — `app.example/account/42` shows account 42 only if the current session owns it. Skipping authz because "the screen is deep inside the app" is the classic IDOR-by-deep-link. - Never feed deep-link parameters into: WebView URLs (open redirect → token/session theft), file paths (traversal), SQL, or `Intent` forwarding. ```kotlin // BAD: trusts the URL wholesale, loads attacker-controlled page in an authed WebView fun handle(uri: Uri) { webView.loadUrl(uri.getQueryParameter("next")!!) } // GOOD: allowlist route parsing, typed params, authz at the destination sealed interface DeepLink { data class Order(val id: OrderId) : DeepLink data object Inbox : DeepLink } fun parse(uri: Uri): DeepLink? = when { uri.host != "app.example.com" -> null uri.pathSegments.firstOrNull() == "orders" -> uri.pathSegments.getOrNull(1)?.let { OrderId.parse(it) }?.let(DeepLink::Order) else -> null // unknown = dropped, logged } ``` - Android component hygiene: `android:exported="false"` unless deliberately public; validate callers of exported components; explicit intents internally; no forwarding of received intents (intent-redirection vulnerability class); `PendingIntent.FLAG_IMMUTABLE` unless mutation is specifically required. ### 4.7 WebView hardening Every WebView is a full browser engine you ship, configured by you: - **Default-deny:** JavaScript off unless the content needs it; `allowFileAccess=false`, `allowContentAccess=false`; never load `file://` or untrusted content with file access on; block geolocation/permissions prompts unless required. - **Constrain navigation:** `shouldOverrideUrlLoading` / `WKNavigationDelegate` allowlists your origins; external links go to the system browser/Custom Tabs, which has the URL bar and sandbox your WebView lacks. - **JS bridges are RPC endpoints exposed to whatever page loads.** `addJavascriptInterface` / `WKScriptMessageHandler` rules: attach only when loading your own origins; check the message's source origin per call; expose narrow, typed methods — never generic `eval`, `openUrl`, `getAuthToken` bridges. The canonical CRITICAL chain is: deep-link parameter → WebView URL → hostile page → token-returning JS bridge. Two rules above each independently break that chain; implement both. - Don't build login inside WebViews (4.4); don't share the app's session cookies with arbitrary web content; clear WebView cookies/storage on logout; keep the WebView component updated (Android System WebView updates via Play — minSdk policy affects which engine versions you see). ### 4.8 Root/jailbreak detection: honest deterrence only Hand-rolled detection (su binary checks, jailbreak file paths, hook-framework scans) is bypassed by commodity tooling (Magisk DenyList, Shamiko, Frida hide scripts) precisely for the attackers who matter, while false-positiving on harmless power users. Honest posture: - Prefer **server-verified attestation** (4.5) over client-side checks; decide consequences server-side where they can't be patched out. - Degrade rather than block where possible (hide cached credentials, require fresh auth, disable offline vaults) unless regulation mandates hard blocks — and if it does, document that the block is best-effort. - In design docs and audits, never let root/JB detection be listed as a *control*. An app whose data protection depends on jailbreak detection has no data protection — the protection is Keychain/Keystore hardware semantics (4.1) and server enforcement. ### 4.9 Reverse-engineering posture: obfuscation is a speed bump, design like the binary is open source - Android: **R8** on for release (shrinking + obfuscation) — its real value is size and noise; keep and archive `mapping.txt` per release (also needed for crash symbolication, rules/06). Commercial protectors (DexGuard, iXGuard) buy *time*, justified mainly for finance/DRM threat models; budget for the build-pipeline and crash-debugging tax they impose. - iOS: strip symbols in release; compiled Swift resists casual reading; no `#if DEBUG` backdoors, staging endpoints, or test bypasses compiled into release builds (audit: grep release artifacts, not source). - **Premium features are server-entitled, never client-flag-gated.** A client-side `isPremium` boolean is flipped once in a patched APK and redistributed forever; the server checking entitlement per request is unpatchable. - Logs are an exfiltration channel: release builds log no PII/tokens (Timber tree swap; `os_log` with `%{private}` specifiers; proguard rules don't accidentally keep debug log calls). Crash breadcrumbs follow the same rule. ### 4.10 Privacy is a security surface and a store-enforcement surface - Collect the minimum (MASVS-PRIVACY). Every collected data type must appear accurately in the iOS privacy manifest → Privacy Nutrition Label and the Android **Data safety form**. Mismatches between declared and observed behavior (proxy the app and compare) are both an audit finding and a store-enforcement risk. - Third-party SDKs inherit your permissions and your users' trust: maintain an SDK inventory with each SDK's data collection; on iOS, commonly-used SDKs must ship their own **privacy manifest and signature** — prefer SDKs that do. - iOS ATT: prompt only if you actually track across apps/sites; IDFA reads without consent return zeros and invite rejection. Gate analytics/ads SDK initialization behind consent where GDPR/CCPA applies — initializing then asking is the pattern regulators fine. - Don't request permissions you can avoid (photo *picker* instead of library permission; coarse instead of fine location) — each permission is attack surface, review friction, and user trust spent. ## Audit checklist - [ ] Grep + decompile spot-check: no secrets/tokens/PII in UserDefaults, SharedPreferences, plists, unencrypted DBs, hardcoded strings, or release logs. - [ ] Keychain items use `WhenUnlockedThisDeviceOnly`-class accessibility; Android secrets AEAD-encrypted under Keystore keys; StrongBox/Secure Enclave requested where available. - [ ] Backup rules exclude secret stores; app-switcher snapshots redacted on sensitive screens; `FLAG_SECURE` where warranted; sensitive fields opted out of keyboard learning/autofill. - [ ] Biometrics release hardware-bound keys (`setUserAuthenticationRequired` + STRONG class / `biometryCurrentSet`); enrollment changes invalidate; no boolean-gated auth; nothing sends "biometric ok" to a server as identity. - [ ] ATS fully on / cleartext off in release config; if pinned: SPKI pins + backup pin + expiry + remote disable + rotation runbook. - [ ] OAuth via system browser + PKCE; refresh rotation with reuse detection; no tokens in deep links, pushes, query strings, logs, analytics, or breadcrumbs; logout verified to wipe Keychain/Keystore, WebView state, push association. - [ ] High-value endpoints verify App Attest / Play Integrity server-side with nonce binding and a written per-verdict policy; no SafetyNet remnants; rate limiting exists independently. - [ ] Deep links: verified app/universal links for sensitive flows; central allowlist router; typed params; authz re-checked at destination; params never reach WebView URLs, paths, SQL, or forwarded intents. - [ ] Android: `exported=false` default; explicit internal intents; immutable PendingIntents; exported components validate callers. - [ ] WebViews: JS/file access default-off; navigation origin-allowlisted; bridges narrow, typed, origin-checked; no auth flows inside WebViews; WebView state cleared on logout. - [ ] Root/JB posture documented as deterrent; consequences decided server-side; no design doc lists client detection as a control. - [ ] R8/symbol stripping on; mapping files archived; no debug backdoors in release artifacts; premium features server-entitled. - [ ] Privacy manifest + Data safety form match observed network behavior; SDK inventory current; ATT/consent gates precede SDK initialization; permissions minimized (picker over library, coarse over fine). - [ ] Findings mapped to MASVS v2.1 groups; every group either has findings or an explicit "examined, clean." -
05-performance.md 14.4 KB
# 05 — Performance & Quality Mobile performance is policed by the OS and the stores, not just by users: ANRs and crashes above Android vitals "bad behavior" thresholds reduce Play Store visibility; the iOS watchdog kills apps that block at launch; janky lists drive uninstalls measurable in retention curves. Budgets live in CI and production telemetry — a performance "initiative" after launch is an admission the budgets didn't exist. ## Budgets Set these in the repo (`docs/perf-budgets.md` or CI config). Adjust the numbers per product; never delete the rows. | Metric | Budget | Enforcement | |---|---|---| | Cold start → first meaningful content | ≤ 2.0 s on a mid-tier device (target 1.5 s) | Macrobenchmark / XCTest launch metrics in CI; Android vitals flags cold start ≥ 5 s | | Warm / hot start | ≤ 1.0 s / ≤ 0.5 s | same | | Frame budget | 16.6 ms @ 60 Hz; 8.3 ms @ 120 Hz — budget for the worst supported device, not your ProMotion dev phone | JankStats / MetricKit hang rate | | Slow / frozen frames | < 5% / < 0.1% (vitals: > 16 ms / > 700 ms) | Android vitals, APM | | ANR rate | < 0.47% of daily sessions (Play bad-behavior threshold; main thread blocked > 5 s) | Play vitals, release gate | | Crash-free sessions | ≥ 99.9% (vitals user-perceived crash threshold: 1.09%) | Crash SDK, rollout gate (rules/06) | | Download size | Tracked per release with a delta gate (e.g., +2 MB needs sign-off). Play: 200 MB compressed per-device download cap from AAB (Play Asset/Feature Delivery beyond); iOS: large apps prompt on cellular | CI size report | | Memory | Flat across a 10-min core-loop soak; survives `onTrimMemory`/`didReceiveMemoryWarning` without data loss | soak test, LeakCanary | ## Rules ### 5.1 Main-thread discipline is rule zero The main thread renders frames and dispatches input — nothing else. JSON parsing, DB queries, image decoding, crypto, disk I/O, and lock waits move off it. Every other rule in this file is downstream of this one. ```swift // BAD: decode on main → dropped frames on every message batch let msgs = try JSONDecoder().decode([Message].self, from: data) self.messages = msgs // GOOD: decode off-main, hop back for the state write let msgs = try await Task.detached(priority: .userInitiated) { try JSONDecoder().decode([Message].self, from: data) }.value await MainActor.run { self.messages = msgs } ``` ```kotlin // BAD: hidden main-thread disk I/O — synchronous commit prefs.edit().putString("draft", text).commit() // commit() blocks; apply() doesn't // GOOD: Room/DataStore are main-safe by construction; suspend functions on IO dispatcher suspend fun saveDraft(text: String) = withContext(io) { dao.saveDraft(text) } ``` - Tooling, debug builds: Android `StrictMode` (`detectDiskReads/detectNetwork().penaltyDeath()` in CI-instrumented runs), iOS Main Thread Checker + `os_signpost` spans. New StrictMode violations fail CI — that's the cheapest perf regression gate that exists. - Hidden main-thread work to hunt in audits: synchronous `SharedPreferences.commit()`, eager DI graph construction at startup, `DateFormatter`/`NumberFormatter` *creation* in list rows (creation is expensive; cache them), oversized `Codable` decodes in `@MainActor` contexts, synchronous `UIImage(named:)` of huge assets, blocking `.get()`/`runBlocking` on futures. - ANR specifics (Android): also caused by slow `BroadcastReceiver.onReceive` (delegate to WorkManager immediately) and input-dispatch timeouts. ANR rate above the vitals threshold suppresses your store ranking — treat the budget as a release gate, not a dashboard. ### 5.2 Cold start: defer everything that isn't first-frame Instrument the segments — process start → `Application.onCreate` / `didFinishLaunching` → first frame → first *meaningful* content — and attack the largest one. Typical findings, in order of frequency: 1. **Third-party SDK pile-up in `onCreate`.** Eight SDKs initializing synchronously is the classic startup killer. Lazy-initialize via DI; use Jetpack `App Startup` / deferred initializers; analytics, attribution, and ads SDKs initialize *after* first frame (also required for consent gating, rules/04 §4.10). 2. **Synchronous I/O before first frame** — migrations, feature-flag fetches, "quick" prefs reads. First frame renders from local DB/cache (rules/03); flags use last-known values. 3. **Eager DI graphs** — constructing the entire object graph at launch. Scope construction to the first screen's needs. Platform tooling: - Android: **Baseline Profiles** are mandatory equipment — precompiled hot paths routinely cut cold start 20–30%. Generate with Macrobenchmark, verify in CI on a physical-device runner, regenerate per release train. - iOS: minimize dylib count, avoid `+load` and heavy static initializers, profile with Instruments' App Launch template; watch the watchdog (apps blocking the main thread at launch get killed — visible in MetricKit as launch terminations). - Show real content skeletons, never a blocking splash hiding three seconds of synchronous setup. A splash screen longer than ~500 ms is concealing a 5.2 violation. ### 5.3 Lists: virtualize, stabilize identity, zero work per row - Use the virtualizing primitive: `LazyColumn` (stable `key` + `contentType`), SwiftUI `List`/`LazyVStack` with stable `Identifiable` IDs, RN **FlashList** (or `FlatList`; never `.map()` inside a `ScrollView`), Flutter `ListView.builder` with `itemExtent`/`prototypeItem` when rows are uniform. - **Stable keys are correctness and performance:** unstable identity (index keys, regenerated UUIDs) forces full rebind/recompose on every change, breaks item animations, and corrupts scroll position on insert. ```kotlin // BAD: index keys — insert at top rebinds every row LazyColumn { itemsIndexed(orders) { i, o -> OrderRow(o) } } // GOOD LazyColumn { items(orders, key = { it.id }, contentType = { it.type }) { OrderRow(it) } } ``` - Row bodies do **zero** work: no formatter creation, no date math, no image decode (5.4), no allocation-heavy mapping. Precompute display fields in the repository/mapper layer (`displayDate: String` on the UI model), off-main. - Paginate at the data layer (Paging 3, cursor queries, TanStack Query infinite) — loading 10k rows and virtualizing only the rendering still pays full memory and query cost. ### 5.4 Images: dedicated loader, decode to display size, two cache levels - Use the platform-standard loader: Coil (Compose/KMP), Nuke/Kingfisher (iOS; `AsyncImage` is fine for simple cases with URLCache tuned), `expo-image` (RN), `cached_network_image` (Flutter). Hand-rolled `URLSession`→`UIImage` pipelines lose decode-off-main, downsampling, request dedup, and caching in one stroke. - **Downsample at decode time to display size.** Decoding a 4000-px photo into a 120-px avatar wastes ~100× the memory (decoded size = w×h×4 bytes, regardless of file size) and is the most common cause of list jank plus background OOM kills. - Two cache levels, both bounded: memory LRU sized as a fraction of the heap, plus disk. Serve size-variant URLs and modern formats (WebP/AVIF/HEIC) from the CDN — client-side resizing of giant originals is paying twice. - Placeholder + crossfade beats layout shift; cache keys must include the size variant or you'll serve thumbnails into full-screen viewers. ### 5.5 Memory: respond to pressure, find leaks before users do - Implement `onTrimMemory` / `didReceiveMemoryWarning`: drop memory caches, release decoded bitmaps, close what can be reopened. Apps ignoring pressure are first in line for background kill → next open is a cold start → your startup and retention metrics pay for the laziness. - Leak discipline: **LeakCanary** in Android debug builds with CI failure on detected leaks; Xcode Memory Graph + Instruments Leaks in the iOS release ritual. The usual suspects: closures strongly capturing `self`, Activity/Context captured by singletons, listeners/observers never unregistered, Flow/Rx collections outliving their scope, NotificationCenter tokens dropped. - Soak test: 10 minutes of scripted core-loop usage (Macrobenchmark/XCUITest) asserting flat RSS. Catches the "leaks 200 KB per screen visit" class that no unit test sees. ### 5.6 Battery and radio: batch, coalesce, respect the schedulers - **Radio wake-ups dominate network battery cost** — one request every 30 s keeps the radio in high-power state continuously. Batch small requests, coalesce via WorkManager/BGTaskScheduler (rules/03), prefer push-triggered sync over polling, and enable gzip/Brotli + HTTP/2 connection reuse. - **Location is the top battery-complaint generator:** request the coarsest accuracy and longest interval the feature tolerates; stop updates the instant the feature ends (audit for dangling `startUpdatingLocation`/`requestLocationUpdates`); use geofencing and significant-change APIs instead of continuous GPS; background location triggers extra store review scrutiny on both platforms and must be product-essential. - No wakelocks for convenience — Android vitals tracks excessive wakeups and partial wakelocks as bad behavior. No iOS background audio/location modes kept alive to fake background execution (App Review and the battery screen both catch it). - Defer non-urgent work to charging+unmetered constraints; users notice the app that drained 8% overnight, and the OS battery screen names you. ### 5.7 App size is a conversion metric Install conversion drops measurably with download size, and storage-pressure uninstalls target the biggest apps first. - Android: AAB (mandatory for new Play apps) + R8 with resource shrinking; per-device compressed download tracked in Play Console (the 200 MB cap applies to the per-device download, not the bundle); Play Asset Delivery / Feature Delivery for large content and rarely-used features; strip unused locales/ABIs (`resConfigs`, ABI splits come free with AAB). - iOS: App Thinning handles per-device slicing; audit the App Store Connect app-size report per release; asset catalogs with on-demand resources for big media. - Cross-platform runtimes add a real floor (Flutter/RN baseline MBs) — accepted at stack choice time (rules/01), but the *delta per release* is yours: CI prints the size diff per PR and the release gate requires sign-off on regressions. Lazy-download ML models, fonts, and media; never ship debug symbols or test fixtures in release artifacts. ### 5.8 Jank: measure on weak hardware, keep work off the render path - Keep a **low-end test device** in rotation — a 4-year-old mid-tier Android and the oldest supported iPhone. Jank invisible on a flagship is the median user's daily experience; performance sign-off on a dev phone is not sign-off. - Framework-specific hygiene: - **Compose:** read state at the lowest scope; `derivedStateOf` for derived values; defer reads with lambda modifiers (`Modifier.graphicsLayer { translationY = offset }` instead of recomposing on every scroll tick); check skippability with compiler reports for hot composables; hoist unstable lambdas. - **SwiftUI:** keep `body` cheap and value-typed; split observed state so unrelated changes don't invalidate large trees (`@Observable` fine-grained tracking helps but doesn't absolve giant views); profile with Instruments' SwiftUI template; avoid `AnyView` in hot paths. - **React Native:** animations on the UI thread via Reanimated worklets; no per-frame bridge/JSI chatter; `React.memo` + stable props for list rows; Hermes profiles for JS hot spots. - **Flutter:** `const` constructors everywhere applicable; `RepaintBoundary` around expensive repainting subtrees; DevTools rebuild stats; shader-compilation jank addressed (impeller default on both platforms — verify if targeting older Flutter). - Animations: drive them from the compositor/render thread (platform animation APIs, Reanimated, Core Animation) — main-thread-tick animations jank under any load. ### 5.9 Production performance telemetry, not just lab numbers Lab benchmarks regress quietly; production distributions are the truth. - **iOS: MetricKit** — launch times, hang rate, memory peaks, disk writes, plus `MXSignpostMetric` custom spans — reviewed per release alongside Xcode Organizer's hangs/launch/termination reports. - **Android: Android vitals** (ANR, crash, startup, slow/frozen frames, wakeups — with store-ranking consequences) + **Macrobenchmark** in CI + `JankStats` for in-field frame data tagged by screen. - One APM (Sentry, Firebase Performance, Datadog, Embrace) for screen-load traces and network latency distributions, tagged with app version, OS, device class, and **active feature flags** — flag-tagged regressions are how you catch a bad rollout at 5% instead of 100% (rules/06 §6.5). - Track **p90/p95, not means** — mobile distributions are long-tailed and the tail is where churn lives. Alert on per-release regression of p95 cold start, p95 screen load, ANR rate, and crash-free rate; these alerts are the staged-rollout gates. ## Audit checklist - [ ] Budgets documented in-repo with CI/telemetry enforcement; numbers exist for startup, frames, ANR, crash-free, size, memory. - [ ] StrictMode (debug/CI) and Main Thread Checker active; no disk/network/parse/decode on main in hot paths; formatters cached; no `runBlocking`/`.get()` on main. - [ ] Startup segments instrumented; SDKs lazy-initialized post-first-frame; first frame renders from local data; Baseline Profiles generated and benchmarked (Android); no blocking splash hiding setup. - [ ] Long lists virtualized with stable keys and `contentType`; row bodies free of formatting/decoding/allocation; pagination at the data layer; RN lists on FlashList/FlatList, never mapped ScrollViews. - [ ] Standard image loader; decode-time downsampling verified (inspect memory cache entry sizes); bounded memory+disk caches; CDN size variants. - [ ] Memory-pressure callbacks implemented and tested; LeakCanary in CI; soak test asserts flat memory; release ritual includes Instruments/Memory Graph pass. - [ ] Network batched and coalesced; no polling where push suffices; location coarsest/shortest possible and provably stopped; no convenience wakelocks; vitals wakeup metrics clean. - [ ] AAB + R8 + shrinking; per-device download size tracked; CI size-delta gate; no symbols/fixtures in release artifacts. - [ ] Jank validated on low-end hardware; framework-specific recomposition/rebuild hygiene applied to hot screens; animations off the main thread. - [ ] MetricKit + Android vitals reviewed per release; APM traces tagged by version/device/flags; p95 alerts wired to rollout gates; ANR < 0.47% and crash-free ≥ 99.9% or a dated remediation plan exists. -
06-release-and-operations.md 16.6 KB
# 06 — Release & Operations The defining constraint of mobile: **you cannot roll back a shipped binary.** Store review takes hours to days, users update on their own schedule, and a meaningful cohort runs every version you ever shipped — for years. All mobile release engineering is the discipline of designing around that single fact. The toolkit: kill switches instead of rollbacks, staged exposure instead of big bangs, forced updates as the last resort, and a server that never assumes clients are current. ## Store requirements (verified June 2026 — these ratchet annually; re-check Apple's "Upcoming Requirements" page and Play policy updates quarterly, with a named owner) ### 6.1 iOS / App Store - **Privacy manifest (`PrivacyInfo.xcprivacy`)** — mandatory since May 2024. Declares: collected data types (feeds the Privacy Nutrition Label), tracking domains, and **required-reason APIs** (UserDefaults, file timestamps, system boot time, disk space, active keyboard APIs) with approved reason codes. App Store Connect **rejects uploads** with undeclared required-reason API use — including use inside third-party SDKs. Commonly-used SDKs must ship their own manifest and signature; prefer dependencies that do. - **SDK floor:** uploads must be built with **Xcode 26 / the iOS 26 SDK since April 28, 2026**. The annual SDK bump is a real project, not a version-number edit — the 26 SDK applies the Liquid Glass appearance to system controls by default, so the bump includes a UI regression pass. - Review logistics: working demo account, export-compliance/crypto declaration, sign-in-with-Apple obligation if you offer other third-party logins, in-app account deletion if you have account creation, updated age-rating questionnaire answered (mandatory since Jan 31, 2026 — submissions are interrupted without it; existing ratings were auto-migrated to the new tiers, so review them for accuracy), App Review notes for anything non-obvious (hardware requirements, geo-gated content). - Distribution lanes: TestFlight internal (instant, 100 testers) → TestFlight external (review-gated, 10k) → App Store **phased release**: automatic 7-day ramp (1→2→5→10→20→50→100%) — pausable, but it only paces *automatic* updates; users updating manually get the new version immediately, so phased release is a damper, not a gate. ### 6.2 Android / Google Play - **Data safety form** must accurately cover app + every SDK's collection and sharing; Google compares declarations against observed behavior and enforcement actions follow mismatches. - **Target API ratchet:** API 36 required for new apps and updates by **Aug 31, 2026** (API 35 floor for Wear OS/TV). **16 KB page-size support** required since **Nov 1, 2025** for apps targeting Android 15+ with native code (rules/01 has the behavior-change details for each bump). - **Play App Signing** (Google holds the app signing key) is standard: protect the *upload key*, document the upload-key reset process before you need it, and keep signing entirely in CI. - AAB is the required upload format. The free **pre-launch report** runs your build on a device farm per track upload — read it; it catches crashes, accessibility, and security warnings at zero cost. - Tracks: internal → closed → open → production with **staged rollout** at a percentage you control. Critical mechanic: a *halted* staged rollout leaves the affected users on the bad build — your fix path is always a new, higher version rolled out fast (see 6.5). ## Rules ### 6.3 Forced-update mechanism ships in v1.0 Some day a shipped version will have a security hole, a data-corrupting bug, or a dependency on an API you must kill. The mechanism must already be in the oldest binary that matters — which means it ships in the first one: - On launch and periodically, the app calls a version-policy endpoint **you control**: ```json { "minSupportedBuild": 2024, "recommendedBuild": 2107, "message": { "en": "This version is no longer supported." }, "storeUrl": "https://apps.apple.com/app/id..." } ``` - Below `minSupportedBuild` → blocking, non-dismissible screen with a store link. Below `recommendedBuild` → dismissible nudge with snooze. Everything localized and server-controlled. - **Fail open:** the policy endpoint being down must never brick the app (cache last verdict, default to allow). The blocking screen itself must be the most-tested screen in the app — a forced-update screen that crashes is an unrecoverable brick for that cohort. - Platform helpers layer on top: Play **In-App Updates API** (immediate flow for forced, flexible for recommended) gives a better Android UX; iOS has no system API — your endpoint + store link is the mechanism. - Exercise the blocking path in every release candidate (point staging at a policy that blocks the RC). ### 6.4 Feature flags and kill switches: the only rollback you have - **Every feature with server interaction, and every risky change, ships behind a remote flag whose hardcoded default is the safe state** (usually OFF). Flags fetch at launch, cache last-known values, and degrade to defaults when the flag service is unreachable — flag-service downtime must not take the app down with it. - Maintain the distinction: - **Experiment flags** — A/B tests, temporary by definition; a removal ticket is created with the flag; stale experiment flags create 2^n untested configuration combinations. - **Ops kill switches** — permanent, deliberate, documented: certificate-pinning enforcement (rules/04 §4.3), sync engine, each third-party SDK's initialization, chatty/expensive subsystems, any feature that can hammer your backend. Auditors: a server-dependent feature with no kill switch is HIGH — when it misbehaves in a shipped binary, there is no other off button. - Kill switches are **exercised, not just installed**: flip each one in staging (ideally periodically in production canaries) and verify the app degrades as designed. An untested kill path is a hope, not a control. - Flag state is attached to crash reports and APM traces (rules/05 §5.9) — otherwise you can't attribute a regression to a rollout. ### 6.5 Staged rollouts with explicit promotion gates - Never 0→100%. Standard ladder: internal track/TestFlight internal → beta (open testing / TestFlight external) → production at 1% → 5% → 25% → 50% → 100%. - Promotion between stages is a **decision made against written gates**, not a timer: - Crash-free sessions ≥ target (rules/05 budget) and no *new* top-10 crash signature. - ANR rate and p95 cold start flat versus the previous release. - Core business metrics (activation, purchase success) flat. - Automate the gate check against the crash/APM dashboards; a human approves, a dashboard decides. - Platform mechanics to internalize: - **Play:** halting a staged rollout strands affected users on the bad build until you roll out a higher `versionCode` — keep a hotfix branch + fast-track release process rehearsed. - **App Store:** phased release pauses only slow *automatic* updates; manual updaters still get it. For a truly bad build, pause phased release *and* submit an expedited-review hotfix (Apple grants expedited review sparingly — have the justification ready). - Hotfix discipline: the hotfix branches from the released tag, contains only the fix, and rides the same gates at an accelerated ramp. ### 6.6 Crash reporting and symbolication are release blockers - Crash SDK (Crashlytics, Sentry, Embrace, …) initializes **first** in the app lifecycle — before the DI graph, before anything that can crash. Early-startup crashes in uninstrumented code are the ones you can least afford to lose. - **Symbol upload is automated in CI and verified per release:** dSYMs (iOS — including for frameworks and extensions) and R8 `mapping.txt` + NDK native symbols (Android). An unsymbolicated crash report is noise; discovering missing dSYMs during an incident is the standard failure. Make "RC crash symbolicates correctly" a checklist item (force a test crash in the RC build). - Attach context: app version/build, OS, device class, low-memory indicator, **active feature flags** (6.4), and PII-scrubbed breadcrumbs (navigation + key actions). Flag state on crashes is what turns "crash rate up" into "kill switch X, now." - Watch platform-native sources too — they see what your SDK can't: **Xcode Organizer + MetricKit** diagnostics catch watchdog kills, hangs, and pre-SDK-init crashes; **Play vitals** catches ANRs and crashes from before instrumentation and feeds store ranking. - Triage SLO wired to rollouts: a new signature affecting more than N users during a ramp halts promotion automatically (6.5). ### 6.7 OTA updates (RN/Expo): powerful, policy-bounded, operationally identical to releases - React Native: Expo Updates / self-hosted OTA ships JS bundle changes without store review. Policy boundaries (long-standing, both stores): downloaded code must run in the platform's sanctioned interpreter (JavaScriptCore/Hermes/WebKit), must not change the app's primary purpose, and must not circumvent review for things that deserve review. Flutter compiles Dart AOT — **no generally store-sanctioned code push for Flutter on iOS**; evaluate any "code push for Flutter" product's current store standing before adopting it. - Treat every OTA push as a production deploy with the full 6.5 discipline: - **Version-targeted:** an OTA bundle declares which binary versions it's compatible with. Shipping JS that calls a native module absent from older binaries is a crash factory aimed precisely at your slowest-updating users. CI must verify bundle↔binary compatibility (Expo runtime versions / manual native-interface versioning). - **Staged and instantly rollback-able** — rollback is the entire point of OTA; if your OTA pipeline can't revert in minutes, it's only an outage accelerator. - **Gated on the same crash metrics** as binary rollouts, with OTA bundle ID attached to crash reports. - OTA never delivers: new native modules, permission changes, or payment/purchase-flow changes that would merit review. Keep regular store builds shipping regardless — an app that only OTAs accumulates binary debt (stale SDKs, unpatched native CVEs) invisibly. ### 6.8 The API must serve every binary still alive Server teams deploy hourly; your two-year-old binary is still calling them tonight. The contract: - **Every request self-identifies:** `X-App-Version`, build number, platform, OS version headers. The server can then branch, throttle, or sunset *by explicit version predicate* — never by accident. - **Additive-only evolution** on endpoints old clients touch: never remove, rename, or retype fields; never repurpose enum values (old clients will render them); new required request fields get server-side defaults. New semantics → new endpoint or version, not mutated meaning on the old path. - Mirror-image client rule: **decoders tolerate unknown fields and absent optionals.** A strict decoder that throws on a new server field is a self-inflicted, fleet-wide outage triggered by a routine backend deploy. ```swift // BAD: any new enum case from the server kills every shipped build enum OrderStatus: String, Decodable { case pending, shipped, delivered } // GOOD: forward-compatible enum OrderStatus: RawRepresentable, Decodable { case pending, shipped, delivered case unknown(String) // renders as a neutral state in UI init(rawValue: String) { ... } } ``` - **CI contract tests pin the oldest supported client:** record the oldest supported app version's API expectations (request/response fixtures) and run them against every server build. The server team breaking a three-year-old client should fail *their* CI, not your crash dashboard. - Deliberate sunsetting is a process, not a deploy: measure the affected cohort → announce in-app to that cohort → raise `minSupportedBuild` (6.3) with a grace window → server returns explicit `426 Upgrade Required` (with a client-rendered message) after the cutoff — never a silent 500 or, worse, subtly wrong data. ### 6.9 Testing strategy: a pyramid with device reality at the top - **Unit (the bulk):** view models, reducers, repositories, sync/outbox logic against fakes — milliseconds each, every commit. Cheap because the architecture made it cheap (rules/02 §2.9). - **Snapshot:** design-system components and key screens per sealed-state case, across dark mode, large dynamic type/font scale, and RTL. This layer catches the visual regressions E2E suites are too slow and flaky to police. - **Integration:** repository ↔ real local DB; sync engine against a fake server scripted with the rules/03 edge cases (conflict, resync, poison message); **DB schema migration tests** (Room's `MigrationTestHelper`, GRDB migrator tests) — a failed migration bricks the app for exactly your most loyal, longest-installed users. - **UI/E2E (a handful):** login, the core loop, purchase — per-PR on emulators/simulators; nightly on a **real-device farm** (Firebase Test Lab, AWS Device Farm, BrowserStack) across an OS × device matrix mirroring your actual user base. Emulators miss OEM skins, real keyboards, memory pressure, and vendor-modified WebViews. - **Release-candidate ritual**, written down and checked off: 1. Upgrade-path test: install the previous production build, log in, create data → upgrade to RC → verify data and session survive (migrations, 2.3 restoration). 2. Fresh-install test (first-run experience, permission pre-prompts). 3. Forced-update blocking screen exercised (6.3). 4. Forced test crash symbolicates (6.6). 5. Play pre-launch report / TestFlight feedback reviewed. ### 6.10 Release cadence and build hygiene - Ship on a **fixed cadence** (1–2 weeks) from a release branch/train. Small diffs make rollout gates meaningful, regressions bisectable, and hotfixes surgical; quarterly big-bang releases maximize undiagnosable risk and gate-meaningless rollouts. - **CI-only builds reach stores.** No laptop builds: signing keys and store credentials live in CI secrets with least privilege; provisioning/signing is reproducible (fastlane match or equivalent, Play App Signing). - Every production build is traceable: commit SHA, CI run, and flag snapshot embedded in the binary and visible in a hidden debug/about screen — "which exact code is crashing" must never require archaeology. - Maintain user-readable release notes and an internal changelog recording active experiments/flags per build — six months later, "what was different about 4.12?" must have an answer. ## Audit checklist - [ ] iOS privacy manifest present; required-reason API declarations match actual code and SDK usage; Nutrition Label consistent with observed traffic. - [ ] Android Data safety form matches observed behavior; Play App Signing on; upload-key reset process documented. - [ ] Built against currently mandated SDKs/targets (iOS 26 SDK since Apr 2026; Play target API per current deadline); a named owner exists for the annual ratchets and quarterly policy review. - [ ] Forced-update mechanism: implemented on owned infrastructure, fail-open, localized, blocking path exercised in the RC ritual; Play In-App Updates integrated on Android. - [ ] Server-dependent/risky features behind remote flags with safe hardcoded defaults; kill switches exist for pinning, sync, and each third-party SDK init; kill paths actually tested; flag-removal tickets created with experiment flags. - [ ] Staged rollout ladder with written promotion gates (crash-free, no new signatures, ANR/p95/business metrics flat); gate checks automated; hotfix fast-track rehearsed for both stores' mechanics (Play halt-strands-users; iOS phased-release limits). - [ ] Crash SDK initialized first; dSYM/mapping/native-symbol upload automated and *verified* via forced test crash per RC; flag state and scrubbed breadcrumbs attached; Organizer/MetricKit and Play vitals reviewed alongside the SDK dashboard. - [ ] OTA (if used): bundle↔binary compatibility enforced in CI; staged, rollback-able in minutes, crash-gated; nothing review-worthy shipped via OTA; store builds still ship regularly. - [ ] All API requests carry version headers; additive-only evolution on live endpoints; client decoders tolerate unknown fields/enum cases; contract tests pin the oldest supported client; sunsets use cohort measurement → in-app notice → forced update → explicit 426. - [ ] Test pyramid intact: unit bulk; snapshots incl. dark mode/font-scale/RTL; integration incl. DB migration tests and sync edge cases; ≤ ~10 E2E flows; nightly real-device-farm matrix. - [ ] RC ritual documented and followed (upgrade-path, fresh-install, forced-update, symbolication, pre-launch report). - [ ] Fixed release cadence; CI-only signed builds; commit SHA + flag snapshot traceable from any production binary. -
07-swift-language.md 26.5 KB
# 07 — Swift as a Language Rules 01–06 cover the mobile *platform*; this file covers **Swift the language** — idioms, concurrency, memory, unsafe interop, packaging, testing. It applies to any Swift target: an iOS app, a server-side service (e.g. Vapor or Hummingbird on SwiftNIO), a CLI, or embedded firmware. Audit Swift code against this file even when the deliverable is not a mobile app. Baseline (verified July 2026 at [swift.org/blog](https://www.swift.org/blog/)): **Swift 6.3** shipped 2026-03-24; the current toolchain is the 6.3.x patch line (6.3.3 at verification time). The load-bearing line, though, is not the toolchain version but the **language mode**: Swift 6 language mode turns data-race safety into compile-time errors, and Swift 6.2+ made that mode adoptable module-by-module without annotation blizzards. ## Concurrency & data-race safety ### 7.1 Build in Swift 6 language mode; migrate module-by-module - Swift 6 language mode enforces **complete data-race safety at compile time**: `Sendable` checking across actor/task boundaries, actor isolation, and region-based isolation analysis that proves some non-`Sendable` transfers safe. A codebase still on Swift 5 mode with strict-concurrency warnings suppressed is accumulating latent races — HIGH in audit for concurrent code paths. - Migration is **per-module** (`swiftLanguageMode(.v6)` in Package.swift / `SWIFT_VERSION = 6`), so "the app is too big" is not a reason to stay on 5 mode; leaf modules first, then work inward. Intermediate step: Swift 5 mode + `-strict-concurrency=complete` to see the diagnostics as warnings. - Swift 6.2's "approachable concurrency" (verified: [swift.org/blog/swift-6.2-released/](https://www.swift.org/blog/swift-6.2-released/)) removed the main adoption pain: - **Default main-actor isolation** per module (`defaultIsolation(MainActor.self)` SwiftSetting): UI/app modules get `@MainActor` implicitly instead of annotating every type. Use it for app targets; leave libraries at `nonisolated` default so they stay usable off the main actor. - **Caller's-actor async functions** (opt-in upcoming feature): `nonisolated` async functions run in the caller's execution context instead of always hopping to the global executor — eliminating a whole class of spurious `Sendable` errors. - **`@concurrent`** explicitly marks the functions that *should* leave the actor and run in parallel — concurrency becomes something you opt into where profiling justifies it, not the ambient default. - Audit greps: `@unchecked Sendable`, `@preconcurrency`, `-strict-concurrency=minimal`, `nonisolated(unsafe)`. None is banned, but each is a suppressed check and needs a justification comment stating the manual invariant (e.g. "guarded by `lock`"). ### 7.2 Actors: isolation is per-suspension, not per-method - `actor` is the tool for shared mutable state; a class + `DispatchQueue`/`NSLock` in new code is legacy style and easier to get wrong. But actors are **reentrant**: every `await` inside an actor method is a point where *other* messages interleave. Check invariants **after** each `await`, not just at entry. ```swift actor AccountCache { private var balances: [AccountID: Int] = [:] // BAD — balance read before await, written after: interleaved calls double-apply func applyBAD(_ tx: Tx) async throws { let current = balances[tx.account] ?? 0 try await ledger.validate(tx) // suspension: others run here balances[tx.account] = current + tx.amount // stale `current` } // GOOD — re-read state after the suspension; mutation is one synchronous step func apply(_ tx: Tx) async throws { try await ledger.validate(tx) balances[tx.account, default: 0] += tx.amount } } ``` - Don't funnel everything through one global actor: `@MainActor` on business logic serializes it behind UI work (and on server-side Swift there may be no main run loop doing UI at all). Main actor for UI state; separate actors (or plain `Sendable` value pipelines) for the rest. - `Sendable`: value types with `Sendable` members conform for free — prefer them. `@unchecked Sendable` is an unverified promise; require an adjacent comment naming the synchronization mechanism, and treat one on a class with public mutable `var`s as a finding. - `Sendable` errors at a boundary are usually telling you a *type* is wrong, not that you need `@unchecked`. Fix order: make it a value type → make it an actor → transfer ownership with a `sending` parameter (region isolation proves the caller keeps no reference) → only then consider `@unchecked Sendable` with a lock. ### 7.3 Structured concurrency, cancellation, and streams - **Structured first**: `async let` for a fixed set of children, `withTaskGroup`/`withThrowingTaskGroup` for dynamic fan-out. Children are bounded by the scope and cancelled together — no orphans, no leaks. ```swift // GOOD — bounded fan-out with propagated cancellation and per-item error policy func thumbnails(for ids: [ImageID]) async throws -> [ImageID: Thumbnail] { try await withThrowingTaskGroup(of: (ImageID, Thumbnail).self) { group in for id in ids { group.addTask { (id, try await self.render(id)) } } var out: [ImageID: Thumbnail] = [:] for try await (id, thumb) in group { out[id] = thumb } // first throw cancels siblings return out } } ``` - Unstructured `Task { }` needs an owner that cancels it (store the handle; cancel in teardown — SwiftUI's `.task` modifier does this for you). `Task.detached` also discards priority and task-locals — it is almost never what you want; each use needs a reason. - **Cancellation is cooperative.** A task that never checks never stops. Long loops call `try Task.checkCancellation()` or check `Task.isCancelled`; wrap callback resources in `withTaskCancellationHandler`. Audit: search long-running loops and retry/polling code for any cancellation check. - **Never block the cooperative pool**: no `DispatchSemaphore.wait()`, `sleep()`, or synchronous I/O to bridge sync→async — under load this deadlocks the width-limited thread pool. Bridge with continuations instead (`withCheckedThrowingContinuation`, resumed **exactly once** on every path — double/never-resume is a runtime crash or a permanent hang). - `AsyncStream`/`AsyncThrowingStream` bridge callback and delegate APIs into `for await` loops — but choose the **buffering policy** deliberately. The default buffers unboundedly: a fast producer (sensor events, socket frames) with a slow consumer is a memory leak with extra steps. `.bufferingNewest(n)`/`.bufferingOldest(n)` make the drop policy explicit; if every element matters, the design needs real backpressure (pull-based iteration or an explicit bounded queue), not a bigger buffer. - One `AsyncSequence` instance generally supports **one** consumer; a second `for await` on the same stream silently splits elements between them. Multicast needs an explicit fan-out layer. ## Value semantics & type design ### 7.4 Structs by default; classes only for identity - Default to `struct` + `let`. Value semantics make code trivially `Sendable`, testable, and free of spooky mutation at a distance. Reach for `class` only when identity or shared mutable state is the point — and if the state is shared *across concurrency domains*, that's an `actor`, not a class. - Standard collections (`Array`, `Dictionary`, `String`, `Data`) are **copy-on-write**: assignment is O(1) until mutation. Two consequences: passing big collections around is cheap (don't "optimize" with classes), and mutating a shared instance triggers a full copy — in hot loops, mutate in place and watch for accidental extra references defeating uniqueness. Custom large value types wrapping a reference-typed buffer implement COW the same way: ```swift struct Bitmap { private var storage: PixelStorage // reference type holding the buffer mutating func set(_ p: Pixel, at i: Int) { if !isKnownUniquelyReferenced(&storage) { // shared → copy before write storage = storage.copy() } storage[i] = p } } ``` - Mark classes `final` unless subclassing is a designed extension point — enables static dispatch and stops unplanned inheritance. `struct` needs no such marking; that's another point for structs. - **Noncopyable types** (`~Copyable`, available since Swift 5.9) encode unique ownership of a resource — file descriptors, connections, locks — so "two owners" is a compile error instead of a double-close at runtime. Pair with the `borrowing`/`consuming` parameter modifiers: `borrowing` reads without taking ownership; `consuming` takes the value and ends the caller's access (the natural signature for `close()`/`send()`-style terminal operations). Use them at resource boundaries; don't retrofit them onto ordinary model types. ### 7.5 Protocols, generics, and enums - Protocol-oriented design: depend on capabilities (`protocol Clock`, `protocol TokenStore`), not concrete types — this is also the DI seam rules/02 requires. But don't invent a protocol per type "for testability" when a struct of closures or a generic parameter is simpler. - **`some` over `any`**: `some P` (opaque type) keeps static dispatch and zero boxing; `any P` (existential) allocates a box and dynamically dispatches. Use `any` only where heterogeneity is required (mixed-type collections, storage). Hot-path code full of `any P` parameters instead of generics is a performance finding (rules/05 discipline applies to CPU too). - Model state with **enums with associated values**, and switch exhaustively **without `default`** on enums you own — then adding a case is a compile error at every site that must care, instead of a silent fallthrough. `default` is acceptable only on non-frozen enums from other modules (where `@unknown default` is the right spelling). ## Optionals & error handling ### 7.6 Optionals: unwrap early, crash never (accidentally) - `guard let` at function top is the idiom: unwrap-or-exit, then straight-line code with non-optionals. Nested `if let` pyramids and repeated `foo?.bar?.baz` chains signal a missing early exit or a type that should not be optional. ```swift // BAD — pyramid; the happy path is three indents deep if let user = session.user { if let email = user.email { if let domain = email.split(separator: "@").last { register(domain) } } } // GOOD — invariants established once, straight-line code after guard let user = session.user, let email = user.email, let domain = email.split(separator: "@").last else { return .missingProfileData } register(domain) ``` - **`!` force-unwrap, `try!`, and `as!` are assertions, not error handling.** In production code paths each is a crash waiting for input you didn't foresee. Legitimate uses (programmer invariant, e.g. a bundled resource) get a comment stating the invariant — or better, `preconditionFailure("...")` with a message so the crash report says *why*. Audit grep: `!` unwraps, `try!`, `as!`, and implicitly-unwrapped `var x: T!` outside @IBOutlet-style two-phase init. - Don't use `Optional` to encode "not loaded yet / failed / empty" as one flat `nil` — that's an enum (`enum Loadable<T> { case idle, loading, loaded(T), failed(Error) }`, per rules/02 state modeling). ### 7.7 Errors: `throws` untyped by default; typed where the boundary is closed - Errors are typed Swift `enum`s conforming to `Error` (with associated values for context), thrown with `throw`/`throws` — not `NSError` codes, not sentinel returns, not `fatalError` for recoverable conditions. Same rule for *values*: absence is `Optional`, never `-1`/`0`/`""` — the class, its ordering hazard and the three audit probes are in `sota-architecture` rules/02 §8a. - **Typed throws** (`throws(ParseError)`) is implemented since Swift 6.0 (SE-0413, verified: [proposal status](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0413-typed-throws.md)) — and the proposal itself says plain `throws` "remains the better default error-handling mechanism for most Swift code." Use typed throws where the proposal recommends: same-module/package code where the error set is closed, generic code passing through a caller's error type (`rethrows` replacement), and embedded/no-existential environments. Do **not** type a public API's throws to today's single error enum — you've frozen your error surface into the ABI. ```swift // Good typed-throws fit: closed, internal, exhaustive at the catch site enum FrameError: Error { case truncated(needed: Int), badMagic(UInt32) } func parseFrame(_ s: inout Span<UInt8>) throws(FrameError) -> Frame { ... } // Wrong fit: public API frozen to today's one failure mode public func loadConfig() throws(FileError) -> Config { ... } // network/keychain sources later = source break ``` - `try?` silently discards the error. Fine for genuinely optional lookups; a finding on operations whose failure someone must observe (writes, sync, payments) — at minimum log before dropping. Audit grep: `try?` on non-read paths. - Every `catch` either handles meaningfully, adds context and rethrows, or routes to the observability layer. `catch { }` (swallow-all) is the Swift equivalent of `except: pass` — MEDIUM by default. ## Memory: ARC & retain cycles ### 7.8 Closures capturing `self` are the leak factory - ARC frees objects when the last strong reference drops; **reference cycles never drop**. The canonical cycle: an object stores a closure that captures `self` strongly. Escaping closures stored in properties, handlers, observers, and long-lived `Task`s all qualify. ```swift // BAD — self → task → closure → self: VM leaks until the task ends (maybe never) final class Poller { var task: Task<Void, Never>? func start() { task = Task { while true { await self.tick(); try? await Task.sleep(for: .seconds(5)) } } } } // GOOD — weak capture + early exit; cancel in deinit-adjacent teardown func start() { task = Task { [weak self] in while !Task.isCancelled { guard let self else { return } await self.tick() try? await Task.sleep(for: .seconds(5)) } } } ``` - **`weak` vs `unowned`**: `weak` becomes `nil` when the target deallocates (safe, requires unwrap); `unowned` crashes if touched after deallocation. Use `unowned` only when lifetime containment is *structural* (the closure cannot outlive the target) — when in doubt, `weak`. `unowned` chosen "because it's faster" is a finding. - After `[weak self]`, the `guard let self else { return }` line decides semantics: with it, the work completes atomically while self lives; without it, each `self?.` call silently no-ops mid-work. Choose deliberately for multi-step operations. - Standard cycle suspects to audit: delegates declared without `weak`; `NotificationCenter`/KVO observer tokens never removed; timers (`Timer.scheduledTimer` retains its target — invalidate it); Combine `AnyCancellable`s capturing self strongly while stored on self; long-lived `Task`s as above. - Verification is empirical, not by inspection: memory-graph debugger / Instruments Leaks on device (rules/05), plus `deinit`-fires assertions in tests for controller-like objects. On Linux/server, track RSS per request under load — same ARC, same cycles, no memory-graph UI. ## Unsafe interop ### 7.9 Unsafe pointers: the compiler stops checking exactly where you type `Unsafe` - **Pointers must not escape their scope.** `withUnsafePointer(to:)`, `withUnsafeBytes`, and friends hand you a pointer valid *only inside the closure*; returning or storing it is undefined behavior that works until it doesn't. Same trap in one token: passing `&array` or a `String` to a C function creates a pointer valid **only for that call** — capturing it C-side is a dangling pointer. - `UnsafeBufferPointer` subscripts are **not bounds-checked in release builds**. Every index is your proof obligation. Prefer **`Span`** (Swift 6.2+, verified: [swift.org/blog/swift-6.2-released/](https://www.swift.org/blog/swift-6.2-released/)) — safe, bounds-checked, non-escapable access to contiguous memory at zero overhead — and **`InlineArray`** (`[40 of UInt8]`) for fixed-size inline storage without heap allocation. New code reaching for `UnsafeBufferPointer` where `Span` suffices is a finding. - Swift 6.2 also added **opt-in strict memory safety** which flags every unsafe construct for review — turn it on for parser/codec/crypto modules that handle attacker-controlled bytes (this is the trust-boundary code where UB becomes exploitable, per rules/04's threat model). - C interop pitfalls: ownership across the boundary is a contract, not inferred — document who frees (`UnsafeMutablePointer.deallocate` vs C-side `free`); un-annotated C headers import pointers as implicitly-unwrapped — add nullability annotations rather than sprinkling `!`; `unsafeBitCast` and `assumingMemoryBound(to:)` assert type facts the compiler can't see and each needs a comment proving the layout claim. Swift 6.3's `@c` attribute (verified: [swift.org/blog/swift-6.3-released/](https://www.swift.org/blog/swift-6.3-released/)) exports Swift functions/enums directly to C — prefer it over hand-maintained shim headers. - Audit grep: `Unsafe`, `unsafeBitCast`, `assumingMemoryBound`, `unsafeDowncast`, `withMemoryRebound` — the list of hits *is* the manual-review surface; it should be short and concentrated in a few audited files, not smeared across the app. ## Supply chain: Swift Package Manager ### 7.10 Pin, checksum, and treat macros as build-time code execution - **Commit `Package.resolved`** (and the Xcode-embedded copy for app projects). CI must build against the committed resolution, not silently re-resolve — resolve with the equivalent of "fail if resolved file is out of date" so a hijacked upstream tag can't slide in. Version rules: semver ranges (`from:`) for libraries; **no `branch:`/`revision:` dependencies in anything that ships** — they are unpinned by definition. - **Remote `binaryTarget`s require a SHA-256 checksum** in the manifest (`swift package compute-checksum` produces it); SwiftPM refuses mismatches. The checksum authenticates the artifact you first vetted — it does not make an unauditable binary safe. Keep an inventory of binary dependencies (they also carry the rules/04 SDK-privacy obligations on iOS). ```swift // Package.swift — pinned range, checksummed binary; no branch:/revision: in shipping code dependencies: [ .package(url: "https://github.com/example/parser.git", from: "2.4.0"), // resolved exactly in Package.resolved // BAD in a release product: .package(url: "...", branch: "main") ], targets: [ .binaryTarget( name: "AnalyticsSDK", url: "https://cdn.example.com/AnalyticsSDK-3.1.0.xcframework.zip", checksum: "6d988a1a27418674b4d7c31732f6d60e60734ceb11a0ce9b54d1871918d9c194" ), ] ``` - **Registry-based dependencies** get real security machinery (verified: [SwiftPM PackageRegistryUsage.md](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md)): package **signing** with publisher **TOFU** (signer must stay consistent across versions; `--resolver-signing-entity-checking strict`), **checksum TOFU** persisted under `~/.swiftpm/security/fingerprints/` (`--resolver-fingerprint-checking` defaults to `strict`), and per-registry policy in `registries.json` (`signing.onUnsigned: error|prompt|warn|silentAllow`). If you consume from a registry, set `onUnsigned` and signing-entity checking to the strict end in CI. - **Macros and build plugins execute code at build time.** A malicious macro dependency owns your build machine and your CI credentials. Review macro/plugin dependencies at the same bar as CI config changes; Swift 6.3's prebuilt swift-syntax also removes the historic build-time tax that pushed teams to fork or vendor macros. - Dependency hygiene is the same discipline as any ecosystem: fewer deps, review the diff on updates, and a scanner/audit step in CI. SwiftPM has no built-in vulnerability audit command — wire an external checker or at minimum subscribe to advisories for your dependency list. ## Testing ### 7.11 Swift Testing for new tests; XCTest where it still owns the ground - **Swift Testing** ships in Swift 6 toolchains and Xcode 16+ — no package dependency — and runs **side-by-side with XCTest in the same target**, so adoption is incremental, not a rewrite (verified: [swift-testing README](https://github.com/swiftlang/swift-testing)). It is cross-platform (Apple platforms, Linux, Windows; Wasm/Android experimental), so server-side Swift uses the same framework. - Default for new unit tests: `@Test` functions with `#expect`/`#require`, parameterized tests (`@Test(arguments:)`) instead of copy-pasted cases, suites as structs (fresh instance per test — instance state instead of `setUp` mutation), tags and traits for organization. `#expect` records-and-continues; `#require` aborts the test — use `#require` for preconditions whose failure makes the rest noise. ```swift @Suite struct FrameParserTests { @Test(arguments: [ ("empty", Data(), FrameError.truncated(needed: 8)), ("badMagic", Data([0xde, 0xad, 0xbe, 0xef]), FrameError.badMagic(0xdeadbeef)), ]) func rejectsMalformedInput(_ name: String, _ bytes: Data, _ expected: FrameError) throws { #expect(throws: expected) { try FrameParser().parse(bytes) } } @Test func roundTrips() throws { let frame = Frame.fixture() let parsed = try #require(try? FrameParser().parse(frame.encoded)) // abort if precondition fails #expect(parsed == frame) // record-and-continue } } ``` - Async is native (`@Test func x() async throws`); callback APIs bridge via `confirmation()`. Recent additions, verified against release posts: **exit tests** (assert a process terminates — finally testable `precondition` paths) and **attachments** in 6.2; warning-severity issues, `Test.cancel()`, and image attachments in 6.3 (ST-0012–0017, ST-0020). - **XCTest is not dead**: UI automation (XCUITest) and performance measurement (`measure`) have no Swift Testing equivalent as of mid-2026 — keep those suites in XCTest and re-verify before planning any "full migration" (fast-moving; check current Xcode release notes). - Framework choice doesn't change test *strategy*: behavior-first, deterministic, real dependencies where cheap — sota-testing owns that layer; rules/06 owns the mobile CI pyramid. ## Swift beyond the app ### 7.12 One language, several deployment realities - Everything above applies unchanged to **server-side Swift** (e.g. Vapor, Hummingbird — both on SwiftNIO), CLIs (swift-argument-parser), and embedded targets. Swift officially supports Linux, Windows, WebAssembly (since 6.2), and — new in 6.3 — a first official **Android SDK** (verified: [swift.org/blog/swift-6.3-released/](https://www.swift.org/blog/swift-6.3-released/)). - Server specifics worth flagging in audit: concurrency pressure is *higher* (thousands of concurrent requests make 7.1–7.3 violations statistical certainties, not rare crashes); blocking the cooperative pool stalls request handling for everyone (7.3); NIO `EventLoopFuture` code bridges to async/await at the edges — new logic should be async/await-native. Don't assume Darwin-only Foundation behavior on Linux; prefer the portable `FoundationEssentials`-level APIs and run CI on the deployment OS. - Cross-compilation and the static Linux SDK make single-binary deployment normal — which also means containerized Swift services follow the ordinary backend rules (sota-sandboxing, sota-observability), not mobile ones. Use this file for the language; route platform concerns to the right skill. ## Audit checklist - [ ] All first-party modules build in Swift 6 language mode (or have a dated migration plan); no `-strict-concurrency=minimal`; UI modules use default main-actor isolation rather than blanket `@MainActor` annotations. - [ ] Every `@unchecked Sendable`, `nonisolated(unsafe)`, and `@preconcurrency` has an adjacent comment naming the synchronization invariant; none sits on a class with public mutable state. - [ ] Actor methods re-establish invariants after each `await` (reentrancy reviewed); no read-await-write on the same actor state with a stale local. - [ ] Unstructured `Task { }` handles are owned and cancelled; `Task.detached` is justified per use; long loops/retries check cancellation; no `DispatchSemaphore.wait`/sync I/O bridging inside async contexts; continuations resume exactly once on all paths. - [ ] `AsyncStream` bridges set an explicit buffering policy (no unbounded default between fast producer and slow consumer); no stream is consumed by two `for await` loops. - [ ] Types default to `struct`/`let`; classes are `final` or deliberately designed for subclassing; shared mutable state crossing concurrency domains lives in actors; unique resources (descriptors, connections) use `~Copyable` or a single-owner wrapper rather than copyable handles. - [ ] Hot paths prefer generics/`some` over `any` existentials; owned-enum switches are exhaustive without `default` (`@unknown default` only on non-frozen external enums). - [ ] Grep clean (or comment-justified): force unwraps `!`, `try!`, `as!`, IUO properties, `try?` on write/sync/payment paths, empty `catch {}` blocks. - [ ] Public API errors use plain `throws`; typed `throws(E)` appears only in closed same-module/package boundaries, generic pass-through, or embedded code. - [ ] Closure/`Task`/timer/observer/Combine captures reviewed for cycles; delegates are `weak`; `unowned` only with structurally bounded lifetime; leak checks are empirical (memory graph/Instruments, or RSS-under-load on server) and `deinit` fires in tests for controller-like objects. - [ ] `Unsafe*`/`unsafeBitCast`/`assumingMemoryBound` hits are few, concentrated, and comment-justified; no pointer escapes its `with*` closure or C call; attacker-input parsing modules use `Span`/`InlineArray` (or enable strict memory safety) instead of raw buffer pointers. - [ ] `Package.resolved` committed and enforced in CI (build fails on drift); no `branch:`/`revision:` dependencies in release products; remote binary targets carry checksums and appear in the SDK/binary inventory. - [ ] Registry consumers set fingerprint and signing-entity checking to `strict` and `signing.onUnsigned` to `error`/`prompt` in CI; macro and build-plugin dependencies are reviewed at CI-config rigor. - [ ] New unit tests use Swift Testing (`#expect`/`#require`, parameterized over copy-paste); UI-automation and performance suites remain on XCTest knowingly, not accidentally. - [ ] Server-side Swift services run CI on the deployment OS (e.g. Linux), avoid Darwin-only Foundation assumptions, and bridge NIO futures to async/await at the edges only.
-
-
SKILL.md 9.3 KB
--- name: sota-mobile description: >- State-of-the-art mobile engineering for building and auditing iOS and Android applications. Use when the task involves mobile apps in any form — native (Swift, SwiftUI, Kotlin, Jetpack Compose), cross-platform (React Native, Flutter, Kotlin Multiplatform), Swift as a language — Swift 6 strict concurrency, actors, Sendable, ARC, SwiftPM, Swift Testing — in any target including server-side Swift (e.g. Vapor), app store submission and review (App Store, Google Play, privacy manifests, data safety), push notifications (APNs, FCM), offline-first architecture and sync, mobile security (Keychain, Keystore, certificate pinning, app attestation, OWASP MASVS), mobile performance (startup, jank, battery, app size), or mobile release operations (phased rollouts, feature flags, forced updates, crash reporting, OTA updates). Trigger keywords: mobile, iOS, Android, Swift, SwiftUI, Kotlin, Jetpack Compose, React Native, Flutter, app store, push notifications, offline-first, server-side Swift, Vapor, SwiftPM. --- # SOTA Mobile Engineering Expert-level rules for building new mobile apps and auditing existing ones. Mobile is unlike web or backend in three load-bearing ways, and every rule in this skill flows from them: 1. **You cannot roll back a shipped binary.** Users update on their own schedule; some never do. Every release is permanent for some cohort. Design for kill switches, forced updates, and servers that tolerate ancient clients. 2. **The device is hostile territory.** The attacker owns the hardware, can decompile the binary, and can read anything you store insecurely. Client-side checks are deterrents, not controls; enforcement lives on the server. 3. **Resources are budgeted, not abundant.** Main thread, battery, memory, radio, and background execution time are all rationed by the OS. Apps that overspend get janked, killed, or throttled. Facts in this skill (OS versions, store policies, framework status) were verified against primary sources in June 2026. Mobile platforms move fast — when a specific deadline or version matters, re-verify against Apple/Google developer docs before relying on it. ## BUILD mode When creating or extending a mobile app: 1. **Settle the platform decision first.** Stack choice (native vs cross-platform), minimum OS floor, and target SDK are one-way doors. Use `rules/01` decision factors; record the decision and its rationale in the repo. 2. **Establish architecture before features.** Unidirectional data flow, DI seams, module boundaries, and navigation pattern from day one (`rules/02`). Retrofitting UDF onto a ball of mutable state is a rewrite. 3. **Decide the offline posture explicitly.** "Online-only with graceful errors" is a valid choice; "accidentally breaks offline" is not. If offline-first: local DB is the source of truth, mutations queue, sync is a background concern (`rules/03`). 4. **Wire operational survival kit before v1.0 ships:** crash reporting with symbol upload, forced-update mechanism, remote kill switches for risky features, API version header on every request (`rules/06`). These cannot be added retroactively for already-shipped binaries. 5. **Security defaults from the start:** secrets in Keychain/Keystore only, TLS everywhere, deep links validated, WebView locked down (`rules/04`). 6. **Budget performance up front:** cold start, frame time, and app size budgets in CI, not as a post-launch rescue (`rules/05`). 7. **Comply with current store requirements** before first submission: privacy manifest + required-reason APIs (iOS), Data safety form + target API level + 16 KB page support (Android) (`rules/01`, `rules/06`). ## AUDIT mode When auditing an existing mobile app, work through the rules files in order and report findings using this convention. ### Severity levels - **CRITICAL** — Exploitable security flaw or guaranteed user-facing breakage: secrets in SharedPreferences/UserDefaults/NSUserDefaults, tokens in deep-link URLs, unvalidated deep links reaching auth-sensitive screens, `javaScriptEnabled` WebView loading untrusted content with a JS bridge, no forced-update mechanism plus a known-bad shipped version, biometric auth gating a boolean instead of a key. - **HIGH** — Likely production incident or store rejection: missing crash reporting/symbolication, blocking main thread on I/O, no kill switch for a server-dependent feature, store policy violations (missing privacy manifest entries, stale target API), unbounded silent-push reliance, sync without conflict resolution. - **MEDIUM** — Degrades quality or future velocity: no DI seams (untestable), monolithic module (slow builds), missing list virtualization, no startup budget, permission prompts fired at launch, no staged rollout process. - **LOW** — Hygiene: missing snapshot tests, inconsistent navigation patterns, unbatched analytics, image caching misconfiguration. ### Finding format ``` [SEVERITY] <rule-file>#<rule> — <one-line title> Location: <file:line or module> Evidence: <the offending code/config, quoted> Impact: <what breaks, who exploits it, or what it costs> Fix: <concrete change, with code where non-obvious> ``` Order the report by severity, then by blast radius. An audit that returns only style nits has failed — check the CRITICAL list above explicitly and state "verified absent" for each. ## Rules index | File | Covers | |---|---| | [rules/01-platform-and-stack.md](rules/01-platform-and-stack.md) | Native vs cross-platform decision, React Native new architecture, Flutter, KMP/CMP status, when web/PWA suffices, minimum OS floors, target SDK policy, current platform baselines | | [rules/02-architecture-and-state.md](rules/02-architecture-and-state.md) | Unidirectional data flow (MVVM/MVI/TCA), state modeling, dependency injection, modularization for build times, navigation patterns | | [rules/03-offline-background-push.md](rules/03-offline-background-push.md) | Offline-first design, local DB as source of truth, sync engines, conflict resolution, mutation queues, optimistic UI, iOS background modes, WorkManager/Doze, APNs/FCM, token lifecycle, permission timing UX | | [rules/04-security.md](rules/04-security.md) | Keychain/Keystore, certificate pinning tradeoffs, biometrics gating keys, App Attest/Play Integrity, token handling, deep link validation, root/jailbreak detection honesty, WebView hardening, obfuscation reality, OWASP MASVS | | [rules/05-performance.md](rules/05-performance.md) | Startup budgets, main-thread discipline, jank/frame budgets, list virtualization, image loading, memory pressure, battery, app size, ANR avoidance, MetricKit/Android vitals | | [rules/06-release-and-operations.md](rules/06-release-and-operations.md) | Store submission requirements, phased rollouts, feature flags/kill switches, forced updates, crash reporting, OTA updates policy, API versioning for old clients, testing strategy | | [rules/07-swift-language.md](rules/07-swift-language.md) | Swift as a language (any target, incl. server-side Swift): Swift 6 strict concurrency (actors, Sendable, isolation), value semantics/COW, protocol-oriented design, optionals/typed throws, ARC and retain cycles, unsafe pointer/C interop, SwiftPM supply chain (Package.resolved, registry signing, binary checksums), Swift Testing vs XCTest | ## Top 10 non-negotiables 1. **Secrets live in Keychain (iOS) or Keystore-backed encrypted storage (Android) — never in UserDefaults, SharedPreferences, plist files, or hardcoded in the binary.** The binary is public; assume it is decompiled the day you ship. 2. **The server enforces; the client suggests.** Any authorization, entitlement, price, or integrity decision made only client-side is a finding. Root detection, pinning, and obfuscation are deterrents that raise cost — never the control. 3. **Never block the main thread on I/O, parsing, or crypto.** Main thread is for UI. Violations are jank on iOS and ANRs (and Play Store visibility penalties) on Android. 4. **Ship a forced-update mechanism in v1.0.** A version-check endpoint plus a blocking upgrade screen. The release where you discover you need it is the release you cannot fix. 5. **Every risky or server-dependent feature ships behind a remotely controllable kill switch.** You cannot roll back a binary; you can flip a flag. 6. **The API must tolerate every app version still in the wild.** Version every client request; never remove or repurpose fields a shipped binary reads; test the oldest supported client in CI against new server releases. 7. **Offline is a designed state, not an error state.** Local database as source of truth, queued mutations with idempotency keys, explicit conflict resolution. If you choose online-only, fail with designed UX, not spinners. 8. **Biometric auth must gate a cryptographic key, not a boolean.** `if (authenticated) { unlock() }` is patchable with one Frida hook; a key released by the secure enclave/StrongBox is not. 9. **Crash reporting with symbol upload (dSYM/mapping) wired into CI before first release**, with crash-free-session rate monitored per release and gates on staged rollout promotion. 10. **Meet current store requirements proactively:** iOS — privacy manifests and required-reason API declarations (mandatory since May 2024), built with the latest required SDK (iOS 26 SDK as of April 28, 2026); Android — target API 36 by Aug 31, 2026, Data safety form accuracy, 16 KB page-size support (required since Nov 1, 2025 for apps targeting Android 15+).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.