diff --git a/App.tsx b/App.tsx index 0329d0c..54e8590 100644 --- a/App.tsx +++ b/App.tsx @@ -1,20 +1,54 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +import { StatusBar } from "expo-status-bar"; +import { useEffect, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import { HomeScreen } from "./src/HomeScreen"; +import { CameraScreen } from "./src/CameraScreen"; +import { AudioPermissionSheet } from "./src/AudioPermissionSheet"; +import { AudioRecordingScreen } from "./src/AudioRecordingScreen"; +import { useCameraPermission, useLocationPermission } from "./src/permissions"; + +export type Screen = "home" | "camera" | "audioPermission" | "audioRecording"; export default function App() { + // using state instead of react navigation for simplicity + const [screen, setScreen] = useState("home"); + const location = useLocationPermission(); + const camera = useCameraPermission(); + + // Matches comapeo, which asks for camera + location together at launch, + // before onboarding. iOS queues permission dialogs and shows them one at a + // time, so these must be awaited in sequence — firing both at once can drop + // the second prompt. + useEffect(() => { + (async () => { + await location.request(); + await camera.request(); + })(); + }, []); + return ( - Open up App.tsx to start working on your app! + {screen === "home" ? ( + + ) : screen === "camera" ? ( + setScreen("home")} /> + ) : screen === "audioPermission" ? ( + setScreen("home")} + onGranted={() => setScreen("audioRecording")} + /> + ) : ( + setScreen("home")} /> + )} ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, + container: { flex: 1, backgroundColor: "#fff" }, }); diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..958e608 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,181 @@ +# iOS rehearsal — notes + +Practice app for working out the iOS build/submit pipeline before doing it for CoMapeo. +The app is disposable; these notes are the point. + +## Apple account + +We already have an organizational Apple Developer account for Awana, under the legal +entity **DTWO LTD**. + +- **Gregor** is the Account Holder — anything admin-like probably needs him. + +**The Program License Agreement blocks the whole account, not just one role.** Apple +periodically updates it, and until the **Account Holder** accepts, nobody on the team can +create apps, submit, or update — regardless of their role. Only the Account Holder can +accept it; Admins can't. Hit this immediately on pressing **+ → New App**: + +> The Apple Developer Program License Agreement has been updated and needs to be reviewed. +> In order to update your existing apps and submit new apps, the Account Holder must review +> and accept the updated agreement. + +Worth checking before any planned release — it can appear at any time and stops submissions +cold, with a fix only one person can perform. + +**Roles:** a plain Developer role can't create app records or manage signing certificates — +that needs **App Manager** or **Admin**. Creating an **App Store Connect API key** (what CI +uses to submit) needs Admin. Check your own role in App Store Connect → Users and Access. + +## Plugins inject Info.plist keys you didn't ask for + +The main finding so far, and CoMapeo will hit it too. + +Adding three plugins (location, camera, audio) also produced: + +- `NSLocationAlwaysAndWhenInUseUsageDescription` and `NSLocationAlwaysUsageDescription` — + `expo-location` writes all three location keys unconditionally, with generic text + ("Allow $(PRODUCT_NAME) to access your location"). So the app advertises **background + location** with no justification, which is the worst combination for review. +- `UIBackgroundModes: ["audio"]` from `expo-audio` — this app never plays background audio. +- `NSMotionUsageDescription` — sensor never used. + +**`ios.infoPlist` in `app.json` can only add or overwrite keys — it cannot remove them.** +Setting a key to `null` writes an empty object `{}` rather than deleting it, and setting +`UIBackgroundModes: []` is overridden straight back to `["audio"]` by the plugin. An empty +usage string is worse than the boilerplate: iOS crashes when a permission is requested +whose description is missing or malformed. + +Removing a plugin-injected key takes a **config plugin** that deletes it during prebuild — +the pattern CoMapeo already uses in `expo-config-plugins/` +(e.g. `removeMediaPlaybackPermission.js` does exactly this on the Android side). + +Also verify against the real `Info.plist` in a built `.ipa` — `expo config --type +introspect` only shows what Expo _intends_ to write. + +**Verified on a simulator build:** all three prompts showed our configured usage strings +verbatim, so `app.json` → prebuild → `Info.plist` → user-facing dialog works as expected. + +**The leftover `Always` keys did not affect the prompt.** The location dialog offered only +_Allow Once / Allow While Using App / Don't Allow_ — no "Always Allow" — even with +`NSLocationAlwaysAndWhenInUseUsageDescription` still in the plist. iOS drives the prompt from +what the app **requests**, not from what's declared. So the injected keys are a +**review-surface** problem (a reviewer sees background location declared and may ask why), +not a runtime one. + +> Gotcha: run introspection with an explicit `cd` into this repo. From the wrong directory +> it silently reports comapeo-mobile's config, which looks plausible and is wrong. + +## Local iOS builds need Xcode 26.4 — EAS builds don't + +**Expo SDK 56 requires Xcode 26.4.** Building locally on Xcode 16.4 fails with one useful +line buried in ~7,500 lines of log: + + package 'apple' is using Swift tools version 6.2.0 but the installed version is 6.1.0 + +(Xcode 16.4 ships Swift 6.1; SDK 56 needs 6.2.) Everything else in that output — 105 targets, +`ExpoModulesJSI` script failure, `IDELogStore` warnings — is noise. When `xcodebuild` exits +65, grep the log for `error:` rather than reading it: + + grep -nE "error:" .expo/xcodebuild.log | grep -v IDELogStore + +**EAS cloud builds are unaffected** — their images already run Xcode 26.4. This is the real +tradeoff for CoMapeo: + +- EAS builds insulate the team from local toolchain drift; anyone can build regardless of + their Xcode version. +- Local builds are free (no EAS build minutes) but require **every developer on Xcode 26.4+**. + Unlike Android, where NDK/Gradle versions are pinned in-repo, Xcode is a machine-level + install that can't be pinned per project. + +`eas build --local` additionally needs **fastlane** on PATH (`spawn fastlane ENOENT` if +missing). comapeo-mobile has a `Gemfile` pinning CocoaPods but no fastlane, and +`expo prebuild` doesn't generate one — so iOS local builds need that added. + +## Permission hooks hold per-call state + +`useCameraPermissions()` (expo-camera) and friends keep their own `useState` **per call +site**. Calling the same hook in two components gives two independent copies: request in one, +and the other never updates — it silently shows a stale status forever. + +Fix is a single call high up, passed down. Worth watching for in CoMapeo, where the same +permission is read in several places. + +Related: these hooks start at "undetermined" and only become accurate after a request, so +anything that displays status needs a `get…PermissionsAsync()` check on mount, plus a +re-check when the app returns to the foreground (granting in Settings backgrounds the app). + +## Where Info.plist actually lives + +`Info.plist` is iOS's app manifest — roughly `AndroidManifest.xml`'s counterpart. It holds +the bundle ID, version, background modes, and the permission usage strings iOS shows in the +system prompt. Apple's reviewers read it. + +In this app (Expo managed workflow) it is **not checked in and does not exist in the repo**: + + app.json → expo prebuild → ios/…/Info.plist → .ipa + ↑ ↑ + source of truth generated, gitignored + +EAS runs `prebuild` on its servers for every build, generating `ios/` fresh and discarding +it. That's why the injected keys are invisible in the repo — they only appear in a file +that exists mid-build. + +Note comapeo-mobile is different: it has `ios/` checked in (bare workflow), so its +`Info.plist` is a real, editable file there. + +## Decisions + +- **`supportsTablet: false`** — not cosmetic. With `true`, Apple reviews the app on an iPad + and iPad layout problems become rejectable. CoMapeo is phone-oriented, so same there. +- **`expo-camera`, not `react-native-vision-camera`** — vision-camera is a heavier native + module with more ways to fail on iOS. Start with the basics; if a build breaks it should + be Apple's signing, not a third-party camera library. +- **When-in-use location only** — no background location. It needs a real justification at + review and this app has no tracks feature to point at. +- **`ITSAppUsesNonExemptEncryption: false`** — keep. Without it every upload stalls behind a + manual export-compliance questionnaire. +- **Local network / Bonjour removed** — CoMapeo needs it for sync; this app has no sync and + the issue didn't ask for it. + +`expo.name` is user-visible in the permission dialogs (`"comapeo-ios-practice" Would Like to +Access the Camera`), so it needs to be a real display name, not a slug. CoMapeo already +handles this via `APP_NAME` in `app.config.js`. + +## eas.json — what iOS specifically needs + +CoMapeo already has an `eas.json` with iOS entries. The iOS-specific parts worth knowing: + +**Build numbers.** iOS has two version fields: `version` (`CFBundleShortVersionString`, the +"1.0.0" users see) and `buildNumber` (`CFBundleVersion`), which **must increase on every +upload** — Apple rejects a duplicate outright. So the store profile wants +`autoIncrement: true`, paired with `cli.appVersionSource: "remote"` (EAS tracks the number +server-side, so nothing to commit and CI can't collide). Without it the _second_ upload +fails with "bundle version must be higher than the previously uploaded version". +Only needed on the store profile — internal builds never reach App Store Connect. + +**`distribution: "internal"` already means ad-hoc** on a standard Organization account. +There is no extra field to set. `ios.enterpriseProvisioning` looks relevant but applies only +to **Apple Enterprise Program** accounts ($299/yr in-house distribution) — setting it on a +standard account is misleading noise. + +**`simulator: true` builds without any signing.** A simulator build needs no certificate, no +provisioning profile, and no Apple account at all — so it works even while the account is +blocked. Useful as a pure "does this compile for iOS" check, and it runs on EAS's Macs so no +local Xcode is required. + +**`submit.production: {}` can start empty.** EAS prompts for the App Store Connect app record +and credentials on first submit. + +**Builds cost money once the plan's credits run out.** The digidem plan's included builds +were already used up — additional builds bill pay-as-you-go (~$1 each). Worth batching config +changes rather than rebuilding to test one tweak, and worth checking the quota before wiring +up a workflow that builds on every push. + +## Environment + +Expo SDK 56.0.12 · React 19.2.3 · React Native 0.85.3 (matches +`refactor/core-react-native-module`) · Node 24.13.0 · eas-cli 21.4.0 · +EAS project `@digidem/comapeo-ios-practice` · bundle ID `com.comapeo.practice` + +Versions pinned exact, no `^`/`~`. `expo install` adds tilde ranges — strip them after. +Global eas-cli is per-Node-version; switching Node hides it. diff --git a/package-lock.json b/package-lock.json index 51465f2..d203a81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "expo": "56.0.12", "expo-audio": "56.0.13", "expo-camera": "56.0.8", - "expo-dev-client": "~56.0.24", + "expo-dev-client": "56.0.24", "expo-location": "56.0.22", "expo-status-bar": "~56.0.4", "react": "19.2.3", diff --git a/package.json b/package.json index 5651f3a..3602929 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "private": true diff --git a/src/AudioPermissionSheet.tsx b/src/AudioPermissionSheet.tsx new file mode 100644 index 0000000..de610c6 --- /dev/null +++ b/src/AudioPermissionSheet.tsx @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { + openSettings, + useMicrophonePermission, + useRecheckOnForeground, +} from "./permissions"; + +type Props = { + onNotNow: () => void; + onGranted: () => void; +}; + +/** + * Mirrors AudioAskPermissionBottomSheet in comapeo-mobile + * Once denied, iOS never prompts again and the button + * becomes "Go to Settings". + */ +export function AudioPermissionSheet({ onNotNow, onGranted }: Props) { + const microphone = useMicrophonePermission(); + + useRecheckOnForeground(microphone.check); + + useEffect(() => { + if (microphone.granted) onGranted(); + }, [microphone.granted]); + + return ( + + + Recording audio + + To record audio this app needs access to your microphone. + {microphone.canAskAgain + ? "" + : " Microphone access is currently blocked, so it has to be enabled in Settings."} + + + + + + Not Now + + + {microphone.canAskAgain ? ( + microphone.request()} + > + Allow + + ) : ( + + Go to Settings + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 32, + paddingTop: 120, + justifyContent: "space-between", + }, + content: { gap: 16, alignItems: "center" }, + title: { fontSize: 22, fontWeight: "600", textAlign: "center" }, + body: { + fontSize: 16, + color: "#555", + textAlign: "center", + lineHeight: 23, + }, + actions: { gap: 12, paddingBottom: 40 }, + primary: { + padding: 16, + borderRadius: 8, + backgroundColor: "#3D4B8C", + alignItems: "center", + }, + primaryText: { color: "#fff", fontSize: 17, fontWeight: "500" }, + secondary: { + padding: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: "#ccc", + alignItems: "center", + }, + secondaryText: { fontSize: 17, color: "#333" }, +}); diff --git a/src/AudioRecordingScreen.tsx b/src/AudioRecordingScreen.tsx new file mode 100644 index 0000000..971933b --- /dev/null +++ b/src/AudioRecordingScreen.tsx @@ -0,0 +1,40 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; + +export function AudioRecordingScreen({ onBack }: { onBack: () => void }) { + return ( + + Microphone granted + + This is a pretend Audio recording screen or button. + + + Back + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 32, + gap: 16, + }, + title: { fontSize: 22, fontWeight: "600" }, + body: { + fontSize: 16, + color: "#555", + textAlign: "center", + lineHeight: 23, + }, + button: { + marginTop: 16, + padding: 16, + paddingHorizontal: 32, + borderRadius: 8, + backgroundColor: "#3D4B8C", + }, + buttonText: { color: "#fff", fontSize: 17, fontWeight: "500" }, +}); diff --git a/src/CameraScreen.tsx b/src/CameraScreen.tsx new file mode 100644 index 0000000..e9e170a --- /dev/null +++ b/src/CameraScreen.tsx @@ -0,0 +1,99 @@ +import { useEffect } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { CameraView as ExpoCameraView } from "expo-camera"; +import { + openSettings, + useRecheckOnForeground, + type PermissionState, +} from "./permissions"; + +type Props = { + camera: PermissionState & { + check: () => void; + request: () => void; + }; + onBack: () => void; +}; + +export function CameraScreen({ camera, onBack }: Props) { + // Camera is already requested at launch, so this screen only re-asks when the + // launch prompt was dismissed and iOS will still allow a prompt. + useEffect(() => { + if (!camera.granted && camera.canAskAgain) { + camera.request(); + } + }, [camera.granted, camera.canAskAgain]); + + useRecheckOnForeground(camera.check); + + return ( + + {camera.granted ? ( + + ) : ( + + + {camera.canAskAgain + ? "Waiting for camera permission" + : "Camera access is blocked"} + + {!camera.canAskAgain && ( + <> + + iOS will not show the prompt again. The only way back is the + Settings app. + + + Go to Settings + + + )} + + )} + + + Back + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#000" }, + camera: { flex: 1 }, + blocked: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 32, + gap: 16, + }, + blockedTitle: { + color: "#fff", + fontSize: 20, + fontWeight: "600", + textAlign: "center", + }, + blockedBody: { + color: "#bbb", + fontSize: 15, + textAlign: "center", + lineHeight: 21, + }, + button: { + padding: 16, + paddingHorizontal: 32, + borderRadius: 8, + backgroundColor: "#3D4B8C", + }, + buttonText: { color: "#fff", fontSize: 17, fontWeight: "500" }, + back: { + position: "absolute", + top: 60, + left: 20, + padding: 12, + borderRadius: 8, + backgroundColor: "rgba(0,0,0,0.5)", + }, + backText: { color: "#fff", fontSize: 17 }, +}); diff --git a/src/HomeScreen.tsx b/src/HomeScreen.tsx new file mode 100644 index 0000000..4c241da --- /dev/null +++ b/src/HomeScreen.tsx @@ -0,0 +1,126 @@ +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { Screen } from "../App"; +import { + useMicrophonePermission, + useRecheckOnForeground, + type PermissionState, +} from "./permissions"; + +type Props = { + location: PermissionState & { check: () => void }; + camera: PermissionState & { check: () => void }; + onNavigate: (screen: Screen) => void; +}; + +export function HomeScreen({ location, camera, onNavigate }: Props) { + const microphone = useMicrophonePermission(); + + useRecheckOnForeground(() => { + location.check(); + camera.check(); + microphone.check(); + }); + + return ( + + Permission check + + Location and camera are requested at launch, matching Comapeo. + Microphone is requested on demand, when you go to record audio. + + + + + + + +