Building for One, Part four: the £200 tablet that just worked

Building for One, Part one: a calm iPad display for someone living with dementia built a calm daily display for someone in the early stages of dementia. Building for One, Part two: what it costs to buy one command spent two days turning it into one command that ships to TestFlight and Play. Building for One, Part three: what a real iPad changeddiscovered, on a real device, how much of the design had been wrong.

This part is shorter and cheaper, and it starts with something that is meant to be a treat in build-in-public writing: a thing that worked first time.

TL;DR – A £200 Amazon Fire Max 11 tablet ran the build with no changes at all. Then we read the real screen numbers off the device, which proved a careful hedge wrong and uncovered a live layout bug in about ninety seconds. The lesson is about the guess, not the bug.

Why a Fire tablet at all

The reason is commercial, not technical, and it is worth saying plainly because it is the sort of thing that gets rationalised backwards later.

A family kitting out a parent's home faces a very different decision at £200 than at £500. An iPad, in this product's story, is usually a device that already exists: it belongs to the person, it has their photos on it, and the display is one more thing it does. An 11″ Fire tablet is something you buy for this purpose, deliberately, as part of setting a house up.

Those are two distinct markets with two distinct conversations, and there is a decent argument that the cheaper one is larger. So Fire is now a genuine second target rather than a curiosity someone mentions in a planning meeting.

It just worked

The experiment was about as unceremonious as these things get. Build split release APKs from the command line:

flutter build apk --split-per-abi --release

Put the arm64-v8a one on the device. It ran. Not “ran with a workaround”: ran, correctly, at the right size, with the right typography, first time, with no Fire-specific code and no changes to the project at all.

That is a genuinely nice property of Flutter on Android and it is worth understanding why rather than just being pleased about it, because the reason is also the constraint.

The reason is that we were not using anything Amazon does not have. Flutter draws its own pixels. It does not compose a screen out of platform widgets, so there is no vendor UI layer to disagree with. And this app's dependency list (state management, preferences, a wakelock, date formatting, local notifications) touches nothing that belongs to Google.

Fire OS has no Google Play Services. That is the whole story of Fire compatibility, and it is a cliff rather than a slope. A transitive Play Services dependency does not fail to compile. It does not fail in CI. It fails at runtime, on a device that nobody on the team uses daily, in front of the one person least able to tell you what they are seeing.

So the build working first time is not really a fact about our skill. It is a fact about what we happened not to have added yet.

There is one part of it we will take credit for, though, and it is the chassis from part one. The reason there was an Android build sitting there to try at all is that the chassis ships both platforms from the first commit (signing config, core library desugaring, a debug APK built on every push) on an iPad-centred product where nobody had asked for Android yet. Had that been left until the day somebody wanted a Fire tablet, this experiment would have opened with a week of Gradle archaeology instead of one command. That is the argument for building simultaneously for iOS and Android from the outset, and this is the first time in this project that it has actually been validated.

Which is why the next thing we did was make it hard to break

A capability that works by accident is a capability you lose without noticing. The gap between “this runs on a Fire tablet today” and “this runs on a Fire tablet in eight months” is one dependency added by someone on our team working to add some future feature, who has never read a word about Amazon.

So the constraint got written down in three places, each of which catches a different kind of forgetting.

Writing it down, with the reasoning attached

The project's requirements documentation gained a section explaining what Fire OS rules out and what we do instead. Not a list of banned libraries: a table of the decision:

Ruled outInstead
Firebase Cloud Messaging Polling, which the backend design had already chosen
Firebase / Firestore Joomla plus a local snapshot
Google Play Billing The display device never transacts
Crashlytics Sentry, if we ever need one
Maps, Sign-In, Play Integrity Not needed by this app

The reasoning is the part that survives. A rule without it looks arbitrary in three months and gets tidied away by whoever is next in the file.

Making it a build failure

Our pre-release script now refuses to pass if a Play Services dependency appears anywhere in the Android project or the manifest of packages:

if grep -rqiE 'play-services|com\.google\.android\.gms|google-services' \
     android/ pubspec.yaml; then
  echo "✘ a Google Play Services dependency has appeared: Fire OS cannot run it"
  fail=1
fi

Four lines of shell. It is not clever and it does not need to be. It converts a runtime failure on an untested device into a build failure on the machine of the person who caused it, which is the entire trick.

Pinning the shape in tests, and where it stopped being tidy

Every screen size this display has been designed against is 4:3, because every one of them has been an iPad. A Fire Max 11 is 5:3, and it is landscape by construction; the camera is on the long edge, because Amazon expects it to be held that way. So the two-page layout that an iPad treats as its wide variant is a Fire tablet's default.

We wrote the tests before we had the numbers. We knew the panel was 1200×2000 and we did not know what logical size Fire OS would report for it, because that depends on a density the device chooses. So rather than guess at one, we pinned both plausible ones (1333×800 and 1000×600) on the reasoning that whichever the device turned out to be, it was covered.

