Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ Flip the home screen to **Demo**, or run `npx expo start --web`, to explore the
passes. The full runbook — EAS cloud builds, the Simulator profile, and signing notes — is in
**[docs/guides/ios-dev-build.md](docs/guides/ios-dev-build.md)**.

> 👨‍👩‍👧 **Handing the iPad to kids?** Lock it to just this app with iOS **Guided Access**. There's
> one Bluetooth quirk to set up first (otherwise the speedometer can go dead and iOS may refuse to
> start Guided Access) — the one-minute fix is in the
> **[Guided Access / kiosk guide](docs/guides/ios-guided-access.md)**.

## Protocol Documentation

We've fully reverse-engineered the BLE protocol! See [PROTOCOL.md](PROTOCOL.md) for details.
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PORTAL_NAME } from '@redlineid/protocol';
import { RecentPasses } from '@/components/RecentPasses';
import { Speedometer } from '@/components/gauge/Speedometer';
import { StatusPill } from '@/components/StatusPill';
import { BleStatusBanner } from '@/components/BleStatusBanner';
import { createMockPortal } from '@/mock/mockPortal';
import { createBlePortal, isBleAvailable } from '@/ble/blePortal';
import type { BlePhase } from '@/ble/types';
Expand Down Expand Up @@ -187,6 +188,8 @@ export default function SpeedometerScreen() {
</View>
)}

{useBle && <BleStatusBanner phase={blePhase} />}

