Previously, in Part 5, the app learned to tap, buzz and post to the tray — but only while running, because the hub connection and the nudge logic both live inside the process. Patients close apps. This part reaches a phone whose app is not running at all, through Firebase Cloud Messaging, and learns the hard way that "closed" means two different things to Android.

The prompt: a table, a flag and a silence

Verbatim from the commit in the repo (tag pocket-06):

"Reach the phone when the app is closed. Server: a DeviceRegistration
table (token unique — one phone, one visit, the token MOVES when the same
phone looks at a later appointment), a POST /visits/{code}/device endpoint,
and an IPushSender with two implementations: Firebase Admin SDK behind a
config path to the service-account file, and a NullPushSender that logs —
so the app, the tests and CI run without any key. CallNextAsync pushes
'It's your turn' to whoever was called and 'You're next' to the new head of
the queue, once. App: a fifth capability, IPushRegistration — Android asks
Firebase for its token, every other host answers null honestly. The Visit
screen registers the device on load, and the Firebase service stays silent
in the foreground so Parts 4 and 5 don't get doubled. Prove it with the app
not running."

Three things here are decisions rather than features. "The token MOVES" fixes the data model before anyone draws it: a push token identifies a phone, not a visit, so a phone looking at a later appointment re-points its token rather than growing a second row. "A NullPushSender that logs" means the feature is switched off by not having a file, so tests and CI never need a key. And "stays silent in the foreground" is the prompt protecting Parts 4 and 5 from being doubled by their own successor.

Model pick: Opus, high effort — foreground, background and stopped-state semantics are exactly the kind of thing that reads fine and behaves wrong, and "the flag is a class" is an architecture call. Sonnet took the bindings plumbing: the packages, the manifest entries, the token call.

Two files that never enter the repo

Push needs two credentials, and neither is code. google-services.json is the Firebase project's config for the Android package — com.cliniclive.pocket, registered in the project cliniclive-pocket on the free plan, analytics off. It goes in Platforms/Android, and the build picks it up if present: the GoogleServicesJson item is conditional, so a fresh clone still compiles. The service-account JSON is the server's credential; it lives anywhere outside the repo, and dotnet user-secrets holds only the path to it. Both were downloaded by hand from the Firebase console; the AI never saw either — the assistant never holds a secret. docs/pocket.md walks through getting your own.

The first catch of the part arrived with the packages, and every MAUI project that adds Firebase meets a version of it: the Android build failed in D8 with "Type androidx.fragment.app.FragmentKt is defined multiple times". Firebase Messaging pulls fragment-ktx 1.8.8, MAUI 10 ships fragment 1.9.0, and 1.9 moved that class into the base package. Pinning Xamarin.AndroidX.Fragment.Ktx to 1.9.0 makes them agree — the AndroidX version dance, the toll at the door.

The feature flag is a class

The server's push sender is an interface with two implementations; Program.cs registers the real one only when Push:ServiceAccountPath points at a file that exists, and the null one otherwise:

public interface IPushSender
{
    Task SendAsync(IReadOnlyList<string> tokens, string title, string body, IReadOnlyDictionary<string, string> data);
}

/// The feature flag in class form: when Push:ServiceAccountPath isn't configured
/// (tests, a fresh clone, CI), pushes are logged and nothing leaves the building.
public sealed class NullPushSender(ILogger<NullPushSender> logger) : IPushSender
{
    public Task SendAsync(IReadOnlyList<string> tokens, string title, string body, IReadOnlyDictionary<string, string> data)
    {
        logger.LogInformation("Push not configured — would have sent \"{Title}\" to {Count} device(s)", title, tokens.Count);
        return Task.CompletedTask;
    }
}

No if (pushEnabled) scattered through the queue code: the queue always calls SendAsync, and in a test it lands in a fake. The real one is the Firebase Admin SDK's multicast send, carrying both a notification (so Android's tray shows it when the app is closed) and data (so the app can act on it when open), aimed at the channel Part 5 created:

var message = new MulticastMessage
{
    Tokens = tokens,
    Notification = new Notification { Title = title, Body = body },
    Data = new Dictionary<string, string>(data),
    Android = new AndroidConfig
    {
        Priority = Priority.High,
        Notification = new AndroidNotification { ChannelId = "queue" },
    },
};

var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(message);
_logger.LogInformation("Push \"{Title}\": {Success} delivered, {Failure} failed", title, response.SuccessCount, response.FailureCount);

