Build

After a reboot, iOS would not let my app read its own data

The alarm fired but the voice mission did not open. The app was reading its own storage before the first unlock and trusting the empty answer it got back.

I was testing RYZA’s voice mission alarm on iOS. The idea is simple: the alarm rings, and you read a short sentence aloud to stop it. I restarted the phone, waited for the alarm, and got a strange result.

The system alarm still appeared. That part worked.

But the app could not reliably open the voice mission. In the worst version it looked as if the mission had vanished. In another version the app started from an empty state, which worried me more than a spinner would have, because an empty alarm list was not actually empty. It was temporary.

The important detail was the timing. This happened after a device restart and before the first unlock.

I did not have a normal crash to chase. I had two parts of the app disagreeing about reality.

Phone restarts AlarmKit still knows the alarm exists RYZA is launched for the alarm App storage is not readable yet Flutter sees no saved alarm data the two steps nothing accounted for
On a normal launch, reading the saved alarm list early is fine. After a reboot it is not.

The mistake: treating “not available” as “empty”

The app has a Flutter layer and an iOS native layer. Flutter owns most of the UI and the saved alarm definitions. AlarmKit, Apple’s alarm framework, owns the system-level schedule.

After a restart AlarmKit could still know an alarm existed. At the same time the app’s protected datafiles iOS keeps encrypted and unreadable until the device has been unlocked once after boot was not ready to read.

I had been thinking about this as a mission-recovery problem. How do I get the voice screen back?

That was half the problem. The more dangerous question was what happens if the app starts while its own alarm data is temporarily missing.

In the older flow, the answer was bad. The app could read the missing data as a real empty state and begin its normal migration and sync work. In plain terms, it made decisions using a blank notebook, because the notebook was still locked in a drawer.

A missing alarm record after reboot is not proof that the alarm does not exist.

Written out like that it sounds obvious. It was not obvious in the code, because the existing launch flow worked almost every other time.

I first looked in the wrong place

My instinct was to add more recovery logic around the voice mission itself.

That made sense on the surface. I already had recovery alarms, mission handoff data, and a path back to the challenge. So I added work around guard alarms: small follow-up alarms that bring the mission back if the first handoff does not finish cleanly.

It helped with some cases. It did not solve the reboot problem.

What it did do was hand me an extra failure mode. Someone could stop the alarm at the system level and a previously reserved recovery alarm would still ring a minute later. That is a terrible kind of bug. The alarm is technically reliable and it behaves like it does not believe you.

I changed the native side so that stopping or handing off a voice mission also tries to clear the related recovery guards. I kept that cleanup best-effort, because I did not want a failed cleanup call to block the main handoff.

Guard cleanup still was not the root fix. The root fix was refusing to boot the Flutter alarm domain until it was safe.

The small check that changed the launch path

I added a native check with three facts:

  • Is protected data available?
  • Does the saved alarm domain exist?
  • Does AlarmKit still have native alarms?

The first one is a single property, and if you have never looked at it, it is worth printing on your own launch path to see how often it comes back false:

UIApplication.shared.isProtectedDataAvailable
// and the notification that tells you it flipped:
UIApplication.protectedDataDidBecomeAvailableNotification

The app now blocks the normal Flutter launch when either of these is true:

  1. Protected data is unavailable.
  2. AlarmKit has an alarm, but the saved alarm domain cannot be read yet.

The second condition is the one that mattered. Checking only whether the phone had been unlocked was not enough, because there is still a brief window where the app should not trust what it reads.

The decision became intentionally boring:

Protected data unavailable?             → wait
Native alarm exists, saved data absent? → wait
Otherwise                               → launch normally

A fresh install is different. It has no saved alarm domain and no native alarms, so it can launch normally. I wanted that distinction to be explicit rather than buried inside migration code.

On the Flutter side I added a temporary screen for the protected-data state. It does not pretend the mission is loading. It says that unlocking the device will safely start the intention mission.

I chose that wording because “Loading…” would have been a lie. Nothing was loading. The app was waiting for permission to read the state it needed.

The screen checks again every second, and also when the app returns to the foreground. Once the data is available, the normal launch path runs.

I usually dislike screens whose only job is to wait. This one still felt better than guessing.

Why the native side is the source of truth here

The split between AlarmKit and Flutter was the real source of complexity.

AlarmKit can act while the full app is not ready. That is useful, and it is why an alarm survives a reboot at all.

It also means I cannot assume Flutter is the source of truth at every moment. During this narrow reboot window, the native alarm schedule is more trustworthy than Flutter’s local copy.

So the bootstrap check is native, rather than asking Flutter’s cached preferences whether an alarm exists. That detail was deliberate. The cached value was the thing I no longer trusted.

I changed the alarm snapshot behaviour for the same reason. If the native snapshot record is missing before the first unlock, the app no longer turns that absence into “this is a normal alarm without a voice challenge.” It waits for the saved definition instead.

That avoided a quiet downgrade, where a voice-mission alarm got treated as a plain alarm only because the supporting record had not become readable yet.

What I tested

Three cases, the ones I cared about most:

  • Protected data unavailable, even if alarm records appear to exist: block the launch.
  • AlarmKit has a scheduled alarm but the saved domain is unreadable: block the launch.
  • A normal existing alarm domain, or a genuinely fresh install: allow the launch.

The test is small. The value is that the rule now has a name and a boundary, so I can change the recovery flow later without casually reintroducing an early read of the alarm repository.

I kept the diagnostics around mission handoffs and guard cleanup too. I do not know whether every future iOS version will expose this restart window in the same way. I do know I will want evidence when it happens again.

The part I got wrong

I treated this as a screen-routing problem.

It was not. I thought I needed to get from the system alarm into the voice mission faster. The actual problem was that I was starting the app before the data that defines the mission was safe to use.

More recovery actions would have made the symptom rarer. They would not have made the state trustworthy.

The fix ended up being less dramatic than the bug: wait, then launch. For an alarm app, waiting is not usually the feature I want to build. After a reboot, a wrong alarm state is worse than a short honest pause.

If you are wiring AlarmKit into an app that owns its own dismissal flow, the reboot window is not the only place the two layers disagree. The stop button is the other one.