{useBle && blePhase === 'locked' && (
<View style={styles.lockedBanner}>
<Text style={styles.lockedTitle}>Portal firmware unsupported</Text>
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import * as Device from 'expo-device';

import { prewarmBle } from '@/ble/blePortal';
import { initPersistence } from '@/store/persistence/initPersistence';

export default function RootLayout() {
useEffect(() => {
initPersistence();
// Trigger the iOS Bluetooth permission / power prompts now, on the home
// screen, rather than mid-race inside Guided Access (where iOS hides them and
// a pending prompt can block Guided Access from starting). Device-only: the
// Simulator has no radio and web has no native module. See
// docs/guides/ios-guided-access.md.
if (Device.isDevice) prewarmBle();
}, []);

return (
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/ble/blePortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ export function isBleAvailable(): boolean {
return Platform.OS === "ios" || Platform.OS === "android";
}

/**
* Warm up CoreBluetooth at app launch so iOS surfaces the one-time Bluetooth
* **permission** prompt (and the "Bluetooth is off" power alert) up front, on the
* normal home screen — *not* mid-session inside iOS **Guided Access**, where iOS
* suppresses those system dialogs and a still-pending one can even block Guided
* Access from starting. Simply constructing the shared `BleManager` is enough to
* trigger CoreBluetooth's authorization flow; we deliberately do **not** start a
* scan here, so it never fabricates a connection or shows activity. No-op on web
* (the native module is never required) and harmless on the iOS Simulator.
*
* See `docs/guides/ios-guided-access.md`.
*/
export function prewarmBle(): void {
if (!isBleAvailable()) return;
try {
getManager();
} catch {
/* best-effort — on failure the prompts simply defer to the first Connect. */
}
}

/**
* Create a BLE transport. Returns the shared {@link PortalTransport} contract
* (`start`/`stop`) plus nothing else — all event delivery happens through the
Expand Down
52 changes: 52 additions & 0 deletions apps/mobile/src/ble/bleStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";

import { bleStatusBanner } from "./bleStatus";
import type { BlePhase } from "./types";

describe("bleStatusBanner", () => {
it("flags Bluetooth-off as an actionable amber banner", () => {
const banner = bleStatusBanner("poweredOff");
expect(banner?.tone).toBe("warn");
expect(banner?.title).toMatch(/bluetooth is off/i);
expect(banner?.openSettings).toBeFalsy();
// The Guided Access gotcha is the whole point of surfacing this.
expect(banner?.body).toMatch(/guided access/i);
});

it("offers Open Settings when permission is denied", () => {
const banner = bleStatusBanner("unauthorized");
expect(banner?.tone).toBe("danger");
expect(banner?.openSettings).toBe(true);
expect(banner?.body).toMatch(/permission/i);
expect(banner?.body).toMatch(/guided access/i);
});

it("explains an unsupported radio (e.g. the Simulator)", () => {
const banner = bleStatusBanner("unsupported");
expect(banner?.tone).toBe("danger");
expect(banner?.title).toMatch(/no bluetooth/i);
});

it("offers a retry hint on a generic error", () => {
const banner = bleStatusBanner("error");
expect(banner?.tone).toBe("warn");
expect(banner?.body).toMatch(/try again/i);
});

it("stays silent for the healthy / in-progress phases", () => {
const quiet: (BlePhase | null)[] = [
null,
"idle",
"scanning",
"connecting",
"discovering",
"authenticating",
"connected",
"reconnecting",
"locked", // the home screen renders its own dedicated "locked" banner
];
for (const phase of quiet) {
expect(bleStatusBanner(phase)).toBeNull();
}
});
});
76 changes: 76 additions & 0 deletions apps/mobile/src/ble/bleStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Map a BLE adapter {@link BlePhase} to a plain-language banner for the home
* speedometer screen.
*
* The Live screen has a raw event log that explains *why* the radio isn't
* connected, but the home gauge previously showed **nothing** when Bluetooth was
* off or unauthorized — the needle just sat dead. That is especially painful
* under iOS **Guided Access** (the kids'-iPad / kiosk lock): iOS suppresses the
* system Bluetooth prompt during a Guided Access session, so a portal that can't
* connect gives zero on-screen feedback. Surfacing these states on the gauge is
* the fix. See `docs/guides/ios-guided-access.md`.
*
* This module is intentionally **pure** (no React Native imports) so it can be
* unit-tested without a renderer; {@link BleStatusBanner} renders the result.
*/
import type { BlePhase } from "./types";

export type BleBannerTone = "warn" | "danger";

export interface BleBanner {
/** Visual severity — amber for "easily fixed", red for "needs attention". */
readonly tone: BleBannerTone;
readonly title: string;
readonly body: string;
/** When true, the UI should offer an "Open Settings" affordance. */
readonly openSettings?: boolean;
}

/** Shared tip: the BLE/Guided-Access gotcha, appended where it's actionable. */
const GUIDED_ACCESS_TIP =
"Using Guided Access? Turn Bluetooth on and connect the portal once before you start it — " +
"iOS hides the Bluetooth prompt during a Guided Access session.";

/**
* Return the banner for an adapter-problem phase, or `null` when nothing is
* wrong (idle, scanning, connected, the normal happy path, or a null phase).
* Only the states the user can act on surface a banner.
*/
export function bleStatusBanner(phase: BlePhase | null): BleBanner | null {
switch (phase) {
case "poweredOff":
return {
tone: "warn",
title: "Bluetooth is off",
body:
"Turn Bluetooth on in Control Center or Settings, then tap “Connect portal”. " +
GUIDED_ACCESS_TIP,
};
case "unauthorized":
return {
tone: "danger",
title: "Allow Bluetooth",
body:
"Redline ID needs Bluetooth permission to reach your portal. Open Settings and turn " +
"on Bluetooth for Redline ID. " +
GUIDED_ACCESS_TIP,
openSettings: true,
};
case "unsupported":
return {
tone: "danger",
title: "No Bluetooth radio here",
body:
"This device has no usable Bluetooth radio (for example the iOS Simulator). Run on a " +
"physical iPhone, or switch to Demo to explore the app.",
};
case "error":
return {
tone: "warn",
title: "Bluetooth hiccup",
body: "Something interrupted the Bluetooth connection. Tap “Connect portal” to try again.",
};
default:
return null;
}
}
81 changes: 81 additions & 0 deletions apps/mobile/src/components/BleStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* BleStatusBanner — surfaces a BLE adapter problem (Bluetooth off, permission
* denied, no radio, transient error) on the home speedometer screen.
*
* Without this, a portal that can't connect leaves the gauge silently dead —
* worst of all under iOS Guided Access, where iOS hides the system Bluetooth
* prompt, so the user sees no explanation at all. The copy + the "Open Settings"
* shortcut give them a way out. The phase→copy mapping lives in the pure
* {@link bleStatusBanner} so it can be unit-tested without a renderer.
*/
import { Linking, Pressable, StyleSheet, Text, View } from "react-native";

import { bleStatusBanner } from "@/ble/bleStatus";
import type { BlePhase } from "@/ble/types";
import { colors, fontSize, fontWeight, radius, spacing } from "@/theme/tokens";

export function BleStatusBanner({ phase }: { phase: BlePhase | null }) {
const banner = bleStatusBanner(phase);
if (!banner) return null;

const accent = banner.tone === "danger" ? colors.danger : colors.warn;

return (
<View style={[styles.banner, { borderColor: accent }]}>
<Text style={styles.title}>{banner.title}</Text>
<Text style={styles.body}>{banner.body}</Text>
{banner.openSettings && (
<Pressable
onPress={() => {
Linking.openSettings().catch(() => {});
}}
style={({ pressed }) => [
styles.button,
{ borderColor: accent },
pressed && styles.buttonPressed,
]}
>
<Text style={[styles.buttonText, { color: accent }]}>Open Settings</Text>
</Pressable>
)}
</View>
);
}

const styles = StyleSheet.create({
banner: {
width: "100%",
maxWidth: 420,
backgroundColor: colors.surface,
borderWidth: 1,
borderRadius: radius.md,
padding: spacing(4),
gap: spacing(2),
},
title: {
color: colors.textPrimary,
fontSize: fontSize.md,
fontWeight: fontWeight.bold,
},
body: {
color: colors.textSecondary,
fontSize: fontSize.sm,
lineHeight: 19,
},
button: {
marginTop: spacing(1),
alignSelf: "flex-start",
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderRadius: radius.md,
paddingVertical: spacing(2.5),
paddingHorizontal: spacing(4),
},
buttonText: {
fontSize: fontSize.sm,
fontWeight: fontWeight.bold,
},
buttonPressed: {
opacity: 0.7,
},
});
94 changes: 94 additions & 0 deletions docs/guides/ios-guided-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Using Redline ID in Guided Access (kids' iPad / kiosk lock)

iOS **Guided Access** locks the iPad (or iPhone) into a single app until you enter a
passcode — perfect for handing the device to a kid for a race session without them
wandering off into other apps. Redline ID works great this way, with **one Bluetooth
gotcha** that can make the speedometer look dead and even lock you out of Guided Access
itself. This guide explains why, and the one-minute setup that avoids it.

> **TL;DR.** Turn Bluetooth **on** and tap **Connect portal** once (accepting the Bluetooth
> permission prompt) **before** you triple-click into Guided Access. iOS hides Bluetooth
> prompts *during* a Guided Access session, so anything that needs one has to happen first.

---

## Why Bluetooth and Guided Access can fight

Redline ID talks to the Race Portal over Bluetooth (CoreBluetooth). iOS guards Bluetooth
with **system dialogs** — the one-time "Allow Redline ID to use Bluetooth?" permission
prompt, and the "Turn On Bluetooth" alert when the radio is off. Two iOS behaviors collide
during Guided Access:

1. **iOS suppresses system dialogs while a Guided Access session is active.** If the first
Bluetooth prompt (or the "Bluetooth is off" alert) would have appeared mid-session, it
never shows — so the portal silently never connects and the **speedometer registers
nothing**.
2. **A still-pending system dialog blocks Guided Access from starting.** If a Bluetooth
alert is waiting to be answered, triple-clicking to start Guided Access fails with an
iOS error ("Guided Access could not be started" / it just won't engage).

That's the whole bug in a nutshell: a dead gauge under the lock, and then being unable to
re-enter Guided Access afterward.

**What the app already does to help:**

- It **warms up Bluetooth at launch**, on the normal home screen, so the permission prompt
(and any "Bluetooth is off" alert) appears *before* you ever start a session — not in the
middle of one.
- The home **speedometer screen shows a banner** when Bluetooth is off or permission is
denied, with an **Open Settings** shortcut, so a stalled gauge always explains itself.

The clean fix is still to get Bluetooth sorted out **before** locking the device down.

---

## One-time setup (before you ever start Guided Access)

1. **Turn Bluetooth on** — Control Center, or Settings → Bluetooth.
2. **Open Redline ID** and, on first launch, tap **Allow** on the Bluetooth permission
prompt.
3. **Tap "Connect portal"**, power on the portal, and roll a car through the gate — confirm
the needle moves. This proves permission is granted and the radio works.
4. **Enable Guided Access:** Settings → Accessibility → **Guided Access** → on, then set a
**passcode** (Guided Access → Passcode Settings).

You only do this once. After permission is granted, iOS won't need to prompt again.

---

## Starting a locked race session

1. With Redline ID open **and connected**, **triple-click** the side button (or Home button
on older devices).
2. In the options panel you can leave **Motion** and **Touch** **on** — neither affects
Bluetooth. Add a time limit if you like.
3. Tap **Start**.

Guided Access also keeps the screen awake, so the gauge stays live through a whole session.

---

## Troubleshooting

**The speedometer stops registering passes during a session**

- Look for the on-screen banner ("Bluetooth is off" or "Allow Bluetooth") and follow it.
- End Guided Access (triple-click → enter passcode), make sure **Bluetooth is on**,
reconnect on the home screen, then start Guided Access again.

**iOS won't start Guided Access ("Guided Access could not be started")**

- A pending system dialog is usually the cause. Make sure **Bluetooth is on** (so there's no
"Turn On Bluetooth" alert waiting) and that you've already granted the Bluetooth
permission once (see setup above).
- **Force-quit and reopen** Redline ID, confirm it connects, then triple-click to start.
- If it's still stuck, **restart the device** to clear the lingering dialog state.

**Reminder:** none of the Guided Access hardware toggles (Motion, Touch, volume, …) disable
Bluetooth. The connection keeps streaming as long as the app is in the foreground — which
Guided Access guarantees.

---

See also: [Running on your iPhone (dev build)](ios-dev-build.md) ·
[TestFlight & distribution](ios-testflight.md).
4 changes: 4 additions & 0 deletions docs/guides/ios-testflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ family and friends over **TestFlight**, then (optionally) the App Store. It pick
> dev-build guide) is the only way onto a physical iPhone, and it only covers *your* device,
> not other testers'. Everything below assumes you've decided the $99 is worth it.

> 👨‍👩‍👧 **Putting it on a kid's iPad?** Once a tester has the app, lock the device to it with iOS
> Guided Access — see the **[Guided Access / kiosk guide](ios-guided-access.md)** for the Bluetooth
> setup that keeps the speedometer live under the lock.

---

## The distribution ladder
Expand Down
Loading