Remember that log line. It's about to tell the truth and be misread.

One phone, one visit

A DeviceRegistration row is an appointment id, a platform name and a token, with a unique index on the token and a cascade from the appointment. RegisterDeviceAsync is an upsert by token: a token the clinic has never seen is added, one it has seen is re-pointed at the new appointment, and an unknown code is refused. The API door from Part 3 grows one endpoint, POST /api/pocket/visits/{code}/device, answering 204 or 404. And CallNextAsync — the method the reception desk has called since season one — now ends by reaching two phones: whoever was just called, and whoever is now first in line, the latter exactly once thanks to a new NextNotifiedAt stamp:

// Season 3: reach the phones that aren't on this screen. The person just
// called, and whoever is now first in line (once — hence NextNotifiedAt).
await PushToAsync(db, next.AppointmentId, "It's your turn",
    $"{FirstNameOf(next.Appointment.Patient.FullName)}, please go through now.");

var upNext = /* … the first waiting entry: slot time, then check-in time … */;

if (upNext is not null && upNext.NextNotifiedAt is null)
{
    await PushToAsync(db, upNext.AppointmentId, "You're next", "Get ready — you're first in the queue.");
    upNext.NextNotifiedAt = DateTime.UtcNow;
    await db.SaveChangesAsync();
}

Same words as Part 5's local nudges, on purpose: a patient should not be able to tell whether the phone worked it out itself or the clinic told it.

The fifth capability answers null, honestly

/// Capability #5: "how does the clinic reach this device when the app is closed?"
/// Android answers with a Firebase token. Windows, the web and (here) iOS answer
/// null — and the shared UI simply doesn't promise what it can't deliver.
public interface IPushRegistration
{
    /// The platform name the server stores ("android"), or null when push isn't available.
    string? Platform { get; }

    Task<string?> GetTokenAsync();
}

Android's body is one call — FirebaseMessaging.Instance.GetToken() — wrapped so that no Play services, no network or no config file means null and nothing else breaks. Windows returns null outright (WNS needs a Store identity, which is not this series); so does the browser host. The Visit screen registers on every load of a checked-in visit — the server's upsert makes that free — and shows "Push is on — you can close the app and we'll still reach you" only when the token and the clinic's 204 both actually came back.

The My visit screen for Ava, Checked in, Today at 16:15: a very large petrol 1 under 'Your place in the queue', the line 'You're the only one waiting. Now serving Sofia R.', and beneath it a smaller line, 'Push is on — you can close the app and we'll still reach you.'
"Push is on" appears only once the token round-trip to Google has completed — which is why the first attempt at this shot didn't have the line: the first token fetch takes a few seconds and the harness photographed too early. Not a bug; re-shot.

Two rules that surprise everyone

The Firebase service is where messages arrive on the phone, and its header comment is the part's second lesson:

/// Where Firebase delivers to the app. Two rules that surprise everyone the first time:
///
/// 1. When the app is in the BACKGROUND, a message with a "notification" payload is
///    shown by Android itself (on the channel the server named) and this method is
///    NOT called. It's only called for data-only messages, or in the foreground.
/// 2. When the app is in the FOREGROUND, Android shows nothing — it's on us. But
///    in the foreground the live queue (Part 4) and the nudges (Part 5) already
///    cover it, so we deliberately stay quiet here rather than notify twice.
public sealed class PocketFirebaseMessagingService : FirebaseMessagingService
{
    public override void OnMessageReceived(RemoteMessage message)
    {
        if (AppLifecycle.Instance.IsInForeground)
        {
            return;   // rule 2: the open screen handles it
        }
        /* … data-only message in the background: show it ourselves … */
    }
}

Rule 1 has consequences in the manifest and at startup: Android shows the notification on our channel with our icon only if two meta-data entries say so, and the channel must already exist when a push arrives while the app is closed — so MainActivity.OnCreate calls Part 5's EnsureChannel() on every launch. Rule 2 has a subtler one: IsInForeground used to default to true. A process Firebase starts just to deliver a message has no window at all — it would have believed it was in front and swallowed the message as "the screen handles it". It now defaults to false until a window actually resumes. And on resume, the Visit page skips one nudge, because the push already said it.

What "closed" means: force-stop versus swipe

Now the centerpiece. "Prove it with the app not running" was first tested the obvious way:

adb shell am force-stop com.cliniclive.pocket

