Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -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<Screen>("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 (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
{screen === "home" ? (
<HomeScreen
location={location}
camera={camera}
onNavigate={setScreen}
/>
) : screen === "camera" ? (
<CameraScreen camera={camera} onBack={() => setScreen("home")} />
) : screen === "audioPermission" ? (
<AudioPermissionSheet
onNotNow={() => setScreen("home")}
onGranted={() => setScreen("audioRecording")}
/>
) : (
<AudioRecordingScreen onBack={() => setScreen("home")} />
)}
<StatusBar style="auto" />
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
container: { flex: 1, backgroundColor: "#fff" },
});
181 changes: 181 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions src/AudioPermissionSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Recording audio</Text>
<Text style={styles.body}>
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."}
</Text>
</View>

<View style={styles.actions}>
<Pressable style={styles.secondary} onPress={onNotNow}>
<Text style={styles.secondaryText}>Not Now</Text>
</Pressable>

{microphone.canAskAgain ? (
<Pressable
style={styles.primary}
onPress={() => microphone.request()}
>
<Text style={styles.primaryText}>Allow</Text>
</Pressable>
) : (
<Pressable style={styles.primary} onPress={openSettings}>
<Text style={styles.primaryText}>Go to Settings</Text>
</Pressable>
)}
</View>
</View>
);
}

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" },
});
Loading