Skip to content
Back to skills

Mobile Lifecycle Scan

BSecurity

Find lifecycle defects in RN/Expo, Flutter, iOS and Android apps — state lost on process death, background and FGS limits, permission dead ends, network calls with no timeout, deep-link payload crashes, push token and cold-start tap routing, OTA runtime mismatch — each proved on a device.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
developmentjavascripttypescriptgojavaphpswiftkotlinshellbashreact

Works with

  • cli
  • api

Security analysis

B88/100
  • criticalSends environment variables or credentials to an external URL

Pro shows the line behind each finding and how to fix it

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill mobile-lifecycle-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Mobile Lifecycle Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Mobile Lifecycle Scan
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-mobile-lifecycle-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-mobile-lifecycle-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: mobile-lifecycle-scan
description: Find lifecycle defects in RN/Expo, Flutter, iOS and Android apps — state lost on process death, background and FGS limits, permission dead ends, network calls with no timeout, deep-link payload crashes, push token and cold-start tap routing, OTA runtime mismatch — each proved on a device.
kind: skill
pack: mobile
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: mobile-lifecycle-scan

## Premise

A mobile app does not own its process. The OS kills it in the background, revokes its permissions from Settings, delivers a link or a push to an app that is not running, runs its background work on the OS's schedule or not at all, and updates its JavaScript under a binary that may not match. Every class here is a gap between what the code assumes about that lifecycle and what the OS does, on the OS version and `targetSdk` the app really builds against. None of it shows in a host test: a unit test runs in a process nobody kills.

**Every finding cites the code or config `<file:line>` + the OS event that triggers it + the version gate (lockfile or build-file line) + the oracle at its required tier (or the command that would settle it) + the fix in the project's stack.** A grep hit is never a finding by itself, and "no lifecycle defect found" is never printed without the class-coverage block (§ Output).