Reception called next. The server logged the line from earlier — one delivered, none failed — and the phone showed nothing. No tray entry, no wake-up. For a while that looked like a Firebase problem, a channel problem, a manifest problem. It was none of them. force-stop is the "Force stop" button on the app's settings page, and it puts the app into Android's stopped state, in which the system deliberately delivers nothing to it — Firebase messages included — until the user launches it again. A user swiping the app out of Recents does not do that. The harness had modelled "closed" as something no patient ever does.

adb shell am kill com.cliniclive.pocket

Home first, then am kill — which ends the process the way memory pressure or a swipe would — and a process listing confirmed zero processes for the package. Reception called next again. The server logged:

Push "It's your turn": 1 delivered, 0 failed

A new process id appeared for the package — Firebase had woken the app — and the tray showed "It's your turn" with the app not running. That's the proof the prompt asked for. Two lessons, both bigger than push: "delivered to Firebase" is not "shown on the phone", and a test harness can quietly redefine a word like "closed" out from under you.

The Android notification shade at 12:57 with a ClinicLive Pocket group of three: 'It's your turn: Ava, please go through now.' on top, then two identical lines reading 'You're next: Get ready — you're first in the qu…'. Below, in a Silent section, the emulator's 'Serial console enabled' notice, and Manage and Clear all buttons.
The evidence, warts included. The top line arrived from Firebase with no app process alive. The two identical "You're next" lines beneath it are local nudges from Part 5 — and there should be one. That duplicate is the next catch, photographed before it was fixed.

Two of the same

Ava checked in straight into position 1, which correctly earns a "You're next". Twice is a bug. The check-in button reloads the visit, and the hub's QueueChanged — fired by that very check-in — reloads it again within milliseconds. Both loads captured the pre-check-in visit as "before", both saw position 1 as "after", both nudged. Two correct reloads, one race. LoadAsync now waits on a SemaphoreSlim(1, 1) — one load at a time, in order — and the comment above it keeps the story.

The test that needs no key

Because the flag is a class, the test hands the queue a FakePushSender and asserts on what it collected — against the real Postgres fixture from season one, draining whoever earlier tests left waiting until the patient under test is the one being served:

Assert.Contains(push.Sent, s => s.Title == "It's your turn" && s.Tokens.Contains("token-for-M") && s.Body.StartsWith("Test,"));
Assert.Contains(push.Sent, s => s.Title == "You're next" && s.Tokens.Contains("token-for-N"));

// Same phone, new appointment: the token MOVES (unique index), it isn't duplicated.
Assert.True(await service.RegisterDeviceAsync(second.Appointment.ConfirmationCode, "android", "token-for-M"));
await using var db = await fx.DbFactory.CreateDbContextAsync();
Assert.Equal(1, await db.DeviceRegistrations.CountAsync(d => d.Token == "token-for-M"));

The token-moves rule was also watched in the real database during the demo: the emulator's token re-pointed from one appointment row to a later one, no second row. Fifteen tests green, and CI runs them all with no Firebase file anywhere.

What the AI got wrong: five catches, and the biggest wasn't in code. The D8 failure was the AndroidX version dance — pin Fragment.Ktx to what MAUI ships. Then the catch: it tested "closed" with force-stop, read "1 delivered, 0 failed", and hunted the bug in Firebase, the channel and the manifest before questioning the harness. "Delivered" was true; "closed" was wrong. The first registration shot was simply early. The duplicate "You're next" was a race between two correct reloads — serialize them, don't pick one. And IsInForeground defaulting to true was a plausible default that a window-less process started by Firebase would have believed. The lesson: when the log says success and the screen says nothing, question the definition you tested against before the code you tested.

The meter: ≈ $4.20 on season three's running meter. The force-stop detour cost the most: the tokens went on diagnosing a delivery that had already succeeded.

Checkpoint: git checkout pocket-06 in the repo: run the clinic with no Push:ServiceAccountPath and watch NullPushSender log what it would have sent on every "Call next". With your own Firebase project (docs/pocket.md explains the two files), check in on the emulator, wait for "Push is on", press Home, adb shell am kill com.cliniclive.pocket, call next from reception, and watch the tray. 15 tests green, including the push test with the fake sender. Windows toasts: still open.

The app can now reach you when it's closed. The Find-us tab, meanwhile, has shown a clinic card since Part 3 and done nothing with it. Next: where the clinic is, how far away you are, one tap for directions — and two questions whose honest answer may be "no" — Part 7: Find the Clinic: Geolocation, a Testable Haversine and One-Tap Directions.