Both passed. We were quite pleased with ourselves.

Then we read the numbers off the device

Physical size:    1200x2000
Physical density: 213
Override density: 248

Amazon overrides the density. 248 is not a standard Android bucket (it is a number Amazon picked), so the device pixel ratio is 248/160 = 1.55, and the logical window is 2000/1.55 × 1200/1.55 = 1290×774 in landscape.

Neither guess was right. Not close, in the case of the one we thought was the tighter of the two.

And there is a second subtraction that matters more than the first. The app is not immersive, so the Fire's status and navigation bars come out of that height via SafeArea, leaving roughly 702 points. An iPad mini (the device we had been calling the tightest screen we support, and sizing everything against) has 744.

So the cheap tablet is now the constraint. Not the small iPad.

What the real numbers found in about ninety seconds

Swapping the guesses for the measurements turned one of the three tests red immediately: a 49-pixel overflow in portrait.

Two bugs, one behind the other, and the second is the interesting one.

The first was ordinary. The widget that decides whether the note of the day fits takes a minimumPage parameter (how far the day may be squeezed before the note is dropped), and then ignored it, reading the wide-layout constant instead. The stacked layout's larger floor had never once applied. Nobody noticed because on every iPad there was room to spare.

The second was the real one. That floor should never have been a constant.

How tall the page needs to be depends on how the NOW and NEXT labels wrap, and how they wrap depends on the width. A number pitched by eye against an 834-point iPad portrait is simply wrong at 774 points, where “Second set of pills” takes an extra line. It was about 50 short. The note stayed up, the page had nowhere to put it, and the display did the one thing it is not allowed to do.

So the floor is measured now. Three widgets gained a static that returns their worst-case height before layout (every line they are allowed assumed taken), and the layout composes its own floor from what is actually on it:

minimumPage: math.max(
  kMinimumStackedPageHeight,
  _FocusArea.heightFor(context, moment) +
      Spacing.md +
      (next == null ? 0 : NextCard.heightFor(context, next) + Spacing.md) +
      Spacing.md * 2,
),

Worst case on purpose, and the asymmetry is the point: under-estimating puts a striped overflow banner on the screen of someone who cannot be expected to understand it or report it, and over-estimating costs a note there was no room for anyway. Those are not comparable prices, so the estimate is allowed to be generous.

The schedule is deliberately not counted, because the shed order says the day's remaining rows give way before the note does. That ordering has been written down since part one; this is the first time it has had to be encoded rather than described.

The lesson is about the guess, not the bug

The honest version of this: our careful hedge (test both plausible densities, cover ourselves either way) was worse than useless. It was reassuring. Two tests passed, we wrote a paragraph about how principled it was to avoid guessing, and the actual device was somewhere neither of them looked.

Thirty seconds of adb shell wm size; wm density beat all of it. Which is the same finding as part three, where a simulator had happily rendered a display that a real iPad put to sleep after two minutes, and the same finding as most parts of this series: the device knows and you do not.

