# TestFlight (iOS) — hoe we een build de deur uit krijgen

> **Status:** ✅ Live (gebouwd, bereikbaar op genoemde route) · 🟡 Gedeeltelijk (werkt, met benoemd gat) · 🔧 In uitvoering · 📋 Gepland (met fase) · 💤 Bewust uitgesteld / non-goal

_Peildatum: 2026-07-25. Aanleiding: eerste TestFlight-ronde voorbereiden. De bredere
release-context staat in [design/tendlo-release-assessment.html](../design/tendlo-release-assessment.html)
(milestones M0–M3); de account-/hostingstand in [OWNER-INPUTS-NEEDED.md](../OWNER-INPUTS-NEEDED.md) §6._

## Korte stand

✅ Een **App Store-artefact bouwt en signeert lokaal met succes**. `flutter build ipa`
(export-method `app-store`, de standaard) levert een `.ipa` die met **Apple
Distribution: Aqua-IT B.V. (CKS7GP8VV5)** is gesigneerd, met `get-task-allow=false`
en `beta-reports-active=true` — precies wat TestFlight verwacht. Xcode's automatic
signing heeft de bijbehorende distributieassets zelf aangemaakt (zie
[Signing](#signing)). Er is dus **geen signing-blokkade** meer.

Wat wél nog moet gebeuren staat in [Vóór de eerste upload](#vóór-de-eerste-upload).

## Bouwen

```bash
scripts/build-store-ipa.sh                    # buildnummer = git rev-list --count HEAD
TENDLO_BUILD_NUMBER=1300 scripts/build-store-ipa.sh   # expliciete override
```

Gebruik voor alles wat naar App Store Connect gaat **`scripts/build-store-ipa.sh`**,
niet `scripts/flutter-with-defines.sh`. Het verschil is niet cosmetisch:

| | `flutter-with-defines.sh` (dev) | `build-store-ipa.sh` (store) |
|---|---|---|
| Cloud-defines ontbreken | waarschuwt en **bouwt door** | **stopt** |
| `PLUS_OVERRIDE=true` | **altijd aan** (eigen toestellen) | **nooit** |
| Buildnummer | pubspec (`+1`) | git-commitcount, of override |
| `SENTRY_ENV` | niet gezet (→ `development`) | `beta` |
| Controle achteraf | geen | `verify-store-ipa.sh` |

### Waarom dat verschil telt (P1)

`lib/core/config/app_config.dart` leest alle backend-configuratie uit
`--dart-define`. Ontbreken die waarden, dan **start de app gewoon op** maar draait
volledig local-first: geen inloggen, geen cloud-sync, geen gezins-uitnodigingen,
geen AI, geen account-sectie in Instellingen. Er is geen foutmelding en geen
crash — de app ziet er normaal uit. Zo'n build in TestFlight zetten levert testers
een halve app op die niemand als "kapot" herkent.

De defines komen in een archive/ipa terecht via `ios/Flutter/Generated.xcconfig`
(sleutel `DART_DEFINES`, base64 per waarde). Dat bestand is **gitignored en wordt
door élke `flutter build`/`flutter run` overschreven**. Gevolgen:

- Een `flutter build ipa` zónder defines produceert stil een local-only artefact.
- Een archive vanuit **Xcode** (Product → Archive, Organizer → Distribute) gebruikt
  wát er op dat moment toevallig in `Generated.xcconfig` staat — het resultaat hangt
  dus af van welk flutter-commando er als laatste draaide. Dat is de gevaarlijkste
  route en die moeten we niet gebruiken.
- Andersom: bouw je een store-artefact mét `flutter-with-defines.sh`, dan zit
  `PLUS_OVERRIDE=true` erin en krijgt **iedere tester gratis Plus** — waarmee de
  entitlement-grens (die sinds migratie 0042 server-side echt afdwingt) onbeproefd
  blijft.

Daarom is de controle op het **artefact** gezet, niet op de commandoregel:

```bash
scripts/verify-store-ipa.sh [pad/naar/Tendlo.ipa]
```

Die pakt zonder argument de nieuwste `build/ios/ipa/*.ipa` en controleert:
Supabase-URL/anon-key/PowerSync-URL daadwerkelijk in `App.framework` gebakken,
Sentry-DSN aanwezig, `PLUS_OVERRIDE` niet in de build-config, `PrivacyInfo.xcprivacy`
in de bundle, `ITSAppUsesNonExemptEncryption` gezet, distributiesignatuur, en
versie/buildnummer. Exit-code ≠ 0 = niet uploaden.

## Versie- en buildnummerbeleid

`pubspec.yaml` staat op `version: 1.0.0+1`. Voor TestFlight is `1.0.0` prima als
versienaam, maar App Store Connect weigert een upload met een **buildnummer dat al
bestaat voor dezelfde versie**. Met een vaste `+1` in pubspec kun je dus precies één
keer uploaden.

Gekozen werkwijze: het buildnummer komt **van buiten pubspec**, uit
`git rev-list --count HEAD` (stand 2026-07-25: **1202**). Dat loopt monotoon op met
de historie, is reproduceerbaar vanuit een commit, en vergt geen commit-per-upload.
`pubspec.yaml` blijft ongemoeid op `+1` — dat getal wordt bij een store-build
overschreven met `--build-number`.

Eén randgeval: twee uploads vanaf **dezelfde commit** krijgen hetzelfde nummer. Zet
dan `TENDLO_BUILD_NUMBER` hoger. Als dat vaak gebeurt, is
`date -u +%y%m%d%H%M` een alternatief (altijd hoger, maar minder leesbaar en niet
terug te voeren op een commit).

## Signing

Op deze Mac stond alleen een **Apple Development**-certificaat. Bij de eerste
`flutter build ipa` heeft Xcode's automatic signing zelf de distributieassets
aangemaakt in het team (Aqua-IT B.V., `CKS7GP8VV5`):

- certificaat **Apple Distribution: Aqua-IT B.V.**, geldig 2026-07-25 → 2027-07-25;
- profiel **iOS Team Store Provisioning Profile: com.aquait.tendlo**;
- profiel **iOS Team Store Provisioning Profile: com.aquait.tendlo.ShareExtension**.

Beide profielen hebben `get-task-allow=false` en `beta-reports-active=true`. De
ShareExtension-target heeft een eigen bundle-id (`…​.ShareExtension`) en dus een
eigen profiel — die moet meekomen, anders faalt de export.

⚠️ De privésleutel van het distributiecertificaat staat in de door Xcode beheerde
opslag en is **niet** zichtbaar via `security find-identity`. Exporteer hem een keer
als `.p12` (Xcode → Settings → Accounts → Manage Certificates → rechtsklik →
Export) en bewaar hem veilig. Zonder die back-up is een teamaccount met een
verlopen/ingetrokken certificaat een vervelende avond. Dit is ook wat een latere
CI-pipeline nodig heeft.

## PrivacyInfo

`ios/Runner/PrivacyInfo.xcprivacy` (app) en `ios/ShareExtension/PrivacyInfo.xcprivacy`
(extensie) zijn toegevoegd. De Runner-target heeft een expliciete registratie in
`Copy Bundle Resources` nodig (`scripts/wire_privacy_manifests.rb`, idempotent); de
ShareExtension-target gebruikt een Xcode-16 *synchronized folder* en pikt het bestand
automatisch op.

De required-reason API's zijn niet gegokt maar **uit het gebouwde artefact afgelezen**:
per framework zonder eigen manifest is met `nm -u` gekeken welke symbolen worden
geïmporteerd.

| Framework (geen eigen manifest) | Symbolen | Categorie | Reason |
|---|---|---|---|
| `sqlite3.framework` | `_stat _lstat _fstat _utimes _futimes` | FileTimestamp | `C617.1` (bestanden in de eigen app-/App Group-container) |
| `sqlite3.framework` | `_statfs _fstatfs` | DiskSpace | `E174.1` (ruimte checken vóór schrijven) |
| `receive_sharing_intent.framework` | `NSUserDefaults` | UserDefaults | `1C8F.1` (App Group-suite) + `CA92.1` (eigen defaults) |

Gecontroleerd en **niet** nodig voor het app-binary: `SystemBootTime` (Sentry en
PostHog declareren dat in hun eigen manifest). Ook zonder treffers:
`App`, `powersync_core`, `sqlite3_connection_pool`, `health`, `pdfx`,
`speech_to_text`, `sentry_flutter`, `device_calendar_plus_ios`, `objective_c`,
`CwlCatchException(Support)`.

`NSPrivacyTracking` staat op `false`: het Runner-binary linkt geen `AdSupport` of
`AppTrackingTransparency` (`otool -L`, 0 treffers), er is geen IDFA en PostHog draait
met anonieme profielen zonder autocapture op de EU-cloud.

`NSPrivacyCollectedDataTypes` bevat alleen wat uit de code te onderbouwen is:
e-mailadres (magic-link-login), naam (gezinsleden), overige gebruikersinhoud
(agenda/taken/lijsten/bijlagen naar Supabase + AI-proxy) en gezondheid (gewicht en
stappen synchroniseren mee, zie `powersync_cloud/sync-config.yaml`). Zie
[Open beslissingen](#open-beslissingen) voor wat bewust is opengelaten.

## Export-compliance

`ITSAppUsesNonExemptEncryption` staat nu in `ios/Runner/Info.plist` op **`false`**,
met een uitgebreide toelichting in het bestand zelf. Zonder deze sleutel blijft elke
build in App Store Connect op "Missing Compliance" staan en is hij **niet aan testers
uit te delen** tot iemand de vraag handmatig beantwoordt.

Feitelijk gebruikt de app alleen standaard platform-encryptie: HTTPS/TLS naar
Supabase, PowerSync, de ai-proxy (OpenRouter), open-meteo, PostHog en Sentry; de
Keychain via `flutter_secure_storage`; app-lock via LocalAuthentication. Geen eigen
crypto; de lokale database is niet versleuteld (SQLCipher staat op de
post-release-lijst). Dat valt normaal onder de standaard-exportvrijstelling — maar
**dat is een uitspraak die de eigenaar moet doen**, geen codebevinding. Zie
[Open beslissingen](#open-beslissingen).

## Vóór de eerste upload

1. **Export-compliance bevestigen** (zie hierboven) — anders blijft de build hangen.
2. **`SENTRY_PA_DSN` in `~/.zprofile` zetten.** Die variabele bestaat daar op dit
   moment **niet** (alleen `SENTRY_AUTH_TOKEN`/`SENTRY_ORG`/`SENTRY_PROJECT`), terwijl
   [OWNER-INPUTS-NEEDED.md](../OWNER-INPUTS-NEEDED.md) §1 aanneemt van wel. Gevolg: de
   builds op de vijf testtoestellen sturen **geen crashrapporten**. Zonde bij een
   testronde; `verify-store-ipa.sh` waarschuwt hierop.
3. **Testers een Plus-grant geven.** `plusEnforcementProvider` staat op `true` en
   `plusTrialAvailableProvider` op `false`: er is nog geen koopflow (RevenueCat/
   `purchases_flutter` zit niet in `pubspec.yaml`). Een tester zonder rij in
   `plus_entitlements` loopt dus tegen een harde Plus-grens waar niets achter zit.
   Zet per testhuishouden een admin-grant, zoals al gebeurd is voor de drie bestaande
   huishoudens.
4. **Uploaden** met Transporter of `xcrun altool --upload-app` + een App Store
   Connect API-key.

Wat **géén** blokkade is voor TestFlight: de Paid Apps Agreement op "Pending User
Info" en de nog niet aangemaakte abonnementen in ASC. Die raken in-app aankopen, en
de app doet nog geen enkele StoreKit-aanroep — er is geen paywall die stukloopt. Ze
blokkeren wel het testen van de koopflow zelf, zodra die gebouwd wordt.

## Open beslissingen

| # | Vraag | Aanbeveling |
|---|---|---|
| 1 | Valt Tendlo onder de export-vrijstelling voor encryptie? | Ja → `ITSAppUsesNonExemptEncryption` op `false` laten staan. Alleen standaard TLS/Keychain, geen eigen crypto. |
| 2 | Is `NSPrivacyCollectedDataTypes` compleet? | Nog te bevestigen: **locatie** (coördinaten gaan naar api.open-meteo.com voor het weer, worden niet opgeslagen — "Precise Location", App Functionality, niet gekoppeld) en **foto's/documenten** (AI-capture stuurt beelden via de ai-proxy). Deze twee zijn verdedigbaar maar ook verdedigbaar wég te laten; het antwoord moet 1-op-1 kloppen met het App Privacy-formulier in ASC. |
| 3 | Buildnummer uit de commitcount, of tijdstempel? | Commitcount (nu 1202) — reproduceerbaar en leesbaar. |
| 4 | TestFlight-upload via CI? | Nog niet. Zie [CI](#ci). |

## CI

`.github/workflows/ci.yaml` draait op `ubuntu-latest` en doet uitsluitend
`pub get` → `build_runner` → `gen-l10n` → `analyze` → `test`. Er is **geen macOS-runner,
geen signing, geen archive, geen fastlane en geen App Store Connect API-key**. CI
draagt op dit moment dus niets bij aan het release-pad.

Een TestFlight-upload via CI is technisch prima te doen (macOS-runner + `.p12` en
profiel uit secrets, of fastlane `match`, plus een App Store Connect API-key `.p8`),
maar het is een aparte klus met eigen risico's: geheimenbeheer, een macOS-runner die
een stuk trager en duurder is, en een tweede plek waar de dart-defines goed moeten
staan. Voor de eerste ronde(s) is lokaal bouwen met
`build-store-ipa.sh` + `verify-store-ipa.sh` sneller en beter te overzien. Wel al
zinvol in de huidige CI: `verify-store-ipa.sh` als losse baan draaien zodra er een
artefact is, en de dSYM-/Dart-symbol-upload naar Sentry (§1 van OWNER-INPUTS-NEEDED)
inregelen.

---
*Bronnen: `scripts/build-store-ipa.sh`, `scripts/verify-store-ipa.sh`, `scripts/wire_privacy_manifests.rb`, `ios/Runner/Info.plist`, `ios/Runner/PrivacyInfo.xcprivacy`, `ios/ShareExtension/PrivacyInfo.xcprivacy`, `ios/Runner.xcodeproj/project.pbxproj`, `lib/core/config/app_config.dart`, `lib/core/entitlement/plus_entitlement.dart`, `.github/workflows/ci.yaml`, `docs/OWNER-INPUTS-NEEDED.md` §1/§6; `nm -u`/`otool -L`/`codesign -dvvv` op de gebouwde `build/ios/ipa/Tendlo.ipa`. Geverifieerd op 2026-07-25.*