**Detect statically, prove on a device.** The proof tiers are D (device or emulator), I (instrumented or widget test), A (shipped artifact: merged manifest, signed entitlements) and S (static trace) — [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § The claim, restated. Each class below names its required tier. **No booted device means `SKIPPED`, never PASS**, and a candidate below its tier is **Unconfirmed** with the settling command, never dropped and never promoted on reading alone ([`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 2, gate 6).

## The rule

A candidate is a finding when all five hold:

1. **It ships.** Release build, not `__DEV__` / `kDebugMode` / `BuildConfig.DEBUG` / `#if DEBUG` / `src/debug/` / a test target, and a real entry point (launcher, link, push, job, screen) reaches it.
2. **The version gate holds.** The behaviour exists on the project's versions, read from the files grep 0 prints: merged `targetSdk`, `react-native`, `expo` SDK, Flutter, iOS deployment target ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 50–59).
3. **The effective config agrees.** The merged `AndroidManifest.xml` (`build/intermediates/merged_manifests/**`, or `apkanalyzer manifest print`), the signed entitlements (`codesign -d --entitlements :-`), the Expo prebuild output and config plugins — not only the source file. Libraries inject services, receivers and permissions.
4. **Nothing neutralizes it.** A wrapper, a library default, a base screen, a config plugin.
5. **The OS event really occurs** in the product's use — and the oracle at the class's tier fires, or the candidate stays Unconfirmed.

## Boundaries

- Listener, observer, controller and subscription leaks (acquire minus release) → `mobile-perf-scan` § 7 (this pack). Main-thread blocking, lists, images, startup → `mobile-perf-scan`.
- Outbox durability, retry idempotency, cross-account residue in caches and databases → `offline-sync-scan` (this pack), judged by `@offline-sync-auditor`. A push token left registered at logout stays here (§ 6); the two runs dedupe by `<file:line>`.
- Predictive back, edge-to-edge insets, navigation patterns (navigate in a composable body, unbounded stacks) → `platform-conventions-audit` (this pack).
- Purpose-string wording, FGS and permission **declarations** as store policy, privacy manifest, target-API dates, OTA content that changes the app's purpose → `store-compliance-scan` and `@app-store-reviewer` (this pack). A missing purpose string is a **crash** here (§ 3) and a release blocker there.
- Link hijack (an https filter with no `autoVerify`, a custom scheme for OAuth or payment returns), a link parameter reaching a WebView or `openURL`, token storage, cleartext → `mobile-security-scan` when the security pack is installed. Without it, § 5d rows are reported here, tagged `security-class (owner pack not installed)`.
- The **rendered** error or never-ending loading state → `ui-state-scan` § 3 and § 11 when the ui-ux pack is installed; § 4 here owns the client's timeout. Without ui-ux, § 4e rows are reported here. Both runs dedupe by `<file:line>`.
- An unhandled rejection or swallowed error in general code → code-quality's error-handling detector when that pack is installed; § 4e keeps only network calls that drive a screen.
- New-Architecture bridge threading and lifecycle → `native-bridge-audit` (this pack).

## Adapt to the codebase — lanes

Read the versions grep 0 prints before applying any row, and print the lanes you ran in the output header. Under Expo CNG (`android/` and `ios/` git-ignored), ripgrep skips the generated folders because it honours `.gitignore`; that is correct — the fix belongs in `app.json` / `app.config.*` or a config plugin ([`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) § 3).

| Lane | Restoration primitive | Background API | Permission + "go to Settings" | Push | OTA | Lint lane (project config, keep only the rule) |
|---|---|---|---|---|---|---|
| **React Native** (bare, ≥ 0.82 New Architecture only) | none in JS: persist drafts; `RNScreensFragmentFactory` in `MainActivity`; React Navigation state persistence | headless JS, native modules | `react-native-permissions` `RESULTS.BLOCKED` + `openSettings`; `PermissionsAndroid` | `@react-native-firebase/messaging` | EAS Update in bare RN, hot-updater; CodePush retired | `react-hooks/exhaustive-deps`, `@typescript-eslint/no-floating-promises` |
| **Expo** (SDK 53–57) | as RN | `expo-background-task` (not `-fetch`), `TaskManager.defineTask` at module scope | `canAskAgain` + `Linking.openSettings()` | `expo-notifications` | `expo-updates`: `runtimeVersion` policy, code signing, `checkAutomatically` | as RN |
| **Flutter** (3.x) | `restorationScopeId`, `RestorationMixin`, `restorablePush*` | workmanager, `@pragma('vm:entry-point')` | permission_handler `isPermanentlyDenied` + `openAppSettings()` | `firebase_messaging` | Shorebird (Dart-only patches) | `flutter analyze`: `use_build_context_synchronously`, `unawaited_futures` |
| **Android** (Kotlin, Compose, Views) | `SavedStateHandle`, `rememberSaveable`, `onSaveInstanceState` | WorkManager, typed FGS, `canScheduleExactAlarms` | `shouldShowRequestPermissionRationale` + `ACTION_APPLICATION_DETAILS_SETTINGS` | `FirebaseMessagingService.onNewToken`, channels | — | `./gradlew lintRelease`: `ForegroundServiceType`, `ScheduleExactAlarm`, `WakelockTimeout`, `NotificationPermission`, `SelectedPhotoAccess`, `RepeatOnLifecycleWrongUsage`; detekt `GlobalCoroutineUsage` |
| **iOS** (Swift, SwiftUI, UIKit) | `@SceneStorage`, `NavigationPath` codable, `stateRestorationActivity` | `BGTaskScheduler` + `BGTaskSchedulerPermittedIdentifiers` | `UIApplication.openSettingsURLString` | `UNUserNotificationCenterDelegate` `willPresent` | — | SwiftLint `discarded_notification_center_observer`, `unhandled_throwing_task` (opt-in) |

## Pre-flight greps

Run as **one** script from the repo root (shell state does not survive between tool calls). Grep **N** feeds detector **§N**; grep 0 is context. Every `rg` names its path (`.` or `"${ROOTS[@]}"`), and set-differences use `while read` with an explicit file, never a bare `xargs rg`: given no path, ripgrep reads stdin under an agent's shell and returns zero hits or hangs. A line printed with a `2a`–`7d` tag is a set-difference result; the rest are grep hits. Any `rg` error on stderr is a failed grep, never zero hits.

```bash
# Mobile roots: RN/Expo package.json dirs, Flutter pubspec dirs, Android modules, Xcode project dirs; nested roots folded.
ROOTS=()
while IFS= read -r d; do ROOTS+=("$d"); done < <(
  { rg -l --pcre2 '"(?:react-native|expo)"\s*:' -g 'package.json' -g '!**/node_modules/**' .
    rg -l --pcre2 '^\s+sdk:\s*flutter\b' -g 'pubspec.yaml' -g '!**/.dart_tool/**' .
    rg --files -g '**/src/main/AndroidManifest.xml' -g '!**/{build,node_modules,.gradle}/**' . | sed 's#/src/main/AndroidManifest\.xml$#/x#'
    rg --files -g '**/*.xcodeproj/project.pbxproj' -g '!**/{Pods,node_modules,DerivedData}/**' . | sed 's#/[^/]*\.xcodeproj/project\.pbxproj$#/x#'
  } | while IFS= read -r f; do dirname "$f"; done | sort -u | awk 'p != "" && index($0, p "/") == 1 { next } { print; p = $0 }')
[ ${#ROOTS[@]} -gt 0 ] || { echo 'no mobile project marker: every class N/A (no mobile surface)'; exit 0; }
printf 'root %s\n' "${ROOTS[@]}"
# Never shipped in a release build: deps, build output, tests, debug source sets, generated code, docs.
X=(-g '!**/{node_modules,Pods,build,.gradle,.dart_tool,.expo,DerivedData,dist,vendor,.cxx}/**' -g '!**/src/{test,androidTest,debug}/**'
   -g '!**/{test,tests,__tests__,__mocks__,mocks,fixtures,__fixtures__,testdata,e2e,integration_test,.maestro,docs,ai}/**'
   -g '!**/*{Tests,UITests}/**' -g '!**/*.{test,spec,stories}.*' -g '!**/*_test.dart' -g '!**/*.{g,freezed,mocks}.dart' -g '!**/app_localizations*.dart' -g '!**/*.{md,mdx,lock,snap,map}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#(?!\{)|<!--)')
JS=(-g '*.{js,jsx,ts,tsx}'); KT=(-g '*.{kt,java}'); DA=(-g '*.dart'); SW=(-g '*.swift'); MF=(-g 'AndroidManifest.xml')

# 0. Version gates (every rule below is gated on one of these lines) and the CNG check.
rg -n --pcre2 '"(?:react-native|expo|react-native-screens|expo-updates|expo-notifications|expo-background-(?:fetch|task)|@react-native-firebase/messaging|@notifee/react-native|react-native-code-push|axios)"\s*:' -g 'package.json' "${X[@]}" "${ROOTS[@]}"
rg -n --pcre2 '^\s+(?:flutter|dio|http|firebase_messaging|workmanager|go_router|permission_handler):\s*["^>=]*\d' -g 'pubspec.yaml' "${ROOTS[@]}"
rg -n --pcre2 '\b(?:targetSdk(?:Version)?|compileSdk(?:Version)?)\b\s*[=:]?\s*(?:\d+|flutter\.\w+)|IPHONEOS_DEPLOYMENT_TARGET|SWIFT_VERSION|"targetSdkVersion"' -g '*.{gradle,kts,pbxproj,xcconfig,json}' "${X[@]}" "${ROOTS[@]}"
rg -n --hidden --pcre2 '^/?(?:android|ios)/?\s*$' -g '.gitignore' "${ROOTS[@]}"

# 1. Process death, restoration, owner-scoped work. 1a Compose input in remember; 1b ViewModel without SavedStateHandle;
#    1c fragment add in onCreate without a restore guard; 1d RN MainActivity without the screens fragment factory;
#    1e Flutter app without restorationScopeId; 1f setState after await with no mounted check; 1g SwiftUI input
#    with no scene storage; 1h collect without repeatOnLifecycle; 1i GlobalScope.
rg -n --pcre2 '\bremember\s*\{\s*mutableStateOf\(\s*(?:""|TextFieldValue\()' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n -U --pcre2 'class\s+\w+\s*(?:@Inject\s+constructor\s*)?(?:\((?![^)]*SavedStateHandle)[^)]*\))?\s*:\s*(?:Android)?ViewModel\(' "${KT[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n -U --pcre2 '(?s)\bonCreate\([^)]*Bundle[^)]*\)\s*\{(?:(?!(?i:override)\b|\bfun\s|savedInstanceState\s*[!=]=\s*null).)*?\bbeginTransaction\(\)' "${KT[@]}" "${X[@]}" "${ROOTS[@]}"
rg -l 'ReactActivity\b' -g 'MainActivity.{kt,java}' "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q 'RNScreensFragmentFactory|onCreate\(null\)' "$f" || echo "1d $f"; done
rg -n -U --pcre2 '\b(?:MaterialApp|CupertinoApp|WidgetsApp)(?:\.router)?\((?![^;]*\brestorationScopeId\b)' "${DA[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n -U --pcre2 'await [^;]+;\s*\n\s*setState\(' "${DA[@]}" "${X[@]}" "${ROOTS[@]}"
rg -l --pcre2 '\b(?:TextField|TextEditor|SecureField)\(' "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q '@SceneStorage|NavigationPath|stateRestorationActivity|restorationIdentifier' "$f" || rg -H -n --pcre2 '@State\s+(?:private\s+)?var\s+\w+\s*(?::\s*String\s*)?=\s*""' "$f"; done
rg -n --pcre2 'lifecycleScope\.launch(?:When\w+)?\s*\{(?!.*(?:repeatOnLifecycle|flowWithLifecycle)).*\.collect\b' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\bGlobalScope\.(?:launch|async)\b' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"

# 2. Background work. 2a <service> with no FGS type; 2b dataSync/mediaProcessing FGS class with no onTimeout;
#    2c FGS started from BOOT_COMPLETED; 2d exact alarm with no capability check; 2e wake lock with no timeout;
#    2f iOS BGTask ids, plist vs register (constants resolved; duplicates kill the app); 2g Expo task API;
#    2h Flutter background entry points without @pragma('vm:entry-point').
rg -o --no-filename --pcre2 '<service\b(?![^>]*foregroundServiceType)[^>]*?android:name="[\w.]*?\K\w+(?=")' "${MF[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u | while IFS= read -r c; do
  rg -l --pcre2 "\bclass\s+$c\b" "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q -U --pcre2 "(?s)\bclass\s+$c\b(?:(?!\n(?:[\w@]+\s+)*class\s).)*?\bstartForeground\(" "$f" && echo "2a $c $f"; done; done
rg -o --no-filename -U --pcre2 '<service\b(?=[^>]*foregroundServiceType="[^"]*(?:dataSync|mediaProcessing))[^>]*?android:name="[\w.]*?\K\w+(?=")' "${MF[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u | while IFS= read -r c; do
  rg -l --pcre2 "\bclass\s+$c\b" "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q -U --pcre2 "(?s)\bclass\s+$c\b(?:(?!\n(?:[\w@]+\s+)*class\s).)*?\bonTimeout\(" "$f" || echo "2b $c $f"; done; done
rg -l 'BOOT_COMPLETED' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -H -n 'startForegroundService\(' "$f"; done
rg -l --pcre2 '\bset(?:Exact(?:AndAllowWhileIdle)?|AlarmClock)\(' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q 'canScheduleExactAlarms' "$f" || echo "2d $f"; done
rg -l --pcre2 '\bnewWakeLock\(|\bWakeLock\b' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -H -n --pcre2 '\.acquire\(\s*\)' "$f"; done
P=$(rg -o --no-filename -U --pcre2 '(?s)BGTaskSchedulerPermittedIdentifiers</key>\s*<array>.*?</array>' -g 'Info.plist' "${X[@]}" "${ROOTS[@]}" | rg -o --pcre2 '(?<=<string>)[^<]+' | sort -u)
R=$( { rg -o --no-filename --pcre2 'forTaskWithIdentifier:\s*"\K[^"]+' "${SW[@]}" "${X[@]}" "${ROOTS[@]}"
      rg -o --no-filename --pcre2 'forTaskWithIdentifier:\s*(?:\w+\.)*\K[a-z]\w*(?=\s*,)' "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r n; do
        rg -o --no-filename --pcre2 "\b(?:let|var)\s+$n\s*(?::\s*String\s*)?=\s*\"\K[^\"]+" "${SW[@]}" "${X[@]}" "${ROOTS[@]}"; done; } | sort)
comm -3 <(printf '%s\n' "$P") <(printf '%s\n' "$R" | sort -u) | awk -F'\t' '$1 != "" { print "2f permitted-no-handler " $1; next } { print "2f registered-not-permitted " $2 }'; printf '%s\n' "$R" | uniq -d | sed 's/^/2f registered-twice /'
rg -n --pcre2 '"expo-background-fetch"\s*:' -g 'package.json' "${ROOTS[@]}"; rg -n --pcre2 '^\s+TaskManager\.defineTask\(' "${JS[@]}" "${X[@]}" "${ROOTS[@]}"
rg -o --no-filename --pcre2 '(?:Workmanager\(\)\.initialize|onBackgroundMessage|onStart:)\(?\s*\K[a-zA-Z_]\w*(?=\s*[,)])' "${DA[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u | while IFS= read -r n; do
  rg -q -U --pcre2 "@pragma\(\s*'vm:entry-point'\s*\)\s*\n(?:[\w<>?,]+\s+)*$n\s*\(" "${DA[@]}" "${X[@]}" "${ROOTS[@]}" || echo "2h $n has no @pragma('vm:entry-point')"; done

# 3. Permissions. 3a iOS purpose string missing for an API in use (Info.plist, build settings, Expo config);
#    3b request in an entry file at launch; 3c roots with requests and no permanently-denied path;
#    3d permission state persisted; 3e Android POST_NOTIFICATIONS / partial-photo declarations.
while IFS='~' read -r api key; do for r in "${ROOTS[@]}"; do   # per root: one app's Info.plist never covers another's API use
  rg -q --pcre2 "$api" -g '*.{swift,m,mm}' "${X[@]}" "$r" || continue
  rg -q "$key" -g 'Info.plist' -g '*.{pbxproj,xcconfig}' -g 'app.{json,config.js,config.ts}' "${X[@]}" "$r" || echo "3a $r: $key missing; used at $(rg -l --pcre2 "$api" -g '*.{swift,m,mm}' "${X[@]}" "$r" | head -3 | tr '\n' ' ')"
done; done <<'MAP'
\bAVCaptureDevice\b~NSCameraUsageDescription
\brequestWhenInUseAuthorization\(~NSLocationWhenInUseUsageDescription
\brequestAlwaysAuthorization\(~NSLocationAlwaysAndWhenInUseUsageDescription
\bPHPhotoLibrary\.(?:requestAuthorization|authorizationStatus)~NSPhotoLibraryUsageDescription
\bCNContactStore\b~NSContactsUsageDescription
\bAVAudio(?:Recorder|Session\.sharedInstance\(\)\.requestRecordPermission)~NSMicrophoneUsageDescription
\bLAContext\b~NSFaceIDUsageDescription
\bCBCentralManager\b~NSBluetoothAlwaysUsageDescription
\bATTrackingManager\b~NSUserTrackingUsageDescription
\bEKEventStore\b~NSCalendarsFullAccessUsageDescription
\bSFSpeechRecognizer\b~NSSpeechRecognitionUsageDescription
\bCMMotionActivityManager\b~NSMotionUsageDescription
MAP
RQ='\b(?:requestPermissions?|request(?:Foreground|Background)?Permissions(?:Async)?|requestAuthorization|requestWhenInUseAuthorization|requestAlwaysAuthorization|requestAccess|PermissionsAndroid\.request(?:Multiple)?|request(?=\(PERMISSIONS\.)|Permission\.\w+\.request)\s*\('
rg -n --pcre2 "$RQ" -g '{MainActivity,*Application,AppDelegate,SceneDelegate}.{kt,java,swift}' -g '{App,_layout}.{js,jsx,ts,tsx}' -g 'main.dart' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
for r in "${ROOTS[@]}"; do n=$(rg -n --pcre2 "$RQ" "${X[@]}" "$r" | "${NC[@]}" | wc -l | tr -d ' ')
  [ "$n" -gt 0 ] && ! rg -q --pcre2 'shouldShowRequestPermissionRationale|ACTION_APP(?:LICATION_DETAILS|_NOTIFICATION)_SETTINGS|open(?:App|System|Notification)?Settings|AppSettings\.open|canAskAgain|[pP]ermanentlyDenied|RESULTS\.BLOCKED|open(?:Notification)?SettingsURLString' "${X[@]}" "$r" && echo "3c $r: $n request line(s), no permanently-denied path"; done
rg -n -i --pcre2 '(?:permission|granted|authori[sz])\w*.{0,40}(?:putBoolean|setBool|AsyncStorage\.setItem|UserDefaults|prefs\.set)|(?:putBoolean|setBool|AsyncStorage\.setItem|UserDefaults\.standard\.set|\.set(?:Bool|String)?)\(\s*["\x27]\w*(?:permission|granted|authori[sz])' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 'READ_MEDIA_(?:IMAGES|VIDEO)' "${MF[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q 'READ_MEDIA_VISUAL_USER_SELECTED' "$f" || echo "3e partial-photo $f"; done
rg -l --pcre2 '\bNotification(?:Compat)?\.Builder\(|FirebaseMessagingService\b' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do m=${f%%/src/main/*}; rg -q 'POST_NOTIFICATIONS' "${X[@]}" "$m" || echo "3e no POST_NOTIFICATIONS in $m ($f)"; done | sort -u

# 4. Network timeouts. 4a axios instance with no timeout; 4b fetch with no signal; 4c dio / http with no timeout;
#    4d a timeout set to zero (OkHttp, URLSession) and HttpURLConnection / Ktor with none; 4e .then with no .catch.
rg -n -U --pcre2 '\baxios\.create\(\s*(?:\)|\{(?![^}]*\btimeout\b)[^}]*\}\s*\))' "${JS[@]}" "${X[@]}" "${ROOTS[@]}"; rg -n --pcre2 '\bdefaults\.timeout\b' "${JS[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n -U --pcre2 '(?<![.\w])fetch\((?![^)]*\bsignal\b)' "${JS[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n -U --pcre2 '\bDio\(\s*(?:\)|BaseOptions\((?![^;]*(?:connectTimeout|receiveTimeout)))|\bhttp\.(?:get|post|put|patch|delete|head|read)\((?![^;]*\.timeout\()' "${DA[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --pcre2 '\b(?:connect|read|write|call)Timeout\(\s*(?:0L?\s*,|Duration\.ZERO\b|0\.(?:seconds|milliseconds)\b)|\btimeoutIntervalFor(?:Request|Resource)\s*=\s*0\b' "${KT[@]}" "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 '\bopenConnection\(\)|\bHttpClient\(' "${KT[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q --pcre2 'setConnectTimeout|setReadTimeout|connectTimeout\s*=|readTimeout\s*=|HttpTimeout|OkHttpClient' "$f" || echo "4d $f"; done
rg -n -U --pcre2 '\b(?:fetch\w*|\w*[aA]pi\.\w+)\([^;]*?\)\s*\.then\((?![^;]*\.catch\()' "${JS[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"

# 5. Deep-link payloads. 5a link params parsed with a throwing call; 5b Expo +native-intent with no try;
#    5c link entry points and routers with no redirect (read against auth hydration); 5d routed to security.
rg -n --pcre2 'getQueryParameter\([^)]*\)!!|getStringExtra\([^)]*\)!!|getQueryParameter\([^)]*\)\S*\.to(?:Int|Long|Double)\(\)|\bint\.parse\([^)]*(?:pathParameters|queryParameters)|(?:pathParameters|queryParameters)\[[^\]]+\]!|JSON\.parse\([^)]*(?:url|params|query|link)|\bInt\([^)]*(?:queryItems|url|components)[^)]*\)!|queryItems\?\.first\([^)]*\)\??!' -i "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l . -g '+native-intent.{js,jsx,ts,tsx}' "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q '\btry\s*\{' "$f" || echo "5b $f"; done
rg -n --pcre2 '\bgetInitialURL\(|Linking\.addEventListener\(\s*["\x27]url|\buseURL\(|\.onOpenURL\b|openURLContexts|\bonNewIntent\(|\bgetInitialLink\(|\buriLinkStream\b' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n -U --pcre2 '\bGoRouter\((?![^;]*\bredirect\s*:)' "${DA[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n -U --multiline-dotall --pcre2 '<intent-filter(?![^>]*autoVerify="true")[^>]*>(?:(?!</intent-filter>).)*?android:scheme="https?"' "${MF[@]}" "${X[@]}" "${ROOTS[@]}"; rg -n -U --multiline-dotall --pcre2 '<intent-filter\b(?:(?!</intent-filter>).)*?\K<data\b[^>]*android:scheme="(?!https?")[^"]+"' "${MF[@]}" "${X[@]}" "${ROOTS[@]}"; rg -n --pcre2 '<key>CFBundleURLSchemes</key>|"scheme"\s*:' -g 'Info.plist' -g 'app.json' "${X[@]}" "${ROOTS[@]}"

# 6. Push. 6a roots with a push SDK and no token-refresh hook; 6b logout with no token delete; 6c tap handler with no
#    cold-start read; 6d RNFB background handler outside index; 6e no Android channel; 6f iOS delegate with no
#    willPresent / Expo with no handler; 6g aps-environment per entitlements file; 6h legacy FCM error codes.
for r in "${ROOTS[@]}"; do rg -q --pcre2 '^\s+firebase_messaging:|"(?:@react-native-firebase/messaging|expo-notifications)"\s*:' -g 'pubspec.yaml' -g 'package.json' "$r" || rg -q --pcre2 'FirebaseMessagingService|registerForRemoteNotifications\(' "${KT[@]}" "${SW[@]}" "${X[@]}" "$r" || continue
  rg -q --pcre2 'onNewToken|onTokenRefresh|addPushTokenListener|didReceiveRegistrationToken|didRegisterForRemoteNotificationsWithDeviceToken' "${X[@]}" "$r" || echo "6a $r: no token-refresh hook"
  rg -q --pcre2 'onNotificationOpenedApp|onMessageOpenedApp|addNotificationResponseReceivedListener' "${X[@]}" "$r" && ! rg -q --pcre2 'getInitialNotification|getInitialMessage|useLastNotificationResponse|getLastNotificationResponse' "${X[@]}" "$r" && echo "6c $r: tap handler with no cold-start read"
  rg -q --pcre2 '\bNotification(?:Compat)?\.Builder\(|expo-notifications|@notifee' "${X[@]}" "$r" && ! rg -q --pcre2 'createNotificationChannel|NotificationChannel(?:Compat)?\(|setNotificationChannelAsync|createChannel\(|AndroidNotificationChannel' "${X[@]}" "$r" && echo "6e $r: notifications posted, no channel created"
  rg -q '"expo-notifications"' -g 'package.json' "$r" && ! rg -q 'setNotificationHandler' "${X[@]}" "$r" && echo "6f $r: expo-notifications with no setNotificationHandler"
  rg -l -i --pcre2 '\b(?:fun|func|function|Future<void>|const|def)\s+(?:log_?out|sign_?out)\b' "${X[@]}" "$r" | while IFS= read -r f; do rg -q -i --pcre2 'deleteToken|unregisterForNotificationsAsync|unregisterForRemoteNotifications|DELETE[^\n]*(?:device|push)[-_]?token|remove(?:Device|Push)Token' "$f" || echo "6b $f"; done; done
rg -l 'setBackgroundMessageHandler' "${JS[@]}" -g '!index.{js,ts}' "${X[@]}" "${ROOTS[@]}"; rg -n --pcre2 'onBackgroundMessage\(\s*(?:\(|async\b|\w+\s*=>)' "${DA[@]}" "${X[@]}" "${ROOTS[@]}"
rg -l 'UNUserNotificationCenterDelegate' "${SW[@]}" "${X[@]}" "${ROOTS[@]}" | while IFS= read -r f; do rg -q 'willPresent' "$f" || echo "6f $f"; done; rg -n '\bshouldShowAlert\b' "${JS[@]}" "${X[@]}" "${ROOTS[@]}"
rg -n -A1 'aps-environment' -g '*.entitlements' "${ROOTS[@]}"; rg -n 'CODE_SIGN_ENTITLEMENTS' -g '*.pbxproj' "${ROOTS[@]}"
rg -n --pcre2 '\b(?:NotRegistered|InvalidRegistration|MismatchSenderId)\b|fcm\.googleapis\.com/fcm/send\b' "${X[@]}" .

# 7. OTA. 7a expo-updates with no runtimeVersion; 7b self-hosted updates with no code signing; 7c launch policy;
#    7d CodePush with no self-hosted server.
for r in "${ROOTS[@]}"; do rg -q '"expo-updates"' -g 'package.json' "$r" || continue; c=$(rg --files -g 'app.{json,config.js,config.ts}' "$r" | head -1)
  [ -n "$c" ] || { echo "7a $r: expo-updates, no app config"; continue; }
  rg -q 'runtimeVersion' "$c" || echo "7a $c: no runtimeVersion"
  rg -q --pcre2 '"url"\s*:\s*"https://(?!u\.expo\.dev)' "$c" && ! rg -q 'codeSigningCertificate' "$c" && echo "7b $c: self-hosted updates url, no codeSigningCertificate"
  rg -H -n --pcre2 '"checkAutomatically"\s*:\s*"(?:NEVER|ON_ERROR_RECOVERY)"|"fallbackToCacheTimeout"\s*:\s*[1-9]' "$c"; done
rg -n --pcre2 '\b(?:checkForUpdateAsync|fetchUpdateAsync|reloadAsync)\(' "${JS[@]}" "${X[@]}" "${ROOTS[@]}"
for r in "${ROOTS[@]}"; do rg -q '"react-native-code-push"' -g 'package.json' "$r" && ! rg -q --pcre2 'CodePushServerUrl|CodePushServerURL' "$r" && echo "7d $r: react-native-code-push, no self-hosted server URL"; done; rg --files -g 'shorebird.yaml' "${ROOTS[@]}"
```

A finding is a grep hit **plus** a read of the enclosing function or config block **plus** the version gate **plus** the effective config. Three blind spots are structural:

- **The merged manifest is not in the source tree.** Grep 2a/2b/3e read `src/main/AndroidManifest.xml`; a library can add a service, a receiver or `POST_NOTIFICATIONS`. When a build exists, read `**/build/intermediates/merged_manifests/**/AndroidManifest.xml` (git-ignored, so name the path explicitly) or `apkanalyzer manifest print app.apk`, and judge on that.
- **Wrappers hide the call.** Greps 4a–4c see the client at its construction; a shared `apiClient` built once with a timeout closes every call through it, and a hand-rolled `request()` that calls `fetch` without a signal opens all of them. Read the one client module before the call sites.
- **Indentation and statement scope are heuristics.** 1f needs `setState` on the line after the `await`; 2g needs `defineTask` indented. Read each Flutter `State` with an `await`, and each task file.

**Measured** (2026-10-05, ripgrep 15.2.0; bash 3.2 and zsh give the same hit set, with stdin piped or not). On the program's seeded multi-stack sample (bare RN 0.82, Expo SDK 57, Flutter 3.47, Kotlin + Compose at `targetSdk` 35, SwiftUI; 57 positives and 41 decoys across the seven classes) the script runs in under 1 s, surfaces every positive and no decoy; two printed lines are left to the read step (a Debug-only entitlements file, an OkHttp read timeout of 0 that may be a stream). On every web and server fixture in `benchmarks/fixtures/*` it finds no mobile root and prints `N/A` — without the root computation, the § 4 fetch grep fires on every web client; on `mobile-defects` (five mobile roots, measured 2026-10-10) it prints all 14 of its seeds in 74 lines — its `DEFECTS.md` § Detector measurement. On a read-only real Flutter app (1,167 files, 440 Dart) the first version printed 8 candidate lines, 7 false — a gen-l10n doc comment, a permanently-denied path named `permanentlyDenied` / `openSystemSettings`, two logout files in an app with no push SDK (the gate had matched a doc comment), three `<queries>` schemes. The exclusion of `app_localizations*.dart`, the comment filter on 1e, the wider settings vocabulary, the manifest-based push gate and the `<intent-filter>` scope fixed all seven; the one line left (`MaterialApp.router` with no `restorationScopeId`) is a true § 1 candidate.

## The 3,000-hit path

1. **Intersect** with reachability: a screen no navigator, route file or link config reaches is dismissed with one read; a whole module (a sample app, a design-system gallery, `tool/`) is dismissed with one read that shows nothing imports it, and named in the coverage block.
2. **Group** by (root cause, wrapper): one HTTP client with no timeout is one group however many calls use it; one base screen or `BaseViewModel` is one group.
3. **Rank** by user cost: payment, checkout, compose and sign-in flows first; then screens reached from links and pushes; then the rest. Field signals (Play vitals, Crashlytics, Sentry, Xcode Organizer) outrank code order when present.
4. **Read the top 30 groups fully.**
5. **Prefer the bulk device oracle at scale**: one Maestro flow per top screen (type → `pressKey: Home` → `killApp` → `launchApp: {stopApp: false}` → assert) settles § 1 for every group on that screen; one permissions-denied launch settles § 3 for the app.
6. **Ledger the rest** as `Live, unreviewed (k groups / n hits)` with the command that continues them. Never `checked`.

## Scans for

Severity assumes a release build and a reachable screen. Raise one step on payment, health or account flows; drop one when the screen is internal-only. **Critical** only with production evidence (a crash cluster, a store rejection).

| § | Class | Standard / lint id | Tier | Default |
|---|---|---|---|---|
| 1 | State lost on process death; restore crash; work outliving its owner | lint `RememberSaveableSaverParameter`, `RepeatOnLifecycleWrongUsage`; detekt `GlobalCoroutineUsage`; dart `use_build_context_synchronously` | D (I partial) | high on input or multi-step flows, else medium |
| 2 | Background work off the platform scheduler: FGS type, timeout, boot start, exact alarms, wake locks, BGTask ids | lint `ForegroundServiceType`, `ScheduleExactAlarm`, `Wakelock`, `WakelockTimeout` | A + D | high (crash or never runs) |
| 3 | Permissions: missing purpose string, launch wall, permanently-denied dead end, cached state, A13/A14 declarations | lint `NotificationPermission`, `SelectedPhotoAccess` | D | high (crash or dead end); medium otherwise |
| 4 | Network call with no timeout; network error with no handler | CWE-1088 | I | medium; high on checkout and sign-in |
| 5 | Deep-link payload that crashes or races auth at cold start | CWE-20 | D | high |
| 6 | Push: token not refreshed or not deleted, cold-start tap lost, background handler not invocable, no channel, silent foreground | — | D | high for wrong-account delivery, else medium |
| 7 | OTA: runtime mismatch, unsigned self-hosted updates, no self-heal, retired CodePush | CWE-494 (unsigned code) | D (A for config) | high; critical for unsigned from a self-hosted server |

### 1. Process death, restoration and owner-scoped work

```
BAD:   var query by remember { mutableStateOf("") }              class CheckoutViewModel : ViewModel() { val draft = MutableStateFlow("") }
       MaterialApp.router(routerConfig: r)                        final s = await api.status(); setState(() => status = s);
       @State private var draft = ""   // compose screen          lifecycleScope.launch { repo.updates.collect { … } }
GOOD:  var query by rememberSaveable { mutableStateOf("") }      class CheckoutViewModel(h: SavedStateHandle) : ViewModel()
       MaterialApp.router(restorationScopeId: 'app', …)           if (!mounted) return; setState(…)
       @SceneStorage("draft") var draft = ""                       repeatOnLifecycle(STARTED) { repo.updates.collect { … } }
```
**Read the code:** Is the state user input or a flow position (precedents § 11: transient UI state is not), or a cache re-read on restore (precedents § 12)? Does the screen sit behind a singleton (a session object, a DI `@Singleton`) that is null after process death, so the restored top screen crashes instead of recovering? On RN Android, does `MainActivity` install `RNScreensFragmentFactory` (precedents § 13)? Does an Android 16 tablet rotate a portrait-locked screen (orientation ignored at sw600dp, `targetSdk` 36)? **Finding when** user-visible state or a multi-step position is lost, or the restore path crashes, under background + `am kill`. **Severity:** high for typed input, checkout or compose; high for a crash on restore; medium for owner-scoped work (1f, 1h, 1i). **Proof:** the process-death restore (§ Proof), never force-stop, Don't keep activities or `recreate()` alone (precedents § 9–10).

### 2. Background work off the platform scheduler

```
BAD:   <service android:name=".SyncService" />  + startForeground(…)        // targetSdk ≥ 34: throws
       foregroundServiceType="dataSync", no onTimeout()                       // targetSdk ≥ 35: killed after the budget
       BOOT_COMPLETED receiver → startForegroundService(…)                   // targetSdk ≥ 35: dataSync start forbidden
       am.setExactAndAllowWhileIdle(…)  // no canScheduleExactAlarms()       lock.acquire()  // no timeout
       BGTaskScheduler.register(forTaskWithIdentifier: "com.acme.sync")      // not in BGTaskSchedulerPermittedIdentifiers
GOOD:  foregroundServiceType="dataSync" + FOREGROUND_SERVICE_DATA_SYNC + override fun onTimeout(…) { stopSelf() }
       WorkManager enqueueUniquePeriodicWork · if (am.canScheduleExactAlarms()) … else setAndAllowWhileIdle · acquire(10 * 60_000L)
```
**Read the code:** Read the **merged** manifest for the service's type and permission. Is the work user-initiated and visible (an FGS), or deferrable (WorkManager / `expo-background-task` / `BGProcessingTask`)? Is the BG identifier a constant or a `$(PRODUCT_BUNDLE_IDENTIFIER)` expansion the set-difference must resolve (precedents § 20)? Is registration complete before `didFinishLaunching` returns, and only once (a second register kills the app)? Flutter: is every background entry point top-level with `@pragma('vm:entry-point')` (precedents § 57)? Expo: `expo-background-fetch` is deprecated (precedents § 54) and `defineTask` must run at module scope. **Finding when** the gate's `targetSdk` (precedents § 52) is met and the work crashes, is killed, or never runs. **Severity:** high; medium for battery-only (an unbounded wake lock), raised when Play's excessive-wake-lock vital is at risk. **Proof:** tier A (merged manifest, `comm -3` set-difference) for the declaration, D for behaviour: `adb shell cmd jobscheduler run -f <pkg> <id>`, `dumpsys alarm`, `dumpsys power`, LLDB `_simulateLaunchForTaskWithIdentifier` on an iOS **device** (debug only).

### 3. Permissions

```
BAD:   AVCaptureDevice.requestAccess(…)  // no NSCameraUsageDescription in Info.plist, build settings or app config → crash
       useEffect(() => { PermissionsAndroid.request(CAMERA) }, [])  ·  Flutter: Permission.camera.request() in main()  ·  Kotlin: launcher.launch(CAMERA) in onCreate
       request(…) with no RESULTS.BLOCKED / canAskAgain / isPermanentlyDenied / openSettings path
       AsyncStorage.setItem('cameraPermissionGranted', …)            // stale after revoke, reset or auto-reset
GOOD:  request on the action that needs it; on permanently denied, explain and deep-link to Settings; re-check on every use
```
**Read the code:** Is the key supplied by `INFOPLIST_KEY_*` or an Expo config plugin (precedents § 24 — confirm in the prebuild output)? Is the request on a screen the user opened for that purpose (precedents § 23)? After two denials (Android 11+) or one (iOS), what does the user see — a dead button, a loop, a crash? Does the merged manifest declare `POST_NOTIFICATIONS` at `targetSdk` ≥ 33, and is it requested at runtime? At `targetSdk` ≥ 34 with `READ_MEDIA_IMAGES`, is `READ_MEDIA_VISUAL_USER_SELECTED` declared or the Photo Picker used? **Finding when** the feature crashes, loops or dead-ends on deny, deny-twice or a Settings revoke. **Severity:** high for a missing purpose string on a reachable path and for a dead end on a primary feature; medium for launch walls and cached state. **Proof:** D — Maestro `launchApp: {permissions: {all: deny}}`; `adb shell pm revoke <pkg> <perm>` (kills the process; relaunch must re-check) and `dumpsys package <pkg>` showing `USER_FIXED`; `xcrun simctl privacy booted revoke <service> <bundle>`.

### 4. Network call with no timeout, network error with no handler

```
BAD:   axios.create({ baseURL })         fetch(url)                      Dio(BaseOptions(baseUrl: u))       http.get(uri)
       OkHttpClient.Builder().readTimeout(0, SECONDS)                    config.timeoutIntervalForRequest = 0
       fetchCart().then(setCart)   // no .catch: the screen spins forever or shows stale data silently
GOOD:  axios.create({ baseURL, timeout: 15_000 })   fetch(url, { signal: AbortSignal.timeout(10_000) })
       Dio(BaseOptions(connectTimeout: …, receiveTimeout: …))   http.get(uri).timeout(const Duration(seconds: 10))
```
**Read the code:** Which client, and what is its default (precedents § 26): axios `timeout: 0`, dio null, Dart `http` none, and RN's Android networking client (`OkHttpClientProvider`) sets 0 — while OkHttp's own builder defaults to 10 s and `URLSession` to a 60 s idle timeout. Is the client built once in a wrapper that sets a timeout (then the call sites are covered)? Is a zero timeout on a streaming client bounded by a heartbeat or a call timeout? Does the screen render an error and a retry when the call fails (the rendered half is `ui-state-scan` § 3 / § 11 when ui-ux is installed)? **Finding when** a call that drives a screen or a write can wait forever, or its failure reaches no handler. **Severity:** medium; high on checkout, sign-in and sync. **Proof:** I — a test server (MSW, `MockWebServer` with `SocketPolicy.NO_RESPONSE`, a `URLProtocol` stub) that never answers; assert the call fails within the timeout and the error state renders.

### 5. Deep-link payloads that crash or race auth at cold start

```
BAD:   intent.data?.getQueryParameter("id")!!.toLong()       int.parse(state.pathParameters['id']!)
       JSON.parse(decodeURIComponent(url.split('?data=')[1]))  export function redirectSystemPath({ path }) { return new URL(path)… }  // throws
       Linking.getInitialURL().then(url => navigate(…))        // before the session store hydrates
GOOD:  getQueryParameter("id")?.toLongOrNull() ?: return showNotFound()   int.tryParse(…) ?? fallback
       try { … } catch { return '/' } in +native-intent          go_router redirect: + refreshListenable · Stack.Protected guard={signedIn}
```
**Read the code:** Is the parameter parsed with a throwing call (`!!`, `int.parse`, `JSON.parse`, `Int(…)!`) before any validation? Is the link handled before auth state is restored — does a logged-out cold link to an account screen land on sign-in, then the target? Does an Expo Router `+native-intent` `redirectSystemPath` ever throw (Expo's docs say it must not)? Does go_router have a `redirect` and `refreshListenable` when routes need a session? **Finding when** a malformed or out-of-order link crashes, or reaches an authed screen without a session. **Severity:** high. Grep 5d (an https filter with no `autoVerify`, custom schemes) is **routed**, not judged here (§ Boundaries). **Proof:** D — the matrix {cold, warm} × {logged out, logged in, expired} × {valid, malformed, foreign host}: `adb shell am start -W -a android.intent.action.VIEW -d "<url>" <pkg>` after a force-stop (a true cold start is correct **here**), `xcrun simctl openurl booted "<url>"`, Maestro `openLink`.

### 6. Push

```
BAD:   class PushService : FirebaseMessagingService() { /* no onNewToken */ }      logout(): clears storage, keeps the push token
       messaging().onNotificationOpenedApp(…)   // no getInitialNotification: a tap from a killed app is lost
       FirebaseMessaging.onBackgroundMessage((m) async { … })                      // anonymous: never runs in release
       NotificationCompat.Builder(this, "updates") with no channel created          // Android 8+: nothing shows
       UNUserNotificationCenterDelegate with no willPresent                         // iOS: silent in the foreground
GOOD:  onNewToken → upload · logout → deleteToken() + server DELETE · getInitialNotification + onNotificationOpenedApp
       @pragma('vm:entry-point') top-level handler · createNotificationChannel at start-up · willPresent → [.banner, .list]
```
**Read the code:** Is the token uploaded on every launch (precedents § 29 — that covers refresh)? Does logout unregister the device so account B never receives account A's notifications? Is `@notifee/react-native` present (archived; ≥ 7 swallows the RNFB tap APIs, § 55)? Does server-side send code prune tokens on FCM v1 `UNREGISTERED` (404), not only the legacy `NotRegistered` (precedents § 56)? Which build configuration signs with which `.entitlements` (`CODE_SIGN_ENTITLEMENTS` in the `.pbxproj`) — `aps-environment = development` in Release breaks TestFlight and App Store delivery. **Finding when** a notification is lost, silent, or delivered to the wrong account. **Severity:** high for wrong-account delivery and a lost cold-start tap; medium otherwise. **Proof:** D — the matrix {foreground, background, terminated, killed by the system} × {notification, data-only}; log out, then send to the old token and assert nothing shows; `xcrun simctl push booted <bundle> payload.apns`; `codesign -d --entitlements :- <app>` (A) for the environment.

### 7. OTA updates

```
BAD:   "updates": { "url": "https://updates.example.com/api/manifest", "checkAutomatically": "NEVER" }   // no runtimeVersion, no codeSigningCertificate
       "react-native-code-push": "…"  // service retired 2025-03-31, no CodePushServerURL
GOOD:  "runtimeVersion": { "policy": "fingerprint" } · "codeSigningCertificate" + "codeSigningMetadata" · ON_LOAD, or NEVER + an early checkForUpdateAsync
```
**Read the code:** Does the runtime version change whenever native code changes (the `fingerprint` policy, or a CI bump — precedents § 30)? Is the update server self-hosted, and are updates signed? With `NEVER`, is there an explicit early check — error recovery only rolls back failures in the first seconds after launch? Is `fallbackToCacheTimeout` > 0 blocking launch on the network outside a kiosk? **Finding when** an update can reach a binary that lacks its native code, run unsigned from a server the team does not control, or leave a crash with no recovery path. **Severity:** high; critical for unsigned updates from a self-hosted server. **Proof:** D — publish an update that calls a new native module to a build of the previous binary (it must not be offered under `fingerprint`); a tampered or wrongly signed update must be rejected; an update that throws at first render must roll back.

## Proof — the device oracles

The oracle shapes, per stack, and the whole-class variants a fix must survive are in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Mobile; `device-harness` (this pack) boots the device and captures the evidence. The process-death recipe, because it is the one most often run wrong:

```bash
adb shell input keyevent KEYCODE_HOME          # background first: `am kill` is a no-op on a foreground app
adb shell am kill "<application.id>"            # system-style kill — NOT force-stop, NOT stopApp
adb shell ps -A | grep "<application.id>"      # must print nothing; otherwise the step is SKIPPED (kill did not land)
# relaunch from Recents (not the launcher icon), then assert the typed text / step / selection is back
```

iOS: background (`XCUIDevice.shared.press(.home)`), then stop the process from Xcode, relaunch from the home screen; `terminate()` + `launch()` is a cold start, not this test. Flutter adds `tester.restartAndRestore()` (I). Each oracle **fails before the fix**; a test that passes on the unfixed code proves nothing. No booted device: the row is `[report-flagged]`, Unconfirmed, with the exact flow written out — never PASS.

## Output

```
mobile-lifecycle-scan — lanes: expo SDK 57 / react-native 0.87 (expo-app/package.json), android targetSdk 35 (android-native/app/build.gradle.kts:5),
                         flutter 3.47 (flutter_app/pubspec.yaml); ios deployment target not set in the repo
Roots: 4 · CNG: expo-app (android/ ios/ not ignored — prebuilt)    Device: emulator-5554 (API 35); no iOS simulator → iOS rows Unconfirmed

Findings: 2 · Unconfirmed: 1

1. android-native/app/src/main/java/com/acme/notes/SearchScreen.kt:8          [report-with-fix]   high
   §1 state lost on process death — the search query is in `remember`, the checkout draft in a ViewModel with no
   SavedStateHandle (CheckoutViewModel.kt:7). Proof: maestro flows/search-restore.yaml — type, Home, killApp,
   launchApp {stopApp:false}: the query is gone (fails). Fix: rememberSaveable; SavedStateHandle.getStateFlow("draft", "").

2. android-native/app/src/main/java/com/acme/notes/Services.kt:21              [report-with-fix]   high
   §2 dataSync foreground service with no onTimeout — UploadService, targetSdk 35 (build.gradle.kts:5); merged manifest
   confirms foregroundServiceType="dataSync". Started from BootReceiver (Services.kt:39), which Android 15 forbids.
   Fix: override onTimeout(startId, fgsType) { stopSelf() }; move the boot path to WorkManager.

3. flutter_app/lib/push/background.dart:6                                          [report-flagged]    high
   §6 background handler not invocable in release — anonymous closure passed to onBackgroundMessage.
   Unproven link: no release build on a device. Settling run: release build, data-only high-priority send while terminated.
   Fix: a top-level function annotated @pragma('vm:entry-point').

Verification: 31 candidates → 2 findings · 1 unconfirmed · 4 hardening · 0 pre-existing · 24 refuted
Routed: §5d 3 rows → mobile-security-scan (security pack installed) · §4e 1 row → ui-state-scan §11

Class coverage:
  §1  process death / restoration      checked (9 hits → 1 finding)
  §2  background work                  checked (10 hits → 1 finding; PlayerService mediaPlayback is precedent § 18)
  §3  permissions                      checked (7 hits → 0 findings; 2 Unconfirmed folded into the iOS device gap)
  §4  timeouts / error handler         checked (8 hits / 3 groups → 0 findings; the shared client sets 15 s)
  §5  deep-link payloads               checked (6 hits → 0 findings) · 5d routed
  §6  push                             checked (10 hits → 1 unconfirmed)
  §7  OTA                              checked (3 hits → 0 findings: fingerprint runtime, signed updates, ON_LOAD)
```

`[report-with-fix]`: the trigger, the version gate and the effective config are cited, the oracle at the class's tier failed, and the fix is local. `[report-flagged]`: one link is unproven (no device, no merged manifest, a wrapper not read), and the row names it and the settling run. The coverage block lists **all seven** classes on every run: `checked (k hits → m findings)` only when the grep ran on a present lane; `N/A (<reason>)` only when the repo has no such surface; `blocked (<reason>)` when a grep failed or a file could not be read; `Live, unreviewed (k groups / n hits)` for a partial review.

## False positives / precedents

The verifier reads [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most: force-stop, `stopApp`, swipe-away and `terminate()` are not process death (§ 9); transient UI state and repository caches (§ 11–12); the RN screens restoration fix (§ 13); special background access in the right app (§ 18); resolved BG constants (§ 20); requests in context and plugin-supplied purpose strings (§ 23–24); clients that already time out (§ 26); public links and once-only initial messages (§ 27–28); Debug-only entitlements and idempotent token uploads (§ 29); OTA policy choices (§ 30); every rule outside its `targetSdk` or version gate (§ 50–59).

## When to run

- On any diff that adds or changes a screen with input, a ViewModel or `State`, a service, receiver, alarm, job or BG task, a permission request, an HTTP client, a link handler or route config, push wiring, a logout path, or the update config — the `/mobile-audit` dispatch signal.
- On every whole-repo `/mobile-audit`, reporting all seven classes. `lane-mobile` (delivery pack, when installed) runs it on T2/T3 changes to the files above.
- When field signals show restore crashes, ANRs in services, `ForegroundServiceStartNotAllowedException`, or "notifications stopped" reports.

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/mobile.md`](../../../../snippets/precedents/mobile.md). Under `/mobile-audit` that happens in its verification phase; run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Halt on any finding without the `<file:line>`, the triggering OS event, the version-gate line and the fix.
- Do not mark a row `[report-with-fix]` without its oracle failing at the class's tier. No device or emulator → `SKIPPED` for the run's device rows and `[report-flagged]` for the finding — never PASS, and never a pass inferred from host tests.
- Do not use `am force-stop`, Maestro `stopApp`, Don't keep activities, `recreate()` or XCUITest `terminate()` as process-death proof; do not accept a kill whose process check still shows the process.
- Do not put a fix into git-ignored `ios/` or `android/` of an Expo CNG project; it belongs in app config or a config plugin.
- Do not judge a manifest class from the source manifest alone when a merged manifest or a build is available.
- Do not print "no lifecycle defect found" without the class-coverage block; a class whose grep did not run is `blocked`, never `checked`.
- Do not re-derive a class another skill owns (§ Boundaries). Route it.

## Related

- `/mobile-audit` dispatches this skill and folds its coverage block into the class ledger; `/mobile-fix` closes the findings failing-first on a device and re-runs this detector on the patched line.
- `device-harness` (this pack) runs the D-tier oracles; `@offline-sync-auditor` caps durability claims at `unproven` without a real kill.
- `mobile-perf-scan`, `offline-sync-scan`, `platform-conventions-audit`, `store-compliance-scan`, `native-bridge-audit` (this pack) own the neighbouring classes; `@app-store-reviewer` judges store gates.
- `mobile-security-scan` (security pack, when installed) owns link hijack, WebView, storage and transport; `ui-state-scan` (ui-ux pack, when installed) owns the rendered error and loading states.
- `ai-patterns/app-lifecycle.md`, `permissions.md`, `push-notifications.md`, `deep-linking.md`, `ota-updates.md` (this pack) hold the design guidance this detector checks against.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.