The tests now pin sunstone (Amazon's codename for the Fire Max 11, and a better name for a screen size than any of ours) in landscape, in landscape with the system bars out, and in portrait with the system bars out. All three at the longest menu and note the app permits.

The consequence that is more interesting than the constraint

No Play Billing means no Google in-app purchase. On most projects that is a serious problem: it forces a second payment integration, or it forces the Fire build to be a different product.

Here it costs nothing, and the reason is a decision made months earlier for an entirely unrelated motive.

Entitlement in this app is held against the household on the server, not as a receipt on a device. A carer or a daughter buys on their own phone; the display device unlocks by pairing with the household. That was chosen because an upfront paid app would have tied the person's own iPad to the payer's Apple ID: a genuinely bad outcome when the payer is an adult child two hundred miles away and the device belongs to their mother.

The side effect is that a Fire tablet never has to transact. Amazon's in-app purchasing never enters the picture, because the display device is a display. It reads.

This happens more often than the tidy version of software engineering admits: a decision made for one reason quietly pays for something else two quarters later. It is not foresight and it would be dishonest to present it as such. But it is an argument for making decisions on principle rather than on the shape of whichever SDK is in front of you, because principled decisions travel and expedient ones do not.

What is still awkward, and worth overcoming anyway

None of the following is a reason not to do it. They are the actual bill.

Fastlane does not do the Amazon Appstore. Part two of this series was entirely about buying one command: push a tag, and a signed build reaches both stores with no human in the loop. Amazon does not get to join that yet.

There is no first-party Fastlane action for the Amazon Appstore in the way deliver and supply exist for Apple and Google. Amazon does publish a real REST submission API, though, so a custom lane is genuinely possible: create an edit, upload the APK, GET-then-PUT the listing with its ETag, commit. One open edit per app, and no PATCH. But the first upload is manual, exactly as it was for Google Play, and for the same structural reason: none of these APIs will create an app's first release for you.

The next detail we simply got wrong, and it is worth showing the working. We had it written down that Amazon does not accept an Android App Bundle, planned the build around that, and discovered otherwise at the point of uploading. The console takes an AAB, and has done since 2021. The bundle that goes to Google Play is the one Amazon accepted, unchanged.

What is true is narrower, and easy to conflate with it: Amazon's App Submission API is APK only. So the constraint is real, but it lands on automation rather than on submission; the opposite way round from how we had planned it. Uploading by hand needs no new artefact at all.

The separate Fire lane we built for the wrong reason has kept its place for a better one. It produces a universal APK rather than the per-ABI splits, and a Fire Max 11 is arm64 where older Fire tablets are 32-bit, so one file runs on any of them. That is the only way to get a build onto Fire hardware without waiting in Amazon's queue, and it is what an automated lane will need if we ever write one.

So the tag still carries three things instead of two, but the third is a testing artefact now rather than the thing we submit. Both deploy workflows build the Amazon APK after the Play step and hang it off the release, which turns getting a build onto a Fire tablet into a download and a drag rather than somebody compiling a binary on a laptop: the thing every other channel has rules against.

That is the second wrong guess in this one article, after the screen dimensions, and the two failed the same way. Both were written down confidently, in our own documentation, on the strength of something read rather than something run. A note in a markdown file looks exactly as authoritative whether it came from a vendor's docs, a forum post, or an afternoon's assumption, which is an argument for recording where a constraint came from alongside the constraint itself.

Amazon re-signs the binary. Amazon does not distribute the APK you upload signed with your key; it re-signs it with theirs. That is fine here, because nothing in this app is pinned to a signing fingerprint. It would not be fine at all in a project that had wired up, say, application-restricted API keys against a SHA-1, and it is the kind of thing that is discovered at the worst possible moment if nobody wrote it down.

Sideloading breaks a rule we otherwise keep. Our standing rule across every app is that local development is simulator and emulator only: any build that reaches physical hardware goes through TestFlight or Play Internal Testing, so that every device build is reproducible, built from a tag, and gated behind the release audit.

Putting an APK on a Fire tablet from the command line breaks that rule. It was worth breaking once, for an experiment whose entire purpose was to find out whether the thing runs at all. It is not a habit, and Amazon's Live App Testing is the route back to the rule, which is another item on the same bill.

Fire OS 8 is Android 11, and that cuts both ways. The current Fire OS is built on Android 11 (API 30. Local notifications are something we are thinking about rather than committing to, and probably not for a first release), but they are worth checking against now, because if they do arrive they have to work here too.

The good news is that the pre-Android-13 world is easier: no runtime notification permission prompt, and none of the exact-alarm restrictions that arrived later. The trap is writing code that assumes the newer flow is there: a permission request that silently no-ops, or worse, a code path that treats its absence as a refusal and quietly schedules nothing. Core library desugaring has been enabled since the first commit because flutter_local_notifications requires it, which is one thing already paid for.

The pattern this is really an instance of

Part two's argument was that Android had to ship from day one even though this is an iPad-centred product, because a platform left until later accrues technical debt in silence (a Gradle plugin two versions behind, a desugaring flag never set, a dependency that quietly dropped support), and then costs a week at the exact moment somebody finally wants it.

Fire is the second instance of the same argument, one level further out. The build works today. The cost of keeping it working today is a table in a markdown file, four lines of shell, and two tests. The cost of rediscovering it in eight months, after a Firebase dependency has been added by someone acting entirely reasonably, is an afternoon of confusion followed by an architectural argument.

The general form: when something works by accident, either write down why or expect to lose it. That is what a constraint in a test is for. It is not there to catch you being stupid; it is there to catch you being sensible about something you had no way of knowing was load-bearing.

Still open

Assistive Access, still unverified at this point in the story. Apple's iPadOS mode for cognitive disability was the single largest unknown in the project, and the Fire work does not touch it at all. It has since been confirmed on real hardware, and part five covers what that took, but nothing in this part answers it.

Then we are thinking about local notifications, which could matter a great deal, though almost certainly not in a first release. Then a home screen widget. Then a backend. And now a third distribution channel behind all of them, with its own console, its own review, its own assets and its own bootstrap upload: on the same terms Google Play started on, and for a market that might well be the bigger one.

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 it.

iPad and Android are both open for testing now. Sign up at daysome.org/testers.html. It takes two minutes, and it is how we send an iPad tester their TestFlight invitation, tell an Android tester where to go, and let you know when the Fire tablet version opens. A Fire build follows once Amazon clears.

Plainly, so nobody is misled: Daysome is an app centred on 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.