Part one ended with an app that ran. A calm iPad display for someone in the early stages of dementia — what day it is, what is happening now, what is next, and what is for tea. Tested, documented, and living entirely on one laptop.
TL;DR – Part two is about the two days spent turning that into this:
git push origin v0.1.0-beta1
One command. It runs the audit, builds both platforms, archives and signs iOS against a pinned certificate, signs Android with its own upload key, and delivers to TestFlight and Google Play internal testing. Both stores, from one tag.
Contents
- First, the app needed a name it could actually have
- The certificate limits nobody tells you about
- Ship every platform from day one, even the one nobody asked for
- The debug-signed bundle that looked perfectly healthy
- Every error pointed somewhere other than its cause
- 1. “CocoaPods did not set the base configuration”
- 2. “The caller does not have permission”
- 3. “Runner has conflicting provisioning settings”
- 4. “string contains null byte”
- And one that was our own fault
- Secrets have a scope, and getting it wrong is invisible
- Neither store lets automation create the first release
- The actual lesson: make it fail loudly
- One command
- An unexpected candidate for a primary device
- What part three will cover
- Still looking for testers
Getting there took four failed pipeline runs, and every single one of them reported something other than its actual cause. That turns out to be the most useful thing we learned, so it is most of what this piece is about.
First, the app needed a name it could actually have
We called it Today for a week. Today is, predictably, not available on the App Store.
Nor was Teatime, Look In, Savour, Relish, Hearth, Cuppa, Elevenses, or roughly twenty other perfectly ordinary English words. This is not really like domain name squatting, though it feels like it. It is seventeen years of published apps, plus abandoned ones that keep their names indefinitely, because Apple only reclaims unpublished reservations after a year. Every plain word that could plausibly name a consumer app was gone by about 2015.
Two things came out of that grind worth passing on.
The store name and the icon label are different fields, and they do not have to match. CFBundleDisplayName on iOS and android:label on Android control what appears under the icon; the store listing is separate. That mattered here more than usual, because the person using this app has to be able to find her way back to it among others on her iPad. So the store says Daysome — coined from day plus the -some of wholesome and winsome, and therefore unambiguously free — and her icon says My Day, which is true and plain and means something to her. The store name is aimed at the daughter browsing for something to help. The icon is aimed at the person using it. Different audiences, different words.
The App Store gives you three fields, not one. Name (30 characters, must be unique), subtitle (30, need not be), and keywords (100, never displayed). Almost everybody fights for a descriptive name when the description belongs in the subtitle and the search terms belong in keywords. A coined name plus a plain subtitle beats a generic name you cannot have.
There are now three names in this project and they are documented as three separate things: sienna (the package, bundle id and repo — never changes), Daysome (the listing), and My Day (what she reads).
The certificate limits nobody tells you about
Apple caps development certificates at two per team member and distribution certificates at three per team. We have two other apps in the estate, each with its own encrypted certificate repository. So the very first attempt to set up signing died on:
Could not create another Development certificate, reached the maximum number of available Development certificates.
Worse, the setup lane created the development certificate first, so it aborted before reaching the distribution certificate. Nothing at all was installed.
The fix was not to revoke somebody else's certificate. It was to notice we did not need one, which turned an annoyance into a policy worth having:
Local development is simulator only. Every build that reaches a physical device goes through TestFlight or Play internal testing.
Three reasons, all load-bearing. Store channels use the registered signing chain, so fingerprints always match what the backend expects. Every device build is reproducible, built from a tag, and gated behind the release audit — a sideloaded build is none of those, and when it misbehaves nobody can say what was actually on the device. And it means new apps do not spend one of the two development certificate slots.
It also means the per-app certificate repository pattern stops scaling at three apps. Worth knowing before app four, rather than during it.
Ship every platform from day one, even the one nobody asked for
This is an iPad app. The display is designed for a tablet on a stand in a living room. Android is not the point.
Android ships from the same tag anyway, from the first commit, and CI builds an Android AAB on every push. Not because anyone wants it yet — because a platform left until later accumulates debt silently, and then costs a week at the exact moment somebody finally needs it.
That claim got tested immediately. Adding Android surfaced two real problems within an hour:
Core library desugaring was missing. The notifications plugin needs it, and without it the release build fails at with a missing-class error that reads exactly like a dependency problem and is not one. Ten minutes to fix on the day; a baffling afternoon in six months.
The display genuinely broke on a phone. 425 pixels of overflow. The cause was specific and interesting: a full-length note is 444 pixels tall in a 313-pixel-wide column, and the schedule had already shed everything it could. This app has a hard rule that nothing scrolls and nothing is ever truncated with an ellipsis, so everything it is asked to show must physically fit — and the failure mode is a striped overflow banner across the screen of someone who cannot be expected to report it.
So the note now sheds too, and the order is documented: a meal's menu line goes first, then a row of the schedule, then the note. The note goes last because it is the only one of the three that is not about right now. We measure it with a TextPainter rather than estimating, because its height swings by a factor of five with column width and a wrong guess is a banner.
A phone is not a target for the person's display. But there is now a test asserting that it degrades rather than breaks, which is a good general pattern for a platform you support without designing for.
The debug-signed bundle that looked perfectly healthy
To check the Android platform still built, we built a release bundle. It built. 43MB, no warnings, entirely plausible.
It was signed with the debug key.
A Gradle release build with no keystore configured falls back to debug signing, and nothing in the output says so. On a laptop that fallback is a convenience worth keeping, so flutter run --release works. On CI it is a disaster in slow motion: a debug-signed bundle uploaded to the store under the wrong key, discovered long afterwards.
CI now refuses to build a release without a keystore. And the verification is worth knowing, because you cannot see this any other way:
keytool -printcert -jarfile app-release.aab | grep -E '^Owner|SHA256'
If Owner reads CN=Android Debug, it fell back.
Every error pointed somewhere other than its cause
Here is the centrepiece. Four failures, four misleading messages.
1. “CocoaPods did not set the base configuration”
The first iOS run failed after a wall of CocoaPods warnings. They looked like the culprit. They were not — they appear on every Flutter build and always have. The real line was further down:
Unable to find a destination matching { generic:1, platform:iOS }
error: iOS 26.5 is not installed. Please download and install
the platform from Xcode > Settings > Components.
The build machine is a separate Mac from the development laptop, and the iOS device platform is a separate download from the simulator SDK. A machine can build simulator apps indefinitely and still fail a device build, and an Xcode update will do this to you silently. Nothing surfaces it until a release, because CI only ever builds for the simulator.
How we found it: ran the runner's exact build command on the laptop, where it succeeded in 48 seconds. That immediately ruled out the code, the signing and CocoaPods, and left the machine.
2. “The caller does not have permission”
Google Play, on the Android half. That reads like a bad credential, and the natural instinct is to re-encode the service account key.
It is not a credential problem. That exact wording means the API authenticated successfully and then refused the resource — a bad key gives you an authentication error instead. The shared service account had permissions on the two existing apps and none on the new one. The fix was a checkbox in the Play Console, not a secret.
The general form: authentication failures and authorisation failures look similar and have nothing in common. Read which one you have before touching anything.
3. “Runner has conflicting provisioning settings”
This one at least says what is wrong, though not why. flutter create leaves an Xcode project on automatic signing. Fastlane's gym passes a manually specified identity and profile. Xcode refuses to reconcile the two.
The fix is to pin the Runner target's Release configuration to manual signing with the distribution identity and the match profile. We committed that into the project rather than having Fastlane rewrite it at build time, so local and CI builds are identical and the setting is visible in a diff.
4. “string contains null byte”
The best one. The archive succeeded, the IPA was exported and signed, and then the upload step failed with a Ruby error about null bytes — which sends you straight to your certificates, where nothing is wrong.
The App Store Connect API key secret held the raw .p8 file contents rather than base64 of them. Running a base64 decode over readable PEM text produces binary noise, and Ruby then refuses it as a file path. Nothing in the error mentions any of that.
And one that was our own fault
Between failures two and three we pushed fixes to main and re-ran the failed job. It failed identically.
Re-running a tag-triggered workflow checks out the tag, not the branch. The fixes were four commits ahead and had never been in the build. The tell was a warning we had already fixed still being present in the log — which is a decent argument for keeping logs quiet enough that a surviving warning is noticeable.
Secrets have a scope, and getting it wrong is invisible
Three apps, sharing one Mac build runner and one Apple team, had accumulated eleven secrets each with substantial duplication. Tidying them produced a rule worth stating.
Genuinely shared, so organisation level: the App Store Connect API key trio, the runner machine's keychain password, the Gitea token that reads the certificate repositories, the match passphrase, and the Play service account. Genuinely per-app, so repository level: the Android upload keystore and its passwords.
Seven and four, where we had assumed a roughly even split. The only thing that is truly per-app is the Android keystore.
Two traps in there. Repository secrets override organisation secrets, so adding an organisation-level value while the repository copy still exists changes nothing — the migration only lands when you delete the duplicates, which means the risky moment is the deletion, not the addition. And none of these are exercised by ordinary CI, only by deploys, so a mistake stays invisible until a release.
There is a subtler one. An unset secret expands to an empty string rather than being absent. We had two signing values written as ENV['X'] || default, and an empty string is truthy in Ruby — so a missing secret would have handed the build a blank signing identity and failed somewhere deep inside xcodebuild. Empty is now treated as unset.
Neither store lets automation create the first release
A one-time gate that catches everyone. The Play Developer API refuses uploads until an app has had at least one bundle through the Console by hand. App Store Connect will not accept a TestFlight build before the app record exists. Both fail in ways that read as credential problems when the real cause is that the app does not exist yet.
One detail worth copying: build the manual bootstrap through your release lane rather than a bare build command, so the hand-uploaded version number comes from the same scheme CI uses. Ours is a floor plus UTC-minutes-since-epoch, which means a local build and a CI build can never collide on the store's monotonic requirement — a problem an earlier commit-count scheme had caused us more than once.
The actual lesson: make it fail loudly
The thread running through all of this is that anything temporary should fail loudly rather than quietly produce something plausible.
- The signing placeholders in the Fastfile are deliberately invalid strings, so a misconfigured build fails at once instead of quietly signing one app with another app's certificate.
- CI refuses to build an Android release with no keystore, rather than falling back to the debug key and producing a perfectly healthy-looking bundle.
- The API key reader now accepts base64 or PEM and, given neither, names the secret and prints the command to regenerate it — instead of failing three layers down in somebody else's library.
- There is no default PIN in the app at all. The first person into supervisor mode is asked to choose one rather than guess one, because a shipped default means every device in the field opens with the same four digits.
Every one of those exists because of an hour lost to something that failed quietly first.
One command
Both platforms are green. The build is in TestFlight and on Play internal testing, and it is running on an iPad mini and real Android tablet.
What that buys is not the deployment. It is that the deployment has stopped being a decision. There is no window in which someone remembers whether the last build was signed properly, no step where a version number is typed by hand, no possibility of shipping something the tests have not passed. The audit runs first or nothing ships.
For a two-person operation building an app for one family, that sounds like over-engineering. It is the opposite. It is what lets the next fortnight be spent on whether the type is big enough at kitchen-table distance, rather than on remembering how to release.
An unexpected candidate for a primary device
One genuine surprise. We sideloaded the Android build onto an Amazon Fire Max 11 to see the tablet layout, expecting friction — Fire OS has no Google Play Services, and Flutter is widely assumed to need them.
It installed and ran first time. Nothing in this app touches Play Services, so there was nothing to miss.
Which raises a product question we did not expect to be asking. A family kitting out a parent's home faces a very different decision at two hundred pounds than at five hundred. An eleven-inch Fire tablet is a thing you buy for this purpose; an iPad is usually one that already exists. Those are two distinct markets, and the cheaper one may well be larger.
It was noticeably less responsive than a Google Pixel, which is worth taking seriously rather than dismissing and the real target is “an iPad she already has”, which is rarely a new one. One immediate consequence: the corner gesture that opens supervisor mode wanted five taps within three seconds, which turned out to be genuinely hard to hit on slower hardware. It is five seconds now.
What part three will cover
The two questions that have been open since the first day, both of which need real glass and neither of which a simulator can answer:
- Does Assistive Access work from a Flutter host? Apple's iPadOS mode for cognitive disability strips the device to enormous targets and a curated set of apps, and it persists across reboots — which solves “how do we keep this iPad on one screen after a power cut” outright. We have opted in via the Info.plist key, but Apple's API is SwiftUI-shaped and Flutter renders into a plain view controller. It is marked unverified in the project documentation and it is the first thing the TestFlight build is for.
- Is the type big enough? Every size in this app was chosen against a simulator on a desk. An iPad on a stand across a room is the only thing that settles it.
Then local notifications, a home screen widget, and the backend — which is not forced by the data, which is tiny, but by the people. A daughter updating the plan from her own phone is what needs a server.
Still looking for testers
If you support someone in the early stages of dementia who is still living independently, or you work in dementia care and would be willing to tell us where we have got it wrong, we would like a small number of people on TestFlight.
Plainly, so nobody is misled: it is a display that shows the day. It is not a medical device, not a monitoring system, and not a substitute for anybody. It does not track medication adherence and it never will.